Compare commits

..
28 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 f9c7aba8b6 Regenerate the traceability matrix
Build and test / Desktop (Linux) (push) Failing after 2m34s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Six commits added nineteen TRACES tags between them and none regenerated the
matrix, so the gate failed on every one of them — including the commit the
release tag points at. The gate is doing its job: a matrix that disagrees with
the tree is worse than none, because it is read as current.

Coverage is unchanged at 50.3%; what moved is where each requirement is
tagged, which is the half of the file that is actually consulted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:00:45 +02:00
dtourolleandClaude Opus 5 fb4b05fb6f Let the date range be opened before there is a date range
Build and test / Desktop (Linux) (push) Failing after 2m16s
Build and test / Layer separation (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 6s
Pressing "limit to range" did nothing, and the reason was two early returns
that sat above the line which shows the controls. If the catalog was not open,
or nothing in it carried a capture date, the handler returned before
`set_library_range_active`, so no state changed and nothing appeared.

Capture dates are read from EXIF as thumbnails load, so a freshly opened
library has none — the button was inert on exactly the libraries where someone
is most likely to go looking for a date, and it failed by doing nothing at all,
which is the hardest failure to report.

Underneath that was a smaller mistake with the same shape: whether the controls
showed was read from whether a range was set. The fields are how a range gets
set, so requiring one before they appear is a door locked from the inside. The
panel has its own state now, and the timeline span is a seed for the fields
rather than a precondition for them — no span means two empty fields waiting to
be typed into, which is a way to choose a range rather than a refusal to offer
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:15:34 +02:00
dtourolleandClaude Opus 5 5a9beedca6 Give the range's ends a row of their own, not a corner of someone else's
Build and test / Desktop (Linux) (push) Failing after 2m32s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Failing after 1m3s
Build and test / Android (aarch64) (push) Failing after 7s
Third attempt at one control, and each failure was a different reason it could
not be seen.

It narrowed to the whole library, because it took its span from a timeline zoom
that is zero until someone zooms. The fields that fixed that went into the chip
row, which scrolls sideways, so they sat past the right-hand edge. And moving
them "below the chips" put them inside the same `Rectangle` — which stacks its
children at the origin rather than laying them out, so they were drawn over the
chips inside a strip 34px tall, unconstrained in width and clipped in height.

A `Rectangle` is not a layout. The row is a sibling of the chips' strip in the
header's `VerticalLayout` now, with a height of its own and the width of the
window: two 108px fields, a "to", and a warning when what was typed is not a
date — about 354px, against 768 on a tablet in portrait.

Left-aligned and inset by the same gap the chips use, so the two rows begin on
one vertical line instead of a few pixels apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:06:37 +02:00
dtourolleandClaude Opus 5 db43d8ec8b Put the range's ends where a tablet can see them
"Limit to range" looked broken a second time, for a second reason. The ends
were added to the filter chip row, and that row scrolls: its own comment
records that fourteen chips do not fit across 768 logical pixels, "so that is
every tablet in portrait". Two date fields and a caption went straight past the
right-hand edge, into the part of the row that has to be panned to.

So the fix for a control that appeared to do nothing was itself invisible, and
pressing the chip still looked like it did nothing.

They have their own line now, below the chips and outside the Flickable, and it
exists only while a range does. Nothing competes with it for width, and nothing
has to be panned to reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:58:56 +02:00
dtourolleandClaude Opus 5 3a42c63b5b Format the refine tests the way the gate asks for it
Build and test / Desktop (Linux) (push) Failing after 3m36s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 1m7s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
Build and test / Android (aarch64) (push) Failing after 7s
`cargo fmt --check` is a required step and the mask-refine work landed with a
test body it disagrees with. Whitespace only — kept as its own commit so it can
be skipped wholesale rather than read for a change that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:24:48 +02:00
dtourolleandClaude Sonnet 5 b3641b5307 Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.

## Refine mask

A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.

The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.

## Multi-select

`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:17:07 +02:00
dtourolleandClaude Sonnet 5 e6b01226eb Let the segmentation export script pick its own input size
Comparing a larger model against a larger input size meant re-exporting at
resolutions other than the shipped 640, and the script only ever wrote that
one number. `IMGSZ` is now a second positional argument, defaulted to 640 so
every existing call is unchanged.

The experiment this was built for found bigger input a net loss on its own
merits — yolo26n-seg and yolo26s-seg at 1280 both lost track of large,
frame-filling subjects (a bus's box shrank and its score nearly halved)
in exchange for catching small or partially-occluded ones tiling already
handles. Nothing shipped from it, but the ability to re-run that comparison
is worth keeping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:16:51 +02:00
dtourolleandClaude Opus 5 4b7648082e Let a date range be stated, and draw the axis at the scale it deserves
"Limit to range" did nothing, and the reason was not visible from the button.
It took its span from the timeline's zoom, which is zero until someone zooms —
so `zoomed_span` returned the whole library and the filter narrowed to
everything. The chip lit up and the grid did not change.

The range has ends now, shown and typed as `YYYY-MM-DD`. Seeding them from the
timeline is kept, because zooming to a fortnight and pressing the chip is the
fast path; the fields say which fortnight it landed on and let it be corrected.
Ends given backwards are swapped rather than refused — there is exactly one
range between two days — and the closing day is included, since "to the 5th"
means the whole of the 5th and a range ending at its midnight contains none of
it. `parse_date` refuses anything that is not a date rather than guessing at an
order, because the alternative is a library silently filtered to a span nobody
asked for.

The axis then follows the range. It used to keep drawing the full extent while
a range was on, because it was the only way back out; the typed ends are the
way back out now, so it is free to show what was asked about.

And bucket size is chosen by how many bars it makes rather than by fixed
cut-offs. Each zoom step halves the span, so under thresholds the bar count
halved with it until a boundary was crossed: fifteen years went 15 bars, 8,
then 46, 23, 11, and finally 6. Zooming in made the picture coarser, which is
the opposite of what zooming is for. Aiming at forty bars keeps the count in
the same neighbourhood at every level, and the test asserts the property
directly — halving a span never coarsens the bucket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:40:14 +02:00
dtourolleandClaude Opus 5 fbf286d504 Keep the mask when the photograph is closed
A local adjustment survived until the session ended and then did not exist.
Nothing reported it, because nothing had failed.

The sidecar format has carried masks since they were added, `Version::apply`
restores them into the graph, and `Version::update` captures them — that work
landed complete and was never called. The autosave writes `copy_settings()`,
which is a `Preset`: a map from (operation, parameter) to a number. A mask is
not a parameter. It is a rule about *where*, with a chain of its own, so it
fell outside the only thing being written, and the read path had nothing to
read.

The save now carries the stack beside the preset, on both the local and the
remote path. Deliberately not merged but replaced wholesale: this is the stack
as it stands, so a layer the user deleted has to leave the file too. Merging
two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9).

A paste still carries no masks, and the `Option` is how that is said. Settings
travel between photographs; a mask does not, because it is drawn against one
frame and describes nothing on another — and `Scope` cannot express that,
since it filters parameters and a mask is not one.

The test fails against the old save path with "the mask must come back with
the photograph", which is the whole of the defect: not a crash, not an error,
just an edit that was not there in the morning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:29:41 +02:00
dtourolleandClaude Opus 5 f4c7afb74c Say which version this is: 0.5.0
Build and test / Desktop (Linux) (push) Failing after 3m8s
Build and test / Layer separation (push) Successful in 1m32s
Traceability / Requirement traces (push) Failing after 2m56s
🐳 Android image / Build and push (push) Successful in 24m43s
Build and test / android-image (push) Successful in 24m45s
Build and test / Android (aarch64) (push) Failing after 6s
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.5.0, so a bug report naming a version names one commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:48:03 +02:00
dtourolleandClaude Opus 5 5807b67693 Caption a photograph with its name, not with how it is stored
A grid cell is about four words wide and `.CR2` spent one of them saying
something the photographer already knows: in a RAW library every frame ends
the same way, so the extension distinguishes nothing while taking room from
the part that does. The caption elides from the end under pressure, so an
extension can push the digits that actually identify a frame off the visible
part of its own label.

Dropped in the view, not in the model. `LibraryCell::name` keeps the true
filename and `remote_path` the full path, because both are used to find the
file again and a stem is not a filename.

The case it is wrong for, recorded rather than discovered later: a library
holding `IMG_1234.CR2` beside `IMG_1234.JPG` now shows two cells captioned
`IMG_1234`. They remain two rows with two thumbnails and two entries in the
info panel, and a RAW+JPEG pair is usually one photograph anyway — but the
caption alone no longer separates them.

Only the last dot goes, and only when something precedes it: `2026.08.23-a.dng`
keeps its dates, and `.hidden` keeps its leading dot, because that dot is how
the name starts rather than an extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:46:40 +02:00
dtourolleandClaude Opus 5 a8969c149e Move the instrumentation off the header and into About
The render backend, the layout class and the frame rate sat permanently in a
44px strip that also carries the only way out of develop, the undo pair, the
panel toggle and the export button. They cost about 200 logical pixels, and a
`HorizontalLayout` given less width than its children's minimums does not
shrink them — it runs off the end.

There was already a breakpoint hiding them below 820px, which is why a tablet
in portrait (768) looked fine and landscape (1200) did not: above the
breakpoint the readouts came back and pushed the header off the right-hand
edge. A breakpoint that hides a problem at one size and not another is a
workaround, and this removes the reason for it rather than moving it.

They are diagnostics — read once when something looks wrong, and then not
again — so they are in Settings under ABOUT, beside the version, which is
where someone goes when they have a bug to report rather than a photograph to
edit. The version is there for the same reason and comes from
`CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it.

The frame rate keeps its warning hue. It is the number that says whether the
zero-copy display path is holding up, which is the assumption the whole
display design rests on, and that is worth colour wherever it is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:41:17 +02:00
dtourolleandClaude Opus 5 7a5e1adf51 Merge what two tiles saw of one subject, instead of picking a side
"Look closer" tiles the frame so a small subject reaches a fixed 640x640 model
at its own size. A tile sees only the part of an object inside it, so an object
on a seam produces two *partial* masks — neither of them the object.

This kept the higher-scoring one and discarded the other, which quietly threw
away what tiling had just been paid 2.8 seconds for: a bird with its tail cut
off at a tile edge, described by whichever tile happened to hold more of the
bird. Both halves existed; one survived.

They are unioned now, and the overlap is what makes that sound. At 25% every
pixel is seen by at least one tile at full resolution and pixels near a seam by
two, so the pointwise maximum is the better estimate everywhere rather than a
compromise: where one tile saw a pixel its opinion is the only one there is,
and where both did, the higher value came from the tile with more context
around it. A maximum of soft coverage also stays soft, which is what `prior.rs`
weights merges by and what a mask layer's edge treatment needs.

Each quantity gets the operation that suits it: maximum for coverage, union
for the box, and the higher score rather than a blend — the score is shown to a
photographer and means "how sure the model is this is a bird", so averaging in
a tile that saw a wingtip would make a confident detection look doubtful for
straddling a seam.

The test fails against the old rule with "pixel 4 was seen by a tile and must
survive the merge", which is the whole defect in one line: not a crash, not a
duplicate, just a plausible mask missing half its subject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:14:53 +02:00
dtourolleandClaude Opus 5 5a0a9719eb Set the version once, in one place, for every artefact
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.

`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.

Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.

A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:09:03 +02:00
dtourolleandClaude Opus 5 7fcb8dc107 Give the develop column room, and the mask a way out of the way
Three faults reported together, and they share a shape: each is something the
panel decided on the photographer's behalf.

**The column was 280px on every screen.** That width was chosen for a tablet,
where the column is a large fraction of the display and every pixel of it is
taken from the photograph. On a desktop window the mode strip alone — two
modes, a separator, "All", and a chip per attribute the operation set declares
— does not fit, so it scrolled sideways. A control you have to pan to reach is
one you do not know is there. 380px when the window is classed expanded, 280px
when it is not; driven off `layout-class` because reading the window width
inside the layout that sets it is a binding loop.

**The overlay could not be hidden.** An earlier "Overlay" button was removed
for a good reason — it *armed* the overlay, so local mode could be entered and
still show nothing. Hiding is the opposite need and was never served: a mask is
judged against the photograph beneath it, and that photograph is exactly what
the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a
recompute cannot switch the overlay back on under someone who just turned it
off.

**The masks were coarse because the model saw the subject small.** The graph's
input is a fixed 640x640 and every frame is letterboxed into it, so a bird
200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has
been implemented and tested since the segmentation spike and defaulted off,
because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms.
Now offered as "Look closer (slower)", which says what it costs, rather than
spending it on every image or on none.

The tiling choice enters the segmentation signature. A mask stores the
signature its region ids index into, and a tiled run finds different instances
in a different order; sharing a signature would silently reinterpret a layer
built against the coarse pass — a wrong mask rather than a stale one, and
nothing announces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:54:35 +02:00
dtourolleandClaude Opus 5 ec8582032e Fetch the model CI has been building without
Both failing jobs failed for the same reason, and it was not the reason either
of them appeared to fail. Desktop reported a Clippy failure and Android
reported the API-level check; both were `dr-segment`'s build script panicking
because `models/yolo26n-seg.onnx` was a 133-byte LFS pointer rather than the
11 MB model.

No checkout step asked for LFS objects. `.gitattributes` predicted this exactly
— "a clone without git-lfs gets a ~130-byte pointer file where the model should
be" — and the build script fails loudly by design rather than embedding a
pointer and failing at inference. That design worked; nothing was reading the
message.

It stayed hidden because the Android job built `-p dr-gpu`, which never reaches
dr-segment. Building the app crate does, which is how one fix surfaced another.

Only the two jobs that compile get `lfs: true`. `layering` runs `cargo tree`
and builds nothing, so it has no reason to fetch 11 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:44:09 +02:00
dtourolleandClaude Opus 5 b7bd2356be Regenerate the traceability matrix
The gate regenerates it and fails if the result differs from what is
committed, which is what it is for: a matrix that disagrees with the tree is
worse than none, because it is read as current.

Stale since the detail stage landed — 160 files and 436 tags then against 180
and 521 now. Coverage 49.1% to 50.3%, and no orphan tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:26:59 +02:00
dtourolleandClaude Opus 5 e25f0ad2c6 Let the launcher find an icon for the window
The icon was already embedded and had been all along: `app.slint` sets it and
`build.rs` compiles it into the binary with `EmbedFiles`. That is not what a
Wayland compositor looks at. It ignores a client-set icon entirely, matches
the surface's `app_id` against installed `.desktop` files, and takes the icon
from the one whose basename agrees. Nothing set an app id and no desktop entry
existed, so GNOME had nothing to resolve and drew the placeholder.

Both halves are needed and neither substitutes for the other: the embedded
icon is what X11 and the window itself use, and the desktop entry is what the
overview, the dash and alt-tab use.

The app id, the `.desktop` basename and the installed icon's filename are one
string in three places — `paris.tourolle.darkroom`, the same reverse-DNS name
the Android manifest already uses, because it is one application. Change one
without the others and the icon disappears again with nothing logged, so each
file says so where the string appears.

The PKGBUILD builds from the checkout rather than a release tarball, so
`makepkg -si` installs what is being worked on. Install verified by inspection
of the built package: binary, desktop entry, and the icon under
hicolor/256x256 by the name the entry asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:26:04 +02:00
dtourolleandClaude Opus 5 2a4a48c0fb Release 0.4.0
Build and test / Desktop (Linux) (push) Failing after 8m39s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 22m44s
Not a patch: this carries a data-loss fix and a feature.

Collection membership merged on `images.content_hash`, which the schema
computes only for import dedup and reconnect — never in a scan. On a synced
library it is NULL for every image, so the union matched nothing and the
catalog push that follows a merge overwrote whatever the other device had
uploaded. Collections arrived named and empty on every device, and each sync
destroyed the other's membership. Keyed on `oc:fileid` now, which every scanned
image has. Verified on a 23,000-image library: 247 memberships recovered, and a
round trip between tablet and desktop confirmed in both directions.

Card import (FR-CAT-10, FR-CAT-11, FR-NC-7a, FR-NC-7b) arrives with it: the
ingest engine, the write half of the storage abstraction, removable-volume
detection, duplicate detection in two tiers, dated folders on both sides, and
thumbnails made while the originals are still in hand. Uploads always go to the
library on the server; what lands on this machine is a staging copy the app
removes once the upload is confirmed, and a move-import empties the card only
of photographs the server has acknowledged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:23:53 +02:00
dtourolleandClaude Opus 5 85aae25d0f Let the header readouts shrink instead of pushing the controls off screen
Found uncommitted in the working tree and committed as its own change rather
than folded into the sync work beside it: the three header readouts gain
`overflow: elide` and `horizontal-stretch: 1`, which is what actually lets a
Text shrink under pressure in Slint. Without the stretch they kept their full
natural width and pushed the sidebar toggle and the More button past the right
edge on a narrow window, reachable only by knowing to flick-scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:22:30 +02:00
dtourolleandClaude Opus 5 11a63fc7a8 Refresh the collection tree when a sync brings membership
New-York gained 127 photographs from the tablet and the sidebar went on showing
no number beside it.

Two reasons, and the second is why the first was never noticed. The sync's
completion handler reloads the grid and never rebuilds the tree — the scan
already does, and the sync merges the same tables. And the condition it reloads
under was `collections_gained > 0`, which counts collections, not members: a
sync that files 127 photographs into a collection both devices already had
gains no collection at all, so the count was zero and nothing refreshed.

`SyncReport` now carries `members_gained` from the merge report, and either one
rebuilds the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:10:40 +02:00
dtourolleandClaude Opus 5 1297e3259b Stop claiming the app crates cannot cross-compile
The note above the core-crate check said the UI and app crates would join
"once the Android shell exists". They already had. Building `darkroom-android`
for aarch64 takes 4m23s and produces `libdarkroom.so`, linked for Android 28 —
which is what the step below now checks, and what a device installs.

Rewritten to say what the core check is actually for: a fast, link-free gate
that fails early and names a smaller suspect, ahead of the full build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:23:52 +02:00
dtourolleandClaude Sonnet 5 d78c9a27fd Exit desktop process immediately after window close
window.run() returning unwinds through main, which races a still-live
zbus/keyring background thread's TLS teardown and aborts with
"thread local ... during or after destruction". Skip that unwind with
a hard exit once the run succeeds; dr_ui::run itself is left alone
since the Android entry point also calls it and must not be exited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:20:48 +02:00
dtourolleandClaude Opus 5 89f0f1fb4b Merge branch 'fix/collection-members-by-fileid'
Collection membership merged on a column that is NULL on every scanned
library, so collections synced their names and arrived empty everywhere.
Keyed on oc:fileid now, as keyword assignment already was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:20:27 +02:00
dtourolleandClaude Opus 5 83528c068a Check the API level of an object a linker actually produced
The step built `-p dr-gpu` and then looked for a `*.so` to read the API level
out of with `file`. dr-gpu declares no `crate-type`, so it produces an rlib —
an archive of object files no linker has touched, and about which `file` has
nothing to say. The step could therefore only ever reach its own "no aarch64
.so was produced" branch, whatever the linker did, which is the one thing it
exists to rule out.

Builds `darkroom-android` instead: the workspace's only
`crate-type = ["cdylib"]`, and the artefact that ships in the APK. The API
level verified is now the one a device will refuse to install against.

The neighbouring comment claiming only core crates cross-compile is left
alone until the build proves otherwise; it is either stale or this commit is
wrong, and the same run answers both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:19:46 +02:00
dtourolleandClaude Opus 5 67f15bffb7 Key collection membership on the identity that exists
Collections synced their names and arrived empty on every device. The names are
keyed on a uuid and worked; the membership union was keyed on
`images.content_hash`, and the schema says plainly what that column is:
"computed only when something needs it (import dedup, reconnect-by-hash), never
in a scan". A library that has only ever been scanned has one for no image at
all, so the join matched nothing and `WHERE ri.content_hash IS NOT NULL`
discarded whatever survived. The union could never have moved a single row.

Measured on a real catalog: 23,174 images, content hashes for 0 of them,
`oc:fileid` for all 23,174, twelve collections, zero members.

So membership now resolves through the file id first, exactly as keyword
assignment already did — `ASSIGN_BY_FILE_ID` was added for this same reason and
its doc comment even notes that membership was still on the hash. It is
recorded for every image the moment a remote scan sees it, survives server-side
rename and move (FR-NC-5), is the same integer on every device pointed at one
Nextcloud, and is already what the thumbnail shards are keyed by.

The content-hash union is kept rather than replaced: a local-only library has no
`remote` rows, and where a hash has been computed it is a true identity that
survives a library moving between servers. Both statements run; `INSERT OR
IGNORE` against the primary key makes the overlap free.

This repairs the merge. It cannot invent membership that no device recorded —
where the rows were never written, collections stay empty until they are filled
in again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:19:13 +02:00
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00
dtourolleandClaude Opus 5 d04087af83 Import into the library, which is on the server
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.

An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.

What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.

And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.

FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:09:26 +02:00
74 changed files with 3143 additions and 598 deletions
+43 -3
View File
@@ -29,7 +29,18 @@ jobs:
steps:
- name: Checkout
# `lfs: true` is load-bearing, not hygiene. `core/dr-segment/models/*.onnx`
# is tracked in LFS (see .gitattributes), and a checkout without it writes
# a ~130-byte pointer where the 11 MB model should be. `dr-segment`'s build
# script detects that and panics by design, so every job that compiles it
# fails at the build script with a message about git-lfs — which reads like
# a broken build rather than a missing fetch.
#
# It stayed hidden while the Android job built `-p dr-gpu`, which does not
# reach dr-segment. Building the app crate does.
uses: actions/checkout@v4
with:
lfs: true
- name: Cache cargo
uses: actions/cache@v4
@@ -84,7 +95,18 @@ jobs:
steps:
- name: Checkout
# `lfs: true` is load-bearing, not hygiene. `core/dr-segment/models/*.onnx`
# is tracked in LFS (see .gitattributes), and a checkout without it writes
# a ~130-byte pointer where the 11 MB model should be. `dr-segment`'s build
# script detects that and panics by design, so every job that compiles it
# fails at the build script with a message about git-lfs — which reads like
# a broken build rather than a missing fetch.
#
# It stayed hidden while the Android job built `-p dr-gpu`, which does not
# reach dr-segment. Building the app crate does.
uses: actions/checkout@v4
with:
lfs: true
- name: Cache cargo
uses: actions/cache@v4
@@ -94,8 +116,15 @@ jobs:
target-android
key: android-${{ hashFiles('**/Cargo.lock') }}
# Only the core crates cross-compile today; the UI and app crates join
# once the Android shell exists (milestone v0.1, FR-PLAT-AND-*).
# A fast gate on the crates most likely to break the cross-compile, run
# before the expensive part. It is `cargo check`, so it type-checks
# without linking and returns in a fraction of the time the step below
# takes.
#
# Not a statement that only these crates cross-compile — `darkroom-android`
# and the whole UI stack beneath it build for aarch64 too, which is what
# the API-level step below does. This one exists to fail fast and name a
# smaller suspect when it does.
- name: Cross-compile core
env:
CARGO_TARGET_DIR: target-android
@@ -117,7 +146,18 @@ jobs:
CARGO_TARGET_DIR: target-android
run: |
set -e
cargo ndk -t arm64-v8a build -p dr-gpu --release
# `darkroom-android`, not a core crate: this step reads the API level
# out of a *linked* object, and only that crate produces one. It is
# the workspace's single `crate-type = ["cdylib"]`; a library crate
# builds an rlib, which is an archive of object files that no linker
# has yet touched and that `file` therefore has nothing to say about.
# Asking for `-p dr-gpu` here could only ever reach the "no aarch64
# .so was produced" branch below, whatever the linker did.
#
# It is also the honest artefact to check: the .so this names is the
# one that ships in the APK, so the API level verified here is the
# API level a device will refuse to install against.
cargo ndk -t arm64-v8a build -p darkroom-android --release
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
# Empty on both sides would compare equal and pass, so neither side
# is allowed to be the result of a failed parse.
+9
View File
@@ -2,3 +2,12 @@
/target-android
Cargo.lock.bak
*.log
# makepkg build products. `packaging/PKGBUILD` and the .desktop entry are
# sources and belong in the tree; everything makepkg derives from them does
# not — `pkg/` and `src/` are staging directories it recreates on every run,
# and the package itself is 33 MB of compiled output.
/packaging/pkg/
/packaging/src/
/packaging/*.pkg.tar.*
/packaging/*.log
Generated
+17 -17
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"android_logger",
"dr-sync-nextcloud",
@@ -1232,7 +1232,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"anyhow",
"dr-ui",
@@ -1404,7 +1404,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-catalog"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1417,7 +1417,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"dr-types",
"env_logger",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1449,7 +1449,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1465,7 +1465,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1477,7 +1477,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"lensfun",
"log",
@@ -1485,7 +1485,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"dr-types",
"log",
@@ -1494,7 +1494,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1507,7 +1507,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"env_logger",
"log",
@@ -1520,7 +1520,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"async-trait",
"dr-types",
@@ -1531,7 +1531,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"async-trait",
"dr-decode",
@@ -1552,7 +1552,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1564,7 +1564,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"serde",
"serde_json",
@@ -1573,7 +1573,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"anyhow",
"dr-catalog",
@@ -6896,7 +6896,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"anyhow",
"serde",
+1 -1
View File
@@ -21,7 +21,7 @@ members = [
]
[workspace.package]
version = "0.3.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
+8 -1
View File
@@ -17,5 +17,12 @@ fn main() -> anyhow::Result<()> {
eprintln!("usage: darkroom-desktop <file-or-directory>...");
}
dr_ui::run(paths)
dr_ui::run(paths)?;
// Skip Rust's normal static/thread-local teardown on the way out: a
// background zbus/keyring connection opened by dr_ui::launch_ui can
// still be alive here, and unwinding through it races its async-io
// reactor thread, panicking with "thread local ... during or after
// destruction" when the window is closed.
std::process::exit(0);
}
+96 -7
View File
@@ -114,13 +114,78 @@ impl Granularity {
}
/// A sensible bucket size for a span of seconds, so the UI need not guess.
///
/// # Chosen by how many bars it produces, not by fixed cut-offs
///
/// This used to be four thresholds on the span, which reads sensibly and
/// behaves badly under zoom. Each zoom step halves the span, so the bar
/// count halves with it until a threshold is crossed — a fifteen-year
/// library went 15 bars, 8, then 46, 23, 11, and finally *6*. Zooming in
/// made the picture coarser, which is the opposite of what zooming is for.
///
/// So the choice is made on the axis's terms: of the four bucket sizes,
/// take the one whose bar count comes nearest [`Self::TARGET_BARS`]. The
/// count then stays in the same neighbourhood at every zoom level, and
/// each step in genuinely shows finer structure rather than the same
/// structure drawn wider.
///
/// Nearest in *ratio*, not in difference: the counts available for a given
/// span are orders of magnitude apart — a span is either about 4 years or
/// about 48 months — and on a linear measure the larger count always looks
/// further away, which would bias every choice towards too few bars.
pub fn for_span(seconds: i64) -> Self {
let seconds = seconds.max(1) as f64;
// Finest first, so that when two options are equally far from the
// target the finer one wins: `min_by` keeps the first minimum it saw,
// and more detail is the better failure.
[
Granularity::Hour,
Granularity::Day,
Granularity::Month,
Granularity::Year,
]
.into_iter()
.min_by(|a, b| {
let cost = |g: Granularity| {
let bars = seconds / g.approx_seconds() as f64;
// How far off the target, measured multiplicatively: twice as
// many and half as many are equally wrong.
//
// Deliberately not clamped to at least one bar. A span shorter
// than a bucket scores *worse* the coarser the bucket, which is
// what makes an hour of photographs pick hourly bars instead of
// every option tying at "one bar" and the coarsest winning.
(bars / Self::TARGET_BARS as f64).ln().abs()
};
cost(*a)
.partial_cmp(&cost(*b))
// Ties cannot arise from real spans, but a NaN would; falling
// back to the coarser option keeps the axis drawable.
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(Granularity::Day)
}
/// How many bars the timeline wants across its axis.
///
/// Not a hard count — the bucket sizes are calendar units, so the actual
/// number lands where the calendar puts it. It is the figure the choice
/// aims at: enough bars that a busy fortnight is visibly busier than a
/// quiet one, few enough that each is wide enough to hit with a finger.
const TARGET_BARS: i64 = 40;
/// Nominal length of one bucket, for choosing between them.
///
/// Approximate on purpose: months and years vary and it does not matter
/// here, because this only ranks four options that are a factor of ~12 or
/// ~30 apart. The exact boundaries come from `strftime` on the real dates.
fn approx_seconds(self) -> i64 {
const DAY: i64 = 86_400;
match seconds {
s if s > 5 * 365 * DAY => Granularity::Year,
s if s > 90 * DAY => Granularity::Month,
s if s > 2 * DAY => Granularity::Day,
_ => Granularity::Hour,
match self {
Granularity::Year => 365 * DAY,
Granularity::Month => 30 * DAY,
Granularity::Day => DAY,
Granularity::Hour => 3600,
}
}
}
@@ -422,10 +487,34 @@ mod tests {
#[test]
fn timeline_granularity_follows_the_span() {
const DAY: i64 = 86_400;
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Year);
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Month);
// Chosen by how many bars it makes, not by fixed cut-offs — see
// `for_span`. Ten years of yearly bars is ten bars, which says almost
// nothing about a library; monthly is 122, which is a shape.
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Month);
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
// The property the target exists for: zooming in never coarsens the
// axis. Under the old thresholds a fifteen-year library went 15 bars,
// then 8, then 46, 23, 11 — finer spans drawn with wider bars.
let mut span = 15 * 365 * DAY;
let mut previous = Granularity::for_span(span).approx_seconds();
for _ in 0..10 {
span /= 2;
let bucket = Granularity::for_span(span).approx_seconds();
assert!(
bucket <= previous,
"halving the span to {span}s coarsened the bucket \
from {previous}s to {bucket}s"
);
previous = bucket;
}
// And a span shorter than any bucket still picks the finest, rather
// than every option tying at one bar and the coarsest winning.
assert_eq!(Granularity::for_span(60), Granularity::Hour);
assert_eq!(Granularity::for_span(1), Granularity::Hour);
}
#[test]
+177 -19
View File
@@ -383,30 +383,70 @@ fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result
// ---- membership -------------------------------------------------------
//
// Set union, keyed on (collection uuid, image content hash). The hash
// rather than the image id, for the same reason collections use a uuid:
// image ids are local. An image the remote has and we do not is skipped —
// it will join when a scan or sync catalogues it, and the next merge picks
// it up.
// Set union, keyed on (collection uuid, image identity). Not the image id,
// for the same reason collections use a uuid: image ids are local. An image
// the remote has and we do not is skipped — it will join when a scan or
// sync catalogues it, and the next merge picks it up.
//
// Both identities are tried, and the file id first. See
// [`MEMBERS_BY_FILE_ID`] for why keying on the content hash alone made this
// whole union a no-op on every ordinary library.
//
// Tombstoned collections are excluded, or a merge would repopulate a
// collection it had just deleted.
let added = tx.execute(
"INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
SELECT lc.id, li.id, rm.position, rm.added
FROM remote_cat.collection_members rm
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
JOIN remote_cat.images ri ON ri.id = rm.image_id
JOIN main.images li ON li.content_hash = ri.content_hash
WHERE ri.content_hash IS NOT NULL",
[],
)?;
let added = tx.execute(MEMBERS_BY_FILE_ID, [])? + tx.execute(MEMBERS_BY_CONTENT_HASH, [])?;
report.members_added = added;
Ok(())
}
/// Take membership for images both devices know by the server's file id.
///
/// **This is the identity that exists.** Membership was keyed on
/// `images.content_hash` alone, and the schema is explicit that the column is
/// "computed only when something needs it (import dedup, reconnect-by-hash),
/// never in a scan" — so on an ordinary library it is NULL for every row, the
/// join matched nothing, and `WHERE ri.content_hash IS NOT NULL` discarded what
/// little was left. Collections synced their names, because those are keyed on
/// a uuid, and arrived empty on every device. A 23,000-image library had a
/// content hash for none of them and an `oc:fileid` for all of them.
///
/// `oc:fileid` is recorded for every image the moment a remote scan sees it, is
/// stable across server-side rename and move (FR-NC-5), and is the same integer
/// on every device pointed at the same Nextcloud — which is exactly the
/// situation where two devices share collections. It is already what the
/// thumbnail shards are keyed by, and what [`ASSIGN_BY_FILE_ID`] uses for
/// keywords; membership was the one thing left behind.
const MEMBERS_BY_FILE_ID: &str = "
INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
SELECT lc.id, li.id, rm.position, rm.added
FROM remote_cat.collection_members rm
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
JOIN remote_cat.remote rr ON rr.image_id = rm.image_id
JOIN main.remote lr ON lr.file_id = rr.file_id
JOIN main.images li ON li.id = lr.image_id";
/// The same union for a library with no server behind it.
///
/// A local-only library has no `remote` rows at all, so [`MEMBERS_BY_FILE_ID`]
/// matches nothing and the content hash is the only identity available. Kept
/// rather than replaced: where a hash *has* been computed — an imported card,
/// a reconnect — it is a true identity, and one that survives a library moving
/// between servers.
///
/// Both statements run. `INSERT OR IGNORE` against the
/// `(collection_id, image_id)` primary key makes the overlap free.
const MEMBERS_BY_CONTENT_HASH: &str = "
INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
SELECT lc.id, li.id, rm.position, rm.added
FROM remote_cat.collection_members rm
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
JOIN remote_cat.images ri ON ri.id = rm.image_id
JOIN main.images li ON li.content_hash = ri.content_hash
WHERE ri.content_hash IS NOT NULL";
/// Schema name the downloaded remote catalog is attached under.
///
/// Repeated from [`crate::sync`] rather than shared, because the SQL below
@@ -615,9 +655,9 @@ const ASSIGN_BY_FILE_ID: &str = "
/// The same union for a library with no server behind it.
///
/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`]
/// matches nothing and this is the only identity available — and it is the one
/// collection membership already uses, so a library where membership merges
/// has keywords that merge too.
/// matches nothing and this is the only identity available — the same pair
/// collection membership uses, so a library where membership merges has
/// keywords that merge too.
const ASSIGN_BY_CONTENT_HASH: &str = "
INSERT OR IGNORE INTO main.keywords(version_id, keyword)
SELECT lv.id, rk.keyword
@@ -741,6 +781,43 @@ mod tests {
c
}
/// An image with no content hash — which is every image in a library that
/// has only ever been scanned.
fn add_image_without_hash(c: &Connection, db: &str, id: i64) {
c.execute(
&format!(
"INSERT INTO {db}.roots(id, kind, label) VALUES (1, 'remote', 'lib')
ON CONFLICT(id) DO NOTHING"
),
[],
)
.unwrap();
c.execute(
&format!(
"INSERT INTO {db}.images(id, root_id, source_ref, added_at)
VALUES (?1, 1, ?2, 0)"
),
rusqlite::params![id, format!("img{id}.CR3")],
)
.unwrap();
}
fn count(c: &Connection, sql: &str) -> i64 {
c.query_row(sql, [], |r| r.get(0)).unwrap()
}
/// Give an image the `oc:fileid` a remote scan records for it.
///
/// What every image in a real synced library has, and what none of them
/// has a content hash for.
fn add_remote_id(c: &Connection, db: &str, image_id: i64, file_id: i64) {
c.execute(
&format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"),
rusqlite::params![image_id, file_id],
)
.unwrap();
}
fn add_image(c: &Connection, db: &str, id: i64, hash: &str) {
c.execute(
&format!(
@@ -910,6 +987,87 @@ mod tests {
assert_eq!(n, 2, "both devices' additions survive");
}
#[test]
fn a_library_that_never_computed_a_hash_still_merges_membership() {
// The bug this replaces. `content_hash` is computed only by import
// dedup or a reconnect — never by a scan — so a synced library has one
// for no image at all. Keying membership on it alone meant collections
// arrived with their names and none of their contents, on every device,
// for every user.
let c = two_catalogs();
add_collection(&c, "main", 1, "shared", "Trip", 1);
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
// No hashes anywhere, and a file id for both — a real library.
add_image_without_hash(&c, "main", 77);
add_image_without_hash(&c, "remote_cat", 3);
add_remote_id(&c, "main", 77, 90210);
add_remote_id(&c, "remote_cat", 3, 90210);
c.execute(
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
VALUES (1, 3, 0)",
[],
)
.unwrap();
let report = merge_collections(&c).unwrap();
assert_eq!(report.members_added, 1);
let img: i64 = c
.query_row("SELECT image_id FROM main.collection_members", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(img, 77, "resolved through the server's file id");
}
#[test]
fn a_different_photograph_on_the_server_is_not_adopted() {
// The file id is an identity, so two different ids must not join.
let c = two_catalogs();
add_collection(&c, "main", 1, "shared", "Trip", 1);
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
add_image_without_hash(&c, "main", 77);
add_image_without_hash(&c, "remote_cat", 3);
add_remote_id(&c, "main", 77, 111);
add_remote_id(&c, "remote_cat", 3, 222);
c.execute(
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
VALUES (1, 3, 0)",
[],
)
.unwrap();
let report = merge_collections(&c).unwrap();
assert_eq!(report.members_added, 0);
assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 0);
}
#[test]
fn an_image_identified_both_ways_is_added_once() {
// Both statements run, and their overlap must be free rather than a
// constraint violation or a double count.
let c = two_catalogs();
add_collection(&c, "main", 1, "shared", "Trip", 1);
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
add_image(&c, "main", 77, "same-photo");
add_image(&c, "remote_cat", 3, "same-photo");
add_remote_id(&c, "main", 77, 90210);
add_remote_id(&c, "remote_cat", 3, 90210);
c.execute(
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
VALUES (1, 3, 0)",
[],
)
.unwrap();
merge_collections(&c).unwrap();
assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 1);
}
#[test]
fn membership_maps_across_devices_by_content_hash() {
// The same photograph carries different integer ids on each device.
+4 -15
View File
@@ -487,7 +487,8 @@ mod tests {
// And the last span is shallower than the one before it, which is
// what a shoulder *is*. Without one the curve clips highlights
// harder than the linear rendering did.
let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
let slope =
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
assert!(
slope(POINTS - 2) < slope(POINTS - 3),
"{name} has no highlight shoulder"
@@ -502,13 +503,7 @@ mod tests {
// as a dark halo in a gradient, which reads as a rendering fault
// rather than as a bad profile.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.4],
[0.5, 0.3],
[0.75, 0.8],
[1.0, 1.0]
]),
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
None
);
}
@@ -540,13 +535,7 @@ mod tests {
// point above 1.0 would put the shoulder outside the range the curve
// is defined over and silently flatten everything below it.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.3],
[0.5, 1.4],
[0.75, 1.5],
[1.0, 1.6]
]),
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
None
);
}
+8 -2
View File
@@ -375,7 +375,10 @@ fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option<Location> {
(r.d != 0).then(|| r.n as f64 / r.d as f64)
}
fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option<f64> {
fn degrees(
dms: &[rawler::formats::tiff::Rational; 3],
reference: Option<&String>,
) -> Option<f64> {
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
// South and west are stored as positive magnitudes with a letter.
let negative = matches!(
@@ -534,7 +537,10 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
// both callers ask the same object for it.
let profile = profile::CameraProfile::extract(&image, &dng);
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
let wb_coeffs = sane_wb(image.wb_coeffs, profile.as_ref().map(|p| p.xyz_to_cam()).as_ref());
let wb_coeffs = sane_wb(
image.wb_coeffs,
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
);
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
// are preferred where it has them — they are what the shipped database is
+5 -2
View File
@@ -678,7 +678,8 @@ fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option<dr_types::Locat
// still writes the entry.
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
let e = find(tag)?;
let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0
let d = r.rational(e, 0)?
+ r.rational(e, 1).unwrap_or(0.0) / 60.0
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0;
// The magnitude is unsigned; the hemisphere is a letter beside it.
let south_or_west = find(ref_tag)
@@ -1412,7 +1413,9 @@ pub fn defects(tiff_data: &[u8]) -> Defects {
/// skip one this build does not implement rather than abandoning the list —
/// and lists mixing a warp with a defect map are ordinary.
fn read_opcode_list(bytes: &[u8], out: &mut Defects) {
let Some(count) = be_u32(bytes, 0) else { return };
let Some(count) = be_u32(bytes, 0) else {
return;
};
// A sensor has a handful of opcodes, not thousands. A huge count is a
// corrupt or hostile file.
if count > 256 {
+5 -1
View File
@@ -1048,7 +1048,11 @@ mod tests {
// would render black, which is much harder to diagnose than
// uncalibrated.
assert!(!usable(&[[0.0; 3]; 3]));
assert!(!usable(&[[f32::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]));
assert!(!usable(&[
[f32::NAN, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0]
]));
assert!(usable(&SIX_D_D65));
}
+17 -8
View File
@@ -77,9 +77,7 @@ pub fn encode(
.map(|m| m.sanitised(settings.strip_location));
// JPEG and PNG take a finished block; TIFF writes the tags into its own
// directory and needs the fields.
let block = carried
.as_ref()
.and_then(|m| exif::block(m, width, height));
let block = carried.as_ref().and_then(|m| exif::block(m, width, height));
match settings.format {
ExportFormat::Jpeg => jpeg(
@@ -303,8 +301,11 @@ where
W: std::io::Write + std::io::Seek,
K: tiff::encoder::TiffKind,
{
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), Undefined(profile))
.map_err(|e| ExportError::Encode(e.to_string()))
dir.write_tag(
tiff::tags::Tag::Unknown(TAG_ICC_PROFILE),
Undefined(profile),
)
.map_err(|e| ExportError::Encode(e.to_string()))
}
/// TRACES: FR-EXP-8
@@ -376,7 +377,10 @@ where
)?;
}
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
dir.write_tag(Tag::Unknown(exif::tag::FNUMBER), Rationals(&[exif::tenths(f)]))?;
dir.write_tag(
Tag::Unknown(exif::tag::FNUMBER),
Rationals(&[exif::tenths(f)]),
)?;
}
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
dir.write_tag(
@@ -935,11 +939,16 @@ mod tests {
assert_eq!(md.aperture, Some(2.8), "{format:?}");
assert_eq!(md.focal_length, Some(85.0), "{format:?}");
let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix"));
let loc = md
.location
.unwrap_or_else(|| panic!("{format:?} lost the fix"));
// Within a metre of where it started, which is finer than any
// consumer receiver and far finer than the tag's own rounding.
assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}");
assert!((loc.longitude - LONGITUDE).abs() < 1e-5, "{format:?} {loc:?}");
assert!(
(loc.longitude - LONGITUDE).abs() < 1e-5,
"{format:?} {loc:?}"
);
assert_eq!(loc.altitude, Some(35.0), "{format:?}");
}
}
+21 -3
View File
@@ -250,7 +250,13 @@ mod tests {
#[test]
fn png_export_produces_a_png() {
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap();
let out = export(
&frame(32, 32),
&settings(ExportFormat::Png),
"a.png".into(),
None,
)
.unwrap();
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
}
@@ -270,8 +276,20 @@ mod tests {
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
// Both are uncompressed RGB; the only difference is the sample width,
// so this is what proves the 16-bit path is not quietly writing 8.
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap();
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap();
let eight = export(
&frame(16, 16),
&settings(ExportFormat::Tiff8),
"a".into(),
None,
)
.unwrap();
let sixteen = export(
&frame(16, 16),
&settings(ExportFormat::Tiff16),
"a".into(),
None,
)
.unwrap();
assert!(sixteen.bytes.len() > eight.bytes.len());
}
+45 -8
View File
@@ -26,9 +26,7 @@
//! mask pinned to the viewport, a slice read from the wrong layer — shows up
//! here as an adjustment in the wrong place, and nowhere else.
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks,
};
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full;
@@ -152,7 +150,18 @@ fn main() {
drain.feather = 0.02;
pop.push(drain);
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &alpha, pw, ph, ow, oh);
render_stack(
&ctx,
&source,
&mut masks,
&mut adjust,
&pop,
&alpha,
pw,
ph,
ow,
oh,
);
write(&format!("{prefix}-colour-pop.ppm"), &adjust);
// ---- 2. lift the subject out of its background ------------------------
@@ -170,7 +179,18 @@ fn main() {
darker.feather = 0.03;
lift.push(darker);
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &alpha, pw, ph, ow, oh);
render_stack(
&ctx,
&source,
&mut masks,
&mut adjust,
&lift,
&alpha,
pw,
ph,
ow,
oh,
);
write(&format!("{prefix}-subject-lift.ppm"), &adjust);
// ---- 3. the same edit, grown and shrunk -------------------------------
@@ -192,7 +212,18 @@ fn main() {
layer.morph_radius = radius;
stack.push(layer);
render_stack(&ctx, &source, &mut masks, &mut adjust, &stack, &alpha, pw, ph, ow, oh);
render_stack(
&ctx,
&source,
&mut masks,
&mut adjust,
&stack,
&alpha,
pw,
ph,
ow,
oh,
);
write(&format!("{prefix}-{name}.ppm"), &adjust);
}
@@ -292,12 +323,18 @@ fn render_stack(
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
let s = (longest as f32 / w.max(h) as f32).min(1.0);
(((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1))
(
((w as f32 * s) as u32).max(1),
((h as f32 * s) as u32).max(1),
)
}
fn write(path: &str, adjust: &AdjustPass) {
let (rgba, w, h) = adjust.export_pixels().expect("readback");
let rgb: Vec<u8> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
let rgb: Vec<u8> = rgba
.chunks_exact(4)
.flat_map(|p| [p[0], p[1], p[2]])
.collect();
write_ppm(path, &rgb, w, h);
}
+1 -1
View File
@@ -12,8 +12,8 @@
//! PPM for the same reason `develop` uses it: no encoder dependency, and
//! every viewer reads it. This is a diagnostic, not an export path.
use dr_segment::{MergeTree, RegionField};
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass};
use dr_segment::{MergeTree, RegionField};
/// The ladder the example dumps. Chosen to span "far too fine to be useful"
/// through "one or two objects", because both ends are informative: if no rung
+8 -8
View File
@@ -589,14 +589,14 @@ impl AdjustPass {
});
if !reuse {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let params_buf =
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
+1 -2
View File
@@ -123,8 +123,7 @@ impl Intermediates {
// output these are never handed to a compositor, and no
// COPY_SRC, because nothing reads them back — that is the
// point (ARCH §6.1).
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
+1 -1
View File
@@ -30,8 +30,8 @@ pub use adjust::AdjustPass;
// rather than an implementation detail: a detail pass is guaranteed linear,
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
// VRAM at 24 MP needs to know what an intermediate costs.
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use demosaic::{DemosaicedImage, Demosaicer};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError;
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation.
+33 -22
View File
@@ -582,10 +582,7 @@ impl MaskPass {
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!(
"mask layer {} has no distance field; skipping",
layer.id
);
log::warn!("mask layer {} has no distance field; skipping", layer.id);
continue;
}
},
@@ -599,7 +596,14 @@ impl MaskPass {
}
_ => {
let selected = self.selection_buffer(layer, field);
self.draw(&mut encoder, slot as u32, &params, field, &selected, subject);
self.draw(
&mut encoder,
slot as u32,
&params,
field,
&selected,
subject,
);
}
}
}
@@ -730,14 +734,14 @@ impl MaskPass {
// Still worth beginning the pass: the slice has to be cleared, or an
// unpainted layer would show whatever the last edit left in it.
let bind_group = (!batch.is_empty()).then(|| {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-brush-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
let params_buf =
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-brush-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
// Rebuilt per rasterisation rather than kept and patched. This runs
// when a mask's shape changes, not per frame, and a few kilobytes
// of stroke geometry is cheaper to upload than a residency scheme
@@ -901,12 +905,9 @@ impl MaskPass {
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
subject
.0
.view(subject.1)
.unwrap_or_else(|| {
self.empty_subject.view(0).expect("placeholder exists")
}),
subject.0.view(subject.1).unwrap_or_else(|| {
self.empty_subject.view(0).expect("placeholder exists")
}),
),
},
],
@@ -949,7 +950,11 @@ impl MaskPass {
}
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
if self.array.as_ref().is_some_and(|a| a.matches(width, height, layers)) {
if self
.array
.as_ref()
.is_some_and(|a| a.matches(width, height, layers))
{
return Ok(());
}
@@ -1111,7 +1116,9 @@ mod tests {
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, Some(&field), None, w, h).expect("render");
let array = pass
.render(&stack, Some(&field), None, w, h)
.expect("render");
assert_eq!(array.size(), (w, h));
assert_eq!(array.layers(), 1);
}
@@ -1201,7 +1208,11 @@ mod tests {
assert_eq!(batch.headers.len(), 1);
let h = &batch.headers[0];
assert!(h.hi[0] - h.lo[0] > 0.2, "wide enough for the dab: {h:?}", h = (h.lo, h.hi));
assert!(
h.hi[0] - h.lo[0] > 0.2,
"wide enough for the dab: {h:?}",
h = (h.lo, h.hi)
);
assert!(
h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0],
"and taller than it is wide in normalised units, since the radius \
+1 -3
View File
@@ -62,9 +62,7 @@ fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust
.render(&source, &shader, SIZE, SIZE)
.expect("render");
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize]
+5 -7
View File
@@ -84,12 +84,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
/// The whole calling convention a frontend adopts, in five lines: compose both
/// halves from one graph at one output space, ask the graph for the scale, and
/// pass the invalidation key through.
fn render(
pass: &mut AdjustPass,
graph: &EditGraph,
source: &DemosaicedImage,
out: u32,
) -> Vec<u8> {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
@@ -281,7 +276,10 @@ fn a_proxy_and_an_export_sharpen_the_same_photograph() {
proxy_width > 0.05 && export_width > 0.05,
"{proxy_width:.3} / {export_width:.3}"
);
assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}");
assert!(
proxy_area > 1.0 && export_area > 1.0,
"{proxy_area} / {export_area}"
);
}
#[test]
+10 -5
View File
@@ -80,9 +80,7 @@ fn expected_profile(size: u32, r: i32) -> Vec<u8> {
let edge = (size / 2) as i32;
(0..size as i32)
.map(|x| {
let white = (-r..=r)
.filter(|i| (x + i).clamp(0, last) >= edge)
.count();
let white = (-r..=r).filter(|i| (x + i).clamp(0, last) >= edge).count();
srgb_encode(white as f32 / (2 * r + 1) as f32)
})
.collect()
@@ -232,7 +230,10 @@ fn an_inactive_detail_operation_costs_exactly_nothing() {
let mut without = AdjustPass::new(&ctx);
let b = render(&ctx, &mut without, &plain, &source, SIZE);
assert_eq!(a, b, "an operation at its defaults must not touch the image");
assert_eq!(
a, b,
"an operation at its defaults must not touch the image"
);
}
#[test]
@@ -252,7 +253,11 @@ fn moving_a_detail_parameter_does_not_re_run_the_colour_pass() {
graph.set_param(PROBE, RADIUS, 0.0625);
render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable blur is two passes");
assert_eq!(
pass.detail_dispatches(),
2,
"a separable blur is two passes"
);
// Drag the sharpening slider. The colour chain is untouched, so the linear
// intermediate it wrote is still exactly right.
+20 -20
View File
@@ -65,12 +65,7 @@ fn render_at(
h: u32,
) -> Vec<u8> {
let source = grey_at(ctx, w, h);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
);
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise");
@@ -245,11 +240,14 @@ fn stacked_layers_use_their_own_masks() {
ids: vec![0],
}));
// Right half down.
let mut darken = MaskLayer::new("m2", MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
});
let mut darken = MaskLayer::new(
"m2",
MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
},
);
darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken);
@@ -412,12 +410,7 @@ fn a_stroke_that_doubles_back_does_not_build_up() {
return;
};
let once = stack_of(painted(&[(
false,
0.15,
0.5,
vec![(0.1, 0.5), (0.9, 0.5)],
)]));
let once = stack_of(painted(&[(false, 0.15, 0.5, vec![(0.1, 0.5), (0.9, 0.5)])]));
let twice = stack_of(painted(&[(
false,
0.15,
@@ -473,10 +466,17 @@ fn a_dab_is_round_on_a_frame_that_is_not_square() {
let pixels = render_at(&ctx, &stack, None, W, H);
let lit = |v: u8| v > 160;
let across = (0..W).filter(|&x| lit(luma_in(&pixels, W, x, H / 2))).count();
let down = (0..H).filter(|&y| lit(luma_in(&pixels, W, W / 2, y))).count();
let across = (0..W)
.filter(|&x| lit(luma_in(&pixels, W, x, H / 2)))
.count();
let down = (0..H)
.filter(|&y| lit(luma_in(&pixels, W, W / 2, y)))
.count();
assert!(across > 4 && down > 4, "the dab should exist: {across}x{down}");
assert!(
across > 4 && down > 4,
"the dab should exist: {across}x{down}"
);
assert!(
across.abs_diff(down) <= 2,
"a dab must be as wide as it is tall, got {across} across and {down} down"
+15 -3
View File
@@ -158,7 +158,12 @@ fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
// Far outside the kernel's reach the base equals the pixel, the detail
// signal is zero, and the output must be the input to the last code value.
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] {
for x in [
0,
reach / 2,
SIZE as usize - 1 - reach / 2,
SIZE as usize - 1,
] {
assert!(
edited[x].abs_diff(plain[x]) <= 1,
"column {x} moved by {} away from any edge",
@@ -246,7 +251,10 @@ fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
an unlimited unsharp mask would give"
);
// But it is still a real effect, not a control that does nothing.
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops");
assert!(
worst > 0.1,
"clarity moved almost nothing: {worst:.3} stops"
);
}
#[test]
@@ -476,7 +484,11 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
let mut graph = graph_with(CLARITY, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes");
assert_eq!(
pass.detail_dispatches(),
2,
"a separable mask is two passes"
);
let pipelines = pass.cached_detail_pipelines();
for amount in [50.0, 60.0, 70.0] {
+3 -1
View File
@@ -25,7 +25,9 @@ fn ctx() -> Option<GpuContext> {
}
fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size).flat_map(|_| [128, 128, 128, 255]).collect();
let data: Vec<u8> = (0..size * size)
.flat_map(|_| [128, 128, 128, 255])
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
+59 -17
View File
@@ -112,11 +112,7 @@ fn split(pixels: &[u8], width: u32, x: u32, y: u32) -> (f32, [f32; 3]) {
}
/// Upload an image built from a per-pixel closure.
fn upload(
ctx: &GpuContext,
size: u32,
f: impl Fn(u32, u32) -> [u8; 3],
) -> DemosaicedImage {
fn upload(ctx: &GpuContext, size: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let (x, y) = (i % size, i / size);
@@ -248,7 +244,11 @@ fn modulation(
let mut count = 0.0;
for x in margin..(width - margin) {
let value = sample(linear(pixels, width, x, row));
let sign = if (x / half_period) % 2 == 0 { 1.0 } else { -1.0 };
let sign = if (x / half_period) % 2 == 0 {
1.0
} else {
-1.0
};
total += value * sign;
count += 1.0;
}
@@ -305,7 +305,13 @@ fn chroma_noise_reduction_never_moves_lightness() {
let mut off = AdjustPass::new(&ctx);
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
let mut on = AdjustPass::new(&ctx);
let chroma = render(&ctx, &mut on, &graph_with(0.0, 100.0), &source, (SIZE, SIZE));
let chroma = render(
&ctx,
&mut on,
&graph_with(0.0, 100.0),
&source,
(SIZE, SIZE),
);
let colour_before = modulation(&plain, SIZE, 4, chroma_r);
let colour_after = modulation(&chroma, SIZE, 4, chroma_r);
@@ -338,7 +344,13 @@ fn luminance_noise_reduction_never_moves_colour() {
let mut off = AdjustPass::new(&ctx);
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
let mut on = AdjustPass::new(&ctx);
let luma = render(&ctx, &mut on, &graph_with(100.0, 0.0), &source, (SIZE, SIZE));
let luma = render(
&ctx,
&mut on,
&graph_with(100.0, 0.0),
&source,
(SIZE, SIZE),
);
// The colour difference — not the raw channel, which follows lightness.
let row = SIZE / 2;
@@ -413,10 +425,10 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() {
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 256;
const HALF_PERIOD: u32 = 8; // in source pixels
// Six code values of swing. Small on purpose: the colour difference has to
// land near the filter's threshold, because a larger one is a colour
// boundary and the whole point of a bilateral is that it refuses to cross
// those. There would be nothing to measure at either resolution.
// Six code values of swing. Small on purpose: the colour difference has to
// land near the filter's threshold, because a larger one is a colour
// boundary and the whole point of a bilateral is that it refuses to cross
// those. There would be nothing to measure at either resolution.
let source = chroma_pattern(&ctx, SOURCE, HALF_PERIOD, 6);
// Sixty percent is an eight-source-pixel radius, which halves to exactly
@@ -430,14 +442,26 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() {
let proxy_size = SOURCE / 2;
let mut proxy_pass = AdjustPass::new(&ctx);
let proxy = render(&ctx, &mut proxy_pass, &graph, &source, (proxy_size, proxy_size));
let proxy = render(
&ctx,
&mut proxy_pass,
&graph,
&source,
(proxy_size, proxy_size),
);
let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r);
// Both must be doing something: two flat images would agree perfectly and
// prove nothing. About 0.0098 of 0.0188, by the derivation above.
let untouched = {
let mut pass = AdjustPass::new(&ctx);
let plain = render(&ctx, &mut pass, &graph_with(0.0, 0.0), &source, (SOURCE, SOURCE));
let plain = render(
&ctx,
&mut pass,
&graph_with(0.0, 0.0),
&source,
(SOURCE, SOURCE),
);
modulation(&plain, SOURCE, HALF_PERIOD, chroma_r)
};
assert!(
@@ -486,7 +510,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] {
let mut pass = AdjustPass::new(&ctx);
render(&ctx, &mut pass, &graph_with(luminance, chroma), &source, (SIZE, SIZE));
render(
&ctx,
&mut pass,
&graph_with(luminance, chroma),
&source,
(SIZE, SIZE),
);
assert_eq!(
pass.detail_dispatches(),
expected,
@@ -496,7 +526,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
}
let mut neutral = AdjustPass::new(&ctx);
let a = render(&ctx, &mut neutral, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
let a = render(
&ctx,
&mut neutral,
&graph_with(0.0, 0.0),
&source,
(SIZE, SIZE),
);
assert_eq!(neutral.detail_dispatches(), 0);
assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated");
@@ -504,7 +540,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
// touch the image, and a stage that ran and wrote back the same values
// would still have quantised twice.
let mut absent = AdjustPass::new(&ctx);
let b = render(&ctx, &mut absent, &EditGraph::default_chain(), &source, (SIZE, SIZE));
let b = render(
&ctx,
&mut absent,
&EditGraph::default_chain(),
&source,
(SIZE, SIZE),
);
assert_eq!(a, b, "a neutral denoiser changed the picture");
}
+8 -7
View File
@@ -24,7 +24,9 @@ fn ctx() -> Option<GpuContext> {
/// The centre pixel's red, green and blue, after `graph` has run over flat
/// mid-grey.
fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect();
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|_| [128, 128, 128, 255])
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
// Composed the way the display path composes it. A curve that generates
@@ -44,11 +46,7 @@ fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
/// an edit.
fn lifted(channel: Channel) -> EditGraph {
let mut graph = EditGraph::default_chain();
graph.set_param(
curve::ID,
curve::coordinate(channel, 2, Axis::Y),
0.75,
);
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.75);
graph
}
@@ -62,7 +60,10 @@ fn the_master_curve_lifts_every_component_together() {
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
let (r, g, b) = rendered(&ctx, &lifted(Channel::Master));
assert!(r > r0, "the master curve did not lift the image: {r} vs {r0}");
assert!(
r > r0,
"the master curve did not lift the image: {r} vs {r0}"
);
// Grey in, grey out: the master curve is applied as a ratio over
// luminance, so it changes tone and not hue. A tolerance of one code
// value, because the components travel through the ratio separately and
+4 -1
View File
@@ -1532,7 +1532,10 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
.iter()
.map(|a| {
let mut c = a.chars();
let head = c.next().expect("attribute names are non-empty").to_uppercase();
let head = c
.next()
.expect("attribute names are non-empty")
.to_uppercase();
format!("Attribute::{head}{}", c.as_str())
})
.collect();
+1 -5
View File
@@ -499,11 +499,7 @@ pub fn compose_detail(
// case where a photographer has a kernel switched on at a scale that
// cannot draw it — against the alternative of the preview failing outright
// or `compose_full` growing a resolution argument it has no other use for.
if planned.is_empty()
&& ops
.iter()
.any(|o| o.is_active() && o.detail().is_some())
{
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
return ComposedDetail {
passes: vec![compose_one(
RESOLVE_ID,
+10 -4
View File
@@ -8,8 +8,7 @@
//! so reordering the pipeline needs no code change.
use crate::descriptor::{
Attribute,
Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack;
@@ -378,7 +377,11 @@ impl EditGraph {
/// Only the detail stage needs this. Every point operation is scale-free
/// — a multiply is a multiply at any resolution — which is why nothing in
/// the pipeline had to know its own size until a kernel arrived.
pub fn render_scale(&self, source: (u32, u32), render: (u32, u32)) -> crate::detail::RenderScale {
pub fn render_scale(
&self,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::RenderScale {
let (fw, fh) = self.framing.output_size(source.0, source.1);
let view = self.framing.view();
// The *viewed* part of the framed image, at source resolution. Zoom
@@ -397,7 +400,10 @@ impl EditGraph {
/// Empty for every edit with no active neighbourhood operation, which is
/// almost all of them — and in that case [`Self::compose`] emits the
/// single encoded dispatch it always has.
pub fn compose_detail(&self, scale: crate::detail::RenderScale) -> crate::detail::ComposedDetail {
pub fn compose_detail(
&self,
scale: crate::detail::RenderScale,
) -> crate::detail::ComposedDetail {
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
}
+2 -2
View File
@@ -44,8 +44,8 @@ pub mod preset;
pub mod sidecar;
pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
Scale, Unit, WidgetDemand, WidgetKind,
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
};
pub use detail::{
compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale,
+30 -10
View File
@@ -856,7 +856,11 @@ impl MaskLayer {
pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool {
let full = self.stroke_points() >= MAX_LAYER_POINTS;
let MaskSource::Brush { strokes } = &mut self.source else {
log::warn!("layer {} is a {} mask, not a brush", self.id, self.source.kind());
log::warn!(
"layer {} is a {} mask, not a brush",
self.id,
self.source.kind()
);
return false;
};
if full {
@@ -1121,7 +1125,10 @@ impl MaskStack {
/// Generate a fresh layer id that does not collide with an existing one.
pub fn next_id(&self) -> String {
(1..).map(|n| format!("m{n}")).find(|id| self.get(id).is_none()).expect("infinite range")
(1..)
.map(|n| format!("m{n}"))
.find(|id| self.get(id).is_none())
.expect("infinite range")
}
}
@@ -1178,10 +1185,7 @@ pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
// place. A second copy here would be a second thing to keep in step
// with `Framing::wgsl_prologue`, and the failure would be a mask that
// is subtly wrong only when straightened.
let _ = writeln!(
out.body,
" var m = sample_mask(uv_src, {slot});"
);
let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});");
let _ = writeln!(
out.body,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
@@ -1403,7 +1407,11 @@ mod tests {
assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!(
shader.uniform_values.len(),
shader.uniform_fields.lines().filter(|l| l.trim_start().starts_with("mask")).count(),
shader
.uniform_fields
.lines()
.filter(|l| l.trim_start().starts_with("mask"))
.count(),
"one value per emitted field"
);
}
@@ -1448,7 +1456,11 @@ mod tests {
// A gradient has no region ids, so nothing can go stale about it.
let grad = MaskLayer::new(
"m2",
MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2 },
MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 0.2,
},
);
assert!(!grad.is_stale(999));
}
@@ -1458,8 +1470,16 @@ mod tests {
let base = segmentation_signature(1600, 1067, 6730, 2);
assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2));
assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning");
assert_ne!(base, segmentation_signature(800, 1067, 6730, 2), "proxy size");
assert_ne!(base, segmentation_signature(1600, 1067, 42, 2), "region count");
assert_ne!(
base,
segmentation_signature(800, 1067, 6730, 2),
"proxy size"
);
assert_ne!(
base,
segmentation_signature(1600, 1067, 42, 2),
"region count"
);
}
#[test]
+5 -5
View File
@@ -472,10 +472,7 @@ pub fn compose_full(
// than from a flag the caller sets, because a caller that got the flag
// wrong would produce a shader whose storage format does not match the
// texture bound to it.
let output_mode = if ops
.iter()
.any(|o| o.is_active() && o.detail().is_some())
{
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
@@ -1389,7 +1386,10 @@ mod tests {
// how a shoulder rolls off.
let mut curve = crate::ops::ToneCurve::new();
curve.set_param(crate::ops::curve::P2_Y, 0.7);
assert!(curve.is_active(), "the fixture must actually reach the shader");
assert!(
curve.is_active(),
"the fixture must actually reach the shader"
);
let source = compose(&[Box::new(curve)]).source;
assert_eq!(
+3 -1
View File
@@ -32,7 +32,9 @@
//! least to perceived sharpness. Scaling all three about a virtual reference
//! would soften the image even when the correction is right.
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::lens::Warp;
use crate::operation::{Helper, Uniform};
+42 -8
View File
@@ -764,9 +764,11 @@ mod tests {
};
assert_eq!(gate_of(0.0), 0.0);
assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
assert!(chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
.source
.contains("gate > 0.0"));
assert!(
chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
.source
.contains("gate > 0.0")
);
}
#[test]
@@ -783,7 +785,11 @@ mod tests {
for scale in scales {
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite()), "{}", pass.label);
assert!(
pass.uniforms.iter().all(|v| v.is_finite()),
"{}",
pass.label
);
}
}
}
@@ -805,10 +811,38 @@ mod tests {
// word be safe in one stage and not in the other, which is the sort of
// difference nobody discovers until a shader fails to compile.
const RESERVED: &[&str] = &[
"target", "sample", "filter", "texture", "buffer", "binding", "const", "enum", "mat",
"vec", "ptr", "ref", "shared", "static", "typedef", "union", "unless", "handle",
"layout", "packed", "premerge", "regardless", "active", "do", "input", "output",
"private", "resource", "restrict", "self", "std", "where",
"target",
"sample",
"filter",
"texture",
"buffer",
"binding",
"const",
"enum",
"mat",
"vec",
"ptr",
"ref",
"shared",
"static",
"typedef",
"union",
"unless",
"handle",
"layout",
"packed",
"premerge",
"regardless",
"active",
"do",
"input",
"output",
"private",
"resource",
"restrict",
"self",
"std",
"where",
];
for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes {
+3 -1
View File
@@ -30,7 +30,9 @@
//! adjusted, each one's share depends on what the other is set to, so turning
//! up one colour's saturation quietly weakened its neighbour's hue shift.
use crate::descriptor::{Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
+8 -8
View File
@@ -121,12 +121,7 @@ impl Channel {
/// Master first because it runs first — see the module documentation for
/// why that is the composition order — and because a list showing the
/// grade before the tone would be describing a different operation.
pub const ALL: [Channel; 4] = [
Channel::Master,
Channel::Red,
Channel::Green,
Channel::Blue,
];
pub const ALL: [Channel; 4] = [Channel::Master, Channel::Red, Channel::Green, Channel::Blue];
/// Position in [`Self::ALL`], and so in every array keyed by channel.
pub const fn index(self) -> usize {
@@ -771,7 +766,10 @@ impl Operation for ToneCurve {
// declared from, so a call and its uniform block cannot disagree
// about a name.
let args = UNIFORM_NAMES[channel.index()].join(", ");
let _ = writeln!(body, "c.{component} = channel_curve(c.{component}, {args});");
let _ = writeln!(
body,
"c.{component} = channel_curve(c.{component}, {args});"
);
}
// Whichever curves ran, the result has to be a colour: the spline's
@@ -1345,7 +1343,9 @@ mod tests {
let body = c.wgsl_body();
let master = body.find("apply_tone_gain").expect("the master curve runs");
let red = body.find("c.r = channel_curve").expect("the red curve runs");
let red = body
.find("c.r = channel_curve")
.expect("the red curve runs");
assert!(master < red, "the master curve must run first:\n{body}");
}
+3 -1
View File
@@ -26,7 +26,9 @@
//! profile is a "make the horizon straight" task, which one term does well.
//! The full triple is reachable by loading a profile.
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::lens::Warp;
use crate::operation::{Helper, Uniform};
+15 -4
View File
@@ -619,7 +619,10 @@ mod tests {
let scale = RenderScale::full((2000, 1500));
let only_clarity = composed(50.0, 0.0, scale);
assert_eq!(only_clarity.len(), 2, "one operation, two passes");
assert!(only_clarity.passes.iter().all(|p| p.label.starts_with("clarity/")));
assert!(only_clarity
.passes
.iter()
.all(|p| p.label.starts_with("clarity/")));
let both = composed(50.0, 50.0, scale);
assert_eq!(both.len(), 4);
@@ -701,7 +704,10 @@ mod tests {
// acutance family's unit.
let proxy = RenderScale::new((2000, 1500), (6000, 4500));
let clarity = Clarity::with_amount(60.0);
assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500))));
assert_eq!(
clarity.kernel(proxy),
clarity.kernel(RenderScale::full((2000, 1500)))
);
assert!(
proxy.source_pixels(96.0) < 40.0,
"the same length in the other unit would have collapsed"
@@ -786,7 +792,9 @@ mod tests {
// slider can produce is `gain * threshold` stops however violent the
// edge — a bound that holds by construction rather than by tuning.
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
assert!(combine.wgsl.contains("threshold * tanh(detail / threshold)"));
assert!(combine
.wgsl
.contains("threshold * tanh(detail / threshold)"));
let bound = |u: &[Uniform]| {
let get = |n| u.iter().find(|x| x.name == n).unwrap().value;
@@ -819,7 +827,10 @@ mod tests {
// And texture does not carry the helpers it would need for one, so its
// shader says what it does rather than merely not calling it.
assert!(Texture::new().helpers().iter().any(|h| h.name == "log_luma"));
assert!(Texture::new()
.helpers()
.iter()
.any(|h| h.name == "log_luma"));
assert!(!Texture::new()
.helpers()
.iter()
+7 -1
View File
@@ -820,7 +820,13 @@ mod tests {
// Everything else about the two must match, or one axis is filtered
// harder than the other and a round blotch comes out oval.
assert_eq!(passes[0].radius, passes[1].radius);
for name in ["radius", "inv_spatial", "guide_k", "chroma_k", "noise_floor"] {
for name in [
"radius",
"inv_spatial",
"guide_k",
"chroma_k",
"noise_floor",
] {
let of = |p: &DetailPass| {
p.uniforms
.iter()
+6 -5
View File
@@ -67,9 +67,7 @@ use std::fmt;
use std::fmt::Write as _;
use crate::graph::EditGraph;
use crate::mask::{
Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
};
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
use crate::preset::{resolve, Preset};
/// Format version of the document itself.
@@ -662,7 +660,6 @@ impl Sidecar {
}
}
/// Write one mask layer as its own block.
///
/// The version uuid is repeated in the header rather than relying on the
@@ -1292,7 +1289,11 @@ mod tests {
// else.
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] {
for channel in [
curve::Channel::Red,
curve::Channel::Green,
curve::Channel::Blue,
] {
for point in 0..curve::POINTS {
for axis in [curve::Axis::X, curve::Axis::Y] {
let id = curve::coordinate(channel, point, axis);
+4 -1
View File
@@ -40,7 +40,10 @@ fn the_groups_are_derivable_from_the_chain() {
present.sort();
present.dedup();
assert!(present.contains(&Attribute::Tone), "the chain has tonal work");
assert!(
present.contains(&Attribute::Tone),
"the chain has tonal work"
);
assert!(present.contains(&Attribute::Colour));
assert!(
present.contains(&Attribute::Geometry),
+23 -4
View File
@@ -55,7 +55,14 @@ fn a_region_mask_survives_a_round_trip() {
assert_eq!(layer.id, "m1");
assert_eq!(layer.name, "Subject");
assert_eq!(layer.source, regions(&[3, 7, 12]));
assert_eq!(layer.ops.iter().find(|o| o.descriptor().id.0 == "exposure").map(|o| o.param(ParamId("exposure"))), Some(0.75));
assert_eq!(
layer
.ops
.iter()
.find(|o| o.descriptor().id.0 == "exposure")
.map(|o| o.param(ParamId("exposure"))),
Some(0.75)
);
}
#[test]
@@ -120,7 +127,10 @@ fn flags_and_opacity_survive() {
let layer = &restored.masks().layers()[0];
assert!(layer.invert);
assert!((layer.opacity - 0.35).abs() < 1e-6);
assert!(!layer.enabled, "a disabled layer must stay disabled, not vanish");
assert!(
!layer.enabled,
"a disabled layer must stay disabled, not vanish"
);
}
/// A selection's identity is the *set*. Two files naming the same regions in
@@ -272,7 +282,10 @@ fn unknown_top_level_keys_still_round_trip_alongside_masks() {
exposure.exposure = 1\n";
let out = Sidecar::parse(text).expect("parse").to_text();
assert!(out.contains("future.thing"), "unknown keys are still preserved");
assert!(
out.contains("future.thing"),
"unknown keys are still preserved"
);
assert!(out.contains("[mask default m1]"));
}
@@ -361,7 +374,13 @@ fn a_brush_layer_writes_one_line_per_stroke() {
.lines()
.filter(|l| l.starts_with("stroke = "))
.collect();
assert_eq!(lines, ["stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5", "stroke = erase 0.05 0.5 1 0.5,0.5"]);
assert_eq!(
lines,
[
"stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5",
"stroke = erase 0.05 0.5 1 0.5,0.5"
]
);
}
#[test]
+2 -2
View File
@@ -12,7 +12,8 @@ use dr_pipeline::{EditGraph, Sidecar};
/// A version block carrying `params`, in the on-disk spelling.
fn sidecar_with(params: &[(&str, f32)]) -> String {
let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
let mut text =
String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
for (key, value) in params {
text.push_str(&format!("{key} = {value}\n"));
}
@@ -199,4 +200,3 @@ fn every_point_of_every_curve_round_trips_through_a_sidecar() {
}
}
}
+7 -9
View File
@@ -46,7 +46,11 @@ fn main() {
};
println!(
"tiling {}",
if tiled { "grid, 25% overlap" } else { "whole frame" }
if tiled {
"grid, 25% overlap"
} else {
"whole frame"
}
);
let t0 = std::time::Instant::now();
@@ -57,10 +61,7 @@ fn main() {
let instances = model
.detect(&rgb, width, height, &options)
.expect("inference");
println!(
"detect {:.0} ms",
t1.elapsed().as_secs_f32() * 1000.0
);
println!("detect {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
println!("found {} instances", instances.len());
for (i, inst) in instances.iter().enumerate() {
@@ -131,10 +132,7 @@ fn load_jpeg(path: &str) -> (Vec<f32>, usize, usize) {
// where *not* linearising is the correct thing to do.
let rgb = match pixels.len() / (w * h) {
3 => pixels.iter().map(|&v| v as f32 / 255.0).collect(),
1 => pixels
.iter()
.flat_map(|&v| [v as f32 / 255.0; 3])
.collect(),
1 => pixels.iter().flat_map(|&v| [v as f32 / 255.0; 3]).collect(),
n => panic!("unexpected {n} components per pixel"),
};
+11 -4
View File
@@ -346,8 +346,8 @@ impl Shaped {
radius_px: f32,
) -> Self {
let distance = signed_distance(coverage, width, height, threshold);
let distance = apply_morphology(&distance, width, height, morphology, radius_px)
.unwrap_or(distance);
let distance =
apply_morphology(&distance, width, height, morphology, radius_px).unwrap_or(distance);
Self {
distance,
width,
@@ -403,7 +403,11 @@ mod tests {
// Distance from the boundary, so the half-pixel comes back off to
// compare against the centre-to-centre figures.
let at = |x: usize, y: usize| -d[y * 5 + x] + 0.5;
assert!((at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4, "{}", at(1, 1));
assert!(
(at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4,
"{}",
at(1, 1)
);
assert!((at(0, 0) - (8.0f32).sqrt()).abs() < 1e-4, "{}", at(0, 0));
assert_eq!(at(2, 0), 2.0, "straight up is exactly two");
}
@@ -489,7 +493,10 @@ mod tests {
m[10 * w + 10] = 0;
let before = signed_distance(&m, w, h, 128);
assert!(before[10 * w + 10] < 0.0, "the hole starts outside the mask");
assert!(
before[10 * w + 10] < 0.0,
"the hole starts outside the mask"
);
let after = apply_morphology(&before, w, h, Morphology::Close, 2.0).expect("recomputed");
assert!(after[10 * w + 10] >= 0.0, "closing should have filled it");
+37 -18
View File
@@ -142,7 +142,11 @@ impl Membership {
/// The instance a region belongs to, if any.
pub fn instance_of(&self, region: u32) -> Option<usize> {
self.of.get(region as usize).copied().flatten().map(|(i, _)| i)
self.of
.get(region as usize)
.copied()
.flatten()
.map(|(i, _)| i)
}
/// How two regions relate semantically, in `-1.0..=1.0`.
@@ -233,11 +237,7 @@ pub fn apply_semantic_prior(
///
/// The returned ids are sorted, so the same click always produces the same
/// mask — which is what lets it be a cache key.
pub fn regions_for_instance(
field: &RegionField,
mask: &[f32],
options: &PriorOptions,
) -> Vec<u32> {
pub fn regions_for_instance(field: &RegionField, mask: &[f32], options: &PriorOptions) -> Vec<u32> {
let mut coverage = vec![0.0f32; field.region_count];
let mut sizes = vec![0u32; field.region_count];
@@ -278,10 +278,26 @@ mod tests {
labels: vec![0, 0, 2, 2, 1, 1, 3, 3],
region_count: 4,
adjacency: vec![
Edge { a: 0, b: 1, saddle: 1.0 },
Edge { a: 0, b: 2, saddle: 1.0 },
Edge { a: 1, b: 3, saddle: 1.0 },
Edge { a: 2, b: 3, saddle: 1.0 },
Edge {
a: 0,
b: 1,
saddle: 1.0,
},
Edge {
a: 0,
b: 2,
saddle: 1.0,
},
Edge {
a: 1,
b: 3,
saddle: 1.0,
},
Edge {
a: 2,
b: 3,
saddle: 1.0,
},
],
}
}
@@ -361,7 +377,10 @@ mod tests {
#[test]
fn zero_strength_is_arm_a_exactly() {
let f = field();
let opts = PriorOptions { strength: 0.0, ..PriorOptions::default() };
let opts = PriorOptions {
strength: 0.0,
..PriorOptions::default()
};
let m = Membership::compute(&f, &[&left_half()], &opts).unwrap();
assert_eq!(apply_semantic_prior(&f, &m, &opts).adjacency, f.adjacency);
}
@@ -394,7 +413,11 @@ mod tests {
let err = Membership::compute(&f, &[&vec![0.0; 3]], &PriorOptions::default()).unwrap_err();
assert_eq!(
err,
MembershipError::MaskSize { instance: 0, expected: 8, got: 3 }
MembershipError::MaskSize {
instance: 0,
expected: 8,
got: 3
}
);
}
@@ -403,12 +426,8 @@ mod tests {
fn different_instances_repel() {
let f = field();
let right_half = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
let m = Membership::compute(
&f,
&[&left_half(), &right_half],
&PriorOptions::default(),
)
.unwrap();
let m = Membership::compute(&f, &[&left_half(), &right_half], &PriorOptions::default())
.unwrap();
assert_eq!(m.instance_of(0), Some(0));
assert_eq!(m.instance_of(2), Some(1));
+145 -7
View File
@@ -145,6 +145,39 @@ impl Instance {
}
}
/// Take in another view of the same object, from an overlapping tile.
///
/// Pointwise maximum over the coverage, union of the boxes, higher of the
/// scores — see `merge_into` for why each is right for its own quantity.
/// Both instances describe the whole frame in the same coordinates, so no
/// resampling is involved and the masks are already aligned pixel for
/// pixel.
fn absorb(&mut self, other: Self) {
debug_assert_eq!(
(self.width, self.height),
(other.width, other.height),
"instances from one detection run share the frame they are defined over"
);
for (mine, theirs) in self.mask.iter_mut().zip(&other.mask) {
if *theirs > *mine {
*mine = *theirs;
}
}
self.bbox = (
self.bbox.0.min(other.bbox.0),
self.bbox.1.min(other.bbox.1),
self.bbox.2.max(other.bbox.2),
self.bbox.3.max(other.bbox.3),
);
if other.score > self.score {
self.score = other.score;
// The name travels with the score: they are one judgement, and a
// mask labelled by the less confident of two detections would be
// labelled by the one we just decided to trust less.
self.class_name = other.class_name;
}
}
fn iou(&self, other: &Self, threshold: f32) -> f32 {
let mut inter = 0usize;
let mut union = 0usize;
@@ -357,7 +390,6 @@ impl SemanticModel {
options,
))
}
}
/// Turn one forward pass into instances in source-image space.
@@ -543,7 +575,11 @@ fn assemble_mask(
let (gx1, gy1) = ((gx0 + 1).min(pw - 1), (gy0 + 1).min(ph - 1));
let mut acc = 0.0;
for (k, &c) in coeffs.iter().enumerate().take(PROTOTYPES.min(protos.shape()[0])) {
for (k, &c) in coeffs
.iter()
.enumerate()
.take(PROTOTYPES.min(protos.shape()[0]))
{
if c == 0.0 {
continue;
}
@@ -567,8 +603,33 @@ fn assemble_mask(
///
/// Only needed for [`Tiling::Grid`]: an object straddling a seam is seen by
/// both tiles, and without this it would appear twice in the list a person
/// chooses from. Keeps the higher-scoring copy, which is generally the tile
/// that saw more of the object.
/// chooses from.
///
/// # Why the two are unioned rather than one of them chosen
///
/// This kept the higher-scoring copy and discarded the other. That is the
/// wrong answer for the case tiling exists to serve, and it quietly threw away
/// what tiling had just paid for.
///
/// A tile sees the part of an object that falls inside it and nothing of the
/// rest, so an object on a seam produces *two partial masks*, neither of them
/// the object. Keeping the better one keeps the larger fragment — a bird with
/// its tail cut off at the tile edge, described by whichever tile held more of
/// the bird. Both halves exist; only one survived.
///
/// Unioning is sound precisely because the tiles overlap. With a 25% overlap
/// every pixel is seen by at least one tile at full resolution and pixels near
/// a seam by two, so the pointwise maximum of the two coverages is the better
/// estimate everywhere rather than a compromise: where only one tile saw a
/// pixel it is the only opinion available, and where both did, the higher
/// value comes from the tile that had more context around it. Taking a maximum
/// of soft coverage also keeps the mask soft, which is what `prior.rs` weights
/// merges by and what a mask layer's edge treatment needs.
///
/// The score is the higher of the two rather than a blend. It is shown to a
/// photographer beside the class name and means "how sure the model is this is
/// a bird"; averaging in the tile that saw only a wingtip would make a
/// confident detection look doubtful for straddling a seam.
fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &SemanticOptions) {
for candidate in batch {
let duplicate = found.iter_mut().find(|existing| {
@@ -577,8 +638,7 @@ fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &Semanti
});
match duplicate {
Some(existing) if existing.score < candidate.score => *existing = candidate,
Some(_) => {}
Some(existing) => existing.absorb(candidate),
None => found.push(candidate),
}
}
@@ -643,7 +703,12 @@ mod tests {
#[test]
fn letterbox_round_trips_a_landscape_window() {
let window = Window { x: 0.0, y: 0.0, w: 1600.0, h: 1067.0 };
let window = Window {
x: 0.0,
y: 0.0,
w: 1600.0,
h: 1067.0,
};
let lb = Letterbox::fit(window.w, window.h);
// A source point maps into the input and back to where it started.
@@ -663,6 +728,79 @@ mod tests {
assert!(lb.pad_y > 100.0, "wide image should pad in y: {}", lb.pad_y);
}
/// A subject on a seam, seen in part by each of two tiles.
///
/// The failure this pins down is not a crash and not a duplicate: it is a
/// mask that looks plausible and is missing the half of the subject that
/// fell in the other tile. Keeping the higher-scoring detection produced
/// exactly that, and it is invisible unless you already know what the
/// whole subject should have been.
#[test]
fn two_tiles_seeing_one_subject_produce_the_whole_subject() {
// The left tile sees the left half strongly and nothing of the right;
// the right tile sees the right half. Together they are one bar.
let left = instance_with(
&[0.9, 0.9, 0.9, 0.8, 0.0, 0.0, 0.0, 0.0],
0.80,
(0.0, 0.0, 4.0, 1.0),
);
let right = instance_with(
&[0.0, 0.0, 0.3, 0.7, 0.9, 0.9, 0.9, 0.0],
0.60,
(2.0, 0.0, 7.0, 1.0),
);
let options = SemanticOptions {
mask_threshold: 0.5,
merge_iou: 0.1,
..SemanticOptions::default()
};
let mut found = vec![left];
merge_into(&mut found, vec![right], &options);
assert_eq!(found.len(), 1, "one subject, not two");
let m = &found[0];
// Every pixel either tile was sure about survives. Under the old
// keep-the-better-one rule, pixels 4..=6 were lost entirely.
for i in [0, 1, 2, 3, 4, 5, 6] {
assert!(
m.mask[i] >= 0.5,
"pixel {i} was seen by a tile and must survive the merge: {:?}",
m.mask
);
}
assert!(m.mask[7] < 0.5, "a pixel neither tile saw must stay out");
// Pointwise maximum, not an average: pixel 2 is 0.9 in one tile and
// 0.3 in the other, and averaging would report 0.6 — a softer edge
// than either tile actually saw.
assert!(
(m.mask[2] - 0.9).abs() < 1e-6,
"expected the max, got {}",
m.mask[2]
);
assert!(
(m.score - 0.80).abs() < 1e-6,
"the confident detection's score survives"
);
assert_eq!(m.bbox, (0.0, 0.0, 7.0, 1.0), "the box covers both halves");
}
fn instance_with(mask: &[f32], score: f32, bbox: (f32, f32, f32, f32)) -> Instance {
Instance {
class_id: 14,
class_name: "bird".into(),
score,
bbox,
mask: mask.to_vec(),
width: mask.len(),
height: 1,
}
}
/// The seam case tiling exists for, and the one it must not double-count.
#[test]
fn grid_tiling_covers_the_frame_with_overlap() {
+3 -1
View File
@@ -19,7 +19,9 @@ pub use settings::{
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
ExportTarget, ImportSettings, OutputSharpening, Settings, SizingMode,
};
pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil};
pub use time::{
civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil,
};
/// Identifies a granted library location — a directory on Linux, a persisted
/// document tree on Android.
+13 -13
View File
@@ -62,9 +62,14 @@ pub struct Settings {
/// TRACES: FR-CAT-10 | FR-NC-7a
/// What an import defaults to, remembered between cards.
///
/// A photographer imports the same way every time — same folders, same
/// destination, same answer about the card — and retyping a template on every
/// card is the kind of friction that makes people stop using the importer.
/// A photographer imports the same way every time — same folders, same answer
/// about the card — and retyping a template on every card is the kind of
/// friction that makes people stop using the importer.
///
/// **There is no destination here.** The library is the folder on the server
/// (FR-NC-6), so an import has exactly one place to go and nothing to ask.
/// What lands on this machine is a staging copy the app owns and removes once
/// the server confirms it (FR-NC-7b), which is not a setting either.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ImportSettings {
@@ -76,15 +81,13 @@ pub struct ImportSettings {
/// defined — so it owns the default too, and this records only a
/// deliberate departure from it.
pub folder_template: String,
/// Where originals are copied to on this machine. Empty until chosen.
///
/// A local folder even on a library that lives on a server: FR-NC-7b has
/// the bytes land and be verified here first, and the upload follow.
pub destination: String,
/// The optional second copy (FR-CAT-10). Empty means none.
///
/// The only local path an import has, and deliberately so: the library
/// lives on the server, so there is no *first* destination for the user to
/// choose. This is an extra copy kept somewhere of their choosing — an
/// external drive, another disk — and nothing reads it back.
pub backup: String,
/// Whether to send imported originals on to the server (FR-NC-7b).
pub upload: bool,
/// Whether the card is to be emptied once everything is confirmed.
///
/// **Defaults to false and is the one setting worth not remembering
@@ -109,9 +112,7 @@ impl Default for ImportSettings {
fn default() -> Self {
Self {
folder_template: String::new(),
destination: String::new(),
backup: String::new(),
upload: true,
// The one irreversible thing this application does starts off.
move_from_card: false,
verify: true,
@@ -1136,7 +1137,6 @@ mod import_settings_tests {
let s: Settings = serde_json::from_str(stored).unwrap();
assert!(s.import.verify);
assert!(s.import.skip_duplicates);
assert!(s.import.upload);
// And the irreversible one stays off.
assert!(!s.import.move_from_card);
}
+107
View File
@@ -64,6 +64,68 @@ pub fn civil_from_unix_at(t: i64, offset: Option<i32>) -> Civil {
civil_from_unix(t + offset.unwrap_or(0) as i64 * 60)
}
/// TRACES: FR-CAT-6
/// A civil date back to Unix seconds — midnight UTC on that day.
///
/// The exact inverse of [`civil_from_unix`]'s date part, by the companion
/// era-based algorithm, so a date formatted by [`format_date`] and read back
/// here lands on the same day it named. Hand-rolled for the same reason the
/// forward direction is: four fields do not justify a date library, and two
/// implementations that disagree would put a photograph on a day the timeline
/// does not show it.
///
/// Midnight rather than noon because a range is half-open in spirit: "from the
/// 3rd" means from the first instant of the 3rd, and the caller decides what
/// "to the 5th" means by adding a day if it wants the 5th included.
pub fn unix_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = if month > 2 { month - 3 } else { month + 9 };
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
(era * 146_097 + doe - 719_468) * 86_400
}
/// TRACES: FR-CAT-6
/// `YYYY-MM-DD` to Unix seconds, or `None` if that is not what it is.
///
/// Strict on purpose. This reads what a person typed into a date field, and
/// the useful answer to "13/2026/08" is that it is not a date — guessing at an
/// order would silently filter a library to a span nobody asked for, and the
/// grid going empty is a poor way to learn your input was misread.
///
/// The day is checked against the month it is in, so `2026-02-30` is refused
/// rather than quietly becoming the 2nd of March.
pub fn parse_date(text: &str) -> Option<i64> {
let text = text.trim();
let mut parts = text.split('-');
let year: i64 = parts.next()?.parse().ok()?;
let month: i64 = parts.next()?.parse().ok()?;
let day: i64 = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
if !(1..=12).contains(&month) || day < 1 {
return None;
}
if day > days_in_month(year, month) {
return None;
}
Some(unix_from_civil(year, month, day))
}
/// Length of a month, with the full Gregorian leap rule.
fn days_in_month(year: i64, month: i64) -> i64 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
2 => 28,
_ => 0,
}
}
/// A capture instant as `YYYY-MM-DD`.
pub fn format_date(t: i64) -> String {
let c = civil_from_unix(t);
@@ -74,6 +136,51 @@ pub fn format_date(t: i64) -> String {
mod tests {
use super::*;
/// The two directions must agree, or a photograph is filed on a day the
/// timeline does not show it on — the failure this module exists to stop.
#[test]
fn a_date_survives_the_round_trip() {
for t in [
0, // the epoch
1_760_000_000, // an ordinary recent instant
951_782_400, // 2000-02-29, the leap day the 100/400 rule keeps
-86_400, // the day before the epoch
] {
let c = civil_from_unix(t);
let back = unix_from_civil(c.year, c.month, c.day);
assert_eq!(
civil_from_unix(back),
Civil { hour: 0, ..c },
"{t} -> {c:?} -> {back} disagreed"
);
}
}
#[test]
fn parsing_accepts_a_date_and_refuses_everything_else() {
assert_eq!(parse_date("1970-01-01"), Some(0));
assert_eq!(parse_date(" 2026-08-23 "), parse_date("2026-08-23"));
assert_eq!(format_date(parse_date("2026-08-23").unwrap()), "2026-08-23");
// A leap day is real in 2024 and not in 2026.
assert!(parse_date("2024-02-29").is_some());
assert_eq!(parse_date("2026-02-29"), None);
// Refused rather than guessed at: silently reordering these would
// filter the library to a span nobody asked for.
assert_eq!(
parse_date("23-08-2026"),
None,
"day-first is not a format here"
);
assert_eq!(parse_date("2026-13-01"), None, "there is no month 13");
assert_eq!(parse_date("2026-08-32"), None);
assert_eq!(parse_date("2026-08"), None, "a month is not a date");
assert_eq!(parse_date("2026-08-23-01"), None);
assert_eq!(parse_date(""), None);
assert_eq!(parse_date("yesterday"), None);
}
#[test]
fn the_epoch_is_the_first_of_january_1970() {
let c = civil_from_unix(0);
+22
View File
@@ -27,6 +27,26 @@ CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android"
OUT="${CACHE}/target/apk"
APK="${OUT}/darkroom.apk"
# The version, taken from the workspace rather than restated here.
#
# `AndroidManifest.xml` deliberately declares none: a manifest that states a
# version is a second place for one to be wrong, and it was — the APK reported
# `versionName=null` and `versionCode=0` on the device while the binary inside
# it knew perfectly well what it was.
#
# `versionCode` must be a single increasing integer, which a semantic version
# is not, so it is packed: MAJOR*10000 + MINOR*100 + PATCH. That keeps the
# ordering Android needs (it refuses to install an APK whose code is lower than
# the installed one) and stays readable — 0.4.0 is 400. It allows 99 minors and
# 99 patches per major, which is a limit worth knowing about and a long way off.
VERSION_NAME="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
if [[ -z "${VERSION_NAME}" ]]; then
echo "error: no version in Cargo.toml" >&2
exit 1
fi
VERSION_CODE="$(awk -F. '{ print $1 * 10000 + $2 * 100 + $3 }' <<< "${VERSION_NAME}")"
echo "==> version ${VERSION_NAME} (code ${VERSION_CODE})"
INSTALL=0
[[ "${1:-}" == "--install" ]] && INSTALL=1
@@ -100,6 +120,8 @@ echo "==> packaging APK"
-R "${OUT}/res.zip" \
--min-sdk-version 28 \
--target-sdk-version 36 \
--version-name "'"${VERSION_NAME}"'" \
--version-code "'"${VERSION_CODE}"'" \
-o "${OUT}/base.apk" \
--auto-add-overlay
+23 -6
View File
@@ -654,14 +654,31 @@ Two consequences the mechanics do not give for free:
Sidecars follow their image, not the template: they are named from `oc:fileid` (FR-NC-8) and live
beside the file they describe.
**FR-NC-7b — Ingest to remote.** Import (FR-CAT-10) and upload (FR-NC-7) shall compose: a card
import may target a Nextcloud library, in which case files are copied locally first, verified by
checksum, and only then queued for upload. The local copy is not deleted on a move-import until
the upload is confirmed — a card erased against an in-flight upload is unrecoverable, which is the
one failure in this app with no undo.
**FR-NC-7b — Ingest to remote.** Import (FR-CAT-10) and upload (FR-NC-7) compose, and the library
an import targets is **always** the remote one. There is no local library for a card to land in:
FR-NC-6 puts the library on the server, so an import has exactly one destination and the interface
shall not offer a choice of another.
What lands on the device is a **staging copy**, in a location the app owns, and it is not a
library: no view lists it, and it is removed once the server confirms the file. A staged file the
server did not take remains queued, and a later import drains the queue. An import with no
reachable server therefore succeeds and defers, rather than failing (FR-NC-10); an import with no
*account* has nowhere to go at all and shall be refused.
The bytes shall reach the device before they reach the network. Streaming a card straight to the
server would make a move-import erase a card against an in-flight upload, and would make importing
impossible offline.
**On erasing the card.** A move-import shall delete from the card only those photographs the
**server has confirmed** — not those merely written to the staging copy, since that copy is removed
as soon as the upload succeeds and would otherwise be the only remaining copy. Anything that does
not upload keeps its card copy. A card erased against an unfinished upload is unrecoverable, which
is the one failure in this app with no undo.
Duplicate detection (FR-CAT-11) runs against the catalog before upload, so re-inserting a card
that was already imported transfers nothing.
that was already imported transfers nothing. Where the catalog cannot answer — a file already in
the destination folder, locally or on the server — the folder's own listing shall answer instead,
so a re-import neither duplicates nor renames what is already held.
**FR-NC-8 — Edit metadata sync.** Edit graphs sync bidirectionally as sidecars, one per image,
named deterministically from `oc:fileid`. Each sidecar carries a monotonic revision counter, a
+53 -53
View File
@@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value |
|---|---|
| Source files scanned | 180 |
| TRACES tags found | 516 |
| TRACES tags found | 540 |
| Requirements defined | 177 |
| Requirements covered | 89 |
| **Coverage** | **50.3%** (89/177) |
@@ -33,92 +33,92 @@ _None._
| ID | Tagged in |
|---|---|
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198), [`core/dr-types/src/lib.rs:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:692`](../ui/dr-ui/ui/library.slint#L692), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) |
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111) |
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:692`](../ui/dr-ui/ui/library.slint#L692), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) |
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2081`](../ui/dr-ui/src/library.rs#L2081), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) |
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2933`](../ui/dr-ui/src/library.rs#L2933), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:51`](../core/dr-types/src/lib.rs#L51) |
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1173`](../ui/dr-ui/src/collections_ui.rs#L1173), [`ui/dr-ui/src/collections_ui.rs:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2962`](../ui/dr-ui/src/library.rs#L2962), [`ui/dr-ui/src/library.rs:2994`](../ui/dr-ui/src/library.rs#L2994), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:4154`](../ui/dr-ui/src/library_ui.rs#L4154), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5391`](../ui/dr-ui/src/library_ui.rs#L5391), [`ui/dr-ui/src/library_ui.rs:5402`](../ui/dr-ui/src/library_ui.rs#L5402), [`ui/dr-ui/src/library_ui.rs:5415`](../ui/dr-ui/src/library_ui.rs#L5415), [`ui/dr-ui/src/library_ui.rs:5430`](../ui/dr-ui/src/library_ui.rs#L5430), [`ui/dr-ui/src/library_ui.rs:5439`](../ui/dr-ui/src/library_ui.rs#L5439), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2237`](../ui/dr-ui/src/develop.rs#L2237), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:1585`](../ui/dr-ui/src/lib.rs#L1585), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:768`](../ui/dr-ui/src/lib.rs#L768), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4402`](../ui/dr-ui/src/library_ui.rs#L4402), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:117`](../core/dr-types/src/lib.rs#L117), [`ui/dr-ui/src/develop.rs:1945`](../ui/dr-ui/src/develop.rs#L1945), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3148`](../ui/dr-ui/src/library.rs#L3148), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2548`](../ui/dr-ui/src/library_ui.rs#L2548), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:3011`](../ui/dr-ui/src/library_ui.rs#L3011), [`ui/dr-ui/src/library_ui.rs:3083`](../ui/dr-ui/src/library_ui.rs#L3083), [`ui/dr-ui/src/library_ui.rs:3248`](../ui/dr-ui/src/library_ui.rs#L3248), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4632`](../ui/dr-ui/src/library_ui.rs#L4632), [`ui/dr-ui/src/library_ui.rs:4747`](../ui/dr-ui/src/library_ui.rs#L4747), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library_ui.rs:5533`](../ui/dr-ui/src/library_ui.rs#L5533), [`ui/dr-ui/src/library_ui.rs:5544`](../ui/dr-ui/src/library_ui.rs#L5544), [`ui/dr-ui/src/library_ui.rs:5557`](../ui/dr-ui/src/library_ui.rs#L5557), [`ui/dr-ui/src/library_ui.rs:5572`](../ui/dr-ui/src/library_ui.rs#L5572), [`ui/dr-ui/src/library_ui.rs:5581`](../ui/dr-ui/src/library_ui.rs#L5581), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library_ui.rs:4689`](../ui/dr-ui/src/library_ui.rs#L4689), [`ui/dr-ui/src/library_ui.rs:5673`](../ui/dr-ui/src/library_ui.rs#L5673), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/library.slint:1101`](../ui/dr-ui/ui/library.slint#L1101), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1546`](../ui/dr-ui/src/collections_ui.rs#L1546), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3224`](../ui/dr-ui/src/library_ui.rs#L3224), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:2628`](../ui/dr-ui/src/develop.rs#L2628), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1169`](../ui/dr-ui/src/lib.rs#L1169), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/lib.rs:1614`](../ui/dr-ui/src/lib.rs#L1614), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:796`](../ui/dr-ui/src/lib.rs#L796), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library_ui.rs:4443`](../ui/dr-ui/src/library_ui.rs#L4443), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3177`](../ui/dr-ui/src/library.rs#L3177), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:639`](../ui/dr-ui/src/library.rs#L639), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library_ui.rs:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2552`](../ui/dr-ui/src/library_ui.rs#L2552), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:3015`](../ui/dr-ui/src/library_ui.rs#L3015), [`ui/dr-ui/src/library_ui.rs:3087`](../ui/dr-ui/src/library_ui.rs#L3087), [`ui/dr-ui/src/library_ui.rs:3275`](../ui/dr-ui/src/library_ui.rs#L3275), [`ui/dr-ui/src/library_ui.rs:3393`](../ui/dr-ui/src/library_ui.rs#L3393), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4734`](../ui/dr-ui/src/library_ui.rs#L4734), [`ui/dr-ui/src/library_ui.rs:4849`](../ui/dr-ui/src/library_ui.rs#L4849), [`ui/dr-ui/src/presets.rs:309`](../ui/dr-ui/src/presets.rs#L309), [`ui/dr-ui/src/presets.rs:321`](../ui/dr-ui/src/presets.rs#L321), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373) |
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) |
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) |
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) |
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1298`](../ui/dr-ui/src/develop.rs#L1298), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1418`](../ui/dr-ui/src/develop.rs#L1418), [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2595`](../ui/dr-ui/src/develop.rs#L2595), [`ui/dr-ui/src/develop.rs:2649`](../ui/dr-ui/src/develop.rs#L2649), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1176`](../ui/dr-ui/src/lib.rs#L1176), [`ui/dr-ui/src/lib.rs:1828`](../ui/dr-ui/src/lib.rs#L1828), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1884`](../ui/dr-ui/ui/app.slint#L1884) |
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) |
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) |
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`ui/dr-ui/src/develop.rs:3228`](../ui/dr-ui/src/develop.rs#L3228) |
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:431`](../core/dr-gpu/tests/capture_sharpen.rs#L431), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-gpu/tests/local_contrast.rs:467`](../core/dr-gpu/tests/local_contrast.rs#L467), [`core/dr-gpu/tests/noise_reduction.rs:513`](../core/dr-gpu/tests/noise_reduction.rs#L513), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:702`](../core/dr-decode/src/lib.rs#L702), [`core/dr-decode/src/lib.rs:742`](../core/dr-decode/src/lib.rs#L742), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) |
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:334`](../core/dr-types/src/lib.rs#L334) |
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:1329`](../core/dr-pipeline/src/sidecar.rs#L1329), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2984`](../ui/dr-ui/src/develop.rs#L2984), [`ui/dr-ui/src/develop.rs:3038`](../ui/dr-ui/src/develop.rs#L3038), [`ui/dr-ui/src/develop.rs:3082`](../ui/dr-ui/src/develop.rs#L3082), [`ui/dr-ui/src/develop.rs:3132`](../ui/dr-ui/src/develop.rs#L3132), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/src/lib.rs:1857`](../ui/dr-ui/src/lib.rs#L1857), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/app.slint:888`](../ui/dr-ui/ui/app.slint#L888) |
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) |
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) |
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3711`](../ui/dr-ui/src/develop.rs#L3711) |
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:429`](../core/dr-gpu/tests/capture_sharpen.rs#L429), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:426`](../core/dr-pipeline/src/graph.rs#L426), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1341`](../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1366`](../core/dr-pipeline/src/operation.rs#L1366), [`core/dr-pipeline/src/operation.rs:1381`](../core/dr-pipeline/src/operation.rs#L1381), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:510`](../core/dr-pipeline/src/operation.rs#L510) |
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) |
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2253`](../ui/dr-ui/src/develop.rs#L2253), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/develop.rs:2263`](../ui/dr-ui/src/develop.rs#L2263), [`ui/dr-ui/src/lib.rs:1168`](../ui/dr-ui/src/lib.rs#L1168) |
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:127`](../core/dr-types/src/settings.rs#L127), [`ui/dr-ui/src/develop.rs:2214`](../ui/dr-ui/src/develop.rs#L2214), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1105`](../ui/dr-ui/ui/library.slint#L1105), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:747`](../ui/dr-ui/ui/library.slint#L747), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) |
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2654`](../ui/dr-ui/src/develop.rs#L2654), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1197`](../ui/dr-ui/src/lib.rs#L1197) |
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:128`](../core/dr-types/src/settings.rs#L128), [`ui/dr-ui/src/develop.rs:2594`](../ui/dr-ui/src/develop.rs#L2594), [`ui/dr-ui/src/develop.rs:2615`](../ui/dr-ui/src/develop.rs#L2615), [`ui/dr-ui/src/lib.rs:1169`](../ui/dr-ui/src/lib.rs#L1169), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1118`](../ui/dr-ui/ui/library.slint#L1118), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:747`](../ui/dr-ui/ui/library.slint#L747), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) |
| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) |
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:204`](../core/dr-gpu/tests/capture_sharpen.rs#L204), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-gpu/tests/local_contrast.rs:254`](../core/dr-gpu/tests/local_contrast.rs#L254), [`core/dr-gpu/tests/noise_reduction.rs:365`](../core/dr-gpu/tests/noise_reduction.rs#L365), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:655`](../core/dr-pipeline/src/ops/local_contrast.rs#L655), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:1781`](../ui/dr-ui/src/develop.rs#L1781), [`ui/dr-ui/src/develop.rs:2401`](../ui/dr-ui/src/develop.rs#L2401), [`ui/dr-ui/src/develop.rs:2773`](../ui/dr-ui/src/develop.rs#L2773), [`ui/dr-ui/src/develop.rs:2807`](../ui/dr-ui/src/develop.rs#L2807), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) |
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:367`](../core/dr-pipeline/src/graph.rs#L367), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2792`](../ui/dr-ui/src/develop.rs#L2792), [`ui/dr-ui/src/develop.rs:3256`](../ui/dr-ui/src/develop.rs#L3256), [`ui/dr-ui/src/develop.rs:3290`](../ui/dr-ui/src/develop.rs#L3290), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) |
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1826`](../ui/dr-ui/src/develop.rs#L1826), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3907`](../ui/dr-ui/src/develop.rs#L3907), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1227`](../ui/dr-ui/src/lib.rs#L1227), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:305`](../ui/dr-ui/ui/app.slint#L305), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4358`](../ui/dr-ui/src/develop.rs#L4358), [`ui/dr-ui/src/develop.rs:4390`](../ui/dr-ui/src/develop.rs#L4390), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1256`](../ui/dr-ui/src/lib.rs#L1256), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:540`](../core/dr-types/src/settings.rs#L540), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:357`](../core/dr-pipeline/src/graph.rs#L357), [`core/dr-pipeline/src/graph.rs:410`](../core/dr-pipeline/src/graph.rs#L410), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:541`](../core/dr-types/src/settings.rs#L541), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) |
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:1751`](../ui/dr-ui/src/lib.rs#L1751), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:5373`](../ui/dr-ui/src/library_ui.rs#L5373), [`ui/dr-ui/src/library_ui.rs:5450`](../ui/dr-ui/src/library_ui.rs#L5450), [`ui/dr-ui/src/library_ui.rs:5462`](../ui/dr-ui/src/library_ui.rs#L5462), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1353`](../ui/dr-ui/ui/app.slint#L1353), [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992), [`ui/dr-ui/ui/library.slint:1113`](../ui/dr-ui/ui/library.slint#L1113), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1163`](../core/dr-decode/src/locate.rs#L1163), [`core/dr-decode/src/locate.rs:1221`](../core/dr-decode/src/locate.rs#L1221), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:119`](../core/dr-export/src/encode.rs#L119), [`core/dr-export/src/encode.rs:163`](../core/dr-export/src/encode.rs#L163), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:208`](../core/dr-export/src/encode.rs#L208), [`core/dr-export/src/encode.rs:237`](../core/dr-export/src/encode.rs#L237), [`core/dr-export/src/encode.rs:310`](../core/dr-export/src/encode.rs#L310), [`core/dr-export/src/encode.rs:324`](../core/dr-export/src/encode.rs#L324), [`core/dr-export/src/encode.rs:404`](../core/dr-export/src/encode.rs#L404), [`core/dr-export/src/encode.rs:452`](../core/dr-export/src/encode.rs#L452), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:791`](../core/dr-export/src/encode.rs#L791), [`core/dr-export/src/encode.rs:805`](../core/dr-export/src/encode.rs#L805), [`core/dr-export/src/encode.rs:846`](../core/dr-export/src/encode.rs#L846), [`core/dr-export/src/encode.rs:894`](../core/dr-export/src/encode.rs#L894), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:442`](../core/dr-types/src/lib.rs#L442), [`core/dr-types/src/settings.rs:258`](../core/dr-types/src/settings.rs#L258), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:503`](../core/dr-decode/src/lib.rs#L503), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1904`](../ui/dr-ui/src/develop.rs#L1904), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) |
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:1780`](../ui/dr-ui/src/lib.rs#L1780), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3104`](../ui/dr-ui/src/library_ui.rs#L3104), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5592`](../ui/dr-ui/src/library_ui.rs#L5592), [`ui/dr-ui/src/library_ui.rs:5604`](../ui/dr-ui/src/library_ui.rs#L5604), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1349`](../ui/dr-ui/ui/app.slint#L1349), [`ui/dr-ui/ui/app.slint:979`](../ui/dr-ui/ui/app.slint#L979), [`ui/dr-ui/ui/library.slint:1126`](../ui/dr-ui/ui/library.slint#L1126), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1222`](../core/dr-decode/src/locate.rs#L1222), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:259`](../core/dr-types/src/settings.rs#L259), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3104`](../ui/dr-ui/src/library_ui.rs#L3104), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:4154`](../ui/dr-ui/src/library_ui.rs#L4154), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1511`](../ui/dr-ui/src/lib.rs#L1511), [`ui/dr-ui/src/lib.rs:2251`](../ui/dr-ui/src/lib.rs#L2251), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:4649`](../ui/dr-ui/src/library_ui.rs#L4649), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2359`](../ui/dr-ui/ui/app.slint#L2359), [`ui/dr-ui/ui/app.slint:589`](../ui/dr-ui/ui/app.slint#L589), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:808`](../ui/dr-ui/ui/library.slint#L808) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2965`](../ui/dr-ui/src/collections_ui.rs#L2965), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/lib.rs:1540`](../ui/dr-ui/src/lib.rs#L1540), [`ui/dr-ui/src/lib.rs:2280`](../ui/dr-ui/src/lib.rs#L2280), [`ui/dr-ui/src/library.rs:1283`](../ui/dr-ui/src/library.rs#L1283), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:4751`](../ui/dr-ui/src/library_ui.rs#L4751), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:4769`](../ui/dr-ui/src/library_ui.rs#L4769), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2378`](../ui/dr-ui/ui/app.slint#L2378), [`ui/dr-ui/ui/app.slint:565`](../ui/dr-ui/ui/app.slint#L565), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:808`](../ui/dr-ui/ui/library.slint#L808) |
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:117`](../core/dr-types/src/lib.rs#L117), [`core/dr-types/src/lib.rs:199`](../core/dr-types/src/lib.rs#L199), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316) |
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916) |
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:51`](../core/dr-types/src/lib.rs#L51) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2965`](../ui/dr-ui/src/collections_ui.rs#L2965), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330) |
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944) |
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:839`](../ui/dr-ui/src/library.rs#L839) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:127`](../core/dr-types/src/lib.rs#L127), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198) |
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:503`](../core/dr-decode/src/lib.rs#L503), [`core/dr-decode/src/locate.rs:1364`](../core/dr-decode/src/locate.rs#L1364) |
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:736`](../ui/dr-ui/src/lib.rs#L736), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1365`](../core/dr-decode/src/locate.rs#L1365) |
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175) |
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2333`](../ui/dr-ui/src/lib.rs#L2333), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) |
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:619`](../ui/dr-ui/ui/app.slint#L619), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1900`](../ui/dr-ui/ui/library.slint#L1900), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707) |
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1884`](../ui/dr-ui/ui/app.slint#L1884), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) |
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/library_ui.rs:3953`](../ui/dr-ui/src/library_ui.rs#L3953), [`ui/dr-ui/src/library_ui.rs:4056`](../ui/dr-ui/src/library_ui.rs#L4056), [`ui/dr-ui/src/library_ui.rs:4084`](../ui/dr-ui/src/library_ui.rs#L4084), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:1580`](../ui/dr-ui/ui/app.slint#L1580), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1900`](../ui/dr-ui/ui/library.slint#L1900), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1978`](../ui/dr-ui/src/lib.rs#L1978), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2552`](../ui/dr-ui/src/lib.rs#L2552), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:326`](../ui/dr-ui/ui/app.slint#L326), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2362`](../ui/dr-ui/src/lib.rs#L2362), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) |
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/src/library_ui.rs:5623`](../ui/dr-ui/src/library_ui.rs#L5623), [`ui/dr-ui/ui/app.slint:2107`](../ui/dr-ui/ui/app.slint#L2107), [`ui/dr-ui/ui/app.slint:262`](../ui/dr-ui/ui/app.slint#L262), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:602`](../ui/dr-ui/ui/app.slint#L602), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:2003`](../ui/dr-ui/ui/library.slint#L2003), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/settings.slint:93`](../ui/dr-ui/ui/settings.slint#L93) |
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/ui/app.slint:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) |
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1546`](../ui/dr-ui/src/collections_ui.rs#L1546), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/src/library_ui.rs:3994`](../ui/dr-ui/src/library_ui.rs#L3994), [`ui/dr-ui/src/library_ui.rs:4097`](../ui/dr-ui/src/library_ui.rs#L4097), [`ui/dr-ui/src/library_ui.rs:4125`](../ui/dr-ui/src/library_ui.rs#L4125), [`ui/dr-ui/src/library_ui.rs:4769`](../ui/dr-ui/src/library_ui.rs#L4769), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/ui/app.slint:1576`](../ui/dr-ui/ui/app.slint#L1576), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/app.slint:602`](../ui/dr-ui/ui/app.slint#L602), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:2003`](../ui/dr-ui/ui/library.slint#L2003), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2007`](../ui/dr-ui/src/lib.rs#L2007), [`ui/dr-ui/src/lib.rs:2396`](../ui/dr-ui/src/lib.rs#L2396), [`ui/dr-ui/src/lib.rs:2581`](../ui/dr-ui/src/lib.rs#L2581), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:297`](../ui/dr-ui/ui/app.slint#L297), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) |
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1785`](../ui/dr-ui/src/lib.rs#L1785), [`ui/dr-ui/ui/app.slint:1003`](../ui/dr-ui/ui/app.slint#L1003), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1814`](../ui/dr-ui/src/lib.rs#L1814), [`ui/dr-ui/ui/app.slint:990`](../ui/dr-ui/ui/app.slint#L990), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) |
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906) |
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) |
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528) |
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557) |
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
+46
View File
@@ -0,0 +1,46 @@
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
#
# Builds from the local checkout rather than a release tarball, which is what
# makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom
pkgver=0.5.0
pkgrel=1
pkgdesc="Non-destructive RAW photo library and editor"
arch=('x86_64')
url="https://gitea.tourolle.paris/dtourolle/DarkRoom"
license=('GPL-3.0-or-later')
# Runtime: Vulkan for wgpu, and a Secret Service implementation for the
# Nextcloud credentials (FR-NC-2) — gnome-keyring or kwallet both provide it.
depends=('vulkan-icd-loader' 'fontconfig' 'libxkbcommon')
makedepends=('cargo' 'git')
optdepends=('gnome-keyring: store Nextcloud credentials'
'kwallet: store Nextcloud credentials')
options=('!lto') # the workspace sets its own LTO in Cargo.toml
_repo="$(cd "${startdir}/.." && pwd)"
build() {
cd "${_repo}"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR="${_repo}/target"
cargo build --release --locked -p darkroom-desktop
}
package() {
cd "${_repo}"
install -Dm755 "target/release/darkroom-desktop" \
"${pkgdir}/usr/bin/darkroom-desktop"
install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \
"${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop"
# The icon's *name* is the contract, not its path: the desktop entry says
# `Icon=paris.tourolle.darkroom` and the compositor resolves that through
# the hicolor theme. Installed under 256x256 because that is the source's
# actual size — lying about it makes scaled-down icons look wrong.
install -Dm644 "ui/dr-ui/ui/app-icon.png" \
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png"
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
}
+18
View File
@@ -0,0 +1,18 @@
[Desktop Entry]
Type=Application
Name=DarkRoom
GenericName=Photo Editor
Comment=Non-destructive RAW photo library and editor
# The basename of this file is the contract: a Wayland compositor finds it by
# matching the surface's app_id, which `dr_ui::run` sets to the same string.
# Rename one without the other and the icon silently disappears.
Exec=darkroom-desktop %F
Icon=paris.tourolle.darkroom
Terminal=false
Categories=Graphics;Photography;RasterGraphics;2DGraphics;
Keywords=RAW;photo;photography;develop;darkroom;library;
# X11 falls back to matching the WM_CLASS instead of the app_id; winit sets it
# from the same string, so naming it here covers both display servers.
StartupWMClass=paris.tourolle.darkroom
StartupNotify=true
MimeType=image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-sony-arw;image/x-adobe-dng;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/jpeg;image/png;image/tiff;
+6 -4
View File
@@ -17,7 +17,7 @@
# (see the workspace manifest for why that backend was chosen), and it is what
# makes the tiling option in `semantic.rs` the only route to more resolution.
#
# `imgsz=640` square rather than a rectangle matched to 3:2: one graph has to
# `imgsz` square rather than a rectangle matched to 3:2: one graph has to
# serve portrait, landscape, square crops and panoramas. A landscape-shaped
# graph trades letterbox waste on 3:2 for worse waste on everything else.
set -euo pipefail
@@ -27,21 +27,23 @@ REPO="$(cd "${HERE}/.." && pwd)"
OUT="${REPO}/core/dr-segment/models"
MODEL="${1:-yolo26n-seg}"
IMGSZ="${2:-640}"
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
echo "==> exporting ${MODEL} in ${WORK}"
echo "==> exporting ${MODEL} at imgsz=${IMGSZ} in ${WORK}"
cd "${WORK}"
uv venv --python 3.12 venv
VIRTUAL_ENV="${WORK}/venv" uv pip install ultralytics onnx onnxslim
VIRTUAL_ENV="${WORK}/venv" "${WORK}/venv/bin/python" - "${MODEL}" <<'PY'
VIRTUAL_ENV="${WORK}/venv" "${WORK}/venv/bin/python" - "${MODEL}" "${IMGSZ}" <<'PY'
import sys, json
from ultralytics import YOLO
name = sys.argv[1]
imgsz = int(sys.argv[2])
m = YOLO(f"{name}.pt")
path = m.export(format="onnx", opset=17, simplify=True, imgsz=640, dynamic=False)
path = m.export(format="onnx", opset=17, simplify=True, imgsz=imgsz, dynamic=False)
print("ONNX:", path)
# The class names travel with the model rather than being retyped into Rust —
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Set the project's version in the few places that must state it, from one
# source, and optionally commit the result.
#
# tools/set-version.sh --from-tag # take it from the latest git tag
# tools/set-version.sh 0.5.0 # or state it
# tools/set-version.sh --from-tag --commit
#
# Why this exists: the workspace read 0.1.0 for two releases, so every binary
# reported a version two releases stale, and the APK reported none at all. A
# version that has to be edited in several files by hand is a version that will
# be wrong in at least one of them.
#
# The number of places is kept deliberately small. Only two files state the
# version; everything else derives it:
#
# Cargo.toml the source of truth for every crate in the workspace
# packaging/PKGBUILD pacman needs it before anything is built
#
# AndroidManifest.xml states NO version on purpose — `package.sh` passes
# it to `aapt2 link` from Cargo.toml, so the APK cannot
# drift from the binary inside it.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO}"
COMMIT=0
VERSION=""
for arg in "$@"; do
case "${arg}" in
--commit) COMMIT=1 ;;
--from-tag) VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')" ;;
-h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) echo "error: unknown flag ${arg}" >&2; exit 2 ;;
*) VERSION="${arg#v}" ;;
esac
done
if [[ -z "${VERSION}" ]]; then
echo "error: no version given, and no tag to take one from" >&2
echo "usage: tools/set-version.sh [--from-tag | <version>] [--commit]" >&2
exit 2
fi
# Rejected rather than coerced. A version is a contract with whoever reads a
# bug report, and "0.4" or "v0.4.0-wip" silently becoming something else is
# worse than being told to type it again.
if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "error: '${VERSION}' is not MAJOR.MINOR.PATCH" >&2
exit 2
fi
CURRENT="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)"
echo "==> ${CURRENT:-unknown} -> ${VERSION}"
# 1. The workspace. Every member inherits this through `version.workspace`.
# Anchored to the [workspace.package] table so a dependency's version
# string cannot be hit by accident.
python3 - "${VERSION}" <<'PY'
import re, sys, pathlib
version = sys.argv[1]
p = pathlib.Path("Cargo.toml")
s = p.read_text()
m = re.search(r"(\[workspace\.package\][^\[]*?\nversion = \")([^\"]+)(\")", s, re.S)
if not m:
sys.exit("error: no version under [workspace.package] in Cargo.toml")
p.write_text(s[:m.start(2)] + version + s[m.end(2):])
print(f" Cargo.toml {m.group(2)} -> {version}")
PY
# 2. pacman. `pkgrel` returns to 1: it counts packaging revisions *of one
# version*, so carrying it across a version bump would claim this is the
# second package of a version nobody has packaged yet.
python3 - "${VERSION}" <<'PY'
import re, sys, pathlib
version = sys.argv[1]
p = pathlib.Path("packaging/PKGBUILD")
s = p.read_text()
old = re.search(r"^pkgver=(.*)$", s, re.M)
s = re.sub(r"^pkgver=.*$", f"pkgver={version}", s, count=1, flags=re.M)
s = re.sub(r"^pkgrel=.*$", "pkgrel=1", s, count=1, flags=re.M)
p.write_text(s)
print(f" packaging/PKGBUILD {old.group(1) if old else '?'} -> {version}")
PY
# 3. Cargo.lock. The workspace members appear in it by version, so leaving it
# alone makes `--locked` builds fail — which is exactly what CI uses.
cargo update --workspace --offline >/dev/null 2>&1 \
|| cargo update --workspace >/dev/null 2>&1 \
|| echo " warning: could not refresh Cargo.lock; run 'cargo update --workspace'" >&2
echo " Cargo.lock refreshed"
echo "==> Android takes its version from Cargo.toml at link time; nothing to edit"
if [[ "${COMMIT}" == "1" ]]; then
git add Cargo.toml Cargo.lock packaging/PKGBUILD
if git diff --cached --quiet; then
echo "==> nothing changed; no commit made"
else
git commit -q -m "Say which version this is: ${VERSION}
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state ${VERSION}, so a bug report naming a version names one commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
echo "==> committed: $(git log --oneline -1)"
fi
else
echo "==> not committed (pass --commit to commit)"
fi
+4 -2
View File
@@ -548,8 +548,10 @@ pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return;
};
let (previous_anchor, cursor) =
undo.restore(&mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut());
let (previous_anchor, cursor) = undo.restore(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
);
ctl.previous_anchor.set(previous_anchor);
ctl.set_cursor(cursor);
sync_selection(window, ctl, ids);
+8
View File
@@ -53,6 +53,13 @@ pub struct SyncReport {
pub catalog_uploaded: bool,
pub catalog_merged: bool,
pub collections_gained: usize,
/// Membership rows the merge brought in (FR-CAT-7).
///
/// Counted apart from `collections_gained`, which counts the collections
/// themselves. A sync that files 127 photographs into a collection both
/// devices already had gains *no* collection, and reporting only the
/// former left the sidebar showing no count beside a full collection.
pub members_gained: usize,
}
impl SyncReport {
@@ -334,6 +341,7 @@ async fn sync_catalog(
Ok(merge) => {
report.catalog_merged = true;
report.collections_gained = merge.inserted + merge.updated;
report.members_gained = merge.members_added;
}
Err(e) => log::warn!("merging remote catalog: {e}"),
},
+534 -49
View File
@@ -206,12 +206,275 @@ impl SegmentationJob {
// few places in this codebase where not linearising is correct.
let rgb = rgba
.chunks_exact(4)
.flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0])
.flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect();
Ok((rgb, pw as usize, ph as usize))
}
}
/// Longest edge the refine crop is rendered at.
///
/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the
/// model's own input is still fixed at 640x640, so rendering the crop larger
/// only gets downsampled again inside the model's letterbox. The resolution
/// win is entirely from *what fills the window* — a padded crop around one
/// subject rather than the whole frame — not from feeding the model more
/// pixels than it has ever read.
const REFINE_EDGE: u32 = 640;
/// How much of the box's own size is added on each side before cropping.
///
/// Context for the model to place the subject's edge against, and slack for
/// a box that under-ran the subject slightly on the first pass. Not so much
/// that a second subject standing nearby gets pulled into the same window.
const REFINE_PADDING: f32 = 0.25;
/// TRACES: FR-DEV-3
/// A refine pass lifted out of the session that asked for it.
///
/// The same split as [`SegmentationJob`], for the same reason — see its
/// docs. This one answers for a single already-detected instance rather than
/// the whole frame: it re-runs the model over a padded crop of just that
/// subject's box, so the subject reaches the model at its own size instead
/// of squeezed into the model's fixed window alongside everything else in
/// the photograph.
pub struct RefineJob {
ctx: GpuContext,
source: Arc<DemosaicedImage>,
session: SessionId,
abandon: Abandon,
/// Which instance this answers for. A snapshot of what it needs from the
/// segmentation, taken when the job was built — the same reason
/// `SegmentationJob` carries a proxy render rather than the session.
index: usize,
class_name: Arc<str>,
bbox: (f32, f32, f32, f32),
proxy: (usize, usize),
}
impl RefineJob {
pub fn session(&self) -> SessionId {
self.session
}
pub fn abandon(&self) -> Abandon {
self.abandon.clone()
}
/// TRACES: FR-DEV-3
/// Re-detect this one subject at higher effective resolution. **Blocking.**
///
/// `Ok(None)` covers both "abandoned" and "the model found nothing of the
/// same class in the crop" — a photographer pressing refine on a subject
/// that the padded window no longer contains is a possible outcome, not
/// a bug, and the caller treats it as "kept what was there" either way.
pub fn run(&self) -> Result<Option<RefinedInstance>, String> {
if self.abandon.asked() {
return Ok(None);
}
let (px0, py0, px1, py1) = self.bbox;
let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32);
if pw <= 0.0 || ph <= 0.0 {
return Ok(None);
}
let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0));
let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING);
let x0 = (px0 - padx).max(0.0);
let y0 = (py0 - pady).max(0.0);
let x1 = (px1 + padx).min(pw);
let y1 = (py1 + pady).min(ph);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (cw_px, ch_px) = (x1 - x0, y1 - y0);
let view = CropRect {
x: x0 / pw,
y: y0 / ph,
width: cw_px / pw,
height: ch_px / ph,
};
// Aspect-matched render target, capped against blowing a tiny box up
// absurdly far past what the source ever had to offer.
let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0);
let (tw, th) = (
(cw_px * scale).round().max(1.0) as u32,
(ch_px * scale).round().max(1.0) as u32,
);
if self.abandon.asked() {
return Ok(None);
}
let (rgb, rw, rh) = self.render_view(view, tw, th)?;
if self.abandon.asked() {
return Ok(None);
}
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
let options = dr_segment::SemanticOptions {
tiling: dr_segment::Tiling::Whole,
..dr_segment::SemanticOptions::default()
};
let found = model
.detect(&rgb, rw, rh, &options)
.map_err(|e| e.to_string())?;
// The crop was built around one subject, so the right answer among
// whatever the model found in it is the same class closest to the
// window's centre — not merely the highest score, which a second,
// unrelated instance caught in the padding could win.
let (cx, cy) = (rw as f32 * 0.5, rh as f32 * 0.5);
let best = found
.into_iter()
.filter(|i| i.class_name.as_ref() == self.class_name.as_ref())
.min_by(|a, b| centre_distance(a, cx, cy).total_cmp(&centre_distance(b, cx, cy)));
let Some(instance) = best else {
return Ok(None);
};
// Downsampled back onto the shared proxy grid like every other
// instance's mask is, but built from a sharper source than the
// whole-frame pass ever saw for this subject.
let mask = paste_into_proxy(&instance.mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px));
let bbox = (
x0 + instance.bbox.0 / scale,
y0 + instance.bbox.1 / scale,
x0 + instance.bbox.2 / scale,
y0 + instance.bbox.3 / scale,
);
Ok(Some(RefinedInstance {
index: self.index,
summary: segmentation::InstanceSummary {
class_name: instance.class_name,
score: instance.score,
mask,
bbox,
},
}))
}
/// Render the *unedited* image, showing only `view`, at `(width, height)`.
///
/// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`]
/// uses — a refine pass must read the same kind of pixels the first pass
/// did, or a subject would gain or lose an edge depending on which pass
/// found it. `view` is framing's ephemeral viewport (`Framing::set_view`),
/// the mechanism the on-screen zoom already uses to render a region at
/// more than proxy resolution — not the crop tool's own persisted
/// rectangle, and nothing here touches that.
fn render_view(
&self,
view: CropRect,
width: u32,
height: u32,
) -> Result<(Vec<f32>, usize, usize), String> {
let mut neutral = EditGraph::default_chain();
neutral.framing_mut().set_view(view);
let mut pass = AdjustPass::new(&self.ctx);
pass.render(&self.source, &neutral.compose(), width, height)
.map_err(|e| format!("could not render the refine crop: {e}"))?;
let (rgba, pw, ph) = pass
.export_pixels()
.map_err(|e| format!("could not read the refine crop: {e}"))?;
let rgb = rgba
.chunks_exact(4)
.flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect();
Ok((rgb, pw as usize, ph as usize))
}
}
/// What a finished [`RefineJob`] hands back: a replacement for one instance
/// in the segmentation it was run against.
pub struct RefinedInstance {
index: usize,
summary: segmentation::InstanceSummary,
}
fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 {
let (x0, y0, x1, y1) = instance.bbox;
let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt()
}
/// Sample a crop-local mask back onto its footprint in the shared proxy grid.
///
/// Bilinear, the same as every other resampling in this mask pipeline
/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds
/// it) — correct whether the crop was rendered denser than the proxy (the
/// common case this feature exists for) or coarser than it (a subject large
/// enough that refining it buys little, which still renders a sensible if
/// unremarkable answer rather than a distorted one).
fn paste_into_proxy(
crop_mask: &[f32],
crop_w: usize,
crop_h: usize,
proxy: (usize, usize),
origin_px: (f32, f32),
extent_px: (f32, f32),
) -> Vec<u8> {
let (pw, ph) = proxy;
let mut out = vec![0u8; pw * ph];
let (ew, eh) = extent_px;
if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 {
return out;
}
let (ox, oy) = origin_px;
let px0 = ox.floor().max(0.0) as usize;
let py0 = oy.floor().max(0.0) as usize;
let px1 = ((ox + ew).ceil() as usize).min(pw);
let py1 = ((oy + eh).ceil() as usize).min(ph);
for py in py0..py1 {
let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32;
if gy < 0.0 || gy >= crop_h as f32 {
continue;
}
for px in px0..px1 {
let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32;
if gx < 0.0 || gx >= crop_w as f32 {
continue;
}
let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy);
out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
out
}
fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
let (fx0, fy0) = (x.floor(), y.floor());
let (fx, fy) = (x - fx0, y - fy0);
let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize;
let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize;
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let at = |x: usize, y: usize| mask[y * w + x];
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
top * (1.0 - fy) + bot * fy
}
pub struct DevelopSession {
/// This session's name, for work that outlives the frame it started on.
id: SessionId,
@@ -259,12 +522,17 @@ pub struct DevelopSession {
/// has, so this deliberately does *not* include them: dragging those
/// sliders must not rebuild anything.
subject_key: u64,
/// Which layer the develop panel is editing, if any.
/// Which layers the develop panel is editing, if any.
///
/// This is what lets one panel serve both scopes: with a layer selected,
/// the sliders read and write *its* chain, and the photographer is
/// adjusting a region rather than the frame.
active_mask: Option<String>,
/// This is what lets one panel serve both scopes: with layers selected,
/// the sliders read and write *their* chains, and the photographer is
/// adjusting one or more regions rather than the frame.
///
/// A plain click replaces this outright; a modifier-click toggles one id
/// in or out, so several layers can be shaped by the same slider drag —
/// "make these three subjects a stop darker" is one gesture rather than
/// three. Order is insertion order and nothing reads it, only membership.
active_masks: Vec<String>,
/// Whether to draw the false-coloured region overlay.
show_overlay: bool,
/// Which attribute the panel is filtered to, or all of them.
@@ -350,7 +618,7 @@ impl DevelopSession {
masks: None,
subjects: None,
subject_key: 0,
active_mask: None,
active_masks: Vec::new(),
show_overlay: false,
active_tab: None,
curve_channel: 0,
@@ -430,14 +698,27 @@ impl DevelopSession {
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
}
/// The selection's representative layer, for anything that can only show
/// one answer — which tab is open, what value a slider currently reads.
///
/// The first id selected, not "the" active layer: with more than one
/// selected there is no single truth to show, and the panel has to pick
/// something. Whichever layer this is, [`Self::set_param`] and
/// [`Self::reset_op`] still write to every selected layer, not just this
/// one — a slider shows one number and applies it everywhere selected.
fn active_layer(&self) -> Option<&MaskLayer> {
let id = self.active_mask.as_ref()?;
let id = self.active_masks.first()?;
self.graph.masks().get(id)
}
fn active_layer_mut(&mut self) -> Option<&mut MaskLayer> {
let id = self.active_mask.clone()?;
self.graph.masks_mut().get_mut(&id)
/// Every selected layer, mutably — what a batched slider or reset walks.
fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
let selected = self.active_masks.clone();
self.graph
.masks_mut()
.layers_mut()
.iter_mut()
.filter(move |l| selected.contains(&l.id))
}
}
@@ -962,11 +1243,11 @@ impl DevelopSession {
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
if self.active_mask.is_some() {
if !self.active_masks.is_empty() {
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
let id = cap.id.0;
if let Some(layer) = self.active_layer_mut() {
for (param, default) in params {
for layer in self.active_layers_mut() {
for &(param, default) in &params {
layer.set_param(id, param, default);
}
}
@@ -999,12 +1280,17 @@ impl DevelopSession {
log::warn!("control at ({op_index}, {param_index}) has no parameter");
return;
};
if self.active_mask.is_some() {
if let Some(layer) = self.active_layer_mut() {
if !self.active_masks.is_empty() {
// Every selected layer is set to the same absolute value the
// slider now shows, not offset by however far each one already
// was from it — the slider has one position, and "apply this
// reading to all of them" is the reading a photographer gets
// from watching it move.
for layer in self.active_layers_mut() {
layer.set_param(op.0, param, value);
}
// Coalesced the same way a global drag is: a slider dragged across
// a masked layer is still one gesture and must undo as one.
// masked layers is still one gesture and must undo as one.
let edit = Edit::for_param(&self.graph, op, param);
self.history.record(&self.graph, edit);
return;
@@ -1332,6 +1618,55 @@ impl DevelopSession {
self.subject_key = 0;
}
/// TRACES: FR-DEV-3
/// Everything a refine pass needs for one already-detected instance, so
/// it can be run somewhere else — see [`Self::segmentation_job`] for why
/// the split exists.
///
/// `None` where there is nothing to refine: no segmentation yet, or an
/// index the panel offered a button for a moment ago but the segmentation
/// underneath has since changed.
pub fn refine_job(&self, index: usize) -> Option<RefineJob> {
let seg = self.segmentation.as_ref()?;
let instance = seg.instances().get(index)?;
Some(RefineJob {
ctx: self.ctx.clone(),
source: self.demosaiced.clone(),
session: self.id,
abandon: Abandon::default(),
index,
class_name: instance.class_name.clone(),
bbox: instance.bbox,
proxy: seg.proxy_size(),
})
}
/// Take on a refined instance found elsewhere.
///
/// Same caution as [`Self::adopt_segmentation`]: the caller checks the
/// job's session before handing back its answer, not this. Every mask
/// layer pointing at this instance's index reads it fresh next redraw —
/// `subject_key` is reset because the pixels changed under an index
/// `subject_signature` has no way to know changed, unlike a morphology
/// slider it does track.
pub fn adopt_refined(&mut self, refined: RefinedInstance) {
if let Some(seg) = self.segmentation.as_mut() {
seg.replace_instance(refined.index, refined.summary);
}
self.subjects = None;
self.subject_key = 0;
}
/// The mask layer's original detection index, for the refine button —
/// `None` for a layer that is not a subject at all (a gradient has no
/// instance to re-detect).
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
match &self.graph.masks().get(id)?.source {
MaskSource::Subject { index, .. } => Some(*index as usize),
_ => None,
}
}
/// Find the subjects and take them on, blocking until both are done.
///
/// Test-only, and deliberately: a session-shaped blocking call is exactly
@@ -1450,7 +1785,7 @@ impl DevelopSession {
l.id.clone(),
l.display_name().to_string(),
l.enabled,
Some(&l.id) == self.active_mask.as_ref(),
self.active_masks.iter().any(|a| a == &l.id),
)
})
.collect()
@@ -1458,10 +1793,7 @@ impl DevelopSession {
/// What kind of mask a layer is — "regions", "linear", "radial".
pub fn mask_kind(&self, id: &str) -> &'static str {
self.graph
.masks()
.get(id)
.map_or("", |l| l.source.kind())
self.graph.masks().get(id).map_or("", |l| l.source.kind())
}
pub fn mask_inverted(&self, id: &str) -> bool {
@@ -1485,15 +1817,25 @@ impl DevelopSession {
.is_some_and(|l| l.active_ops().next().is_some())
}
/// The panel's representative selection — see [`Self::active_layer`] for
/// what "representative" means once more than one layer is selected.
pub fn active_mask(&self) -> Option<&str> {
self.active_mask.as_deref()
self.active_masks.first().map(String::as_str)
}
/// Every selected layer's id, in selection order.
pub fn active_masks(&self) -> &[String] {
&self.active_masks
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// The selected gradient's handles, in fractions of the shown image.
///
/// Empty unless a gradient layer is selected, which is what makes this the
/// panel's whole test for "is there anything to draw on the canvas".
/// Empty unless **exactly one** gradient layer is selected. Dragging a
/// shared handle for several gradients at once has no single geometry to
/// move — each one's centre, angle and extent differ — so multi-select
/// simply offers no handles rather than moving one layer's shape while
/// silently leaving the others behind.
///
/// Recomputed on every redraw rather than cached, because the answer
/// changes with the *view* and not only with the mask: a pan moves every
@@ -1501,6 +1843,9 @@ impl DevelopSession {
/// not work worth caching, and a cache keyed on the wrong thing is how a
/// handle comes to sit where the mask used to be.
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
if self.active_masks.len() != 1 {
return Vec::new();
}
let Some(layer) = self.active_layer() else {
return Vec::new();
};
@@ -1522,9 +1867,12 @@ impl DevelopSession {
press: (f32, f32),
now: (f32, f32),
) -> Option<MaskSource> {
if self.active_masks.len() != 1 {
return None;
}
let (sw, sh) = self.demosaiced.size();
let framing = *self.graph.framing();
let id = self.active_mask.clone()?;
let id = self.active_masks.first()?.clone();
let start = match origin {
Some(s) => s.clone(),
None => self.graph.masks().get(&id)?.source.clone(),
@@ -1554,12 +1902,33 @@ impl DevelopSession {
self.active_layer().map(|l| l.source.clone())
}
/// Select a layer for editing, or `None` to return the panel to the
/// global chain.
/// Select exactly one layer for editing, or `None` to return the panel to
/// the global chain. Replaces whatever was selected before, including a
/// multi-selection — the ordinary, unmodified click.
pub fn set_active_mask(&mut self, id: Option<&str>) {
self.active_mask = id
self.active_masks = id
.filter(|id| self.graph.masks().get(id).is_some())
.map(|id| id.to_string());
.map(|id| vec![id.to_string()])
.unwrap_or_default();
}
/// Add or remove one layer from the selection, keeping the rest — the
/// modifier-click that builds a multi-selection.
///
/// A layer id the graph no longer has is dropped rather than toggled in:
/// the row that offered it is stale by the time the click lands, and
/// selecting a ghost would make every subsequent batched edit silently
/// skip it (`active_layers_mut` filters by membership, not existence).
pub fn toggle_active_mask(&mut self, id: &str) {
if self.graph.masks().get(id).is_none() {
return;
}
match self.active_masks.iter().position(|a| a == id) {
Some(i) => {
self.active_masks.remove(i);
}
None => self.active_masks.push(id.to_string()),
}
}
/// Mask the object under a normalised image point.
@@ -1603,7 +1972,7 @@ impl DevelopSession {
if !self.graph.masks_mut().push(layer) {
return None;
}
self.active_mask = Some(id.clone());
self.active_masks = vec![id.clone()];
self.history.record(&self.graph, Edit::Discrete);
Some(id)
}
@@ -1625,19 +1994,21 @@ impl DevelopSession {
width: 0.3,
}
};
if !self.graph.masks_mut().push(MaskLayer::new(id.clone(), source)) {
if !self
.graph
.masks_mut()
.push(MaskLayer::new(id.clone(), source))
{
return None;
}
self.active_mask = Some(id.clone());
self.active_masks = vec![id.clone()];
self.history.record(&self.graph, Edit::Discrete);
Some(id)
}
pub fn remove_mask(&mut self, id: &str) {
if self.graph.masks_mut().remove(id).is_some() {
if self.active_mask.as_deref() == Some(id) {
self.active_mask = None;
}
self.active_masks.retain(|a| a != id);
self.history.record(&self.graph, Edit::Discrete);
}
}
@@ -1660,7 +2031,8 @@ impl DevelopSession {
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.feather = feather.clamp(0.0, 1.0);
self.history.record(&self.graph, Edit::Op(OpId("mask-feather")));
self.history
.record(&self.graph, Edit::Op(OpId("mask-feather")));
}
}
@@ -1694,7 +2066,8 @@ impl DevelopSession {
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.morph_radius = radius.clamp(0.0, 1.0);
self.history.record(&self.graph, Edit::Op(OpId("mask-morph")));
self.history
.record(&self.graph, Edit::Op(OpId("mask-morph")));
}
}
@@ -1702,7 +2075,10 @@ impl DevelopSession {
pub fn mask_falloff(&self, id: &str) -> usize {
use dr_pipeline::mask::Falloff;
self.graph.masks().get(id).map_or(0, |l| {
Falloff::ALL.iter().position(|&f| f == l.falloff).unwrap_or(0)
Falloff::ALL
.iter()
.position(|&f| f == l.falloff)
.unwrap_or(0)
})
}
@@ -1732,7 +2108,10 @@ impl DevelopSession {
pub fn mask_is_shapeable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource;
self.graph.masks().get(id).is_some_and(|l| {
matches!(l.source, MaskSource::Subject { .. } | MaskSource::Regions { .. })
matches!(
l.source,
MaskSource::Subject { .. } | MaskSource::Regions { .. }
)
})
}
@@ -1742,7 +2121,8 @@ impl DevelopSession {
// `Op` rather than `Discrete`: opacity is dragged, and a drag is
// one decision however many values it passes through. `Discrete`
// would put every intermediate position on the undo stack.
self.history.record(&self.graph, Edit::Op(OpId("mask-opacity")));
self.history
.record(&self.graph, Edit::Op(OpId("mask-opacity")));
}
}
@@ -2217,6 +2597,17 @@ impl DevelopSession {
/// Captured at full scope — framing included — because the decision about
/// what travels is made when the preset is *applied*. Copying, then
/// changing one's mind about the crop, must not mean copying again.
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustment stack, for writing this image's edit back.
///
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
/// which travels *between* photographs, and a mask must not — it is drawn
/// against one frame and describes nothing on another. The save path takes
/// both; the paste path takes only the preset.
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
self.graph.masks()
}
pub fn copy_settings(&self) -> Preset {
Preset::capture(&self.graph)
}
@@ -2510,9 +2901,8 @@ mod tests {
fn a_job_carries_the_session_it_was_taken_from() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session =
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
assert_eq!(session.segmentation_job().session(), session.id());
}
@@ -2523,9 +2913,8 @@ mod tests {
fn an_abandoned_job_does_no_work() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session =
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
let job = session.segmentation_job();
job.abandon().now();
@@ -2646,6 +3035,100 @@ mod tests {
);
}
/// TRACES: FR-DEV-3
/// Multi-select: one slider, applied to every selected layer.
///
/// `toggle_active_mask` builds the selection a control-click makes, and
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
/// down that both fan out to every layer in it rather than only the
/// first, which is the whole point of selecting more than one.
#[test]
fn a_slider_moved_with_two_layers_selected_moves_both() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let a = session.add_gradient_mask(true).expect("first gradient");
let b = session.add_gradient_mask(false).expect("second gradient");
// Adding `b` selected it alone — build the multi-selection a
// control-click would, starting from that single-layer state.
session.toggle_active_mask(&a);
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
assert!(
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
"neither layer has been touched yet"
);
let row = session.rows()[0].clone();
session.set_param(row.op_index, row.param_index, row.maximum);
assert!(
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
"one slider, both layers selected, both layers must show the edit"
);
// And a reset walks the same set.
session.reset_op(row.op_index);
assert!(
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
"resetting with both selected must clear both, not just the one \
the panel happens to read values from"
);
}
/// TRACES: FR-DEV-3
/// A refine crop's mask, pasted back onto the proxy grid it replaces,
/// must land exactly where the crop was — not shifted by the origin, not
/// scaled onto the wrong footprint.
#[test]
fn a_refined_mask_pastes_back_at_the_crops_own_position() {
// A crop entirely on (1.0), covering proxy pixels 2..6 in x and
// 2..6 in y of an 8x8 proxy — everywhere inside that box must read
// back as fully covered, everywhere outside as untouched.
let crop = vec![1.0f32; 4 * 4];
let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0));
assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box");
assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box");
assert_eq!(mask[0], 0, "outside the box, untouched");
assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched");
}
#[test]
fn bilinear_sample_averages_its_four_neighbours() {
// Two rows, black then white: the exact midpoint reads as grey.
let mask = [0.0f32, 0.0, 1.0, 1.0];
let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5);
assert!(
(v - 0.5).abs() < 1e-6,
"expected the midpoint grey, got {v}"
);
}
/// A control-click twice — once to add, once to remove — is a no-op on
/// the selection, which is the sanity check for `toggle_active_mask`
/// itself before trusting anything built on it.
#[test]
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let a = session.add_gradient_mask(true).expect("gradient");
assert_eq!(session.active_masks(), [a.clone()].as_slice());
session.toggle_active_mask(&a);
assert!(session.active_masks().is_empty(), "removed by the toggle");
session.toggle_active_mask(&a);
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
}
/// TRACES: FR-DEV-3
/// The mask array and the segmentation proxy are the same size on purpose.
///
@@ -3993,7 +4476,10 @@ mod tests {
let narrowed = session.rows().len();
assert!(narrowed > 0, "a tab must show something");
assert!(narrowed < all, "and less than everything: {narrowed} of {all}");
assert!(
narrowed < all,
"and less than everything: {narrowed} of {all}"
);
}
/// The trap: `op_index` counts over *every* capability, so a row that
@@ -4054,7 +4540,6 @@ mod tests {
session.set_active_tab(99);
assert_eq!(session.rows().len(), all);
}
}
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
+215 -9
View File
@@ -19,6 +19,26 @@
//! user sees "1,847 photographs, 61.2 GB" and then a progress bar, rather than
//! a frozen window and then a progress bar.
//!
//! # There is no local library, so there is no destination to choose
//!
//! DarkRoom's library is a folder on a Nextcloud server (FR-NC-6). An import
//! therefore has exactly one place to go, and the interface has nothing to ask
//! about it.
//!
//! What lands on this machine is a **staging copy**, in a directory the app
//! owns beside the catalog — the same shape `export` uses for its outbox, and
//! for the same reason its module docs give: staging first is the only path,
//! not a fallback for the offline case.
//!
//! The bytes have to touch disk before they touch the network. Streaming a
//! card straight to the server would mean a move-import erasing a card against
//! an in-flight upload, which is the one operation here with no undo, and it
//! would make importing impossible with no connection, which FR-NC-10 forbids.
//!
//! A staged file is removed once the server confirms it. One that is not
//! confirmed stays where it is, and the next import drains it — so an import
//! made on a train finishes when the train arrives somewhere.
//!
//! # The import does not catalogue what it wrote
//!
//! It writes files into the library and then asks for a scan, rather than
@@ -33,7 +53,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot};
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
@@ -56,8 +76,9 @@ pub type Cancel = Arc<AtomicBool>;
pub struct Request {
/// Where the card is mounted.
pub card: PathBuf,
/// The library root the template's folders are created under.
pub library: PathBuf,
/// Where the copies are staged before they go up. Owned by the app, not
/// chosen by the user — see the module docs.
pub staging: PathBuf,
/// The optional second destination (FR-CAT-10).
pub backup: Option<PathBuf>,
/// The catalog, opened by the worker for its duplicate queries.
@@ -68,8 +89,9 @@ pub struct Request {
/// Which file types to take off the card.
pub filter: FormatFilter,
pub options: Options,
/// Where to send the originals afterwards, if anywhere (FR-NC-7b).
pub upload: Option<Upload>,
/// Where the photographs are going. An import without one has nowhere to
/// put anything, and the page refuses to start.
pub upload: Upload,
}
/// TRACES: FR-NC-7a | FR-NC-7b
@@ -89,6 +111,22 @@ pub struct Upload {
/// Where the thumbnail shards live, so a thumbnail made during the import
/// can be filed under the server's `oc:fileid` once the upload assigns one.
pub thumbs: PathBuf,
/// Where copies wait between disk and the server.
///
/// Per account, beside the catalog, like the export outbox: a file queued
/// for one server is meaningless to another, and a staging directory
/// shared between accounts would upload a card to whichever was signed in
/// when the connection came back.
pub staging: PathBuf,
}
/// TRACES: FR-NC-7b
/// Where an account's imports wait between disk and the server.
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
.parent()
.map(|p| p.join("staging"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
}
impl std::fmt::Debug for Upload {
@@ -98,6 +136,7 @@ impl std::fmt::Debug for Upload {
.field("user_id", &self.user_id)
.field("library", &self.library)
.field("thumbs", &self.thumbs)
.field("staging", &self.staging)
.finish_non_exhaustive()
}
}
@@ -166,6 +205,18 @@ pub struct Outcome {
/// and the card has not been touched. Reported so the user knows the
/// server does not have them yet.
pub upload_failed: usize,
/// Card files actually deleted, for a move-import.
///
/// Never more than `retirable`, and zero for a copy-import. Reported
/// because "the card is now empty" is a claim the user needs to be able to
/// check against what they expected.
pub retired: usize,
/// Staged copies still waiting to go up.
///
/// The same files as `upload_failed`, named for what happens next rather
/// than for what went wrong: they are queued, and the next import sends
/// them. An import made with no connection is this and nothing else.
pub staged: usize,
}
/// Start an import.
@@ -188,7 +239,16 @@ pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> {
fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), String> {
let card = LocalStorage::with_root(CARD, request.card.clone());
let library = LocalStorage::with_root(LIBRARY, request.library.clone());
// Created rather than assumed: the first import on a machine finds no
// staging directory, and a failure here is worth reporting as itself
// rather than as every file failing to be written.
if let Err(e) = std::fs::create_dir_all(&request.staging) {
return Err(format!(
"could not prepare {}: {e}",
request.staging.display()
));
}
let library = LocalStorage::with_root(LIBRARY, request.staging.clone());
let backup = request
.backup
.as_ref()
@@ -242,7 +302,8 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), St
// FR-NC-7b. Everything above has already finished: every file is on disk
// and its digest checked, so nothing from here on can cost the user a
// photograph — only a transfer.
if let Some(upload) = &request.upload {
{
let upload = &request.upload;
let (sent, thumbnails) = upload_all(upload, &library, &report.imported, cancel, tx);
outcome.thumbnails = thumbnails;
outcome.uploaded = sent.iter().filter(|s| s.transferred).count();
@@ -264,6 +325,36 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), St
// now. The metadata tier covers the interval, which is why this is not
// worth waiting for a sync to do properly.
record_digests(catalog.connection(), &upload.library, &sent);
// The staging copy has done its job the moment the server confirms it:
// the photograph is in the library, and keeping a second copy in a
// directory no view ever shows would be a slow disk leak. What is *not*
// confirmed stays exactly where it is — that is the queue FR-NC-7b
// describes, and the next import drains it.
outcome.staged = report.imported.len() - sent.len();
clear_staged(&library, &report.imported, &sent);
// TRACES: FR-CAT-10 | FR-NC-7b
// The card, last of all, and only for what the server has.
//
// The rule this enforces: a copy-import never touches the card, and a
// move-import empties it only of photographs that are confirmed on the
// server — not merely written to disk here, because the staging copy
// above has just been removed and this machine is no longer holding
// them either. Anything the upload did not take keeps its card copy,
// which is then the only copy that exists.
//
// Done here rather than handed to the caller: `dr_ingest::retire` is
// deliberately awkward to reach, and a caller that forgot to call it
// was exactly the bug this replaces — Move behaved as Copy, silently.
if request.options.mode == TransferMode::Move {
let confirmed = confirmed_sources(&report.imported, &sent);
outcome.retirable = confirmed.len();
let failures = dr_ingest::retire(&card, &confirmed);
outcome.retired = confirmed.len() - failures.len();
} else {
outcome.retirable = 0;
}
}
let _ = tx.send(Message::Finished(outcome));
@@ -478,6 +569,43 @@ async fn file_thumbnails(
filed
}
/// The card sources whose photographs the server now holds.
///
/// Paired positionally with `imported`, which is how `upload_all` builds
/// `sent`: one entry per original it got an answer about, in order. A file the
/// upload skipped or failed on has no entry, and so is never returned here.
fn confirmed_sources(imported: &[Imported], sent: &[Sent]) -> Vec<dr_types::SourceRef> {
imported
.iter()
.zip(sent.iter())
.map(|(image, _)| image.source.clone())
.collect()
}
/// TRACES: FR-NC-7b
/// Remove the staging copies the server has confirmed.
///
/// Keyed on what came back from the upload rather than on what was imported:
/// a file the server did not take must survive, because the staging directory
/// is the only place it exists once the card is put away.
///
/// Failures are logged, not propagated. A staged file that cannot be deleted
/// costs disk; refusing to finish an import over it would cost the user the
/// report telling them their photographs are safe.
fn clear_staged(library: &LocalStorage, imported: &[Imported], sent: &[Sent]) {
let confirmed: std::collections::HashSet<&str> =
sent.iter().map(|s| s.remote_path.as_str()).collect();
for (image, item) in imported.iter().zip(sent.iter()) {
if !confirmed.contains(item.remote_path.as_str()) {
continue;
}
if let Err(e) = library.remove_file(&image.written) {
log::warn!("could not clear the staged copy of {}: {e}", image.name);
}
}
}
/// Read an imported file back out of the library.
fn read_all(library: &LocalStorage, image: &Imported) -> Result<Vec<u8>, String> {
use std::io::Read;
@@ -640,6 +768,8 @@ pub fn summarise(report: &Report) -> Outcome {
uploaded: 0,
already_on_server: 0,
upload_failed: 0,
staged: 0,
retired: 0,
thumbnails: 0,
}
}
@@ -698,12 +828,18 @@ pub fn describe(outcome: &Outcome) -> String {
}
if outcome.upload_failed > 0 {
out.push_str(&format!(
", {} still only on this computer",
", {} waiting to go up — the next import sends them",
outcome.upload_failed
));
}
}
// The card. Said plainly, because emptying one is irreversible and the
// user should not have to infer it from a count of uploads.
if outcome.retired > 0 {
out.push_str(&format!(". {} removed from the card", outcome.retired));
}
// Worth saying: it is why the grid fills in immediately after an import
// rather than fetching a preview out of every original that just went up.
if outcome.thumbnails > 0 {
@@ -938,7 +1074,77 @@ mod tests {
};
let text = describe(&o);
assert!(text.contains("2 uploaded"), "{text}");
assert!(text.contains("1 still only on this computer"), "{text}");
// Named for what happens next rather than for what went wrong: the
// file is queued, not lost, and the next import sends it.
assert!(text.contains("1 waiting to go up"), "{text}");
}
#[test]
fn emptying_the_card_is_stated_plainly() {
// Irreversible, so the user should not have to infer it from a count
// of uploads.
let o = Outcome {
uploaded: 3,
retirable: 3,
retired: 3,
..outcome()
};
assert!(
describe(&o).contains("3 removed from the card"),
"{}",
describe(&o)
);
}
#[test]
fn a_copy_import_never_mentions_the_card() {
let o = Outcome {
uploaded: 3,
..outcome()
};
assert!(!describe(&o).contains("card"), "{}", describe(&o));
}
#[test]
fn only_confirmed_photographs_are_taken_off_the_card() {
// The rule: a card is emptied of what the *server* has, never of what
// merely reached this disk — the staging copy is removed straight
// after, so anything not uploaded would otherwise exist nowhere.
let imported: Vec<Imported> = (0..3)
.map(|i| Imported {
source: dr_types::SourceRef::Local {
root: CARD,
relative: format!("IMG_{i}.CR3"),
},
written: dr_types::SourceRef::Local {
root: LIBRARY,
relative: format!("2026/2026-08-22/IMG_{i}.CR3"),
},
folders: vec!["2026".into(), "2026-08-22".into()],
name: format!("IMG_{i}.CR3"),
digest: "x".into(),
size: 10,
dated_from: DateSource::Capture,
backup: None,
})
.collect();
// The server took the first two; the third never went up.
let sent: Vec<Sent> = (0..2)
.map(|i| Sent {
remote_path: format!("PhotosRaw/2026/2026-08-22/IMG_{i}.CR3"),
digest: "x".into(),
transferred: true,
thumbnail: None,
})
.collect();
let confirmed = confirmed_sources(&imported, &sent);
assert_eq!(confirmed.len(), 2);
assert_eq!(confirmed[0].key(), "IMG_0.CR3");
assert_eq!(confirmed[1].key(), "IMG_1.CR3");
// IMG_2 keeps its card copy, which is now the only copy of it.
assert!(!confirmed.iter().any(|s| s.key() == "IMG_2.CR3"));
}
#[test]
+44 -31
View File
@@ -188,7 +188,9 @@ impl ImportController {
fn can_start(&self) -> bool {
!self.running.get()
&& !self.card.borrow().is_empty()
&& !self.options().destination.is_empty()
// An account, because the library is on the server and an import
// with none has nowhere to go at all.
&& !self.upload_target.borrow().is_empty()
&& self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false)
}
}
@@ -252,17 +254,9 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
.into(),
);
window.set_import_destination(stored.destination.as_str().into());
// Empty where no account is signed in, or where the user turned the upload
// off — the page then shows one destination because there is one.
window.set_import_upload_target(
if stored.upload {
ctl.upload_target.borrow().clone()
} else {
String::new()
}
.into(),
);
// The only destination there is. Empty means no account, which is what
// keeps Import disabled — see `can_start`.
window.set_import_upload_target(ctl.upload_target.borrow().as_str().into());
window.set_import_folder_template(stored.folder_template.as_str().into());
window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into());
@@ -333,13 +327,6 @@ where
// does: another instance may have written it since.
*ctl.settings.settings.borrow_mut() = ctl.settings.store.load();
// A destination the user has never chosen defaults to somewhere
// that exists, rather than to an empty field they must decode.
if ctl.options().destination.is_empty() {
if let Some(home) = default_destination() {
ctl.edit(|s| s.destination = home.display().to_string());
}
}
*ctl.upload_target.borrow_mut() =
context().map(|c| c.library_label).unwrap_or_default();
ctl.refresh_volumes();
@@ -505,16 +492,6 @@ where
}
}
/// Somewhere sensible for a first import to go.
fn default_destination() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let pictures = home.join("Pictures");
// Only if it exists: inventing a folder the user has not asked for, in a
// field they may not read, is how photographs end up somewhere nobody
// looks. An empty field keeps Import disabled until they choose.
pictures.exists().then_some(pictures)
}
/// Count what is on the card, without copying anything.
///
/// Runs on the UI thread, which is defensible only because it is a directory
@@ -570,14 +547,21 @@ fn start(
Some(PathBuf::from(stored.backup.trim()))
};
let Some(upload) = context.upload else {
*ctl.error.borrow_mut() =
"Sign in first — the library this imports into is on the server.".into();
render(window, ctl);
return;
};
let request = Request {
card: PathBuf::from(ctl.card.borrow().clone()),
library: PathBuf::from(&stored.destination),
staging: upload.staging.clone(),
backup,
catalog: context.catalog.clone(),
filter: context.filter.clone(),
options: ctl.ingest_options(),
upload: stored.upload.then_some(context.upload).flatten(),
upload,
};
let cancel: import::Cancel = Default::default();
@@ -745,6 +729,35 @@ mod tests {
assert_eq!(preview(&s, AUG_22), "2026/EOS R5");
}
#[test]
fn an_import_needs_an_account_before_it_can_start() {
// The library is on the server, so "where does this go" has no local
// answer to fall back on. Import stays disabled rather than quietly
// filling a folder nothing ever shows.
let ctl = ImportController::new(
crate::settings_ui::SettingsController::new(),
crate::activity::ActivityLog::new(),
);
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
*ctl.survey.borrow_mut() = Some((12, 1234));
assert!(!ctl.can_start(), "no account, yet Import was offered");
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
assert!(ctl.can_start());
}
#[test]
fn an_empty_card_cannot_be_imported_either() {
let ctl = ImportController::new(
crate::settings_ui::SettingsController::new(),
crate::activity::ActivityLog::new(),
);
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
*ctl.survey.borrow_mut() = Some((0, 0));
assert!(!ctl.can_start());
}
#[test]
fn a_volume_says_why_it_is_being_offered() {
// "EOS DIGITAL" and "archive" look equally plausible in a list, and
+29
View File
@@ -733,6 +733,30 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let entries = Rc::new(RefCell::new(collect(&paths)));
log::info!("{} image(s) to browse", entries.borrow().len());
// TRACES: FR-PLAT-LIN-1
// The name the compositor knows this window by, and the reason the
// launcher shows a real icon rather than a grey square.
//
// `app.slint` sets `icon:`, and that is genuinely embedded (see
// `build.rs`) — but a Wayland compositor ignores a client-set icon
// entirely. It takes the icon from the `.desktop` file whose basename
// matches the surface's `app_id`, and nothing else. So the embedded icon
// is what X11 and the window itself use, and *this* is what GNOME's
// overview, dash and alt-tab use. Both are needed and neither substitutes
// for the other.
//
// The string must equal the installed `.desktop` file's basename exactly:
// `packaging/paris.tourolle.darkroom.desktop`. It matches the Android
// package id (`AndroidManifest.xml`) on purpose — one application, one
// reverse-DNS name on both platforms.
//
// Before the window, necessarily: it is read when the surface is created.
if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") {
// Not fatal. On X11 and on Android this does nothing useful, and a
// missing app id costs an icon rather than a working application.
log::debug!("could not set the xdg app id: {e}");
}
// Before the window, and it has to be: this selects the Slint backend, and
// creating a window selects one for us. See `shared_gpu`. The device is
// shared by demosaic, the adjust pass and the compositor; without one the
@@ -740,6 +764,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let gpu = shared_gpu();
let window = AppWindow::new()?;
// The version the About page shows. Taken from the crate rather than
// passed in, so it is the version of the code that is running and cannot
// be set to something else by a caller.
window.set_app_version(env!("CARGO_PKG_VERSION").into());
match &gpu {
Some(ctx) => {
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
@@ -953,6 +981,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// pushes, so a thumbnail made during an import is the
// one every other client gets.
thumbs: library::thumbs_dir(&session.server, &session.user_id),
staging: import::staging_dir(&session.server, &session.user_id),
}),
})
},
+30 -1
View File
@@ -375,6 +375,23 @@ pub enum Amendment {
Settings {
preset: dr_pipeline::Preset,
scope: dr_pipeline::Scope,
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustments, when this is an image's own edit being
/// written back rather than a paste onto someone else's.
///
/// A `Preset` is a parameter map, and a mask is not a parameter — it
/// is a rule about *where*, with a chain of its own. So a save that
/// carried only the preset wrote the sliders and silently dropped
/// every local adjustment: the sidecar format has stored masks since
/// they were added and `Version::apply` restores them, but nothing
/// ever put any there. The mask survived until the session ended and
/// then did not exist.
///
/// `None` for a paste, which must not carry the source image's masks
/// onto the target: a mask is drawn against one photograph and means
/// nothing on another, and `Scope` cannot express that because it
/// filters parameters.
masks: Option<dr_pipeline::mask::MaskStack>,
},
}
@@ -591,8 +608,20 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
version.rating = *rating;
version.flag = *flag;
}
Amendment::Settings { preset, scope } => {
Amendment::Settings {
preset,
scope,
masks,
} => {
preset.amend(&mut version.params, *scope);
// Replaced wholesale rather than merged: this is the whole of the
// image's local adjustment stack as it stands, so a layer the user
// deleted has to leave the sidecar too. Cross-device merging of
// two stacks is `Sidecar::merge`'s job and happens on sync, not
// here (FR-NC-9).
if let Some(masks) = masks {
version.masks = masks.clone();
}
}
}
+230 -20
View File
@@ -2008,7 +2008,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
period_heading: heading.into(),
// A freshly loaded window has no drag in flight.
lifted: false,
name: c.name.as_str().into(),
name: without_extension(&c.name).into(),
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
has_thumb: carried.is_some_and(|h| h.has_thumb),
unavailable: carried.is_some_and(|h| h.unavailable),
@@ -2475,6 +2475,10 @@ fn collect_settings_writes(
amendment: library::Amendment::Settings {
preset: preset.clone(),
scope,
// A paste carries no masks, and must not: a mask is drawn
// against one photograph and describes nothing on another.
// The target keeps whatever local adjustments it already had.
masks: None,
},
})
});
@@ -3211,9 +3215,32 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
// Adopted thumbnails and merged collections both change
// what the grid should show.
if report.thumbnails_adopted > 0 || report.collections_gained > 0 {
if report.thumbnails_adopted > 0
|| report.collections_gained > 0
|| report.members_gained > 0
{
load_window(&w, &ctl_cb);
}
// TRACES: FR-CAT-7
// And the sidebar, which the grid reload does not
// touch. Membership counts as a change: a sync that
// files 127 photographs into a collection both devices
// already had gains no *collection*, so keying this on
// `collections_gained` alone left the tree showing no
// number beside a collection that had just been filled.
//
// The scan already does this after it finishes; the
// sync merges the same tables and did not.
if report.collections_gained > 0 || report.members_gained > 0 {
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
if let Some(coll) = coll {
let borrow = ctl_cb.catalog();
let borrow = borrow.borrow();
if let Some(cat) = borrow.as_ref() {
crate::collections_ui::refresh_tree(&w, &coll, cat);
}
}
}
stop(&ctl_cb.sync_timer);
return;
}
@@ -3515,6 +3542,20 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
}
};
// A chosen date range is what the axis draws, when there is one.
//
// It used to keep drawing the full extent while a range was on, because
// the axis was the only way back out: narrow it to the range and there
// would be nothing left to widen from. The range now has typed ends, and
// those are the way back out — so the axis is free to show the span the
// user actually asked about, at the granularity that span deserves. A
// fortnight drawn across fifteen years is one column; drawn across itself
// it is fourteen days.
let span = match (filter.captured_from, filter.captured_to) {
(Some(from), Some(to)) if to > from => (from, to),
_ => span,
};
// Zoom narrows the span around wherever the view sits rather than around
// the library's midpoint, so zooming in keeps what you were looking at.
let zoom = *ctl.timeline_zoom.borrow();
@@ -4599,32 +4640,93 @@ pub fn wire<F>(
let ctl = ctl.clone();
window.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.filter.borrow().has_date_range();
// Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.get_library_range_active();
if on {
// The span the axis is currently drawn over, which is what the
// user is looking at when they ask for "this range".
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
// Seeded from the span the axis is drawn over, which is what
// the user is looking at when they ask for "this range".
//
// Only a seed. If there is no catalog yet, or nothing in it
// carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
};
let Some(span) = catalog_span(catalog, &ctl) else {
return;
};
let (from, to) = zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
if let Some((from, to)) = seeded {
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
} else {
let mut f = ctl.filter.borrow_mut();
f.captured_from = None;
f.captured_to = None;
}
w.set_library_range_active(on);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6
// The ends of the range, typed.
//
// The chip alone used to be the whole control and it read its span from
// the timeline's zoom, which is zero until someone zooms — so "limit to
// range" set the range to the entire library and appeared to do nothing.
// The span is shown now, and these two fields are how it is corrected.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.set_library_range_invalid(true);
return;
};
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// Inclusive of the last day. "To the 5th" means the whole of
// the 5th — `parse_date` returns its midnight, and a range
// ending there would exclude every photograph taken on the day
// the user named.
f.captured_to = Some(to + 86_400 - 1);
}
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
@@ -5087,6 +5189,46 @@ mod tests {
assert_eq!(y, 1969);
}
/// The axis follows a chosen range, so its granularity does too.
///
/// The complaint this fixes: a fortnight selected out of a fifteen-year
/// library was still drawn as one column of a year-per-bar axis, which is
/// the least useful picture of a fortnight available.
#[test]
fn a_chosen_range_decides_the_granularity() {
use dr_catalog::Granularity;
const DAY: i64 = 86_400;
// Fifteen years unfiltered: a bar per year, fifteen of them.
assert_eq!(Granularity::for_span(15 * 365 * DAY), Granularity::Year);
// Narrowed to a fortnight, the axis is handed the range rather than
// the extent, and a fortnight is drawn as fourteen days.
assert_eq!(Granularity::for_span(14 * DAY), Granularity::Day);
// A single day's shoot is drawn by the hour.
assert_eq!(Granularity::for_span(DAY), Granularity::Hour);
// The property rather than the thresholds, because the thresholds are
// no longer what decides it: a narrower range never produces a coarser
// bucket than a wider one.
let mut previous = i64::MAX;
for days in [3650, 730, 180, 60, 14, 3, 1] {
let g = Granularity::for_span(days * DAY);
let bucket = match g {
Granularity::Year => 365 * DAY,
Granularity::Month => 30 * DAY,
Granularity::Day => DAY,
Granularity::Hour => 3600,
};
assert!(
bucket <= previous,
"{days} days chose a coarser bucket than the range above it"
);
previous = bucket;
}
}
#[test]
fn bucket_labels_match_their_granularity() {
use dr_catalog::Granularity;
@@ -5477,3 +5619,71 @@ mod tests {
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
}
}
/// TRACES: FR-UI-2
/// A filename as a caption: without the part that says how it is stored.
///
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
/// something the photographer already knows — every frame in a RAW library
/// ends the same way, so the extension distinguishes nothing while taking
/// room from the part that does. The name is elided under pressure, and it is
/// the *end* that goes, so an extension can push the digits that identify a
/// frame off the visible part of its own label.
///
/// Display only. `LibraryCell::name` keeps the true filename and
/// `remote_path` the full path, because both are used to find the file again
/// and a stem is not a filename.
///
/// **The case this is wrong for**, worth knowing before it is reported: a
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
/// photograph anyway — but the caption alone no longer separates them.
fn without_extension(name: &str) -> &str {
match name.rsplit_once('.') {
// The guard is for a leading dot: `.hidden` splits to an empty stem,
// and a dotfile's name starts with that dot rather than ending with an
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
// which is the better caption anyway.
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
}
}
#[cfg(test)]
mod display_name_tests {
use super::without_extension;
#[test]
fn an_extension_is_dropped_and_nothing_else_is() {
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
// Only the last one: a name can contain dots and they are part of it.
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
// Nothing to drop.
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
// A dotfile is not an extensionless name with an extension.
assert_eq!(without_extension(".hidden"), ".hidden");
// A trailing dot is an empty extension, and dropping it is right.
assert_eq!(without_extension("odd."), "odd");
assert_eq!(without_extension(""), "");
}
}
/// TRACES: FR-CAT-6
/// Show the range the filter is actually using.
///
/// Pushed back rather than left as the user typed it, because the filter may
/// have adjusted it — ends given backwards are swapped, and the closing day is
/// extended to include itself. A field showing something other than what is
/// being filtered on is worse than one showing nothing.
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
let f = ctl.filter.borrow();
let text = |t: Option<i64>| -> slint::SharedString {
t.map(dr_types::format_date).unwrap_or_default().into()
};
window.set_library_range_from(text(f.captured_from));
// The stored end is the last second of the closing day; naming that day is
// what the user typed and what they should read back.
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
}
+135 -20
View File
@@ -28,7 +28,7 @@ use std::rc::Rc;
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::{Abandon, DevelopSession, Segmented, SessionId};
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
use crate::segmentation;
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
@@ -48,17 +48,18 @@ pub(crate) const GLOBAL_SCOPE: &str = "ADJUST";
/// string the row in the stack above shows — one name for one thing, so the
/// selected row and the panel it scopes cannot appear to disagree.
pub(crate) fn scope_label(session: &DevelopSession) -> String {
let Some(id) = session.active_mask() else {
return GLOBAL_SCOPE.to_string();
};
session
.mask_layers()
.into_iter()
.find(|(layer_id, ..)| layer_id == id)
.map_or_else(
|| GLOBAL_SCOPE.to_string(),
|(_, label, ..)| label.to_uppercase(),
)
match session.active_masks() {
[] => GLOBAL_SCOPE.to_string(),
[id] => session
.mask_layers()
.into_iter()
.find(|(layer_id, ..)| layer_id == id)
.map_or_else(
|| GLOBAL_SCOPE.to_string(),
|(_, label, ..)| label.to_uppercase(),
),
many => format!("{} LAYERS", many.len()),
}
}
/// How often the window looks to see whether the model has finished.
@@ -309,7 +310,7 @@ pub(crate) fn wire(
let redraw = redraw.clone();
let rows = rows.clone();
let running = running.clone();
window.on_segment_image(move || {
window.on_segment_image(move |fine| {
let Some(w) = weak.upgrade() else { return };
// Both the photograph and the device come out of the session, so
// no session is nothing to look at and nothing to look with.
@@ -329,7 +330,13 @@ pub(crate) fn wire(
w.set_segmenting(true);
let (tx, rx) = std::sync::mpsc::channel();
let options = segmentation::Options::default();
// The only thing the button decides. Everything else about the
// run is the same, which is what makes the second pass a genuine
// re-run of the first rather than a different feature.
let options = segmentation::Options {
fine,
..segmentation::Options::default()
};
std::thread::spawn(move || {
// A failed send means the window stopped waiting — the user
// moved on, or the app is closing. Neither is worth reporting:
@@ -341,6 +348,49 @@ pub(crate) fn wire(
});
}
// --- refining one subject's mask ---------------------------------------
//
// Its own `Running` slot rather than sharing the segmentation one: the
// two answer different questions (the whole frame's subjects versus one
// already-found instance) and there is no reason a refine in flight
// should block a fresh "Find subjects", or the reverse.
let refining: Rc<RefCell<Running>> = Rc::default();
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
let running = refining.clone();
window.on_mask_refined(move |id| {
let Some(w) = weak.upgrade() else { return };
let Some(index) = session
.borrow()
.as_ref()
.and_then(|s| s.subject_instance_index(&id))
else {
return;
};
let Some(job) = session.borrow().as_ref().and_then(|s| s.refine_job(index)) else {
return;
};
let session_id = job.session();
let claimed = running.borrow_mut().start(session_id, job.abandon());
if !claimed {
return;
}
w.set_refining(true);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(job.run());
});
watch_refine(&w, session_id, rx, &session, &rows, &redraw, &running);
});
}
// --- dragging a gradient on the photograph ----------------------------
//
// The geometry the gesture started from, held for its duration.
@@ -429,15 +479,23 @@ pub(crate) fn wire(
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
window.on_mask_selected(move |id| {
window.on_mask_selected(move |id, extend| {
let Some(w) = weak.upgrade() else { return };
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
// Clicking the selected layer again deselects it, which is how
// the panel gets back to the whole photograph without a
// separate "edit globally" control.
if s.active_mask() == Some(id.as_str()) {
if extend {
// Control/command-click: add or remove this one layer,
// keeping whatever else was already selected.
s.toggle_active_mask(&id);
} else if s.active_masks() == [id.to_string()] {
// Clicking the sole selected layer again deselects it,
// which is how the panel gets back to the whole
// photograph without a separate "edit globally" control.
// Only when it is the *only* one selected — a plain click
// on one row of a multi-selection collapses to just that
// row rather than clearing everything, which is the more
// useful reading of "I clicked a specific layer".
s.set_active_mask(None);
} else {
s.set_active_mask(Some(&id));
@@ -666,6 +724,63 @@ fn watch(
running.borrow_mut().poll = Some(timer);
}
/// [`watch`]'s counterpart for a refine pass — same shape, same reason: the
/// window polls rather than being called back from the worker, and a
/// photograph the user has left discards its answer instead of applying it.
fn watch_refine(
window: &AppWindow,
id: SessionId,
rx: std::sync::mpsc::Receiver<Result<Option<RefinedInstance>, String>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
running: &Rc<RefCell<Running>>,
) {
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let slot = running.clone();
let timer = Rc::new(slint::Timer::default());
let stop = Rc::downgrade(&timer);
timer.start(slint::TimerMode::Repeated, POLL, move || {
let done = || {
if let Some(t) = stop.upgrade() {
t.stop();
}
};
if delivery(id, open_session(&session)) == Delivery::Discard {
slot.borrow_mut().finish(id);
done();
return;
}
let Ok(answer) = rx.try_recv() else { return };
slot.borrow_mut().finish(id);
done();
let Some(w) = weak.upgrade() else { return };
match answer {
Ok(Some(refined)) => {
if let Some(s) = session.borrow_mut().as_mut() {
s.adopt_refined(refined);
}
}
// Abandoned, or the model found nothing of the same class in the
// padded crop — both leave the instance exactly as it was.
Ok(None) => {}
Err(e) => log::warn!("refine failed: {e}"),
}
w.set_refining(false);
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
running.borrow_mut().poll = Some(timer);
}
/// Clear the panel when the open image changes.
///
/// Its own function rather than a call to [`sync`] with an empty session,
@@ -677,6 +792,7 @@ fn watch(
pub(crate) fn reset(window: &AppWindow) {
window.set_overlay_on(false);
window.set_segmenting(false);
window.set_refining(false);
window.set_segmented(false);
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
@@ -844,7 +960,6 @@ mod tests {
);
}
/// The ordinary case: the answer comes back to the photograph that asked.
#[test]
fn a_result_for_the_open_photograph_is_applied() {
+98 -12
View File
@@ -177,7 +177,16 @@ pub fn load_local(image: &Path) -> Option<Sidecar> {
/// Read first for the same reason the remote writer does: the file may already
/// hold a rating, or an operation this build does not know about, and writing
/// a fresh document containing only the current edit would delete both.
pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), String> {
/// `masks` carries the local adjustments when this is the image's own edit,
/// and is `None` for a paste, which must leave the target's masks alone — a
/// mask is drawn against one photograph and describes nothing on another. See
/// `library::Amendment::Settings` for the same distinction on the remote path.
pub fn save_local(
image: &Path,
preset: &Preset,
scope: Scope,
masks: Option<&dr_pipeline::mask::MaskStack>,
) -> Result<(), String> {
let path = local_sidecar_path(image);
// Distinguish "no sidecar yet" from "a sidecar this build cannot read".
@@ -217,6 +226,11 @@ pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), Str
});
preset.amend(&mut version.params, scope);
// Wholesale, not merged: this is the stack as it stands, so a layer the
// user deleted must leave the file too.
if let Some(masks) = masks {
version.masks = masks.clone();
}
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
sidecar.put(version);
@@ -252,7 +266,13 @@ pub fn save_open_edit(
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
// Both taken in one borrow: they are one edit, and a mask stack captured
// from a session that had already moved on would be a different image's.
let Some((preset, masks)) = session
.borrow()
.as_ref()
.map(|s| (s.copy_settings(), s.masks().clone()))
else {
return;
};
@@ -265,7 +285,7 @@ pub fn save_open_edit(
// `Everything`: this is the image's *own* edit being written back,
// not a paste onto someone else's. Excluding framing here would
// make a crop the one adjustment that never survived a restart.
if let Err(e) = save_local(path, &preset, Scope::Everything) {
if let Err(e) = save_local(path, &preset, Scope::Everything, Some(&masks)) {
log::warn!("saving {}: {e}", path.display());
}
}
@@ -277,6 +297,11 @@ pub fn save_open_edit(
amendment: library::Amendment::Settings {
preset,
scope: Scope::Everything,
// The image's own edit, so the whole of it: `Everything`
// already says framing travels, and the local adjustments
// have to travel for the same reason. Without this the
// sliders were written and every mask was dropped.
masks: Some(masks),
},
};
library_ui::start_sidecar_writes(window, library, vec![write]);
@@ -318,8 +343,8 @@ fn refresh_thumbnail_for(
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
let worth_it = window.get_can_undo()
|| session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
let worth_it =
window.get_can_undo() || session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
if !worth_it {
return;
}
@@ -521,6 +546,66 @@ mod tests {
g
}
/// The bug this exists to prevent: a local adjustment that survived until
/// the session ended and then did not exist.
///
/// The sidecar format has stored masks since they were added, and
/// `Version::apply` restores them — but the save path wrote a `Preset`,
/// which is a parameter map, and a mask is not a parameter. So the sliders
/// came back and every mask was silently gone. Nothing reported it,
/// because nothing had failed.
#[test]
fn a_mask_survives_being_written_and_read_back() {
use dr_pipeline::mask::{MaskLayer, MaskSource};
let dir = tempdir("mask-roundtrip");
let image = dir.join("a.CR2");
let mut graph = edited();
let mut layer = MaskLayer::new(
"m1",
MaskSource::Radial {
centre: (0.4, 0.6),
radii: (0.2, 0.3),
angle: 0.0,
feather: 0.5,
},
);
layer.opacity = 0.75;
graph.masks_mut().push(layer);
assert_eq!(graph.masks().len(), 1, "the premise: the edit has a mask");
save_local(
&image,
&Preset::capture(&graph),
Scope::Everything,
Some(graph.masks()),
)
.unwrap();
// Read it back the way opening the photograph again does.
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
let sidecar = Sidecar::parse(&text).unwrap();
let version = sidecar.default_version().expect("a default version");
let mut reopened = EditGraph::default_chain();
version.apply(&mut reopened);
assert_eq!(
reopened.masks().len(),
1,
"the mask must come back with the photograph"
);
let back = &reopened.masks().layers()[0];
assert_eq!(back.id, "m1");
assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings");
assert!(
matches!(back.source, MaskSource::Radial { .. }),
"and its kind: {:?}",
back.source.kind()
);
}
#[test]
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
// Replaced, not appended, so a RAW and the JPEG beside it share one
@@ -537,7 +622,7 @@ mod tests {
let dir = tempdir("round-trip");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let sidecar = load_local(&image).expect("a sidecar was written");
let mut restored = EditGraph::default_chain();
@@ -563,8 +648,8 @@ mod tests {
let dir = tempdir("one-version");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
}
@@ -576,13 +661,13 @@ mod tests {
let dir = tempdir("revision");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let first = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let second = load_local(&image)
.unwrap()
.default_version()
@@ -610,7 +695,7 @@ mod tests {
});
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let back = load_local(&image).unwrap();
let v = back.default_version().unwrap();
@@ -627,7 +712,7 @@ mod tests {
let image = dir.join("a.CR2");
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything).is_err());
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).is_err());
assert_eq!(
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
"drsc 99\n",
@@ -642,6 +727,7 @@ mod tests {
&dir.join("a.CR2"),
&Preset::capture(&edited()),
Scope::Everything,
None,
)
.unwrap();
+73 -5
View File
@@ -49,6 +49,12 @@ pub struct InstanceSummary {
/// This is the *source* a distance field is built from, not the mask
/// itself — `dr_segment::Shaped` turns it into one.
pub mask: Vec<u8>,
/// `(x0, y0, x1, y1)` in [`Segmentation::proxy_size`] pixels.
///
/// Carried through from `dr_segment::Instance` rather than re-derived
/// from the mask, so a refine pass knows what region to crop without
/// scanning a proxy-sized buffer for its own extent.
pub bbox: (f32, f32, f32, f32),
}
/// One image's recognised objects, ready to mask.
@@ -83,6 +89,18 @@ impl Segmentation {
self.instances.get(index).map(|i| i.mask.as_slice())
}
/// Replace one instance in place, keeping every other index and the
/// signature unchanged.
///
/// What a refine pass calls once it has a sharper mask for the subject at
/// `index`: the layers pointing at this run by index still mean what they
/// meant, they just read better pixels now.
pub fn replace_instance(&mut self, index: usize, instance: InstanceSummary) {
if let Some(slot) = self.instances.get_mut(index) {
*slot = instance;
}
}
/// The strongest instance covering a point in normalised image
/// coordinates.
///
@@ -197,11 +215,29 @@ pub struct Options {
/// costs a subject that cannot be selected at all, which is the worse
/// failure for a selection tool.
pub confidence: f32,
/// TRACES: FR-DEV-3
/// Run the model over overlapping tiles instead of the whole frame once.
///
/// Off by default and deliberately so. The graph's input is fixed at
/// 640x640 (docs/segmentation.md F6), so every image is letterboxed into
/// it and a subject 200px across in a 1600px proxy reaches the model at
/// 80px — which is where a coarse outline comes from. Tiling is the only
/// route to more resolution with a fixed window, and it costs one
/// inference per tile: about 2.8s for a 3x2 grid against 470ms whole-frame.
///
/// Six times the wait is the wrong default for the common case, where the
/// subject is large in frame and whole-frame inference is already the best
/// answer. It is the right answer for a bird against sky, so it is offered
/// per-image rather than chosen once for all of them.
pub fine: bool,
}
impl Default for Options {
fn default() -> Self {
Self { confidence: 0.30 }
Self {
confidence: 0.30,
fine: false,
}
}
}
@@ -218,7 +254,7 @@ pub fn compute(
height: usize,
options: &Options,
) -> Result<Segmentation, String> {
let found = detect(rgb, width, height)?;
let found = detect(rgb, width, height, options.fine)?;
let instances: Vec<InstanceSummary> = found
.iter()
@@ -227,6 +263,7 @@ pub fn compute(
class_name: i.class_name.clone(),
score: i.score,
mask: quantise(&i.mask),
bbox: i.bbox,
})
.collect();
@@ -234,7 +271,19 @@ pub fn compute(
width as u32,
height as u32,
instances.len() as u32,
options.confidence.to_bits() as u64,
// The tiling choice belongs in the signature as much as the
// confidence does. A mask stores the signature of the segmentation its
// region ids index into (`MaskSource::Regions`), and a tiled run finds
// different instances in a different order — so if the two runs shared
// a signature, a layer built against the coarse pass would be silently
// reinterpreted against the fine one. That is a *wrong* mask, which is
// far worse than a stale one, because nothing announces it.
options.confidence.to_bits() as u64
^ if options.fine {
0x9E37_79B9_7F4A_7C15
} else {
0
},
);
Ok(Segmentation {
@@ -249,10 +298,27 @@ pub fn compute(
/// Loading is ~24 ms against the ~470 ms of inference that follows, and this
/// runs once per image — so caching the session would keep 11 MB of weights
/// resident for the life of the app to save five percent of a background task.
fn detect(rgb: &[f32], width: usize, height: usize) -> Result<Vec<dr_segment::Instance>, String> {
fn detect(
rgb: &[f32],
width: usize,
height: usize,
fine: bool,
) -> Result<Vec<dr_segment::Instance>, String> {
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
let options = dr_segment::SemanticOptions {
// A quarter shared with each neighbour. It has to exceed zero at all,
// or a subject sitting on a seam is cut in half by both tiles and
// recognised by neither; a quarter is enough to carry a whole subject
// inside one tile at the sizes tiling is reached for.
tiling: if fine {
dr_segment::Tiling::Grid { overlap: 0.25 }
} else {
dr_segment::Tiling::Whole
},
..dr_segment::SemanticOptions::default()
};
model
.detect(rgb, width, height, &dr_segment::SemanticOptions::default())
.detect(rgb, width, height, &options)
.map_err(|e| e.to_string())
}
@@ -291,11 +357,13 @@ mod tests {
class_name: "bus".into(),
score: 0.5,
mask: big,
bbox: (0.0, 0.0, 6.0, h as f32),
},
InstanceSummary {
class_name: "person".into(),
score: 0.9,
mask: small,
bbox: (4.0, 0.0, 8.0, h as f32),
},
],
signature: 1,
+77 -58
View File
@@ -104,64 +104,31 @@ component StatusBar inherits Rectangle {
Rectangle { horizontal-stretch: 1; }
// --- instrumentation, and only where there is room for it ---------
// --- instrumentation lives in Settings now ------------------------
//
// The render backend, the layout class and the frame rate are all
// The render backend, the layout class and the frame rate are
// developer readouts. They cost about 200 logical pixels of a strip
// that also carries the only way out of develop, the undo pair, the
// panel toggle and the export button — and a `HorizontalLayout` given
// less width than its children's minimums does not shrink them, it
// runs off the end.
//
// A tablet in portrait is 768 logical pixels, so that is exactly what
// happened: everything from the panel toggle rightwards was pushed off
// the right-hand edge. The develop column is *also* closed by default
// below the breakpoint, so the toggle being unreachable meant the
// column could not be opened at all — and with it went copy and paste,
// which live in it. Reported as "I have no idea how to copy a setting
// on Android and apply it to other images", and the answer was that
// there was no way to.
// A tablet in portrait is 768 logical pixels and was already below the
// breakpoint that hid them, so it kept its header. Landscape is 1200
// and is not, so the readouts came back and the header ran off the
// right-hand edge there instead — a breakpoint that hides a problem at
// one size and not another is a workaround, not a fix.
//
// Collapsed to zero width rather than wrapped in an `if`: this strip
// is inside the layout that `expanded` feeds, and a conditional child
// here is the shape that has already caused binding loops in this file
// (see the panel below). A `visible: false` child still takes its slot
// in a layout, so the width has to go as well.
Rectangle {
width: root.expanded ? diagnostics.preferred-width : 0px;
visible: root.expanded;
clip: true;
diagnostics := HorizontalLayout {
spacing: Theme.gap;
// The backend is an identifying label, not a state — it says
// which path is in use, and it says the same thing whether
// that path is fast or slow. The accent it used to carry made
// every frame look like an alert; `fps` below is the thing
// here that can go wrong.
Label {
text: root.backend;
vertical-alignment: center;
}
Caption {
text: root.layout-class;
vertical-alignment: center;
}
// Degraded performance, so this is a caution rather than an
// active state: the frame rate has fallen below what the
// compositing path is supposed to sustain, which is exactly
// the assumption A1 exists to test. `warn` is the one hue left
// in the palette and this earns it.
Caption {
text: root.fps + " fps";
warn: root.fps < 55;
vertical-alignment: center;
}
}
}
// The backend, the frame rate and the layout class used to live here.
// They are diagnostics — read once when something looks wrong, and
// never again — and as permanent furniture in a 44px strip they cost
// three slots that the controls beside them actually needed. On a
// tablet in landscape the header ran off the right-hand edge, and this
// was a third of the reason.
//
// They are in Settings under ABOUT now, next to the version, which is
// where someone goes when they have a bug to report rather than a
// photograph to edit.
// Undo and redo, in the strip rather than in the develop column: the
// panel can be put away, and the one control that takes back a
@@ -292,6 +259,10 @@ export component AppWindow inherits Window {
in property <string> adapter: "detecting…";
in property <string> backend: "—";
in property <int> fps: 0;
/// TRACES: FR-UI-2
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
/// the binary it is part of.
in property <string> app-version: "unknown";
// Current image, for the status strip and empty state.
in property <string> filename: "";
@@ -484,6 +455,11 @@ export component AppWindow inherits Window {
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
in property <bool> library-range-invalid: false;
callback library-range-edited(string, string);
callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) ---
@@ -781,7 +757,6 @@ export component AppWindow inherits Window {
in property <bool> import-card-looks-right: false;
in property <bool> import-surveying: false;
in property <string> import-survey-summary: "";
in property <string> import-destination: "";
in property <string> import-upload-target: "";
in property <string> import-folder-template: "";
in property <string> import-template-preview: "";
@@ -910,6 +885,16 @@ export component AppWindow inherits Window {
/// opaque map hides the thing being judged.
in property <float> overlay-strength: 0.55;
in property <bool> overlay-on: false;
/// TRACES: FR-DEV-3
/// The photographer's switch for the same overlay, kept separate from
/// `overlay-on` because the two answer different questions: `overlay-on`
/// is "is there a mask to draw", which Rust decides, and this is "do I
/// want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// Which part of the source-space overlay the view is showing, in overlay
/// pixels. Without it the overlay stays frame-sized while the photograph
/// moves under it.
@@ -939,6 +924,7 @@ export component AppWindow inherits Window {
in property <[SubjectRow]> subject-rows;
in property <bool> segmented: false;
in property <bool> segmenting: false;
in property <bool> refining: false;
/// The selected layer's name, already resolved, or "ADJUST" when the
/// controls are global. Names the adjust panel's heading.
in property <string> adjust-scope: "ADJUST";
@@ -958,12 +944,13 @@ export component AppWindow inherits Window {
/// rather than as one per frame of the gesture.
callback gradient-handle-released();
callback segment-image();
callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it.
callback region-picked(float, float, bool);
callback mask-selected(string);
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
@@ -1099,7 +1086,6 @@ in property <bool> panel-visible: true;
card-looks-right: root.import-card-looks-right;
surveying: root.import-surveying;
survey-summary: root.import-survey-summary;
destination: root.import-destination;
upload-target: root.import-upload-target;
folder-template: root.import-folder-template;
template-preview: root.import-template-preview;
@@ -1135,6 +1121,12 @@ in property <bool> panel-visible: true;
width: 100%;
height: 100%;
adapter: root.adapter;
backend: root.backend;
fps: root.fps;
layout-class: root.layout-class;
app-version: root.app-version;
original-budget: root.settings-original-budget;
original-unlimited: root.settings-original-unlimited;
thumbnail-budget: root.settings-thumbnail-budget;
@@ -1329,6 +1321,10 @@ in property <bool> panel-visible: true;
toggle-local-only() => { root.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
range-from: root.library-range-from;
range-to: root.library-range-to;
range-invalid: root.library-range-invalid;
range-edited(a, b) => { root.library-range-edited(a, b); }
collections-visible: root.collections-visible;
toggle-collections() => { root.toggle-collections(); }
@@ -1521,7 +1517,7 @@ in property <bool> panel-visible: true;
// it is a diagnostic and not an edit: it must not reach
// the histogram, an export, or the texture the develop
// pass hands the compositor.
if root.overlay-on && root.total > 0: Image {
if root.overlay-on && !root.overlay-hidden && root.total > 0: Image {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -2108,7 +2104,24 @@ in property <bool> panel-visible: true;
// width, which the layout then influences. Slint flags it, and
// it can panic at runtime.
Rectangle {
width: root.panel-visible ? 280px : 0px;
// TRACES: FR-UI-2
// 280px was chosen for a tablet, where the column is a
// large fraction of the screen and every pixel of it is
// taken from the photograph. On a desktop window it is
// simply too narrow: the mode strip alone is two modes,
// a separator, "All", and one chip per attribute the
// operation set declares, which is more words than 280px
// holds — so it scrolled sideways, and a control you have
// to pan to reach is one you do not know is there.
//
// Driven off `layout-class` rather than `root.width`
// because reading the window width inside the layout that
// sets it is the binding loop the comment on `expanded`
// above describes. Rust already classifies the window; this
// just spends the extra width where there is some.
width: root.panel-visible
? (root.layout-class == "expanded" ? 380px : 280px)
: 0px;
visible: root.panel-visible;
background: Theme.surface;
clip: true;
@@ -2257,10 +2270,16 @@ in property <bool> panel-visible: true;
subjects: root.subject-rows;
segmented: root.segmented;
segmenting: root.segmenting;
refining: root.refining;
// Two-way: the panel is where it is toggled and
// the canvas is what obeys it, so neither owns
// the value alone.
overlay-hidden <=> root.overlay-hidden;
segment => { root.segment-image(); }
mask-selected(id) => { root.mask-selected(id); }
segment(fine) => { root.segment-image(fine); }
mask-selected(id, extend) => { root.mask-selected(id, extend); }
mask-removed(id) => { root.mask-removed(id); }
mask-refined(id) => { root.mask-refined(id); }
mask-toggled(id, on) => { root.mask-toggled(id, on); }
mask-invert-toggled(id, on) => {
root.mask-invert-toggled(id, on);
+18 -21
View File
@@ -85,11 +85,11 @@ export component ImportPage inherits Rectangle {
in property <string> survey-summary;
// --- where they are going --------------------------------------------
/// The library root. Shown, not editable — see the header comment.
in property <string> destination;
/// The folder on the server the originals go on to, once they are safely
/// on disk (FR-NC-7b). Empty when no account is signed in, which hides the
/// line rather than showing an empty one.
/// The library folder on the server — the only destination there is.
///
/// Empty means no account is signed in, and Import stays disabled: the
/// library lives on the server, so an import without one has nowhere to
/// go at all.
in property <string> upload-target;
in-out property <string> folder-template;
/// The template expanded against a real date, so the answer to "what will
@@ -272,27 +272,24 @@ export component ImportPage inherits Rectangle {
spacing: Theme.gap;
Label { text: "Library"; }
Value {
text: root.destination;
text: root.upload-target != "" ? root.upload-target
: "not signed in";
overflow: elide;
horizontal-stretch: 1;
}
}
if root.upload-target != "": HorizontalLayout {
spacing: Theme.gap;
Label { text: "Server"; }
Value {
text: root.upload-target;
overflow: elide;
horizontal-stretch: 1;
}
}
// Said once, here, rather than beside the Import button:
// the order is the promise, and a user watching a slow
// upload needs to already know the photographs are on disk.
// The order is the promise, and a user watching a slow
// upload needs to already know the photographs are safe.
// Said here, beside the destination, rather than beside the
// Import button where it would read as a warning.
if root.upload-target != "": Caption {
text: "Copied here and verified first, then uploaded.";
text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import.";
wrap: word-wrap;
}
if root.upload-target == "": Caption {
text: "Sign in first — the library this imports into is on the server.";
wrap: word-wrap;
}
@@ -333,7 +330,7 @@ export component ImportPage inherits Rectangle {
// The one operation in this application with no undo,
// and the only honest place to say so is beside the
// control that arms it (FR-NC-7b).
text: "The card is emptied only after every file is verified in the library — and, on a library that syncs, after it has reached the server.";
text: "The card is emptied only of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is.";
wrap: word-wrap;
}
+105 -2
View File
@@ -1098,6 +1098,19 @@ export component LibraryGrid inherits Rectangle {
/// Whether the grid is narrowed to the timeline's visible span.
in property <bool> range-active: false;
callback toggle-date-range();
/// TRACES: FR-CAT-6
/// The ends of the range, as `YYYY-MM-DD`, when one is set.
///
/// Strings rather than instants because this is what the user types and
/// what they read back. Rust parses them and refuses what is not a date;
/// `range-invalid` is how that refusal reaches the field, since a filter
/// that silently ignored a typo would show an empty grid and no reason.
in property <string> range-from;
in property <string> range-to;
in property <bool> range-invalid: false;
/// Both ends at once: they are one range, and applying half of an edit
/// would filter to a span the user never asked for.
callback range-edited(string, string);
/// How many images are selected, for the header's count.
in property <int> selected-count: 0;
@@ -1349,11 +1362,25 @@ export component LibraryGrid inherits Rectangle {
// thought it could draw. Seven action buttons and four readouts do
// not fit across a tablet even in landscape, which is why the last
// one was clipped by the screen edge.
//
// Sized to `min-width`, not `preferred-width`: the latter is the
// row's *untruncated* footprint — every readout at its natural
// text width — so the Flickable was always at least that wide and
// the row never actually shrank, it just scrolled, with the
// sidebar toggle and the "More" disclosure off past the right
// edge on anything narrower than a tablet. `min-width` is what
// the row needs once its `elide` + `horizontal-stretch: 1`
// readouts (image count, selection count, scan status, the
// window label below) are collapsed to an ellipsis, so a window
// with room for that shrunk row now gets the row shrunk to fit
// instead of scrolled — and the toggle and disclosure, which
// don't shrink, stay on screen. Only a window too narrow even for
// the shrunk row still scrolls.
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, header-row.preferred-width);
viewport-width: max(self.width, header-row.min-width);
header-row := HorizontalLayout {
width: parent.viewport-width;
@@ -1381,7 +1408,18 @@ export component LibraryGrid inherits Rectangle {
overflow: elide;
}
Caption { text: root.total > 0 ? root.total + " images" : ""; }
// `elide` + `horizontal-stretch: 1`, like `window-label` below:
// stretch is what actually lets a `Text` shrink under pressure
// in Slint, not `elide` alone (see the comment on the header
// row's Flickable) — without it these three readouts kept
// their full natural width and were what pushed the sidebar
// toggle and the "More" button past the right edge on a
// narrow window, reachable only by knowing to flick-scroll.
Caption {
text: root.total > 0 ? root.total + " images" : "";
overflow: elide;
horizontal-stretch: 1;
}
// The selection count, and how to act on it. Both appear only
// when something is selected — an empty selection has nothing
@@ -1395,6 +1433,8 @@ export component LibraryGrid inherits Rectangle {
modified: true;
compact: true;
font-weight: 600;
overflow: elide;
horizontal-stretch: 1;
}
Label {
@@ -1410,6 +1450,8 @@ export component LibraryGrid inherits Rectangle {
: (root.sweeping
? "indexing " + root.sweep-done + " / " + root.sweep-total
: root.scan-status);
overflow: elide;
horizontal-stretch: 1;
}
// Where in the library the visible window sits. A scrubbable
@@ -1725,6 +1767,67 @@ export component LibraryGrid inherits Rectangle {
}
}
// --- the date range's ends ---------------------------------------
//
// Its own strip, a sibling of the chips rather than a child of them.
//
// Twice now this control has looked broken. First it narrowed to the
// whole library, because it took its span from a timeline zoom that
// is zero until someone zooms. Then the fields that fixed that went
// into the chip row — which is 34px tall and scrolls sideways, so
// they were both clipped and off past the right-hand edge.
//
// A `Rectangle` stacks its children at the origin rather than laying
// them out, which is why putting a second row inside the chips' one
// drew it over them instead of under them. This is a row of the
// header's `VerticalLayout`, so it gets a line of its own and the
// width of the window.
if root.range-active: Rectangle {
height: 40px;
background: Theme.surface;
HorizontalLayout {
width: 100%;
height: 100%;
// The same leading inset the chips above use, so the two rows
// start on one vertical line rather than a few pixels apart.
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
// Fixed-width fields and a two-letter word: left-aligned, so
// they sit under the chip that turned them on instead of
// spreading across the window.
alignment: start;
Field {
width: 108px;
y: (parent.height - self.height) / 2;
text: root.range-from;
placeholder: "YYYY-MM-DD";
accepted(t) => { root.range-edited(t, root.range-to); }
}
Caption {
text: "to";
vertical-alignment: center;
}
Field {
width: 108px;
y: (parent.height - self.height) / 2;
text: root.range-to;
placeholder: "YYYY-MM-DD";
accepted(t) => { root.range-edited(root.range-from, t); }
}
if root.range-invalid: Caption {
text: "not a date";
warn: true;
vertical-alignment: center;
}
}
}
// --- progress -----------------------------------------------------
//
// The bar that used to sit here is now the shell's, drawn across the
+78 -6
View File
@@ -73,8 +73,13 @@ export struct SubjectRow {
component MaskEntry inherits Rectangle {
in property <MaskRow> data;
in property <bool> enabled: true;
/// Whether a refine pass is running (for any row — only one runs at a
/// time), so this row's button can say so and refuse a second press.
in property <bool> refining: false;
callback selected();
/// `true` when the click should add this row to the selection rather
/// than replace it with just this row.
callback selected(bool);
callback removed();
callback toggled(bool);
callback invert-toggled(bool);
@@ -83,6 +88,7 @@ component MaskEntry inherits Rectangle {
callback falloff-picked(int);
callback morphology-picked(int);
callback morph-radius-changed(float);
callback refined();
height: layout.preferred-height;
background: root.data.selected ? Theme.surface-raised : transparent;
@@ -91,7 +97,15 @@ component MaskEntry inherits Rectangle {
touch := TouchArea {
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.selected(); }
// `clicked` carries no modifiers, so a plain click and a
// multi-select click need the pointer event itself: released, over
// the row, with control or command held is "add to the selection"
// rather than "replace it".
pointer-event(event) => {
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
root.selected(event.modifiers.control || event.modifiers.meta);
}
}
}
layout := VerticalLayout {
@@ -190,6 +204,15 @@ component MaskEntry inherits Rectangle {
}
}
// Only a subject has a detection behind it worth re-running. A
// gradient's edge is geometry the photographer placed, and there
// is nothing for the model to find twice.
if root.data.kind == "subject": Button {
text: root.refining ? "Refining…" : "Refine mask";
enabled: !root.refining;
clicked => { root.refined(); }
}
// The edge. All of it reads one signed distance field, so these
// are free to move — none of them recomputes anything except a
// compound morphology.
@@ -242,12 +265,23 @@ export component MaskPanel inherits Rectangle {
in property <bool> segmented: false;
/// One is being computed now.
in property <bool> segmenting: false;
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
in-out property <bool> overlay-hidden: false;
callback segment();
/// `true` asks for the slower, tiled pass.
callback segment(bool);
callback mask-selected(string);
/// `true` when the click should add the layer to the selection rather
/// than replace it.
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
@@ -299,7 +333,7 @@ export component MaskPanel inherits Rectangle {
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
primary: true;
clicked => { root.segment(); }
clicked => { root.segment(false); }
}
// No "Overlay" button and no "Select" button. Both switched on things
@@ -312,6 +346,42 @@ export component MaskPanel inherits Rectangle {
wrap: word-wrap;
}
// Hiding the overlay is not the "Overlay" button this panel used to
// have, and the distinction is the reason it is back. That one *armed*
// the overlay: local mode could be entered and still show nothing,
// which is the fault the comment below records. This one only takes an
// overlay that is already there out of the way for a moment, which is
// the one thing a photographer needs constantly and had no way to do —
// a mask is judged against the photograph under it, and you cannot see
// that photograph through the thing describing it.
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
//
// The model's input is a fixed 640x640 square and every frame is
// letterboxed into it, so a subject that is small in the frame reaches
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
// and an outline traced at 80px is what a coarse mask is. Tiling runs
// the model over overlapping windows instead, so that bird arrives at
// its own size.
//
// It costs one inference per tile: about 2.8s for a 3x2 grid against
// 470ms for the whole frame. That is the wrong trade for the usual
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
}
// Granularity, labelled by what it does rather than by its number:
// "detail" is what a photographer is choosing between, where "300
// regions" is an implementation detail they would have to learn.
@@ -386,8 +456,10 @@ export component MaskPanel inherits Rectangle {
for mask in root.masks: MaskEntry {
data: mask;
enabled: root.enabled;
selected => { root.mask-selected(mask.id); }
refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(mask.id); }
toggled(on) => { root.mask-toggled(mask.id, on); }
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
+73
View File
@@ -90,6 +90,20 @@ export component SettingsPage inherits Rectangle {
/// What the cache currently holds. Empty hides the line.
in property <string> cache-usage;
/// TRACES: FR-UI-2
/// What is drawing, and how fast. These used to sit in the window's top
/// strip, where they were three items of permanent furniture answering a
/// question almost nobody asks twice — and on a tablet in landscape they
/// were part of why the header ran off the screen.
///
/// They are diagnostics, so they belong where a person goes to look
/// something up, not where they are looked at all day.
in property <string> adapter;
in property <string> backend;
in property <int> fps;
in property <string> layout-class;
in property <string> app-version;
callback original-budget-changed(string);
callback original-unlimited-toggled(bool);
callback thumbnail-budget-changed(string);
@@ -551,6 +565,65 @@ export component SettingsPage inherits Rectangle {
}
}
// --- about and diagnostics -------------------------------
Rectangle {
width: content.column;
height: about-panel.preferred-height;
about-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "ABOUT"; }
// The version first, because it is the one line anyone
// is ever asked to quote. A bug report that names a
// version names a commit.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Version"; }
Value { text: root.app-version; horizontal-stretch: 1; overflow: elide; }
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Graphics"; }
Value {
text: root.adapter + " (" + root.backend + ")";
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Frame rate"; }
// Still earns the warning hue here. It is the
// number that says whether the zero-copy path is
// holding up, which is the assumption the whole
// display design rests on.
// `Caption` rather than `Value` here alone: it is
// the one that carries `warn`, and the frame rate
// is the one reading on this page that can be bad
// news rather than merely a fact.
Caption { text: root.fps + " fps"; warn: root.fps < 55; horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Layout"; }
Value { text: root.layout-class; horizontal-stretch: 1; }
}
Caption {
text: "Graphics and frame rate describe this session, "
+ "not a setting — they are here so a bug report "
+ "can quote them.";
wrap: word-wrap;
}
}
}
// --- naming and destination ------------------------------
//
// Its own panel rather than more of the export one: format and