Compare commits

...
23 Commits
Author SHA1 Message Date
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
76 changed files with 2762 additions and 407 deletions
+6
View File
@@ -20,3 +20,9 @@
# 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. CI's
# pulls exclude the directory; nothing built or tested reads it.
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
+1 -1
View File
@@ -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
+3 -3
View File
@@ -96,7 +96,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/**"
ls -lR models/ ls -lR models/
- name: Cache cargo - name: Cache cargo
@@ -213,7 +213,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/**"
ls -lR models/ ls -lR models/
- name: Cache cargo - name: Cache cargo
@@ -406,7 +406,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/**"
ls -l models/face models/scene ls -l models/face models/scene
- name: Cache cargo - name: Cache cargo
+108
View File
@@ -0,0 +1,108 @@
# 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/requirements.md`; this file is
about habits, not features.
## Catalog reads: work is proportional to what changed, never to library size
`docs/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.
**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.
## 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.
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.
Generated
+25 -25
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-plat", "dr-plat",
@@ -1234,7 +1234,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-plat", "dr-plat",
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-bench" name = "dr-bench"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1425,7 +1425,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-face", "dr-face",
"dr-plat", "dr-plat",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1454,7 +1454,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1473,7 +1473,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1495,7 +1495,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1513,7 +1513,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"libloading", "libloading",
"log", "log",
@@ -1527,7 +1527,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1539,7 +1539,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1547,7 +1547,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1561,7 +1561,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1570,7 +1570,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1609,7 +1609,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1623,7 +1623,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1635,7 +1635,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1657,7 +1657,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1669,7 +1669,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1678,7 +1678,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1720,7 +1720,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -7021,7 +7021,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.13.1" version = "0.13.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
+1 -1
View File
@@ -29,7 +29,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.13.1" version = "0.13.2"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
+109 -59
View File
@@ -1,84 +1,134 @@
# 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. The grid is virtualised, ordered by capture
time with a timeline beside it, and filtered by rating, flag, keyword,
person and whether the file is here. Ratings, keywords, collections and a
trash that survives a crash mid-operation. Card ingest. Bursts fold. Face
detection and identity, with the index syncing between devices.
Desktop: **Developing.** Fifteen 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 and straighten, spot repair, and local adjustments over
masks the model draws — click a subject or a category, then paint, subtract
a gradient, grow or shrink the edge. Focus peaking and a raw histogram for
judging what is recoverable. Named presets; 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 the merge as the first step in its history.
[![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
**Export.** JPEG, PNG, AVIF, 8- and 16-bit TIFF, with resize, output
sharpening, a naming template and a colour space — to a folder here or back
into the library.
**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 desktop 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/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; choosing a library does not yet work in the sandbox |
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.13.2**, sixteen tagged releases in. 184 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/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/requirements.md)), compare and
texture that Slint composites directly, which is what survey culling, AI denoise, tiled and progressive rendering, HDR merge and
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96% focus stacking, most of the Android platform integration beyond running,
of frame time at 4K, and and the Flatpak's library chooser. 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/outstanding.md) is the list, with the reasoning for
each.
```bash **The one deliberate compromise worth knowing about before reading
cargo run -p dr-gpu --example bench --features readback anything else:** the Android develop view reads its 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 — [technical-debt.md TD-1](docs/technical-debt.md)
has the measurements and the three things any one of which would remove it.
still reproduces that measurement. **The one exception is the Android develop ## Documentation
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 For someone using it:
grouping, AI denoise, tiled and progressive rendering, and most of the Android
platform integration beyond running. The performance targets in §4.1 are | | |
unverified rather than unmet — the per-commit benchmark suite §8 requires does |---|---|
not exist, so nothing fails a build on a regression. | [manual](docs/manual/README.md) | Every feature, pictured |
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning. | [gestures.md](docs/gestures.md) | How it is driven — generated from the code, so it cannot describe a gesture that does not exist |
For someone changing it:
| | |
|---|---|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
| [requirements.md](docs/requirements.md) | What the software must do — the numbered register, and the decisions |
| [architecture.md](docs/architecture.md) | How it is built — crates, the GPU pipeline, the 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 |
Designs, one per subsystem:
[segmentation](docs/segmentation.md) and [mask editing](docs/mask-editing.md) ·
[spot removal](docs/spot-removal.md) · [panorama](docs/panorama.md) ·
[faces](docs/faces.md) · [inference](docs/inference.md) ·
[storage and sync](docs/storage.md) · [catalog](docs/catalog.md) ·
[display and extension](docs/display-and-extension.md) ·
[navigation](docs/ui-navigation.md) · [distribution](docs/distribution.md) ·
[windows](docs/windows.md) · [benchmarks](docs/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).
+119 -8
View File
@@ -838,6 +838,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 +926,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 +1102,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
+46 -1
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 = 19;
/// Apply migrations up to [`SCHEMA_VERSION`]. /// Apply migrations up to [`SCHEMA_VERSION`].
/// ///
@@ -181,6 +181,13 @@ 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()?;
}
Ok(from) Ok(from)
} }
@@ -766,6 +773,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
+20
View File
@@ -304,6 +304,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());
+86 -14
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/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 {
@@ -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,25 @@ 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) -> 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.
return MirroredContext {
width,
height,
ring: 0,
rgb: rgb.to_vec(),
hole: known.iter().map(|&k| !k).collect(),
};
}
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 +561,7 @@ impl MirroredContext {
MirroredContext { MirroredContext {
width: pw, width: pw,
height: ph, height: ph,
ring: RING,
rgb: canvas, rgb: canvas,
hole, hole,
} }
@@ -634,7 +662,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 +679,42 @@ 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] #[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 +730,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();
+30 -3
View File
@@ -47,6 +47,20 @@ pub struct NextcloudBackend {
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs. /// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
dav_base: String, dav_base: String,
caps: Capabilities, caps: Capabilities,
/// Collections this backend has seen exist, so [`create_dir`] asks the
/// server about each one once.
///
/// Every `MOVE` guarantees its destination's parent, and did so with a
/// `MKCOL` for each ancestor down from the account root — for a trash
/// folder three levels deep that was three round trips of `405 Method
/// Not Allowed` before the one request that moved anything, on every
/// image of a batch. A backend lives for one job (`dr_ui::remote::
/// connect` builds one per worker), so a folder deleted by another
/// client mid-job is the one case this can get wrong, and it is reported
/// as the `409` the `MOVE` then earns rather than hidden.
///
/// [`create_dir`]: RemoteBackend::create_dir
known_dirs: std::sync::Mutex<std::collections::HashSet<String>>,
} }
impl NextcloudBackend { impl NextcloudBackend {
@@ -63,6 +77,7 @@ impl NextcloudBackend {
login: creds.login_name.clone(), login: creds.login_name.clone(),
password: creds.app_password.clone(), password: creds.app_password.clone(),
dav_base, dav_base,
known_dirs: Default::default(),
caps: Capabilities { caps: Capabilities {
// The property that makes a no-op sync one request (ARCH §8.1). // The property that makes a no-op sync one request (ARCH §8.1).
change_detection: ChangeDetection::PropagatingEtags, change_detection: ChangeDetection::PropagatingEtags,
@@ -567,6 +582,16 @@ impl RemoteBackend for NextcloudBackend {
chain.reverse(); chain.reverse();
for dir in chain { for dir in chain {
// Asked once per backend — see `known_dirs`. The lock is held
// across no await: it is taken to look, and again to record.
let known = self
.known_dirs
.lock()
.map(|k| k.contains(dir.as_str()))
.unwrap_or(false);
if known {
continue;
}
let url = self.url_for(&dir); let url = self.url_for(&dir);
let resp = self let resp = self
.client .client
@@ -581,11 +606,13 @@ impl RemoteBackend for NextcloudBackend {
// 405 is "already a collection here", which is exactly what the // 405 is "already a collection here", which is exactly what the
// caller wanted. Anything else is reported. // caller wanted. Anything else is reported.
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED { if resp.status() != reqwest::StatusCode::METHOD_NOT_ALLOWED {
continue;
}
map_status(resp.status(), &url)?; map_status(resp.status(), &url)?;
} }
if let Ok(mut k) = self.known_dirs.lock() {
k.insert(dir.as_str().to_string());
}
}
Ok(()) Ok(())
} }
+14
View File
@@ -127,6 +127,19 @@ pub struct Account {
#[serde(default)] #[serde(default)]
pub root: String, pub root: String,
/// Whether [`root`](Self::root) has been chosen at all.
///
/// An empty `root` is two different things: nothing picked yet, and the
/// endpoint itself picked on purpose — a user who keeps everything at
/// the top level, or a folder library, which is its own root. The string
/// cannot tell them apart, and reading empty as "not chosen" meant the
/// top level could be confirmed in the picker and still not open. So the
/// fact is recorded separately. Defaulted, so an account written before
/// it existed loads as it always did: a non-empty root is chosen by
/// virtue of being there, and an empty one asks again.
#[serde(default)]
pub root_chosen: bool,
/// Which formats the scan looks for (the tick-boxes). /// Which formats the scan looks for (the tick-boxes).
#[serde(default)] #[serde(default)]
pub formats: Vec<String>, pub formats: Vec<String>,
@@ -149,6 +162,7 @@ impl Account {
login: String::new(), login: String::new(),
user_id: String::new(), user_id: String::new(),
root: String::new(), root: String::new(),
root_chosen: false,
formats: Vec::new(), formats: Vec::new(),
last_scan: None, last_scan: None,
} }
+80
View File
@@ -240,6 +240,23 @@ impl ThumbStore {
.collect() .collect()
} }
/// Every file id stored at one size, read from the index in one query.
///
/// For a pass that asks about thousands of images at once — the face
/// audit, the repair lists. Each [`contains`](Self::contains) is a
/// prepared statement and a b-tree probe; asked ten thousand times over a
/// scan it costs more than the scan does, where one walk of the index is
/// a few milliseconds and answers every row.
pub fn held(&self, size: ThumbSize) -> Result<std::collections::HashSet<u64>, ThumbError> {
let mut stmt = self
.index
.prepare("SELECT file_id FROM entries WHERE size = ?1")?;
let ids = stmt
.query_map([size as i64], |r| r.get::<_, i64>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids.into_iter().map(|id| id as u64).collect())
}
/// Store a thumbnail, opening a new shard if the active one is full. /// Store a thumbnail, opening a new shard if the active one is full.
/// ///
/// Re-storing an existing id overwrites in place rather than migrating it /// Re-storing an existing id overwrites in place rather than migrating it
@@ -481,6 +498,30 @@ impl ThumbStore {
self.dir.join(format!("shard-{shard:04}.sqlite")) self.dir.join(format!("shard-{shard:04}.sqlite"))
} }
/// Write a coherent copy of one shard to `dest`, ready to upload.
///
/// Not a file copy. Every shard is in WAL mode and every `put` opens its
/// own connection, so while thumbnails are being generated on several
/// threads at once — which is exactly when the first sync pass runs —
/// there is nearly always a connection open and the log is never
/// checkpointed. The main file then holds whatever the *last* quiet
/// moment left in it, which for a shard created seconds ago is nothing:
/// zero bytes, the schema still in the log. Reading it uploaded an empty
/// file, and every other device merging it failed with "no such table:
/// thumbs". The backup API serialises against writers and copies the
/// database as it is, log included.
pub fn snapshot_shard(&self, shard: u32, dest: &Path) -> Result<(), ThumbError> {
let source = self.open_shard(shard, false)?;
let _ = std::fs::remove_file(dest);
let mut out = Connection::open(dest)?;
let backup = rusqlite::backup::Backup::new(&source, &mut out)?;
// rusqlite asserts a positive page count where SQLite would take -1
// for "everything"; a shard is capped well under this many pages.
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
drop(backup);
Ok(())
}
pub fn index_path(&self) -> PathBuf { pub fn index_path(&self) -> PathBuf {
self.dir.join("index.sqlite") self.dir.join("index.sqlite")
} }
@@ -726,6 +767,45 @@ mod tests {
} }
} }
#[test]
fn a_snapshot_carries_what_the_shard_file_does_not_yet() {
// A thumbnail worker holding the shard open keeps the log from being
// checkpointed; the file on disk is then not the database. The
// upload used to read that file.
let (mut s, _d) = store();
s.put(1, ThumbSize::Grid, &thumb(1024)).unwrap();
let path = s.shard_path(0);
let worker = Connection::open(&path).unwrap();
worker.pragma_update(None, "journal_mode", "WAL").unwrap();
s.put(2, ThumbSize::Grid, &thumb(2048)).unwrap();
s.put(3, ThumbSize::Grid, &thumb(2048)).unwrap();
// What a byte-for-byte reader sees is at most what the last
// checkpoint left; the log is the part a copy misses.
let copy = _d.join("copied.sqlite");
std::fs::copy(&path, &copy).unwrap();
let file_rows = Connection::open(&copy)
.ok()
.and_then(|c| {
c.query_row("SELECT count(*) FROM thumbs", [], |r| r.get::<_, i64>(0))
.ok()
})
.unwrap_or(0);
let snap = _d.join("upload.sqlite");
s.snapshot_shard(0, &snap).unwrap();
let snapshot = Connection::open(&snap).unwrap();
let rows: i64 = snapshot
.query_row("SELECT count(*) FROM thumbs", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 3, "the snapshot is the whole shard");
assert!(
file_rows < 3,
"the file alone lagged ({file_rows} rows), which is what the snapshot exists for"
);
drop(worker);
}
#[test] #[test]
fn a_forgotten_thumbnail_is_no_longer_served() { fn a_forgotten_thumbnail_is_no_longer_served() {
// The point of the whole method: a purged photograph must not keep a // The point of the whole method: a purged photograph must not keep a
+10 -4
View File
@@ -885,9 +885,14 @@ impl ExportSettings {
/// ///
/// The library root has to read as a place rather than as a blank field, /// The library root has to read as a place rather than as a blank field,
/// or confirming the picker where it opens looks like it did nothing. /// or confirming the picker where it opens looks like it did nothing.
///
/// An empty device folder used to read "Ask each time", which nothing
/// does: no platform asks, and an export made with the field blank is
/// refused with "no export folder is set". The label now says what will
/// happen rather than what was once meant to.
pub fn destination_label(&self) -> &str { pub fn destination_label(&self) -> &str {
match self.target { match self.target {
ExportTarget::Device if self.destination.trim().is_empty() => "Ask each time", ExportTarget::Device if self.destination.trim().is_empty() => "Not set",
ExportTarget::Device => &self.destination, ExportTarget::Device => &self.destination,
ExportTarget::Remote if self.remote_destination.trim().is_empty() => "Library root", ExportTarget::Remote if self.remote_destination.trim().is_empty() => "Library root",
ExportTarget::Remote => &self.remote_destination, ExportTarget::Remote => &self.remote_destination,
@@ -1621,14 +1626,15 @@ mod tests {
} }
#[test] #[test]
fn an_empty_device_destination_still_means_ask() { fn an_empty_device_destination_is_not_set_and_says_so() {
// The asymmetry is deliberate: a filesystem has no folder worth // The asymmetry is deliberate: a filesystem has no folder worth
// assuming, so empty there is a question rather than an answer. // assuming, so empty there is a gap rather than an answer — and the
// label must not promise a question nobody will be asked.
let mut s = Settings::default(); let mut s = Settings::default();
s.export.target = ExportTarget::Device; s.export.target = ExportTarget::Device;
s.export.destination = String::new(); s.export.destination = String::new();
assert!(!s.export.destination_is_set()); assert!(!s.export.destination_is_set());
assert_eq!(s.export.destination_label(), "Ask each time"); assert_eq!(s.export.destination_label(), "Not set");
} }
#[test] #[test]
+19 -19
View File
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:2068`</sub> <sub>`ui/dr-ui/ui/app.slint:2070`</sub>
### Move a magnified photograph about ### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture. Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2159`</sub> <sub>`ui/dr-ui/ui/app.slint:2161`</sub>
### Paint a mask by hand ### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make. A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2246`</sub> <sub>`ui/dr-ui/ui/app.slint:2248`</sub>
### Take back the last change ### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does. A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2467`</sub> <sub>`ui/dr-ui/ui/app.slint:2469`</sub>
### Do it again after taking it back ### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header - **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z - **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2480`</sub> <sub>`ui/dr-ui/ui/app.slint:2482`</sub>
### Copy the settings from this photograph ### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way. The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2513`</sub> <sub>`ui/dr-ui/ui/app.slint:2515`</sub>
### Paste the settings onto this photograph ### Paste the settings onto this photograph
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope. The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2525`</sub> <sub>`ui/dr-ui/ui/app.slint:2527`</sub>
### Change which group of adjustments is on screen ### Change which group of adjustments is on screen
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added. The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2553`</sub> <sub>`ui/dr-ui/ui/app.slint:2555`</sub>
### Look at the photograph at 1:1 ### Look at the photograph at 1:1
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2588`</sub> <sub>`ui/dr-ui/ui/app.slint:2590`</sub>
### Move to the next or previous photograph ### Move to the next or previous photograph
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
<sub>`ui/dr-ui/ui/app.slint:2640`</sub> <sub>`ui/dr-ui/ui/app.slint:2642`</sub>
### See the photograph before you edited it ### See the photograph before you edited it
@@ -121,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards. Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2764`</sub> <sub>`ui/dr-ui/ui/app.slint:2766`</sub>
### Put one control back to its default ### Put one control back to its default
@@ -326,7 +326,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach. There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
<sub>`ui/dr-ui/ui/library.slint:2789`</sub> <sub>`ui/dr-ui/ui/library.slint:2795`</sub>
### File photographs in a collection ### File photographs in a collection
@@ -335,7 +335,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture. The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:2986`</sub> <sub>`ui/dr-ui/ui/library.slint:2992`</sub>
### Open a photograph ### Open a photograph
@@ -344,7 +344,7 @@ The selection is what the drag carries, which is why selecting several is worth
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush. A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:3254`</sub> <sub>`ui/dr-ui/ui/library.slint:3260`</sub>
### Rate a photograph without opening it ### Rate a photograph without opening it
@@ -354,7 +354,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
A star has to take the press without it also reaching the cell, or every rating throws the user into develop. A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
<sub>`ui/dr-ui/ui/library.slint:3374`</sub> <sub>`ui/dr-ui/ui/library.slint:3380`</sub>
### Choose the frame a folded burst shows ### Choose the frame a folded burst shows
@@ -363,7 +363,7 @@ A star has to take the press without it also reaching the cell, or every rating
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared. A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
<sub>`ui/dr-ui/ui/library.slint:3505`</sub> <sub>`ui/dr-ui/ui/library.slint:3511`</sub>
### Drop the selection but keep selecting ### Drop the selection but keep selecting
@@ -372,7 +372,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away. Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
<sub>`ui/dr-ui/ui/library.slint:4166`</sub> <sub>`ui/dr-ui/ui/library.slint:4177`</sub>
### Select everything the grid is showing ### Select everything the grid is showing
@@ -381,7 +381,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it. A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:4183`</sub> <sub>`ui/dr-ui/ui/library.slint:4194`</sub>
### Take photographs out of a collection ### Take photographs out of a collection
@@ -390,4 +390,4 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to. The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
<sub>`ui/dr-ui/ui/library.slint:4307`</sub> <sub>`ui/dr-ui/ui/library.slint:4318`</sub>
+224
View File
@@ -0,0 +1,224 @@
# DarkRoom, shown
A tour of what the application does, one picture per thing. Every image on
this page was captured from the desktop build driving itself — nothing is a
mock-up, and nothing has been retouched outside DarkRoom. Where a feature is
better seen moving, it moves.
The requirements behind each feature are in [requirements.md](../requirements.md);
the reasoning is in the design documents linked from each section. This page
is only about what you see.
The photographs are the author's. None show a person.
## Opening a library
DarkRoom opens on a library: a folder on this machine, a folder a sync
client keeps, or a Nextcloud account. A folder needs no password and uploads
nothing.
![The launch screen: a server field, a folder field, and which formats to scan for](media/launch.png)
Once a folder is named, it is the library — you are not asked for it again,
and `Open library` opens it whole. `Subfolder…` narrows the scan to part of
it. The formats ticked are what the scan looks for; RAW is on and JPEG off
by default, because a RAW editor's sensible default is the file the camera
wrote first.
![A folder chosen: the library, whether to scan a subfolder, and the formats](media/launch-folder.png)
## The library
![The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above](media/library.png)
Photographs are ordered by capture time, with a month heading where each
begins. The strip on the left is the timeline — drag it to jump to a year.
The bar above the grid filters by rating, flag and where the file is (on this
device, or only on the server).
### Rating and flagging
Hover a cell and the stars appear; click one. The filter chips above the grid
count what each rating holds, and clicking `3+` shows only those.
![Rating two photographs, then filtering the grid to three stars and more](media/library-rating.gif)
### Getting about
Drag the timeline to scrub through years; Ctrl and the wheel resize the
thumbnails.
![Scrubbing the timeline](media/library-timeline.gif)
![Resizing the thumbnails with Ctrl and the wheel](media/library-thumbsize.gif)
### Selecting several
`Select` in the header — or Ctrl-click — starts a selection. Shift-click picks
a range. The bar at the foot of the grid is everything a selection can be done
to: collections, keywords, presets, export, and merging to a panorama.
![Twelve photographs selected, with the selection bar along the foot of the grid](media/library-selection.png)
`Keywords` on that bar opens a sheet; type a word and press return, and it
is on every photograph selected. The list below the field is every keyword
the library has, ticked where the selection carries it.
![Keywording twelve frames](media/library-keywords.gif)
### Collections
`+` at the head of the sidebar makes one. Drag a photograph — or the whole
selection — onto its row to file it there; click the row to see it. A
photograph can be in several, and the badge on its cell counts them.
![Filing photographs in a collection by dragging them onto it](media/library-collections.gif)
## Developing a photograph
Click a thumbnail to open it. The column on the right is every adjustment;
the strip at its head narrows it to one group.
![The develop view: the photograph, the histogram, and the adjustment column](media/develop.png)
![Switching between the Optics, Light, Colour, Effects and Detail groups](media/develop-groups.gif)
### Light
Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
Hold `Before` to see the photograph as it was.
![Raising exposure, pulling the highlights, lifting the shadows, then holding Before](media/develop-light.gif)
### Looking closer
Double-click for 1:1; drag to move about; double-click again to fit. The
wheel zooms to any amount in between.
![Zooming to 1:1, panning, and back](media/develop-zoom.gif)
### White balance from the photograph
Press `pick` in the White Balance group, then click something neutral.
![Picking a neutral wall to set the white balance](media/develop-wb.gif)
### Composing
Crop by dragging the frame's corners, straighten with the slider, lock a
ratio from the chips. `Done composing` returns to the photograph.
![Cropping, straightening and choosing a ratio](media/compose.gif)
### Local adjustments
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
segmentation model over the photograph; what it recognises appears as a list
of categories with how much of the frame each covers. Click one and it is a
mask — then every slider below edits only that region.
![What the model found in an urban scene: ground, architecture, sky, vegetation](media/local-categories.png)
![The sky chosen: tinted on the photograph, and the column now scoped to it](media/local-segment.png)
A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
or shrink its edge, choose how it falls off. `Show masks as` draws the mask
tinted, as alpha, or as an outline; the eye on its row switches it off.
![Looking at the mask three ways, painting into it, then growing its edge](media/local-paint.gif)
Linear and radial gradients, a tone range and a colour range are the other
ways to make one; each can be combined with any other.
### Repair
`Repair` in the rail: click a mark and it is covered from a source DarkRoom
chooses beside it. Drag either circle to move it; the size, feather and
opacity are in the panel. Heal blends; clone copies.
![Covering marks on a road](media/repair.gif)
### Film
The `Film` chooser at the head of Adjust applies a spectral simulation of a
named stock; below it, the print exposure and push controls a film has and a
sensor does not.
![Choosing Velvia, then holding Before](media/film.gif)
### History, snapshots, presets
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
keeps the current state under a name. `Presets…` saves the settings to
apply elsewhere, and imports `.xmp` from other applications.
![The presets sheet](media/presets.png)
## Merging a panorama
Select the frames, then `Merge to panorama` from the selection bar. The
frames are read, aligned, and drawn on the suggested projection with each one
outlined where it landed — twelve hand-held portrait frames across an alpine
valley, here. Change the projection (a 150° sweep on a flat perspective is
what the middle of the film shows, and why cylindrical is suggested), ask for
the border to be filled rather than cropped, then `Merge`. The composite is
written beside its sources as a DNG and appears in the grid with the merge
as the first step in its history.
![Twelve frames aligned, the projections tried, and the border filled](media/panorama.gif)
![The alignment on a cylinder, each frame outlined where it landed](media/panorama-aligned.png)
![The same, with the ragged border filled by the model rather than cropped away](media/panorama-filled.png)
## Export
`Export` in the develop header, or `Export N` from a selection. Format,
size, colour space, sharpening, naming and where the file goes are in
Settings, and apply to every export until changed. An export with no folder
set is refused, and the header says so.
![Export defaults in Settings](media/settings-export.png)
## Settings
![The settings page: background activity, indexing, storage, display](media/settings.png)
Background activity with progress, thumbnail and face indexing, storage on
this device, display and colour, export defaults, what is written to XMP
sidecars, and a diagnostics bundle for a bug report.
## People
Face detection and identity run over the library and group faces by person;
the `Identity` page is where suggestions are confirmed, rejected and split,
and `People` on the filter bar narrows the grid to someone. Not pictured
here, for the obvious reason — [faces.md](../faces.md) has the design.
## Where things are written down
| Feature | Design |
|---|---|
| Local masks and segmentation | [segmentation.md](../segmentation.md), [mask-editing.md](../mask-editing.md) |
| Repair | [spot-removal.md](../spot-removal.md) |
| Panorama | [panorama.md](../panorama.md) |
| Faces and identity | [faces.md](../faces.md) |
| Gestures, generated from the code | [gestures.md](../gestures.md) |
| Navigation and layout | [ui-navigation.md](../ui-navigation.md) |
| Sync and storage | [storage.md](../storage.md) |
## How this page is made
[`tools/manual/`](../../tools/manual/README.md) drives the desktop build on
a private X server and records each scene; `record.sh <library>` re-makes
every picture here. Run it after a change to the interface and commit what
changed. The pictures are in LFS.
Making it the first time turned up nine faults, each fixed in its own
commit before the pictures were taken: the folder picker could not choose
the top level, month headings overprinted each other, a category mask
widened the column off the window, the mask tint outlived its mode, the
first sync uploaded an empty thumbnail shard, a merge that ran out of GPU
memory left the page on Stop for ever, the export settings promised to ask
for a folder and did not, the keyword sheet sent its keys to the grid, and
an empty trash told you to check your library folder.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+58
View File
@@ -486,3 +486,61 @@ rectangle.
`models/LICENCE.md`), installed by the PKGBUILD and unpacked by the APK `models/LICENCE.md`), installed by the PKGBUILD and unpacked by the APK
beside the face and scene models; `tools/export-migan.sh` regenerates it beside the face and scene models; `tools/export-migan.sh` regenerates it
from the upstream checkpoint. from the upstream checkpoint.
## 14. The fill, trained — 2026-09-20
§13.5 named the remaining fault: in a deep corner the stock model puts its
Places2 prior — clouds, peaks, a water line — into a hole, because it was
trained on holes *inside* pictures and a panorama's border is a hole with
the picture on one side and nothing on the other. Every ring (mirror,
replicate, detrend) treated the symptom. The fix is a model that has seen
the real thing: **MI-GAN's 512 generator fine-tuned on border-shaped voids
cut from the user's own photographs**, in a separate repository
(`darkroom-infill`, beside this one), so the truth beyond the void is known
and the model learns one-sided extrapolation.
**What it was trained on.** Voids made the way this merge makes them:
frames with a yaw, a common pitch and per-frame roll, projected onto the
cylinder and rasterised, the canvas their union's bounding box, the void
the canvas outside the union — arcs where straight edges bent, cusps where
frames meet, the bow-tie wedge at a corner (a third of tiles are cut at a
canvas corner). Voids to 256 px deep at a 512 tile. Half the deep tiles
train the *second pass*: a no-grad first pass fills the tile, its nearest
band (64–256 px) is marked known, and the remaining void is the example —
so the model continues its own output without drift, which is how `fill`
runs deep voids. Data: ~7 400 pictures — the 1024-px proxy tier of the
library and ~2 000 raws sampled evenly across every year, developed at
half size. Loss: hole-weighted L1, VGG16 perceptual, a hinge PatchGAN.
One night on the reference desktop's RTX 3050.
**What changed here.** `FillParams::mirror_depth` **0** is now "no ring":
the void reaches the tile's edge with nothing beyond, and beyond the band
being filled the void stays *unknown* rather than presented as known coarse
fill — the two conditions the model was trained under. Defaults: mirror 0,
coarse 1 (the coarse pass seeds nothing the model is allowed to see), band
192. The ring remains on the page for the stock model's sake, at any depth
above zero. The model file is a drop-in (`models/inpaint/migan-512.onnx`,
same six operators, same tensors) and the engine loads it unchanged.
**Measured, 240 held-out tiles with projection-shaped voids (PSNR in the
hole, dB / LPIPS on the composite), stock → shipped (step 3 607):** edge
16.9 → 18.5 / 0.121 → 0.136; corner 14.6 → 16.3 / 0.183 → 0.205; interior
18.2 → 19.3 / 0.051 → 0.056. Read both columns: the fine-tune gains ~2 dB
on edges and corners because it stops inventing objects, and *loses* on
LPIPS because what it paints in a deep void is smoother than the stock
model's confident wrong texture — LPIPS rewards texture, right or wrong.
On the fixture's dump at half resolution (the merge's working size) the
sky corners are sky, with no structure and a faint tone step at worst;
the ground bands carry a fine texture at the right tone, softer than the
real scree above them. The stock model's top-left corner on the same
dump is a glowing invented structure. The training's own record — what
each loss weighting did, and the two runs abandoned (blur under L1 in
the hole; a brick pattern under a strong adversarial term against a
discriminator that had not learned) — is `runs/` in `darkroom-infill`.
**What is still wrong.** The ground fill is softer than its context —
texture, not structure, is what a night on a laptop GPU could not finish.
The levers, in order: a discriminator that learns (a pretrained one —
MI-GAN's own from the unfused checkpoint — instead of a PatchGAN from
scratch), feature matching, and more steps at 512. FR-MRG-4's
*experimental* stays.
+88 -88
View File
File diff suppressed because one or more lines are too long
+9 -4
View File
@@ -104,11 +104,16 @@ the dataset licence restricts models trained on it by name.
| File | Source | Trained on | Used by | | File | Source | Trained on | Used by |
|---|---|---|---| |---|---|---|---|
| `inpaint/migan-512.onnx` | `migan_512_places2.pt` from `https://github.com/Picsart-AI-Research/MI-GAN` (Sargsyan et al., ICCV 2023) | Places2, by the authors | the panorama border fill (FR-MRG-4) | | `inpaint/migan-512.onnx` | `migan_512_places2.pt` from `https://github.com/Picsart-AI-Research/MI-GAN` (Sargsyan et al., ICCV 2023), **fine-tuned** in the `darkroom-infill` repository (2026-09-20) | Places2 by the authors, then ~7 400 of the maintainer's own photographs with border-shaped voids | the panorama border fill (FR-MRG-4) |
Exported by `tools/export-migan.sh`: the bare 512 generator at a fixed The bare 512 generator at a fixed `1×4×512×512`, six operator types; the
`1×4×512×512`, six operator types. The tiling, the context and the blend are tiling, the context and the blend are Rust (`dr_pano::fill`). Since
Rust (`dr_pano::fill`). 2026-09-20 the shipped file is the fine-tune (`docs/panorama.md` §14),
exported by `python -m infill.export` in `darkroom-infill`;
`tools/export-migan.sh` still produces the stock generator from the upstream
checkpoint, which the fine-tune starts from. The fine-tuned weights are a
derivative of the MIT weights trained on photographs the maintainer owns,
and carry the same MIT grant.
**MIT, code and weights alike** — `LICENSE` and `LICENSE-WEIGHTS` in the **MIT, code and weights alike** — `LICENSE` and `LICENSE-WEIGHTS` in the
repository, both read on 2026-09-19, both the plain MIT text with no further repository, both read on 2026-09-19, both the plain MIT text with no further
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working # makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release. # on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom pkgname=darkroom
pkgver=0.13.1 pkgver=0.13.2
# Back to 1 with the version: a new pkgver is a new archive name, so there is # Back to 1 with the version: a new pkgver is a new archive name, so there is
# nothing for makepkg to reuse and nothing for a release number to disambiguate. # nothing for makepkg to reuse and nothing for a release number to disambiguate.
pkgrel=1 pkgrel=1
+6
View File
@@ -8,6 +8,12 @@
# #
# ./tools/export-migan.sh # -> models/inpaint/migan-512.onnx # ./tools/export-migan.sh # -> models/inpaint/migan-512.onnx
# #
# Since 2026-09-20 the file that ships is not this export but a fine-tune of
# it on panorama-border voids (docs/panorama.md §14), made in the
# `darkroom-infill` repository with `python -m infill.export`. This script
# still yields the stock generator — the fine-tune's starting point, and the
# model the page's "mirror depth" knob above zero was built around.
#
# Requires `uv`. Everything else is fetched into a throwaway venv, including # Requires `uv`. Everything else is fetched into a throwaway venv, including
# a CPU-only torch and `gdown` for the checkpoint, which the authors keep on # a CPU-only torch and `gdown` for the checkpoint, which the authors keep on
# Google Drive (models/LICENCE.md has the licence; it is MIT). # Google Drive (models/LICENCE.md has the licence; it is MIT).
+22
View File
@@ -0,0 +1,22 @@
# tools/manual — the pictures in docs/manual
`record.sh <library>` drives the desktop build on a private X server and
records every scene in `scenes.py` into `docs/manual/media/`. `drive.py` is
the puppeteer underneath — launch, click, drag, type, screenshot, record —
and is usable on its own to look at a panel after changing it:
```bash
DR_HOME=/var/tmp/x tools/manual/drive.py launch /some/folder
tools/manual/drive.py click 1543 22 # Settings
tools/manual/drive.py shot /tmp/settings.png
tools/manual/drive.py stop
```
The demo library is the author's: seventy frames with no people in them,
the `fixtures/pano` set among them. The scenes' cell coordinates are for
that grid, at 1600×1100; the panel coordinates hold for any library.
Two things the scripts know that are not obvious: a Slint `TouchArea` wants
a held press, not xdotool's `click`; and the app is driven on Xvfb rather
than the desktop because under XWayland at scale 2 a pointer warp lands at
twice the coordinate asked for.
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Drive the desktop build from outside, for the manual's screenshots.
drive.py launch [args...] start the app on the private X server
drive.py stop
drive.py shot OUT.png
drive.py click X Y [button]
drive.py move X Y
drive.py drag X1 Y1 X2 Y2 [steps]
drive.py type TEXT
drive.py key KEYSYM...
drive.py rec OUT.mp4 / cut start and stop a recording of the window
drive.py where the pointer, in window coordinates
Everything is in *window* pixels, at scale 1, with the window at the origin
of a 1920×1200 Xvfb on `DR_DISPLAY` (`:7`). The app gets its own XDG
profile under `DR_HOME` (`/var/tmp/dr-manual`), so nothing here touches the
library or settings of whoever is logged in; `DR_BIN` names the binary.
Two things that cost an afternoon, kept here so they cost nobody else one:
a Slint `TouchArea` wants a *held* press (`mousedown`, a beat, `mouseup`) —
xdotool's `click` is sometimes dropped; and under XWayland at scale 2 a
pointer warp lands at twice the coordinate asked for, which is why this runs
on Xvfb rather than the desktop.
"""
import os
import subprocess
import sys
import time
HOME = os.environ.get('DR_HOME', '/var/tmp/dr-manual')
DISPLAY = os.environ.get('DR_DISPLAY', ':7')
BIN = os.environ.get('DR_BIN', 'target/release/darkroom-desktop')
ENV = dict(
os.environ,
XDG_CONFIG_HOME=f'{HOME}/xdg/config',
XDG_DATA_HOME=f'{HOME}/xdg/data',
XDG_STATE_HOME=f'{HOME}/xdg/state',
RUST_LOG='info',
WINIT_X11_SCALE_FACTOR='1',
DISPLAY=DISPLAY,
)
ENV.pop('WAYLAND_DISPLAY', None)
os.environ['DISPLAY'] = DISPLAY
def x(*args, check=True):
return subprocess.run(
['xdotool', *map(str, args)], capture_output=True, text=True, check=check
).stdout.strip()
def win():
return open(f'{HOME}/app.win').read().strip()
def geometry():
out = x('getwindowgeometry', win())
pos = out.split('Position: ')[1].split(' ')[0]
px, py = map(int, pos.split(','))
return px, py
def launch(args):
os.makedirs(HOME, exist_ok=True)
log = open(f'{HOME}/app.log', 'w')
p = subprocess.Popen([BIN, *args], env=ENV, stdout=log, stderr=subprocess.STDOUT)
open(f'{HOME}/app.pid', 'w').write(str(p.pid))
w = ''
for _ in range(120):
w = x('search', '--pid', p.pid, '--name', 'DarkRoom', check=False).split('\n')[-1]
if w:
break
time.sleep(0.5)
time.sleep(1.5)
W, H = os.environ.get('WIDTH', '1600'), os.environ.get('HEIGHT', '1100')
x('windowsize', '--sync', w, W, H)
time.sleep(0.5)
x('windowmove', '--sync', w, 0, 0)
x('windowfocus', '--sync', w, check=False)
open(f'{HOME}/app.win', 'w').write(w)
print(f'pid {p.pid} win {w}')
def stop():
try:
os.kill(int(open(f'{HOME}/app.pid').read()), 15)
except (OSError, ValueError) as e:
print(e)
time.sleep(1)
def shot(out):
subprocess.run(['import', '-window', win(), out], check=True)
def move(px, py):
ox, oy = geometry()
x('mousemove', '--sync', ox + int(px), oy + int(py))
def click(px, py, button=1):
x('windowfocus', '--sync', win(), check=False)
move(px, py)
time.sleep(0.15)
x('mousedown', button)
time.sleep(0.12)
x('mouseup', button)
def drag(x1, y1, x2, y2, steps=20):
x('windowfocus', '--sync', win(), check=False)
move(x1, y1)
time.sleep(0.15)
x('mousedown', 1)
time.sleep(0.15)
for i in range(1, int(steps) + 1):
t = i / int(steps)
move(int(x1) + (int(x2) - int(x1)) * t, int(y1) + (int(y2) - int(y1)) * t)
time.sleep(0.03)
time.sleep(0.15)
x('mouseup', 1)
def rec_start(out):
size = x('getwindowgeometry', win()).split('Geometry: ')[1].strip()
ox, oy = geometry()
p = subprocess.Popen([
'ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'x11grab',
'-framerate', '15', '-video_size', size, '-i', f'{DISPLAY}+{ox},{oy}',
'-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-y', out,
])
open(f'{HOME}/rec.pid', 'w').write(str(p.pid))
time.sleep(0.5)
def rec_stop():
pid = int(open(f'{HOME}/rec.pid').read())
os.kill(pid, 2)
for _ in range(50):
try:
os.kill(pid, 0)
time.sleep(0.1)
except OSError:
break
def where():
out = x('getmouselocation')
mx, my = [int(v.split(':')[1]) for v in out.split()[:2]]
ox, oy = geometry()
print(mx - ox, my - oy)
if __name__ == '__main__':
cmd, *a = sys.argv[1:]
if cmd == 'launch':
launch(a)
elif cmd == 'stop':
stop()
elif cmd == 'shot':
shot(a[0])
elif cmd == 'click':
click(*a)
elif cmd == 'move':
move(*a)
elif cmd == 'drag':
drag(*a)
elif cmd == 'type':
x('windowfocus', '--sync', win(), check=False)
x('type', '--delay', '120', a[0])
elif cmd == 'key':
x('windowfocus', '--sync', win(), check=False)
x('key', '--delay', '80', *a)
elif cmd == 'rec':
rec_start(a[0])
elif cmd == 'cut':
rec_stop()
elif cmd == 'where':
where()
else:
sys.exit(__doc__)
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# gif.sh in.mp4 out.gif [width] [fps] — a palette-optimised GIF of a recording.
in=$1; out=$2; w=${3:-960}; fps=${4:-10}
ffmpeg -hide_banner -loglevel error -y -i "$in" \
-vf "fps=$fps,scale=$w:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=192:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle" \
"$out"
ls -la "$out"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Re-record the manual: docs/manual/README.md's pictures, from the app itself.
#
# tools/manual/record.sh <library-folder> [scene...]
#
# Needs Xvfb, xdotool, ImageMagick's `import` and ffmpeg. Builds the desktop
# binary if it is not there, starts a private X server, opens the folder as
# a library in a throwaway profile, runs the scenes (all of them by default)
# and writes PNGs and GIFs into docs/manual/media/.
#
# The library folder is the author's demo set — seventy face-free frames,
# the twelve-frame panorama from fixtures/pano among them — and scenes.py's
# cell coordinates assume its grid. Another folder needs those looked at.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
library="${1:?library folder}"
shift || true
export DR_HOME="${DR_HOME:-/var/tmp/dr-manual}"
export DR_DISPLAY="${DR_DISPLAY:-:7}"
export DR_BIN="${DR_BIN:-$repo/target/release/darkroom-desktop}"
media="$repo/docs/manual/media"
mkdir -p "$media" "$DR_HOME/xdg/config/darkroom" "$DR_HOME/xdg/data/darkroom"
[ -x "$DR_BIN" ] || (cd "$repo" && cargo build --release -p darkroom-desktop)
# The models and the runtime are shared with the real profile: a segmenter
# that is not there makes "Find subjects" a picture of nothing.
for d in models runtime; do
src="${XDG_DATA_HOME:-$HOME/.local/share}/darkroom/$d"
[ -e "$src" ] && ln -sfn "$src" "$DR_HOME/xdg/data/darkroom/$d"
done
# A folder library, chosen: no launch screen on the way in.
cat > "$DR_HOME/xdg/config/darkroom/sessions.json" <<JSON
{ "version": 0, "sessions": [ { "backend": "folder", "server": "$library",
"login": "", "user_id": "", "root": "", "root_chosen": true,
"formats": [], "last_scan": null } ] }
JSON
if ! DISPLAY="$DR_DISPLAY" xdpyinfo >/dev/null 2>&1; then
Xvfb "$DR_DISPLAY" -screen 0 1920x1200x24 +extension GLX +render -noreset \
> "$DR_HOME/xvfb.log" 2>&1 &
echo $! > "$DR_HOME/xvfb.pid"
sleep 2
fi
python3 "$here/drive.py" launch
sleep 12
python3 "$here/scenes.py" "$media" "${@:-all}"
python3 "$here/drive.py" stop
# GIFs for the page; the MP4s are working files and are not kept.
for mp4 in "$media"/*.mp4; do
[ -e "$mp4" ] || continue
"$here/gif.sh" "$mp4" "${mp4%.mp4}.gif" 960 10
rm "$mp4"
done
optipng -quiet -o2 "$media"/*.png || true
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""The manual's scenes: `scenes.py OUT_DIR scene [scene...]`, or `all`.
Each scene drives the running app through `drive.py` and leaves a PNG or an
MP4 in OUT_DIR, named after itself. `record.sh` runs them in order and turns
the MP4s into GIFs.
Coordinates are window pixels for a 1600×1100 window over the manual's own
library (see record.sh): which cell holds which photograph is part of the
scene, so a different library needs the numbers looked at again. Panel
coordinates hold for any library.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import drive as dr # noqa: E402
OUT = sys.argv[1] if len(sys.argv) > 1 else '.'
def shot(name):
dr.shot(f'{OUT}/{name}.png')
def rec(name):
dr.rec_start(f'{OUT}/{name}.mp4')
def cut():
dr.rec_stop()
def pause(s):
time.sleep(s)
def hold(px, py, seconds):
dr.move(px, py)
pause(0.15)
dr.x('mousedown', 1)
pause(seconds)
dr.x('mouseup', 1)
def wheel(n, px, py):
dr.move(px, py)
dr.x('click', '--repeat', abs(n), '--delay', 40, 5 if n > 0 else 4)
def ctrl_wheel(n, px, py):
dr.move(px, py)
dr.x('keydown', 'ctrl')
for _ in range(abs(n)):
dr.x('click', 5 if n > 0 else 4)
pause(0.35)
dr.x('keyup', 'ctrl')
# --- places on the screen ---------------------------------------------------
LIBRARY = (55, 22) # ‹ Library, in the develop header
SELECT = (942, 22) # Select / Done, in the library header
SETTINGS_LIB = (1543, 22)
SETTINGS_DEV = (1343, 22)
EXPORT = (720, 22)
BACK = (60, 22) # ‹ Back, on the settings page
RAIL = {'photo': (30, 75), 'compose': (30, 130), 'local': (30, 185), 'repair': (30, 240)}
GROUPS = {'all': 1269, 'optics': 1313, 'light': 1359, 'colour': 1406, 'effects': 1457, 'detail': 1506}
BEFORE = (210, 1045)
RESET_ADJUST = (1565, 741) # "reset" in the Adjust heading, All group, scrolled to top
CELL_TEAPOT = (424, 165)
CELL_PANO_FIRST = (1264, 165)
CELL_CHINATOWN = (963, 525)
CELL_NY_LAST = (1502, 525)
COLLECTION_ROW = (60, 90)
def group(name):
dr.click(GROUPS[name], 67)
pause(0.9)
def to_library():
dr.click(*LIBRARY)
pause(2)
def open_chinatown():
dr.click(*CELL_CHINATOWN)
pause(6)
def reset_edit():
group('all')
wheel(-60, 1420, 700)
dr.click(*RESET_ADJUST)
pause(1)
# --- library ----------------------------------------------------------------
def library():
shot('library')
def library_rating():
rec('library-rating')
dr.move(424, 900); pause(1.0)
dr.click(444, 939); pause(1.2)
dr.move(604, 900); pause(0.8)
dr.click(644, 939); pause(1.2)
dr.move(800, 700); pause(0.6)
dr.click(665, 61); pause(1.5) # 3+
dr.click(384, 61); pause(1.2) # All
cut()
def library_timeline():
rec('library-timeline')
dr.drag(300, 200, 300, 900, 40); pause(1.0)
dr.drag(300, 900, 300, 150, 40); pause(1.0)
cut()
def library_thumbsize():
rec('library-thumbsize')
ctrl_wheel(-4, 900, 500); pause(0.8)
ctrl_wheel(4, 900, 500); pause(0.8)
cut()
def library_selection():
dr.click(*SELECT); pause(0.5)
dr.click(*CELL_PANO_FIRST); pause(0.3)
dr.x('keydown', 'shift'); dr.click(*CELL_NY_LAST); dr.x('keyup', 'shift'); pause(0.8)
for x in (963, 1143, 1323, 1502):
dr.x('keydown', 'ctrl'); dr.click(x, 525); dr.x('keyup', 'ctrl'); pause(0.3)
pause(0.5)
shot('library-selection')
def library_keywords():
# Continues from library_selection: twelve frames selected.
rec('library-keywords')
dr.click(1117, 1079); pause(1.2)
for word in ('alps', 'panorama', 'summer'):
dr.x('type', '--delay', 90, word); dr.x('key', 'Return'); pause(0.7)
pause(0.8)
dr.click(800, 673); pause(1.0) # Done
cut()
dr.click(*SELECT); pause(0.5) # leave selecting
def library_collections():
dr.click(212, 22); pause(0.3) # + in the collections header
dr.x('type', '--delay', 90, 'Alps'); dr.x('key', 'Return'); pause(1.2)
rec('library-collections')
dr.drag(*CELL_PANO_FIRST, *COLLECTION_ROW, 40); pause(1.5)
dr.click(*SELECT); pause(0.5)
dr.click(424, 350); pause(0.3)
dr.x('keydown', 'shift'); dr.click(1502, 350); dr.x('keyup', 'shift'); pause(0.8)
dr.drag(963, 350, *COLLECTION_ROW, 40); pause(1.5)
dr.click(*SELECT); pause(0.5)
dr.click(*COLLECTION_ROW); pause(1.5)
cut()
shot('library-collection')
dr.click(60, 48); pause(1.2) # All photographs
# --- develop ----------------------------------------------------------------
def develop():
open_chinatown()
group('all')
shot('develop')
def develop_groups():
rec('develop-groups')
for g in ['optics', 'light', 'colour', 'effects', 'detail', 'all']:
group(g); pause(0.6)
cut()
def develop_light():
group('light')
rec('develop-light')
pause(0.5)
dr.drag(1398, 792, 1422, 792, 25); pause(0.8) # exposure up
dr.drag(1398, 918, 1320, 918, 25); pause(0.8) # highlights down
dr.drag(1398, 964, 1450, 964, 25); pause(0.8) # shadows up
hold(*BEFORE, 1.6); pause(1.0)
cut()
reset_edit()
def develop_zoom():
rec('develop-zoom')
dr.move(650, 500); pause(0.3)
dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.5)
dr.drag(650, 500, 900, 700, 30); pause(0.8)
dr.drag(900, 700, 500, 450, 30); pause(0.8)
dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.2)
cut()
def develop_wb():
group('colour')
rec('develop-wb')
pause(0.4)
dr.click(1542, 767); pause(0.8) # pick
dr.click(1000, 300); pause(1.5) # a neutral wall
hold(*BEFORE, 1.4); pause(0.8)
cut()
reset_edit()
def compose():
dr.click(*RAIL['compose']); pause(1.2)
rec('compose')
pause(0.4)
dr.drag(66, 182, 260, 330, 30); pause(0.8)
dr.drag(1236, 964, 1100, 900, 25); pause(0.8)
dr.drag(1420, 593, 1448, 593, 20); pause(1.0) # straighten
dr.click(1466, 647); pause(1.2) # 1:1
dr.click(1378, 647); pause(1.0) # Original
dr.click(132, 1045); pause(1.2) # Done composing
cut()
shot('compose-done')
dr.click(1567, 510); pause(1.0) # reset compose
dr.click(115, 1045); pause(1.0) # refit
def local_segment():
dr.click(*RAIL['local']); pause(1.2)
dr.click(1420, 435); pause(14) # Find subjects
shot('local-categories')
dr.click(1266, 646); pause(2.5) # Sky
shot('local-segment')
def local_paint():
# Continues from local_segment: the sky mask selected and shown.
rec('local-paint')
wheel(12, 1420, 800); pause(0.8)
shot('local-mask-row')
cut()
def local_done():
dr.click(62, 1045); pause(1.0) # Done masking
reset_edit()
def repair():
dr.click(*RAIL['repair']); pause(1.2)
rec('repair')
pause(0.4)
dr.click(700, 620); pause(3)
dr.drag(1264, 460, 1330, 460, 20); pause(1.5) # size
hold(*BEFORE, 1.4); pause(0.8)
cut()
dr.click(125, 1045); pause(1.0) # Done repairing
reset_edit()
def film():
group('all')
rec('film')
dr.click(1420, 779); pause(1.5) # Film ▸
dr.click(1420, 953); pause(3) # Velvia 100
hold(*BEFORE, 1.4); pause(0.8)
cut()
reset_edit()
def presets():
dr.click(1420, 692); pause(1.5)
shot('presets')
dr.click(800, 783); pause(1.0) # Done
def develop_export():
dr.click(*EXPORT); pause(8)
shot('export-done')
# --- panorama ---------------------------------------------------------------
def panorama():
to_library()
library_selection()
rec('panorama')
dr.click(1325, 1079); pause(40) # Merge to panorama
dr.click(184, 637); pause(45) # Fill the border
dr.click(1543, 22); pause(60) # Merge
cut()
shot('panorama-done')
dr.click(*BACK); pause(2)
# --- settings ---------------------------------------------------------------
def settings():
dr.click(*SETTINGS_LIB); pause(2)
shot('settings')
wheel(30, 700, 600); pause(1)
shot('settings-export')
dr.click(*BACK); pause(1.5)
ALL = [
'library', 'library_rating', 'library_timeline', 'library_thumbsize',
'library_selection', 'library_keywords', 'library_collections',
'develop', 'develop_groups', 'develop_light', 'develop_zoom', 'develop_wb',
'compose', 'local_segment', 'local_paint', 'local_done', 'repair', 'film',
'presets', 'panorama', 'settings',
]
if __name__ == '__main__':
names = sys.argv[2:]
if names == ['all']:
names = ALL
for name in names:
print(f'-- {name}', flush=True)
globals()[name]()
+156
View File
@@ -0,0 +1,156 @@
//! What one click on the Identity screen costs, off the GUI.
//!
//! cargo run --release -p dr-ui --example identity_bench -- CATALOG.sqlite THUMBS_DIR
//!
//! Times each read the screen performs after a confirm, a reject or a
//! split — the people rail, the selected person's faces with their crops,
//! the coverage line — and the two batch writes, against a *copy* of a real
//! catalog. It writes to the catalog it is given (the batch operations are
//! the point), so never hand it the library's own file.
//!
//! The figures are wall-clock on this machine and this library, for reading
//! side by side before and after a change; they are not a gate.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_catalog::faces::{self, PersonId};
use dr_catalog::Catalog;
use dr_thumbs::ThumbStore;
use dr_ui::identity;
use dr_ui::repairs::{self, Capabilities, Scope};
const MODEL_ID: &str = "scrfd_10g+w600k_mbf";
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 2 {
eprintln!("usage: identity_bench CATALOG.sqlite THUMBS_DIR");
std::process::exit(2);
}
let catalog = Catalog::open(&PathBuf::from(&args[0])).expect("catalog");
let store = ThumbStore::open(&PathBuf::from(&args[1])).expect("thumbs");
let conn = catalog.connection();
// The person with the most faces: the worst case for the face grid, and
// the one a user is likeliest to be confirming through.
let (biggest, n_faces): (i64, i64) = conn
.query_row(
"SELECT person_id, COUNT(*) c FROM face_person GROUP BY 1 ORDER BY c DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("a person");
let biggest = PersonId(biggest as u64);
println!("largest person {biggest:?} holds {n_faces} faces\n");
// ── the reads a click triggers ────────────────────────────────────
let detector = dr_types::FaceDetector::for_model_id(MODEL_ID).unwrap_or_default();
let registry = repairs::registry(
Scope::Outstanding,
MODEL_ID,
detector,
Capabilities {
gpu: true,
face_models: true,
eye_models: true,
},
);
time("load_people", 5, || {
identity::load_people(&catalog, MODEL_ID).unwrap();
});
time("load_faces (largest person, cold)", 5, || {
identity::load_faces(&catalog, &store, biggest, Default::default()).unwrap();
});
// What a confirm click costs: the grid's own crops handed back in.
let mut cells = identity::load_faces(&catalog, &store, biggest, Default::default()).unwrap();
time("load_faces (largest person, redraw)", 5, || {
let cut = cells
.drain(..)
.filter_map(|c| Some((c.face, c.crop?)))
.collect();
cells = identity::load_faces(&catalog, &store, biggest, cut).unwrap();
});
time("audit (coverage line)", 5, || {
dr_ui::faces::audit(&catalog, &store, MODEL_ID, &registry).unwrap();
});
// ── the batch writes ──────────────────────────────────────────────
// Every face of the largest person is demoted to a suggestion, then
// confirmed back in one call, so the measurement covers the whole group.
let ids: Vec<i64> = {
let mut q = conn
.prepare("SELECT face_id FROM face_person WHERE person_id = ?1")
.unwrap();
q.query_map([biggest.0 as i64], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
};
let demote = |conn: &rusqlite::Connection| {
conn.execute(
"UPDATE face_person SET confirmed = 0, probability = 0.5 WHERE person_id = ?1",
[biggest.0 as i64],
)
.unwrap();
};
demote(conn);
time("confirm_all (largest person)", 3, || {
demote(conn);
identity::confirm_all(&catalog, biggest).unwrap();
});
// Split the group onto a new person and fold it straight back, so the
// catalog ends where it started apart from the redirect rows.
let members: Vec<faces::FaceId> = ids.iter().map(|&i| faces::FaceId(i as u64)).collect();
time("split_off (largest person, all faces)", 3, || {
let new = identity::split_off(&catalog, biggest, &members, "").unwrap();
faces::merge_people(conn, biggest, new).unwrap();
});
// The split rejects every face from `biggest`; a merge back does not
// clear that, so clear it here or a later run measures a different table.
conn.execute(
"DELETE FROM face_person_rejected WHERE person_id = ?1",
[biggest.0 as i64],
)
.unwrap();
}
/// Run `f` a few times and print the best wall-clock, the median, and the
/// best CPU time. The best is what the code costs, the median is what the
/// user waits — and the CPU figure is the one to compare across runs, since
/// this path is single-threaded and a build running on the same machine
/// doubles the wall clock without touching it.
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.1} ms median {:8.1} ms cpu {:8.1} 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 the scheduler's own account.
///
/// `/proc/self/schedstat` is the main thread's; the bench runs everything on
/// it. Zero where the file is missing, which only makes the CPU column
/// useless rather than the run.
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()
}
+13 -2
View File
@@ -286,9 +286,20 @@ async fn sync_shards(
// ---- upload ---------------------------------------------------------- // ---- upload ----------------------------------------------------------
for shard in &local { for shard in &local {
let path = store.shard_path(shard.id); // A snapshot, never the live file — see `ThumbStore::snapshot_shard`
let Ok(bytes) = std::fs::read(&path) else { // for the zero-byte upload that reading the file produced.
let snapshot = scratch.join(format!("thumb-shard-{:04}-upload.sqlite", shard.id));
let bytes = match store.snapshot_shard(shard.id, &snapshot) {
Ok(()) => std::fs::read(&snapshot),
Err(e) => Err(std::io::Error::other(e.to_string())),
};
let _ = std::fs::remove_file(&snapshot);
let bytes = match bytes {
Ok(b) => b,
Err(e) => {
log::warn!("snapshotting thumbnail shard {}: {e}", shard.id);
continue; continue;
}
}; };
let name = shard_name(&client, shard.id); let name = shard_name(&client, shard.id);
+30
View File
@@ -2552,6 +2552,15 @@ impl DevelopSession {
/// or reordered. /// or reordered.
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> { pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
use dr_pipeline::mask::{Reveal, RevealedLayer}; use dr_pipeline::mask::{Reveal, RevealedLayer};
// 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 a tint is a way of looking at a *mask*, and
// outside Local there is no mask being looked at. Without this the
// sky stayed red through Repair and back in Photo, a mode that had
// been left leaving its overlay behind (ui-navigation.md D-N1).
if !self.show_overlay {
return None;
}
let layers: Vec<RevealedLayer> = self let layers: Vec<RevealedLayer> = self
.graph .graph
.masks() .masks()
@@ -6986,6 +6995,27 @@ mod tests {
/// — that the coverage is present, that it round-trips as bytes — would /// — that the coverage is present, that it round-trips as bytes — would
/// still pass if the raster came back at the wrong scale, upside down, or /// still pass if the raster came back at the wrong scale, upside down, or
/// a threshold out. /// a threshold out.
#[test]
fn a_shown_mask_is_only_shown_while_masking() {
let Some(ctx) = headless() else { return };
let mut s = session_with_a_left_half_subject(&ctx);
let id = s.add_subject_mask(0).expect("a subject layer");
s.set_overlay(true);
s.set_mask_shown(&id, true);
assert!(s.any_mask_shown(), "lit, in Local mode");
// Leaving the mode — what `on_mode_picked` does for Photo and Spots.
s.set_overlay(false);
assert!(
!s.any_mask_shown(),
"the tint belongs to the mode, not to the photograph"
);
assert!(s.mask_shown(&id), "the eye itself is remembered");
s.set_overlay(true);
assert!(s.any_mask_shown(), "and is lit again on return");
}
#[test] #[test]
fn a_stored_mask_renders_exactly_what_the_model_rendered() { fn a_stored_mask_renders_exactly_what_the_model_rendered() {
let Some(ctx) = headless() else { return }; let Some(ctx) = headless() else { return };
+17 -2
View File
@@ -116,6 +116,12 @@ pub fn faces_outstanding(
ORDER BY i.id", ORDER BY i.id",
faces::embedder_sql("fi.model_id") faces::embedder_sql("fi.model_id")
))?; ))?;
// The store's index in one read rather than a probe per image; see
// `audit`, which splits the same list the same way.
let held = store.held(FACE_TIER).unwrap_or_else(|e| {
log::warn!("faces: reading the thumbnail index: {e}");
Default::default()
});
let rows = stmt let rows = stmt
.query_map([faces::embedder_of(model_id)], |r| { .query_map([faces::embedder_of(model_id)], |r| {
Ok(FaceRequest { Ok(FaceRequest {
@@ -134,7 +140,7 @@ pub fn faces_outstanding(
// a whole-library button that could only reach photographs the user had // a whole-library button that could only reach photographs the user had
// personally zoomed into. `repairs::spawn` is that // personally zoomed into. `repairs::spawn` is that
// requirement implemented; this one is the local-only variant. // requirement implemented; this one is the local-only variant.
.filter(|req| store.contains(req.file_id, FACE_TIER)) .filter(|req| held.contains(&req.file_id))
.collect(); .collect();
Ok(rows) Ok(rows)
} }
@@ -270,12 +276,21 @@ pub fn audit(
)", )",
faces::embedder_sql("fi.model_id") faces::embedder_sql("fi.model_id")
))?; ))?;
// One read of the store's index, not one probe per outstanding image:
// `contains` answers the same question, and asked four thousand times
// it cost more than every query above put together. A store whose index
// cannot be read is treated as holding nothing, which is what `contains`
// reports for it too.
let held = store.held(FACE_TIER).unwrap_or_else(|e| {
log::warn!("faces: reading the thumbnail index: {e}");
Default::default()
});
let (mut ready, mut awaiting) = (0u64, 0u64); let (mut ready, mut awaiting) = (0u64, 0u64);
for file_id in stmt for file_id in stmt
.query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))? .query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))?
.filter_map(Result::ok) .filter_map(Result::ok)
{ {
if store.contains(file_id as u64, FACE_TIER) { if held.contains(&(file_id as u64)) {
ready += 1; ready += 1;
} else { } else {
awaiting += 1; awaiting += 1;
+38 -28
View File
@@ -216,7 +216,9 @@ pub struct IdentityView {
/// Filtered rather than deleted, because this is a screen being drawn and not a /// Filtered rather than deleted, because this is a screen being drawn and not a
/// catalog being repaired. A row is withheld; nothing is lost, a sync cannot /// catalog being repaired. A row is withheld; nothing is lost, a sync cannot
/// resurrect what was never removed, and the prune stays the one place that /// resurrect what was never removed, and the prune stays the one place that
/// decides these are disposable. /// decides these are disposable. The filter is the catalog's
/// (`faces::people_in_use`), in the query, so the rows withheld are never
/// read or sorted either.
/// ///
/// An empty group with a *name* still shows. That one is not debris, it is the /// An empty group with a *name* still shows. That one is not debris, it is the
/// symptom of a real failure — a named person whose faces were regrouped out /// symptom of a real failure — a named person whose faces were regrouped out
@@ -228,11 +230,8 @@ pub fn load_people(
model_id: &str, model_id: &str,
) -> Result<IdentityView, dr_catalog::CatalogError> { ) -> Result<IdentityView, dr_catalog::CatalogError> {
let conn = catalog.connection(); let conn = catalog.connection();
let people = faces::people(conn)? let people = faces::people_in_use(conn)?
.into_iter() .into_iter()
.filter(|p| {
p.confirmed_faces + p.suggested_faces > 0 || !p.name.trim().is_empty() || p.ignored
})
.map(|p| PersonRow { .map(|p| PersonRow {
id: p.id, id: p.id,
name: p.name, name: p.name,
@@ -247,7 +246,7 @@ pub fn load_people(
people, people,
selected: None, selected: None,
faces: Vec::new(), faces: Vec::new(),
unassigned: faces::unassigned(conn, model_id)?.len(), unassigned: faces::count_unassigned(conn, model_id)? as usize,
calibrated: faces::calibration(conn, model_id)?.is_some_and(|(c, _)| c.valid), calibrated: faces::calibration(conn, model_id)?.is_some_and(|(c, _)| c.valid),
}) })
} }
@@ -261,18 +260,38 @@ pub fn load_people(
/// Crops come from the proxy the grid already built. An image whose proxy has /// Crops come from the proxy the grid already built. An image whose proxy has
/// been evicted yields a cell with no crop rather than being dropped — the face /// been evicted yields a cell with no crop rather than being dropped — the face
/// is still real, still counted, and still confirmable from its filename. /// is still real, still counted, and still confirmable from its filename.
///
/// `cut` is whatever the previous load of this grid had already decoded,
/// keyed by face, and is consumed: a crop found there is moved into the new
/// cell and neither read from the catalog nor decoded again. A confirm or a
/// reject changes one face's row and redraws the whole grid, and without this
/// the redraw re-read four megabytes of JPEG and decoded seven hundred of
/// them — 300 ms on the reference library's largest person, per click, to
/// arrive at pixels the screen was already showing. Face ids are global, so
/// a map from another person's grid is merely useless, never wrong.
pub fn load_faces( pub fn load_faces(
catalog: &Catalog, catalog: &Catalog,
store: &ThumbStore, store: &ThumbStore,
person: PersonId, person: PersonId,
mut cut: std::collections::HashMap<FaceId, FaceCrop>,
) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> { ) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> {
let conn = catalog.connection(); let conn = catalog.connection();
let rows = faces::for_person(conn, person, true)?; let rows = faces::for_person(conn, person, true)?;
// The crops kept at detection time, in one query. Where a face has one this // The crops kept at detection time, in one query — but only when a face
// is the whole cost of drawing it — no proxy, no full-size JPEG decode, and // is not already in hand. The common redraw has every face cached and
// no dependence on the thumbnail cache still holding the photograph. // skips the blob read entirely; a face the cache lacks (a regroup, a
let stored = faces::crops_for_person(conn, person, true)?; // fresh sweep) costs the read for the whole person once, and it is
// cached from then on.
//
// Where a face has a stored crop this is the whole cost of drawing it —
// no proxy, no full-size JPEG decode, and no dependence on the thumbnail
// cache still holding the photograph.
let stored = if rows.iter().all(|f| cut.contains_key(&f.id)) {
Default::default()
} else {
faces::crops_for_person(conn, person, true)?
};
// The fallback path, for faces indexed before crops were kept. One decode // The fallback path, for faces indexed before crops were kept. One decode
// per *image*, not per face: a group photograph holding six faces of one // per *image*, not per face: a group photograph holding six faces of one
@@ -283,7 +302,10 @@ pub fn load_faces(
let mut out = Vec::with_capacity(rows.len()); let mut out = Vec::with_capacity(rows.len());
for f in rows { for f in rows {
let crop = match stored.get(&f.id).and_then(|b| decode_crop(b)) { let crop = match cut
.remove(&f.id)
.or_else(|| stored.get(&f.id).and_then(|b| decode_crop(b)))
{
Some(c) => Some(c), Some(c) => Some(c),
None => { None => {
let entry = decoded let entry = decoded
@@ -557,15 +579,7 @@ pub fn rename(
/// right — the common case for a well-photographed person — should cost one /// right — the common case for a well-photographed person — should cost one
/// click, not forty. /// click, not forty.
pub fn confirm_all(catalog: &Catalog, person: PersonId) -> Result<usize, dr_catalog::CatalogError> { pub fn confirm_all(catalog: &Catalog, person: PersonId) -> Result<usize, dr_catalog::CatalogError> {
let conn = catalog.connection(); faces::confirm_all(catalog.connection(), person).map(|n| n as usize)
let mut n = 0;
for f in faces::for_person(conn, person, true)? {
if !f.confirmed {
faces::confirm(conn, f.id, person)?;
n += 1;
}
}
Ok(n)
} }
/// The user says this face is this person. /// The user says this face is this person.
@@ -657,7 +671,7 @@ pub fn preview_split(
.map(|(c, _)| c) .map(|(c, _)| c)
.unwrap_or_default(); .unwrap_or_default();
let cells = load_faces(catalog, store, person)?; let cells = load_faces(catalog, store, person, Default::default())?;
if cells.len() < 2 { if cells.len() < 2 {
return Ok(vec![cells]); return Ok(vec![cells]);
} }
@@ -710,13 +724,9 @@ pub fn split_off(
) -> Result<PersonId, dr_catalog::CatalogError> { ) -> Result<PersonId, dr_catalog::CatalogError> {
let conn = catalog.connection(); let conn = catalog.connection();
let new_person = faces::create_person(conn, name.trim())?; let new_person = faces::create_person(conn, name.trim())?;
for &face in members { // Rejected from `from` and confirmed onto the new person, in one
// Rejecting first is what stops the split being undone: without it the // transaction rather than two per face: `faces::reassign` says why both.
// next pass sees a face that looks like `from` and suggests it straight faces::reassign(conn, members, from, new_person)?;
// back, and the user's correction becomes an argument they keep having.
faces::reject(conn, face, from)?;
faces::confirm(conn, face, new_person)?;
}
Ok(new_person) Ok(new_person)
} }
+52 -14
View File
@@ -138,6 +138,26 @@ impl IdentityController {
} }
} }
/// What a reload has to re-read, named by what just changed.
///
/// The coverage line is the expensive half of a redraw: it lists every
/// repair's outstanding images to count them, which is several scans of the
/// whole `images` table (`crate::repairs::counts`). A confirm, a reject, a
/// rename or a merge moves faces between people and cannot change how many
/// images have been indexed, so a redraw for one of those must not pay for
/// it — that was 200 ms of the half-second every click on the face grid used
/// to cost on the reference library.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Changed {
/// Who the faces belong to. The rail and the grid are re-read; the
/// coverage line is left as it was.
Identities,
/// Which faces exist: a sweep finished or was stopped, the face data was
/// deleted, the screen was opened onto a catalog another device may have
/// indexed. Everything is re-read, the coverage line included.
Library,
}
/// Push the people rail and the face grid into the window. /// Push the people rail and the face grid into the window.
/// ///
/// **Draws with the portraits it already has and cuts the rest afterwards.** /// **Draws with the portraits it already has and cuts the rest afterwards.**
@@ -154,6 +174,7 @@ pub fn refresh(
store: Option<Rc<ThumbStore>>, store: Option<Rc<ThumbStore>>,
model_id: &str, model_id: &str,
eyes: bool, eyes: bool,
changed: Changed,
) { ) {
let borrow = catalog.borrow(); let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { let Some(cat) = borrow.as_ref() else {
@@ -239,7 +260,17 @@ pub fn refresh(
match (ctl.selected.get(), store.as_deref()) { match (ctl.selected.get(), store.as_deref()) {
(Some(person), Some(store)) => { (Some(person), Some(store)) => {
let cells = identity::load_faces(cat, store, person).unwrap_or_else(|e| { // The crops the grid is showing now, handed over to be reused
// rather than decoded again — see `load_faces`. Drained, not
// cloned: a crop is 64 KB of pixels and the largest groups hold
// hundreds.
let cut = ctl
.faces
.borrow_mut()
.drain(..)
.filter_map(|c| Some((c.face, c.crop?)))
.collect();
let cells = identity::load_faces(cat, store, person, cut).unwrap_or_else(|e| {
log::warn!("identity: reading faces: {e}"); log::warn!("identity: reading faces: {e}");
Vec::new() Vec::new()
}); });
@@ -274,7 +305,9 @@ pub fn refresh(
window.set_identity_picked(ctl.picked.borrow().len() as i32); window.set_identity_picked(ctl.picked.borrow().len() as i32);
drop(borrow); drop(borrow);
if changed == Changed::Library {
refresh_coverage(window, catalog, store.as_deref(), model_id, eyes); refresh_coverage(window, catalog, store.as_deref(), model_id, eyes);
}
// Last, so a portrait cannot delay anything above it. // Last, so a portrait cannot delay anything above it.
if let Some(store) = store { if let Some(store) = store {
@@ -628,8 +661,10 @@ pub fn wire<S, M, P>(
// The availability closure is an argument rather than named in the // The availability closure is an argument rather than named in the
// body: macro hygiene would bind the name to this scope's `Rc`, which the // body: macro hygiene would bind the name to this scope's `Rc`, which the
// first `move` closure would then take with it. // first `move` closure would then take with it.
// The last argument is a `Changed` variant, named bare so the call
// stays on one line.
macro_rules! reload { macro_rules! reload {
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr) => { ($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr, $changed:ident) => {
refresh( refresh(
&$w, &$w,
&$ctl, &$ctl,
@@ -637,6 +672,7 @@ pub fn wire<S, M, P>(
$store(), $store(),
&model_id(&$settings), &model_id(&$settings),
$eyes(), $eyes(),
Changed::$changed,
) )
}; };
} }
@@ -662,7 +698,7 @@ pub fn wire<S, M, P>(
// A fact about the filesystem, so it is re-checked on every open // A fact about the filesystem, so it is re-checked on every open
// rather than cached: the user may have just put the models there. // rather than cached: the user may have just put the models there.
w.set_identity_model_missing(models_present().is_none()); w.set_identity_model_missing(models_present().is_none());
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Library);
}); });
} }
@@ -702,7 +738,7 @@ pub fn wire<S, M, P>(
// The offer was about the person being navigated away from. Left // The offer was about the person being navigated away from. Left
// up, its "Merge" would fold whoever is selected *now*. // up, its "Merge" would fold whoever is selected *now*.
clear_merge_offer(&w, &ctl); clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
reset_name_field(&w); reset_name_field(&w);
}); });
} }
@@ -748,7 +784,7 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: looking for a namesake: {e}"), Err(e) => log::warn!("identity: looking for a namesake: {e}"),
} }
} }
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
// `rename` trims; the field should show what was actually stored // `rename` trims; the field should show what was actually stored
// rather than the spacing the user happened to type. // rather than the spacing the user happened to type.
reset_name_field(&w); reset_name_field(&w);
@@ -786,7 +822,7 @@ pub fn wire<S, M, P>(
} }
} }
clear_merge_offer(&w, &ctl); clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
reset_name_field(&w); reset_name_field(&w);
}); });
} }
@@ -832,7 +868,7 @@ pub fn wire<S, M, P>(
log::warn!("identity: confirm: {e}"); log::warn!("identity: confirm: {e}");
} }
} }
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
}); });
} }
@@ -853,7 +889,7 @@ pub fn wire<S, M, P>(
log::warn!("identity: reject: {e}"); log::warn!("identity: reject: {e}");
} }
} }
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
}); });
} }
@@ -897,7 +933,7 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: confirm all: {e}"), Err(e) => log::warn!("identity: confirm all: {e}"),
} }
} }
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
}); });
} }
@@ -931,7 +967,7 @@ pub fn wire<S, M, P>(
} }
} }
ctl.clear_picks(); ctl.clear_picks();
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
}); });
} }
@@ -1091,6 +1127,7 @@ pub fn wire<S, M, P>(
store_tick(), store_tick(),
&model_id(&settings_tick), &model_id(&settings_tick),
eyes_tick(), eyes_tick(),
Changed::Identities,
); );
} }
}, },
@@ -1268,6 +1305,7 @@ pub fn wire<S, M, P>(
store_tick(), store_tick(),
&model_id(&settings_tick), &model_id(&settings_tick),
eyes_tick(), eyes_tick(),
Changed::Library,
); );
} }
}, },
@@ -1306,7 +1344,7 @@ pub fn wire<S, M, P>(
a.finish("stopped"); a.finish("stopped");
} }
w.set_identity_indexing(false); w.set_identity_indexing(false);
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Library);
}); });
} }
@@ -1351,7 +1389,7 @@ pub fn wire<S, M, P>(
ctl.selected.set(None); ctl.selected.set(None);
ctl.clear_picks(); ctl.clear_picks();
} }
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
}); });
} }
@@ -1365,7 +1403,7 @@ pub fn wire<S, M, P>(
window.on_identity_toggle_show_ignored(move || { window.on_identity_toggle_show_ignored(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
ctl.show_ignored.set(!ctl.show_ignored.get()); ctl.show_ignored.set(!ctl.show_ignored.get());
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
}); });
} }
@@ -1387,7 +1425,7 @@ pub fn wire<S, M, P>(
ctl.selected.set(None); ctl.selected.set(None);
ctl.clear_picks(); ctl.clear_picks();
ctl.covers.borrow_mut().clear(); ctl.covers.borrow_mut().clear();
reload!(w, ctl, catalog, store, settings, eyes_available); reload!(w, ctl, catalog, store, settings, eyes_available, Library);
}); });
} }
} }
+79 -10
View File
@@ -218,6 +218,43 @@ impl LaunchModel {
self.session().map(|s| s.root.clone()).unwrap_or_default() self.session().map(|s| s.root.clone()).unwrap_or_default()
} }
/// Whether a root has been settled on, including the top level itself.
///
/// Three ways to have one: a subfolder is named; the picker confirmed
/// the top level, which `Account::root_chosen` records because the
/// empty string cannot; or the endpoint is a folder, whose top level is
/// the library by definition.
pub fn root_is_chosen(&self) -> bool {
self.session()
.is_some_and(|s| !s.root.is_empty() || s.root_chosen)
|| self.endpoint_is_library()
}
/// What the signed-in section shows for the root: empty when nothing is
/// chosen, `/` for the top level, else the path.
pub fn library_root_label(&self) -> String {
let root = self.library_root();
if root.is_empty() && self.root_is_chosen() {
"/".to_string()
} else {
root
}
}
/// Whether the endpoint itself is the library.
///
/// A folder account has no sub-root to choose: the directory the user
/// typed is the whole library, and asking them to pick a "library
/// folder" inside it a second time reads as though the first answer was
/// not taken. A cloud account is the opposite — its endpoint is an
/// entire server-side tree, and a root inside it is required (see
/// [`can_open_library`](Self::can_open_library)). Keyed on the absence
/// of a login, the same fact [`prefill`] keys on, rather than on the
/// connector's id.
pub fn endpoint_is_library(&self) -> bool {
self.session().is_some_and(|s| s.login.is_empty())
}
/// The login URL while approval is pending. /// The login URL while approval is pending.
pub fn login_url(&self) -> String { pub fn login_url(&self) -> String {
match &self.state { match &self.state {
@@ -228,11 +265,13 @@ impl LaunchModel {
/// Whether "Open library" should be clickable. /// Whether "Open library" should be clickable.
/// ///
/// Requires a signed-in account *and* a chosen folder: opening without one /// A cloud account requires a signed-in session *and* a chosen folder:
/// would scan the whole account, which on a real library is thousands of /// opening without one would scan the whole account, which on a real
/// directories the user did not ask for. /// library is thousands of directories the user did not ask for. Chosen
/// is the operative word, not non-empty — the top level is a legitimate
/// choice, and a folder library is the whole folder by definition.
pub fn can_open_library(&self) -> bool { pub fn can_open_library(&self) -> bool {
self.is_signed_in() && !self.library_root().is_empty() self.is_signed_in() && self.root_is_chosen()
} }
/// What the app should do on startup. /// What the app should do on startup.
@@ -386,6 +425,9 @@ impl LaunchModel {
let path = self.browser.as_ref()?.path.clone(); let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone(); let mut session = self.session()?.clone();
session.root = path; session.root = path;
// Even when `path` is the top level: that is a choice, and the
// empty string alone would read as none having been made.
session.root_chosen = true;
self.browser = None; self.browser = None;
self.state = LaunchState::SignedIn { self.state = LaunchState::SignedIn {
session: session.clone(), session: session.clone(),
@@ -547,17 +589,32 @@ mod tests {
// Everything past sign-in is backend-neutral, and this is the check // Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here. // that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default(); let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026")); m.signed_in(folder_with_root("2026"));
assert!(m.is_signed_in());
assert!(m.can_open_library()); assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary); assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026"); assert_eq!(m.account_label(), "/mnt/photos/2026");
} }
#[test]
fn a_folder_is_the_whole_library_without_choosing_a_root() {
// The directory typed on the launch screen is the answer to "which
// folder"; a second question with the same name is what confused
// the first person to open one. A server account still needs a
// root, because its endpoint is the entire tree.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.endpoint_is_library());
assert!(m.can_open_library(), "the folder itself is the library");
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
m.signed_in(session_with_root(""));
assert!(!m.endpoint_is_library());
assert!(!m.can_open_library(), "a server account needs a root");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test] #[test]
fn format_toggles_apply_and_out_of_range_is_ignored() { fn format_toggles_apply_and_out_of_range_is_ignored() {
let mut m = LaunchModel::default(); let mut m = LaunchModel::default();
@@ -665,13 +722,25 @@ mod tests {
#[test] #[test]
fn confirming_at_the_root_selects_the_whole_account() { fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level. // Legitimate: a user may keep everything at the top level — and it
// must then open. It used to be recorded as an empty root, which
// is indistinguishable from no root, so "Open library" stayed
// disabled after the picker had plainly been answered.
let mut m = LaunchModel::default(); let mut m = LaunchModel::default();
m.signed_in(session_with_root("")); m.signed_in(session_with_root(""));
assert!(!m.can_open_library(), "nothing chosen yet");
assert_eq!(m.library_root_label(), "");
m.open_browser(); m.open_browser();
let session = m.choose_current_folder().expect("a session"); let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, ""); assert_eq!(session.root, "");
assert!(
session.root_chosen,
"the choice survives to the config file"
);
assert!(m.can_open_library());
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
} }
#[test] #[test]
+2 -1
View File
@@ -44,7 +44,8 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_signed_in(m.is_signed_in()); window.set_launch_signed_in(m.is_signed_in());
window.set_launch_account(m.account_label().into()); window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into()); window.set_launch_root(m.library_root_label().into());
window.set_launch_endpoint_is_library(m.endpoint_is_library());
window.set_launch_server(m.server_url.clone().into()); window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into()); window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy()); window.set_launch_busy(m.is_busy());
+33 -4
View File
@@ -3230,7 +3230,7 @@ async fn fetch_preview(
// The same bytes carry EXIF. Reading it here is free — the alternative is // The same bytes carry EXIF. Reading it here is free — the alternative is
// a second 256 KB fetch per image over the whole library. // a second 256 KB fetch per image over the whole library.
if req.needs_metadata { if req.needs_metadata {
collect_metadata(&header, req, found_metadata); collect_metadata(backend, &id, &header, req, found_metadata).await;
} }
// Read unconditionally, unlike the rest of the EXIF above: `needs_metadata` // Read unconditionally, unlike the rest of the EXIF above: `needs_metadata`
@@ -3350,10 +3350,39 @@ pub(crate) fn store_thumbnail(
/// ///
/// Shared by both paths — the thumbnail fetch, which gets the header anyway, /// Shared by both paths — the thumbnail fetch, which gets the header anyway,
/// and the header-only pass for images whose pixels were already cached. /// and the header-only pass for images whose pixels were already cached.
fn collect_metadata(header: &[u8], req: &ThumbnailRequest, out: &mut Vec<MetadataFound>) { async fn collect_metadata(
let Ok(md) = dr_decode::metadata(header) else { backend: &dyn RemoteBackend,
id: &RemoteId,
header: &[u8],
req: &ThumbnailRequest,
out: &mut Vec<MetadataFound>,
) {
let md = match dr_decode::metadata(header) {
Ok(md) => md,
Err(first) => {
// A file whose IFDs follow its pixels — the linear DNG a merge
// writes — has nothing for the decoder in its head but a
// pointer. Its structure is a few kilobytes at the end; fetch
// that and read the two ranges together, rather than leave the
// composite undated at the end of the grid.
let Some(at) = dr_decode::trailing_ifd(header).filter(|at| *at < req.size) else {
return; return;
}; };
match backend.get(id, Some(at..req.size)).await {
Ok(tail) => match dr_decode::metadata_split(header, &tail, at) {
Ok(md) => md,
Err(e) => {
log::debug!("metadata: {}: head {first}; head and tail {e}", req.path);
return;
}
},
Err(e) => {
log::debug!("metadata: {}: tail not fetched: {e}", req.path);
return;
}
}
}
};
out.push(MetadataFound { out.push(MetadataFound {
image_id: req.image_id, image_id: req.image_id,
captured_at: md.captured_at, captured_at: md.captured_at,
@@ -3442,7 +3471,7 @@ async fn read_metadata_only(
for attempt in 1..=ATTEMPTS { for attempt in 1..=ATTEMPTS {
match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await { match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await {
Ok(header) => { Ok(header) => {
collect_metadata(&header, req, found); collect_metadata(backend, &id, &header, req, found).await;
return true; return true;
} }
Err(e) if e.is_transient() && attempt < ATTEMPTS => { Err(e) if e.is_transient() && attempt < ATTEMPTS => {
+127 -37
View File
@@ -984,14 +984,7 @@ pub fn open(
window.set_library_status("Starting…".into()); window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a // Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan. // scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label( window.set_library_root_label(library_root_label(&conn.account).into());
if conn.account.root.is_empty() {
format!("{} · whole account", conn.account.user_id)
} else {
format!("{}/{}", conn.account.user_id, conn.account.root)
}
.into(),
);
// An empty filter would walk the whole tree and match nothing, which looks // An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead. // exactly like a broken scan. Say so instead.
@@ -2831,38 +2824,17 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Month headings. The grid is ordered by capture time, so without these a // Month headings. The grid is ordered by capture time, so without these a
// wall of thumbnails gives no sense of *when* you are looking — the // wall of thumbnails gives no sense of *when* you are looking — the
// sidebar says it, but only if you consult it. // sidebar says it, but only if you consult it.
//
// Marked on the cell that both begins a month and begins a row: a heading
// stranded mid-row would label the cells to its left, which belong to the
// previous month.
let columns = window.get_library_columns().max(1) as usize; let columns = window.get_library_columns().max(1) as usize;
let mut previous_month: Option<(i64, i64)> = None; let headings = period_headings(
let headings: Vec<String> = cells cells.iter().map(|c| {
.iter() c.captured_at.map(|t| {
.enumerate()
.map(|(i, c)| {
let Some(t) = c.captured_at else {
return String::new();
};
let (y, m, _, _) = civil_from_unix(t); let (y, m, _, _) = civil_from_unix(t);
let is_new = previous_month != Some((y, m)); (y, m)
previous_month = Some((y, m));
// A heading is drawn above its row, so it can only sit on a cell
// that begins one — a heading stranded mid-row would appear to
// label the cells to its left, which belong to the month before.
//
// The first cell of the window always carries one, whichever
// column it lands in: a scrolled window would otherwise show no
// date at all until the next month began.
let begins_row = (i + offset) % columns == 0;
if i == 0 || (is_new && begins_row) {
format!("{} {y}", month_name(m))
} else {
String::new()
}
}) })
.collect(); }),
offset,
columns,
);
// What the outgoing model is still holding — see [`hold_thumbnails`]. // What the outgoing model is still holding — see [`hold_thumbnails`].
let held = { let held = {
@@ -7158,10 +7130,128 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
} }
} }
/// The heading each cell of a window carries: a month name on some, nothing
/// on most.
///
/// A heading is drawn above its row, so it can only sit on a cell that
/// begins one — stranded mid-row it would appear to label the cells to its
/// left, which belong to the month before. So a row carries a heading when
/// its first cell's month is not the one last announced. A month that begins
/// mid-row is therefore announced at the *next* row it opens, which is one
/// row late and still right about every cell under it. It used to be
/// announced only if it happened to begin a row, which at seven columns
/// meant most months were never named at all and the one heading on screen
/// was wrong about everything below its first row.
///
/// The first cell of the window always carries one, whichever column it
/// lands in: a scrolled window would otherwise show no date at all until
/// the next month began.
fn period_headings(
months: impl Iterator<Item = Option<(i64, i64)>>,
offset: usize,
columns: usize,
) -> Vec<String> {
let columns = columns.max(1);
let mut announced: Option<(i64, i64)> = None;
months
.enumerate()
.map(|(i, month)| {
let Some((y, m)) = month else {
return String::new();
};
let begins_row = (i + offset).is_multiple_of(columns);
if i == 0 || (begins_row && announced != Some((y, m))) {
announced = Some((y, m));
format!("{} {y}", month_name(m))
} else {
String::new()
}
})
.collect()
}
/// The line in the library header that says what is being catalogued.
///
/// For an account with a user, the user and the chosen subtree, or the
/// whole account when none was chosen. For a folder there is no user and
/// the endpoint is the library, so the folder's own name: `library`, or
/// `library/2026` when narrowed — never " · whole account", which is a
/// sentence about a server said of a directory.
fn library_root_label(account: &Account) -> String {
if account.login.is_empty() {
let folder = std::path::Path::new(&account.endpoint)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| account.endpoint.clone());
if account.root.is_empty() {
folder
} else {
format!("{folder}/{}", account.root)
}
} else if account.root.is_empty() {
format!("{} · whole account", account.user_id)
} else {
format!("{}/{}", account.user_id, account.root)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_month_that_begins_mid_row_is_still_announced() {
// Four columns; August starts in the second column of the second
// row and October in the third of the third. Neither begins a row,
// and both must still be named — on the next row each opens.
let mar = Some((2024, 3));
let aug = Some((2024, 8));
let oct = Some((2025, 10));
let months = [
mar, mar, mar, mar, //
mar, aug, aug, aug, //
aug, aug, oct, oct, //
oct,
];
let h = period_headings(months.into_iter(), 0, 4);
assert_eq!(h[0], "March 2024");
assert_eq!(h[4], "", "still March at the top of row two");
assert_eq!(h[5], "", "never mid-row");
assert_eq!(h[8], "August 2024");
assert_eq!(h[12], "October 2025");
assert_eq!(h.iter().filter(|s| !s.is_empty()).count(), 3);
}
#[test]
fn a_scrolled_window_names_its_first_cell_wherever_it_lands() {
let jan = Some((2026, 1));
let feb = Some((2026, 2));
// Offset 2 into a 4-column grid: the window's first cell is in the
// third column, and the row after it begins a new month.
let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4);
assert_eq!(h[0], "January 2026");
assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2");
assert_eq!(h[3], "");
// Undated cells carry nothing and announce nothing.
let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4);
assert_eq!(h[0], "");
assert_eq!(h[4], "January 2026");
}
#[test]
fn the_header_names_a_folder_library_by_its_folder() {
let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");
assert_eq!(library_root_label(&folder), "library");
folder.root = "2026".into();
assert_eq!(library_root_label(&folder), "library/2026");
let mut cloud =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
assert_eq!(library_root_label(&cloud), "duncan · whole account");
cloud.root = "PhotosRaw".into();
assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw");
}
#[test] #[test]
fn a_scrub_fraction_interpolates_within_the_span() { fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance // The point of going fractional: a slow drag must advance
+103 -10
View File
@@ -181,7 +181,11 @@ impl FillSettings {
params: dr_pano::FillParams { params: dr_pano::FillParams {
coarse: self.params.coarse, coarse: self.params.coarse,
band: px(self.params.band).max(8), band: px(self.params.band).max(8),
mirror_depth: px(self.params.mirror_depth), mirror_depth: if self.params.mirror_depth == 0 {
0
} else {
px(self.params.mirror_depth)
},
feather: if self.params.feather == 0 { feather: if self.params.feather == 0 {
0 0
} else { } else {
@@ -218,6 +222,17 @@ pub enum MergeEvent {
Cancelled, Cancelled,
} }
impl MergeEvent {
/// Whether this is the job's last word: after one of these the worker
/// has nothing more to say, so its channel closing is expected.
pub fn is_final(&self) -> bool {
matches!(
self,
MergeEvent::Done { .. } | MergeEvent::Failed(_) | MergeEvent::Cancelled
)
}
}
/// The alignment, described for a panel. /// The alignment, described for a panel.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AlignmentReport { pub struct AlignmentReport {
@@ -255,10 +270,29 @@ pub fn run(
let send = |e: MergeEvent| { let send = |e: MergeEvent| {
let _ = events.send(e); let _ = events.send(e);
}; };
match run_inner(&ctx, &request, &events, &decision, &cancel) { // Caught rather than allowed to unwind the thread: wgpu reports a device
Ok(Some(done)) => send(done), // that has run out of memory by panicking, and a twelve-frame merge on a
Ok(None) => send(MergeEvent::Cancelled), // GPU another process is using is exactly where that happens. Uncaught,
Err(e) => send(MergeEvent::Failed(e)), // the thread died, 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. The panic hook still writes that record;
// this is what puts the reason on the page.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_inner(&ctx, &request, &events, &decision, &cancel)
}));
match result {
Ok(Ok(Some(done))) => send(done),
Ok(Ok(None)) => send(MergeEvent::Cancelled),
Ok(Err(e)) => send(MergeEvent::Failed(e)),
Err(panic) => {
let detail = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panicked with a non-string payload".to_string());
log::error!("merge worker panicked: {detail}");
send(MergeEvent::Failed(format!("internal error: {detail}")));
}
} }
} }
@@ -518,7 +552,16 @@ fn run_inner(
let black = first.raw.black_level[0]; let black = first.raw.black_level[0];
let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1); let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1);
let profile = dng_profile(first, white_level); let profile = dng_profile(first, white_level);
let (_, carried) = crate::export::header_for_file(&first.meta); let (_, mut carried) = crate::export::header_for_file(&first.meta);
// The composite is dated at the middle of its sweep — the mean of the
// frames' capture times — so it sorts among the frames it was made
// from, not at the first of them and not at the moment of the merge.
// The zone is the first frame's; a sweep does not cross one.
let stamps: Vec<i64> = frames.iter().filter_map(|f| f.meta.captured_at).collect();
if !stamps.is_empty() {
let sum: i128 = stamps.iter().map(|&t| t as i128).sum();
carried.captured_at = Some((sum / stamps.len() as i128) as i64);
}
let output = MergeOutput { let output = MergeOutput {
projection, projection,
@@ -1263,12 +1306,18 @@ fn unused_name(dir: &Path, name: &str) -> PathBuf {
} }
/// Drain everything a job has said so far. /// Drain everything a job has said so far.
pub fn drain(rx: &Receiver<MergeEvent>) -> Vec<MergeEvent> { /// Everything the worker has said since the last call, and whether it has
/// hung up — a closed channel after nothing [`MergeEvent::is_final`] is a
/// worker that died mid-job.
pub fn drain(rx: &Receiver<MergeEvent>) -> (Vec<MergeEvent>, bool) {
let mut out = Vec::new(); let mut out = Vec::new();
while let Ok(e) = rx.try_recv() { loop {
out.push(e); match rx.try_recv() {
Ok(e) => out.push(e),
Err(std::sync::mpsc::TryRecvError::Empty) => return (out, false),
Err(std::sync::mpsc::TryRecvError::Disconnected) => return (out, true),
}
} }
out
} }
/// The filler's input as the debugging example reads it: `input.ppm`, the /// The filler's input as the debugging example reads it: `input.ppm`, the
@@ -1291,3 +1340,47 @@ fn dump_fill_input(
pgm.extend(known.iter().map(|&k| if k { 255u8 } else { 0 })); pgm.extend(known.iter().map(|&k| if k { 255u8 } else { 0 }));
std::fs::write(dir.join("known.pgm"), pgm) std::fs::write(dir.join("known.pgm"), pgm)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() {
// The failure the page could not see: a panic drops the sender with
// no final event, and `try_recv`'s Disconnected used to be folded
// into "nothing new".
let (tx, rx) = std::sync::mpsc::channel();
tx.send(MergeEvent::Progress {
stage: "Reading",
done: 1,
total: 12,
})
.unwrap();
let (events, gone) = drain(&rx);
assert_eq!(events.len(), 1);
assert!(!gone, "the sender is still alive");
drop(tx);
let (events, gone) = drain(&rx);
assert!(events.is_empty());
assert!(gone, "and now it is not");
}
#[test]
fn a_job_that_said_its_last_word_is_not_a_dead_worker() {
let (tx, rx) = std::sync::mpsc::channel();
tx.send(MergeEvent::Failed("out of memory".into())).unwrap();
drop(tx);
let (events, gone) = drain(&rx);
assert!(gone);
assert!(events.iter().any(MergeEvent::is_final), "Failed is final");
assert!(MergeEvent::Cancelled.is_final());
assert!(!MergeEvent::Progress {
stage: "x",
done: 0,
total: 0
}
.is_final());
}
}
+12 -2
View File
@@ -339,7 +339,7 @@ pub fn wire<S, C, F>(
"erosion" => f.erosion = v, "erosion" => f.erosion = v,
"coarse" => f.params.coarse = v.max(1), "coarse" => f.params.coarse = v.max(1),
"band" => f.params.band = v.max(8), "band" => f.params.band = v.max(8),
"mirror" => f.params.mirror_depth = v.max(1), "mirror" => f.params.mirror_depth = v,
"feather" => f.params.feather = v, "feather" => f.params.feather = v,
_ => return, _ => return,
} }
@@ -487,11 +487,21 @@ fn chip_projection(i: i32) -> Option<dr_pano::Projection> {
/// Take everything the job has said and reflect it on the page. /// Take everything the job has said and reflect it on the page.
fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&AppWindow)>) { fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&AppWindow)>) {
let events = { let (events, gone) = {
let job = ctl.job.borrow(); let job = ctl.job.borrow();
let Some(job) = job.as_ref() else { return }; let Some(job) = job.as_ref() else { return };
merge::drain(&job.rx) merge::drain(&job.rx)
}; };
// The worker is the only sender, so a closed channel with nothing final
// said is a worker that died without saying anything — the one way
// `merge::run` cannot report, since reporting is what it was doing.
// Without this the page stayed on "Stop" for ever.
let mut events = events;
if gone && !events.iter().any(MergeEvent::is_final) {
events.push(MergeEvent::Failed(
"the merge stopped unexpectedly; the log has the reason".into(),
));
}
if events.is_empty() { if events.is_empty() {
return; return;
} }
+177 -18
View File
@@ -82,10 +82,28 @@ pub enum Needs {
/// image still owes it. Evaluated for the list, for the count, and again /// image still owes it. Evaluated for the list, for the count, and again
/// per image before the handler runs. /// per image before the handler runs.
Sql(String), Sql(String),
/// SQL over `faces f`, true where the face still owes it; the image owes
/// the repair if any of its faces does.
///
/// Kept as the per-face fragment rather than folded into an image
/// predicate, because the two questions asked of it want opposite
/// shapes. The list and the per-image check want `EXISTS (... WHERE
/// f.image_id = i.id AND fragment)`, one probe per image. The count
/// wants to start from the faces, where the partial indexes V19 keeps
/// for exactly these fragments make it a walk over the few thousand
/// still owing rather than a probe into eight-kilobyte rows for every
/// image in the library -- and the planner will not use those indexes
/// from inside the EXISTS.
Face(String),
/// Evaluated once, at the start of the job. /// Evaluated once, at the start of the job.
Set(SetFn), Set(SetFn),
} }
/// [`Needs::Face`] as an image predicate: the image holds a face owing it.
fn any_face(fragment: &str) -> String {
format!("EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {fragment})")
}
/// A handler: fill one image, given what was fetched for it. /// A handler: fill one image, given what was fetched for it.
pub type ApplyFn = fn(&mut Toolkit, &Catalog, &Target, &mut Fetched) -> Result<usize, Failure>; pub type ApplyFn = fn(&mut Toolkit, &Catalog, &Target, &mut Fetched) -> Result<usize, Failure>;
@@ -290,11 +308,10 @@ pub fn registry(
.join(", "); .join(", ");
format!("EXISTS (SELECT 1 FROM face_index fi WHERE fi.image_id = i.id AND fi.model_id IN ({list}))") format!("EXISTS (SELECT 1 FROM face_index fi WHERE fi.image_id = i.id AND fi.model_id IN ({list}))")
}; };
let face_needing = |pred: &str| { // The per-face fragment `Needs::Face` carries: this embedder's face,
format!( // still owing the pass. Spelled as the partial indexes' WHERE clauses
"EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {f_embedder} = '{embedder}' AND ({pred}))" // are (V19), which is what lets the count be served from them.
) let face_needing = |pred: &str| format!("{f_embedder} = '{embedder}' AND ({pred})");
};
let can_detect = caps.gpu && caps.face_models; let can_detect = caps.gpu && caps.face_models;
@@ -340,7 +357,7 @@ pub fn registry(
out.push(Repair { out.push(Repair {
name: "face-quality", name: "face-quality",
label: "images with faces to read for quality", label: "images with faces to read for quality",
needs: Needs::Sql(face_needing(NEEDS_QUALITY)), needs: Needs::Face(face_needing(NEEDS_QUALITY)),
input: Input::NativeRender, input: Input::NativeRender,
apply: quality, apply: quality,
give_up: None, give_up: None,
@@ -350,7 +367,7 @@ pub fn registry(
out.push(Repair { out.push(Repair {
name: "face-eyes", name: "face-eyes",
label: "images with faces to read for eye state", label: "images with faces to read for eye state",
needs: Needs::Sql(face_needing(NEEDS_EYES)), needs: Needs::Face(face_needing(NEEDS_EYES)),
input: Input::NativeRender, input: Input::NativeRender,
apply: eyes, apply: eyes,
give_up: None, give_up: None,
@@ -360,7 +377,7 @@ pub fn registry(
out.push(Repair { out.push(Repair {
name: "face-crop", name: "face-crop",
label: "images with faces without a crop", label: "images with faces without a crop",
needs: Needs::Sql(face_needing(NEEDS_CROP)), needs: Needs::Face(face_needing(NEEDS_CROP)),
input: Input::NativeRender, input: Input::NativeRender,
apply: crop, apply: crop,
give_up: None, give_up: None,
@@ -732,12 +749,19 @@ fn faces_without_proxy(
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {} = ?1", WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {} = ?1",
faces::embedder_sql("f.model_id"), faces::embedder_sql("f.model_id"),
))?; ))?;
// The index once, not a probe per image with faces — see
// `ThumbStore::held`. An unreadable index reads as empty, as `contains`
// would have reported it.
let held = store.held(dr_thumbs::ThumbSize::Large).unwrap_or_else(|e| {
log::warn!("repairs: reading the thumbnail index: {e}");
Default::default()
});
let rows = stmt let rows = stmt
.query_map([embedder], |r| { .query_map([embedder], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
})? })?
.filter_map(Result::ok) .filter_map(Result::ok)
.filter(|(_, file_id)| !store.contains(*file_id as u64, dr_thumbs::ThumbSize::Large)) .filter(|(_, file_id)| !held.contains(&(*file_id as u64)))
.map(|(id, _)| id) .map(|(id, _)| id)
.collect(); .collect();
Ok(rows) Ok(rows)
@@ -753,6 +777,7 @@ fn listed(
) -> Result<Vec<Target>, dr_catalog::CatalogError> { ) -> Result<Vec<Target>, dr_catalog::CatalogError> {
let (predicate, set) = match &repair.needs { let (predicate, set) = match &repair.needs {
Needs::Sql(sql) => (sql.clone(), None), Needs::Sql(sql) => (sql.clone(), None),
Needs::Face(fragment) => (any_face(fragment), None),
Needs::Set(f) => ("1".to_string(), Some(f(catalog, store)?)), Needs::Set(f) => ("1".to_string(), Some(f(catalog, store)?)),
}; };
let mut stmt = catalog.connection().prepare(&format!( let mut stmt = catalog.connection().prepare(&format!(
@@ -785,10 +810,13 @@ fn still_owed(
set: Option<&HashSet<i64>>, set: Option<&HashSet<i64>>,
image: ImageId, image: ImageId,
) -> bool { ) -> bool {
match (&repair.needs, set) { let sql = match (&repair.needs, set) {
(Needs::Set(_), Some(s)) => s.contains(&(image.0 as i64)), (Needs::Set(_), Some(s)) => return s.contains(&(image.0 as i64)),
(Needs::Set(_), None) => false, (Needs::Set(_), None) => return false,
(Needs::Sql(sql), _) => catalog (Needs::Sql(sql), _) => sql.clone(),
(Needs::Face(fragment), _) => any_face(fragment),
};
catalog
.connection() .connection()
.query_row( .query_row(
&format!( &format!(
@@ -798,11 +826,17 @@ fn still_owed(
[image.0 as i64], [image.0 as i64],
|r| r.get::<_, bool>(0), |r| r.get::<_, bool>(0),
) )
.unwrap_or(false), .unwrap_or(false)
}
} }
/// How many images each repair still lists, for the settings line. /// How many images each repair still lists, for the settings line and the
/// coverage line.
///
/// Counted, not listed. [`listed`] builds a `Target` per image — its path,
/// its size — and sorts the faces-first order the job visits them in, none of
/// which a count reads; asked for six repairs on a 24,000-image library that
/// was 350 ms of `source_ref` strings built to be dropped. A `COUNT(*)` over
/// the same predicate is the same number in a tenth of the time.
pub fn counts( pub fn counts(
catalog: &Catalog, catalog: &Catalog,
store: &ThumbStore, store: &ThumbStore,
@@ -810,10 +844,67 @@ pub fn counts(
) -> Result<Vec<(&'static str, u64)>, dr_catalog::CatalogError> { ) -> Result<Vec<(&'static str, u64)>, dr_catalog::CatalogError> {
repairs repairs
.iter() .iter()
.map(|r| Ok((r.label, listed(catalog, store, r)?.len() as u64))) .map(|r| Ok((r.label, count(catalog, store, r)?)))
.collect() .collect()
} }
/// How many images one repair lists — the size of [`listed`]'s answer,
/// without building it.
fn count(
catalog: &Catalog,
store: &ThumbStore,
repair: &Repair,
) -> Result<u64, dr_catalog::CatalogError> {
match &repair.needs {
Needs::Sql(sql) => {
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT COUNT(*)
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND ({sql})"
),
[],
|r| r.get(0),
)?;
Ok(n as u64)
}
// From the faces, not the images: see `Needs::Face`.
Needs::Face(fragment) => {
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT COUNT(DISTINCT f.image_id)
FROM faces f
JOIN images i ON i.id = f.image_id
JOIN remote r ON r.image_id = i.id
WHERE {fragment} AND r.file_id IS NOT NULL AND {VISIBLE}"
),
[],
|r| r.get(0),
)?;
Ok(n as u64)
}
// The set is built from its own query and may name images `listed`
// would not visit, so it is intersected with the same base rather
// than trusted for its size.
Needs::Set(f) => {
let set = f(catalog, store)?;
let mut stmt = catalog.connection().prepare(&format!(
"SELECT i.id
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL AND {VISIBLE}"
))?;
let n = stmt
.query_map([], |r| r.get::<_, i64>(0))?
.filter_map(Result::ok)
.filter(|id| set.contains(id))
.count();
Ok(n as u64)
}
}
}
/// One image on the work list, with the most any repair claiming it asks /// One image on the work list, with the most any repair claiming it asks
/// for. /// for.
struct Planned { struct Planned {
@@ -842,7 +933,7 @@ fn plan(
for repair in repairs { for repair in repairs {
let set = match &repair.needs { let set = match &repair.needs {
Needs::Set(f) => Some(f(catalog, store)?), Needs::Set(f) => Some(f(catalog, store)?),
Needs::Sql(_) => None, Needs::Sql(_) | Needs::Face(_) => None,
}; };
let listed = listed(catalog, store, repair)?; let listed = listed(catalog, store, repair)?;
if !listed.is_empty() { if !listed.is_empty() {
@@ -1400,6 +1491,74 @@ mod tests {
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
/// `counts` answers from the faces, `listed` from the images (see
/// `Needs::Face`), and the two spellings of each predicate have to
/// agree -- for every repair, on a library where each has something to
/// do and something already done.
#[test]
fn counts_are_the_sizes_of_the_lists() {
let catalog = with_images(5);
let ids = image_ids(&catalog);
let (store, dir) = store();
let conn = catalog.connection();
// 0: two faces, one measured, neither read for eyes, one without a
// crop -- the per-face repairs disagree about it face by face.
faces::record_detections(
conn,
ids[0],
"w600k_mbf",
4000,
&[
face("w600k_mbf", None, vec![1]),
face("w600k_mbf", Some(18.0), Vec::new()),
],
)
.unwrap();
// 1: done, under the chosen detector.
faces::record_detections(
conn,
ids[1],
"scrfd_10g+w600k_mbf",
4000,
&[complete("scrfd_10g+w600k_mbf")],
)
.unwrap();
// 2: examined by a weaker detector, nothing found.
faces::record_detections(conn, ids[2], "w600k_mbf", 4000, &[]).unwrap();
// 3, 4: never examined.
let repairs = registry(
Scope::Outstanding,
"scrfd_10g+w600k_mbf",
FaceDetector::Scrfd10g,
ALL,
);
let counted = counts(&catalog, &store, &repairs).unwrap();
for (repair, (label, n)) in repairs.iter().zip(counted) {
assert_eq!(label, repair.label);
let list = listed(&catalog, &store, repair).unwrap();
assert_eq!(n as usize, list.len(), "{}", repair.name);
}
// And the fixture exercised what it claims to.
let names: Vec<&str> = repairs.iter().map(|r| r.name).collect();
for name in [
"face-quality",
"face-eyes",
"face-crop",
"face-detection",
"face-upgrade",
] {
assert!(names.contains(&name), "{name} missing from the registry");
assert!(
!listed(&catalog, &store, by_name(&repairs, name))
.unwrap()
.is_empty(),
"{name} has nothing to do"
);
}
let _ = std::fs::remove_dir_all(dir);
}
/// The state schema V14 leaves: a face with no quality and an image /// The state schema V14 leaves: a face with no quality and an image
/// with no marker. It is the quality repair's work, and *only* that /// with no marker. It is the quality repair's work, and *only* that
/// repair's -- a full re-detection of the same image would throw away /// repair's -- a full re-detection of the same image would throw away
+2
View File
@@ -192,6 +192,7 @@ export component AppWindow inherits Window {
in property <bool> launch-signed-in: false; in property <bool> launch-signed-in: false;
in property <string> launch-account: ""; in property <string> launch-account: "";
in property <string> launch-root: ""; in property <string> launch-root: "";
in property <bool> launch-endpoint-is-library: false;
in property <string> launch-server: ""; in property <string> launch-server: "";
in property <string> launch-folder: ""; in property <string> launch-folder: "";
in property <bool> launch-busy: false; in property <bool> launch-busy: false;
@@ -1546,6 +1547,7 @@ in property <bool> panel-visible: true;
signed-in: root.launch-signed-in; signed-in: root.launch-signed-in;
account: root.launch-account; account: root.launch-account;
library-root: root.launch-root; library-root: root.launch-root;
endpoint-is-library: root.launch-endpoint-is-library;
server-url: root.launch-server; server-url: root.launch-server;
folder-path: root.launch-folder; folder-path: root.launch-folder;
busy: root.launch-busy; busy: root.launch-busy;
+4 -1
View File
@@ -100,7 +100,10 @@ export component GestureSheet inherits Rectangle {
spacing: Theme.gap; spacing: Theme.gap;
Text { Text {
text: "How to drive the grid"; // The book covers every place, and opens on Develop — a
// title naming the grid was wrong about the first thing
// under it.
text: "How to drive DarkRoom";
color: Theme.ink; color: Theme.ink;
font-size: Theme.text-lg; font-size: Theme.text-lg;
font-weight: 600; font-weight: 600;
+20 -5
View File
@@ -77,6 +77,10 @@ export component LaunchScreen inherits Rectangle {
in property <bool> signed-in: false; in property <bool> signed-in: false;
in property <string> account: ""; in property <string> account: "";
in property <string> library-root: ""; in property <string> library-root: "";
/// The endpoint is the whole library — a folder — so there is no root
/// to choose inside it, only, optionally, a subfolder to narrow to. A
/// server account's endpoint is the entire tree and a root is required.
in property <bool> endpoint-is-library: false;
in property <string> server-url: ""; in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a // Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other. // folder should not have to clear one field to try the other.
@@ -349,7 +353,9 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && root.browsing: VerticalLayout { if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); } PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN ONLY A SUBFOLDER") : @tr("CHOOSE LIBRARY FOLDER");
}
// Current location, so it is always clear what // Current location, so it is always clear what
// "Use this folder" would select. // "Use this folder" would select.
@@ -428,22 +434,31 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout { if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
PanelHeading { text: @tr("ACCOUNT"); } // A folder *is* the library, and saying "account" of a
// directory, then asking for a "library folder" a second
// time, reads as though the path just typed was not
// taken. A server account genuinely has both: who it is,
// and which subtree of it to catalogue.
PanelHeading { text: root.endpoint-is-library ? @tr("LIBRARY") : @tr("ACCOUNT"); }
Value { text: root.account; } Value { text: root.account; }
Rectangle { height: Theme.gap-sm; } Rectangle { height: Theme.gap-sm; }
PanelHeading { text: @tr("LIBRARY FOLDER"); } PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN") : @tr("LIBRARY FOLDER");
}
HorizontalLayout { HorizontalLayout {
spacing: Theme.gap; spacing: Theme.gap;
Value { Value {
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root; text: root.library-root == "" ? @tr("(not chosen)")
: root.library-root == "/" && root.endpoint-is-library ? @tr("The whole folder")
: root.library-root;
placeholder: root.library-root == ""; placeholder: root.library-root == "";
horizontal-stretch: 1; horizontal-stretch: 1;
overflow: elide; overflow: elide;
} }
FormButton { FormButton {
text: @tr("Choose…"); text: root.endpoint-is-library ? @tr("Subfolder…") : @tr("Choose…");
width: 110px; width: 110px;
clicked => { root.choose-folder(); } clicked => { root.choose-folder(); }
} }
+13 -2
View File
@@ -2581,13 +2581,19 @@ export component LibraryGrid inherits Rectangle {
// Now it is a worker, so it needs a sentence: a grid saying // Now it is a worker, so it needs a sentence: a grid saying
// "Scanning…" while nothing is on the network is the same kind of // "Scanning…" while nothing is on the network is the same kind of
// lie the two answers below were separated to avoid. // lie the two answers below were separated to avoid.
// An empty trash is the ordinary state of a trash, and telling
// someone who opened it to check their folder and formats sends
// them off to fix a library that is fine.
if root.total == 0: EmptyState { if root.total == 0: EmptyState {
headline: root.opening headline: root.opening
? "Opening the library…" ? "Opening the library…"
: (root.scanning ? "Scanning…" : "No images found"); : (root.scanning ? "Scanning…"
: (root.viewing-trash ? "The trash is empty" : "No images found"));
detail: (root.opening || root.scanning) detail: (root.opening || root.scanning)
? root.scan-status ? root.scan-status
: "Check the library folder and which formats are ticked."; : (root.viewing-trash
? "Photographs you delete wait here until the trash is emptied."
: "Check the library folder and which formats are ticked.");
} }
// --- keyboard judgement (FR-CULL-4) ------------------------------- // --- keyboard judgement (FR-CULL-4) -------------------------------
@@ -3897,6 +3903,11 @@ export component LibraryGrid inherits Rectangle {
// typed straight after — keywording a shoot is a run of them. // typed straight after — keywording a shoot is a run of them.
new-keyword := Field { new-keyword := Field {
placeholder: "Type a keyword and press return"; placeholder: "Type a keyword and press return";
// As the naming sheet below does, and for the same
// reason: the field is the only thing to do here. Without
// it the keys went to the grid behind the scrim, and the
// Return meant for the keyword opened a photograph.
init => { self.take-focus(); }
accepted(text) => { accepted(text) => {
root.assign-keyword(text); root.assign-keyword(text);
self.text = ""; self.text = "";
+8
View File
@@ -713,10 +713,17 @@ component MaskEntry inherits Rectangle {
reset => { root.feather-changed(0.004); } reset => { root.feather-changed(0.004); }
} }
// Wrapped, both of them: five chips in one row is 440px of
// declared width, and in this column the widest panel's request
// is what every panel gets — selecting a category mask levered
// the whole sidebar open past the window's edge and clipped the
// histogram, the groups strip and the subject list along with
// it. See `ChipGrid`.
if root.data.shapeable: Segmented { if root.data.shapeable: Segmented {
label: "Falloff"; label: "Falloff";
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"]; options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
selected: root.data.falloff; selected: root.data.falloff;
columns: 3;
picked(i) => { root.falloff-picked(i); } picked(i) => { root.falloff-picked(i); }
} }
@@ -726,6 +733,7 @@ component MaskEntry inherits Rectangle {
+ "pinholes; open removes specks."; + "pinholes; open removes specks.";
options: ["None", "Grow", "Shrink", "Close", "Open"]; options: ["None", "Grow", "Shrink", "Close", "Open"];
selected: root.data.morphology; selected: root.data.morphology;
columns: 3;
picked(i) => { root.morphology-picked(i); } picked(i) => { root.morphology-picked(i); }
} }
+10 -10
View File
@@ -63,9 +63,9 @@ export component MergePage inherits Rectangle {
/// seam. Working pixels, at the merge's working scale. /// seam. Working pixels, at the merge's working scale.
in property <float> fill-scale: 2; in property <float> fill-scale: 2;
in property <float> fill-erosion: 4; in property <float> fill-erosion: 4;
in property <float> fill-coarse: 4; in property <float> fill-coarse: 1;
in property <float> fill-band: 96; in property <float> fill-band: 192;
in property <float> fill-mirror: 48; in property <float> fill-mirror: 0;
in property <float> fill-feather: 48; in property <float> fill-feather: 48;
/// The composite, once written: its name for the status line, and /// The composite, once written: its name for the status line, and
@@ -240,26 +240,26 @@ export component MergePage inherits Rectangle {
SliderRow { SliderRow {
label: "Coarse pass"; label: "Coarse pass";
hint: "reduction of the structure pass; 1 skips it"; hint: "reduction of the structure pass; 1 skips it";
value: root.fill-coarse; default-value: 4; minimum: 1; maximum: 8; value: root.fill-coarse; default-value: 1; minimum: 1; maximum: 8;
enabled: !root.running && !root.done; enabled: !root.running && !root.done;
changed(v) => { root.fill-knob("coarse", v); } changed(v) => { root.fill-knob("coarse", v); }
reset => { root.fill-knob("coarse", 4); } reset => { root.fill-knob("coarse", 1); }
} }
SliderRow { SliderRow {
label: "Band width"; label: "Band width";
hint: "the fine passes' bands from the edge outward, working pixels"; hint: "the fine passes' bands from the edge outward, working pixels";
value: root.fill-band; default-value: 96; minimum: 16; maximum: 384; value: root.fill-band; default-value: 192; minimum: 16; maximum: 384;
enabled: !root.running && !root.done; enabled: !root.running && !root.done;
changed(v) => { root.fill-knob("band", v); } changed(v) => { root.fill-knob("band", v); }
reset => { root.fill-knob("band", 96); } reset => { root.fill-knob("band", 192); }
} }
SliderRow { SliderRow {
label: "Mirror depth"; label: "Mirror depth";
hint: "how far into the picture the mirrored context reaches"; hint: "0: the void as it is, what the model was trained on; otherwise how far into the picture a mirrored context reaches";
value: root.fill-mirror; default-value: 48; minimum: 4; maximum: 512; value: root.fill-mirror; default-value: 0; minimum: 0; maximum: 512;
enabled: !root.running && !root.done; enabled: !root.running && !root.done;
changed(v) => { root.fill-knob("mirror", v); } changed(v) => { root.fill-knob("mirror", v); }
reset => { root.fill-knob("mirror", 48); } reset => { root.fill-knob("mirror", 0); }
} }
SliderRow { SliderRow {
label: "Seam feather"; label: "Seam feather";
+4 -1
View File
@@ -1161,7 +1161,10 @@ export component SettingsPage inherits Rectangle {
TextRow { TextRow {
label: "Destination"; label: "Destination";
hint: "empty asks each time"; // What an empty field does, not what it was once
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
text <=> root.destination; text <=> root.destination;
field-width: 320px; field-width: 320px;
placeholder: root.destination-hint; placeholder: root.destination-hint;