Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three details that would each have been a visible bug:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The registry now keeps a test-only count of threads parked in claim,
bumped under the lock just before the condvar wait. The test spins until
that count is one before releasing. The release needs the same lock, so
it can only reach a waiter that is already waiting. Passed 500 runs in a
row.
2026-09-22 21:12:45 -04:00
294 changed files with 82916 additions and 3984 deletions
+5 -2
View File
@@ -23,6 +23,9 @@ 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.
# and every re-recording would otherwise stay in every clone for good. The
# desktop and benchmark legs exclude the directory, since nothing they build
# or test reads it; the Android and Windows legs fetch it, because the APK
# and the installer carry the manual (docs/manual/index.html) with its
# pictures, and their packagers refuse a pointer.
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
+72 -3
View File
@@ -7,6 +7,10 @@ name: Build and test
on:
push:
branches: [main, master, develop]
# A release tag builds again and publishes what it built (the `release`
# job at the end). The master push of the same commit has usually filled
# the caches, so the second run is the warm one.
tags: ['v*']
pull_request:
branches: [main, master, develop]
@@ -96,7 +100,9 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
# The manual's pictures too: the APK carries the manual, and
# assemble-apk.sh refuses a pointer where a picture should be.
git lfs pull --exclude="fixtures/**"
ls -lR models/
- name: Cache cargo
@@ -154,6 +160,16 @@ jobs:
- name: Build
run: cargo build --workspace --release
# Only on a release tag: the binary is 150 MB and nothing but the
# release job wants it.
- name: Upload the desktop binary
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@v3
with:
name: darkroom-desktop-x86_64-linux
path: target/release/darkroom-desktop
if-no-files-found: error
- name: Disk after
if: always()
run: df -h /workspace 2>/dev/null || df -h .
@@ -213,7 +229,9 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
# The manual's pictures too: the APK carries the manual, and
# assemble-apk.sh refuses a pointer where a picture should be.
git lfs pull --exclude="fixtures/**"
ls -lR models/
- name: Cache cargo
@@ -406,7 +424,9 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
# The manual's pictures too: the installer carries the manual, and
# package.sh refuses a pointer where a picture should be.
git lfs pull --exclude="fixtures/**"
ls -l models/face models/scene
- name: Cache cargo
@@ -460,6 +480,11 @@ jobs:
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
GOT=$(ls "$INST/models" | wc -l)
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
# The manual, and every picture it shows, counted the same way.
[ -f "$INST/manual/index.html" ] || { echo "FAIL: no manual installed"; exit 1; }
WANT=$(ls docs/manual/media | wc -l)
GOT=$(ls "$INST/manual/media" | wc -l)
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
@@ -519,3 +544,47 @@ jobs:
fi
done
exit $FAILED
# A v* tag becomes a Gitea Release carrying the three builds and their
# SHA256SUMS, titled and described by the tag's message. Until this job
# existed every release was made by hand, and most tags never got one.
#
# It needs all three platform jobs, so a tag whose tests fail publishes
# nothing; re-run the failed job and this one follows. The work is
# tools/publish-release.sh, which is also how a release is finished by hand.
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [desktop, android, windows]
runs-on: linux/amd64
name: Publish the release
container:
image: catthehacker/ubuntu:act-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch the builds
uses: actions/download-artifact@v3
with:
path: dist
# Named for the download page, with the version in each name the way
# the hand-made releases had them. The installer already carries its
# version from package.sh.
- name: Publish
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
TAG: ${{ github.ref_name }}
run: |
set -e
V="${TAG#v}"
ls -lR dist
mkdir -p out
cp dist/darkroom-arm64-v8a-apk/darkroom.apk "out/darkroom-${V}-arm64-v8a.apk"
cp dist/darkroom-desktop-x86_64-linux/darkroom-desktop "out/darkroom-desktop-${V}-x86_64-linux"
chmod +x "out/darkroom-desktop-${V}-x86_64-linux"
cp dist/darkroom-windows-x86_64-setup/DarkRoom-${V}-x86_64-setup.exe out/
bash tools/publish-release.sh "$TAG" out/*
+16 -1
View File
@@ -67,6 +67,12 @@ jobs:
# threshold: zero requirements parsed, zero files scanned, a ratio above
# 100%, or any orphan tag all fail the build. A misconfigured run must not
# report a plausible-looking 0%.
# Every picture the manual shows is made by a scene in
# tools/manual/scenes.py, and every picture a scene makes is shown.
# Two files read; no app, no display.
- name: Manual pictures have scenes
run: tools/manual/record.sh --check
- name: Traceability gate
run: cargo run -q -p traceability -- check
@@ -91,10 +97,19 @@ jobs:
# they will conclude the application is broken rather than the page.
#
# This also fails on a malformed tag, so a typo costs a gesture its
# desktop half loudly rather than silently.
# desktop half loudly rather than silently — and on a key a Slint
# handler binds that no tag names, or a key a tag names that no handler
# binds (tools/traceability/src/keymap.rs).
- name: Regenerate the gesture vocabulary and check it is committed
run: cargo run -q -p traceability -- gestures-check
# The manual's page, which the packages carry and the help sheet links
# into. Blocking for the gesture book's reason: it is shown to the user,
# and a page that disagrees with the README is a manual describing an
# application that no longer exists.
- name: Regenerate the manual page and check it is committed
run: cargo run -q -p traceability -- manual-check
# Advisory, not blocking: not every file implements a requirement, and a
# tag on every function is noise that rots faster than it helps. Tag the
# unit that decides.
+15 -1
View File
@@ -26,7 +26,7 @@ fi
# The artefacts are generated from the tree, so regenerating them because one
# was itself edited would be circular.
case "$(tr -d '[:space:]' <<< "${staged}")" in
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html)
exit 0
;;
esac
@@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
echo "pre-commit: regenerated ${f} and staged it"
fi
done
# The manual's page, when its source is part of the commit. Rendered from
# nothing but the README, so there is no reason to pay for it otherwise.
if grep -qx 'docs/manual/README.md' <<< "${staged}"; then
if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then
echo "pre-commit: the manual would not render" >&2
echo "${out}" >&2
exit 1
fi
if ! git diff --quiet -- docs/manual/index.html; then
git add docs/manual/index.html
echo "pre-commit: regenerated docs/manual/index.html and staged it"
fi
fi
+33
View File
@@ -31,6 +31,30 @@ over a result set — including `deep_count` per sidebar row, which is fine
at sidebar scale and would not be at grid scale. Aggregate in one
statement and look up in memory.
**SQL text in a loop is a prepare in a loop.** rusqlite's `execute` and
`query_row` compile their statement on every call. A loop that calls them
per row pays a prepare per row even when each query is a primary-key seek:
the merge of a synced catalog prepared four statements for each of 13,000
incoming faces (450 ms of a pass that changed nothing), `persist` four per
photograph a scan listed (1.5 s for a first scan), the shard sync one per
image each way. Hoist the statement, use `prepare_cached`, or — better, when
the loop asks the same table about every row — read that table once into a
map. And do not rewrite a row with what it already holds: an upsert of
identical values still dirties the page.
**A `LIKE` is case-insensitive, and no index here serves that.**
`source_ref LIKE 'stem.%'` read every name of the root per sidecar a pull
took in. When the check that decides is exact, spell the prefix as a range
(`>= 'stem.' AND < 'stem/'`, `/` being the byte after `.`), which the
`(root_id, source_ref)` key answers with a seek.
**`Catalog::open` is not free, and every worker thread calls it.** The
backfill runs on every open, and the develop view opens a catalog to fetch
each original and again for each neighbour it prefetches. Keep each
backfill step's no-op case to a read of the small side — the unpaired
JPEGs, not every RAW; the distinct keywords, not every assignment — and
measure an open with `catalog_bench` after adding one.
**Filter and aggregate in SQL, and aggregate the small side first.**
`faces::people` read 19,000 rows, grouped, sorted them by name, and the
screen threw 17,000 away (empty unnamed groups). `people_in_use` filters in
@@ -124,6 +148,15 @@ other builds are running on the machine — the wall clock doubles under
load, the CPU figure does not. Keep the binary from before the change and
run both back to back rather than trusting numbers taken an hour apart.
`cargo run --release -p dr-catalog --example catalog_bench -- CATALOG
[FACES_DIR]` does the same for opening the catalog (the backfill step by
step), the upload snapshot, a merge, and the face shard export and import;
`persist_bench`, an ignored test in `dr-ui`'s scan module, replays a scan's
`persist` and a sidecar pull (`DR_BENCH_CATALOG=copy.sqlite cargo test
--release -p dr-ui --lib persist_bench -- --ignored --nocapture
--test-threads=1`). Both take copies; hand `catalog_bench` a copy of the
face store directory too.
Reference figures from the 2026-09-19 fixes, largest person (754 faces),
24k images, 19k faces, before → after. What one click read: `load_people`
22 ms → 12 ms, `load_faces` 316 ms → 2.4 ms, `audit` 190 ms → not run
+18
View File
@@ -130,6 +130,24 @@ strength of plumbing a future feature would use. So: **close a requirement
with a test that would fail if the behaviour were removed.** Coverage that
moves slowly and means something beats coverage that moves quickly.
**Keys and gestures are held the same way.** A key handler in Slint compares
one canonical chord, `Keys.chord(event) == "Ctrl+Z"`, under a `// KEYMAP:`
comment naming its section of the gesture book, and every key it binds must be
named by a `GESTURE:` block beside it. `cargo run -p traceability -- gestures`
regenerates [`docs/gestures.md`](docs/gestures.md) and the in-app help sheet
from those blocks; `-- gestures-check` fails when a handler binds a key no tag
names, or a tag names a key no handler binds. A `manual:` field in a block
links the gesture to a section of the manual, and a heading that is not there
fails the scan.
**The manual is checked too.** `docs/manual/index.html` is rendered from
`docs/manual/README.md` by `-- manual` and `-- manual-check` fails when they
differ; `tools/manual/record.sh --check` fails when the manual shows a picture
no scene in `tools/manual/scenes.py` makes. If you change what a pictured
screen looks like, [`tools/manual`](tools/manual/README.md) says how to record
it again. The pre-commit hook regenerates the matrix, the gesture book and the
page; CI runs all three checks.
## Two invariants the build defends
Worth knowing before you trip one, because both failures name a requirement
Generated
+41 -29
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"android_logger",
"dr-plat",
@@ -1234,7 +1234,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"anyhow",
"dr-plat",
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1425,7 +1425,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-face",
"dr-plat",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-types",
"env_logger",
@@ -1454,7 +1454,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1473,7 +1473,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"log",
"serde",
@@ -1495,7 +1495,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1513,7 +1513,7 @@ dependencies = [
[[package]]
name = "dr-inference-engine"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"env_logger",
"libloading",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"lensfun",
"log",
@@ -1548,7 +1548,7 @@ dependencies = [
[[package]]
name = "dr-pano"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-decode",
"dr-inference-engine",
@@ -1562,7 +1562,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-types",
"log",
@@ -1571,7 +1571,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1587,7 +1587,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-pipeline",
"log",
@@ -1597,7 +1597,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1610,7 +1610,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"async-trait",
"dr-plat",
@@ -1624,7 +1624,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"async-trait",
"dr-sync",
@@ -1636,7 +1636,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"async-trait",
"dr-decode",
@@ -1658,7 +1658,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"serde",
"serde_json",
@@ -1679,7 +1679,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1704,6 +1704,7 @@ dependencies = [
"dr-types",
"dr-xmp",
"env_logger",
"i-slint-backend-testing",
"jni 0.22.4",
"log",
"ndk-context",
@@ -1713,16 +1714,18 @@ dependencies = [
"rusqlite",
"serde_json",
"serde_norway",
"sha2",
"slint",
"slint-build",
"thiserror 2.0.20",
"tokio",
"url",
"wgpu",
]
[[package]]
name = "dr-xmp"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"dr-types",
"log",
@@ -2780,6 +2783,18 @@ dependencies = [
"i-slint-renderer-skia",
]
[[package]]
name = "i-slint-backend-testing"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786"
dependencies = [
"cfg_aliases",
"i-slint-common",
"i-slint-core",
"vtable",
]
[[package]]
name = "i-slint-backend-winit"
version = "1.17.1"
@@ -2960,8 +2975,6 @@ dependencies = [
[[package]]
name = "i-slint-renderer-skia"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
dependencies = [
"ash",
"bytemuck",
@@ -7023,9 +7036,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.14.0"
version = "0.16.0"
dependencies = [
"anyhow",
"pulldown-cmark",
"serde",
"serde_json",
]
@@ -7923,8 +7937,6 @@ dependencies = [
[[package]]
name = "wgpu-hal"
version = "29.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
dependencies = [
"android_system_properties",
"arrayvec",
+17 -1
View File
@@ -27,9 +27,12 @@ members = [
"tools/bench",
"tools/traceability",
]
# Patched copies of upstream crates, not our code: see third_party/README.md.
# Excluded so `--workspace` does not test, lint or format them as ours.
exclude = ["third_party"]
[workspace.package]
version = "0.14.0"
version = "0.16.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
@@ -127,6 +130,10 @@ url = "2.5"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The manual's HTML rendering (tools/traceability). Already in the tree as
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
# the HTML writer is needed, not the command-line front end.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
base64 = "0.23"
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
@@ -262,3 +269,12 @@ opt-level = 0
[profile.release]
lto = "thin"
codegen-units = 1
# Two upstream crates carry a local patch so that the Android build can draw
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
# copies of the version the lockfile already resolves, plus that patch;
# third_party/README.md says what was changed and how to carry it forward
# when Slint or wgpu moves.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
+26 -21
View File
@@ -16,18 +16,22 @@ is still missing.
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, 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.
time with a timeline beside it, and filtered by rating, flag, colour label,
person and whether the file is here. Ratings, colour labels, keywords,
collections and a trash that survives a crash mid-operation. Card ingest.
Bursts fold. The same RAW catalogued twice — a dated folder and a backup
beside it — is found, proved the same, and folded onto one copy with the
spares in the trash. Face detection and identity, with the index syncing
between devices.
**Developing.** Eighteen declared operations fused into one compute
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
capture sharpening, noise reduction, lens correction, spectral film
simulation. Crop 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.
simulation. Crop, straighten and correct converging verticals, spot repair,
and local adjustments over masks the model draws — click a subject or a
category, then paint, subtract a gradient or keep only where two selections
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging
what is recoverable. 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)
@@ -37,13 +41,20 @@ sources as a DNG, with a sidecar recording what it was merged from.
[![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
**From the keyboard, and with its manual.** Rating, flagging and labelling
have keys in the grid and in develop, as do zoom, undo and stepping through a
shoot in develop, and none of them is keyboard-only. The help sheet (`F1`, or
`?` in develop) lists every key and gesture, generated from the code that
binds it, and links them to the sections of the manual that show them — the
manual ships with the application and opens offline.
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
sharpening, a naming template and a colour space — 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
targets throughout. On both, the develop view draws the compute pass's
texture directly — no readback between the GPU and the screen.
## Getting it
@@ -76,27 +87,21 @@ controls, its place in the chain and its tests.
## Where it stands
**0.14.0**, twenty-one tagged releases in. 188 numbered requirements in
scope, 82% of them claimed by code and [traced to it](docs/dev/traceability.md);
**0.16.0**, twenty-four tagged releases in. 191 numbered requirements in
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent.
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
focus stacking, most of the Android platform integration beyond running,
and the Flatpak's library chooser. The performance targets are half
survey culling, AI denoise, tiled rendering, HDR merge and
focus stacking, importing a Lightroom or darktable catalog, translations
beyond the launch screen, most of the Android platform integration beyond
running, 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/dev/outstanding.md) is the list, with the reasoning for
each.
**The one deliberate compromise worth knowing about before reading
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/dev/technical-debt.md)
has the measurements and the three things any one of which would remove it.
## Documentation
[docs/README.md](docs/README.md) is the index. The short version, for someone using it:
@@ -141,6 +141,23 @@
</intent-filter>
</activity>
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
carries in assets/manual. See ManualActivity.java for why it is
not the browser.
Not exported: nothing outside this app has a reason to start it,
and dr_ui starts it by class name, which needs no intent filter.
Its own task entry is not wanted either — it is a page over the
app, and Back returns to the photograph it was opened from.
configChanges so a rotation reflows the page rather than
reloading it at the top. -->
<activity
android:name="paris.tourolle.darkroom.ManualActivity"
android:exported="false"
android:label="DarkRoom manual"
android:theme="@style/ManualTheme"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
between apps since API 24 — handing one out raises
FileUriExposedException in *this* process — so an exported JPEG
@@ -0,0 +1,105 @@
package paris.tourolle.darkroom;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/**
* The manual that ships in the APK, shown in a WebView.
*
* <h2>Why an activity of our own rather than the browser</h2>
*
* <p>The desktop hands the manual to the system browser. Android leaves no
* way to do the same: the page is an asset inside the APK, which is not a
* file; an unpacked copy in app-private storage is a file no browser may
* read; a {@code file:} URI handed to another app is refused since API 24;
* and a {@code content:} URI serves the page but leaves the browser to fetch
* every picture by a relative URL against the provider, which browsers do not
* reliably do. A WebView reads {@code file:///android_asset/} straight from
* the APK, pictures and section anchor included, and nothing is unpacked.
*
* <h2>What it is not</h2>
*
* <p>A browser. JavaScript stays off (the page has none), and a link that
* leaves the manual — the design documents are on the forge — goes to the
* user's browser rather than opening inside this view, so the only thing ever
* shown here is the page the APK carries.
*
* <p>Started by {@code dr_ui::manual} with {@code Intent.setClassName}, so the
* name here and there must agree; a test in lib.rs checks the manifest
* declares it.
*/
public final class ManualActivity extends Activity {
/** The section to open at, a heading's anchor. Absent opens the top. */
public static final String EXTRA_ANCHOR = "anchor";
private static final String PAGE = "file:///android_asset/manual/index.html";
private WebView web;
@Override
protected void onCreate(Bundle saved) {
super.onCreate(saved);
setTitle("DarkRoom manual");
web = new WebView(this);
WebSettings settings = web.getSettings();
settings.setJavaScriptEnabled(false);
// Pinch to zoom into a screenshot, which is 1600 pixels wide and drawn
// at the width of a phone.
settings.setBuiltInZoomControls(true);
settings.setDisplayZoomControls(false);
web.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
if ("file".equals(uri.getScheme())) {
return false;
}
try {
startActivity(new Intent(Intent.ACTION_VIEW, uri));
} catch (ActivityNotFoundException e) {
// No browser on the device: the link does nothing, which
// is all it could do.
}
return true;
}
});
setContentView(web);
if (saved != null) {
web.restoreState(saved);
} else {
String anchor = getIntent().getStringExtra(EXTRA_ANCHOR);
web.loadUrl(anchor == null || anchor.isEmpty() ? PAGE : PAGE + "#" + anchor);
}
}
@Override
protected void onSaveInstanceState(Bundle out) {
super.onSaveInstanceState(out);
web.saveState(out);
}
/** Back walks back through the sections visited, then leaves. */
@Override
public void onBackPressed() {
if (web.canGoBack()) {
web.goBack();
} else {
super.onBackPressed();
}
}
@Override
protected void onDestroy() {
web.destroy();
super.onDestroy();
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Day or night as the system is; see values/themes.xml. -->
<resources>
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The manual's theme (ManualActivity). Light below API 29, which has no
day-night theme in the platform; values-v29 follows the system from there.
The WebView takes prefers-color-scheme from whether this theme is light, and
the manual's stylesheet takes its colours from that.
-->
<resources>
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.Light" />
</resources>
+31
View File
@@ -581,6 +581,37 @@ mod tests {
);
}
/// `dr_ui::manual` starts the manual by class name. A name the manifest
/// does not declare is an `ActivityNotFoundException` on the device and a
/// Manual button that does nothing, so the three spellings — dr_ui's, the
/// manifest's and the Java file's — are checked to be one.
#[test]
fn the_manual_activity_dr_ui_starts_is_declared() {
let manifest = manifest();
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
let element = manifest
.split("<activity")
.skip(1)
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
assert_eq!(
attribute(element, "android:exported").as_deref(),
Some("false"),
"the manual activity has no reason to be startable by another app"
);
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
assert!(java.contains(&format!("package {package};")));
assert!(java.contains(&format!("class {class} ")));
assert!(
java.contains(&format!(
"EXTRA_ANCHOR = \"{}\"",
dr_ui::manual::ANDROID_EXTRA_ANCHOR
)),
"ManualActivity reads the section from a different extra than dr_ui writes"
);
}
#[test]
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
let manifest = manifest();
+3
View File
@@ -25,3 +25,6 @@ winresource = "0.1"
[features]
default = []
# The manual's recording hook (dr-ui's `automation`); tools/manual/record.sh
# builds with it, nothing else does.
automation = ["dr-ui/automation"]
+117
View File
@@ -0,0 +1,117 @@
//! What the catalog's routine reads cost on a real library, off the GUI.
//!
//! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR]
//!
//! Times `Catalog::open` — which every worker thread pays, including the
//! develop view's fetch of each original and each neighbour it prefetches —
//! and the backfill that runs inside it, step by step. Run it against a
//! *copy* of a real catalog: opening migrates and backfills, which write.
//!
//! The figures are for reading side by side before and after a change; they
//! are not a gate. Compare the `cpu` column when the machine is busy.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_catalog::{keywords, rating, schema, Catalog};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(path) = args.first().map(PathBuf::from) else {
eprintln!("usage: catalog_bench CATALOG.sqlite");
std::process::exit(2);
};
// Once untimed, so a migration or a first backfill is not in the figures.
drop(Catalog::open(&path).expect("catalog"));
time("Catalog::open", 20, || {
drop(Catalog::open(&path).unwrap());
});
let catalog = Catalog::open(&path).unwrap();
let conn = catalog.connection();
time("schema::backfill (all steps)", 20, || {
schema::backfill(conn).unwrap();
});
time(" rating::ensure_default_versions", 20, || {
rating::ensure_default_versions(conn).unwrap();
});
time(" rating::align_default_version_uuids", 20, || {
rating::align_default_version_uuids(conn).unwrap();
});
time(" keywords::adopt_orphan_terms", 20, || {
keywords::adopt_orphan_terms(conn).unwrap();
});
// A sync pass: the upload snapshot, then a merge of the catalog with a
// copy of itself — every row a match, which is the steady state.
let scratch = path.with_extension("bench-snapshot");
time("snapshot_for_upload", 3, || {
let _ = std::fs::remove_file(&scratch);
catalog.snapshot_for_upload(&scratch).unwrap();
});
println!(
" snapshot size {:.1} MB",
std::fs::metadata(&scratch).map(|m| m.len()).unwrap_or(0) as f64 / 1e6
);
let remote = path.with_extension("bench-remote");
let _ = std::fs::remove_file(&remote);
conn.execute("VACUUM INTO ?1", [remote.to_string_lossy().as_ref()])
.unwrap();
time("merge_remote_catalog (self)", 5, || {
catalog.merge_remote_catalog(&remote).unwrap();
});
let _ = std::fs::remove_file(&scratch);
let _ = std::fs::remove_file(&remote);
// The face half of a sync pass, against a copy of the face store: both
// directions in the steady state, where nothing is new either way.
if let Some(faces) = args.get(1).map(PathBuf::from) {
let model = "scrfd_10g+w600k_mbf";
let mut store = dr_catalog::FaceShardStore::open(&faces).unwrap();
println!(
" first export sent {}, first import adopted {}",
dr_catalog::face_shard::export_to_shards(conn, &mut store, model).unwrap(),
dr_catalog::face_shard::import_from_shards(conn, &store, model).unwrap()
);
time("face_shard::export_to_shards (steady)", 5, || {
dr_catalog::face_shard::export_to_shards(conn, &mut store, model).unwrap();
});
time("face_shard::import_from_shards (steady)", 5, || {
dr_catalog::face_shard::import_from_shards(conn, &store, model).unwrap();
});
}
}
/// Run `f` a few times and print the best wall-clock, the median, and the
/// best CPU time — the figure to compare across runs on a busy machine.
fn time(label: &str, runs: usize, mut f: impl FnMut()) {
let mut wall: Vec<Duration> = Vec::with_capacity(runs);
let mut cpu: Vec<Duration> = Vec::with_capacity(runs);
for _ in 0..runs {
let c = cpu_now();
let t = Instant::now();
f();
wall.push(t.elapsed());
cpu.push(cpu_now().saturating_sub(c));
}
wall.sort();
cpu.sort();
println!(
"{label:42} best {:8.2} ms median {:8.2} ms cpu {:8.2} ms",
wall[0].as_secs_f64() * 1e3,
wall[runs / 2].as_secs_f64() * 1e3,
cpu[0].as_secs_f64() * 1e3
);
}
/// This thread's time on a CPU so far, from `/proc/self/schedstat`; zero where
/// the file is missing, which only makes the CPU column useless.
fn cpu_now() -> Duration {
std::fs::read_to_string("/proc/self/schedstat")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<u64>().ok())
.map(Duration::from_nanos)
.unwrap_or_default()
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -96,6 +96,13 @@ pub enum CatalogError {
#[error("io: {0}")]
Io(String),
/// TRACES: FR-CAT-11a
/// A duplicate group planned earlier no longer holds: a copy was trashed,
/// rescanned or changed since the review was drawn. The group is left
/// untouched rather than consolidated on a stale plan.
#[error("no longer a duplicate: {0}")]
StaleDuplicate(String),
}
impl From<rusqlite::Error> for CatalogError {
+74 -5
View File
@@ -30,6 +30,7 @@
//! identity every client agrees on (FR-NC-5), and it survives a server-side
//! move, so a shard written before a reorganisation still applies after it.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension};
@@ -155,6 +156,45 @@ impl FaceShardStore {
.flatten()
}
/// [`indexed_at`](Self::indexed_at) for every entry at once.
///
/// What a pass over the whole library asks instead of one lookup per image:
/// the export compares every marker in the catalog with this, and a lookup
/// each was 19,000 statements prepared and run on every sync pass that had
/// nothing to send.
fn all_indexed_at(&self) -> Result<HashMap<(u64, String), Option<i64>>, CatalogError> {
let mut q = self
.index
.prepare("SELECT file_id, model_id, indexed_at FROM entries")?;
let rows = q.query_map([], |r| {
Ok((
(r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?),
r.get::<_, Option<i64>>(2)?,
))
})?;
Ok(rows.collect::<Result<_, _>>()?)
}
/// [`held_model`](Self::held_model) for every file at once: one statement,
/// ordered exactly as that one is, keeping the first row per file.
fn all_held_models(&self, model_id: &str) -> Result<HashMap<u64, String>, CatalogError> {
let mut q = self.index.prepare(&format!(
"SELECT file_id, model_id FROM entries
WHERE {} = ?1
ORDER BY file_id, indexed_at DESC NULLS LAST, model_id",
crate::faces::embedder_sql("model_id")
))?;
let rows = q.query_map([crate::faces::embedder_of(model_id)], |r| {
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
})?;
let mut out = HashMap::new();
for row in rows {
let (file, model) = row?;
out.entry(file).or_insert(model);
}
Ok(out)
}
/// The pipeline this store holds an image under, among those sharing
/// `model_id`'s embedder — the most recently indexed where a peer has
/// sent more than one.
@@ -784,6 +824,18 @@ pub fn export_to_shards_reporting(
/// enough that the reporting is lost in the write it accompanies.
const REPORT_EVERY: usize = 25;
// What the store holds, read once. A put below rewrites only its own
// file's entries -- its generation, and siblings it supersedes -- so a
// file already written in this pass is asked of the store again and every
// other answer is the one a lookup would have given.
//
// An index that cannot be read answers as each lookup did: nothing held.
let held = store.all_indexed_at().unwrap_or_else(|e| {
log::debug!("reading the shard index: {e}");
HashMap::new()
});
let mut written: HashSet<u64> = HashSet::new();
let total = rows.len();
let mut exported = 0;
for (seen, (file_id, image_id, edge, indexed_at, model_id)) in rows.into_iter().enumerate() {
@@ -800,10 +852,14 @@ pub fn export_to_shards_reporting(
//
// The comparison is against when the *catalog* indexed it, so a
// re-index is visible and an unchanged image still costs nothing.
if store
.indexed_at(file_id as u64, model_id)
.is_some_and(|was| was >= indexed_at)
{
let was = if written.contains(&(file_id as u64)) {
store.indexed_at(file_id as u64, model_id)
} else {
held.get(&(file_id as u64, model_id.to_string()))
.copied()
.flatten()
};
if was.is_some_and(|was| was >= indexed_at) {
continue;
}
let mut fq = conn.prepare(
@@ -840,6 +896,7 @@ pub fn export_to_shards_reporting(
&faces,
Some(indexed_at),
)?;
written.insert(file_id as u64);
exported += 1;
}
progress(total, total);
@@ -902,6 +959,18 @@ pub fn import_from_shards(
/// the lock, waits a fraction of a second and not the whole import.
const CHUNK: usize = 100;
// Every file's held pipeline, read once rather than asked per candidate --
// 23,000 prepared lookups on every pass, nearly all of them for images
// this device already holds. Nothing below changes which pipeline the
// store holds a file under (`set_indexed_at` touches only a file already
// decided), and each candidate is a different file, so these are the
// answers the lookups gave.
// An index that cannot be read answers as each lookup did: nothing held.
let held_models = store.all_held_models(model_id).unwrap_or_else(|e| {
log::debug!("reading the shard index: {e}");
HashMap::new()
});
let mut adopted = 0;
let mut tx = conn.unchecked_transaction()?;
let mut in_chunk = 0;
@@ -911,7 +980,7 @@ pub fn import_from_shards(
tx = conn.unchecked_transaction()?;
in_chunk = 0;
}
let Some(held) = store.held_model(file_id as u64, model_id) else {
let Some(held) = held_models.get(&(file_id as u64)).cloned() else {
continue;
};
if let Some(local) = local {
+164
View File
@@ -1599,6 +1599,170 @@ fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
}
}
/// TRACES: FR-CAT-11a | FR-CULL-10
/// What [`carry_onto_copy`] did with one byte-identical copy's faces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FaceCarry {
/// Faces moved onto the survivor outright, because it had none.
pub moved: usize,
/// Names and suggestions put on a survivor's face that lacked one.
pub named: usize,
/// Rejections added to a survivor's face.
pub rejections: usize,
/// Faces named one person on the copy and another on the survivor. The
/// survivor's name is kept; the copy keeps its own, in the trash.
pub conflicts: usize,
/// Named faces on the copy that match nothing on the survivor. Left with
/// the copy rather than mixed into another pipeline's faces.
pub unmatched_named: usize,
}
/// TRACES: FR-CAT-11a | FR-CULL-10
/// Bring what one copy of a photograph knows about its faces onto another
/// copy of the same bytes, inside the caller's transaction.
///
/// Faces are per image, so two copies of one file indexed separately hold
/// two sets of the same boxes, and a name confirmed on one is invisible on
/// the other. The rule is the one [`record_detections_within`] keeps: an
/// image holds one pipeline's faces at a time, and the user's judgements
/// are what must survive.
///
/// - **The survivor has no faces at all.** The copy's faces and its run
/// markers move over wholesale — nothing is duplicated, and the survivor
/// is spared a detection pass it would only repeat. Its own markers go
/// first, because a marker saying "examined, nothing found" over an image
/// that now holds faces is the V12 state.
/// - **The survivor has faces.** Each of the copy's faces is paired with the
/// survivor face its box overlaps most (IoU above one half — the bytes are
/// the same, so the boxes coincide). A name or suggestion is carried onto
/// a survivor face that has none, a confirmation outranks a suggestion,
/// and two different confirmed names are a conflict the survivor wins.
/// Rejections are unioned. The copy's faces stay where they are, with the
/// copy.
pub fn carry_onto_copy(
tx: &Connection,
from: ImageId,
to: ImageId,
) -> Result<FaceCarry, CatalogError> {
let mut out = FaceCarry::default();
let boxes = |image: ImageId| -> Result<Vec<CopyFace>, CatalogError> {
let mut q = tx.prepare(
"SELECT f.id, f.x, f.y, f.w, f.h, fp.person_id, fp.probability, fp.confirmed
FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE f.image_id = ?1
ORDER BY f.id",
)?;
let rows = q.query_map([image.0 as i64], |r| {
let person: Option<i64> = r.get(5)?;
Ok(CopyFace {
id: r.get(0)?,
rect: (
r.get::<_, f64>(1)? as f32,
r.get::<_, f64>(2)? as f32,
r.get::<_, f64>(3)? as f32,
r.get::<_, f64>(4)? as f32,
),
assignment: match person {
Some(p) => Some((p, r.get::<_, f64>(6)?, r.get::<_, i64>(7)? != 0)),
None => None,
},
})
})?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
};
let theirs = boxes(from)?;
if theirs.is_empty() {
return Ok(out);
}
let ours = boxes(to)?;
if ours.is_empty() {
tx.execute("DELETE FROM face_index WHERE image_id = ?1", [to.0 as i64])?;
tx.execute(
"UPDATE face_index SET image_id = ?2 WHERE image_id = ?1",
rusqlite::params![from.0 as i64, to.0 as i64],
)?;
out.moved = tx.execute(
"UPDATE faces SET image_id = ?2 WHERE image_id = ?1",
rusqlite::params![from.0 as i64, to.0 as i64],
)?;
return Ok(out);
}
let mut taken = vec![false; ours.len()];
for face in &theirs {
let best = ours
.iter()
.enumerate()
.filter(|(i, _)| !taken[*i])
.map(|(i, o)| (i, iou(face.rect, o.rect)))
.filter(|(_, overlap)| *overlap > 0.5)
.max_by(|a, b| a.1.total_cmp(&b.1));
let Some((at, _)) = best else {
if face.assignment.is_some_and(|(_, _, confirmed)| confirmed) {
out.unmatched_named += 1;
}
continue;
};
taken[at] = true;
let target = &ours[at];
out.rejections += tx.execute(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
SELECT ?2, person_id FROM face_person_rejected WHERE face_id = ?1",
rusqlite::params![face.id, target.id],
)?;
let Some((person, probability, confirmed)) = face.assignment else {
continue;
};
let carry = match target.assignment {
None => true,
// The same person: only a confirmation upgrades a suggestion.
Some((p, _, theirs_confirmed)) if p == person => confirmed && !theirs_confirmed,
// Another person, only suggested there: the user's word wins.
Some((_, _, false)) => confirmed,
// Another person, confirmed there: the survivor keeps its name.
Some((_, _, true)) => {
if confirmed {
out.conflicts += 1;
}
false
}
};
if carry {
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = excluded.probability,
confirmed = excluded.confirmed",
rusqlite::params![target.id, person, probability, confirmed],
)?;
// A name the user gave outranks a rejection of the same pair made
// on the survivor — the same order `confirm` applies.
if confirmed {
tx.execute(
"DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2",
rusqlite::params![target.id, person],
)?;
}
out.named += 1;
}
}
Ok(out)
}
/// One face as [`carry_onto_copy`] pairs it.
struct CopyFace {
id: i64,
rect: (f32, f32, f32, f32),
assignment: Option<(i64, f64, bool)>,
}
pub(crate) fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+9 -3
View File
@@ -185,7 +185,8 @@ pub fn enqueue(
priority: Priority,
payload: Option<&str>,
) -> Result<(), CatalogError> {
conn.execute(
// Cached: a scan enqueues one per photograph it lists.
conn.prepare_cached(
"INSERT INTO jobs(kind, subject_id, priority, state, payload)
VALUES (?1, ?2, ?3, 0, ?4)
ON CONFLICT(kind, subject_id) DO UPDATE SET
@@ -195,8 +196,13 @@ pub fn enqueue(
state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END,
attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END,
not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END",
rusqlite::params![kind as i64, subject_id, priority as i64, payload],
)?;
)?
.execute(rusqlite::params![
kind as i64,
subject_id,
priority as i64,
payload
])?;
Ok(())
}
+6 -1
View File
@@ -491,7 +491,12 @@ pub fn adopt_orphan_terms(conn: &Connection) -> Result<usize, CatalogError> {
// quietly readmitted to the vocabulary; it stays visible as an
// orphan in [`for_images`] instead, which is a state someone can
// see and act on rather than one that silently undoes a deletion.
"SELECT DISTINCT k.keyword FROM keywords k
//
// The distinct words first, then the check: the vocabulary has no
// index a tombstone-inclusive lookup can use, so checking once per
// assignment scanned it 10,000 times on every open. Once per word
// is a few dozen scans of a few dozen rows.
"SELECT k.keyword FROM (SELECT DISTINCT keyword FROM keywords) k
WHERE NOT EXISTS (SELECT 1 FROM keyword_terms t
WHERE t.name = k.keyword)",
)?;
+1
View File
@@ -40,6 +40,7 @@ pub mod bursts;
pub mod cache;
pub mod collections;
pub mod dedup;
pub mod duplicates;
pub mod error;
pub mod face_shard;
pub mod faces;
+51 -42
View File
@@ -931,16 +931,36 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
.collect::<Result<_, _>>()?;
// Cached, not prepared per row: a steady-state pass walks every
// confirmed and every ignored face the other device holds -- 13,000 on
// the reference library -- and preparing four statements for each was
// most of the 780 ms a merge that changed nothing cost.
let mut person_of = tx.prepare_cached("SELECT id FROM people WHERE uuid = ?1")?;
let mut current = tx.prepare_cached(
"SELECT person_id, probability, confirmed FROM face_person WHERE face_id = ?1",
)?;
let mut rejected_q = tx.prepare_cached(
"SELECT EXISTS(SELECT 1 FROM face_person_rejected
WHERE face_id = ?1 AND person_id = ?2)",
)?;
let mut assign = tx.prepare_cached(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = excluded.probability,
confirmed = excluded.confirmed",
)?;
for (remote_face, uuid, probability, confirmed) in incoming {
let Some(&local_face) = face_map.get(&remote_face) else {
continue;
};
let person: Option<i64> = tx
.query_row("SELECT id FROM people WHERE uuid = ?1", [&uuid], |r| {
r.get(0)
})
.optional()?;
let person: Option<i64> = person_of.query_row([&uuid], |r| r.get(0)).optional()?;
let Some(person) = person else { continue };
let held: Option<(i64, f64, i64)> = current
.query_row([local_face], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.optional()?;
// A local confirmation is never overwritten, in either direction.
// Two devices confirming the same face as different people is a
@@ -948,13 +968,7 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
// settle it with; silently taking the remote's answer would let a
// sync undo something the user did here. It stays as it is, and the
// user can change it on the device they are looking at.
let locally_confirmed: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM face_person
WHERE face_id = ?1 AND confirmed = 1)",
[local_face],
|r| r.get(0),
)?;
if locally_confirmed {
if held.is_some_and(|(_, _, confirmed)| confirmed == 1) {
report.faces_kept_local += 1;
continue;
}
@@ -963,25 +977,22 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
// this user's judgement about this pair, and re-suggesting what
// they pushed away is the behaviour that makes the feature feel
// broken.
let rejected: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM face_person_rejected
WHERE face_id = ?1 AND person_id = ?2)",
[local_face, person],
|r| r.get(0),
)?;
let rejected: bool = rejected_q.query_row([local_face, person], |r| r.get(0))?;
if rejected {
continue;
}
tx.execute(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = excluded.probability,
confirmed = excluded.confirmed",
rusqlite::params![local_face, person, probability, confirmed],
)?;
// Written only when it differs. Rewriting a row with the values it
// already holds dirtied a page per face, every pass, for nothing;
// the report still counts it, as it always has.
if held != Some((person, probability, i64::from(confirmed))) {
assign.execute(rusqlite::params![
local_face,
person,
probability,
confirmed
])?;
}
report.faces_assigned += 1;
}
}
@@ -997,32 +1008,30 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?;
let mut person_of = tx.prepare_cached("SELECT id FROM people WHERE uuid = ?1")?;
let mut reject = tx.prepare_cached(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
)?;
let mut unsuggest = tx.prepare_cached(
"DELETE FROM face_person
WHERE face_id = ?1 AND person_id = ?2 AND confirmed = 0",
)?;
for (remote_face, uuid) in incoming {
let Some(&local_face) = face_map.get(&remote_face) else {
continue;
};
let person: Option<i64> = tx
.query_row("SELECT id FROM people WHERE uuid = ?1", [&uuid], |r| {
r.get(0)
})
.optional()?;
let person: Option<i64> = person_of.query_row([&uuid], |r| r.get(0)).optional()?;
let Some(person) = person else { continue };
let n = tx.execute(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
[local_face, person],
)?;
let n = reject.execute([local_face, person])?;
report.faces_rejected += n;
// A rejection that lands on a face currently *suggested* to be
// that person has to take the suggestion with it, or the screen
// keeps offering exactly what the other device just refused.
tx.execute(
"DELETE FROM face_person
WHERE face_id = ?1 AND person_id = ?2 AND confirmed = 0",
[local_face, person],
)?;
unsuggest.execute([local_face, person])?;
}
}
+171 -14
View File
@@ -49,6 +49,12 @@ pub struct Judgement {
/// 0..=5. Zero means *unrated*, which is a state in its own right.
pub rating: u8,
pub flag: FlagState,
/// TRACES: FR-CAT-5
/// The colour label, or `None`. Not part of [`Judgement::is_judged`]:
/// a label sorts photographs into piles of the photographer's own
/// meaning — "to print", "send to Anna" — and says nothing about whether
/// a frame has been culled, which is the question "unjudged" asks.
pub label: Option<ColourLabel>,
}
impl Judgement {
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
Ok(moved)
}
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
/// TRACES: FR-CAT-13
/// How `versions.label` encodes a colour label, and back.
///
@@ -304,6 +302,14 @@ pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
})
}
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
@@ -387,6 +393,88 @@ pub fn set_flag_many(
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
}
/// TRACES: FR-CAT-5
/// Set or clear the colour label for one image.
pub fn set_label(
conn: &Connection,
image: ImageId,
label: Option<ColourLabel>,
) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET label = ?2 WHERE id = ?1",
rusqlite::params![version, label.map(label_code)],
)?;
Ok(())
}
/// TRACES: FR-CAT-5
/// Set or clear a label on many images in one transaction — one keystroke
/// over a selection is one commit, as for [`set_rating_many`].
pub fn set_label_many(
conn: &Connection,
images: &[ImageId],
label: Option<ColourLabel>,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_label(conn, id, label))
}
/// TRACES: FR-CAT-5
/// What a label key does to a set of images: Lightroom's toggle.
///
/// Pressing the key for the label every one of them already carries takes it
/// off; otherwise every one of them gets it. Decided over the whole set
/// rather than per image, so a selection that was half red comes out all red
/// rather than inverted — the photographer pressed "red", and a key that
/// turned half of them red and the other half plain would be two answers to
/// one question.
pub fn toggled_label(
current: impl IntoIterator<Item = Option<ColourLabel>>,
pressed: ColourLabel,
) -> Option<ColourLabel> {
let mut any = false;
for label in current {
any = true;
if label != Some(pressed) {
return Some(pressed);
}
}
if any {
None
} else {
Some(pressed)
}
}
/// TRACES: FR-CAT-5 | FR-CAT-6
/// How the library divides by colour label, for the filter chips' counts.
///
/// Index 0 is unlabelled and index `n` the label whose code is `n`. One
/// grouped statement — the same shape as [`rating_histogram`], and for the
/// same reason it LEFT JOINs: an image without a version row is unlabelled,
/// not missing.
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6];
let mut stmt = conn.prepare(
"SELECT coalesce(v.label, 0) AS l, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY l",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for (code, count) in rows.flatten() {
// A code this build does not know counts as unlabelled, which is how
// `label_from_code` reads it everywhere else.
let slot = if label_from_code(Some(code)).is_some() {
code as usize
} else {
0
};
out[slot] += count as usize;
}
Ok(out)
}
/// Shared bulk wrapper, so the two axes cannot drift in their commit
/// behaviour — a partially-committed rating and a fully-committed flag from
/// the same keystroke would be hard to explain and harder to notice.
@@ -411,21 +499,22 @@ fn apply_many(
/// An image with no version reads as unrated and unflagged rather than as an
/// error: that is exactly what it is.
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
let row: Option<(i64, i64)> = conn
let row: Option<(i64, i64, Option<i64>)> = conn
.query_row(
"SELECT rating, flag FROM versions
"SELECT rating, flag, label FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
Ok(match row {
Some((rating, flag)) => Judgement {
Some((rating, flag, label)) => Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
label: label_from_code(label),
},
None => Judgement::default(),
})
@@ -452,7 +541,7 @@ pub fn judgements(
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT image_id, rating, flag FROM versions
"SELECT image_id, rating, flag, label FROM versions
WHERE image_id IN ({placeholders}) AND is_default = 1"
);
@@ -467,15 +556,17 @@ pub fn judgements(
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, Option<i64>>(3)?,
))
})?;
for (image, rating, flag) in rows.flatten() {
for (image, rating, flag, label) in rows.flatten() {
out.insert(
ImageId(image as u64),
Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
label: label_from_code(label),
},
);
}
@@ -686,6 +777,72 @@ mod tests {
assert_eq!(distinct, 200);
}
#[test]
fn a_label_round_trips_and_clears() {
// TRACES: FR-CAT-5
let cat = with_images(1);
let id = ids(&cat)[0];
set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap();
assert_eq!(
judgement(cat.connection(), id).unwrap().label,
Some(ColourLabel::Green)
);
set_label(cat.connection(), id, None).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().label, None);
}
#[test]
fn a_label_is_not_a_judgement() {
// "Unjudged" is the cull's resume point; a label is a pile of the
// photographer's own, and labelling a frame must not hide it there.
let cat = with_images(1);
let id = ids(&cat)[0];
set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap();
assert!(!judgement(cat.connection(), id).unwrap().is_judged());
}
#[test]
fn labelling_a_selection_is_one_commit_and_reaches_every_image() {
// TRACES: FR-CAT-5
let cat = with_images(4);
let all = ids(&cat);
assert_eq!(
set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(),
4
);
let found = judgements(cat.connection(), &all).unwrap();
assert!(all
.iter()
.all(|id| found[id].label == Some(ColourLabel::Blue)));
assert_eq!(
label_histogram(cat.connection()).unwrap(),
[0, 0, 0, 0, 4, 0]
);
}
#[test]
fn a_label_key_toggles_only_when_every_image_already_has_it() {
// TRACES: FR-CAT-5
use ColourLabel::*;
assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None);
assert_eq!(toggled_label([Some(Red), None], Red), Some(Red));
assert_eq!(toggled_label([Some(Blue)], Red), Some(Red));
assert_eq!(toggled_label([], Red), Some(Red));
}
#[test]
fn the_label_histogram_sums_to_the_library() {
// TRACES: FR-CAT-6
// Images without a version row count as unlabelled rather than
// vanishing, as the rating histogram's do.
let cat = with_images(3);
let first = ids(&cat)[0];
set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap();
let h = label_histogram(cat.connection()).unwrap();
assert_eq!(h, [2, 0, 0, 0, 0, 1]);
assert_eq!(h.iter().sum::<usize>(), 3);
}
#[test]
fn a_rating_round_trips() {
let cat = with_images(1);
+78 -21
View File
@@ -494,15 +494,51 @@ fn rewrite_for_attached(sql: &str, schema_name: &str) -> String {
fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
use std::collections::HashMap;
// (folder, lowercase stem) -> RAW id, built in one pass over the RAWs.
// The small side first: the JPEGs not yet paired. On a settled library
// these are the ones with no RAW beside them -- 1,900 of 24,000 on the
// reference library -- and this runs on every open, including the ones
// the develop view makes for each photograph it fetches. Reading every
// RAW to find the handful that share a folder with one of them was most
// of what opening the catalog cost.
let jpegs: Vec<(i64, Option<i64>, String)> = {
let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, String>(2)?,
))
})?;
rows.filter_map(Result::ok).collect()
};
if jpegs.is_empty() {
return Ok(0);
}
// (folder, lowercase stem) -> RAW id, over the folders those JPEGs are in
// and no others: a pair is same-folder by definition. Ordered by id so
// that where two RAWs share a stem the later one wins, as it did when this
// read every RAW in table order.
let mut folders: Vec<i64> = jpegs.iter().filter_map(|(_, f, _)| *f).collect();
folders.sort_unstable();
folders.dedup();
let unfiled = jpegs.iter().any(|(_, f, _)| f.is_none());
let folders = serde_json::to_string(&folders).unwrap_or_else(|_| "[]".to_string());
let mut raws: HashMap<(Option<i64>, String), i64> = HashMap::new();
{
let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN
('cr2','cr3','nef','arw','raf','rw2','orf','dng')",
('cr2','cr3','nef','arw','raf','rw2','orf','dng')
AND (folder_id IN (SELECT value FROM json_each(?1))
OR (?2 AND folder_id IS NULL))
ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
let rows = stmt.query_map(rusqlite::params![folders, unfiled], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
@@ -518,25 +554,16 @@ fn pair_raw_and_jpeg(conn: &Connection) -> Result<usize, CatalogError> {
return Ok(0);
}
let pairs: Vec<(i64, i64)> = {
let mut stmt = conn.prepare(
"SELECT id, folder_id, source_ref FROM images
WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, String>(2)?,
))
})?;
rows.filter_map(|row| {
let (id, folder, path) = row.ok()?;
let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?;
Some((id, *raw))
let pairs: Vec<(i64, i64)> = jpegs
.iter()
.filter_map(|(id, folder, path)| {
let raw = raws.get(&(*folder, stem_of(path).to_ascii_lowercase()))?;
Some((*id, *raw))
})
.collect()
};
.collect();
if pairs.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
for (jpeg, raw) in &pairs {
@@ -1582,6 +1609,36 @@ mod tests {
assert_eq!(backfilled(&c, "shadowed_by"), 0);
}
#[test]
fn a_pair_is_found_among_other_folders_and_unfiled_images() {
// The RAWs are read only from the folders an unpaired JPEG is in,
// plus the unfiled ones when an unfiled JPEG is waiting: each JPEG
// must still find its own sibling, and only its own.
let c = with_root();
let raw_a = image(&c, Some(1), "a/IMG_7.CR2", "cr2");
image(&c, Some(2), "b/IMG_7.CR2", "cr2");
image(&c, Some(2), "b/IMG_8.CR2", "cr2");
let raw_unfiled = image(&c, None, "IMG_9.DNG", "dng");
let jpeg_a = image(&c, Some(1), "a/IMG_7.JPG", "jpg");
let jpeg_unfiled = image(&c, None, "IMG_9.jpg", "jpg");
image(&c, Some(1), "a/IMG_9.jpg", "jpg");
assert_eq!(backfilled(&c, "shadowed_by"), 2);
let of = |id: i64| -> Option<i64> {
c.query_row("SELECT shadowed_by FROM images WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(of(jpeg_a), Some(raw_a));
assert_eq!(of(jpeg_unfiled), Some(raw_unfiled));
assert_eq!(
backfilled(&c, "shadowed_by"),
0,
"settled on the second pass"
);
}
#[test]
fn a_raw_is_never_shadowed_by_a_jpeg() {
// The relationship is one-way: the RAW is the photograph.
+30 -18
View File
@@ -129,28 +129,40 @@ pub fn record_trashed(
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
}
let n = record_trashed_within(&tx, moved, now)?;
tx.commit()?;
Ok(n)
}
/// [`record_trashed`] inside a transaction the caller owns.
///
/// For a caller whose trash is one half of a larger write that must land
/// whole or not at all — consolidating duplicates (`crate::duplicates`)
/// merges a copy's judgements onto the survivor and trashes the copy in one
/// commit. `unchecked_transaction` cannot nest, so this is offered here
/// rather than wrapped from above.
pub fn record_trashed_within(
tx: &Connection,
moved: &[(ImageId, String)],
now: i64,
) -> Result<usize, CatalogError> {
let mut n = 0;
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
Ok(n)
}
/// Record that images have been moved back out of the trash.
///
/// Call after the move succeeds, for the same reason as [`record_trashed`].
+121
View File
@@ -0,0 +1,121 @@
//! TRACES: FR-RAW-2
//! The seam a second decoder plugs into.
//!
//! D2 keeps LibRaw as the fallback for bodies rawler does not cover. Adding
//! it later should be a new `impl Decoder`, not an edit to every caller that
//! reads a header, cuts a thumbnail or opens a photograph for export — which
//! is what the free functions alone would have made it. So the callers take a
//! `&dyn Decoder`, and only the places that start a job name [`default`].
//!
//! Bytes in, always. Nothing here takes a path or a `SourceRef`: resolving a
//! file to bytes is `Storage`'s job at the caller, so the same decoder serves a
//! local file, an Android document and a range fetched from Nextcloud. The
//! decoder's part in that is to say how much of a file it needs
//! ([`Decoder::header_bytes`]) and where its preview sits
//! ([`Decoder::locate_preview`]); the storage layer fetches exactly that.
//!
//! What stays a free function is what is not a decoder's to vary: recognising
//! a JPEG ([`crate::probe`]), decoding one ([`crate::decode_jpeg`]) and
//! checking one is whole ([`crate::is_complete_jpeg`]). A second RAW decoder
//! would not read a JPEG differently.
use dr_types::Orientation;
use crate::{DecodeError, Metadata, Preview, PreviewLocation, PreviewSize, RawImage};
/// TRACES: FR-RAW-2
/// A RAW decoder, over bytes.
///
/// Object-safe so a caller can hold `&dyn Decoder` without becoming generic,
/// `Send + Sync` because the callers that need one most — the thumbnail
/// lanes, the export worker — run off the UI thread, and `Debug` so a job
/// description that carries one can still be printed.
pub trait Decoder: Send + Sync + std::fmt::Debug {
/// How much of the start of a file [`Self::metadata`] and
/// [`Self::locate_preview`] need. A caller reading over a network fetches
/// this range and no more.
fn header_bytes(&self) -> u64;
/// Capture metadata, from a header or a whole file, without touching
/// sensor data.
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError>;
/// How the stored pixels are turned, from a header. `None` where the file
/// does not say, which callers take as upright.
fn orientation(&self, header: &[u8]) -> Option<Orientation>;
/// Where the embedded preview best suited to a thumbnail sits in the file,
/// from its header, so a remote caller can fetch that range alone.
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation>;
/// The embedded preview at the size asked for, falling through the ladder
/// to the next size where the file lacks it.
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError>;
/// Sensor data, for develop and export. The expensive path.
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError>;
}
/// TRACES: FR-RAW-2
/// The decoder the application ships: rawler for sensor data and the
/// previews it knows, DarkRoom's own container walk for headers and ranges.
///
/// Its methods are the crate's free functions, unchanged. They stay public
/// for the tools and examples that read one file and have no caller to keep
/// decoder-agnostic.
#[derive(Debug, Clone, Copy, Default)]
pub struct Rawler;
impl Decoder for Rawler {
fn header_bytes(&self) -> u64 {
crate::HEADER_BYTES
}
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError> {
crate::metadata(bytes)
}
fn orientation(&self, header: &[u8]) -> Option<Orientation> {
crate::orientation(header)
}
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
crate::locate_preview(header, file_len)
}
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
crate::extract_preview(bytes, size)
}
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError> {
crate::decode(bytes)
}
}
/// TRACES: FR-RAW-2
/// The decoder a job uses unless it was handed another.
///
/// Named by the places that start work — a thread, a UI handler — and by
/// nothing below them. Returning `&'static dyn Decoder` rather than `Rawler`
/// is the point: a caller that only has this cannot reach past the trait.
pub fn default() -> &'static dyn Decoder {
static RAWLER: Rawler = Rawler;
&RAWLER
}
#[cfg(test)]
mod tests {
use super::*;
/// The default is the shipped decoder, reached through the trait: same
/// header budget, and the same answer to bytes neither can read.
#[test]
fn the_default_is_rawler_behind_the_trait() {
let d = default();
assert_eq!(d.header_bytes(), crate::HEADER_BYTES);
let junk = [0u8; 64];
assert_eq!(d.metadata(&junk).is_err(), crate::metadata(&junk).is_err());
assert!(d.decode(&junk).is_err());
assert_eq!(d.orientation(&junk), crate::orientation(&junk));
}
}
+6
View File
@@ -11,14 +11,20 @@
//!
//! Fusing them would force a full decode where a header read suffices, which
//! is exactly why Lightroom stalls ~2 s per image during culling.
//!
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
//! second decoder can be put behind them without changing any of them
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
pub mod base_curve;
mod decoder;
mod error;
mod locate;
mod preview;
pub mod profile;
pub use base_curve::BaseCurve;
pub use decoder::{default, Decoder, Rawler};
pub use error::DecodeError;
pub use locate::{
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
+462 -5
View File
@@ -124,6 +124,9 @@ pub struct AdjustPass {
/// Cleared by any render that does not write it, so a stale intermediate
/// cannot survive a change of image and be handed to a later detail chain.
colour_key: Option<(u64, u32, u32)>,
/// The source texels the fused pass read on an earlier frame, kept so a
/// slider drag at fit reads them contiguously. See [`SampleCache`].
sample: SampleCache,
/// Fused dispatches actually encoded. Exposed so a test can see the reuse
/// above happening rather than take it on trust.
colour_dispatches: usize,
@@ -147,6 +150,205 @@ struct Target {
const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// TRACES: FR-DEV-3f
/// What a cached sample is valid for: the composer's
/// [`ComposedShader::sample_key`], the source image, and the render size.
type SampleKey = (u64, u64, u32, u32);
/// The largest render the sample cache is kept for, in pixels.
///
/// 4K and a little over, which is every develop view there is. An export
/// renders a whole sensor once and gains nothing from a cache it will not
/// read again; without a ceiling, two exports of the same frame in a row
/// would park a full-resolution copy of it on the device.
const SAMPLE_CACHE_MAX_PIXELS: u64 = 3840 * 2400;
/// How the fused dispatch gets its source colour this frame.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SampleUse {
/// From the source, as it always did.
Direct,
/// From the source, and stored in the cache for the frames after.
Write,
/// From the cache.
Read,
}
/// TRACES: NFR-P5
/// The fused pass's gather from the source, remembered across frames.
///
/// At fit, each output pixel of the fused pass reads one texel of a source
/// three or four times its width, on a stride, and the memory system fetches
/// the neighbours it skips along with it. On a 60 MP source that gather was
/// most of the fused pass: 10.9 ms of a 2560 x 1600 frame against 3.8 ms for
/// the same work reading contiguously (RTX 3050, clocks held down). But which
/// texel a pixel reads depends on the framing and nothing else, and a slider
/// drag does not move the framing. So the shader writes what it gathered to a
/// render-sized texture on one frame and reads it back contiguously on every
/// frame after, until the framing, the image or the size changes.
///
/// Bit-for-bit the same picture: the source is `rgba16float` and so is the
/// cache, so the stored texel is the texel. Only the whole-texel sampling path
/// takes part — [`ComposedShader::sample_key`] is `None` when the sample is
/// interpolated.
///
/// **Written on the second frame with a key, not the first.** A drag of the
/// crop or of a zoom changes the key on every frame, and a cache written then
/// is never read — it would add a write per frame to exactly the gestures that
/// can least afford one. Waiting for the key to repeat once costs a slider drag
/// one uncached frame and costs a crop drag nothing.
struct SampleCache {
/// The cache itself, `rgba16float`, sampled and written as storage.
target: Option<Target>,
/// What `target` holds, once a dispatch has written it.
holds: Option<SampleKey>,
/// The key the previous fused dispatch had, cached or not.
last: Option<SampleKey>,
/// What the most recent plan decided. Read by the tests, which have no
/// other way to tell a cached frame from an uncached one — the point being
/// that the pictures are identical.
last_use: SampleUse,
/// Bound at `@binding(6)` when the cache is not being read.
no_sampled: wgpu::TextureView,
/// Bound at `@binding(7)` when the cache is not being written.
no_sample_out: wgpu::TextureView,
}
impl SampleCache {
const FORMAT: wgpu::TextureFormat = DemosaicedImage::FORMAT;
fn new(ctx: &GpuContext) -> Self {
let placeholder = |label, usage| {
ctx.device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage,
view_formats: &[],
})
.create_view(&Default::default())
};
Self {
target: None,
holds: None,
last: None,
last_use: SampleUse::Direct,
no_sampled: placeholder("adjust-no-sampled", wgpu::TextureUsages::TEXTURE_BINDING),
no_sample_out: placeholder(
"adjust-no-sample-out",
wgpu::TextureUsages::STORAGE_BINDING,
),
}
}
/// Decide how this dispatch samples, on the assumption that it will be
/// submitted — call it after anything that can still fail.
fn plan(
&mut self,
ctx: &GpuContext,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> SampleUse {
self.last_use = self.decide(ctx, source, shader, width, height);
self.last_use
}
fn decide(
&mut self,
ctx: &GpuContext,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> SampleUse {
let key = shader
.sample_key
.filter(|_| u64::from(width) * u64::from(height) <= SAMPLE_CACHE_MAX_PIXELS)
.map(|k| (k, source.id(), width, height));
let last = std::mem::replace(&mut self.last, key);
let Some(key) = key else {
return SampleUse::Direct;
};
if self.holds == Some(key) {
return SampleUse::Read;
}
if last != Some(key) {
return SampleUse::Direct;
}
if !self
.target
.as_ref()
.is_some_and(|t| t.width == width && t.height == height)
{
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-sample-cache"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.target = Some(Target {
texture,
view,
width,
height,
});
}
// Marked as held now: the dispatch that writes it is submitted before
// any that could read it, and one queue orders the two.
self.holds = Some(key);
SampleUse::Write
}
/// Write the flags for `usage` into a fused uniform block.
fn flag(usage: SampleUse, uniforms: &mut [f32]) {
let o = dr_pipeline::SAMPLE_CACHE_UNIFORM_OFFSET;
uniforms[o] = if usage == SampleUse::Read { 1.0 } else { 0.0 };
uniforms[o + 1] = if usage == SampleUse::Write { 1.0 } else { 0.0 };
}
/// The views for `@binding(6)` and `@binding(7)`.
fn views(&self, usage: SampleUse) -> (wgpu::TextureView, wgpu::TextureView) {
let cache = || {
self.target
.as_ref()
.expect("planned with a target")
.view
.clone()
};
match usage {
SampleUse::Direct => (self.no_sampled.clone(), self.no_sample_out.clone()),
SampleUse::Write => (self.no_sampled.clone(), cache()),
SampleUse::Read => (cache(), self.no_sample_out.clone()),
}
}
/// Forget everything; the next render starts over.
fn release(&mut self) {
self.target = None;
self.holds = None;
self.last = None;
}
}
/// A baked film stock, resident on the GPU.
struct FilmTextures {
curves: wgpu::TextureView,
@@ -440,6 +642,7 @@ impl AdjustPass {
camera_pipeline_layout,
camera_target: None,
colour_key: None,
sample: SampleCache::new(ctx),
colour_dispatches: 0,
detail_dispatches: 0,
}
@@ -537,6 +740,30 @@ impl AdjustPass {
},
count: None,
},
// The sample cache, read and written. Present in every
// layout for the reason the masks are, and bound to
// placeholders whenever the flags leave it alone. See
// `SampleCache`.
wgpu::BindGroupLayoutEntry {
binding: 6,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 7,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: SampleCache::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
})
}
@@ -716,7 +943,15 @@ impl AdjustPass {
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
let uniforms = Self::fused_uniforms(source, shader);
// Compile first: `pipeline` takes &mut self, and the sample cache's
// plan below assumes the dispatch it plans for is submitted, so nothing
// after it may fail.
let _ = self.pipeline(shader)?;
let mut uniforms = Self::fused_uniforms(source, shader);
let sampling = self.sample.plan(&self.ctx, source, shader, width, height);
SampleCache::flag(sampling, &mut uniforms);
let (sampled, sample_out) = self.sample.views(sampling);
let params_buf = self
.ctx
@@ -727,8 +962,6 @@ impl AdjustPass {
usage: wgpu::BufferUsages::UNIFORM,
});
// Borrow order: compile first, since `pipeline` takes &mut self.
let _ = self.pipeline(shader)?;
let pipeline = self
.cache
.get(&shader.structure_hash)
@@ -768,6 +1001,14 @@ impl AdjustPass {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
],
});
@@ -885,7 +1126,12 @@ impl AdjustPass {
label: Some("adjust-detail-encoder"),
});
let mut sampling = SampleUse::Direct;
if !reuse {
let mut uniforms = uniforms;
sampling = self.sample.plan(&self.ctx, source, shader, width, height);
SampleCache::flag(sampling, &mut uniforms);
let (sampled, sample_out) = self.sample.views(sampling);
let params_buf =
self.ctx
.device
@@ -927,6 +1173,14 @@ impl AdjustPass {
binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
],
});
let pipeline = self
@@ -954,9 +1208,20 @@ impl AdjustPass {
.expect("ensured above")
.view
.clone();
let ran = self
let ran = match self
.detail
.encode(&mut enc, detail, &target_view, width, height)?;
.encode(&mut enc, detail, &target_view, width, height)
{
Ok(ran) => ran,
Err(e) => {
// Nothing is submitted, so a cache this frame was to write
// holds nothing, and must not be read as though it did.
if sampling == SampleUse::Write {
self.sample.release();
}
return Err(e);
}
};
self.ctx.queue.submit(Some(enc.finish()));
self.detail_dispatches += ran;
self.colour_key = Some((key, width, height));
@@ -1090,6 +1355,7 @@ impl AdjustPass {
self.detail.release_caches();
self.targets = [None, None];
self.colour_key = None;
self.sample.release();
}
/// How many distinct pipelines are compiled. Exposed for tests asserting
@@ -1244,6 +1510,16 @@ impl AdjustPass {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
},
// The sample cache is the display's; a camera-space tap
// reads its source directly (its flags are zero).
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
},
],
});
@@ -1898,6 +2174,62 @@ mod tests {
);
}
/// TRACES: FR-DEV-20
#[test]
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
// The shader half of perspective correction, end to end. A top-bright
// frame with a full vertical keystone spreads its top across the
// output, so the bright half reaches further down than the middle;
// and since the frame is mapped onto a trapezoid *inside* the source,
// no corner is left without a pixel behind it.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, true);
let plain = EditGraph::default_chain().compose();
let tex = pass.render(&img, &plain, 32, 32).expect("render");
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
assert!(
below_middle < 90,
"unkeyed, row 19 is the dark half: {below_middle}"
);
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_V,
100.0,
);
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_H,
100.0,
);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
assert_ne!(
read_pixel(&ctx, tex, x, y),
[0, 0, 0, 255],
"corner ({x},{y}) has no source pixel behind it"
);
}
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_V,
100.0,
);
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
assert!(
keyed > 128,
"the spread top half must reach row 19: {keyed}"
);
}
#[test]
fn dragging_the_crop_does_not_recompile() {
// The cache contract for framing, which is what makes an interactive
@@ -2568,6 +2900,131 @@ mod tests {
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
/// decoded JPEG is.
/// A frame with a different value at every pixel, several times the size
/// of the renders below, so that a fit view reads it on a stride and a
/// texel read from the wrong place cannot go unnoticed.
fn busy_image(ctx: &GpuContext) -> DemosaicedImage {
let (w, h) = (97u32, 61u32);
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 7;
data.extend_from_slice(&[n as u8, (n >> 8) as u8, (x * 2 + y) as u8, 255]);
}
}
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
}
/// Render `g` with a pass that has never seen it, which reads the source
/// directly by construction: the reference a cached frame must equal.
fn fresh(ctx: &GpuContext, img: &DemosaicedImage, g: &EditGraph) -> Vec<u8> {
let mut pass = AdjustPass::new(ctx);
let detail = g.compose_detail(img.size(), (23, 15));
pass.render_detailed(img, &g.compose(), 23, 15, None, &detail, 1)
.expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct);
pass.export_pixels().expect("read").0
}
#[test]
fn a_cached_sample_is_the_same_picture() {
// TRACES: NFR-P5
// The sample cache's whole claim: a frame that reads the source through
// it is bit-for-bit the frame that reads the source directly. Walked
// through each state — direct, writing, reading, reading after a
// slider moved, and direct again once the framing moves — against a
// fresh pass each time.
let Some(ctx) = ctx() else { return };
let img = busy_image(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut g = EditGraph::default_chain();
let frame = |pass: &mut AdjustPass, g: &EditGraph, key: u64| {
let detail = g.compose_detail(img.size(), (23, 15));
pass.render_detailed(&img, &g.compose(), 23, 15, None, &detail, key)
.expect("render");
(pass.sample.last_use, pass.export_pixels().expect("read").0)
};
for (i, (expected, value)) in [
(SampleUse::Direct, 0.3),
(SampleUse::Write, 0.4),
(SampleUse::Read, 0.5),
(SampleUse::Read, -0.7),
]
.into_iter()
.enumerate()
{
g.set_param(exposure::ID, exposure::EXPOSURE, value);
let (used, pixels) = frame(&mut pass, &g, i as u64);
assert_eq!(used, expected, "frame {i}");
assert_eq!(pixels, fresh(&ctx, &img, &g), "frame {i} ({used:?})");
}
// A neighbourhood operation: the fused pass writes the linear
// intermediate instead, through the same sampling.
g.set_param(
dr_pipeline::ops::noise_reduction::ID,
dr_pipeline::ops::noise_reduction::CHROMA,
60.0,
);
for i in 10..13 {
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.01);
let (used, pixels) = frame(&mut pass, &g, i);
assert_eq!(used, SampleUse::Read, "detail frame {i}");
assert_eq!(pixels, fresh(&ctx, &img, &g), "detail frame {i}");
}
// The framing moves: what was cached is for the old framing.
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::CROP_W, 0.6);
let (used, pixels) = frame(&mut pass, &g, 20);
assert_eq!(used, SampleUse::Direct, "a new framing reads directly");
assert_eq!(pixels, fresh(&ctx, &img, &g));
let (used, _) = frame(&mut pass, &g, 21);
assert_eq!(used, SampleUse::Write, "and caches once it holds still");
let (used, pixels) = frame(&mut pass, &g, 22);
assert_eq!(used, SampleUse::Read);
assert_eq!(pixels, fresh(&ctx, &img, &g));
}
#[test]
fn a_cache_is_not_read_for_another_image_or_size() {
// The key is the composer's half plus the two things only this side
// knows. A second photograph with the same edit and the same framing
// must not be shown the first one's texels.
let Some(ctx) = ctx() else { return };
let (a, b) = (busy_image(&ctx), grey_image(&ctx, 8000));
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
for _ in 0..3 {
pass.render(&a, &shader, 23, 15).expect("render");
}
assert_eq!(pass.sample.last_use, SampleUse::Read);
pass.render(&b, &shader, 23, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another image");
pass.render(&b, &shader, 23, 15).expect("render");
pass.render(&b, &shader, 24, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another size");
}
#[test]
fn a_straightened_frame_samples_directly() {
// Interpolated: the sample is a blend of four texels, which the cache's
// format could not hold exactly, so the composer offers no key.
let Some(ctx) = ctx() else { return };
let img = busy_image(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 3.0);
let shader = g.compose();
assert!(shader.sample_key.is_none());
for _ in 0..3 {
pass.render(&img, &shader, 23, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct);
}
}
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
let size = 16u32;
let mut data = Vec::with_capacity((size * size) as usize * 4);
+24
View File
@@ -95,6 +95,15 @@ pub struct DemosaicedImage {
base_curve: BaseCurve,
/// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool,
/// Which upload this is, unique for the life of the process. See
/// [`Self::id`].
id: u64,
}
/// The next [`DemosaicedImage::id`].
fn next_image_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl DemosaicedImage {
@@ -112,6 +121,18 @@ impl DemosaicedImage {
(self.width, self.height)
}
/// Which texture this is, as a number that is never reused.
///
/// For a cache that has to know it is still looking at the same pixels
/// (`AdjustPass`'s sample cache) without holding the texture alive to find
/// out: keeping a handle would keep half a gigabyte of a closed photograph
/// on the device, and comparing addresses would mistake a new upload for an
/// old one the moment the allocator reused the slot. A texture here is
/// never written after it is built, so the same id is the same pixels.
pub(crate) fn id(&self) -> u64 {
self.id
}
/// Camera RGB → linear sRGB, row-major. Identity where the body is
/// uncalibrated, so the image renders uncalibrated rather than black.
pub fn color_matrix(&self) -> [f32; 9] {
@@ -246,6 +267,7 @@ impl DemosaicedImage {
// the highlights of an image that was already finished.
base_curve: BaseCurve::IDENTITY,
non_linear: true,
id: next_image_id(),
})
}
}
@@ -322,6 +344,7 @@ impl DemosaicedImage {
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
base_curve: raw.base_curve,
non_linear: false,
id: next_image_id(),
})
}
}
@@ -660,6 +683,7 @@ impl Demosaicer {
// normalises against black and white levels and applies no
// transfer function.
non_linear: false,
id: next_image_id(),
})
}
}
+6 -13
View File
@@ -470,7 +470,7 @@ impl FocusPeakPass {
// TEXTURE_BINDING to be sampled by the compositor.
// RENDER_ATTACHMENT is not used by anything here and is required
// anyway: Slint rejects an imported texture without it. COPY_SRC
// is for `read_overlay` and its two callers.
// is for `read_overlay` and the tests that call it.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
@@ -490,18 +490,11 @@ impl FocusPeakPass {
/// TRACES: AC-8
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
///
/// **Two callers, and neither is the desktop display path.** The tests
/// below are one: an overlay is a claim about which pixels are sharp, and
/// there is no way to check that claim without looking at the pixels. The
/// other is the Android develop view, which reads the *frame* back for the
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain
/// tears a portrait window, so Slint is not drawing with wgpu there and no
/// texture can be handed over. An overlay that stayed on the device on a
/// platform where the picture underneath it does not would simply never be
/// seen.
///
/// On desktop nothing calls this, and ARCH §6.1 holds on the path that
/// matters: the overlay reaches the compositor as a texture.
/// **The tests below are the only caller, and never the display path.** An
/// overlay is a claim about which pixels are sharp, and there is no way to
/// check that claim without looking at the pixels. On screen, on desktop
/// and Android alike, the overlay reaches the compositor as a texture and
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
let Some(layer) = self.layers[self.current].as_ref() else {
return Err(GpuError::Readback("no overlay has been rendered".into()));
+1 -1
View File
@@ -6,7 +6,7 @@
//! module doc said for eight releases that it held no pipeline and no masks.
//! It holds both now, plus demosaic, detail, segmentation masks, two
//! histograms and focus peaking. The zero-copy claim is still the one that
//! matters, and TD-1 records the one platform where it does not hold.
//! matters, and since TD-1 was paid off it holds on Android too.
//!
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
+13
View File
@@ -395,6 +395,7 @@ pub struct MaskPass {
combine_layout: wgpu::BindGroupLayout,
combine_union: wgpu::RenderPipeline,
combine_subtract: wgpu::RenderPipeline,
combine_intersect: wgpu::RenderPipeline,
/// Where a part is drawn before it is joined.
///
/// One texture for the whole stack rather than one per layer, because
@@ -651,6 +652,16 @@ impl MaskPass {
"mask-combine-subtract",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
);
// TRACES: FR-DEV-19a
// `dst * src`: what the mask had, kept only in proportion to how much
// of it this part also covers. The same three vertices and the same
// scratch, so a third set operation is a third blend state and
// nothing more — which is what `Join::apply` states on the CPU and
// `the_joins_match_their_definition` holds this to.
let combine_intersect = combine(
"mask-combine-intersect",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
);
// The same, with the deposit thrown away: coverage is only ever taken
// off what earlier strokes on this layer put down. There is no negative
@@ -681,6 +692,7 @@ impl MaskPass {
combine_layout,
combine_union,
combine_subtract,
combine_intersect,
scratch: None,
array: None,
allocations: 0,
@@ -1376,6 +1388,7 @@ impl MaskPass {
pass.set_pipeline(match join {
Join::Union => &self.combine_union,
Join::Subtract => &self.combine_subtract,
Join::Intersect => &self.combine_intersect,
});
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
+113
View File
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
);
}
/// TRACES: FR-DEV-19a
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
/// part that half-covers it, joined each of the three ways. The base is the
/// left half of the frame and the part a dab in the middle, so the four
/// quarters of the table are four pixels.
#[test]
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let joined = |join: Join| {
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
assert!(layer.push_part(MaskPart::painted("p2", join)));
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&field))
};
// (base, part): left outside the dab, left inside, right inside, right
// outside.
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
assert_eq!(
lit(&joined(Join::Union)),
[true, true, true, false],
"union: either"
);
assert_eq!(
lit(&joined(Join::Subtract)),
[true, false, false, false],
"subtract: the base without the dab"
);
assert_eq!(
lit(&joined(Join::Intersect)),
[false, true, false, false],
"intersect: only where both are"
);
}
/// TRACES: FR-DEV-19a
/// Intersection on soft coverage is the product `Join::apply` defines, on
/// either side of the join: a gradient intersected with a region it fills is
/// the gradient there and nothing elsewhere, and a region intersected with a
/// gradient is the gradient wherever the region is.
#[test]
fn the_joins_match_their_definition() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let ramp = || MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 1.0,
};
let right_half = || MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
};
let draw = |layer: MaskLayer| {
let mut stack = MaskStack::new();
stack.push(layer);
render(&ctx, &stack, Some(&field))
};
let alone = draw(brighten(ramp()));
let mut ramp_then_region = brighten(ramp());
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
let ramp_then_region = draw(ramp_then_region);
let mut region_then_ramp = brighten(whole_frame());
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
let region_then_ramp = draw(region_then_ramp);
for x in 0..SIZE {
let y = SIZE / 2;
let want = luma_at(&alone, x, y);
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
// within two of the seam are left out: the region's own edge
// is soft there, so neither side of the table is 0 or 1.
let got = luma_at(&ramp_then_region, x, y);
if x.abs_diff(SIZE / 2) <= 2 {
// The seam.
} else if x > SIZE / 2 {
assert!(
got.abs_diff(want) <= 1,
"x={x}: the ramp survives where the region is ({got} vs {want})"
);
} else {
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
}
// 1 · src = src everywhere.
let got = luma_at(&region_then_ramp, x, y);
assert!(
got.abs_diff(want) <= 1,
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
);
}
}
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
/// A radial that covers the middle of the frame and nothing near the corners.
+102
View File
@@ -431,6 +431,24 @@ pub struct DetailPass {
pub storage: Vec<[f32; 4]>,
}
impl DetailPass {
/// Whether this pass hands on exactly what it was given: a full-size pass
/// with nothing to bind and a body with no code in it, only comments.
///
/// The composer drops such a pass where that is exact (see
/// [`compose_detail_with`]); the operation still emits it, because whether
/// dropping it is exact depends on what is around it in the chain.
pub fn is_identity(&self) -> bool {
self.output_scale <= 1
&& self.storage.is_empty()
&& self
.wgsl
.lines()
.map(|l| l.split("//").next().unwrap_or("").trim())
.all(str::is_empty)
}
}
/// TRACES: FR-DEV-3 | FR-DEV-8
/// An operation that reads pixels other than the one it is writing.
///
@@ -646,6 +664,40 @@ pub fn compose_detail_with(
};
}
// TRACES: NFR-P5
// A pass whose body is empty changes nothing but where the pixels are: it
// reads the intermediate and writes the same values to the other one.
// Capture sharpening emits exactly that at a scale too coarse to draw its
// radius (`nothing_to_sharpen`), and at fit on any modern sensor that is
// most of the time — so an edit with sharpening *and* another kernel paid
// a full render-sized read and write for it on every frame, 4.4 ms of a
// 2560 x 1600 frame on the reference laptop with its clocks held down.
//
// Dropped here, where the chain is still a list, and only where dropping
// it is exact:
//
// - **Not the last pass.** The last pass performs the output transform on
// what it read from an `rgba16float` intermediate. Moving that transform
// onto the pass before would apply it to that pass's `f32` result
// instead, which is a different rounding of the same picture.
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
// is what stops the next operation reading the last one's base. None of
// today's operations leave a reduced chain open, but a declared one may.
//
// Everywhere else the pass before and the pass after exchange the same
// `rgba16float` texels either way, `aux` included.
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
let total = planned.len();
for (position, entry) in planned.into_iter().enumerate() {
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
let droppable = position + 1 < total && after_full && entry.2.is_identity();
if !droppable {
kept.push(entry);
}
}
let planned = kept;
let last = planned.len().saturating_sub(1);
let passes = planned
.into_iter()
@@ -1211,6 +1263,56 @@ mod tests {
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
}
#[test]
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
// TRACES: NFR-P5
// Capture sharpening at a scale too coarse to draw its radius emits a
// pass with an empty body. Between two other passes it costs a
// render-sized read and write and changes no texel, so it goes; as the
// last pass it performs the output transform on the intermediate, and
// moving that onto the pass before would round differently, so it
// stays.
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction};
let sharpen = || -> Box<dyn Operation> {
let mut op = CaptureSharpen::new();
op.set_param(capture_sharpen::AMOUNT, 60.0);
Box::new(op)
};
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
// quarter of a render pixel.
let scale = RenderScale::new((1500, 1000), (6000, 4000));
let unresolved = sharpen().detail().expect("a detail stage").passes(scale);
assert!(
unresolved.len() == 1 && unresolved[0].is_identity(),
"the premise: sharpening at this scale is one pass that does nothing"
);
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> {
compose_detail(ops, scale, dr_types::ColourSpace::Srgb)
.passes
.iter()
.map(|p| p.label.clone())
.collect()
};
// First, ahead of the chroma passes: dropped.
let first = labels(&[sharpen(), chroma()]);
assert_eq!(
first,
[
"noise_reduction/chroma-horizontal",
"noise_reduction/chroma-vertical"
]
);
// Last, after them: kept, and it is the pass that encodes.
let last = labels(&[chroma(), sharpen()]);
assert_eq!(last.len(), 3);
assert_eq!(last[2], "capture_sharpen/unresolved");
// Alone: kept, because the fused pass stopped short and something has
// to finish the frame.
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
}
#[test]
fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() {
// What makes an unsharp mask — sharpening, clarity, texture, dehaze —
+681 -9
View File
@@ -27,6 +27,33 @@
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
//! corner. Neither stage needs to know the other exists.
//!
//! # Perspective sits inside framing (FR-DEV-20)
//!
//! A keystone correction is composition too — straightening converging
//! verticals reframes the photograph — so it is a step *of* framing rather
//! than a stage beside it, and inherits framing's `Compose` attribute, its
//! place in the sidecar and its exclusion from a default paste. Expanded, the
//! chain reads:
//!
//! ```text
//! output pixel → crop → straighten → perspective → orientation → warp (lens) → sample
//! ```
//!
//! After the straightening, because the angle is a nudge applied to the
//! corrected picture: the verticals are made parallel and *then* the whole is
//! levelled. Before the stored orientation, because "vertical" means vertical
//! in the photograph as it is shown — a portrait frame the camera stored on
//! its side must converge along its displayed height, not along the sensor's
//! rows. And before the lens warp, which still sees the whole frame it
//! corrects, for the reason given above.
//!
//! The correction maps the output frame onto a trapezoid **inside** the
//! source rather than pulling the source edges in. So a keystone on its own
//! never exposes an empty corner, and the crop the user drew is still valid
//! after it; only in combination with a straightening angle does the
//! inscribed crop have anything to account for — see
//! [`Framing::max_inscribed_crop`].
//!
//! # Why sampling changes with the angle
//!
//! At 90° steps and flips, output pixels land exactly on source pixels, so
@@ -58,6 +85,13 @@ pub const CROP_X: ParamId = ParamId("crop_x");
pub const CROP_Y: ParamId = ParamId("crop_y");
pub const CROP_W: ParamId = ParamId("crop_w");
pub const CROP_H: ParamId = ParamId("crop_h");
/// TRACES: FR-DEV-20
/// Vertical keystone. Positive spreads the top of the frame — the correction
/// for a building photographed looking up, whose verticals lean together.
pub const KEYSTONE_V: ParamId = ParamId("keystone_v");
/// TRACES: FR-DEV-20
/// Horizontal keystone. Positive spreads the right-hand side of the frame.
pub const KEYSTONE_H: ParamId = ParamId("keystone_h");
/// Widest straightening the control offers, in degrees either way.
///
@@ -66,12 +100,28 @@ pub const CROP_H: ParamId = ParamId("crop_h");
/// the edits actually are.
pub const MAX_STRAIGHTEN: f32 = 45.0;
/// The keystone sliders' travel either way.
///
/// A plain amount rather than degrees of tilt: the angle a camera was tilted
/// by depends on a focal length the correction does not know, and a number
/// that claimed to be one would be wrong for every lens but one.
pub const MAX_KEYSTONE: f32 = 100.0;
/// How far a full keystone narrows the far edge of the frame, as a fraction
/// of its width: at `MAX_KEYSTONE` the source trapezoid's short side is half
/// its long one.
///
/// Enough for a tall building from its own pavement, and short of the point
/// where the stretched edge is so magnified that the correction reads as a
/// fault of its own.
const KEYSTONE_REACH: f64 = 0.5;
/// The parameters the framing widget owns — every one of them.
///
/// In the order the widget expects: the rect first, then the angle it is
/// straightened by, then the exact reorientations.
static FRAMING_PARAMS: [ParamId; 8] = [
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
static FRAMING_PARAMS: [ParamId; 10] = [
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, KEYSTONE_V, KEYSTONE_H, ROTATION, FLIP_H, FLIP_V,
];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
@@ -117,6 +167,30 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
// TRACES: FR-DEV-20
// Perspective. Last so every sidecar written before these existed
// reads exactly as it did: a missing parameter is its default, and
// the default is no correction.
ParamDescriptor::scalar(
"keystone_v",
"param.keystone_v",
-MAX_KEYSTONE,
MAX_KEYSTONE,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"keystone_h",
"param.keystone_h",
-MAX_KEYSTONE,
MAX_KEYSTONE,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
})
});
@@ -311,6 +385,86 @@ fn finite(v: f32, fallback: f32) -> f32 {
}
}
/// TRACES: FR-DEV-20
/// A plane projective map, row-major, acting on `(x, y, 1)`.
///
/// Held in `f64` because it is built by solving for four corners and then
/// inverted for [`Framing::output_at`]; the shader gets `f32` copies of the
/// forward map only.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Homography([[f64; 3]; 3]);
impl Homography {
/// The map taking the square `[-0.5, 0.5]²` onto the quadrilateral whose
/// corners are `q`, listed top-left, top-right, bottom-right, bottom-left.
///
/// Heckbert's closed form for the unit square, composed with the shift
/// from the centred square onto it.
fn square_to_quad(q: [(f64, f64); 4]) -> Self {
let [(x0, y0), (x1, y1), (x2, y2), (x3, y3)] = q;
let (dx1, dx2, dx3) = (x1 - x2, x3 - x2, x0 - x1 + x2 - x3);
let (dy1, dy2, dy3) = (y1 - y2, y3 - y2, y0 - y1 + y2 - y3);
let den = dx1 * dy2 - dx2 * dy1;
let (g, h) = if den.abs() < 1e-12 {
(0.0, 0.0)
} else {
((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den)
};
// Unit square (u, v) -> quad.
let unit = [
[x1 - x0 + g * x1, x3 - x0 + h * x3, x0],
[y1 - y0 + g * y1, y3 - y0 + h * y3, y0],
[g, h, 1.0],
];
// Centred square -> unit square is `u = x + 0.5`, so fold the shift
// into the constant column.
let mut m = unit;
for row in &mut m {
row[2] += 0.5 * (row[0] + row[1]);
}
Self(m)
}
/// Where `(x, y)` lands, or `None` past the line the map sends to
/// infinity — a point with no image, which the caller treats as outside
/// the source.
fn apply(&self, (x, y): (f64, f64)) -> Option<(f64, f64)> {
let m = &self.0;
let w = m[2][0] * x + m[2][1] * y + m[2][2];
if w <= 1e-9 {
return None;
}
Some((
(m[0][0] * x + m[0][1] * y + m[0][2]) / w,
(m[1][0] * x + m[1][1] * y + m[1][2]) / w,
))
}
/// The inverse map, by the adjugate. Scale is irrelevant to a projective
/// map, so the determinant is only divided out to keep `w` positive and
/// near one — which is what [`Self::apply`]'s horizon test relies on.
fn inverse(&self) -> Self {
let m = &self.0;
let c = |r0: usize, c0: usize, r1: usize, c1: usize| {
m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]
};
let adj = [
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
];
let det = m[0][0] * adj[0][0] + m[0][1] * adj[1][0] + m[0][2] * adj[2][0];
let det = if det.abs() < 1e-12 { 1.0 } else { det };
let mut inv = adj;
for row in &mut inv {
for v in row.iter_mut() {
*v /= det;
}
}
Self(inv)
}
}
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// Crop, straighten, rotation and flips for one image.
///
@@ -325,6 +479,10 @@ pub struct Framing {
quarter_turns: u8,
flip_h: bool,
flip_v: bool,
/// TRACES: FR-DEV-20
/// Vertical and horizontal keystone, each `-MAX_KEYSTONE..=MAX_KEYSTONE`.
keystone_v: f32,
keystone_h: f32,
/// TRACES: FR-DEV-3h
/// How the file's pixels were stored, from its EXIF orientation.
///
@@ -376,6 +534,8 @@ impl Default for Framing {
quarter_turns: 0,
flip_h: false,
flip_v: false,
keystone_v: 0.0,
keystone_h: 0.0,
baseline: dr_types::Orientation::NORMAL,
crop: CropRect::default(),
view: CropRect::default(),
@@ -396,7 +556,7 @@ impl Framing {
/// How framing would like to be presented.
///
/// **This is what stops a frontend having to name this stage.** Rendered
/// generically these eight parameters are eight bad controls: four crop
/// generically these ten parameters are ten bad controls: four crop
/// edges the photographer would have to type coordinates into, a "rotate"
/// slider running 0..3, and two switches. Every one of them is a worse
/// control than the gesture it stands for — a crop is dragged on the
@@ -407,10 +567,10 @@ impl Framing {
/// a second frontend would have had to learn the same special case, and
/// nothing in the capability output said why. Now the preference is
/// declared, the demand says what the widget needs, and a frontend that
/// cannot meet it falls back to the eight sliders — tedious, but complete,
/// cannot meet it falls back to the ten sliders — tedious, but complete,
/// which is the guarantee the whole hint mechanism rests on.
///
/// The widget owns **all eight** parameters rather than only the rect: a
/// The widget owns **all ten** parameters rather than only the rect: a
/// frontend that takes this on is taking on the whole framing control
/// surface, and leaving rotation and the flips behind would scatter them
/// into the generated panel underneath a crop control that already exists.
@@ -476,6 +636,52 @@ impl Framing {
(self.flip_h, self.flip_v)
}
/// TRACES: FR-DEV-20
/// The vertical and horizontal keystone, as the sliders show them.
pub fn keystone(&self) -> (f32, f32) {
(self.keystone_v, self.keystone_h)
}
/// Whether a perspective correction is applied at all.
pub fn has_keystone(&self) -> bool {
self.keystone_v != 0.0 || self.keystone_h != 0.0
}
/// TRACES: FR-DEV-20
/// The perspective map, from the straightened output frame to the upright
/// source frame, both measured as the centred square `[-0.5, 0.5]²`.
///
/// **Measured in fractions of the frame, not in the aspect-scaled space
/// the rest of the prologue works in**, so the map is the same for every
/// frame shape and the uniforms need no image size. The prologue divides
/// `p.x` by the frame's aspect on the way in and multiplies it back on
/// the way out.
///
/// The output frame's corners go to a trapezoid inside the source: a
/// positive vertical keystone brings the top corners in, so the top of
/// the source is spread across the full width of the output and lines
/// that converged upward come out parallel. Nothing is ever mapped from
/// outside the source, which is why a keystone alone needs no crop.
fn keystone_map(&self) -> Option<Homography> {
if !self.has_keystone() {
return None;
}
let amount = |v: f32| f64::from(v / MAX_KEYSTONE).clamp(-1.0, 1.0) * KEYSTONE_REACH;
let (tv, th) = (amount(self.keystone_v), amount(self.keystone_h));
// How much of each edge survives: the top and bottom rows' widths,
// the left and right columns' heights.
let top = 1.0 - tv.max(0.0);
let bottom = 1.0 + tv.min(0.0);
let right = 1.0 - th.max(0.0);
let left = 1.0 + th.min(0.0);
Some(Homography::square_to_quad([
(-0.5 * top, -0.5 * left),
(0.5 * top, -0.5 * right),
(0.5 * bottom, 0.5 * right),
(-0.5 * bottom, 0.5 * left),
]))
}
/// Add quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) {
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
@@ -544,6 +750,7 @@ impl Framing {
pub fn is_active(&self) -> bool {
let (turns, flip_h, flip_v) = self.effective();
self.angle != 0.0
|| self.has_keystone()
// Effective, not the user's: a file stored sideways needs the
// prologue emitted even on an untouched image, or it renders
// through the identity map and lies on its side.
@@ -572,6 +779,7 @@ impl Framing {
/// edited, the file was merely read correctly.
pub fn edits_image(&self) -> bool {
self.angle != 0.0
|| self.has_keystone()
|| self.quarter_turns != 0
|| self.flip_h
|| self.flip_v
@@ -591,7 +799,9 @@ impl Framing {
/// warp being active forces interpolation regardless, which is the
/// composer's call to make rather than this stage's.
pub fn needs_interpolation(&self) -> bool {
self.angle != 0.0
// A keystone stretches the frame by a different amount at every row,
// so it lands between pixels everywhere but on its centre line.
self.angle != 0.0 || self.has_keystone()
}
pub fn set_param(&mut self, id: ParamId, value: f32) {
@@ -629,6 +839,8 @@ impl Framing {
}
.normalised()
}
KEYSTONE_V => self.keystone_v = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
KEYSTONE_H => self.keystone_h = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
_ => log::warn!("framing: unknown parameter {id}"),
}
}
@@ -643,6 +855,8 @@ impl Framing {
CROP_Y => self.crop.y,
CROP_W => self.crop.width,
CROP_H => self.crop.height,
KEYSTONE_V => self.keystone_v,
KEYSTONE_H => self.keystone_h,
_ => 0.0,
}
}
@@ -707,8 +921,22 @@ impl Framing {
///
/// The standard largest-inscribed-rectangle result for a rotated
/// rectangle of the same aspect ratio.
///
/// TRACES: FR-DEV-20
/// **With a keystone the closed form no longer applies**: the area with a
/// source pixel behind it is the source rectangle pulled back through the
/// perspective map and then turned, a quadrilateral no textbook result
/// describes. That case is searched instead — see
/// [`Self::inscribed_by_search`]. A keystone alone never needs a crop, so
/// the search returns the whole frame for it, exactly.
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
if self.angle == 0.0 || width == 0 || height == 0 {
if width == 0 || height == 0 {
return CropRect::default();
}
if self.has_keystone() {
return self.inscribed_by_search(width, height);
}
if self.angle == 0.0 {
return CropRect::default();
}
@@ -750,6 +978,123 @@ impl Framing {
.normalised()
}
/// TRACES: FR-DEV-20
/// The largest centred crop with a source pixel behind every point, found
/// by search rather than by formula.
///
/// The area that has a source pixel behind it is convex — the source
/// rectangle pulled back through a projective map whose horizon lies
/// outside it, then turned — and a rectangle lies inside a convex region
/// exactly when its four corners do. For a given width the tallest
/// rectangle that fits is therefore found by bisection, and the area
/// `width × tallest(width)` is unimodal in the width (a positive concave
/// function times a line), so a golden-section search finds the best
/// width. Every rectangle returned has been tested corner by corner, so
/// the answer errs inside, never outside.
///
/// A few hundred corner tests, on a gesture's release, is nothing next to
/// the render that follows it.
fn inscribed_by_search(&self, width: u32, height: u32) -> CropRect {
let (w, h) = if self.swaps_axes() {
(f64::from(height), f64::from(width))
} else {
(f64::from(width), f64::from(height))
};
let fa = w / h;
let rad = f64::from(self.angle).to_radians();
let (sn, cs) = (rad.sin(), rad.cos());
let map = self.keystone_map();
// Whether the output point `(fx, fy)`, in fractions of the frame from
// its centre, has a source pixel behind it. The prologue's steps, in
// its order, stopping short of the turns: those are a permutation of
// the frame and cannot move a point across its edge.
let defined = |fx: f64, fy: f64| {
let p = (fx * fa, fy);
let q = (p.0 * cs - p.1 * sn, p.0 * sn + p.1 * cs);
let n = (q.0 / fa, q.1);
let src = match &map {
Some(m) => m.apply(n),
None => Some(n),
};
const EDGE: f64 = 0.5 + 1e-9;
src.is_some_and(|(x, y)| x.abs() <= EDGE && y.abs() <= EDGE)
};
let fits = |hw: f64, hh: f64| {
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.iter()
.all(|(sx, sy)| defined(sx * hw, sy * hh))
};
if fits(0.5, 0.5) {
return CropRect::default();
}
// Half the tallest height that fits at half-width `hw`.
let tallest = |hw: f64| {
if fits(hw, 0.5) {
return 0.5;
}
if !fits(hw, 0.0) {
return 0.0;
}
let (mut lo, mut hi) = (0.0, 0.5);
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if fits(hw, mid) {
lo = mid;
} else {
hi = mid;
}
}
lo
};
let area = |hw: f64| hw * tallest(hw);
let ratio = (5.0_f64.sqrt() - 1.0) * 0.5;
let (mut a, mut b) = (0.0, 0.5);
let mut c = b - ratio * (b - a);
let mut d = a + ratio * (b - a);
let (mut fc, mut fd) = (area(c), area(d));
for _ in 0..48 {
if fc < fd {
a = c;
c = d;
fc = fd;
d = a + ratio * (b - a);
fd = area(d);
} else {
b = d;
d = c;
fd = fc;
c = b - ratio * (b - a);
fc = area(c);
}
}
let hw = 0.5 * (a + b);
let hh = tallest(hw);
// Tested at `hw` as returned, so a width that the search's last step
// nudged past the boundary cannot come back with a height that no
// longer fits it.
let (hw, hh) = if hh > 0.0 && fits(hw, hh) {
(hw, hh)
} else {
(c.min(d), tallest(c.min(d)))
};
let fw = (2.0 * hw) as f32;
let fh = (2.0 * hh) as f32;
let fw = fw.clamp(CropRect::MIN_EXTENT, 1.0);
let fh = fh.clamp(CropRect::MIN_EXTENT, 1.0);
CropRect {
x: (1.0 - fw) * 0.5,
y: (1.0 - fh) * 0.5,
width: fw,
height: fh,
}
.normalised()
}
/// TRACES: FR-DEV-3
/// Where an output point comes from in the source, both in normalised
/// `0..1` coordinates.
@@ -782,6 +1127,15 @@ impl Framing {
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
}
if let Some(m) = self.keystone_map() {
p = match m.apply((f64::from(p.0 / fx), f64::from(p.1))) {
Some((x, y)) => (x as f32 * fx, y as f32),
// Beyond the map's horizon: no source point at all, reported
// as one far outside the frame rather than as a NaN.
None => (1e6, 1e6),
};
}
let (turns, flip_h, flip_v) = self.effective();
p = match turns {
1 => (p.1 * ax, -p.0 / fx),
@@ -824,6 +1178,13 @@ impl Framing {
_ => p,
};
if let Some(m) = self.keystone_map() {
p = match m.inverse().apply((f64::from(p.0 / fx), f64::from(p.1))) {
Some((x, y)) => (x as f32 * fx, y as f32),
None => (1e6, 1e6),
};
}
if self.angle != 0.0 {
let rad = -self.angle * PI / 180.0;
let (s, c) = (rad.sin(), rad.cos());
@@ -865,6 +1226,19 @@ impl Framing {
// identical whether or not the user is zoomed in, and costs no extra
// uniform slot.
let rect = self.visible_rect();
// The perspective map by columns, so the prologue can apply it as
// three multiply-adds. The identity when there is none: the slots
// exist either way and the prologue does not read them.
let m = self
.keystone_map()
.unwrap_or(Homography([
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
]))
.0;
let col = |c: usize| [m[0][c] as f32, m[1][c] as f32, m[2][c] as f32, 0.0];
let [c0, c1, c2] = [col(0), col(1), col(2)];
[
rect.x,
rect.y,
@@ -874,6 +1248,18 @@ impl Framing {
rad.cos(),
0.0,
0.0,
c0[0],
c0[1],
c0[2],
c0[3],
c1[0],
c1[1],
c1[2],
c1[3],
c2[0],
c2[1],
c2[2],
c2[3],
]
}
@@ -972,6 +1358,28 @@ impl Framing {
);
}
if self.has_keystone() {
// TRACES: FR-DEV-20
// After the straightening and before the turns, so the keystone
// acts on the photograph as it is shown. The map is measured in
// fractions of the frame (see `Framing::keystone_map`), hence the
// aspect divided out and put back. A point past the map's horizon
// has no source at all and is sent far outside it, where the
// sampler's bounds test renders it void.
s.push_str(
"
// Perspective: the straightened frame onto a trapezoid of the source.
let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);
let key_h = u.keystone_c0.xyz * key_n.x + u.keystone_c1.xyz * key_n.y + u.keystone_c2.xyz;
p = select(
vec2<f32>(1.0e6),
vec2<f32>(key_h.x / key_h.z * frame_aspect.x, key_h.y / key_h.z),
key_h.z > 1.0e-6,
);
",
);
}
// The user's turns and mirrors composed with the file's stored
// orientation. One permutation covers both, so honouring the EXIF tag
// adds no per-pixel work over an untagged file.
@@ -1040,13 +1448,15 @@ impl Framing {
| u64::from(flip_v) << 3
| u64::from(turns) << 4
| u64::from(self.is_active()) << 6
| u64::from(self.has_keystone()) << 7
}
}
/// Floats the framing block occupies in the generated uniform struct.
///
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
/// Five `vec4`s: the crop rect, the angle's sin/cos with padding, and the
/// perspective map's three columns, each padded.
pub const FRAMING_UNIFORM_FIELDS: usize = 20;
#[cfg(test)]
mod tests {
@@ -2025,6 +2435,8 @@ mod tests {
(CROP_Y, 0.2),
(CROP_W, 0.5),
(CROP_H, 0.4),
(KEYSTONE_V, 35.0),
(KEYSTONE_H, -20.0),
] {
f.set_param(id, v);
assert_eq!(f.param(id), v, "{id} did not round-trip");
@@ -2241,6 +2653,8 @@ mod tests {
f.rotate_quarters(turns);
f.set_param(FLIP_H, 1.0);
f.set_param(FLIP_V, 1.0);
f.set_param(KEYSTONE_V, 60.0);
f.set_param(KEYSTONE_H, -25.0);
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
let src = f.source_at(out, SRC.0, SRC.1);
@@ -2316,6 +2730,27 @@ mod tests {
"the three-turn permutation moved; `source_at` must move with it"
);
// The perspective step: the map applied in fractions of the frame,
// which is what `source_at` divides the aspect out for.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 40.0);
let prologue = f.wgsl_prologue();
assert!(
prologue.contains("let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);")
&& prologue.contains("key_h.x / key_h.z * frame_aspect.x"),
"the perspective step moved; `source_at` must move with it"
);
// After the straightening and before the turns, as `source_at` has it.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 40.0);
f.set_param(ANGLE, 3.0);
f.rotate_quarters(1);
let prologue = f.wgsl_prologue();
let straighten = prologue.find("// Straighten").unwrap();
let keystone = prologue.find("// Perspective").unwrap();
let turn = prologue.find("90° clockwise").unwrap();
assert!(straighten < keystone && keystone < turn, "{prologue}");
// And the sampler's last step, which lives in `operation.rs` and is
// the half of the map this file does not emit.
assert!(
@@ -2323,4 +2758,241 @@ mod tests {
"the sampler's return to texture coordinates moved"
);
}
// ---- perspective (FR-DEV-20) -----------------------------------------
/// A grid over the whole output frame, edges included.
fn grid() -> impl Iterator<Item = (f32, f32)> {
(0..=10).flat_map(|j| (0..=10).map(move |i| (i as f32 / 10.0, j as f32 / 10.0)))
}
fn inside(p: (f32, f32)) -> bool {
(-1e-4..=1.0 + 1e-4).contains(&p.0) && (-1e-4..=1.0 + 1e-4).contains(&p.1)
}
#[test]
fn a_keystone_is_an_edit_and_a_resample() {
let mut f = Framing::new();
let neutral = f.structure_key();
f.set_param(KEYSTONE_V, 30.0);
assert!(f.is_active());
assert!(f.edits_image(), "a keystone must light the modified dot");
assert!(f.needs_interpolation());
assert_ne!(f.structure_key(), neutral);
assert!(f.wgsl_prologue().contains("u.keystone_c0"));
// Neither the output size nor the crop moves: the frame is reshaped
// inside itself, so what the user cropped stays cropped.
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
assert!(f.crop().is_full());
}
#[test]
fn the_keystone_magnitude_does_not_reach_the_structure_key() {
// Dragging the slider is a uniform upload, never a shader build.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 10.0);
let key = f.structure_key();
for (v, h) in [(80.0, 0.0), (-45.0, 30.0), (1.0, -100.0)] {
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
assert_eq!(f.structure_key(), key, "{v}/{h} forced a recompile");
}
}
#[test]
fn the_keystone_is_clamped_to_its_travel() {
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 1e9);
f.set_param(KEYSTONE_H, f32::NAN);
assert_eq!(f.keystone(), (MAX_KEYSTONE, 0.0));
assert!(f.uniforms().iter().all(|v| v.is_finite()));
}
#[test]
fn a_keystone_alone_never_reaches_outside_the_source() {
// The design decision the crop relies on: the output frame is mapped
// onto a trapezoid *inside* the source, so no corner goes empty and
// a crop drawn before the keystone is still a crop of the picture.
for (v, h) in [
(100.0, 0.0),
(-100.0, 0.0),
(0.0, 100.0),
(0.0, -100.0),
(100.0, 100.0),
(-100.0, 100.0),
(37.0, -64.0),
] {
for turns in 0..4 {
let mut f = Framing::new();
f.rotate_quarters(turns);
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
for out in grid() {
let src = f.source_at(out, SRC.0, SRC.1);
assert!(inside(src), "{v}/{h}, {turns} turn(s): {out:?} -> {src:?}");
}
assert!(f.max_inscribed_crop(SRC.0, SRC.1).is_full());
}
}
}
#[test]
fn a_vertical_keystone_makes_upward_converging_lines_parallel() {
// What the control is for. Output columns are straight verticals;
// with a positive keystone each must come from a straight source line
// that leans in toward the centre as it rises — the shape a building
// has when photographed looking up.
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 60.0);
for x in [0.1f32, 0.3, 0.7, 0.9] {
let bottom = f.source_at((x, 1.0), SRC.0, SRC.1);
let middle = f.source_at((x, 0.5), SRC.0, SRC.1);
let top = f.source_at((x, 0.0), SRC.0, SRC.1);
// Straight: the middle sits on the line through the two ends.
let cross = (top.0 - bottom.0) * (middle.1 - bottom.1)
- (top.1 - bottom.1) * (middle.0 - bottom.0);
assert!(cross.abs() < 1e-4, "column {x} is not a straight line");
// Leaning in: the top is nearer the centre than the bottom.
assert!(
(top.0 - 0.5).abs() < (bottom.0 - 0.5).abs(),
"column {x}: top {top:?} is not inside bottom {bottom:?}"
);
}
// The bottom row is left where it was; the top row is the one spread.
close(
f.source_at((0.0, 1.0), SRC.0, SRC.1),
(0.0, 1.0),
"bottom-left",
);
close(
f.source_at((0.0, 0.0), SRC.0, SRC.1),
(0.15, 0.0),
"top-left",
);
}
#[test]
fn a_horizontal_keystone_spreads_the_right_hand_side() {
let mut f = Framing::new();
f.set_param(KEYSTONE_H, 100.0);
// The right-hand column comes from half the source's height.
close(
f.source_at((1.0, 0.0), SRC.0, SRC.1),
(1.0, 0.25),
"top-right",
);
close(
f.source_at((1.0, 1.0), SRC.0, SRC.1),
(1.0, 0.75),
"bottom-right",
);
close(
f.source_at((0.0, 0.0), SRC.0, SRC.1),
(0.0, 0.0),
"top-left",
);
}
#[test]
fn the_keystone_acts_on_the_frame_as_shown() {
// A portrait frame the camera stored on its side: "vertical" is the
// frame's displayed height, so the same keystone must move the same
// *displayed* points whatever the file's stored orientation.
let mut upright = Framing::new();
upright.set_param(KEYSTONE_V, 50.0);
let mut sideways = Framing::new();
sideways.set_baseline(dr_types::Orientation::from_exif(6));
sideways.set_param(KEYSTONE_V, 50.0);
// Compare in the displayed frame: map the sideways result back
// through the orientation alone.
let mut turn_only = Framing::new();
turn_only.set_baseline(dr_types::Orientation::from_exif(6));
for out in grid() {
let a = upright.source_at(out, SRC.1, SRC.0);
let b = turn_only.output_at(sideways.source_at(out, SRC.0, SRC.1), SRC.0, SRC.1);
close(a, b, "the keystone turned with the file");
}
}
#[test]
fn the_inscribed_crop_accounts_for_the_keystone() {
// Straightening a keystoned frame: the empty area is no longer the
// rotated rectangle's, and the crop must avoid the area that is.
for (angle, v, h) in [
(5.0f32, 50.0f32, 0.0f32),
(-8.0, -70.0, 20.0),
(12.0, 100.0, 100.0),
(2.0, 0.0, -40.0),
] {
for turns in [0, 1] {
let mut f = Framing::new();
f.rotate_quarters(turns);
f.set_param(ANGLE, angle);
f.set_param(KEYSTONE_V, v);
f.set_param(KEYSTONE_H, h);
let c = f.max_inscribed_crop(SRC.0, SRC.1);
assert!(
c.width > 0.3 && c.height > 0.3 && !c.is_full(),
"{angle}°/{v}/{h}: {c:?}"
);
assert!(
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4
&& ((c.y + c.height * 0.5) - 0.5).abs() < 1e-4,
"{c:?} is not centred"
);
// Every point of it, edges included, has a source pixel.
f.set_crop(c);
for out in grid() {
let src = f.source_at(out, SRC.0, SRC.1);
assert!(inside(src), "{angle}°/{v}/{h}: {out:?} -> {src:?}");
}
}
}
}
#[test]
fn the_inscribed_crop_with_a_keystone_is_not_needlessly_small() {
// The search must find the best rectangle, not merely a safe one. A
// tenth larger in either direction has to reach outside the source.
let mut f = Framing::new();
f.set_param(ANGLE, 6.0);
f.set_param(KEYSTONE_V, 60.0);
let c = f.max_inscribed_crop(SRC.0, SRC.1);
for (gw, gh) in [(1.1, 1.0), (1.0, 1.1)] {
let mut g = f;
let (w, h) = (c.width * gw, c.height * gh);
g.set_crop(CropRect {
x: 0.5 - w * 0.5,
y: 0.5 - h * 0.5,
width: w,
height: h,
});
let spills = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
.into_iter()
.any(|out| !inside(g.source_at(out, SRC.0, SRC.1)));
// Either the grown rect spills, or it could not grow at all
// because the crop was already at the frame's edge on that axis.
assert!(
spills
|| (gw > 1.0 && c.width >= 1.0 - 1e-4)
|| (gh > 1.0 && c.height >= 1.0 - 1e-4),
"{c:?} grown by {gw}x{gh} still fits"
);
}
}
#[test]
fn reset_clears_the_keystone() {
let mut f = Framing::new();
f.set_param(KEYSTONE_V, 30.0);
f.set_param(KEYSTONE_H, -30.0);
f.reset();
assert_eq!(f.keystone(), (0.0, 0.0));
assert!(!f.is_active());
}
}
+2 -1
View File
@@ -44,6 +44,7 @@ pub mod mask;
pub mod neutral;
pub mod operation;
pub mod ops;
pub mod orphan;
pub mod preset;
pub mod sidecar;
pub mod spot;
@@ -66,7 +67,7 @@ pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
RESERVED_UNIFORM_FIELDS,
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
};
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
pub use sidecar::{Sidecar, Version};
+111 -3
View File
@@ -926,6 +926,21 @@ pub enum Join {
/// erase stroke is a hole in the part it was painted into and reads as
/// nothing at all.
Subtract,
/// TRACES: FR-DEV-19a
/// Only where both agree. "Keep the part of this mask that is also that."
///
/// The join that makes cheap criteria precise: a sky is a category *and*
/// a luminance band, skin is a subject *and* a hue. Neither alone is the
/// selection, and no feather on either makes it one.
///
/// The product of the two coverages rather than their minimum, because
/// that is what one fixed-function blend gives on the device
/// (`dst · src`, `docs/dev/mask-editing.md` §5.2) and it agrees with the
/// minimum wherever either side is fully in or fully out. Between two soft
/// edges it is the softer of the two readings, which is the right way to
/// be wrong: an overlap of two partial selections is less certainly
/// selected than either.
Intersect,
}
impl Join {
@@ -933,6 +948,7 @@ impl Join {
match self {
Self::Union => "union",
Self::Subtract => "subtract",
Self::Intersect => "intersect",
}
}
@@ -940,12 +956,30 @@ impl Join {
Some(match name {
"union" => Self::Union,
"subtract" => Self::Subtract,
"intersect" => Self::Intersect,
_ => return None,
})
}
/// Every variant, for a UI building a choice control.
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract];
/// TRACES: FR-DEV-19a
/// The coverage this join leaves at one point, given what the mask had
/// there (`dst`) and what the part covers (`src`), both in `0..=1`.
///
/// The definition the device's blend states implement, spelled out once
/// on the CPU so a test can hold the GPU to it and a reader can see the
/// three set operations side by side without reading `wgpu` enums.
pub fn apply(self, dst: f32, src: f32) -> f32 {
match self {
Self::Union => dst.max(src),
Self::Subtract => dst * (1.0 - src),
Self::Intersect => dst * src,
}
}
/// Every variant, for a UI building a choice control. The order is the
/// panel's: a chip cycles through it and a stored index names a place in
/// it, so a new join goes on the end.
pub const ALL: [Join; 3] = [Join::Union, Join::Subtract, Join::Intersect];
}
/// One selection inside a layer's mask.
@@ -1580,9 +1614,20 @@ impl MaskLayer {
// A hidden part is not in the build, whichever way it joins — and a
// hidden base hands its role to the first part that is shown, which
// is why "adds" is asked of the shown parts rather than of index 0.
//
// Folded rather than asked with `any`, because an intersection can
// take away everything the parts before it added: a subject
// intersected with an unpainted brush covers nothing. An inverted
// part is taken to cover, whatever its source — an inverted empty
// brush is the whole frame, and saying "covers nothing" of a layer
// that does would hide an adjustment the photographer made.
self.shown_parts()
.enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
.fold(false, |acc, (i, p)| match (i, p.join) {
(0, _) | (_, Join::Union) => acc || p.covers(),
(_, Join::Subtract) => acc,
(_, Join::Intersect) => acc && (p.covers() || p.invert),
})
}
/// TRACES: FR-DEV-19a
@@ -3037,6 +3082,69 @@ mod tests {
);
}
/// TRACES: FR-DEV-19a
/// The three joins, pointwise, on every pair of coverages a part and a
/// mask can meet at: fully in, fully out, and each soft edge. Intersection
/// is the product, which agrees with the minimum wherever either side is
/// decided and is the softer reading where both are not.
#[test]
fn the_joins_are_max_cut_and_product() {
let levels = [0.0f32, 0.25, 0.5, 0.75, 1.0];
for &dst in &levels {
for &src in &levels {
assert_eq!(Join::Union.apply(dst, src), dst.max(src));
assert_eq!(Join::Subtract.apply(dst, src), dst * (1.0 - src));
let meet = Join::Intersect.apply(dst, src);
assert_eq!(meet, dst * src);
assert!(meet <= dst.min(src), "never more than either side");
if dst == 0.0 || dst == 1.0 || src == 0.0 || src == 1.0 {
assert_eq!(meet, dst.min(src), "the minimum where either is decided");
}
}
}
}
/// A stored index names a place in [`Join::ALL`], and a sidecar names a
/// join by word: both have to survive a third join arriving, which means
/// the first two keep their places and every name reads back as itself.
#[test]
fn every_join_reads_back_by_name_and_keeps_its_place() {
assert_eq!(Join::ALL[0], Join::Union);
assert_eq!(Join::ALL[1], Join::Subtract);
for join in Join::ALL {
assert_eq!(Join::from_name(join.name()), Some(join));
}
assert_eq!(Join::from_name("intersect"), Some(Join::Intersect));
}
/// TRACES: FR-DEV-19a
/// An intersection can empty a mask the parts before it filled: a range
/// meeting an unpainted brush selects nothing, and rasterising it would
/// spend a slice to draw an empty field. Painting the brush, or inverting
/// it, gives the intersection something to keep.
#[test]
fn an_intersection_with_nothing_covers_nothing() {
let mut layer = MaskLayer::new("m1", MaskSource::highlights());
layer.set_param("exposure", ParamId("exposure"), 1.0);
layer.push_part(MaskPart::painted("p2", Join::Intersect));
assert!(
!layer.is_active(),
"a range intersected with an unpainted brush selects nothing"
);
layer.part_mut(1).expect("p2").invert = true;
assert!(layer.is_active(), "inverted, the empty brush is everywhere");
layer.part_mut(1).expect("p2").invert = false;
layer.begin_stroke(1, false, 0.1, 0.5, 1.0);
layer.extend_stroke(1, 0.5, 0.5);
layer.end_stroke(1);
assert!(layer.is_active(), "and painted, it keeps what it covers");
layer.part_mut(1).expect("p2").hidden = true;
assert!(layer.is_active(), "hidden, it is out of the build");
}
/// One stale part is a stale layer: the mask is the fold over all of them,
/// so a part that would draw a confidently wrong shape makes the result
/// wrong whichever way it joins.
+76 -4
View File
@@ -456,6 +456,25 @@ pub struct ComposedShader {
pub structure_hash: u64,
/// What this shader writes. See [`OutputMode`].
pub output_mode: OutputMode,
/// What decides which source texel each output pixel reads, when that
/// texel is read whole — `None` when it is interpolated.
///
/// A fit view reads one texel in every three or four of a 60 MP source,
/// on a stride, and that gather is most of what the fused pass costs
/// there: the texel it wants shares a cache line with neighbours nobody
/// reads. But the gather depends on the framing and nothing else, so it
/// is the same on every frame of a slider drag. The shader can therefore
/// write what it gathered to a viewport-sized texture once and read it
/// back contiguously thereafter; the flags in the uniform block at
/// [`SAMPLE_CACHE_UNIFORM_OFFSET`] say which, and `dr-gpu` decides.
///
/// This key is the half of that decision only the composer can make: a
/// hash of the generated prologue and the framing and warp uniforms, which
/// together are everything that maps an output pixel to a source texel.
/// The caller mixes in the source image and the render size. `None` for
/// the interpolating paths, whose sample is a blend of four texels and
/// not representable exactly in the source's own format.
pub sample_key: Option<u64>,
}
/// Fields the generated uniform struct always carries, before op uniforms.
@@ -466,7 +485,19 @@ pub struct ComposedShader {
/// Twelve of the twenty-eight are the camera profile's base curve
/// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot
/// balance and framing's own block.
const BASE_UNIFORM_FIELDS: usize = 16 + BASE_CURVE_UNIFORM_FIELDS;
const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + BASE_CURVE_UNIFORM_FIELDS;
/// Slots the sample cache's two flags occupy: read, write, and two spare to
/// keep the block a whole `vec4`. See [`ComposedShader::sample_key`].
const SAMPLE_CACHE_UNIFORM_FIELDS: usize = 4;
/// Where the sample cache's flags sit in the generated uniform block: `x` says
/// read the source colour from the cache, `y` says write it there.
///
/// Exported for the reason [`BASE_CURVE_UNIFORM_OFFSET`] is — `dr-gpu` writes
/// these by index — and zero in every block the composer hands out, so a
/// caller that never heard of the cache gets the direct read it always had.
pub const SAMPLE_CACHE_UNIFORM_OFFSET: usize = 16;
/// TRACES: FR-DEV-3e
/// Slots the base curve occupies: five `(x, y)` points and an active flag.
@@ -484,7 +515,8 @@ const BASE_CURVE_UNIFORM_FIELDS: usize = 12;
/// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu`
/// writes these by index, and an offset computed independently at both ends is
/// an offset that will eventually disagree with itself.
pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
pub const BASE_CURVE_UNIFORM_OFFSET: usize =
SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS;
/// How many control points a base curve carries.
///
@@ -756,6 +788,10 @@ fn compose_inner(
\x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\
\x20 // prologue reads to decide whether to linearise.\n\
\x20 as_shot_wb: vec4<f32>,\n\
\x20 // The sample cache (see `ComposedShader::sample_key`): `.x` reads\n\
\x20 // the source colour from `sampled`, `.y` writes it to\n\
\x20 // `sample_out`. Zero for both is the direct read.\n\
\x20 sample_cache: vec4<f32>,\n\
\x20 // The camera profile's base curve (FR-DEV-3e): five points on a\n\
\x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\
\x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\
@@ -801,7 +837,11 @@ fn compose_inner(
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
\x20 // value that is constant across the dispatch.\n\
\x20 crop_rect: vec4<f32>,\n\
\x20 framing_angle: vec4<f32>,\n",
\x20 framing_angle: vec4<f32>,\n\
\x20 // The perspective map (FR-DEV-20) by columns, `.w` unused.\n\
\x20 keystone_c0: vec4<f32>,\n\
\x20 keystone_c1: vec4<f32>,\n\
\x20 keystone_c2: vec4<f32>,\n",
);
uniform_values.extend_from_slice(&framing.uniforms());
@@ -932,6 +972,19 @@ fn compose_inner(
);
let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" };
// Everything that decides which texel an output pixel reads: the code that
// computes `coord`, and the uniforms that code reads. Only on the path that
// reads a texel whole — see `ComposedShader::sample_key`.
let sample_key = (!interpolate && !warp.splits_channels).then(|| {
framing
.uniforms()
.iter()
.chain(&warp.uniforms)
.fold(hash_source(&prologue), |h, v| {
mix(h, u64::from(v.to_bits()))
})
});
// The tail, and it is the whole of the difference between the two output
// modes. Everything above — the prologue, the fragments, the mask layers,
// the camera matrix — is emitted identically either way, so an operation
@@ -1083,6 +1136,12 @@ struct Params {{
// stock is loaded, which costs eight bytes and no branch.
@group(0) @binding(4) var film_curves: texture_2d<f32>;
@group(0) @binding(5) var film_lut_texture: texture_3d<f32>;
// The sample cache: the source texel each output pixel read on an earlier
// frame with this framing, and where this frame writes it when asked. See
// `ComposedShader::sample_key`. Declared unconditionally, like the masks, and
// bound to 1x1 placeholders whenever the flags say not to touch them.
@group(0) @binding(6) var sampled: texture_2d<f32>;
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
{sampler_helper}{helper_src}{encode_output}
// Display-encoded sRGB back to linear, for sources that arrive that way.
@@ -1192,6 +1251,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
uniforms: uniform_values,
structure_hash,
output_mode,
sample_key,
}
}
@@ -1411,7 +1471,19 @@ pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'stati
// amount that changes with the aspect ratio. It reads as a correction that
// is simply too weak, which is indistinguishable from a bad profile.
let radius = length(p) / (0.5 * length(aspect));
var c = textureLoad(source, coord, 0).rgb;
// The texel itself, from the source or from the cache of it an earlier
// frame wrote (see `ComposedShader::sample_key`). Both branches yield the
// same bits: the source is `rgba16float` and so is the cache. The flags
// are uniforms, so the whole dispatch takes one branch.
var c: vec3<f32>;
if (u.sample_cache.x > 0.5) {
c = textureLoad(sampled, vec2<i32>(gid.xy), 0).rgb;
} else {
c = textureLoad(source, coord, 0).rgb;
if (u.sample_cache.y > 0.5) {
textureStore(sample_out, vec2<i32>(gid.xy), vec4<f32>(c, 1.0));
}
}
"
}
}
+645
View File
@@ -0,0 +1,645 @@
//! TRACES: FR-DEV-17
//! Whether a crop leaves a mask layer's work outside the frame.
//!
//! # Why this is a question worth asking
//!
//! Mask geometry is stored in normalised *source* coordinates (see
//! [`MaskSource::Linear`] and [`Stroke::points`]), so a tighter crop never
//! destroys a layer. It makes it invisible — which is worse, because nothing
//! announces it. The layer is still in the panel, still in the sidecar, still
//! costing a rasterisation, and its adjustment lands on pixels nobody will
//! ever see. The crop that did that is exactly the kind of edit made early
//! and quickly, and the loss is found, if at all, much later.
//!
//! This module answers one question on the CPU, cheaply enough to ask once
//! per committed crop: *which layers did this change of crop take out of the
//! picture?* The interface turns the answer into a notice. It never refuses
//! the crop — the photographer may well mean it.
//!
//! # How it is measured
//!
//! Each layer's mask is sampled on a [`GRID`]×[`GRID`] lattice over the
//! source, with the same geometry the mask shader uses (`mask.wgsl`), folded
//! part by part with the layer's joins and inversions. The sample points are
//! then mapped through the framing — crop, straighten, turns and flips — into
//! the frame, and the layer's *share inside* is the coverage that lands in
//! the crop over the coverage there is. A layer is hidden by a crop when that
//! share falls below [`HIDDEN_SHARE`] and was not already below it.
//!
//! The lattice is coarse on purpose. The question is "is this layer mostly
//! gone", not "which pixels are": the edge treatment — feather, morphology —
//! moves a boundary by a few hundredths of the frame and cannot turn a layer
//! that is mostly inside into one that is mostly outside, so it is left out.
//!
//! # What cannot be orphaned
//!
//! A range ([`MaskSource::Luminance`], [`MaskSource::Colour`]) selects by a
//! property of the picture, so wherever the crop falls it selects whatever
//! part of the picture remains — there is nothing for a crop to strand. A
//! region selection needs the segmentation's label map, which this crate does
//! not hold, and a model's selection with no raster to hand has nothing to
//! measure. A layer that *adds* any of these is therefore never reported: a
//! false alarm on the common path would teach the photographer to dismiss the
//! notice unread, which is the failure it exists to prevent. Subtracting one
//! is ignored, which can only make the layer look larger — the safe side.
use std::borrow::Cow;
use crate::framing::{CropRect, Framing};
use crate::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Stroke};
/// Samples per axis over the source.
///
/// 4096 points: enough that a brush dab a twentieth of the frame across is
/// several samples wide, and few enough that the whole stack is measured in
/// well under a frame when a crop is let go.
pub const GRID: usize = 64;
/// The share of a layer's coverage below which it counts as cropped away.
///
/// "Mostly outside" rather than "entirely": a gradient reduced to a sliver
/// along one edge, or a subject of which one elbow survives, has lost the
/// work as surely as one that is wholly gone. Low enough that an ordinary
/// recomposition which trims part of a subject is not reported.
pub const HIDDEN_SHARE: f32 = 0.1;
/// A model's selection, as bytes over the source at some proxy size.
///
/// Supplied by the caller for a part whose own [`MaskPart::coverage`] is not
/// set — the live session holds its model output outside the graph and folds
/// it into the parts only when saving.
pub struct Raster<'a> {
pub values: Cow<'a, [u8]>,
pub width: usize,
pub height: usize,
}
/// One layer's coverage, sampled over the source.
#[derive(Debug, Clone, PartialEq)]
pub struct Footprint {
/// Row-major over the lattice, `0.0..=1.0`, one per cell centre.
weights: Vec<f32>,
}
impl Footprint {
/// The layer's coverage on the lattice, or `None` when it has none to
/// strand — see the module header for which layers those are.
///
/// `model` is asked for the raster behind a subject or category part that
/// carries none of its own.
pub fn of<'r>(
layer: &MaskLayer,
source: (u32, u32),
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
) -> Option<Self> {
let mut acc: Option<Vec<f32>> = None;
for (i, part) in layer.shown_parts().enumerate() {
let adds = i == 0 || part.join == Join::Union;
let Some(values) = part_values(part, source, model) else {
if adds {
// It follows the picture, or cannot be measured: either
// way it is not a shape a crop can leave behind.
return None;
}
continue;
};
acc = Some(match acc {
None => values,
Some(mut a) => {
for (d, s) in a.iter_mut().zip(values) {
*d = part.join.apply(*d, s);
}
a
}
});
}
let mut weights = acc?;
if layer.invert {
weights.iter_mut().for_each(|w| *w = 1.0 - *w);
}
Some(Self { weights })
}
/// Of this layer's coverage, the share `inside` marks as in frame.
/// `None` when there is no coverage at all.
fn share(&self, inside: &[bool]) -> Option<f32> {
let (mut total, mut kept) = (0.0f32, 0.0f32);
for (w, &i) in self.weights.iter().zip(inside) {
total += w;
if i {
kept += w;
}
}
(total > 1e-3).then(|| kept / total)
}
/// Of this layer's coverage, the share `framing`'s crop keeps.
pub fn share_inside(&self, framing: &Framing, source: (u32, u32)) -> Option<f32> {
self.share(&in_frame(framing, source))
}
}
/// TRACES: FR-DEV-17
/// The layers that changing the framing from `before` to `after` took out of
/// the picture, in stack order.
///
/// Only those it *newly* hid: a layer already cropped away by `before` is not
/// reported again, or every later adjustment of the crop would repeat a notice
/// the photographer has already answered.
///
/// The view (zoom and pan) of either framing is ignored. It is a way of
/// looking, not the frame.
pub fn hidden_by_crop<'m, 'r>(
masks: &'m MaskStack,
before: &Framing,
after: &Framing,
source: (u32, u32),
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
) -> Vec<&'m MaskLayer> {
if masks.is_empty() || framed_alike(before, after) {
return Vec::new();
}
let was = in_frame(before, source);
let now = in_frame(after, source);
masks
.layers()
.iter()
.filter(|layer| {
let Some(print) = Footprint::of(layer, source, model) else {
return false;
};
let (Some(was), Some(now)) = (print.share(&was), print.share(&now)) else {
return false;
};
was >= HIDDEN_SHARE && now < HIDDEN_SHARE
})
.collect()
}
/// Whether the two frame the same part of the source, zoom aside.
fn framed_alike(a: &Framing, b: &Framing) -> bool {
let mut a = *a;
let mut b = *b;
a.set_view(CropRect::default());
b.set_view(CropRect::default());
a == b
}
/// Which lattice cells the framing's crop keeps.
fn in_frame(framing: &Framing, source: (u32, u32)) -> Vec<bool> {
let mut f = *framing;
f.set_view(CropRect::default());
lattice()
.map(|uv| {
let (x, y) = f.output_at(uv, source.0, source.1);
(0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)
})
.collect()
}
/// Cell centres, row-major, in normalised source coordinates.
fn lattice() -> impl Iterator<Item = (f32, f32)> {
let step = 1.0 / GRID as f32;
(0..GRID).flat_map(move |y| {
(0..GRID).map(move |x| ((x as f32 + 0.5) * step, (y as f32 + 0.5) * step))
})
}
/// One part's coverage on the lattice, its own inversion applied, or `None`
/// where it cannot be measured as a shape.
fn part_values<'r>(
part: &MaskPart,
source: (u32, u32),
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
) -> Option<Vec<f32>> {
let aspect = source.0.max(1) as f32 / source.1.max(1) as f32;
let mut values: Vec<f32> = match &part.source {
MaskSource::Linear {
centre,
angle,
width,
} => {
let axis = (angle.cos(), angle.sin());
lattice()
.map(|(u, v)| {
let d = (u - centre.0) * aspect * axis.0 + (v - centre.1) * axis.1;
if *width <= 0.0 {
if d >= 0.0 {
1.0
} else {
0.0
}
} else {
smoothstep(-width * 0.5, width * 0.5, d)
}
})
.collect()
}
MaskSource::Radial {
centre,
radii,
angle,
feather,
} => {
let (sa, ca) = (-angle).sin_cos();
let radii = (radii.0.max(1e-6), radii.1.max(1e-6));
let edge = feather.clamp(0.0, 1.0);
lattice()
.map(|(u, v)| {
let d = ((u - centre.0) * aspect, v - centre.1);
let local = (d.0 * ca - d.1 * sa, d.0 * sa + d.1 * ca);
let r = (local.0 / radii.0).hypot(local.1 / radii.1);
if edge <= 0.0 {
if r <= 1.0 {
1.0
} else {
0.0
}
} else {
1.0 - smoothstep(1.0 - edge, 1.0, r)
}
})
.collect()
}
MaskSource::Brush { strokes } => brush_values(strokes, source),
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
if let Some(coverage) = &part.coverage {
// The lattice is a regular grid over the source, which is
// exactly the resample `decode_at` does.
coverage
.decode_at(GRID, GRID)
.into_iter()
.map(|b| f32::from(b) / 255.0)
.collect()
} else {
let raster = model(part)?;
if raster.width == 0
|| raster.height == 0
|| raster.values.len() < raster.width * raster.height
{
return None;
}
lattice()
.map(|(u, v)| {
let x = ((u * raster.width as f32) as usize).min(raster.width - 1);
let y = ((v * raster.height as f32) as usize).min(raster.height - 1);
f32::from(raster.values[y * raster.width + x]) / 255.0
})
.collect()
}
}
MaskSource::Regions { .. } | MaskSource::Luminance { .. } | MaskSource::Colour { .. } => {
return None
}
};
if part.invert {
values.iter_mut().for_each(|w| *w = 1.0 - *w);
}
Some(values)
}
/// Strokes composited in order, the way `fs_brush` and its blend states do.
fn brush_values(strokes: &[Stroke], source: (u32, u32)) -> Vec<f32> {
// Into units of the shorter edge, as `to_square` does, so a dab is round.
let short = source.0.min(source.1).max(1) as f32;
let scale = (
source.0.max(1) as f32 / short,
source.1.max(1) as f32 / short,
);
let square = |p: (f32, f32)| (p.0 * scale.0, p.1 * scale.1);
let cells: Vec<(f32, f32)> = lattice().map(square).collect();
let mut out = vec![0.0f32; cells.len()];
for stroke in strokes.iter().filter(|s| !s.is_empty()) {
let points: Vec<(f32, f32)> = stroke.points.iter().copied().map(square).collect();
let r = stroke.radius;
let inner = r * stroke.hardness.clamp(0.0, 1.0);
// Only cells inside the stroke's bounding box can be reached.
let (lo, hi) = points.iter().fold(
((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)),
|(lo, hi), p| {
(
(lo.0.min(p.0), lo.1.min(p.1)),
(hi.0.max(p.0), hi.1.max(p.1)),
)
},
);
for (cell, dst) in cells.iter().zip(out.iter_mut()) {
if cell.0 < lo.0 - r || cell.0 > hi.0 + r || cell.1 < lo.1 - r || cell.1 > hi.1 + r {
continue;
}
let d = if points.len() == 1 {
dist(*cell, points[0])
} else {
points
.windows(2)
.map(|s| segment_distance(*cell, s[0], s[1]))
.fold(f32::MAX, f32::min)
};
let c = ((1.0 - smoothstep(inner, r, d)) * stroke.flow).clamp(0.0, 1.0);
*dst = if stroke.erase {
*dst * (1.0 - c)
} else {
*dst + c - *dst * c
};
}
}
out
}
fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
(a.0 - b.0).hypot(a.1 - b.1)
}
fn segment_distance(q: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
let ab = (b.0 - a.0, b.1 - a.1);
let len2 = ab.0 * ab.0 + ab.1 * ab.1;
if len2 <= 1e-12 {
return dist(q, a);
}
let t = (((q.0 - a.0) * ab.0 + (q.1 - a.1) * ab.1) / len2).clamp(0.0, 1.0);
dist(q, (a.0 + ab.0 * t, a.1 + ab.1 * t))
}
/// WGSL's `smoothstep`, including its behaviour when the edges meet.
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
if e1 <= e0 {
return if x < e0 { 0.0 } else { 1.0 };
}
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::coverage::Coverage;
const SOURCE: (u32, u32) = (3000, 2000);
fn no_model(_: &MaskPart) -> Option<Raster<'static>> {
None
}
fn cropped(x: f32, y: f32, w: f32, h: f32) -> Framing {
let mut f = Framing::new();
f.set_crop(CropRect {
x,
y,
width: w,
height: h,
});
f
}
/// A small circle near the source's top-left corner.
fn top_left_circle() -> MaskLayer {
MaskLayer::new(
"m1",
MaskSource::Radial {
centre: (0.15, 0.15),
radii: (0.08, 0.08),
angle: 0.0,
feather: 0.2,
},
)
}
fn stack(layers: Vec<MaskLayer>) -> MaskStack {
let mut s = MaskStack::new();
for l in layers {
assert!(s.push(l));
}
s
}
fn hidden(masks: &MaskStack, before: &Framing, after: &Framing) -> Vec<String> {
hidden_by_crop(masks, before, after, SOURCE, &no_model)
.into_iter()
.map(|l| l.id.clone())
.collect()
}
#[test]
fn a_crop_away_from_a_shape_hides_it() {
let masks = stack(vec![top_left_circle()]);
let after = cropped(0.5, 0.5, 0.5, 0.5);
assert_eq!(hidden(&masks, &Framing::new(), &after), vec!["m1"]);
}
#[test]
fn a_crop_that_keeps_the_shape_is_silent() {
let masks = stack(vec![top_left_circle()]);
let after = cropped(0.0, 0.0, 0.6, 0.6);
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
}
#[test]
fn a_crop_that_trims_part_of_a_shape_is_silent() {
// Half the circle survives, which is a recomposition, not a loss.
let masks = stack(vec![top_left_circle()]);
let after = cropped(0.15, 0.0, 0.85, 1.0);
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
}
#[test]
fn a_layer_already_cropped_away_is_not_reported_again() {
let masks = stack(vec![top_left_circle()]);
let before = cropped(0.5, 0.5, 0.5, 0.5);
let after = cropped(0.6, 0.6, 0.4, 0.4);
assert!(hidden(&masks, &before, &after).is_empty());
}
#[test]
fn uncropping_brings_a_layer_back_and_says_nothing() {
let masks = stack(vec![top_left_circle()]);
let before = cropped(0.5, 0.5, 0.5, 0.5);
assert!(hidden(&masks, &before, &Framing::new()).is_empty());
}
#[test]
fn an_unchanged_frame_is_silent_whatever_the_zoom() {
let masks = stack(vec![top_left_circle()]);
let before = cropped(0.5, 0.5, 0.5, 0.5);
let mut after = before;
after.set_view(CropRect {
x: 0.5,
y: 0.5,
width: 0.5,
height: 0.5,
});
assert!(hidden(&masks, &Framing::new(), &after).len() == 1);
assert!(hidden(&masks, &before, &after).is_empty());
}
#[test]
fn a_range_follows_the_picture_and_is_never_stranded() {
let masks = stack(vec![
MaskLayer::new("lum", MaskSource::highlights()),
MaskLayer::new("skin", MaskSource::skin_tones()),
]);
let after = cropped(0.9, 0.9, 0.1, 0.1);
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
}
#[test]
fn a_linear_gradient_over_the_bottom_is_hidden_by_keeping_the_top() {
let masks = stack(vec![MaskLayer::new(
"grad",
MaskSource::Linear {
centre: (0.5, 0.8),
angle: std::f32::consts::FRAC_PI_2,
width: 0.05,
},
)]);
assert_eq!(
hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 1.0, 0.5)),
vec!["grad"]
);
assert!(hidden(&masks, &Framing::new(), &cropped(0.0, 0.5, 1.0, 0.5)).is_empty());
}
#[test]
fn a_painted_stroke_is_measured_where_it_was_painted() {
let mut layer = MaskLayer::new("paint", MaskSource::brush());
layer.begin_stroke(0, false, 0.05, 0.8, 1.0);
for i in 0..10 {
layer.extend_stroke(0, 0.8 + i as f32 * 0.01, 0.8);
}
layer.end_stroke(0);
let masks = stack(vec![layer]);
assert_eq!(
hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 0.5, 0.5)),
vec!["paint"]
);
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
}
#[test]
fn an_unpainted_brush_has_nothing_to_lose() {
let masks = stack(vec![MaskLayer::new("empty", MaskSource::brush())]);
assert!(hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 0.3, 0.3)).is_empty());
}
#[test]
fn an_inverted_layer_is_measured_as_what_it_selects() {
// Everything *but* a corner circle: a crop into the other corner
// keeps most of it.
let mut layer = top_left_circle();
layer.invert = true;
let masks = stack(vec![layer]);
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
}
#[test]
fn a_subject_is_measured_from_its_stored_coverage_or_the_model() {
// A subject occupying the right-hand quarter of a 40x20 proxy.
let (w, h) = (40usize, 20usize);
let bytes: Vec<u8> = (0..w * h)
.map(|i| if i % w >= 30 { 255 } else { 0 })
.collect();
let subject = MaskSource::Subject {
signature: 1,
index: 0,
class: "dog".into(),
score: 0.9,
};
let left = cropped(0.0, 0.0, 0.5, 1.0);
let mut stored = MaskLayer::new("stored", subject.clone());
stored.base_mut().coverage = Coverage::encode(&bytes, w, h, 2).map(Arc::new);
let live = MaskLayer::new("live", subject);
let masks = stack(vec![stored, live]);
// No model to hand: only the layer carrying its raster is measured.
assert_eq!(hidden(&masks, &Framing::new(), &left), vec!["stored"]);
let model = |_: &MaskPart| {
Some(Raster {
values: Cow::Borrowed(bytes.as_slice()),
width: w,
height: h,
})
};
let ids: Vec<_> = hidden_by_crop(&masks, &Framing::new(), &left, SOURCE, &model)
.into_iter()
.map(|l| l.id.as_str())
.collect();
assert_eq!(ids, vec!["stored", "live"]);
}
#[test]
fn a_crop_is_read_in_the_turned_frame() {
// Turned a quarter clockwise, the source's top-left lands at the
// frame's top-right, so keeping the right half of the frame keeps it.
let masks = stack(vec![top_left_circle()]);
let mut before = Framing::new();
before.rotate_quarters(1);
let mut right = before;
right.set_crop(CropRect {
x: 0.5,
y: 0.0,
width: 0.5,
height: 1.0,
});
let mut left = before;
left.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
assert!(hidden(&masks, &before, &right).is_empty());
assert_eq!(hidden(&masks, &before, &left), vec!["m1"]);
}
#[test]
fn a_subtracted_range_does_not_hide_the_shape_it_cuts() {
let mut layer = top_left_circle();
assert!(layer.push_part(MaskPart::new(
"p2",
Join::Subtract,
MaskSource::highlights()
)));
let masks = stack(vec![layer.clone()]);
assert_eq!(
hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)),
vec!["m1"]
);
// Added instead, the range reaches everywhere and nothing is lost.
let mut layer = top_left_circle();
assert!(layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights())));
let masks = stack(vec![layer]);
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
}
#[test]
fn an_intersection_keeps_only_what_both_parts_cover() {
let circle = |id: &str, join, centre, r| {
MaskPart::new(
id,
join,
MaskSource::Radial {
centre,
radii: (r, r),
angle: 0.0,
feather: 0.2,
},
)
};
// Two circles in opposite corners: keeping the bottom-right quarter
// keeps one of them, and nothing is lost.
let mut layer = top_left_circle();
assert!(layer.push_part(circle("p2", Join::Union, (0.85, 0.85), 0.08)));
let bottom_right = cropped(0.5, 0.5, 0.5, 0.5);
let masks = stack(vec![layer.clone()]);
assert!(hidden(&masks, &Framing::new(), &bottom_right).is_empty());
// Intersected with a disc around the top-left, only that corner's
// circle survives, and the same crop takes it out of the frame.
assert!(layer.push_part(circle("p3", Join::Intersect, (0.15, 0.15), 0.3)));
let masks = stack(vec![layer]);
assert_eq!(hidden(&masks, &Framing::new(), &bottom_right), vec!["m1"]);
}
}
+10
View File
@@ -725,6 +725,7 @@ mod tests {
height: 0.5,
});
g.set_param(framing::ID, framing::ANGLE, -2.0);
g.set_param(framing::ID, framing::KEYSTONE_V, 40.0);
g
}
@@ -820,6 +821,14 @@ mod tests {
Some(0.0),
"the source's straightening must not travel on this scope"
);
// TRACES: FR-DEV-20
// Perspective is composition on the same terms as the crop: it is
// withheld by the default scope, not pasted over the target's frame.
assert_eq!(
target.param(framing::ID, framing::KEYSTONE_V),
Some(0.0),
"the source's keystone must not travel on this scope"
);
}
#[test]
@@ -829,6 +838,7 @@ mod tests {
preset.apply(&mut target, Scope::everything());
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
assert!(
(target.crop().width - 0.5).abs() < 1e-5,
"{:?}",
+73
View File
@@ -93,6 +93,9 @@ pub const MAX_RATING: u8 = 5;
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
pub const MAX_FLAG: u8 = 2;
/// Highest colour-label code: purple. Mirrors `dr_catalog::rating::label_code`.
pub const MAX_LABEL: u8 = 5;
/// TRACES: FR-CAT-8 | FR-NC-8
/// One image's sidecar: a keyed set of versions.
///
@@ -187,6 +190,17 @@ pub struct Version {
/// so a value moving between the two stores needs no translation table
/// that could drift.
pub flag: u8,
/// TRACES: FR-CAT-5
/// The colour label, `0` for none and `1..=5` red, yellow, green, blue,
/// purple — the catalog's `versions.label` codes, for the reason
/// [`Self::flag`] shares its encoding.
///
/// Here for the reason the rating is: a label that lived only in the
/// catalog would go with the catalog, and would never reach the
/// photographer's other devices, which learn judgements from this file.
/// A build that predates the key keeps it as an unknown line and writes
/// it back, so an older device passes it on rather than erasing it.
pub label: u8,
/// The edit itself: `(op, param) -> value`, non-default values only.
pub params: BTreeMap<(String, String), f32>,
/// TRACES: FR-DEV-3 | FR-NC-9
@@ -264,6 +278,7 @@ impl Version {
// it in the "not yet looked at" state a cull resumes from.
rating: 0,
flag: 0,
label: 0,
params,
masks,
film,
@@ -572,6 +587,10 @@ impl Version {
// that never had it.
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
// TRACES: FR-CAT-5
// A label under the same rule: 0 is "none given", so a device that
// never labelled a frame cannot clear another's label.
self.label = merge_judgement(self.label, remote.label, remote_wins);
// TRACES: FR-DEV-3f
// The film resolves wholesale to the higher revision, like a mask
@@ -873,6 +892,9 @@ impl Sidecar {
if v.flag > 0 {
let _ = writeln!(out, "flag = {}", v.flag);
}
if v.label > 0 {
let _ = writeln!(out, "label = {}", v.label);
}
// TRACES: FR-DEV-3f
// Before the parameters, because it decides what they mean: the
// film's exposure slider is a slider on *that stock's* curve.
@@ -1068,6 +1090,17 @@ impl Sidecar {
Some(value.to_string());
}
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
// TRACES: FR-CAT-5
// A code this build does not know reads as none rather than
// being clamped onto purple: a wrong colour is a claim, and
// no colour is only a gap.
"label" => {
version.label = value
.parse::<u8>()
.ok()
.filter(|l| *l <= MAX_LABEL)
.unwrap_or(0)
}
// TRACES: FR-DEV-8
// Ahead of the `op.param` arm below, which would otherwise try
// to read eight numbers as one float and drop the repair with a
@@ -2141,6 +2174,8 @@ mod tests {
height: 0.6,
});
g.set_param(framing::ID, framing::ANGLE, -1.5);
g.set_param(framing::ID, framing::KEYSTONE_V, 42.0);
g.set_param(framing::ID, framing::KEYSTONE_H, -17.0);
g.rotate_quarters(1);
let mut sidecar = Sidecar::new();
@@ -2157,6 +2192,12 @@ mod tests {
assert_eq!(restored.crop(), g.crop());
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
// TRACES: FR-DEV-20
assert_eq!(restored.param(framing::ID, framing::KEYSTONE_V), Some(42.0));
assert_eq!(
restored.param(framing::ID, framing::KEYSTONE_H),
Some(-17.0)
);
}
#[test]
@@ -2828,6 +2869,37 @@ mod tests {
assert_eq!(back.flag, 1);
}
#[test]
fn a_label_survives_the_round_trip_and_an_unknown_one_reads_as_none() {
// TRACES: FR-CAT-5
let mut v = version_of(&EditGraph::default_chain());
v.label = 3;
let mut sidecar = Sidecar::new();
sidecar.put(v);
let text = sidecar.to_text();
assert!(text.contains("label = 3"), "{text}");
let parsed = Sidecar::parse(&text).expect("valid");
assert_eq!(parsed.default_version().expect("a version").label, 3);
let odd = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
label = 9\n";
let parsed = Sidecar::parse(odd).expect("valid");
assert_eq!(parsed.default_version().expect("a version").label, 0);
}
#[test]
fn an_unlabelled_device_cannot_clear_another_devices_label() {
// TRACES: FR-CAT-5
let mut local = version_of(&EditGraph::default_chain());
local.label = 0;
local.revision = 9;
let mut remote = version_of(&EditGraph::default_chain());
remote.label = 1;
remote.revision = 1;
local.merge(&remote, None);
assert_eq!(local.label, 1);
}
#[test]
fn an_unrated_image_writes_no_judgement_lines() {
// The non-default rule applied to judgement: a library that has never
@@ -2838,6 +2910,7 @@ mod tests {
let text = sidecar.to_text();
assert!(!text.contains("rating"), "{text}");
assert!(!text.contains("flag"), "{text}");
assert!(!text.contains("label"), "{text}");
}
#[test]
+22
View File
@@ -1344,6 +1344,28 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
);
}
/// TRACES: FR-DEV-19a
/// An intersecting part is written under its own word and reads back as an
/// intersection. A build from before intersection read that word as a union
/// (see `an_unknown_join_adds_the_part`), which keeps the part visible and
/// fixable rather than silently cutting the mask down.
#[test]
fn an_intersecting_part_survives_a_round_trip() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(corrected("m1", Join::Intersect));
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text();
assert!(text.contains("join = intersect"), "{text}");
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert_eq!(layer.parts().len(), 2);
assert_eq!(layer.parts()[1].join, Join::Intersect);
assert_eq!(layer.parts()[0].join, Join::Union, "the base is untouched");
}
/// TRACES: FR-DEV-19a
/// A part left out of the build comes back left out, and the file says so
/// under a word that cannot be confused with the layer's own switch.
+121 -4
View File
@@ -22,6 +22,11 @@ pub struct LoginFlow {
pub login_url: String,
#[serde(rename = "poll")]
pub poll: PollInfo,
/// The server the flow was started against — the address the user typed,
/// already normalised. Not part of the response: [`begin`] fills it in so
/// [`poll`] can put it in the credentials instead of the server's answer.
#[serde(skip)]
pub server: String,
}
#[derive(Debug, Clone, Deserialize)]
@@ -66,9 +71,57 @@ pub async fn begin(
});
}
resp.json::<LoginFlow>()
let mut flow = resp
.json::<LoginFlow>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
.map_err(|e| RemoteError::Protocol(e.to_string()))?;
// Both URLs are the server's to choose, and neither may be trusted as
// sent. The login URL is handed to the operating system to open, where a
// `file:` or UNC path is a program launch rather than a web page; the poll
// endpoint is where the app password comes back from.
flow.login_url = upgraded("login URL", &flow.login_url)?;
flow.poll.endpoint = upgraded("poll endpoint", &flow.poll.endpoint)?;
flow.server = server.trim_end_matches('/').to_string();
Ok(flow)
}
/// TRACES: NFR-SEC-3
/// A URL the server sent, upgraded to HTTPS, or refused.
///
/// `http` is upgraded rather than refused: a Nextcloud behind a TLS-terminating
/// proxy without `overwriteprotocol` builds every absolute URL it returns with
/// `http`, and the same path over `https` is the one that works. Any other
/// scheme is refused, because the only thing it could be for is reaching
/// something that is not this server.
///
/// The host is not checked. A server reached by its LAN address may answer with
/// its public name, and nothing here is safer for refusing that: the account
/// is stored under the address the user typed (see [`poll`]), not under
/// anything the server said.
pub(crate) fn upgraded(what: &str, url: &str) -> Result<String, RemoteError> {
let mut parsed = url::Url::parse(url).map_err(|e| {
RemoteError::Protocol(format!(
"the server sent a {what} that is not a URL ({e}): {url}"
))
})?;
match parsed.scheme() {
"https" => {}
"http" => parsed.set_scheme("https").map_err(|()| {
RemoteError::Protocol(format!("{what} cannot be upgraded to https: {url}"))
})?,
other => {
return Err(RemoteError::Protocol(format!(
"the server sent a {what} using {other}:, and only https is accepted: {url}"
)))
}
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err(RemoteError::Protocol(format!(
"the server sent a {what} with no host: {url}"
)));
}
Ok(parsed.into())
}
/// Poll until the user finishes authenticating in the browser.
@@ -98,10 +151,11 @@ pub async fn poll(
match resp.status().as_u16() {
200 => {
return resp
let creds = resp
.json::<AppCredentials>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
.map_err(|e| RemoteError::Protocol(e.to_string()))?;
return Ok(under_typed_server(creds, &flow.server));
}
// Still waiting for the user.
404 => tokio::time::sleep(POLL_INTERVAL).await,
@@ -117,6 +171,26 @@ pub async fn poll(
Err(RemoteError::AuthFailed)
}
/// TRACES: NFR-SEC-3
/// Credentials filed under the address the user typed, not the one the server
/// reports.
///
/// That report is the server's idea of its own URL, and behind a proxy
/// without `overwriteprotocol` it says `http://` — which, stored, would send
/// the app password in the clear on every request from then on. The typed
/// address has just carried the whole flow, so it is known to reach the
/// server.
fn under_typed_server(mut creds: AppCredentials, server: &str) -> AppCredentials {
if creds.server.trim_end_matches('/') != server {
log::info!(
"server reports itself as {}; keeping {server}",
creds.server
);
}
creds.server = server.to_string();
creds
}
/// Percent-encode a form value.
fn urlencode(s: &str) -> String {
s.bytes()
@@ -167,6 +241,49 @@ mod tests {
assert!(flow.login_url.contains("/login/v2/flow/"));
}
/// TRACES: NFR-SEC-3
#[test]
fn server_urls_are_upgraded_to_https_or_refused() {
assert_eq!(
upgraded("login URL", "http://cloud.example/login/v2/flow/xyz").unwrap(),
"https://cloud.example/login/v2/flow/xyz"
);
// A non-default port stays, and only the scheme changes.
assert_eq!(
upgraded("poll endpoint", "http://cloud.example:8443/login/v2/poll").unwrap(),
"https://cloud.example:8443/login/v2/poll"
);
assert_eq!(
upgraded("login URL", "https://cloud.example/x").unwrap(),
"https://cloud.example/x"
);
// What `rundll32 url.dll,FileProtocolHandler` would run, and what
// `xdg-open` would hand to whatever claims it.
for hostile in [
"file:///C:/Windows/System32/calc.exe",
"\\\\evil\\share\\x.exe",
"C:\\x.exe",
"javascript:alert(1)",
"-v",
"",
] {
assert!(upgraded("login URL", hostile).is_err(), "{hostile:?}");
}
}
/// TRACES: NFR-SEC-3
#[test]
fn credentials_keep_the_typed_server_not_the_reported_one() {
let reported = AppCredentials {
server: "http://cloud.example".into(),
login_name: "duncan".into(),
app_password: "secret-token".into(),
};
let c = under_typed_server(reported, "https://cloud.example");
assert_eq!(c.server, "https://cloud.example");
assert_eq!(c.app_password, "secret-token");
}
#[test]
fn form_values_are_encoded() {
assert_eq!(urlencode("abc123"), "abc123");
+49 -1
View File
@@ -723,6 +723,15 @@ pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
// webpki-roots set is still installed alongside.
.tls_certs_only(extra_roots())
// TRACES: NFR-SEC-3
// Refuse `http` here, below every URL this crate builds, rather than
// trusting each place a URL comes from. `normalise_endpoint` upgrades
// what the user types, but the login flow's poll endpoint, the account
// an older build saved and a redirect all arrive from somewhere else,
// and any of them naming `http://` would send the app password in
// Basic auth in the clear. reqwest checks this before connecting and
// again on every redirect, so a refused request never opens a socket.
.https_only(true)
// A request that hangs forever is indistinguishable from a worker that
// died, and cost a long time to tell apart once. These turn that into
// an error the UI can show.
@@ -780,8 +789,16 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
let mut detail = e.to_string();
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
let mut tls = false;
// A URL the client refused to send — `http` under `https_only`, on the
// first request or a redirect. Nothing left the process, so this is the
// account's configuration, not the network: reported as `Network` it
// would put the app into offline mode over a connection that is fine.
let mut refused = e.is_builder();
while let Some(s) = src {
let text = s.to_string();
if text.contains("URL scheme is not allowed") {
refused = true;
}
// rustls surfaces every verification failure through this wording:
// UnknownIssuer, Expired, NotValidForName, BadSignature.
if text.contains("invalid peer certificate")
@@ -795,7 +812,9 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
src = std::error::Error::source(s);
}
if tls {
if refused {
RemoteError::Configuration(detail)
} else if tls {
RemoteError::Tls(detail)
} else {
RemoteError::Network(detail)
@@ -1013,6 +1032,35 @@ mod tests {
assert!(c.is_ok(), "client must build without a backend");
}
/// TRACES: NFR-SEC-3
#[tokio::test]
async fn plain_http_is_refused_before_a_connection_opens() {
// A listener that would take the connection if one were made. The
// app password travels in a header, so a request that reached the
// socket has already leaked it; failing on the response is too late.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}/remote.php/dav/", listener.local_addr().unwrap());
let err = http_client("test")
.unwrap()
.get(&url)
.basic_auth("duncan", Some("app-password"))
.send()
.await
.expect_err("http must be refused");
assert!(
matches!(map_send_error(err), RemoteError::Configuration(_)),
"a refused scheme is configuration, not the network"
);
assert_eq!(
listener.accept().err().map(|e| e.kind()),
Some(std::io::ErrorKind::WouldBlock),
"nothing may have connected"
);
}
#[tokio::test]
async fn delta_is_unsupported_and_says_why() {
// Verified absent in the server; the engine must fall back rather
+24
View File
@@ -96,6 +96,18 @@ impl BackendProvider for NextcloudProvider {
}
}
/// TRACES: NFR-SEC-3
/// An `http://` account written before sign-in kept the address the user
/// typed: it was stored as the server reported itself, and behind a proxy
/// without `overwriteprotocol` that is `http`. The client refuses to send
/// to it now, so it is upgraded here rather than left to fail. The
/// namespace ignores the scheme, so the catalog stays where it is.
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
stored
.strip_prefix("http://")
.map(|rest| format!("https://{rest}"))
}
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
let creds = Self::credentials(conn)?;
Ok(Box::new(NextcloudBackend::new(
@@ -132,6 +144,18 @@ mod tests {
assert!(p.normalise_endpoint(" ").is_err());
}
/// TRACES: NFR-SEC-3
#[test]
fn a_stored_http_endpoint_is_upgraded_and_nothing_else_is_touched() {
let p = NextcloudProvider;
assert_eq!(
p.upgrade_endpoint("http://cloud.example/nextcloud")
.as_deref(),
Some("https://cloud.example/nextcloud")
);
assert_eq!(p.upgrade_endpoint("https://cloud.example"), None);
}
#[test]
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
// A login can be an email address while the user id is something
+223 -1
View File
@@ -29,7 +29,7 @@ use dr_plat::{SecretError, SecretRef, SecretStore};
use dr_types::{Format, FormatFilter};
use serde::{Deserialize, Serialize};
use crate::RemoteError;
use crate::{BackendRegistry, RemoteError};
/// The connector every account had before there was a choice.
///
@@ -510,6 +510,95 @@ impl AccountStore {
written
}
/// TRACES: NFR-SEC-3
/// Rewrite the endpoints an older build stored in a form this one would
/// not, asking each account's connector
/// ([`upgrade_endpoint`](crate::BackendProvider::upgrade_endpoint)).
///
/// Per account and best effort: one that cannot be moved — its keyring
/// locked, say — is logged and left as it was, and is tried again on the
/// next launch, rather than stopping the others or the launch. Returns
/// the accounts it rewrote.
pub fn upgrade_endpoints(&self, registry: &BackendRegistry) -> Vec<Account> {
let mut upgraded = Vec::new();
for account in self.list() {
let Ok(provider) = registry.for_account(&account) else {
continue;
};
let Some(endpoint) = provider.upgrade_endpoint(&account.endpoint) else {
continue;
};
if endpoint == account.endpoint {
continue;
}
match self.move_endpoint(&account, &endpoint) {
Ok(moved) => {
log::info!("account {} moved to {endpoint}", account.describe());
upgraded.push(moved);
}
Err(e) => log::warn!(
"account {} could not be moved to {endpoint}: {e}",
account.describe()
),
}
}
upgraded
}
/// Move an account to a new endpoint, taking its credential with it.
///
/// The endpoint is half of two keys, and both have to be dealt with. The
/// credential is filed under it ([`Account::secret_ref`]), so rewriting
/// the record alone would strand the app password under the old key and
/// sign the user out. And it feeds [`Account::namespace`], so a rewrite
/// that changed the namespace would abandon the catalog and everything
/// beside it; that is refused outright rather than left to the caller.
///
/// Ordered so an interruption at any step leaves something that works:
/// the credential is copied before the record names the new key, and the
/// old copy is deleted only once nothing names the old one.
fn move_endpoint(&self, account: &Account, endpoint: &str) -> Result<Account, AccountError> {
let mut moved = account.clone();
moved.endpoint = endpoint.to_string();
if moved.namespace() != account.namespace() {
return Err(AccountError::WouldMoveData {
from: account.namespace(),
to: moved.namespace(),
});
}
let mut config = self.read_config();
// Already there — the user signed in again at the new address. That
// record and its credential are the newer, so the old one just goes.
let duplicate = config.sessions.iter().any(|a| a.is_same_as(&moved));
let old_ref = account.secret_ref();
let secret = match self.secrets.retrieve(&old_ref) {
Ok(s) => Some(s),
Err(SecretError::NotFound) => None,
Err(e) => return Err(e.into()),
};
if let (Some(s), false) = (&secret, duplicate) {
self.secrets.store(&moved.secret_ref(), s)?;
}
if duplicate {
config.sessions.retain(|a| !a.is_same_as(account));
} else {
// In place, not removed and pushed: the last record is the one
// the next launch resumes.
for a in config.sessions.iter_mut().filter(|a| a.is_same_as(account)) {
*a = moved.clone();
}
}
self.write_config(&config)?;
if secret.is_some() {
self.secrets.delete(&old_ref)?;
}
Ok(moved)
}
fn read_config(&self) -> ConfigFile {
std::fs::read_to_string(&self.config_path)
.ok()
@@ -545,6 +634,11 @@ pub enum AccountError {
#[error(transparent)]
Remote(#[from] RemoteError),
/// A change that would give an account a different local data directory,
/// leaving its catalog and caches behind under the old one.
#[error("moving the account would leave its local data behind ({from} → {to})")]
WouldMoveData { from: String, to: String },
}
#[cfg(test)]
@@ -574,6 +668,134 @@ mod tests {
d
}
/// A connector that upgrades `http://` endpoints the way Nextcloud's does,
/// and every other one not at all.
struct Upgrading(&'static str);
impl crate::BackendProvider for Upgrading {
fn id(&self) -> &'static str {
self.0
}
fn display_name(&self) -> &'static str {
"U"
}
fn endpoint_label(&self) -> &'static str {
"Server"
}
fn endpoint_placeholder(&self) -> &'static str {
""
}
fn sign_in(&self) -> crate::SignIn {
crate::SignIn::Browser
}
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
Ok(i.into())
}
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
stored
.strip_prefix("http://")
.map(|rest| format!("https://{rest}"))
}
fn connect(&self, _: &Connection) -> Result<Box<dyn crate::RemoteBackend>, RemoteError> {
Err(RemoteError::Unsupported("stub"))
}
}
fn upgrading(id: &'static str) -> BackendRegistry {
let mut r = BackendRegistry::new();
r.register(std::sync::Arc::new(Upgrading(id)));
r
}
/// TRACES: NFR-SEC-3
#[test]
fn an_http_account_is_upgraded_and_keeps_its_credential_and_data() {
let dir = tmpdir("upgrade");
let store = store_in(&dir);
let old =
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
store.save(&folder(), None).unwrap();
store
.save(&old, Some(&Secret::new("secret-token")))
.unwrap();
let moved = store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
assert_eq!(moved.len(), 1);
let now = store.current().expect("still the account resumed");
assert_eq!(now.endpoint, "https://cloud.example");
assert_eq!(
now.namespace(),
old.namespace(),
"the catalog directory must not move"
);
assert_eq!(
store
.connection(&now, true)
.unwrap()
.require_secret()
.unwrap()
.expose(),
"secret-token",
"the credential moves with the account"
);
assert!(
store.connection(&old, true).is_err(),
"nothing is left under the old key"
);
assert_eq!(store.list().len(), 2, "the folder library is untouched");
// And a second launch has nothing to do.
assert!(store
.upgrade_endpoints(&upgrading(LEGACY_BACKEND))
.is_empty());
}
/// TRACES: NFR-SEC-3
#[test]
fn a_move_that_would_change_the_data_directory_is_refused() {
// A scheme change keeps the namespace, which is what makes the upgrade
// safe; a host change does not, and would give the library a new,
// empty data directory. The account is left exactly as it was.
let dir = tmpdir("upgrade-refused");
let store = store_in(&dir);
let old = nextcloud();
store
.save(&old, Some(&Secret::new("secret-token")))
.unwrap();
assert!(matches!(
store.move_endpoint(&old, "https://elsewhere.example"),
Err(AccountError::WouldMoveData { .. })
));
assert_eq!(store.current().unwrap().endpoint, old.endpoint);
assert!(store.connection(&old, true).is_ok());
}
/// TRACES: NFR-SEC-3
#[test]
fn an_upgrade_onto_an_existing_sign_in_keeps_the_newer_one() {
let dir = tmpdir("upgrade-duplicate");
let store = store_in(&dir);
let old =
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
let new =
Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan");
store.save(&old, Some(&Secret::new("stale"))).unwrap();
store.save(&new, Some(&Secret::new("fresh"))).unwrap();
store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
assert_eq!(store.list().len(), 1);
assert_eq!(
store
.connection(&new, true)
.unwrap()
.require_secret()
.unwrap()
.expose(),
"fresh"
);
}
#[test]
fn a_saved_account_survives_reopening() {
let dir = tmpdir("survives");
+14
View File
@@ -83,6 +83,20 @@ pub trait BackendProvider: Send + Sync {
/// wrong rather than naming a type.
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
/// The form a *stored* endpoint should take now, where an older build
/// wrote one this build would not.
///
/// Not [`normalise_endpoint`](Self::normalise_endpoint) run again: that
/// judges what a person typed, and may touch the world to do it — a folder
/// is canonicalised and must exist — so rerunning it on every launch would
/// fail a library whose disk is unplugged, or rename one whose path now
/// resolves differently. This is a pure rewrite of the string, and `None`
/// means leave it alone, which is the answer for almost every connector.
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
let _ = stored;
None
}
/// Build an account from a normalised endpoint alone.
///
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
+8
View File
@@ -115,6 +115,10 @@ pub enum PlaceScope {
#[serde(default)]
pub struct StoredFilter {
pub min_rating: u8,
/// TRACES: FR-UI-5
/// The top of a star range. A record from before ranges existed has none,
/// which reads as no ceiling — what it meant when it was written.
pub max_rating: Option<u8>,
pub unjudged: bool,
pub flag: Option<FlagState>,
pub local_only: bool,
@@ -131,6 +135,10 @@ pub struct StoredFilter {
/// Travels: it is a narrowing like `local_only`, and a record without it
/// — from a build before it existed — reads as off.
pub eyes_open: bool,
/// TRACES: FR-CAT-6
/// The colour label the grid was narrowed to. A record without it reads
/// as none, as every term added after the first does.
pub label: Option<crate::ColourLabel>,
}
/// Where the photographer was, at the moment they were there.
+23
View File
@@ -332,6 +332,29 @@ else
echo " assets: no models found (face indexing and the scene tab will be off on the device)"
fi
# The manual: the rendered page and its pictures, read in place by
# ManualActivity's WebView as file:///android_asset/manual/index.html. Not
# unpacked like the models: a WebView reads an asset straight out of the APK,
# and relative links to media/ resolve inside the same asset tree, so the page
# costs no first-launch copy and no second copy on /data.
#
# About 27 MB, stored below like the models — a GIF or PNG is already
# compressed, and deflating it again buys nothing. The pictures are LFS
# objects, and unlike a missing model a pointer would not fail loudly: it
# ships as a manual full of broken images. So it stops the build here.
rm -rf "${OUT}/staging/assets/manual"
mkdir -p "${OUT}/staging/assets/manual/media"
cp "${REPO}/docs/manual/index.html" "${OUT}/staging/assets/manual/"
for f in "${REPO}/docs/manual/media"/*; do
if head -c 40 "${f}" | grep -q '^version https://git-lfs'; then
echo "error: $(basename "${f}") is an LFS pointer, not a picture." >&2
echo " run: git lfs pull --include='docs/manual/media/**'" >&2
exit 1
fi
cp "${f}" "${OUT}/staging/assets/manual/media/"
done
echo " manual: index.html and $(ls "${OUT}/staging/assets/manual/media" | wc -l) picture(s), $(du -sh "${OUT}/staging/assets/manual" | cut -f1)"
# -0 "" stores the .so without compression so Android can mmap it directly
# (extractNativeLibs=false territory); for a 37 MB library that also keeps
# install times sane.
+17
View File
@@ -69,6 +69,23 @@ for dir in face scene inpaint; do
done
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
# The manual: the rendered page and its pictures, beside the executable where
# dr_ui::manual looks on Windows (dr_plat::system_data_dirs is the exe's own
# directory there). The pictures are LFS objects; a pointer is ~130 bytes of
# text that every browser draws as a broken image, so refuse it here rather
# than ship a manual with no pictures in it.
mkdir -p "${STAGE}/manual/media"
cp "${REPO}/docs/manual/index.html" "${STAGE}/manual/"
for f in "${REPO}/docs/manual/media"/*; do
if head -c 40 "${f}" | grep -q '^version https://git-lfs'; then
echo "error: $(basename "${f}") is an LFS pointer, not a picture." >&2
echo " run: git lfs pull --include='docs/manual/media/**'" >&2
exit 1
fi
cp "${f}" "${STAGE}/manual/media/"
done
echo "==> staged the manual and $(ls "${STAGE}/manual/media" | wc -l) picture(s)"
# One installer in the output directory, the one just built. The directory
# is cached between CI runs, so after a version bump a glob over it would find
# two and the smoke test would hand Wine both names as one path.
+9 -7
View File
@@ -7,8 +7,8 @@ second.
| | |
|---|---|
| [The manual](manual/README.md) | Every feature, pictured from the application itself — opening a library, rating and filing, developing, local masks, repair, film, panoramas, export |
| [How it is driven](gestures.md) | Every gesture and shortcut, by screen. Generated from the code, so it cannot describe one the application does not have |
| [The manual](manual/README.md) | Every feature, pictured from the application itself — opening a library, rating, labelling and filing, duplicate originals, developing, local masks, repair, film, panoramas, export. The application carries it and opens it from Help and from Settings |
| [How it is driven](gestures.md) | Every gesture and shortcut, by screen. Generated from the code, so it cannot describe one the application does not have. The same list is the in-app help sheet: `Help` or `F1` in the grid, `?` or `F1` in develop |
The [top-level README](../README.md) says what DarkRoom is, how to get it on
each platform, and what is still missing.
@@ -74,11 +74,13 @@ recollection.
## Conventions
Two files here are generated and must not be edited by hand:
`gestures.md` and `dev/traceability.md`. Both come from
`cargo run -p traceability` and the pre-commit hook keeps them in step with
the tree. The manual's pictures are recorded by
[`tools/manual`](../tools/manual/README.md) and live in LFS.
Three files here are generated and must not be edited by hand:
`gestures.md`, `dev/traceability.md` and `manual/index.html`, the page the
packages install, rendered from `manual/README.md`. All three come from
`cargo run -p traceability`, the pre-commit hook keeps them in step with the
tree, and CI fails when one is not what the tree generates. The manual's
pictures are recorded by [`tools/manual`](../tools/manual/README.md) and live
in LFS.
A design document links to the requirements it satisfies and to the code
that satisfies them. When the code moves, the link moves with it; a document
+19 -10
View File
@@ -11,9 +11,10 @@ and records the decisions and constraints behind the design.
## 1. Overview
DarkRoom is a Rust application with a Slint interface. On Linux it renders through wgpu to Vulkan.
On Android it renders through Skia to OpenGL — not by preference but because wgpu's Vulkan
swapchain cannot pre-rotate, which tears a portrait window on a landscape-mounted panel
([technical-debt.md TD-1](technical-debt.md)). The compute passes are wgpu on both. The design is organised around four ideas, each of which the rest of this
On Android it renders through Skia, also on wgpu's Vulkan swapchain, which a patched wgpu-hal and
Skia renderer pre-rotate for a landscape-mounted panel ([technical-debt.md TD-1](technical-debt.md),
[third_party/](../../third_party/README.md)). The compute passes are wgpu on both, on the same
device the compositor draws with. The design is organised around four ideas, each of which the rest of this
document elaborates:
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the
@@ -51,7 +52,7 @@ darkroom/
│ ├── dr-types SourceRef, ImageId, VersionId, ParamValue — shared vocabulary
│ ├── dr-catalog SQLite index, scan, query, metadata
│ ├── dr-sidecar the authoritative edit store (§6.12)
│ ├── dr-decode RawDecoder trait, rawler impl, embedded-preview extraction
│ ├── dr-decode Decoder trait (§3.2), rawler impl, embedded-preview extraction
│ ├── dr-pipeline Operation trait, descriptors, edit graph, registry
│ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser
│ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms
@@ -131,6 +132,15 @@ Four separate entry points because the caller's needs differ sharply by phase. C
`RawImage` carries CFA-pattern sensor data plus black/white levels and camera colour matrices — it
is *not* demosaiced. Demosaic is a GPU pipeline stage (§5.2).
> **As built (FR-RAW-2, 0.15.0).** The trait is `dr_decode::Decoder`, and it takes bytes rather
> than a reader: `header_bytes` says how much of a file metadata needs, `metadata` and
> `orientation` read it, `locate_preview` says where the embedded preview sits so the caller's
> storage can fetch that range, and `preview` and `decode` take the bytes fetched. The split this
> section argues for survives; what moved is who reads the file, which is the storage layer
> (§3.1's `read_range`), not the decoder. `dr_decode::Rawler` is the one implementation, and only
> the places that start a job name `dr_decode::default()`; everything below them takes a
> `&dyn Decoder`.
### 3.3 Operation and descriptors
The develop pipeline is a sequence of operations with a uniform interface. Polymorphism is by
@@ -1125,12 +1135,11 @@ At 4K the shader finishes in 0.28 ms and then 7.15 ms is spent moving pixels thr
26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The
constraint is not a stylistic preference; it is the dominant cost in the frame.
**One exception, on Android only, and it is debt rather than a revision.** The develop view there
reads the frame back rather than handing over a texture, because zero-copy requires Slint to draw
with wgpu and wgpu's Android swapchain tears a portrait window. The reasoning, the measurements
that forced it and what would remove it are in [technical-debt.md TD-1](technical-debt.md). The
constraint above still governs every other path, including the desktop develop view and the export
pipeline, and the Android exception is expected to be temporary.
**No exceptions since 0.15.0.** Android read the develop frame back until then, because wgpu's
Vulkan swapchain could not pre-rotate and a portrait window tore on the tablet. Two local patches
removed the need ([technical-debt.md TD-1](technical-debt.md)), so the frame reaches the compositor
as a texture on both platforms. The constraint governs every display path. The export pipeline
reads pixels back by design, because a file is its output.
### 6.2 Tiling from day one
+10 -2
View File
@@ -249,6 +249,13 @@ what you actually have.
only when something needs it: import duplicate detection (FR-CAT-11), or reconnecting a moved source
(FR-CAT-9). Never during a routine scan.
Consolidating the duplicates a library already holds (FR-CAT-11a, 0.16.0) does not wait for it. It
proves a group the same by `content_hash` where every copy has one, and otherwise by a digest of
each copy's first and last megabyte, read by range through the backend and kept in `dedup_probes`
keyed on the size and mtime it was taken at, so a second review reads nothing.
`dedup_probes` is created on first use rather than by a migration, because a schema bump would make
an older build refuse this catalog's snapshot at sync (`core/dr-catalog/src/duplicates.rs`).
---
## 4. The library view
@@ -711,8 +718,9 @@ because if the SQLite path proves troublesome, this is the fallback with a known
membership needs to be *stable* — for manual ordering, or for a pinned set that must not shift
under the user — it needs materialising with an invalidation rule. Deferred until there is a
concrete need.
- **Multi-root capture-time collisions.** FR-CAT-11 detects duplicates on import; the same image
catalogued under two roots is a related but distinct case, not yet specified.
- **Multi-root capture-time collisions.** FR-CAT-11 detects duplicates on import, and FR-CAT-11a
consolidates the copies one root holds in several folders; the same image catalogued under two
roots is a related but distinct case, not yet specified.
- **Timeline granularity selection.** Which bucket size the UI picks for a given zoom is a UI
concern, but the catalog should probably suggest one from the query's date span rather than have
the UI guess.
+1 -1
View File
@@ -22,7 +22,7 @@ and the reason is that some of the work is done and untagged.
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
| FR-DSP-2 tiled computation | **Absent, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). |
| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. |
| FR-DSP-4 progressive refinement | **Absent**, and §4's condition did not fire. Every render is full quality and can afford to be. |
| FR-DSP-4 progressive refinement | **Built** (0.15.0), although §4's condition did not fire on the fused path: a half-resolution draft while a gesture moves, one sharp frame 120 ms after it stops, the histogram dimmed while it lags, and the draft faded out over 150 ms — `ui/dr-ui/src/refine.rs`. See [frame-budget.md](frame-budget.md). |
| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
| FR-DSP-6 colour management | **Done.** Output space is a parameter of composition. |
| FR-DSP-7 histogram and clipping | **Done**, GPU-side, no per-frame readback. |
+11 -4
View File
@@ -45,10 +45,11 @@ somewhere:
`project_license` is GPL-3.0-or-later; those differ on purpose — see the
comment in the file.
- **Vulkan is a requirement, not a preference.** The develop pipeline is
compute shaders through wgpu, and NFR-R8 — how far a CPU fallback goes — is
still open, so today there is nothing behind it. A package that installs onto
a machine with no working ICD produces an application that starts and cannot
develop.
compute shaders through wgpu, and NFR-R8 was decided on 2026-09-19 against a
CPU pipeline: without a device the library, the grid and the judgements work
on embedded previews, and develop and export say they are unavailable. A
package that installs onto a machine with no working ICD produces an
application that starts and cannot develop.
- **A Secret Service implementation, or an honest degraded mode.** FR-NC-2 is
explicit that the absence of a secrets daemon is a stated degraded mode and
never a silent fall back to plaintext. Packages express this as an optional
@@ -59,6 +60,12 @@ somewhere:
the Flatpak manifest check the file size and refuse, because the alternative
is a package whose face indexing fails inside the graph loader on a user's
machine rather than on the packager's.
- **So are the manual's pictures.** Since 0.15.0 the Arch package, the Windows
installer and the APK carry `docs/manual/index.html` and its `media/`, where
`dr_ui::manual` looks for them (`/usr/share/darkroom/manual`, `manual\` beside
`darkroom.exe`, the APK's `assets/manual`), and each refuses a pointer where a
picture should be — shipped, a pointer is a manual of broken images that
nothing reports. The Flatpak manifest does not install it yet.
---
+43
View File
@@ -264,6 +264,14 @@ base, computed at reduced resolution and correct at any moment the user stops.
"Render coarse while dragging, sharpen when it settles" would paper over the same
34 ms with a visible swap. Fix the stage.
> **Since, 0.15.0.** The stage was fixed (§ The reduced base, below), and the
> draft was built anyway, in the form this section would accept: the develop
> view renders at half resolution while a gesture moves and once at full
> resolution 120 ms after it stops (`ui/dr-ui/src/refine.rs`), the histogram
> dims while it describes an older frame, and the last draft fades out over
> 150 ms rather than being swapped. It is not a mask over a slow stage; it
> spares a drag the full-resolution frames it does not need.
---
## Which GPU, on a machine with more than one
@@ -431,3 +439,38 @@ difference between a quarter-scale and a half-scale base to 0.03 stops of peak
excursion and 2% of frame reach. But it is a change in reach rather than only
in cost, it is what a tile scheduler would be handed, and it is worth knowing
that the number moved rather than discovering it later as a seam.
---
## The fit view, again — 2026-09-25
**Status:** Measured in the commits named, not re-run for this file.
Two changes to the fused path made the `fit` rows above cheaper again,
each with its before and after in its commit message. Both were measured
on the laptop RTX 3050 with its clocks held at 420/810 MHz by the power
cap, on the synthetic 60 MP source of `examples/frame_budget.rs`, median of
five alternated runs; both leave the rgba8 output bit-identical.
**The source gather is read once per framing** (`1dc7b45`). At fit every
output pixel reads one texel on a stride through a source three or four
times its width, and that gather was most of the fused pass. The pass now
keeps a render-sized `rgba16float` cache of it, keyed on the framing, and
reads it back while only the adjustments move:
| scene | before | after |
|---|---:|---:|
| neutral, 2560 × 1600 fit | 10.62 ms | 3.88 ms |
| neutral, 3840 × 2160 fit | 21.05 ms | 7.11 ms |
| clarity, 3840 × 2160 fit | 42.20 ms | 27.88 ms |
| neutral, 2560 × 1600 1:1 (control) | 3.83 ms | 3.84 ms |
An interpolated read (straightening, lens warps, CA) is not cached, and the
cache is written on the second frame with a given key, so a crop or zoom
drag pays nothing for it.
**A detail pass that changes nothing is dropped** (`d430ec9`). Capture
sharpening at a scale too coarse to draw its radius emits an empty pass,
which cost a full read and write when another neighbourhood operation
followed it: sharpen with clarity at 2560 × 1600 fit went from 18.66 ms to
14.16 ms, and at 3840 × 2160 from 37.93 ms to 27.88 ms.
+19 -1
View File
@@ -269,7 +269,7 @@ The set operations are already expressible in fixed-function blending over
|------|-------------------------------|--------|-------|
| Union | `One`, `One`, `Max` | `max(dst, src)` | M1 |
| Subtract | `Zero`, `OneMinusSrc`, `Add` | `dst · (1 − src)` | M1 |
| Intersect | `Zero`, `Src`, `Add` | `dst · src` | M2 |
| Intersect | `Zero`, `Src`, `Add` | `dst · src` | M2 (built 2026-09-24) |
The middle row is `brush_erase`, already constructed in
[`MaskPass::new`](../../core/dr-gpu/src/mask.rs). The other two are the same
@@ -292,6 +292,16 @@ rendering as it did. `an_erase_stroke_holes_its_own_part_and_not_the_mask` in
[`local_adjustments.rs`](../../core/dr-gpu/tests/local_adjustments.rs) is the test
that holds this in place.
Intersection, as built, is exactly the table's row: a third pipeline beside
the other two in `MaskPass::new`, `Join::apply` stating the three on the CPU,
and `a_part_unioned_subtracted_and_intersected_gives_the_three_fields` and
`the_joins_match_their_definition` in `local_adjustments.rs` holding the GPU
to it. It is a product, not a minimum: the two agree wherever either side is
fully in or out, and between two soft edges the product is the softer reading,
which is the right way for an overlap of two partial selections to be wrong.
Its blend is `Add`, not `Max`, so it adds nothing to the Android question
below.
`Max` blending on `r8unorm` is core WGPU and universally supported on the
desktop backends; **verify it on the Android adapter before M2 lands**, since
that is the platform where a blend mode is most likely to be quietly emulated
@@ -689,6 +699,14 @@ nothing, and every report of it came back as "the masks do not work".
per-part distance fields (§5.5), the part list with its join chips, folding
two layers.
Done (2026-09-24): `Intersect` — the join, its blend state, the sidecar word
`intersect`, the part row's chip cycling + / − / ∩, and an "∩ Intersect"
button beside Add and Subtract (§7.1). The part list with its chips and a
per-part eye was already there from M1. Outstanding: joining a model,
gradient or range part from the panel (the buttons still join a painted
part, so an intersection is painted where the mask should survive), per-part
distance fields, and folding two layers.
**M3 — Push, and cling.** The warp pass (§5.3) and edge-aware deposit (§5.6).
Both are refinements of a tool that already works, which is the right place
for the two riskiest pieces.
+106 -48
View File
@@ -28,6 +28,12 @@ Android memory pressure and lost-root recovery (FR-PLAT-AND-5, FR-PLAT-AND-2), i
recorded where it appears rather than deleted, because a requirement that is *half* met is the
one most likely to be reported as closed.
**Swept again on 2026-09-26, for 0.16.0.** Progressive refinement (FR-DSP-4) and the panorama
(§3.11) were built and are struck below; consolidating duplicate originals (FR-CAT-11a) now sits
beside re-import detection; the accessibility and localisation counts in §6 had not been read
against the tree since 2026-08-30 and are replaced; and §4a records what the develop and keyboard
work of 0.15.0 and 0.16.0 left open.
---
## 1. Plugins — post-v1 since 2026-09-19
@@ -80,8 +86,8 @@ somebody reads the matrix.
## 2. Culling — the stated differentiator, half built
[D11](requirements.md) names culling "the core differentiator". FR-CULL-1, -2, -3, -4 and -8 through
-12 are built. Three are not.
[D11](requirements.md) names culling "the core differentiator". FR-CULL-1 through -5 and -8 through
-13 are built. Two are not.
**FR-CULL-3 — Raw-truth overlays. Built, all three bullets.** Focus peaking is
`core/dr-gpu/src/focus.rs` and `ui/dr-ui/src/peaking.rs`; the raw histogram and the raw clipping
@@ -115,8 +121,13 @@ labels — that comment was a forward reference and is now simply wrong, rather
And `dr_catalog::bursts::choose_representative` is written and tested but bound to no gesture, so
today the only override is expanding the burst.
`core/dr-catalog/src/dedup.rs` remains a different thing: re-import detection under FR-CAT-11,
matching a file against one already catalogued, not two photographs against each other.
Neither is deduplication, which is about one file held twice rather than two frames that look
alike. `core/dr-catalog/src/dedup.rs` is re-import detection under FR-CAT-11, matching a file on
a card against one already catalogued. Its other half, FR-CAT-11a, is built since 0.16.0:
`core/dr-catalog/src/duplicates.rs` groups the copies a library already holds (same root, camera,
capture instant and size), `ui/dr-ui/src/duplicates.rs` proves each group the same by digest and
compares their edits, and a group is folded onto one survivor with the others trashed in one
transaction, from the Duplicate originals review in the sidebar and in Settings.
**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan.
This is the one of the four with no adjacent machinery at all, and it is also the one that most
@@ -166,10 +177,15 @@ Two measurements say it costs more than it saves on the interactive path. Neithe
about the export path or about a device under memory pressure, which is where the case for it
actually lives — and that is spike S6, which has not run.
**FR-DSP-4 — Progressive refinement.** Unbuilt. FR-DSP-1's proxy rendering and TD-4's
quarter-resolution base are adjacent and are not it: both are fixed choices about what resolution to
compute at, where FR-DSP-4 asks for a first frame that is deliberately cheap and a second that
replaces it. Nothing tracks a "this frame is provisional" state.
**FR-DSP-4 — Progressive refinement. Built in 0.15.0.** While a gesture moves the canvas renders
a half-resolution draft, and the sharp frame lands once, 120 ms after the last movement: the
decision is `ui/dr-ui/src/refine.rs`, a debounce whose every draft re-arms the settle timer, driven
through simulated timelines in its tests. The draft flag reaches the interface (`canvas-draft`,
`Levels.provisional`), so the histogram dims while it describes a frame older than the one on
screen, and the last draft is kept and faded out over 150 ms when the sharp frame arrives, so
refinement is not a swap. Draft frames themselves date from 2026-08-09; what this entry missed,
and 0.15.0 finished, was a settle that waited for the gesture to stop, a provisional state the
interface could see, and a refinement that was not a jarring swap.
**NFR-RES-2 — Images larger than GPU memory.** Half answered. NFR-R8's "decide explicitly" was
decided on 2026-09-19: there is no CPU render pipeline, the degraded mode is the viewer on
@@ -181,11 +197,42 @@ settle both this and FR-DSP-2, and there is no evidence it has run.
---
## 4a. Develop, masks and the keyboard — what the 0.15.0 and 0.16.0 work left open
Most of what these two releases built closed a clause outright, and is listed here only so that
nobody looks for it below: perspective correction (FR-DEV-20, Vertical and Horizontal in Compose),
the crop that says when it orphans a mask (FR-DEV-17), intersection as a third mask join
(FR-DEV-19a), the keyboard vocabulary for develop (FR-DEV-16, met and held by `gestures-check` in
both directions), colour labels set, shown and filtered (NFR-A11Y-3's first example), the RAW
decoder behind a trait (FR-RAW-2), and duplicate originals (FR-CAT-11a, §2 above). What they left:
- **FR-UI-5's pointer half.** Keys cover navigation, rating and common adjustments in every view,
and judging works in develop on the open photograph; but no develop slider takes the scroll
wheel, which the clause names. Its status note in the register says so rather than rounding up.
- **FR-RAW-2's second decoder.** The trait is built and a stub decoder is proved to reach the
scan, the preview ladder and export without a caller changing; LibRaw itself (D2) is not
built, and S7 is what would say when it is needed.
- **FR-DEV-19's M2 remainder** ([mask-editing.md §11](mask-editing.md)). The panel's Add, Subtract
and Intersect buttons join a painted part only, so an intersection with a gradient or a range is
painted where the mask should survive; per-part distance fields and folding two layers are
unbuilt. From M1, the brush has no ring drawn on the photograph and a layer's whole stroke
history is redrawn per dab.
- **FR-DEV-17's blind spot, by design.** A layer that adds a range or a region selection is never
reported, because the check has no label map to measure it with (`dr_pipeline::orphan`); a
false alarm on the common path would teach the notice to be dismissed unread.
The desktop's scrollbars (the develop column, the grid, the sidebar, Settings, the film list and
the help sheet) are drawn only where `dr_plat::is_touch_first()` is false; on Android the lists
still scroll by flick alone, which is deliberate rather than outstanding.
---
## 5. Android beyond running, and Flatpak
The Android app is not a stub — it builds an APK, runs the whole application, unpacks bundled face
models, and has been measured on a tablet ([faces.md §12.1](faces.md),
[technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it.
models, has been measured on a tablet ([faces.md §12.1](faces.md)), and since 0.15.0 draws the
develop view zero-copy as the desktop does ([technical-debt.md TD-1](technical-debt.md), paid off).
It carries the manual and opens it in a WebView. What is missing is the platform contract around it.
**FR-PLAT-AND-1 is untagged, and what it was tagged for was intent rather than code.**
The requirement demands that library access be obtained *exclusively* through the Storage Access
@@ -267,38 +314,44 @@ fixes users will need — is unaddressed, and there is no update mechanism of an
## 6. Accessibility and internationalisation — the hard half is done and the easy half is not
**NFR-A11Y-1 — Localisation.** `@tr(` appears **zero** times across 14,482 lines of Slint. That
number overstates the problem, because the part that is genuinely architectural was got right:
`LocalizedKey` keeps display strings out of `core/` entirely, every operation publishes a key rather
than a label, and `labels::resolve` is the single point where a key becomes text. What that single
point does, however, is a hardcoded English `match` in Rust source — so changing a translation
requires a recompile, which is the one thing the requirement explicitly forbids. There is no message
catalogue in any format, no locale-resolution rule, and no decision recorded about RTL.
**NFR-A11Y-1 — Localisation.** One screen of twenty-eight Slint files is converted: the launch
screen wraps its strings in `@tr(`, and `ui/dr-ui/build.rs` records the mechanism — Slint's bundled
translations, a `.po` per language under `ui/dr-ui/lang/`, extracted with `slint-tr-extractor` —
and why bundling rather than gettext (Android has no path a `.mo` could sit at). No `.po` exists
yet, so every build is the original English, and the rest of the interface (about 23,600 lines of
markup) is unwrapped. The part that is genuinely architectural was got right: `LocalizedKey` keeps
display strings out of `core/` entirely, every operation publishes a key rather than a label, and
`labels.rs` is the single point where a key becomes text — but that point is a hardcoded English
`match`, and `build.rs` says the Rust-side mechanism is not built. Two things the requirement asks
remain against it even when the conversion is finished: a bundled translation is compiled in, so
changing one still needs a rebuild, which the clause forbids; and there is no locale-resolution rule
and no decision recorded about RTL.
The work left is therefore smaller than it looks and entirely mechanical: a catalogue format, a load
path behind `resolve`, and `@tr(` around the Slint literals. The design it needs already exists.
**NFR-A11Y-2 — Accessibility. Tagged, and half tested.** `accessible-*` properties now appear on
some 127 lines across eleven Slint files — the shared controls in `widgets.slint` and
`controls.slint`, every slider, the rail, the grid's cells (named by file), the rating stars, the
labels and the duplicates review — and `ui/dr-ui/tests/ui_controls_are_accessible.rs` fails when a
shared control stops declaring its role or a slider is added without a name. The manual's recording
scripts find every control they press through the same names (`dr_ui::automation`), so a control a
screen reader cannot name is also one the manual cannot record. What is unchecked is stated in the
test: whether the words are good, and whether Android exposes any of it — spike S13, which has not
run. The requirement's other two clauses are debt with entries of their own: platform font scaling
([TD-7](technical-debt.md)) and WCAG AA contrast ([TD-6](technical-debt.md)).
**NFR-A11Y-2 — Accessibility.** `accessible-*` appears five times in the whole interface, all five
on one control — the parameter slider in `adjust.slint` — and nothing is set from the Rust side at
all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own
caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run.
**NFR-A11Y-3 — Colour-independent status.** Built, and now asserted. The clipping readout pairs a
marker that appears or disappears with a figure in words; the rating strip is a solid star against
an outline in an achromatic palette; the pick/reject mark is a tick against a cross; the
focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches; and colour labels —
the requirement's first named example — now have an interface built with a shape from the start:
every mark carries its label's initial (R, Y, G, B, P) on its colour, and every chip, picker and the
develop bar name the label in words.
**NFR-A11Y-3 — Colour-independent status.** Built where a control exists, and now tagged: the
clipping readout pairs a marker that appears or disappears with a figure in words, the rating strip
is a solid star against an outline in an achromatic palette, the pick/reject mark is a tick against
a cross, and the focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches. Each
of those already carried the reasoning in a comment naming this requirement and simply had no
`TRACES` line.
Two caveats, because the tag now says more than the evidence does. **Only the clipping clause has a
test** — `a_clipping_figure_distinguishes_none_from_nearly_none`, which pins `<0.1%` apart from `0%`
so the figure cannot contradict the lit marker beside it. The three Slint components are
inspected-and-argued, not asserted, and nothing would fail if a future edit made a star differ only
in tint. **And the requirement's first named example has no interface at all**: catalog colour
labels are a nullable `label INTEGER` column on the versions table and are set and shown nowhere, so
the clause about them is untestable rather than satisfied. That clause closes when the label UI is
built, not before, and it should be built with a shape from the outset — which is the same argument
as below, for doing this alongside NFR-A11Y-2 rather than after it.
The clipping clause is pinned by `a_clipping_figure_distinguishes_none_from_nearly_none`. The other
four are pinned by `ui/dr-ui/tests/status_is_not_colour_alone.rs`, which reads the markup and fails
if a star, a flag or a label comes to differ in tint alone — if the glyph a state selects stops
depending on the state, if two states select the same drawing, if two labels share a letter, or if
the peaking chips stop being words. It is a structural check, not a perceptual one: it cannot say
whether the letters are legible at 14px on a given screen, which is still a matter of looking.
---
@@ -455,18 +508,23 @@ whether something *should* be built — which is the opposite of the order §9 a
---
## 11. Merging — specified 2026-09-19, nothing built
## 11. Merging — the panorama built, three clauses open
§3.11 was written on 2026-09-19 under D18, undeferring the panorama from §7 and leaving HDR merge
and focus stacking there with their data model decided. Eleven `FR-MRG` clauses and two `NFR-MRG`
figures entered the register at once with no code behind any of them, which is why the coverage
figure fell from 83.0% to 77.2% on the same day — a specification, not a regression.
and focus stacking there with their data model decided. Its clauses entered the register with no
code behind them, which is why the coverage figure fell from 83.0% to 77.2% on that day — and the
panorama was then built in the same week: alignment, projections, the chunked composite written as
a DNG beside its sources, the auto-crop and the model's border fill.
[panorama.md](panorama.md) §11 and §13 are where it stands.
[panorama.md](panorama.md) is the design, and its §10 is the order of work. Nothing starts before
**S15**: whether rawler reads back a linear DNG the application writes, whether XFeat loads under
tract at a fixed shape, whether the working-space texture can be tapped where FR-MRG-2 needs it,
and what a chunked blend of a 100 MP composite costs on the tablet. The first two are a day each
and either can change the design, which is the reason they come first.
Three clauses carry no tag:
- **FR-MRG-9 — the tablet.** Android runs the same code, but the stated ceiling on frame count and
source resolution, refused with a message rather than an out-of-memory kill, is not set.
- **NFR-MRG-1 — merge latency.** Measured on the desktop and inside its 60 s (panorama.md §11);
the tablet's figure is S15.4's open half, and nothing asserts either per commit.
- **NFR-MRG-2 — reproducible merges.** Nothing checks that the same sources and settings give a
byte-identical composite.
## 12. D12, which governed all of the above
+79 -6
View File
@@ -179,6 +179,17 @@ card into the library. Both are needed.
original filename) and by content hash, offering skip or import-as-new. Camera filenames wrap at
`IMG_9999`, so filename alone is insufficient. Existing catalog duplicates are detectable on demand.
**FR-CAT-11a — Consolidating catalog duplicates.** Files already in the library more than once
(same root, camera, capture time and size) shall be listed on demand as groups, from the library
and from Settings. Before anything moves, each group shall be proved the same file by a stored full
digest or by a digest of the first and last megabyte of every copy, and its copies' develop edits
compared; a group that differs in either is left out and the review says why. One copy survives —
not under a backup-looking folder, then camera-named, then oldest, overridable per group — and the
others' collections, keywords, highest rating, agreed flag and label, and faces are merged onto it,
with disagreements reported rather than decided silently. Each group is merged and its other copies
moved to the trash (FR-CAT-15) in one transaction: wholly consolidated or untouched. Nothing is
deleted.
**FR-CAT-12 — Versions (virtual copies).** An image may carry multiple named `Version`s, each with
an independent edit graph, without duplicating source data. Versions are creatable, nameable,
deletable, and independently exportable; one is the default.
@@ -243,9 +254,15 @@ read metadata, and `import.rs` fetches exactly that range through `Storage::read
calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies
it; a decoder holding its own `SourceRef` would have had to implement the range policy itself.
What is genuinely not built is the trait. There is one decoder, reached through free functions, so
"without changing callers" is a claim nothing yet tests. The clause stands as written and is
outstanding work, not a satisfied one.
*Status (2026-09-24).* The trait is built: `dr_decode::Decoder`, over bytes — `header_bytes`,
`metadata`, `orientation`, `locate_preview`, `preview` and `decode` — with `dr_decode::Rawler` as
its one implementation, delegating to the free functions that were there before. The catalog scan,
the thumbnail ladder, import, the viewer, export, merge and repairs take a `&dyn Decoder`; only the
places that start a job name `dr_decode::default()`. "Without changing callers" is tested by
`dr-ui`'s `decoder_seam` tests, which hand a stub decoder for a container no real decoder reads to
the scan, the ladder and export, and fail if any of them reaches past the trait. Nothing in the
trait takes a path or a `SourceRef`. The second decoder itself (LibRaw, D2) is not built; S7 (#48)
is what would say when it is needed.
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
@@ -544,9 +561,17 @@ decade; a mask that cannot be corrected is the reason an edit leaves this applic
editor.
**FR-DEV-19a — Mask composition.** A layer's mask is an ordered list of parts, each naming a source
and how it joins the mask before it — added to it, or taken out of it. A layer of one part is
exactly the layer that existed before this, and reads and writes the same sidecar. The parts of a
layer merge under FR-NC-9 as the layer does, and each carries its own edge treatment.
and how it joins the mask before it — added to it, taken out of it, or intersected with it, keeping
only where both agree. A layer of one part is exactly the layer that existed before this, and reads
and writes the same sidecar. The parts of a layer merge under FR-NC-9 as the layer does, and each
carries its own edge treatment.
*Intersection added 2026-09-24* (issue #9, `mask-editing.md` M2). Union and subtraction were built
first; the selections that most need composing — the sky that is also bright, the subject that is
also skin — are intersections, and spelling one as a subtraction needs a part that selects the
complement. Intersection is the product of the two coverages, which is the minimum wherever either
side is fully in or out. A sidecar written before it never names it; a build from before it reads
the word as a union, which keeps the part visible rather than dropping it.
**FR-DEV-19b — Hand correction.** A part may be painted, with add and erase strokes, at a radius,
hardness and flow the photographer sets. Strokes are stored as normalised source coordinates and
@@ -569,6 +594,17 @@ which knows nothing of a layer's shaping and nothing at all about a gradient, a
so choosing a subject or a category produced a layer whose extent was invisible, and every control
in FR-DEV-19a and FR-DEV-19b acted on something the photographer could not see.
**FR-DEV-17 — A crop that orphans a mask says so.** When a crop is let go — the end of the drag,
not any frame of it — and it has left a mask layer's coverage entirely or mostly outside the frame,
the photographer is told how many layers, and which, and offered to keep the crop or take it back.
The crop is applied either way and is never refused; taking it back is the ordinary undo, so the
crop and its warning are one step in the history. A crop that strands nothing, and a layer already
outside the frame before the crop moved, produce no notice at all.
Mask geometry is stored in source coordinates, so a tighter crop does not destroy a layer: it makes
it invisible, and nothing announces it. That silent loss is the whole case for cropping last; a
notice at the moment it happens turns it into a decision, and lets the crop stay where the histogram
argues it belongs — early.
**FR-DEV-16 — A keyboard vocabulary for develop.** Every develop gesture that can be reached from
the keyboard is bound, tagged beside its implementation, and generated into the gesture book
(FR-UI-4): stepping through the folder, fit and 1:1, holding the original, undo and redo, copying
@@ -580,6 +616,28 @@ Editing rhythm depends on the hands staying put: reaching for a menu breaks the
edit the same way it breaks the pace of a cull. A binding nobody can discover is the same as no
binding, which is why the generated book is part of the requirement rather than documentation of it.
*Status (2026-09-24).* Met. Every binding named above is bound in develop's key handler and tagged
beside it, and develop also answers zoom in, out, fit and 1:1, panning a magnified view, rating,
flagging and labelling the open photograph, nudging the control last moved (the framing sliders
included), turning a mask part's join, keeping a crop that hid a mask, and going back to the grid.
"Cannot describe a binding the application does not have" is now checked rather than argued:
`traces gestures-check` reads the chord every handler compares against (`Keys.chord`, in
`ui/dr-ui/ui/keys.slint`) and fails when a bound key has no tag in its place or a tagged key has no
handler (`tools/traceability/src/keymap.rs`).
**FR-DEV-20 — Perspective correction.** Framing shall include perspective correction — a vertical
and a horizontal keystone — applied before the crop. It runs in the coordinate chain after the
crop and the straightening and before the stored orientation and the lens warp, so that "vertical"
means vertical in the photograph as it is shown and the lens is still corrected on the full frame
it projected. It is part of framing and carries its `Compose` attribute, so a settings paste
withholds it by default for the same reason it withholds the crop. The correction never exposes
undefined area on its own; where it is combined with a straightening angle, the crop that avoids
the empty corners (`max_inscribed_crop`) accounts for it, and the crop is refitted when the gesture
ends.
Converging verticals are the commonest geometric fault in architectural and interior work, and
correcting them reframes the image — which is why the order relative to the crop is not a
preference, and why the correction belongs to composition and not to the colour operations.
### 3.4 Display and interaction
**FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually
@@ -609,6 +667,12 @@ asynchronously, and the proxy result remains on screen until it is ready.
quality or resolution, refining to full quality when interaction settles. Refinement is visually
smooth, not a jarring swap.
*Status (2026-09-26).* Met in 0.15.0. A gesture renders half-resolution drafts, and the sharp frame
lands once, 120 ms after the last movement rather than on a timer counted from the first
(`ui/dr-ui/src/refine.rs`). While a draft is up the histogram's display reading is dimmed, since it
describes the last settled frame; the last draft is kept over the sharp one and faded out in
150 ms, so the refinement is not a swap.
**FR-DSP-5 — Zoom and pan.** Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the pipeline
operates on the visible crop at full source resolution.
@@ -695,6 +759,15 @@ rating, and common adjustments. Neither is required for any operation to be reac
> wherever they can be set, and on the roll's cells, so stepping along a set shows what has been
> judged.
*Status (2026-09-24).* The keyboard half and the amendment are met; the pointer half is not yet.
Keys cover navigation, rating and common adjustments in the grid, develop, the collections sidebar,
People and the export and copy sheets, each one in the gesture book and held to its handler by the
gestures gate. Rating and flagging work in develop on the open photograph without advancing, with
stars and Pick/Reject in the top bar, and the roll's cells carry each frame's flag and stars; pick
and reject also gained a pointer and touch route in the grid (Flag in the selection bar), where they
had been keyboard-only. Outstanding: scroll-wheel adjustment on numeric controls — the develop
sliders do not take the wheel.
**FR-UI-6 — Shared component library.** Touch and desktop presentations are variants of shared
components, not parallel implementations. A new operation (FR-DEV-3c) becomes usable on both
without frontend work.
+13
View File
@@ -195,6 +195,7 @@ pub trait BackendProvider: Send + Sync {
fn endpoint_placeholder(&self) -> &'static str;
fn sign_in(&self) -> SignIn;
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
fn upgrade_endpoint(&self, stored: &str) -> Option<String>; // defaulted: None
fn account_for(&self, endpoint: &str) -> Result<Account, RemoteError>; // defaulted
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError>;
}
@@ -215,6 +216,18 @@ pub enum SignIn {
Nextcloud provider upgrades `http://` to `https://` here (`NFR-SEC-3`); the
folder provider canonicalises the path, so two spellings of one directory do
not become two accounts indexing the same photographs.
- **`upgrade_endpoint` is for accounts already saved,** and is not a second
call to `normalise_endpoint`: the folder provider's needs the path to exist,
so running it on every launch would fail a library on an unplugged disk.
`AccountStore::upgrade_endpoints` runs it at launch; only Nextcloud answers,
rewriting an `http://` account an older build saved to `https://`
(#65). The keyring entry is filed under the endpoint, so the secret is
copied across before the record changes, and a move that would change
`namespace()` is refused rather than performed. Below both, the Nextcloud
client sets `https_only`, so no URL it sends — a typed one, the login flow's
poll endpoint, a redirect — can carry the app password in the clear, and
the login flow keeps the address the user typed rather than the server's
idea of its own URL.
- **`connect` is synchronous and cheap.** It validates configuration and builds
a client; it does not talk to the remote. Workers call it per task.
- **`SignIn` is a shape, not a method.** It would be tidier to expose
+36 -1
View File
@@ -14,7 +14,7 @@ has quietly stopped being necessary.
---
## TD-1 — The Android develop view reads pixels back through the CPU
## TD-1 — The Android develop view reads pixels back through the CPU ✅ PAID OFF
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
@@ -88,6 +88,41 @@ Any one of these removes it:
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
`DevelopSession::render` is gone, and the tablet is clean in portrait.
### Paid off
By none of the three routes above. It came from a fourth that the list missed: patching both
halves locally. "wgpu cannot do [the rotation] on Skia's behalf" was true and beside the point,
because Skia can rotate its own canvas. Slint's Skia renderer already does it for rotated panels on
linuxkms, just not on its wgpu surface. So two small patches, carried in `third_party/`
([README](../../third_party/README.md)), do it for us:
- **wgpu-hal 29.0.4.** `vulkan::Surface::set_pre_transform` lets a caller choose the swapchain's
`preTransform`, and `current_transform` reads the surface's. It is opt-in, and the default stays
`IDENTITY`, so desktop is unchanged.
- **i-slint-renderer-skia 1.17.1.** On Android the wgpu surface sizes its swapchain in the panel's
orientation, promises the display's transform, and concatenates the matching rotation onto the
canvas before Slint draws. The imported develop texture goes through the same matrix as
everything else. The surface re-reads the transform every frame, because a half turn does not
resize the window. The item renderer's pixel snapping now accepts right-angle rotations,
or portrait would have lost it everywhere.
With those in place, `unstable-wgpu-29` is common to both platforms, `shared_gpu` hands the one
device to Slint on Android too, and both readbacks are gone (the frame through `export_pixels` and
the focus overlay through `read_overlay`). The develop frame and its overlay reach the compositor
as textures, as they do on desktop.
**Verified by eye, not by instrument.** On 2026-09-25 the user checked the release-signed build on
the tablet and found it clean. That is the portrait tear this entry was opened over. The
`dumpsys SurfaceFlinger` readings that would complete the table above (composition and
`bufferTransform` per orientation, expected `DEVICE` in all three) were not taken, because adb
would not hold the device that morning. Nor was the develop frame time measured before or after.
The readback's cost was never measured either, so the saving is reasoned, not measured.
**The cost moved rather than vanished:** two upstream crates are pinned by path. A Slint or wgpu
bump has to carry the patches forward (the README says how), and cargo only *warns* when a patch no
longer matches, then silently builds the unpatched crate. Both patches should go upstream: the
Skia half is the Android counterpart of a feature Slint already has, and the wgpu half is #3345.
---
## TD-2 — Thumbnails are fetched one at a time
+116 -113
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -684,7 +684,8 @@ three columns of equal width side by side, each its own scroll:
with them because it is not in their column.
2. **Sliders** — `GroupStrip` above `AdjustPanel`, exactly as in the column.
With a group selected this is one screen of sliders; with All it scrolls.
3. **The mode's panels** — `ComposePanel` and `TransferPanel` in photo mode,
3. **The mode's panels** — `ComposePanel` in photo mode (copy and paste
moved to the top bar, 2026-09-22),
`SpotPanel` in repair, `MaskPanel` in local. Empty otherwise, which is
a signal of its own about which mode the view is in (§1.1).
+2
View File
@@ -285,6 +285,8 @@ $LOCALAPPDATA\Programs\DarkRoom\
scrfd_500m_640.int8.onnx scrfd_2.5g_640.int8.onnx scrfd_10g_640.int8.onnx
yolo26s-sem-ade20k.onnx yolo26s-sem-ade20k.classes.json categories.txt
migan-512.onnx
manual\
index.html media\ (the rendered manual and its pictures)
LICENSE
uninstall.exe
```
+457 -61
View File
@@ -5,7 +5,7 @@
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
41 gestures, in 4 places.
76 gestures, in 7 places.
## Develop
@@ -13,121 +13,254 @@ Every entry here is extracted from the comment beside the code that implements i
- **Touch** — Press "pick" in the group's heading, then tap something neutral in the picture
- **Pointer** — Press "pick", then click something neutral
- **See it** — [in the manual](manual/README.md#white-balance-from-the-photograph)
Sampling a neutral is the first move of the tonal pass — every colour judgement afterwards is measured against where the grey was put — and guessing at two sliders until a wall stops looking green is the wrong way round. One click is one sample and one step to undo; the sliders stay, because a sampled neutral is where the decision starts rather than where it ends.
<sub>`ui/dr-ui/ui/adjust.slint:158`</sub>
<sub>`ui/dr-ui/ui/adjust.slint:159`</sub>
### Choose a film stock from the keyboard
- **Touch** — Tap the Film row, flick the list, tap a stock
- **Pointer** — Click the Film row, then a stock; the wheel or a drag scrolls the list
- **Keyboard** — With the list open, `↑` and `↓` move along it, `Enter` chooses, and `Escape` or `Back` closes it unchanged
- **See it** — [in the manual](manual/README.md#film)
The list is longer than it is tall, so a way to walk it that cannot be lost to the column's scrolling is part of it being reachable at all. Focus goes back to the photograph's keys when it closes.
<sub>`ui/dr-ui/ui/adjust.slint:1476`</sub>
### Magnify the photograph by any amount
- **Touch** — Pinch it with two fingers
- **Pointer** — The scroll wheel over it
- **Keyboard** — `Ctrl+=` or `Ctrl+Plus` in, `Ctrl+-` out, about the middle of the view
- **See it** — [in the manual](manual/README.md#looking-closer)
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. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
<sub>`ui/dr-ui/ui/app.slint:1762`</sub>
<sub>`ui/dr-ui/ui/app.slint:1908`</sub>
### Move a magnified photograph about
- **Touch** — Drag it
- **Pointer** — Drag it
- **Keyboard** — `Shift+←`, `Shift+→`, `Shift+↑` and `Shift+↓`, a fifth of the view at a time
- **See it** — [in the manual](manual/README.md#looking-closer)
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:1853`</sub>
<sub>`ui/dr-ui/ui/app.slint:2004`</sub>
### Paint a mask by hand
- **Touch** — Choose Paint or Erase, then drag on the photograph
- **Pointer** — Choose Paint or Erase, then drag
- **See it** — [in the manual](manual/README.md#local-adjustments)
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:1940`</sub>
<sub>`ui/dr-ui/ui/app.slint:2095`</sub>
### Open this list
- **Touch** — Press "?" in the top bar, beside Settings, and Done to put it away
- **Pointer** — Press "?" in the top bar, beside Settings, and Done to put it away
- **Keyboard** — `F1`; the key that closes any sheet puts it away
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
<sub>`ui/dr-ui/ui/app.slint:2321`</sub>
### Take back the last change
- **Touch** — Tap the step above the current one in the History list
- **Pointer** — Click it, or press Undo in the History header
- **Keyboard** — Ctrl+Z
- **Keyboard** — `Ctrl+Z`
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
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:2160`</sub>
<sub>`ui/dr-ui/ui/app.slint:2351`</sub>
### Do it again after taking it back
- **Touch** — Tap the step below the current one in the History list
- **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
<sub>`ui/dr-ui/ui/app.slint:2173`</sub>
<sub>`ui/dr-ui/ui/app.slint:2365`</sub>
### Remove a repair
- **Touch** — Tap it, then Delete Repair
- **Pointer** — Click it, then Delete Repair
- **Keyboard** — `Delete` or `Backspace`, while repairing
<sub>`ui/dr-ui/ui/app.slint:2385`</sub>
### Copy the settings from this photograph
- **Touch** — Press Copy in the Settings panel
- **Pointer** — Press Copy in the Settings panel
- **Keyboard** — Ctrl+C
- **Touch** — Press Copy in the top bar
- **Pointer** — Press Copy in the top bar
- **Keyboard** — `Ctrl+C`
- **See it** — [in the manual](manual/README.md#copying-settings)
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 button 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:2206`</sub>
<sub>`ui/dr-ui/ui/app.slint:2404`</sub>
### Paste the settings onto this photograph
- **Touch** — Press Paste in the Settings panel
- **Pointer** — Press Paste in the Settings panel
- **Keyboard** — Ctrl+V
- **Touch** — Press Paste in the top bar
- **Pointer** — Press Paste in the top bar
- **Keyboard** — `Ctrl+V`
- **See it** — [in the manual](manual/README.md#copying-settings)
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:2218`</sub>
<sub>`ui/dr-ui/ui/app.slint:2417`</sub>
### Choose which kinds of edit a copy carries
- **Touch** — Open Presets and toggle the kinds
- **Pointer** — Open Presets and toggle the kinds
- **Keyboard** — `Ctrl+Shift+C`, which offers Copy beside them
- **See it** — [in the manual](manual/README.md#copying-settings)
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
<sub>`ui/dr-ui/ui/app.slint:2435`</sub>
### Export this photograph as the last one was
- **Touch** — Press Export in the top bar
- **Pointer** — Press Export in the top bar
- **Keyboard** — `Ctrl+Shift+E`
- **See it** — [in the manual](manual/README.md#export)
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
<sub>`ui/dr-ui/ui/app.slint:2460`</sub>
### Choose how to export, then export
- **Touch** — Open Settings, then Export defaults
- **Pointer** — Open Settings, then Export defaults
- **Keyboard** — `Ctrl+E`
- **See it** — [in the manual](manual/README.md#export)
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
<sub>`ui/dr-ui/ui/app.slint:2473`</sub>
### Keep a crop that leaves a mask outside
- **Touch** — Press "Keep crop" on the notice, or "Undo crop" to take it back
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
<sub>`ui/dr-ui/ui/app.slint:2538`</sub>
### Go back to the grid
- **Touch** — Press "‹ Library" in the top bar
- **Pointer** — Press "‹ Library" in the top bar
- **Keyboard** — `G`
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
<sub>`ui/dr-ui/ui/app.slint:2555`</sub>
### Nudge the control last moved
- **Touch** — Drag its track
- **Pointer** — Drag its track
- **Keyboard** — `=` or `Plus` up and `-` down, a hundredth of its travel at a time; hold for more
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
<sub>`ui/dr-ui/ui/app.slint:2584`</sub>
### Change which group of adjustments is on screen
- **Touch** — Tap a group in the rail down the left
- **Pointer** — Click a group in the strip above the develop column
- **Keyboard** — [ and ] step through them, wrapping round through "everything"
- **Keyboard** — `[` and `]` step through them, wrapping round through "everything"
- **See it** — [in the manual](manual/README.md#developing-a-photograph)
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:2246`</sub>
<sub>`ui/dr-ui/ui/app.slint:2612`</sub>
### Look at the photograph at 1:1
- **Touch** — Double-tap the photograph
- **Pointer** — Double-click it, or press the zoom readout floating over the canvas
- **Keyboard** — Z
- **Keyboard** — `Z` goes in and back out; `Ctrl+1` goes to 1:1 and `Ctrl+0` back to the whole frame
- **See it** — [in the manual](manual/README.md#looking-closer)
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. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
<sub>`ui/dr-ui/ui/app.slint:2281`</sub>
<sub>`ui/dr-ui/ui/app.slint:2648`</sub>
### Rate this photograph
- **Touch** — Tap a star in the top bar
- **Pointer** — Click a star in the top bar
- **Keyboard** — `0`–`5`
<sub>`ui/dr-ui/ui/app.slint:2705`</sub>
### Pick or reject this photograph
- **Touch** — Press Pick or Reject in the top bar; again to take the flag off
- **Pointer** — Press Pick or Reject in the top bar; again to take the flag off
- **Keyboard** — `P` picks, `X` rejects and `U` takes the flag off
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
<sub>`ui/dr-ui/ui/app.slint:2711`</sub>
### Give this photograph a colour label
- **Touch** — Tap Label in the top bar, then a colour
- **Pointer** — Click Label in the top bar, then a colour
- **Keyboard** — `6` red, `7` yellow, `8` green, `9` blue; the same key again takes it off
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
<sub>`ui/dr-ui/ui/app.slint:2741`</sub>
### Move to the next or previous photograph
- **Touch** — Tap a frame in the roll along the foot of the canvas
- **Pointer** — Click a frame in the roll
- **Keyboard** — Right arrow or space for the next, left arrow for the one before
- **Keyboard** — `→`, `D` or `Space` for the next; `←` or `A` for the one before
- **See it** — [in the manual](manual/README.md#moving-between-photographs)
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. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
<sub>`ui/dr-ui/ui/app.slint:2333`</sub>
<sub>`ui/dr-ui/ui/app.slint:2766`</sub>
### See the photograph before you edited it
- **Touch** — Press and hold "Before"
- **Pointer** — Press and hold "Before"
- **Keyboard** — Hold \
- **Keyboard** — Hold `\`
- **See it** — [in the manual](manual/README.md#light)
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:2457`</sub>
<sub>`ui/dr-ui/ui/app.slint:2896`</sub>
### Put one control back to its default
- **Touch** — Double-tap its track
- **Pointer** — Double-click its track, or right-click it
- **Keyboard** — R, for the control last moved
- **Keyboard** — `R`, for the control last moved
The column is 280px wide and the colour mixer alone puts thirty-six of these in it, so a reset button per row would be most of the width. Two ways in with a pointer because right-click is the one a hand already reaches for and double-click is the one that needs no second button. A group's own reset is in its heading; this is the single control.
@@ -137,6 +270,7 @@ The column is 280px wide and the colour mixer alone puts thirty-six of these in
- **Touch** — Press and hold the eye beside the snapshot
- **Pointer** — Press and hold the eye beside the snapshot
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
The same hold as "Before", against a point the photographer chose rather than the file: "the version I liked twenty minutes ago" is how a choice between two treatments is actually made. It takes no history step and changes nothing; letting go puts the edit back.
@@ -146,38 +280,81 @@ The same hold as "Before", against a point the photographer chose rather than th
- **Touch** — Type a name in the History panel and press Snapshot
- **Pointer** — Type a name in the History panel and press Snapshot
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
The history is forgotten with the sitting, on purpose; a snapshot is the photographer saying this one should not be. It is written into the sidecar as a version of the edit, so it survives a restart and reaches the other device. Pressing a snapshot puts the photograph back to it, as one step that undo takes back whole.
<sub>`ui/dr-ui/ui/history.slint:307`</sub>
<sub>`ui/dr-ui/ui/history.slint:308`</sub>
### Show or hide one mask layer
- **Touch** — Tap the ring at the head of its row
- **Pointer** — Click the ring at the head of its row
- **Keyboard** — H, for the selected layer history step, unlike holding "Before" — the layer really is off until it is switched back on.
- **Keyboard** — `H`, for the selected layer
- **See it** — [in the manual](manual/README.md#local-adjustments)
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a history step, unlike holding "Before" — the layer really is off until it is switched back on.
<sub>`ui/dr-ui/ui/masks.slint:212`</sub>
<sub>`ui/dr-ui/ui/masks.slint:213`</sub>
### Show or hide one mask on the photograph
- **Touch** — Tap the eye on its row
- **Pointer** — Click the eye on its row
- **See it** — [in the manual](manual/README.md#local-adjustments)
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
<sub>`ui/dr-ui/ui/masks.slint:279`</sub>
<sub>`ui/dr-ui/ui/masks.slint:280`</sub>
### Change how a part joins its mask
- **Touch** — Tap the + / − / ∩ chip on the part's row
- **Pointer** — Click the + / − / ∩ chip on the part's row
- **Keyboard** — `J` turns the selected part's chip, while masking
- **See it** — [in the manual](manual/README.md#local-adjustments)
A chip that cycles rather than a menu, because a photographer flips a join while looking at the picture, not at the panel: add, take away, keep only where both agree, and round.
<sub>`ui/dr-ui/ui/masks.slint:367`</sub>
### Leave one part out of a mask, and put it back
- **Touch** — Tap the ring on the part's row
- **Pointer** — Click the ring on the part's row
- **See it** — [in the manual](manual/README.md#local-adjustments)
The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing.
<sub>`ui/dr-ui/ui/masks.slint:394`</sub>
<sub>`ui/dr-ui/ui/masks.slint:409`</sub>
## Everywhere
### Close what is open, or go back a step
- **Touch** — The system Back gesture, or the Back button
- **Pointer** — The Close or Back button on whatever is open
- **Keyboard** — `Escape`, or `Back` where the device has one
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
<sub>`ui/dr-ui/ui/app.slint:987`</sub>
### Do what a sheet offers
- **Touch** — Press its button — Export, or Copy
- **Pointer** — Press its button — Export, or Copy
- **Keyboard** — `Enter`, on the export and copy sheets
<sub>`ui/dr-ui/ui/app.slint:997`</sub>
### Scroll by the scrollbar
- **Pointer** — Drag the bar along the right-hand edge of a list, or click the track above or below it to move by a page
A list cut off at its edge looks, to a mouse, like a list that ends there — the film stocks past the tenth read as deleted. The bar says there is more and where the view is in it, and it is the one way to scroll that needs neither a wheel nor a drag on the content, which may be a row that would take the click.
<sub>`ui/dr-ui/ui/widgets.slint:1074`</sub>
## Collections sidebar
@@ -185,37 +362,94 @@ The question a correction raises is whether it did what it was for — whether t
- **Touch** — Press and hold it until it lifts, then drag it
- **Pointer** — Drag it, or hold it until it lifts and then drag
- **See it** — [in the manual](manual/README.md#collections)
The tree is inside a Flickable, which claims any drag beginning inside it — so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something, and it is what every mobile list already uses to pick a row up. The row lifts the moment it fires, so the gesture says it has been understood before anything moves.
<sub>`ui/dr-ui/ui/collections.slint:335`</sub>
<sub>`ui/dr-ui/ui/collections.slint:352`</sub>
### Act on a collection — rename, nest, un-nest, delete
- **Touch** — Press and hold the collection, then let go without moving
- **Pointer** — Right-click it
- **See it** — [in the manual](manual/README.md#collections)
The hold arms a drag and opens this menu, and which one you get is decided by whether you moved — the same fork the grid uses. One menu for everything done to a row, because there is one hold per row: while the hold opened the offline question by itself, nothing else the tree can do had a touch route.
<sub>`ui/dr-ui/ui/collections.slint:346`</sub>
<sub>`ui/dr-ui/ui/collections.slint:364`</sub>
### Take a collection back out of the one it is nested in
- **Touch** — Hold it, then drag it onto "All photographs" — or let go and choose "Move to top level"
- **Pointer** — Drag it onto "All photographs", or right-click it and choose "Move to top level"
- **See it** — [in the manual](manual/README.md#collections)
Nesting is a drag of one row onto another, and its inverse had no gesture at all: "All photographs" refused every drop, which is right for a photograph — it is already in the library — and wrong for a collection, which has a top level to be returned to. Without it a collection dragged into another was in there permanently.
<sub>`ui/dr-ui/ui/collections.slint:356`</sub>
<sub>`ui/dr-ui/ui/collections.slint:375`</sub>
### Rename a collection
- **Touch** — Hold the collection, then "Rename"
- **Pointer** — Double-click its name, or right-click it and choose "Rename"
- **Keyboard** — Type the name and press `Enter`; `Escape` abandons it
- **See it** — [in the manual](manual/README.md#collections)
Double-click is what a file manager and a Lightroom panel use for the same thing, so it needs no discovering — but nothing on screen says so, which is what the menu item is for.
<sub>`ui/dr-ui/ui/collections.slint:369`</sub>
<sub>`ui/dr-ui/ui/collections.slint:389`</sub>
### Review duplicate originals
- **Touch** — Tap "Duplicate originals" under the trash
- **Pointer** — Click "Duplicate originals" under the trash
- **See it** — [in the manual](manual/README.md#duplicate-originals)
Under the trash because the trash is where the spare copies go, and only while the catalog holds any: a row that is always there and usually empty is noise.
<sub>`ui/dr-ui/ui/collections.slint:884`</sub>
## Duplicate originals
### Check duplicate originals are the same file
- **Touch** — Tap "Check"
- **Pointer** — Click "Check"
- **See it** — [in the manual](manual/README.md#duplicate-originals)
Nothing is moved on the catalog's say-so. The check reads the first and last megabyte of each copy and its sidecar, keeps what it read, and drops any group whose bytes or edits differ.
<sub>`ui/dr-ui/ui/duplicates.slint:233`</sub>
### Move the spare copies to the trash
- **Touch** — Tap "Move N copies to trash"
- **Pointer** — Click "Move N copies to trash"
- **See it** — [in the manual](manual/README.md#duplicate-originals)
The one verb, named with its count, and off until a check has proved something. Each group is merged onto the copy that stays and the others go to the trash together or not at all; the trash view gives them back.
<sub>`ui/dr-ui/ui/duplicates.slint:248`</sub>
### Choose which copy of a duplicate stays
- **Touch** — Tap the copy's path
- **Pointer** — Click the copy's path
- **See it** — [in the manual](manual/README.md#duplicate-originals)
The rule picks the copy outside a backup folder that still has the camera's name. The path is the evidence, so the path is what is tapped to overrule it.
<sub>`ui/dr-ui/ui/duplicates.slint:313`</sub>
### Leave a duplicate group as it is
- **Touch** — Untick "Include" on the group
- **Pointer** — Untick "Include" on the group
- **See it** — [in the manual](manual/README.md#duplicate-originals)
The plan is every group the check proved; a group you want to keep in two places is taken out of it rather than argued with.
<sub>`ui/dr-ui/ui/duplicates.slint:323`</sub>
## People
@@ -223,37 +457,59 @@ Double-click is what a file manager and a Lightroom panel use for the same thing
- **Touch** — Tap the faces that do not belong, then "Split off"
- **Pointer** — Click the faces that do not belong, then "Split off"
- **See it** — [in the manual](manual/README.md#people)
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
<sub>`ui/dr-ui/ui/identity.slint:188`</sub>
<sub>`ui/dr-ui/ui/identity.slint:189`</sub>
### Rule on a suggested face
- **Touch** — Tick to confirm it, cross to reject it
- **Pointer** — Tick to confirm it, cross to reject it
- **See it** — [in the manual](manual/README.md#people)
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on.
<sub>`ui/dr-ui/ui/identity.slint:208`</sub>
<sub>`ui/dr-ui/ui/identity.slint:210`</sub>
### Move between people
- **Touch** — Tap a person on the rail
- **Pointer** — Click a person on the rail
- **Keyboard** — `↑` and `↓`, along the rail
<sub>`ui/dr-ui/ui/identity.slint:385`</sub>
### Name a person
- **Touch** — Tap the name field over their faces, type, and press Enter
- **Pointer** — Click the name field over their faces, type, and press Enter
- **Keyboard** — `F2` puts the name field under the keys
The rename key everywhere else. Enter finishes the name and hands the keys back to the rail, so naming a run of people is `Down`, `F2`, a name, Enter, over and over.
<sub>`ui/dr-ui/ui/identity.slint:391`</sub>
### See a person's photographs
- **Touch** — Choose them in the rail, then "Show photos"
- **Pointer** — Choose them in the rail, then "Show photos"
- **See it** — [in the manual](manual/README.md#people)
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
<sub>`ui/dr-ui/ui/identity.slint:635`</sub>
<sub>`ui/dr-ui/ui/identity.slint:689`</sub>
### Change how faces are grouped
- **Touch** — "Grouping…", move the dials, then Regroup
- **Pointer** — "Grouping…", move the dials, then Regroup
- **See it** — [in the manual](manual/README.md#people)
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
<sub>`ui/dr-ui/ui/identity.slint:672`</sub>
<sub>`ui/dr-ui/ui/identity.slint:727`</sub>
## Library grid
@@ -261,133 +517,273 @@ The right match confidence is a property of your library, not of the model. "Wha
- **Touch** — Press and hold a photograph, or press Select in the header
- **Pointer** — Ctrl-click, or press Select in the header
- **See it** — [in the manual](manual/README.md#selecting-several)
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
<sub>`ui/dr-ui/ui/library.slint:1572`</sub>
<sub>`ui/dr-ui/ui/library.slint:1701`</sub>
### Add or remove one photograph
- **Touch** — While selecting, tap it
- **Pointer** — Ctrl-click it
- **See it** — [in the manual](manual/README.md#selecting-several)
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
<sub>`ui/dr-ui/ui/library.slint:1581`</sub>
<sub>`ui/dr-ui/ui/library.slint:1711`</sub>
### Leave selecting
- **Touch** — Press Done in the header
- **Pointer** — Press Done in the header
- **Keyboard** — Escape
- **Keyboard** — `Escape`, or `Back`; an open sheet closes first
- **See it** — [in the manual](manual/README.md#selecting-several)
<sub>`ui/dr-ui/ui/library.slint:1589`</sub>
<sub>`ui/dr-ui/ui/library.slint:1720`</sub>
### Pick a photograph up to drag it
- **Touch** — Press and hold it until a ring opens around it, then drag
- **Pointer** — Drag it
- **See it** — [in the manual](manual/README.md#collections)
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
<sub>`ui/dr-ui/ui/library.slint:1619`</sub>
<sub>`ui/dr-ui/ui/library.slint:1751`</sub>
### Select a range
- **Touch** — While selecting, press "Select to…", then tap the last photograph of the run
- **Pointer** — Shift-click the last photograph of the run
- **Keyboard** — Shift with any key that walks the grid — `Shift+←`, `Shift+→`, `Shift+↑`, `Shift+↓`, `Shift+Page Up`, `Shift+Page Down`, `Shift+Home`, `Shift+End`
- **See it** — [in the manual](manual/README.md#selecting-several)
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
<sub>`ui/dr-ui/ui/library.slint:1684`</sub>
<sub>`ui/dr-ui/ui/library.slint:1817`</sub>
### Take the blinks out of a burst
- **Touch** — Narrow to a person, then tap "Eyes open" beside their name on the filter bar
- **Pointer** — Narrow to a person, then click "Eyes open" beside their name on the filter bar
- **See it** — [in the manual](manual/README.md#bursts)
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
<sub>`ui/dr-ui/ui/library.slint:2383`</sub>
<sub>`ui/dr-ui/ui/library.slint:2534`</sub>
### Find photographs with two people in them
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
- **Pointer** — Open the People chip on the filter bar, click each name, then switch the chip beside them to "all of them"
- **See it** — [in the manual](manual/README.md#people)
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
<sub>`ui/dr-ui/ui/library.slint:2412`</sub>
<sub>`ui/dr-ui/ui/library.slint:2564`</sub>
### Show only photographs with one colour label
- **Touch** — Tap its chip in the filter bar
- **Pointer** — Click its chip in the filter bar
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label.
<sub>`ui/dr-ui/ui/library.slint:2688`</sub>
### Export the selection as the last export was
- **Touch** — Select them, then Export in the selection bar
- **Pointer** — Select them, then Export in the selection bar
- **Keyboard** — `Ctrl+Shift+E`, or `Ctrl+E` to see the export settings first
- **See it** — [in the manual](manual/README.md#export)
Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting.
<sub>`ui/dr-ui/ui/library.slint:3158`</sub>
### Paste copied settings onto the selection
- **Touch** — Select them, then "Paste to N" in the selection bar
- **Pointer** — Select them, then "Paste to N"
- **Keyboard** — `Ctrl+V`
- **See it** — [in the manual](manual/README.md#copying-settings)
<sub>`ui/dr-ui/ui/library.slint:3182`</sub>
### Keyword the selection
- **Touch** — Select them, then Keywords in the selection bar
- **Pointer** — Select them, then Keywords in the selection bar
- **Keyboard** — `Ctrl+K`
Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes.
<sub>`ui/dr-ui/ui/library.slint:3211`</sub>
### Show only photographs with some number of stars
- **Touch** — Tap a star chip in the filter bar
- **Pointer** — Click a star chip in the filter bar
- **Keyboard** — Hold `F` and tap a digit, `0`–`5`, for exactly that many stars, or two digits for everything between them; tap `F` alone to show every rating again
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds.
<sub>`ui/dr-ui/ui/library.slint:3245`</sub>
### Give photographs a colour label
- **Touch** — Select them, then Label in the selection bar and tap a colour
- **Pointer** — Select them, then Label in the selection bar and click a colour
- **Keyboard** — `6` red, `7` yellow, `8` green, `9` blue, with the pointer over it or on the selection; the same key again takes the label off
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart.
<sub>`ui/dr-ui/ui/library.slint:3295`</sub>
### Pick or reject a photograph
- **Touch** — Select them, then Flag in the selection bar and Pick, Reject or No flag
- **Pointer** — Select them, then Flag in the selection bar and Pick, Reject or No flag
- **Keyboard** — `P` picks, `X` rejects and `U` takes the flag off, with the pointer over it or on the selection
The keys every culling tool uses, so muscle memory built elsewhere works here.
<sub>`ui/dr-ui/ui/library.slint:3319`</sub>
### Move photographs to the trash
- **Touch** — Tap the bin at the start of a cell's stars
- **Pointer** — Hover the cell and click the bin before its stars
- **Keyboard** — `Delete` or `Backspace`, on the selection
The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view.
<sub>`ui/dr-ui/ui/library.slint:3346`</sub>
### Open this list
- **Touch** — Press Help in the header, and Done to put it away
- **Pointer** — Press Help in the header, and Done to put it away
- **Keyboard** — `F1`, and `Escape` to put it away
<sub>`ui/dr-ui/ui/library.slint:3371`</sub>
### Rename the collection the grid is showing
- **Touch** — Hold it in the sidebar, then "Rename"
- **Pointer** — Double-click it in the sidebar
- **Keyboard** — `F2`
<sub>`ui/dr-ui/ui/library.slint:3379`</sub>
### Move through the grid
- **Touch** — Scroll, and tap a photograph
- **Pointer** — Scroll, and click a photograph
- **Keyboard** — `←`, `→`, `↑` and `↓` move one photograph; `Page Up` and `Page Down` a screenful; `Home` and `End` to the first and the last
The cursor selects what it lands on, so walking and judging are one hand's work.
<sub>`ui/dr-ui/ui/library.slint:3399`</sub>
### Resize the thumbnails
- **Touch** — Pinch the grid with two fingers
- **Pointer** — Ctrl and the scroll wheel
- **Keyboard** — `=` or `Plus` for larger, `-` for smaller
- **See it** — [in the manual](manual/README.md#getting-about)
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:3072`</sub>
<sub>`ui/dr-ui/ui/library.slint:3530`</sub>
### File photographs in a collection
- **Touch** — Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.
- **Pointer** — Drag a photograph — or a whole selection — onto a collection in the sidebar
- **See it** — [in the manual](manual/README.md#collections)
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:3269`</sub>
<sub>`ui/dr-ui/ui/library.slint:3729`</sub>
### Open a photograph
- **Touch** — Tap it — a single tap, any length
- **Pointer** — Click it
- **Keyboard** — `Enter`, on the photograph the arrow keys have walked to
- **See it** — [in the manual](manual/README.md#developing-a-photograph)
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:3537`</sub>
<sub>`ui/dr-ui/ui/library.slint:4034`</sub>
### Rate a photograph without opening it
- **Touch** — Tap a star on the cell
- **Pointer** — Hover the cell, then click a star
- **Keyboard** — 0 to 5 on the selection
- **Keyboard** — `0`–`5` with the pointer over it, or on the selection
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
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:3657`</sub>
<sub>`ui/dr-ui/ui/library.slint:4157`</sub>
### Choose the frame a folded burst shows
- **Touch** — Open the burst, then tap the ring on the frame you want
- **Pointer** — Open the burst, then click the ring on the frame you want
- **See it** — [in the manual](manual/README.md#bursts)
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:3788`</sub>
<sub>`ui/dr-ui/ui/library.slint:4290`</sub>
### Drop the selection but keep selecting
- **Touch** — Press Clear in the selection strip
- **Pointer** — Press Clear in the selection strip
- **Keyboard** — `Ctrl+D` or `Ctrl+Shift+A`
- **See it** — [in the manual](manual/README.md#selecting-several)
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:4454`</sub>
<sub>`ui/dr-ui/ui/library.slint:4981`</sub>
### Select everything the grid is showing
- **Touch** — While selecting, press "Select all"
- **Pointer** — While selecting, press "Select all"
- **Keyboard** — `Ctrl+A`
- **See it** — [in the manual](manual/README.md#selecting-several)
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:4471`</sub>
<sub>`ui/dr-ui/ui/library.slint:5000`</sub>
### Take photographs out of a collection
- **Touch** — Select them, then "Collections…" in the selection bar
- **Pointer** — Select them, then "Collections…" in the selection bar
- **See it** — [in the manual](manual/README.md#collections)
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:4595`</sub>
<sub>`ui/dr-ui/ui/library.slint:5181`</sub>
## Settings
### Review duplicate originals
- **Touch** — Tap "Review duplicate originals"
- **Pointer** — Click "Review duplicate originals"
- **See it** — [in the manual](manual/README.md#duplicate-originals)
Beside the other whole-library passes, because it is one; the sidebar offers the same page under the trash.
<sub>`ui/dr-ui/ui/settings.slint:533`</sub>
+120 -12
View File
@@ -33,20 +33,43 @@ wrote first.
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).
The bar above the grid filters by rating, flag, colour label 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.
count what each rating holds, and clicking `3+` shows only those. From the
keyboard, `0` to `5` set the stars on the photograph under the pointer or on
the selection; `P` picks, `X` rejects and `U` takes the flag off, and `Flag`
on the selection bar does the same with the pointer. In
develop the same keys judge the open photograph and stay on it, and the top
bar carries its stars and `Pick` and `Reject`; the roll marks each frame's
flag and stars.
![Rating two photographs, then filtering the grid to three stars and more](media/library-rating.gif)
Colour labels work as Lightroom's do: `6` red, `7` yellow, `8` green, `9`
blue, on the photograph under the pointer or on the selection, and the same
key again takes the label off. `Label` on the selection bar offers all five,
purple included, and `None`. Each label is drawn with its initial on it, so
it reads without telling the colours apart, and the filter bar has a chip for
each. In develop, the top bar names the open photograph's label and sets it,
and the same keys work there.
![Labelling four frames with 6, 7, 8 and 9, taking one off with the same key, and filtering the grid to green](media/library-labels.gif)
### Getting about
Drag the timeline to scrub through years; Ctrl and the wheel resize the
thumbnails.
thumbnails. On a desktop a scrollbar beside the grid says how far through the
library the view is — drag its thumb, or click the track to move a page — and
the sidebar, the develop column and Settings have one too whenever they run
past the window. On a tablet they scroll by flick alone.
`Help` in the header, or `F1`, opens the controls and shortcuts: every key and
gesture, screen by screen, with `See it` beside those this page shows, and
`Manual` to open this page. In develop it is the `?` beside `Settings`.
![Scrubbing the timeline](media/library-timeline.gif)
@@ -86,6 +109,49 @@ move back to the top level, keep it offline, delete.
![The menu on a collection](media/library-collection-menu.png)
### Bursts
Frames taken a moment apart that look alike fold into one cell, with a badge
counting them. Click the badge — tap it, on a tablet — to open the burst in
the grid, and again to fold it back. A folded burst shows its earliest frame;
to have it show another, open it and click the ring on the frame you want.
With faces indexed, narrowing the grid to a person puts `Eyes open` beside
their name on the filter bar, which leaves out the frames where they blinked.
### Duplicate originals
A library put together by hand often holds the same RAW more than once — a
dated folder, a `bck` folder beside it, a renamed copy another program
exported. When the catalog finds files with the same camera, capture time
and size in more than one place, `Duplicate originals` appears under the
trash in the sidebar with how many there are; Settings offers the same page
beside the other whole-library passes.
The page lists each group with its picture and its paths. One copy is
marked `Stays`: the one outside a folder named like a backup (`bck`,
`backup`, `copy`, `old`…), then the one still named the way the camera named
it (`_MG_4623`, `IMG_0001`, `DSC_0042`), then the one catalogued first. Tap
another path to keep that copy instead, and untick `Include` to leave a
group alone.
Nothing moves until `Check` has read the first and last megabyte of every
copy and each copy's sidecar. A group whose files differ, or whose copies
carry two different edits, is marked skipped and says why — two edits of
one frame are kept for virtual copies. What the check reads is kept, so a
second visit costs nothing. The line under each group says what the copy
that stays will gain: the highest rating, every keyword and collection, a
flag or label the copies agree on, faces and the names on them, and the
edit if only a copy had one. Where the copies disagree on a flag or a
label, the one that stays keeps its own and the line says so.
![Two frames with a copy each in a bck folder, checked and proved the same, the copy that stays marked on each](media/duplicates.png)
`Move N copies to trash` does it, one group at a time: each group is merged
and its spare copies moved together, or not at all. The copies go to the
trash, not away — select them in the trash view and `Restore` puts them
back where they were.
## Developing a photograph
Click a thumbnail to open it. The column on the right is every adjustment;
@@ -105,9 +171,19 @@ Hold `Before` to see the photograph as it was.
### Looking closer
Double-click for 1:1; drag to move about; double-click again to fit. The
wheel zooms to any amount in between.
wheel zooms to any amount in between. Past 1:1 the file's own pixels are
drawn as hard-edged blocks rather than smoothed, so what you see is what
the sensor recorded.
![Zooming to 1:1, panning, and back](media/develop-zoom.gif)
![Zooming to 1:1 with a double-click, panning, then further in with the wheel](media/develop-zoom.gif)
### Moving between photographs
The roll along the foot of the canvas holds the photographs the grid was
showing; click one to open it. The right arrow, `D` or space opens the next,
and the left arrow or `A` the one before; held down, they go on past the
stretch the roll has loaded, through everything the grid would show. The
edit on screen is saved on the way, so stepping along a shoot loses nothing.
### White balance from the photograph
@@ -126,6 +202,17 @@ ratio from the chips. `Done composing` returns to the photograph.
![Cropping, straightening and choosing a ratio](media/compose.gif)
`Vertical` and `Horizontal`, under `Straighten`, correct the lines that
converge when the camera is tilted up at a building; the crop refits to
what is left.
![Standing towers shot from below upright with the Vertical slider](media/compose-perspective.gif)
A crop that leaves a mask wholly outside the frame says so, and offers to
take the crop back or keep it.
![A stroke in a corner, a crop that leaves it outside, and the notice with Undo crop and Keep crop](media/crop-orphan.gif)
### Local adjustments
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
@@ -144,7 +231,11 @@ 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.
ways to make one; each can be combined with any other. `∩ Intersect` keeps
only where the new part and the mask agree: below, a gradient over the
lower half, then two strokes that survive only where the gradient is.
![A linear gradient, then Intersect and two painted strokes](media/local-intersect.gif)
### Repair
@@ -158,9 +249,11 @@ opacity are in the panel. Heal blends; clone copies.
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.
sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
black-and-white stocks at its end.
![Choosing Velvia, then holding Before](media/film.gif)
![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif)
### History, snapshots, presets
@@ -170,6 +263,15 @@ apply elsewhere, and imports `.xmp` from other applications.
![The presets sheet](media/presets.png)
### Copying settings
`Copy` in the top bar, or Ctrl+C, takes this photograph's settings; `Paste`,
or Ctrl+V, puts them on another, and says what it would paste — how many
adjustments, and whether the crop comes too. In the grid, `Paste to N` on the
selection bar pastes onto every photograph selected. Which kinds of edit a
copy carries is chosen in `Presets…`, or with Ctrl+Shift+C: a look carried
across a shoot usually leaves each frame's own crop alone.
## Merging a panorama
Select the frames, then `Merge to panorama` from the selection bar. The
@@ -200,9 +302,10 @@ set is refused, and the header says so.
![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.
Background activity with progress, thumbnail and face indexing, how many
duplicate originals the library holds and the way to their review, storage
on this device, display and colour, export defaults, what is written to XMP
sidecars, the manual, and a diagnostics bundle for a bug report.
## People
@@ -230,6 +333,11 @@ 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.
The application carries this page. `cargo run -p traceability -- manual`
renders it to `index.html` beside it, which the packages install with the
pictures and the app opens from Help, from Settings, and from the "See it"
link beside a gesture on the help sheet. CI fails when the two differ.
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
+388
View File
@@ -0,0 +1,388 @@
<!DOCTYPE html>
<!-- GENERATED FILE — do not edit by hand. -->
<!-- Source: docs/manual/README.md. Regenerate: cargo run -p traceability -- manual -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>DarkRoom, shown</title>
<style>
:root {
--bg: #fbfaf8;
--ink: #1d1c1a;
--ink-dim: #5c5955;
--rule: #dedad4;
--accent: #8a4b12;
--panel: #f1eee9;
--mark: #f6e3c7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161514;
--ink: #e9e6e1;
--ink-dim: #a39e97;
--rule: #34312d;
--accent: #e8a25c;
--panel: #201e1c;
--mark: #43321f;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.page {
display: grid;
grid-template-columns: 15rem minmax(0, 46rem);
gap: 3rem;
justify-content: center;
padding: 2rem 1.5rem 4rem;
}
.toc {
position: sticky;
top: 1.5rem;
align-self: start;
max-height: calc(100vh - 3rem);
overflow-y: auto;
font-size: 0.9rem;
}
.toc-title {
margin: 0 0 0.5rem;
color: var(--ink-dim);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc ul ul { padding-left: 0.9rem; }
.toc li { margin: 0.2rem 0; }
.toc a { color: var(--ink-dim); text-decoration: none; }
.toc a:hover { color: var(--accent); }
main { min-width: 0; }
h1, h2, h3 { line-height: 1.25; scroll-margin-top: 1rem; }
h1 { font-size: 2.1rem; margin: 0 0 1rem; }
h2 { font-size: 1.5rem; margin: 2.6rem 0 0.8rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
h3 { font-size: 1.15rem; margin: 1.8rem 0 0.6rem; }
h2:target, h3:target { background: var(--mark); border-radius: 4px; padding-left: 0.3rem; margin-left: -0.3rem; }
a { color: var(--accent); }
code {
font: 0.88em ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", monospace;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 0.05em 0.3em;
}
figure { margin: 1.4rem 0; }
figure img, main img {
display: block;
width: 100%;
height: auto;
aspect-ratio: auto 16 / 11;
border-radius: 6px;
border: 1px solid var(--rule);
}
figcaption { margin-top: 0.4rem; color: var(--ink-dim); font-size: 0.9rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.95rem; }
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
th { color: var(--ink-dim); font-weight: 600; }
@media (max-width: 52rem) {
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 16px 3rem; }
.toc { position: static; max-height: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.8rem 1rem; background: var(--panel); }
body { font-size: 16px; }
}
</style>
</head>
<body>
<div class="page">
<nav class="toc" aria-label="Contents">
<p class="toc-title">Contents</p>
<ul>
<li><a href="#opening-a-library">Opening a library</a></li>
<li><a href="#the-library">The library</a>
<ul>
<li><a href="#rating-and-flagging">Rating and flagging</a></li>
<li><a href="#getting-about">Getting about</a></li>
<li><a href="#selecting-several">Selecting several</a></li>
<li><a href="#collections">Collections</a></li>
<li><a href="#bursts">Bursts</a></li>
<li><a href="#duplicate-originals">Duplicate originals</a></li>
</ul>
</li>
<li><a href="#developing-a-photograph">Developing a photograph</a>
<ul>
<li><a href="#light">Light</a></li>
<li><a href="#looking-closer">Looking closer</a></li>
<li><a href="#moving-between-photographs">Moving between photographs</a></li>
<li><a href="#white-balance-from-the-photograph">White balance from the photograph</a></li>
<li><a href="#composing">Composing</a></li>
<li><a href="#local-adjustments">Local adjustments</a></li>
<li><a href="#repair">Repair</a></li>
<li><a href="#film">Film</a></li>
<li><a href="#history-snapshots-presets">History, snapshots, presets</a></li>
<li><a href="#copying-settings">Copying settings</a></li>
</ul>
</li>
<li><a href="#merging-a-panorama">Merging a panorama</a></li>
<li><a href="#export">Export</a></li>
<li><a href="#settings">Settings</a></li>
<li><a href="#people">People</a></li>
<li><a href="#where-things-are-written-down">Where things are written down</a></li>
<li><a href="#how-this-page-is-made">How this page is made</a></li>
</ul>
</nav>
<main>
<h1 id="darkroom-shown">DarkRoom, shown</h1>
<p>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.</p>
<p>The requirements behind each feature are in <a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/requirements.md">requirements.md</a>;
the reasoning is in the design documents linked from each section. This page
is only about what you see.</p>
<p>The photographs are the author's. None show a person.</p>
<h2 id="opening-a-library">Opening a library</h2>
<p>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.</p>
<figure><img loading="lazy" src="media/launch.png" alt="The launch screen: a server field, a folder field, and which formats to scan for"><figcaption>The launch screen: a server field, a folder field, and which formats to scan for</figcaption></figure>
<p>Once a folder is named, it is the library — you are not asked for it again,
and <code>Open library</code> opens it whole. <code>Subfolder…</code> 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.</p>
<figure><img loading="lazy" src="media/launch-folder.png" alt="A folder chosen: the library, whether to scan a subfolder, and the formats"><figcaption>A folder chosen: the library, whether to scan a subfolder, and the formats</figcaption></figure>
<h2 id="the-library">The library</h2>
<figure><img loading="lazy" src="media/library.png" alt="The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above"><figcaption>The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above</figcaption></figure>
<p>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, colour label and where the
file is (on this device, or only on the server).</p>
<h3 id="rating-and-flagging">Rating and flagging</h3>
<p>Hover a cell and the stars appear; click one. The filter chips above the grid
count what each rating holds, and clicking <code>3+</code> shows only those. From the
keyboard, <code>0</code> to <code>5</code> set the stars on the photograph under the pointer or on
the selection; <code>P</code> picks, <code>X</code> rejects and <code>U</code> takes the flag off, and <code>Flag</code>
on the selection bar does the same with the pointer. In
develop the same keys judge the open photograph and stay on it, and the top
bar carries its stars and <code>Pick</code> and <code>Reject</code>; the roll marks each frame's
flag and stars.</p>
<figure><img loading="lazy" src="media/library-rating.gif" alt="Rating two photographs, then filtering the grid to three stars and more"><figcaption>Rating two photographs, then filtering the grid to three stars and more</figcaption></figure>
<p>Colour labels work as Lightroom's do: <code>6</code> red, <code>7</code> yellow, <code>8</code> green, <code>9</code>
blue, on the photograph under the pointer or on the selection, and the same
key again takes the label off. <code>Label</code> on the selection bar offers all five,
purple included, and <code>None</code>. Each label is drawn with its initial on it, so
it reads without telling the colours apart, and the filter bar has a chip for
each. In develop, the top bar names the open photograph's label and sets it,
and the same keys work there.</p>
<figure><img loading="lazy" src="media/library-labels.gif" alt="Labelling four frames with 6, 7, 8 and 9, taking one off with the same key, and filtering the grid to green"><figcaption>Labelling four frames with 6, 7, 8 and 9, taking one off with the same key, and filtering the grid to green</figcaption></figure>
<h3 id="getting-about">Getting about</h3>
<p>Drag the timeline to scrub through years; Ctrl and the wheel resize the
thumbnails. On a desktop a scrollbar beside the grid says how far through the
library the view is — drag its thumb, or click the track to move a page — and
the sidebar, the develop column and Settings have one too whenever they run
past the window. On a tablet they scroll by flick alone.</p>
<p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and
gesture, screen by screen, with <code>See it</code> beside those this page shows, and
<code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p>
<figure><img loading="lazy" src="media/library-timeline.gif" alt="Scrubbing the timeline"><figcaption>Scrubbing the timeline</figcaption></figure>
<figure><img loading="lazy" src="media/library-thumbsize.gif" alt="Resizing the thumbnails with Ctrl and the wheel"><figcaption>Resizing the thumbnails with Ctrl and the wheel</figcaption></figure>
<h3 id="selecting-several">Selecting several</h3>
<p><code>Select</code> 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.</p>
<figure><img loading="lazy" src="media/library-selection.png" alt="Twelve photographs selected, with the selection bar along the foot of the grid"><figcaption>Twelve photographs selected, with the selection bar along the foot of the grid</figcaption></figure>
<p><code>Keywords</code> 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.</p>
<figure><img loading="lazy" src="media/library-keywords.gif" alt="Keywording twelve frames"><figcaption>Keywording twelve frames</figcaption></figure>
<h3 id="collections">Collections</h3>
<p><code>+</code> 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.</p>
<figure><img loading="lazy" src="media/library-collections.gif" alt="Filing photographs in a collection by dragging them onto it"><figcaption>Filing photographs in a collection by dragging them onto it</figcaption></figure>
<p>Collections nest. Drag one onto another to put it inside; <code>+</code> with a
collection selected — or <code>New collection inside</code> from its menu — makes a
child. A parent shows everything its children hold, and its count says so.
Right-click a row (hold it, on a tablet) for the menu: rename, nest,
move back to the top level, keep it offline, delete.</p>
<figure><img loading="lazy" src="media/library-nesting.gif" alt="Making Trips, nesting Alps and New York inside it, and opening the parent"><figcaption>Making Trips, nesting Alps and New York inside it, and opening the parent</figcaption></figure>
<figure><img loading="lazy" src="media/library-nesting.png" alt="Trips showing both of its children's photographs"><figcaption>Trips showing both of its children's photographs</figcaption></figure>
<figure><img loading="lazy" src="media/library-collection-menu.png" alt="The menu on a collection"><figcaption>The menu on a collection</figcaption></figure>
<h3 id="bursts">Bursts</h3>
<p>Frames taken a moment apart that look alike fold into one cell, with a badge
counting them. Click the badge — tap it, on a tablet — to open the burst in
the grid, and again to fold it back. A folded burst shows its earliest frame;
to have it show another, open it and click the ring on the frame you want.</p>
<p>With faces indexed, narrowing the grid to a person puts <code>Eyes open</code> beside
their name on the filter bar, which leaves out the frames where they blinked.</p>
<h3 id="duplicate-originals">Duplicate originals</h3>
<p>A library put together by hand often holds the same RAW more than once — a
dated folder, a <code>bck</code> folder beside it, a renamed copy another program
exported. When the catalog finds files with the same camera, capture time
and size in more than one place, <code>Duplicate originals</code> appears under the
trash in the sidebar with how many there are; Settings offers the same page
beside the other whole-library passes.</p>
<p>The page lists each group with its picture and its paths. One copy is
marked <code>Stays</code>: the one outside a folder named like a backup (<code>bck</code>,
<code>backup</code>, <code>copy</code>, <code>old</code>…), then the one still named the way the camera named
it (<code>_MG_4623</code>, <code>IMG_0001</code>, <code>DSC_0042</code>), then the one catalogued first. Tap
another path to keep that copy instead, and untick <code>Include</code> to leave a
group alone.</p>
<p>Nothing moves until <code>Check</code> has read the first and last megabyte of every
copy and each copy's sidecar. A group whose files differ, or whose copies
carry two different edits, is marked skipped and says why — two edits of
one frame are kept for virtual copies. What the check reads is kept, so a
second visit costs nothing. The line under each group says what the copy
that stays will gain: the highest rating, every keyword and collection, a
flag or label the copies agree on, faces and the names on them, and the
edit if only a copy had one. Where the copies disagree on a flag or a
label, the one that stays keeps its own and the line says so.</p>
<figure><img loading="lazy" src="media/duplicates.png" alt="Two frames with a copy each in a bck folder, checked and proved the same, the copy that stays marked on each"><figcaption>Two frames with a copy each in a bck folder, checked and proved the same, the copy that stays marked on each</figcaption></figure>
<p><code>Move N copies to trash</code> does it, one group at a time: each group is merged
and its spare copies moved together, or not at all. The copies go to the
trash, not away — select them in the trash view and <code>Restore</code> puts them
back where they were.</p>
<h2 id="developing-a-photograph">Developing a photograph</h2>
<p>Click a thumbnail to open it. The column on the right is every adjustment;
the strip at its head narrows it to one group.</p>
<figure><img loading="lazy" src="media/develop.png" alt="The develop view: the photograph, the histogram, and the adjustment column"><figcaption>The develop view: the photograph, the histogram, and the adjustment column</figcaption></figure>
<figure><img loading="lazy" src="media/develop-groups.gif" alt="Switching between the Optics, Light, Colour, Effects and Detail groups"><figcaption>Switching between the Optics, Light, Colour, Effects and Detail groups</figcaption></figure>
<h3 id="light">Light</h3>
<p>Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
Hold <code>Before</code> to see the photograph as it was.</p>
<figure><img loading="lazy" src="media/develop-light.gif" alt="Raising exposure, pulling the highlights, lifting the shadows, then holding Before"><figcaption>Raising exposure, pulling the highlights, lifting the shadows, then holding Before</figcaption></figure>
<h3 id="looking-closer">Looking closer</h3>
<p>Double-click for 1:1; drag to move about; double-click again to fit. The
wheel zooms to any amount in between. Past 1:1 the file's own pixels are
drawn as hard-edged blocks rather than smoothed, so what you see is what
the sensor recorded.</p>
<figure><img loading="lazy" src="media/develop-zoom.gif" alt="Zooming to 1:1 with a double-click, panning, then further in with the wheel"><figcaption>Zooming to 1:1 with a double-click, panning, then further in with the wheel</figcaption></figure>
<h3 id="moving-between-photographs">Moving between photographs</h3>
<p>The roll along the foot of the canvas holds the photographs the grid was
showing; click one to open it. The right arrow, <code>D</code> or space opens the next,
and the left arrow or <code>A</code> the one before; held down, they go on past the
stretch the roll has loaded, through everything the grid would show. The
edit on screen is saved on the way, so stepping along a shoot loses nothing.</p>
<h3 id="white-balance-from-the-photograph">White balance from the photograph</h3>
<p>Press <code>pick</code> in the White Balance group, then click something neutral —
a white wall, a grey card, the air conditioner here. The picker sets the
sliders from the photograph, not from where they were: below, the frame
is dragged cold first and one click puts it right. A blown highlight is
refused, since a clipped pixel has no colour left to balance.</p>
<figure><img loading="lazy" src="media/develop-wb.gif" alt="Cooling the frame with the slider, then picking a white air conditioner to set the white balance"><figcaption>Cooling the frame with the slider, then picking a white air conditioner to set the white balance</figcaption></figure>
<h3 id="composing">Composing</h3>
<p>Crop by dragging the frame's corners, straighten with the slider, lock a
ratio from the chips. <code>Done composing</code> returns to the photograph.</p>
<figure><img loading="lazy" src="media/compose.gif" alt="Cropping, straightening and choosing a ratio"><figcaption>Cropping, straightening and choosing a ratio</figcaption></figure>
<p><code>Vertical</code> and <code>Horizontal</code>, under <code>Straighten</code>, correct the lines that
converge when the camera is tilted up at a building; the crop refits to
what is left.</p>
<figure><img loading="lazy" src="media/compose-perspective.gif" alt="Standing towers shot from below upright with the Vertical slider"><figcaption>Standing towers shot from below upright with the Vertical slider</figcaption></figure>
<p>A crop that leaves a mask wholly outside the frame says so, and offers to
take the crop back or keep it.</p>
<figure><img loading="lazy" src="media/crop-orphan.gif" alt="A stroke in a corner, a crop that leaves it outside, and the notice with Undo crop and Keep crop"><figcaption>A stroke in a corner, a crop that leaves it outside, and the notice with Undo crop and Keep crop</figcaption></figure>
<h3 id="local-adjustments">Local adjustments</h3>
<p><code>Local</code> in the rail turns the column into a mask stack. <code>Find subjects</code> 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.</p>
<figure><img loading="lazy" src="media/local-categories.png" alt="What the model found in an urban scene: ground, architecture, sky, vegetation"><figcaption>What the model found in an urban scene: ground, architecture, sky, vegetation</figcaption></figure>
<figure><img loading="lazy" src="media/local-segment.png" alt="The sky chosen: tinted on the photograph, and the column now scoped to it"><figcaption>The sky chosen: tinted on the photograph, and the column now scoped to it</figcaption></figure>
<p>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. <code>Show masks as</code> draws the mask
tinted, as alpha, or as an outline; the eye on its row switches it off.</p>
<figure><img loading="lazy" src="media/local-paint.gif" alt="Looking at the mask three ways, painting into it, then growing its edge"><figcaption>Looking at the mask three ways, painting into it, then growing its edge</figcaption></figure>
<p>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. <code>∩ Intersect</code> keeps
only where the new part and the mask agree: below, a gradient over the
lower half, then two strokes that survive only where the gradient is.</p>
<figure><img loading="lazy" src="media/local-intersect.gif" alt="A linear gradient, then Intersect and two painted strokes"><figcaption>A linear gradient, then Intersect and two painted strokes</figcaption></figure>
<h3 id="repair">Repair</h3>
<p><code>Repair</code> 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.</p>
<figure><img loading="lazy" src="media/repair.gif" alt="Covering marks on a road"><figcaption>Covering marks on a road</figcaption></figure>
<h3 id="film">Film</h3>
<p>The <code>Film</code> 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. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
black-and-white stocks at its end.</p>
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
keeps the current state under a name. <code>Presets…</code> saves the settings to
apply elsewhere, and imports <code>.xmp</code> from other applications.</p>
<figure><img loading="lazy" src="media/presets.png" alt="The presets sheet"><figcaption>The presets sheet</figcaption></figure>
<h3 id="copying-settings">Copying settings</h3>
<p><code>Copy</code> in the top bar, or Ctrl+C, takes this photograph's settings; <code>Paste</code>,
or Ctrl+V, puts them on another, and says what it would paste — how many
adjustments, and whether the crop comes too. In the grid, <code>Paste to N</code> on the
selection bar pastes onto every photograph selected. Which kinds of edit a
copy carries is chosen in <code>Presets…</code>, or with Ctrl+Shift+C: a look carried
across a shoot usually leaves each frame's own crop alone.</p>
<h2 id="merging-a-panorama">Merging a panorama</h2>
<p>Select the frames, then <code>Merge to panorama</code> 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 <code>Merge</code>. 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.</p>
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the projections tried, and the border filled</figcaption></figure>
<figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure>
<figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure>
<h2 id="export">Export</h2>
<p><code>Export</code> in the develop header, or <code>Export N</code> 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.</p>
<figure><img loading="lazy" src="media/settings-export.png" alt="Export defaults in Settings"><figcaption>Export defaults in Settings</figcaption></figure>
<h2 id="settings">Settings</h2>
<figure><img loading="lazy" src="media/settings.png" alt="The settings page: background activity, indexing, storage, display"><figcaption>The settings page: background activity, indexing, storage, display</figcaption></figure>
<p>Background activity with progress, thumbnail and face indexing, how many
duplicate originals the library holds and the way to their review, storage
on this device, display and colour, export defaults, what is written to XMP
sidecars, the manual, and a diagnostics bundle for a bug report.</p>
<h2 id="people">People</h2>
<p>Face detection and identity run over the library and group faces by person;
the <code>Identity</code> page is where suggestions are confirmed, rejected and split,
and <code>People</code> on the filter bar narrows the grid to someone. Not pictured
here, for the obvious reason — <a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/faces.md">faces.md</a> has the design.</p>
<h2 id="where-things-are-written-down">Where things are written down</h2>
<table><thead><tr><th>Feature</th><th>Design</th></tr></thead><tbody>
<tr><td>Local masks and segmentation</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/segmentation.md">segmentation.md</a>, <a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/mask-editing.md">mask-editing.md</a></td></tr>
<tr><td>Repair</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/spot-removal.md">spot-removal.md</a></td></tr>
<tr><td>Panorama</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/panorama.md">panorama.md</a></td></tr>
<tr><td>Faces and identity</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/faces.md">faces.md</a></td></tr>
<tr><td>Gestures, generated from the code</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/gestures.md">gestures.md</a></td></tr>
<tr><td>Navigation and layout</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/ui-navigation.md">ui-navigation.md</a></td></tr>
<tr><td>Sync and storage</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/storage.md">storage.md</a></td></tr>
</tbody></table>
<h2 id="how-this-page-is-made">How this page is made</h2>
<p><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/tools/manual/README.md"><code>tools/manual/</code></a> drives the desktop build on
a private X server and records each scene; <code>record.sh &lt;library&gt;</code> re-makes
every picture here. Run it after a change to the interface and commit what
changed. The pictures are in LFS.</p>
<p>The application carries this page. <code>cargo run -p traceability -- manual</code>
renders it to <code>index.html</code> beside it, which the packages install with the
pictures and the app opens from Help, from Settings, and from the "See it"
link beside a gesture on the help sheet. CI fails when the two differ.</p>
<p>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.</p>
</main>
</div>
</body>
</html>
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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