From 95847e3a31dfc4f9aeb5f7ff97354efb0227c802 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:18:20 +0200 Subject: [PATCH 01/10] Say which channels v1 ships through, and that the Flatpak cannot reach a library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NFR-COMPAT-2 asks for the v1 channels to be stated, and says why in its own second sentence: the channel decision and the storage design are coupled. There was nowhere that statement lived. packaging/ held a PKGBUILD and a desktop entry, which is a recipe rather than a decision, and the coupling the requirement points at was therefore invisible. docs/distribution.md states five channels and, more usefully, which two of them exist only as promises. It also records what every channel has to get right independently of format — the one identifier that appears in four places, the metainfo, Vulkan being a requirement rather than a preference while NFR-R8 is open, a secrets daemon being optional rather than required, and the LFS pointer check that stops a package shipping 130 bytes where an 11 MB model should be. §4 is the part worth reading. Preparing a Flatpak is what surfaced that FR-PLAT-LIN-3 is not satisfied and cannot be satisfied by packaging alone: a folder library is chosen by typing an absolute path into an EndpointOnly field that checks it with std::fs, and nothing in the tree calls the FileChooser portal. Inside a sandbox that path does not exist, so the launch screen refuses it. Import fails one step earlier, because a sandboxed process reads its own mount namespace and a card mounted on the host is not in it. That is written down rather than fixed with --filesystem=host, and the argument for not fixing it that way is §2: the Arch package and an AppImage both hand the application the same unrestricted process the developer runs it in, so Flatpak is the only Linux channel that tests whether a design assumed unrestricted access. Granting the permission removes the only reason to ship it. The reverse coupling on Android is recorded too. NFR-COMPAT-2 says Play distribution is what makes ARCH §6.9 binding; §6.9 is verified rather than assumed, so SAF is already unconditional and a sideloaded build would gain nothing by asking for more. Play is deferred over the GPLv3 question, which is a licence-reading exercise and blocks nothing in the storage design. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 1 + docs/distribution.md | 251 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 docs/distribution.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6e1e06..7c554e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,7 @@ One commit per change. If you fixed two things, that is two commits. | [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync | | [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs | | [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen | +| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one | | [`docs/requirements.md`](docs/requirements.md) | Reference, not reading | `technical-debt.md` is the one to check before "fixing" anything surprising. diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..90e3075 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,251 @@ +# DarkRoom — Distribution + +**Satisfies:** NFR-COMPAT-2 (v1 channels) · FR-PLAT-LIN-3 (sandboxed distribution) +**Companion to:** [requirements.md](requirements.md) §3.8, §4.8 · [storage.md](storage.md) + +NFR-COMPAT-2 asks for the v1 channels to be *stated*, and says why in its own +second sentence: the channel decision and the storage design are coupled. A +channel is not a build target. It is a set of constraints that reach back into +the code — what the application is allowed to see, what it may ask for, and +what it must be able to do without asking. This document records which channels +v1 targets and what each one costs, and it is where to look before adding a +permission to a package rather than after. + +--- + +## 1. The channels + +| Platform | Channel | State | What it constrains | +|---|---|---|---| +| Linux | Arch source package — [`packaging/PKGBUILD`](../packaging/PKGBUILD) | Built, in tree | Nothing. Full filesystem access, system Vulkan, system secret daemon | +| Linux | Flatpak — [`packaging/flatpak/`](../packaging/flatpak/) | Manifest in tree, **library selection does not work** (§4) | Portals only. No `--filesystem=`, no host mount table, no typed paths | +| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all | +| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs | +| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering | + +Three of these five exist as recipes and two do not. That is stated rather than +smoothed over, because the value of writing the channels down is knowing which +constraints are already being met and which are promises. + +### What every channel has to get right + +Independent of packaging format, and each of these has bitten a package +somewhere: + +- **One identifier, four places.** `paris.tourolle.darkroom` is the AppStream + component id, the `.desktop` basename, the Flatpak application id, and the + string `dr_ui::run` sets as the Wayland `app_id` and X11 `WM_CLASS`. A rename + that misses one of them costs the icon in the shell or the association in the + software centre, and neither failure announces itself. +- **The metainfo, not just the desktop entry.** + [`packaging/paris.tourolle.darkroom.metainfo.xml`](../packaging/paris.tourolle.darkroom.metainfo.xml) + is the single description of the application, installed by every channel that + has somewhere to put it. Its `metadata_license` is CC0-1.0 and its + `project_license` is GPL-3.0-or-later; those differ on purpose — see the + comment in the file. +- **Vulkan is a requirement, not a preference.** The develop pipeline is + compute shaders through wgpu, and NFR-R8 — how far a CPU fallback goes — is + still open, so today there is nothing behind it. A package that installs onto + a machine with no working ICD produces an application that starts and cannot + develop. +- **A Secret Service implementation, or an honest degraded mode.** FR-NC-2 is + explicit that the absence of a secrets daemon is a stated degraded mode and + never a silent fall back to plaintext. Packages express this as an optional + dependency (the PKGBUILD) or a talk hole (the Flatpak manifest), never as a + hard dependency — a headless or minimal-WM install is a supported way to run. +- **The face models are Git LFS objects.** A checkout without `git lfs pull` + has ~130-byte pointers where 11 MB models should be. Both the PKGBUILD and + the Flatpak manifest check the file size and refuse, because the alternative + is a package whose face indexing fails inside the graph loader on a user's + machine rather than on the packager's. + +--- + +## 2. Why Flatpak is the channel that matters most + +Not because it is expected to be the most used. Because it is the only one that +tests anything. + +The Arch package and an AppImage both hand the application the same +unrestricted process the developer runs it in, so neither can discover that a +design assumed unrestricted access. Flatpak takes that assumption away, and +FR-PLAT-LIN-3 exists to make the discovery happen deliberately rather than in a +bug report. §4 is what it discovered. + +The same argument runs the other way on Android, where SAF has been the only +option since before the first line was written (ARCH §6.9) and `SourceRef` +exists because of it. Linux got the abstraction — `LocalStorage::grant` is the +one place a `Path` enters — and never got the constraint that would have proved +it worked. + +--- + +## 3. What already works inside the sandbox, unchanged + +Worth listing, because it is the part FR-PLAT-LIN-1 quietly paid for in +advance: + +- **XDG directories.** Flatpak redirects `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and + `XDG_CACHE_HOME` into `~/.var/app/paris.tourolle.darkroom/`. Settings + (`settings_store.rs`), accounts (`dr_sync::account`), the catalog and the + thumbnail store all read those variables, so every one of them lands in the + application's own directory with no code change and no permission. +- **The face models.** `system_face_models_dirs()` reads `$XDG_DATA_DIRS` + rather than hard-coding `/usr/share`, which is exactly why `/app/share` + inside a Flatpak is found by the same lookup that finds the Arch package's + copy. +- **Opening a photograph from a file manager.** The `.desktop` entry declares + the RAW MIME types and `Exec=darkroom-desktop %F`; under Flatpak the file is + exported through the document portal and arrives in `argv` as a path under + `/run/user/$UID/doc/`, which is mounted in every sandbox. `main.rs` takes + paths from `argv` and `collect()` handles a file or a directory. This is + genuine portal-mediated access and it needs nothing new. +- **The Nextcloud sign-in browser.** `open_in_browser` spawns `xdg-open`; the + freedesktop runtime's `xdg-open` forwards to the OpenURI portal, and portal + calls need no `--talk-name` because Flatpak always permits them. FR-NC-1's + "system browser, never an embedded webview" therefore holds inside the + sandbox for the same reason it holds outside it. +- **Credentials.** The keyring crate speaks the Secret Service D-Bus interface, + reached through the session-bus proxy with one talk hole. The app password + stays visible to `secret-tool` and Seahorse, which is what keeps it + individually revocable by the user. + +--- + +## 4. What does not work: choosing a library + +**FR-PLAT-LIN-3 is not satisfied today, and the manifest does not pretend +otherwise.** + +A folder library is chosen by typing an absolute path. `dr-sync-folder`'s +provider declares `SignIn::EndpointOnly` with the placeholder +`/home/you/Pictures`, and `normalise_endpoint` expands `~`, requires the path +to be absolute, and checks it with `std::fs`. Nothing in the tree calls the +FileChooser portal — there is no `ashpd`, no `rfd`, and no toolkit file dialog +anywhere in `ui/`, `platform/` or `core/`. + +Inside a sandbox with no `--filesystem=`, `$HOME` still resolves to the real +home *path* but that directory holds only the application's own +`.var/app/…` tree. So a typed `~/Pictures` fails the `exists()` check and the +launch screen says `No folder at /home/you/Pictures.` — a truthful message +about a situation the user cannot fix from inside the application. + +Import is blocked one step earlier. `dr_plat::volumes()` finds a camera card by +reading `/proc/self/mountinfo` and the `removable` flag under `/sys`. A +sandboxed process is in its own mount namespace, so the table it reads +describes the sandbox; a card mounted at `/run/media/…` on the host is not in +it. `volumes()` correctly returns an empty list, which the interface presents +as "no card found" — right for the code, wrong for the user, who is looking at +a card. + +### The permission that would hide this, and why it is not in the manifest + +`--filesystem=host` makes both work immediately and is the thing FR-PLAT-LIN-3 +names as the alternative to portals. Granting it would mean the sandboxed build +never exercises the sandbox, which removes the entire reason for shipping one +(§2). `--filesystem=xdg-pictures` is narrower and would be tempting, but it is +still a static grant that lets a typed path resolve — it makes the same design +work by not testing it, only in a smaller directory. + +So the manifest grants no filesystem access at all. The consequence is stated +plainly: **a Flatpak built from this manifest can open photographs handed to it +and cannot yet be pointed at a library.** + +### What closes it + +Two changes, in this order: + +1. **A portal file chooser behind a platform seam.** `ashpd`'s + `OpenFileRequest` with `directory(true)` returns a URI the document portal + has exported, which the sandbox can read and which stays valid across + restarts. It resolves to a real path under `/run/user/$UID/doc/`, so + `normalise_endpoint` accepts it as it stands — `canonicalize()` on a fuse + path returns the path itself. The seam matters more than the crate: this + belongs beside `LocalStorage::grant` in `dr-plat`, which is already the one + place a `Path` enters the application, and must not become a second way for + `ui/` to learn about paths. +2. **Removable volumes through the same door.** There is no portal for "list + the mounted cards". The honest answer is that under a sandbox + `imports_supported()` should report the same `false` it reports on Android, + for the same reason it gives there — the operation cannot be performed + however hard the user tries — and the import flow should offer the folder + chooser instead of a volume list. + +**Done when:** a Flatpak built from +[`packaging/flatpak/paris.tourolle.darkroom.yml`](../packaging/flatpak/paris.tourolle.darkroom.yml), +with its `finish-args` unchanged and no `flatpak override` applied, can select a +library root, scan it, and write a sidecar back into it. + +### Running a Flatpak build before then + +For testing the rest of the application inside the sandbox, grant the access +per-installation rather than in the manifest, so the file that describes the +application keeps telling the truth: + +```bash +flatpak override --user --filesystem=~/Pictures paris.tourolle.darkroom +``` + +--- + +## 5. AppImage + +A v1 channel, and the recipe is outstanding work rather than a decision to be +made. What it will have to account for, none of which is a surprise: + +- **glibc.** An AppImage links against the oldest glibc it must run on, so it + is built in a container with an old base rather than on a rolling-release + developer machine. A release binary built on a current rolling-release host carries + `GLIBC_2.44` references and would run on almost nothing else. +- **What to bundle and what not to.** The binary links fontconfig, freetype, + expat, libpng, zlib, brotli and bzip2 — bundle those. It does *not* link + Vulkan, libxkbcommon or either display-server library: wgpu `dlopen`s + `libvulkan.so.1`, and `x11rb` and `wayland-client` speak the wire protocols + in Rust. The Vulkan loader and the ICD must come from the host, and bundling + a loader is the classic way to break an AppImage on a driver it did not + expect. +- **The models.** ~15 MB of ONNX weights inside the image, or a first-run + download. In-tree is consistent with how the Lensfun database ships and with + NFR-SEC-5's local-first posture; the licence question (D13) is the same one + it is everywhere else and is not made easier or harder by this channel. +- **No sandbox.** An AppImage tests nothing about FR-PLAT-LIN-3. It is a + convenience channel for distributions the PKGBUILD does not serve, and should + never be the channel a portal problem is discovered on. + +--- + +## 6. Android: F-Droid in v1, Play deferred + +NFR-COMPAT-2 says Play distribution is what makes ARCH §6.9's constraints +binding, and that is worth reading precisely, because the constraint is already +met and would be met whatever the channel. + +§6.9 is *verified*, not assumed: `MANAGE_EXTERNAL_STORAGE` is not grantable +under Play policy, and `READ_MEDIA_IMAGES` would not help because proprietary +RAW is not typed `image/*` by the platform scanner and does not appear in +`MediaStore.Images`. SAF is the only route that works, so FR-PLAT-AND-1 asks +for it unconditionally and `SourceRef` (ARCH §3.1) exists to make it possible. +A sideloaded or F-Droid build *could* ask for broader permissions; it would +gain nothing by doing so. + +So the coupling runs the opposite way from how it is usually described. Play is +deferred for a reason that has nothing to do with storage: GPLv3 distribution +through Play is generally workable but has not been confirmed for this project +(ARCH §14), and F-Droid has no such question. Confirming it is a licence-reading +exercise; nothing in the storage design waits on the answer. + +--- + +## 7. Where the recipes live + +``` +packaging/ + PKGBUILD Arch source package + paris.tourolle.darkroom.desktop the desktop entry, installed by every channel + paris.tourolle.darkroom.metainfo.xml AppStream, installed by every channel + flatpak/ + paris.tourolle.darkroom.yml the manifest, and where the permissions are argued +``` + +`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local +`makepkg` runs. Those are not part of any channel and should not be committed. From 47a40d1afa151d40a8bc3dcdeaf67febe9a336c6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:18:35 +0200 Subject: [PATCH 02/10] Describe the application once, in a file every channel installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A desktop entry gives a software centre a name and a one-line Comment, and nothing else — no description, no licence, no age rating, no statement of what hardware the interface was laid out for. GNOME Software and Discover both show an application with no metainfo as an unexplained icon, and both packages this repository produces were in that position. packaging/paris.tourolle.darkroom.metainfo.xml is the AppStream component, and it is installed by the PKGBUILD as well as by the Flatpak manifest because the description, the licence fields and the OARS rating are facts about the application rather than about how it was packaged. Writing it twice is how the two packages start disagreeing. Two things in it are easy to get wrong and are commented in place. The two licence fields differ on purpose: metadata_license covers the file itself and has to permit the unconditional redistribution and reformatting a catalogue does, which GPLv3 does not, so it is CC0-1.0; project_license is the application's own and reads GPL-3.0-or-later to match D8. And the component id is not a fifth name but the same string as the desktop basename, the Flatpak application id, and the app_id dr_ui::run sets — a rename that misses one costs the icon or the association, and neither failure announces itself. D15's decision that the target devices are a tablet and a desktop is stated as a display_length requirement rather than left implicit, so a software centre does not offer this on hardware where the photograph and the parameter panel cannot both be on screen. Validates clean under appstream-util validate-relax; appstreamcli --pedantic reports only that the gitea URLs are unreachable from a machine that cannot see that host. Co-Authored-By: Claude Opus 5 (1M context) --- packaging/PKGBUILD | 8 ++ .../paris.tourolle.darkroom.metainfo.xml | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 packaging/paris.tourolle.darkroom.metainfo.xml diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index b75f03d..f5fde21 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -37,6 +37,14 @@ package() { install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \ "${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop" + # The same AppStream file the Flatpak installs, so a software centre + # describes the two packages identically instead of falling back to the + # desktop entry's one-line Comment for this one. Installed here rather than + # written twice: the description, the licence fields and the OARS rating + # are facts about the application, not about how it was packaged. + install -Dm644 "packaging/paris.tourolle.darkroom.metainfo.xml" \ + "${pkgdir}/usr/share/metainfo/paris.tourolle.darkroom.metainfo.xml" + # 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 diff --git a/packaging/paris.tourolle.darkroom.metainfo.xml b/packaging/paris.tourolle.darkroom.metainfo.xml new file mode 100644 index 0000000..864c662 --- /dev/null +++ b/packaging/paris.tourolle.darkroom.metainfo.xml @@ -0,0 +1,97 @@ + + + + + paris.tourolle.darkroom + + + CC0-1.0 + GPL-3.0-or-later + + DarkRoom + Non-destructive RAW photo library and editor + + +

+ DarkRoom catalogues, culls and develops RAW photographs. Edits are stored + as a graph of operations beside the original rather than baked into it, + so every change stays reversible and the file the camera wrote is never + rewritten. +

+

+ The library can live in a plain directory — a local disk, an external + drive, an NFS or SMB mount — or on a Nextcloud server, browsed and edited + without downloading whole RAW files first. +

+

Where it differs from the tools it sits beside:

+
    +
  • Culling shows the camera's embedded preview immediately and replaces it with a full render when one is ready, so moving to the next frame does not wait on a demosaic
  • +
  • Sidecars are the record of an edit; the catalog is a cache that can be deleted and rebuilt
  • +
  • The develop pipeline runs on the GPU through Vulkan, including drawn masks — a working Vulkan driver is required, not merely preferred, because there is no CPU renderer behind it
  • +
  • Faces are detected and grouped locally — nothing is uploaded to identify anybody
  • +
+
+ + paris.tourolle.darkroom.desktop + + darkroom-desktop + + + https://gitea.tourolle.paris/dtourolle/DarkRoom + https://gitea.tourolle.paris/dtourolle/DarkRoom/issues + https://gitea.tourolle.paris/dtourolle/DarkRoom + + + Duncan Tourolle + + + + Graphics + Photography + + + + RAW + photography + develop + darkroom + catalog + + + + + 768 + + + pointing + keyboard + touch + + + + + + + +
From a56ac87e2c516225cbba1dd0c9eb455db9909681 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:18:56 +0200 Subject: [PATCH 03/10] Build a Flatpak that asks for no filesystem at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-PLAT-LIN-3 says that where DarkRoom is distributed as a Flatpak, filesystem access uses portals. There was no manifest, so the sandboxed path had never been exercised and the requirement had never been tested against the code. The manifest is YAML rather than JSON because it has more to explain than to declare, and every permission in finish-args carries the argument for itself. The two that are not obvious: - --device=dri is not an optimisation. The develop pipeline is compute shaders through wgpu with nothing behind it while NFR-R8 is open, so without the render node the application starts and cannot develop. - --talk-name=org.freedesktop.secrets rather than the Secret portal. These are different things and the requirement's wording invites the wrong one: the portal hands an app a master key for a store it keeps itself, whereas the session's secret daemon is what keeps the Nextcloud app password visible to secret-tool and Seahorse, and therefore individually revocable by the user. FR-NC-2's degraded mode is what happens if nothing answers, inside the sandbox exactly as outside it. There is no --filesystem= line, and that absence is the substance rather than an oversight. It leaves the document-portal path working — a photograph opened from a file manager arrives in argv under /run/user/$UID/doc and opens with no code change — and leaves library selection broken, because dr-sync-folder takes a typed absolute path and nothing in the tree calls the FileChooser portal. --filesystem=host would fix that and is precisely what the requirement forbids; --filesystem=xdg-pictures would fix it by not testing the design, in a smaller directory. docs/distribution.md §4 records what actually closes the gap and the flatpak override to use in the meantime. The runtime version is chosen from what the binary needs rather than from what is newest. ldd on a release build names fontconfig, freetype, expat, libpng, zlib, brotli and bzip2 and nothing more: wgpu dlopens libvulkan.so.1, and x11rb and wayland-client speak the wire protocols in Rust rather than binding libxcb or libwayland. So what the runtime must supply at runtime is a Vulkan loader and an ICD, which is the GL extension's job, and freedesktop 25.08 carries a rust-stable extension at 1.98.0 — comfortably above the workspace's 1.92 minimum. That extension is not rustup, so rust-toolchain.toml's pin is ignored here; the manifest says why that is correct rather than a violation of CONTRIBUTING.md, since the pin exists to make fmt and clippy agree and neither runs in a packaging build. Like the PKGBUILD, it builds from the local checkout, so a build is of what you are working on. That needs network for cargo, which Flathub forbids — the comment says what a submission there would need instead and why generating 30,000 lines of vendored sources buys nothing yet. The LFS pointer check is carried over from the PKGBUILD for the same reason it exists there: a dir source copies a 130-byte pointer in without complaint, and the failure would land on a user's machine rather than the packager's. Not verified: nothing has been built. flatpak-builder is not installed here and the machine is under a build embargo. The manifest parses, its keys are the ones flatpak-builder reads, and the desktop entry and metainfo it installs both validate — but no Flatpak has been produced from it and no permission has been observed to be sufficient. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 7 + packaging/flatpak/paris.tourolle.darkroom.yml | 186 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 packaging/flatpak/paris.tourolle.darkroom.yml diff --git a/.gitignore b/.gitignore index ba2478f..69321fb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ Cargo.lock.bak # tools/film-profiles/convert.py --fetch. Not source: the converted # profiles in core/dr-film/profiles are. tools/film-profiles/upstream/ + +# flatpak-builder's cache and its output tree. `packaging/flatpak/` holds the +# manifest, which is source; everything a build derives from it is not — and +# `.flatpak-builder/` in particular caches an unpacked copy of the whole +# checkout, so it is larger than the repository it sits in. +/.flatpak-builder/ +/build/ diff --git a/packaging/flatpak/paris.tourolle.darkroom.yml b/packaging/flatpak/paris.tourolle.darkroom.yml new file mode 100644 index 0000000..c6b9c27 --- /dev/null +++ b/packaging/flatpak/paris.tourolle.darkroom.yml @@ -0,0 +1,186 @@ +# Flatpak manifest — FR-PLAT-LIN-3 (sandboxed distribution), NFR-COMPAT-2. +# +# YAML rather than JSON because this file has more to explain than to declare, +# and JSON cannot hold a comment. flatpak-builder reads both. +# +# Build it, from the repository root: +# +# flatpak-builder --user --install --force-clean \ +# build/flatpak packaging/flatpak/paris.tourolle.darkroom.yml +# +# Read docs/distribution.md before changing any permission below. Every line in +# `finish-args` is a hole in the sandbox, and the one that is conspicuously +# absent — `--filesystem=` — is absent on purpose and is explained there. +id: paris.tourolle.darkroom + +# 25.08 is the current freedesktop runtime, and the choice is made by what the +# binary needs rather than by what is newest. `ldd` on a release build names +# fontconfig, freetype, expat, libpng, zlib, brotli and bzip2 — all in the +# Platform — and nothing else: Vulkan, libxkbcommon and the two display-server +# protocols are reached without a link-time dependency (wgpu dlopens +# libvulkan.so.1, and x11rb and wayland-client speak the wire protocols in Rust +# rather than binding libxcb or libwayland). So the runtime has to supply a +# Vulkan loader and an ICD at *runtime*, which is the GL extension's job, and +# not much else. +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk + +# The Rust toolchain is an SDK extension rather than something this manifest +# installs, so the build is offline-capable in the part that matters and the +# compiler is the one freedesktop tested against its own glibc. +# +# Note what this quietly overrides: `rust-toolchain.toml` pins 1.92.0, and that +# pin is honoured by *rustup*, which is not what the extension provides. The +# extension's cargo therefore ignores the file and builds with its own stable +# (1.98.0 on 25.08). That is fine here and deliberately different from +# CONTRIBUTING.md's "do not override the toolchain": the pin exists so `cargo +# fmt --check` and `clippy -D warnings` agree between a laptop and CI, and +# neither runs in this build. A *release binary* only needs a compiler at or +# above the workspace's `rust-version`. +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable + +command: darkroom-desktop + +finish-args: + # FR-PLAT-LIN-2 asks for both display servers. `fallback-x11` rather than + # `x11`: it grants the X socket only when Wayland is unavailable, so a + # Wayland session does not leave an X11 hole open beside the socket actually + # in use. `--share=ipc` goes with it — without it X11 cannot use shared + # memory and every frame is pushed through the socket instead. + - --socket=wayland + - --socket=fallback-x11 + - --share=ipc + + # The GPU. The develop pipeline is compute shaders through wgpu and there is + # no CPU renderer behind it, so this is not an optimisation: without + # /dev/dri the application starts and cannot develop anything. + # + # `dri` rather than `all`: it covers the render nodes and the NVIDIA device + # nodes, which is the whole of what a Vulkan ICD opens. `all` would add every + # other device on the machine for no gain. + - --device=dri + + # Nextcloud (FR-NC-*). Nothing else here reaches the network — face grouping, + # segmentation and lens correction are all local and stay local (NFR-SEC-5). + - --share=network + + # Credential storage (FR-NC-2). The keyring crate speaks the Secret Service + # D-Bus interface directly, which GNOME Keyring and KWallet's `ksecretd` both + # implement, so what it needs is a talk hole to that well-known name. + # + # Worth being precise, because FR-PLAT-LIN-3 says "Secret Service portal" and + # these are two different things: xdg-desktop-portal's `org.freedesktop. + # portal.Secret` hands an application a master key for a store it keeps + # itself, whereas this talks to the session's secret daemon. Only the latter + # puts the app password where `secret-tool` and Seahorse can see it, which is + # what makes a credential individually revocable by the user rather than + # opaque inside our own data directory. If no daemon answers, FR-NC-2's + # degraded mode is what the user gets — the same behaviour as outside a + # sandbox, which is the point. + - --talk-name=org.freedesktop.secrets + + # No `--filesystem=` line of any kind, and this is the substance of + # FR-PLAT-LIN-3 rather than an omission. + # + # What that leaves working: /run/user/$UID/doc is mounted in every sandbox, so + # a photograph opened from a file manager — the .desktop entry declares the + # RAW MIME types and `Exec=darkroom-desktop %F` — arrives as a document-portal + # path in argv and opens. That path is genuinely portal-mediated and needs no + # code change. + # + # What that leaves broken: choosing a *library root*. The folder connector + # takes a typed absolute path (`SignIn::EndpointOnly`, placeholder + # `/home/you/Pictures`) and checks it with `std::fs`, and nothing in the tree + # calls the FileChooser portal — there is no ashpd, no rfd, no toolkit dialog. + # A path typed into that field does not exist in this sandbox, so the launch + # screen refuses it with "that folder does not exist", which is at least an + # honest error. + # + # `--filesystem=host` would make that work today and is exactly what the + # requirement forbids, so it is not here. docs/distribution.md §4 records what + # closes the gap and how to run a Flatpak build in the meantime. + +modules: + - name: darkroom + buildsystem: simple + + build-options: + append-path: /usr/lib/sdk/rust-stable/bin + env: + # Inside the build sandbox rather than in $HOME, so a rebuild starts + # from the state flatpak-builder is managing and not from whatever the + # host's cargo cache happens to hold. + CARGO_HOME: /run/build/darkroom/cargo + # Cargo fetches 826 crates, and Flathub's builders forbid this — a + # submission there needs `cargo-sources.json` generated by + # flatpak-builder-tools' `flatpak-cargo-generator.py` from Cargo.lock, + # listing every crate as its own source, plus a vendored-registry + # `.cargo/config.toml`. That file is ~30k lines, has to be regenerated on + # every dependency change, and buys nothing for a build from a local + # checkout, which is what this manifest is for and what packaging/PKGBUILD + # is for as well. Add it when there is a Flathub submission, not before. + build-args: + - --share=network + + build-commands: + # `--locked` for the reason CI uses it: a lockfile that resolves + # differently in the packaging build than in the tree is a release whose + # dependency versions nobody chose. + - cargo build --release --locked -p darkroom-desktop + + - install -Dm755 target/release/darkroom-desktop /app/bin/darkroom-desktop + + - install -Dm644 packaging/paris.tourolle.darkroom.desktop + /app/share/applications/paris.tourolle.darkroom.desktop + + - install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml + /app/share/metainfo/paris.tourolle.darkroom.metainfo.xml + + # The icon's name is the contract, not its path — the desktop entry says + # `Icon=paris.tourolle.darkroom` and the shell resolves that through the + # hicolor theme. 256x256 because that is the source's actual size; + # installing it under a size it is not makes scaled icons look wrong. + - install -Dm644 ui/dr-ui/ui/app-icon.png + /app/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png + + # The face models, where `system_face_models_dirs()` looks: it reads + # $XDG_DATA_DIRS, which includes /app/share inside a Flatpak, so this is + # the same lookup that finds /usr/share/darkroom/models from the Arch + # package. Last in the search order, so a pair the user dropped in their + # own data directory still outranks these. + # + # These live in Git LFS. A checkout made without `git lfs pull` has + # ~130-byte pointers here, and `type: dir` below would copy the pointers + # in without complaint — producing a Flatpak whose face indexing fails + # inside the graph loader on the user's machine. Refuse instead, with the + # command that fixes it. + - | + for m in scrfd_500m_640.onnx arcface_mbf_b1.onnx; do + if [ "$(stat -c%s "models/face/$m")" -lt 100000 ]; then + echo "error: $m is an LFS pointer, not a model — run: git lfs pull" >&2 + exit 1 + fi + install -Dm644 "models/face/$m" "/app/share/darkroom/models/$m" + done + + - install -Dm644 README.md /app/share/doc/darkroom/README.md + + sources: + # The local checkout, for the same reason packaging/PKGBUILD builds from + # one: this makes a Flatpak of what you are actually working on. Swap it + # for an `archive` or `git` source with a tag when there is a release to + # point at. + # + # `skip` is not tidiness. `target/` is tens of gigabytes and `.git` with + # LFS objects is not small; flatpak-builder copies a `dir` source + # wholesale, so without these two lines the copy is the slowest part of + # the build by a wide margin. + - type: dir + path: ../.. + skip: + - target + - target-android + - .git + - build From ab0ef6a26d98e58fb74190772c3b618bd13da62c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:23:07 +0200 Subject: [PATCH 04/10] Say which requirements the code was already satisfying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen requirements were surveyed as built but untagged. Eight of them were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and NFR-SEC-3. Each was read against its full text in requirements.md and against the code before the tag was added, because a tag that is wrong is worse than an absent one — it turns a visible gap into an invisible one. The five that were refused, and why, because the reasoning is the part worth keeping: R2 carries "(figure TBD)" in its own acceptance criterion and asks for a stated prefetch margin and cache-hit rate; neither figure exists anywhere in the tree and neither quantity is measured, while TD-2 and TD-3 both describe the thumbnail path falling short of it. R5 asks for three things and the code does one. The display pipeline does run at viewport resolution, but "only visible tiles are computed" and "panning recomputes only newly exposed tiles" need a tile scheduler that does not exist — and frame_budget.rs currently argues for striking tiled computation from the interactive path rather than building it. FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can be added without changing callers. What exists is free functions over &[u8]. That meets the requirement's stated *purpose* — the same decoder serves a local file, a SAF document and a byte range, which is exactly why it takes bytes — but there is no trait and no second implementation seam, so the requirement should probably be amended rather than tagged. NFR-ARCH-1 asks for named executors with stated thread counts. architecture.md §7.1 states the table; nothing implements it. Workers are twenty-odd ad-hoc std::thread::spawn sites, each building its own one-worker tokio runtime, with no decode pool, no GPU-submit executor and no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an outcome with no stated means", and that is still true. NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence has no module to tag. A tag would point at nothing. NFR-OPS-3 was the closest call of the eight taken. The store is single, separate from the catalog, survives a catalog rebuild and does not sync between devices; it has no version *field*, deliberately, and settings.rs argues why and names the condition that would need one. The substance is met and the reasoning is recorded where it belongs. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-export/src/lib.rs | 2 +- core/dr-gpu/src/lib.rs | 1 + core/dr-pipeline/src/graph.rs | 1 + core/dr-pipeline/src/lib.rs | 1 + core/dr-pipeline/src/sidecar.rs | 1 + core/dr-sync-folder/src/borrow.rs | 2 +- core/dr-sync-folder/src/lib.rs | 2 +- core/dr-sync-folder/src/vfs.rs | 2 +- core/dr-sync-nextcloud/src/lib.rs | 1 + core/dr-sync-nextcloud/src/provider.rs | 2 +- core/dr-types/src/settings.rs | 2 +- ui/dr-ui/src/settings_store.rs | 2 +- ui/dr-ui/ui/widgets.slint | 1 + 13 files changed, 13 insertions(+), 7 deletions(-) diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs index cc9699d..a406101 100644 --- a/core/dr-export/src/lib.rs +++ b/core/dr-export/src/lib.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 +//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 | R3 //! Turning a rendered frame into a file's worth of bytes. //! //! # What this crate is, and is not diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 705ceb1..b644b01 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -1,3 +1,4 @@ +//! TRACES: NFR-PORT-2 //! GPU device and compute for DarkRoom. //! //! In v0.1 this exists to prove one thing: a compute shader can write a diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 209bbd3..20c2225 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -1,3 +1,4 @@ +//! TRACES: FR-DEV-1 //! The edit graph — an ordered set of operations (ARCH §3.4). //! //! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index c8ffbf8..fb7faab 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -1,3 +1,4 @@ +//! TRACES: R3 //! The develop pipeline — operations, descriptors, and shader composition. //! //! # What this crate is diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 93e2372..f70d91c 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1,3 +1,4 @@ +//! TRACES: FR-DEV-1 //! Sidecar serialisation — the edit graph as durable, mergeable data. //! //! # Generic, for the same reason the UI is generic diff --git a/core/dr-sync-folder/src/borrow.rs b/core/dr-sync-folder/src/borrow.rs index f48526a..490a938 100644 --- a/core/dr-sync-folder/src/borrow.rs +++ b/core/dr-sync-folder/src/borrow.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-6c | FR-NC-6a +// TRACES: FR-NC-6c | FR-NC-6a | FR-NC-6d //! Hydrating a file for as long as it is needed, and no longer. //! //! A pass over a library — thumbnails, face indexing — needs each photograph's diff --git a/core/dr-sync-folder/src/lib.rs b/core/dr-sync-folder/src/lib.rs index 69f0d22..24b84cd 100644 --- a/core/dr-sync-folder/src/lib.rs +++ b/core/dr-sync-folder/src/lib.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-13 | FR-NC-12 +// TRACES: FR-NC-13 | FR-NC-12 | FR-NC-6d //! A library that is just a directory. //! //! The second [`RemoteBackend`], and the one that exists to prove the first diff --git a/core/dr-sync-folder/src/vfs.rs b/core/dr-sync-folder/src/vfs.rs index 3e05e4d..a49e0d9 100644 --- a/core/dr-sync-folder/src/vfs.rs +++ b/core/dr-sync-folder/src/vfs.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-6c +// TRACES: FR-NC-6c | FR-NC-6d //! Virtual-filesystem conventions layered over a directory. //! //! A sync client in virtual-files mode leaves a *placeholder* where a file is diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index bfc30f7..ad93db0 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -1,3 +1,4 @@ +//! TRACES: R6 | NFR-SEC-3 //! Nextcloud connector. //! //! One of two [`RemoteBackend`] implementations, registered through diff --git a/core/dr-sync-nextcloud/src/provider.rs b/core/dr-sync-nextcloud/src/provider.rs index c182935..7b1a503 100644 --- a/core/dr-sync-nextcloud/src/provider.rs +++ b/core/dr-sync-nextcloud/src/provider.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-12 | FR-NC-1 +// TRACES: FR-NC-12 | FR-NC-1 | NFR-SEC-3 //! Registering Nextcloud as a storage backend. //! //! The account model this connector used to own now lives in diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index 8bc950d..a6b596c 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1 +//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1 | NFR-OPS-3 //! Device preferences: how much disk to spend, and what an export defaults to. //! //! # Why these live beside the session and not in the catalog diff --git a/ui/dr-ui/src/settings_store.rs b/ui/dr-ui/src/settings_store.rs index 755772d..d8b9c54 100644 --- a/ui/dr-ui/src/settings_store.rs +++ b/ui/dr-ui/src/settings_store.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 +//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3 //! Reads and writes `settings.json` beside the session config. //! //! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore) diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint index 5a9db2f..793439e 100644 --- a/ui/dr-ui/ui/widgets.slint +++ b/ui/dr-ui/ui/widgets.slint @@ -1,3 +1,4 @@ +// TRACES: FR-UI-6 // Shared chrome primitives and the style layer. // // Before this file every button was a Rectangle + TouchArea written out where From 8165619065524caa8fc3cbfdbe45b68fd539d4e3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:23:28 +0200 Subject: [PATCH 05/10] Describe the project the README is actually in front of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It said the status was "early", that v0.1 is a remote library viewer, and that the zero-copy display path was "not yet working" and its replacement was the highest priority. All three were true when they were written and none of them has been true for eight releases. The zero-copy line was the damaging one, because it is the project's central architectural constraint and the README stated the opposite of what happened. Desktop keeps the zero-copy path — the compute pass writes a texture Slint composites directly — and the readback survives in exactly one place, the Android develop view, because wgpu's Vulkan swapchain tears a portrait window on a tablet whose panel is mounted landscape. That is TD-1, with the on-device measurements and the three things any one of which would remove it. A reader who took the old text at face value would have gone looking for a bug that was fixed, and missed a compromise that was chosen. "Current state" now says what runs, checked item by item against the tree rather than from memory: the first draft claimed AVIF and JPEG XL export, which the settings page offers and dr-export refuses on purpose, and sixteen declared operations where there are fifteen. Both are the kind of error this commit exists to remove. The requirement count moves from 122 to 179, the documentation table gains the five documents somebody would actually want next, and the "Not built" paragraph points at docs/outstanding.md rather than leaving the reader to infer the gap from silence. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0125092..8656899 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,25 @@ A cross-platform, non-destructive RAW photo editor for Linux and Android. -**Status:** early. v0.1 is a remote library viewer — see -[docs/milestone-v0.1.md](docs/milestone-v0.1.md). +**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and +exports on both platforms, across eight tagged releases. What is *not* +built is written down rather than merely absent — see +[docs/outstanding.md](docs/outstanding.md) for the requirements that have no +implementation and why, and [docs/technical-debt.md](docs/technical-debt.md) +for the compromises that were chosen. ## Documentation | Document | Contents | |---|---| -| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest | +| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements | | [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync | -| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone | -| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures | +| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it | +| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap | +| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured | +| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file | +| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured | ## Building @@ -28,21 +36,48 @@ Android (containerised toolchain, see [docker/android](docker/android/README.md) ./docker/android/build.sh cargo ndk -t arm64-v8a build --release ``` +Git LFS is required for the model weights, and the toolchain pins itself. +[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI +will run against what you send. + ## Current state -Working: workspace, GPU context and compute pass, adaptive Slint shell, Android -cross-compilation of the core crates. +**Working.** A catalog over a local folder, a Nextcloud account, or a folder a +sync client keeps in virtual-files mode — where a placeholder is treated as the +photograph rather than as a one-byte file. A virtualised library grid with a +capture-time timeline, ratings, labels, keywords, collections and a trash that +survives a crash mid-operation. Card ingest. Face detection and identity, with +the index syncing between devices. A develop pipeline of fifteen declared +operations fused into a single compute dispatch, plus the neighbourhood +operations that cannot be — clarity, texture, capture sharpening, noise +reduction, lens correction, spectral film simulation. Crop, straighten, spot +removal, gradient and subject-segmentation masks, named presets, and a +generated panel that no operation in `ui/` is allowed to name. Export to JPEG, +PNG and 8- or 16-bit TIFF with resize and output sharpening. -**Not yet working:** the zero-copy display path. The build currently uploads -frames through the CPU, which is exactly what -[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at -4K. Replacing it is spike S1, the project's highest priority. +**The zero-copy display path works on desktop.** The compute pass writes a +texture that Slint composites directly, which is what +[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96% +of frame time at 4K, and -``` +```bash cargo run -p dr-gpu --example bench --features readback ``` -reproduces that measurement. +still reproduces that measurement. **The one exception is the Android develop +view**, which reads the frame back through the CPU because zero-copy there +needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet +whose panel is mounted landscape. It is debt, not a revision of the rule: the +reasoning, the on-device measurements that forced it, and the three separate +things any one of which would remove it are in +[technical-debt.md TD-1](docs/technical-debt.md). + +**Not built.** Plugins, compare and survey culling, focus peaking, burst +grouping, AI denoise, tiled and progressive rendering, and most of the Android +platform integration beyond running. The performance targets in §4.1 are +unverified rather than unmet — the per-commit benchmark suite §8 requires does +not exist, so nothing fails a build on a regression. +[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning. ## Licence From 88ef7148d6f03730b5644dac48105b3be818507e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:23:52 +0200 Subject: [PATCH 06/10] Write down what is not built, so the gap reads as a decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The traceability matrix reports one number and cannot say what it means. An untagged requirement is either one nobody built or one somebody built and did not label, and both look identical in the summary table. Eight of the second kind were tagged in the previous commit. This is the first kind, written out with the reasoning, so that the distance between the register and the binary is something a reader can see rather than reconstruct from a percentage. Eleven clusters, each verified against the tree rather than inherited from a survey. Several turned out to be more interesting than "not done": Plugins are 21 untagged requirements — nearly a third of the shortfall — and §7 already lists the Plugin API as out of scope for v1 while §3.10 spends 280 lines specifying it. The two that *are* built, FR-PLG-2 and FR-PLG-2d, are the declarative operation format, which is a plugin system that resolves at build time. The fix here is an edit to requirements.md, not code, and D16 has to be answered before any format is called stable. FR-DSP-2 is not merely unbuilt; it is under challenge. frame_budget.rs and TD-4 independently argue that tiling costs more than it saves on the interactive path, so the open question is whether the requirement should survive, not when it will be met — and the spike that would settle it, S6, has not run. NFR-A11Y-1's hard half is done and its easy half is not. LocalizedKey already keeps display strings out of core/ and labels::resolve is a single resolution point; what that point does is a hardcoded English match, so a translation needs a recompile, which is the one thing the requirement forbids. The §4.1 performance targets are unverified rather than unmet. §8 requires a per-commit benchmark suite whose regressions fail the build; there is no benches/ directory, no criterion, and no benchmark step in any of the three CI workflows. The one guard that does live in CI skips itself without a GPU adapter and asserts its CPU half only in release, while CI tests in dev. Nothing says the targets are missed. It says nobody would find out. And three requirements are reported as covered while not being met: R1 and NFR-OPS-1 are tagged only by string literals inside the traceability tool's own tests, which it scans along with everything else, and FR-CAT-13's single tag sits on keyword storage while no XMP is parsed or written anywhere. CONTRIBUTING already warns that a tag proves a tag exists; these are the specific ones. Focus peaking, burst grouping, Flatpak packaging and the Android platform integration are being built in parallel and are marked in progress rather than listed as absent, so those lines can be struck as they land. Co-Authored-By: Claude Opus 5 (1M context) --- docs/outstanding.md | 351 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/outstanding.md diff --git a/docs/outstanding.md b/docs/outstanding.md new file mode 100644 index 0000000..45c6a8f --- /dev/null +++ b/docs/outstanding.md @@ -0,0 +1,351 @@ +# DarkRoom — Outstanding work + +**Status:** Living document · first written 2026-08-29 +**Companion to:** [requirements.md §7](requirements.md), [technical-debt.md](technical-debt.md), +[traceability.md](traceability.md) + +What is specified and not built, and for each cluster whether that is a decision, a dependency, or a +gap nobody has looked at. + +This document exists because [traceability.md](traceability.md) cannot tell those apart. It reports +one number — the share of requirements carrying a `TRACES` tag — and a missing tag means either +"nobody has built this" or "somebody built it and did not say so". Both read the same way in the +summary table, which makes that figure pessimistic *and* uninformative at once: it understates what +works while hiding which of the remainder matters. Eight requirements gained a tag on this branch +because the code already satisfied them and nobody had said so. Everything below is the other kind. + +It is also not a plan. [requirements.md §7](requirements.md) records what was deferred deliberately +and needs no argument; this records what is still nominally in scope, so that the distance between +the register and the binary is visible rather than something a reader has to reconstruct from a +percentage. Where the honest answer is "this requirement should be amended rather than met", it says +so — an unbuilt requirement that nobody intends to build is worse than a deferred one, because it +keeps costing attention. + +**Four of these are being built right now**, in parallel worktrees, and are marked **⟳ in +progress** where they appear: focus peaking (part of FR-CULL-3), burst grouping (FR-CULL-5), +Flatpak packaging (FR-PLAT-LIN-3), and Android platform integration (FR-PLAT-AND-2/4/5/6). Strike +those lines as they land rather than rewriting around them. + +--- + +## 1. Plugins — 21 requirements, and a contradiction to resolve before any of them + +**Untagged:** FR-PLG-1, -1a, -2a, -2b, -2c, -3, -3a, -4, -4a, -5, -5a, -5b, -5c, -6, -6a, -7, -8, +-9, -10, -11, -12. + +No plugin host exists. No crate loads anything at runtime: there is no manifest reader, no WASM or +Lua engine, no registry, no signature check, no install path, no capability grant, no per-plugin +failure ledger. `declared/mod.rs` says as much in its own documentation — the operation format is +"not a plugin directory read at startup". + +**Two of §3.10's requirements are met, and they are the interesting two.** FR-PLG-2 and FR-PLG-2d — +the declarative node format — are built and tagged: `core/dr-pipeline/ops/*.yaml` compiled by +`build.rs`, with the restricted expression grammar in `declared/expr.rs` and a parity test asserting +a declared operation and a hand-written one produce identical output. +[code-health.md §3](code-health.md) calls it "a working plugin system that happens to resolve at +build time", and that is exactly right. What is missing is not the format; it is everything that +would let somebody who is not in this repository use it. + +**The contradiction.** [requirements.md §7](requirements.md) lists `| Plugin API | — |` among the +things deferred for v1 — a bare row, where most deferrals carry a justifying note. §3.10 then spends +roughly 280 lines and 23 requirement IDs specifying that same Plugin API in detail. Both statements +are in the register of record, and the traceability denominator counts the second one: 21 IDs, 12% +of all 179 defined requirements, worth about twelve points of coverage on their own — and nearly a +third of everything the matrix reports as uncovered. A reader looking at the coverage figure has no +way to know that, or that the subsystem behind it is one the same document says is not in this +version. + +**And D16 is open.** [Decision D16](requirements.md) — plugin licensing — records that GPLv3 +answers the derivative-work question differently for each of §3.10's three plugin forms, and that +this "must be answered *before* an ecosystem exists, not after", because a term introduced later +cannot be applied to plugins already written. D16 explicitly does not block FR-PLG-2; it blocks +publishing a third-party format as stable. + +**What would resolve this:** an edit to `requirements.md`, not code. Either §7 drops the row, or +§3.10 is marked deferred with the two built requirements carved out. Until one of those happens the +coverage figure is measuring a decision that has already been taken, and taking it again every time +somebody reads the matrix. + +--- + +## 2. Culling — the stated differentiator, half built + +[D11](requirements.md) names culling "the core differentiator". FR-CULL-1, -2, -4 and -8 through -12 +are built. Four are not. + +**FR-CULL-3 — Raw-truth overlays.** Focus peaking does not exist anywhere; the string appears zero +times in the tree. A histogram and clipping indicators *do* exist, but they are not the ones this +requirement asks for: they live in the develop view under FR-DSP-7, they are threshold readouts +rather than per-pixel overlays on the image, and `develop.rs` says plainly that what they count is +"the levels the display will show" — the pipeline result, not the sensor data. FR-CULL-3 exists +because a culling decision made against a rendered preview is a decision made against the wrong +image. **⟳ in progress** (focus peaking). + +**FR-CULL-5 — Burst and near-duplicate grouping.** Absent. Worth knowing before it is built: +`core/dr-face/src/calibrate.rs` already *assumes* it exists — "since FR-CULL-5 already groups +bursts, positives are bootstrapped from bursts" — and in fact bootstraps from confirmed labels +instead. That comment is a forward reference to this requirement and will need correcting either +way. `core/dr-catalog/src/dedup.rs` is not this: it is re-import detection under FR-CAT-11, matching +a file against one already catalogued, not two photographs against each other. **⟳ in progress**. + +**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan. +This is the one of the four with no adjacent machinery at all, and it is also the one that most +directly distinguishes culling from browsing. + +**FR-CULL-7 — Culling on tablet.** Absent, and blocked by the three above rather than independent +of them: there is no separate tablet culling surface to build until there is something to put on it. + +--- + +## 3. FR-DEV-3g — AI denoise + +Promoted into v1 by [D11](requirements.md), and named there as the precondition for deferring AI +masking — the argument being that one learned stage earns the runtime that a second could then +reuse. Only classical noise reduction exists: `ops/noise_reduction.rs`, a bilateral filter in two +arrangements, exact for luminance and separable for chroma. It is good, and it is not this. + +`models/` holds two face models and nothing else; `core/dr-segment/models/` holds a YOLO +segmentation model for subject masks. There is no denoise model, no learned demosaic, and no +inference path that is not face or segmentation. + +The obstacle is not the pipeline. It is that [D13](requirements.md) — model licensing — is still +open for the models that already ship, and adding a third learned stage adds a third licence to +answer for. Building the runtime before that is settled means owning the same problem in one more +place. + +--- + +## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2 + +**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md) +calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built, +and the evidence has since moved. `core/dr-gpu/tests/frame_budget.rs` carries the argument in its +own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget, +and "if that stops being true, the recommendation to strike tiled computation from the interactive +path stops being supported, and this test is what says so." +[technical-debt.md TD-4](technical-debt.md) reaches the same place from the other direction — a +tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the +stage that looks most like it wants a tile cache is the stage that would be hurt most by one. + +What exists is the declaration and not the mechanism: `DetailPass::radius` is documented as the halo +a tile would have to be grown by, with a test that pins it, and there is no scheduler to read it. +That is deliberate plumbing, not an oversight. + +**So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement".** +Two measurements say it costs more than it saves on the interactive path. Neither says anything +about the export path or about a device under memory pressure, which is where the case for it +actually lives — and that is spike S6, which has not run. + +**FR-DSP-4 — Progressive refinement.** Unbuilt. FR-DSP-1's proxy rendering and TD-4's +quarter-resolution base are adjacent and are not it: both are fixed choices about what resolution to +compute at, where FR-DSP-4 asks for a first frame that is deliberately cheap and a second that +replaces it. Nothing tracks a "this frame is provisional" state. + +**NFR-RES-2 — Images larger than GPU memory.** No answer, and §4.3 knows it: the requirement text +itself asks the reader to "decide explicitly" how ARCH §6.4 and NFR-RES-2 are reconciled. There is +no headroom budget, no allocation-failure fallback, and no spill. Spike S6 — a tiled pipeline on a +mid-range Android device with an image larger than available GPU memory — is the one that would +settle both this and FR-DSP-2, and there is no evidence it has run. + +--- + +## 5. Android beyond running, and Flatpak + +The Android app is not a stub — it builds an APK, runs the whole application, unpacks bundled face +models, and has been measured on a tablet ([faces.md §12.1](faces.md), +[technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it. + +**FR-PLAT-AND-1 is tagged and should not be relied on.** The requirement demands that library access +be obtained *exclusively* through the Storage Access Framework. There is no SAF code: no +`ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`, no `DocumentsContract`. The two tags +rest on a `SourceRef::Document` variant that nothing constructs and a volumes helper, which is the +"plumbing a future feature would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and +[code-health.md CH-4](code-health.md) both warn about. Android reaches a library through a Nextcloud +account or a folder, over paths, like the desktop. + +That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a +granted tree permission and marking images offline rather than deleting rows — cannot be built until +there is a permission to lose. It is listed here as unbuilt, but it is blocked, not skipped. + +**FR-PLAT-AND-4** (managed background execution, foreground service for exports, stated Doze +behaviour): the manifest declares one activity, no service, and neither `FOREGROUND_SERVICE` nor +`POST_NOTIFICATIONS`. **FR-PLAT-AND-5** (`onTrimMemory` with a stated eviction order): no callback +is registered, though the eviction order it is supposed to drive is specified in FR-NC-6's text. +**FR-PLAT-AND-6** (view and share intents, `FileProvider`): the only intent filter is +`MAIN`/`LAUNCHER`. **⟳ in progress** for this group. + +**FR-PLAT-LIN-3 — Flatpak.** `packaging/` holds an Arch `PKGBUILD` and a `.desktop` entry. There is +no Flatpak manifest, nothing goes through a portal, and `platform/dr-plat/src/secrets.rs` talks to +the Secret Service directly rather than through the portal the requirement names. **⟳ in progress**. + +**NFR-COMPAT-2 — distribution channels.** Unstated, and this is the requirement that makes the +others binding: §4.8 observes that the decision to publish on Play is what turns SAF from a +preference into a constraint. Spike S11, the Play permissions dry-run that would settle it, has not +run. Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android +Dockerfile and are checked in CI against the built ELF, which is good — while the items the +requirement singles out are missing: whether `shaderFloat16` and 16-bit storage are required (the +one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device +from a second GPU vendor. + +**NFR-OPS-2 and NFR-OPS-4.** Crash reporting is a `log::error!` panic hook on Android and nothing at +all on desktop: no local crash record, no backtrace capture, no upload path and therefore no opt-in +gate to guard it. Update and first run are undefined; the concrete reason NFR-OPS-4 gives — that D2 +pins rawler at a non-SemVer alpha whose camera-support fixes users will need — is unaddressed, and +there is no update mechanism of any kind. + +--- + +## 6. Accessibility and internationalisation — the hard half is done and the easy half is not + +**NFR-A11Y-1 — Localisation.** `@tr(` appears **zero** times across 14,482 lines of Slint. That +number overstates the problem, because the part that is genuinely architectural was got right: +`LocalizedKey` keeps display strings out of `core/` entirely, every operation publishes a key rather +than a label, and `labels::resolve` is the single point where a key becomes text. What that single +point does, however, is a hardcoded English `match` in Rust source — so changing a translation +requires a recompile, which is the one thing the requirement explicitly forbids. There is no message +catalogue in any format, no locale-resolution rule, and no decision recorded about RTL. + +The work left is therefore smaller than it looks and entirely mechanical: a catalogue format, a load +path behind `resolve`, and `@tr(` around the Slint literals. The design it needs already exists. + +**NFR-A11Y-2 — Accessibility.** `accessible-*` appears five times in the whole interface, all five +on one control — the parameter slider in `adjust.slint` — and nothing is set from the Rust side at +all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own +caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run. + +**NFR-A11Y-3 — Colour-independent status.** No compliance work found. This is cheap to satisfy while +a control is being written and expensive to retrofit across forty of them, which is an argument for +doing it as part of the NFR-A11Y-2 pass rather than after it. + +--- + +## 7. Catalog and sync + +**FR-CAT-14 — Migration import.** Reading ratings, labels, keywords and collections out of a +Lightroom `.lrcat` or a darktable `library.db`. Unbuilt. The destination is not: keywords, +collections, ratings and the cross-device merge rules are all built and tested, and +`keywords.rs` already anticipates the arrival ("an import from Lightroom can bring in…"). What is +missing is only the two source adapters — which is a comparatively contained piece of work for a +requirement that decides whether somebody can try this software on a library they already have. + +**FR-NC-11 — Initial catalog build.** Using WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/`, +filtered by mimetype and paginated, in preference to walking folders with PROPFIND. Unbuilt: no +`SEARCH` request is issued anywhere. The PROPFIND walk this exists to replace is fully built and +well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library into one request — so the +gap is narrower than it reads. It is the *first* build against a large remote library that pays, and +that is the moment a new user meets. + +**FR-CAT-13 — XMP interoperability, tagged and not met.** Read and write standard XMP sidecars. The +single tag sits on `keywords.rs`, which stores keywords; no XMP is parsed or written anywhere in the +tree, and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode` +today"). Listed here rather than silently, because a tag makes a gap invisible and this one is +load-bearing for interoperating with the editors FR-CAT-14 imports from. + +--- + +## 8. The performance targets are unverified, not unmet + +Eleven of the fifteen §4.1 targets carry no tag: NFR-P2, -P3, -P4, -P6, -P7, -P8, -P10, -P11, -P12, +-P14, -P15. That is the uninteresting part of this section. + +The interesting part is that §8 and §4.1 both require the same thing, in the same words, and it does +not exist: an automated benchmark suite against a synthetic 50k catalog, run per commit, where **"a +regression beyond a stated tolerance is a build failure, not a notification."** There is no +`benches/` directory in the workspace, no criterion dependency, and no synthetic catalog. The three +CI workflows run `cargo fmt --check`, clippy, `cargo test --workspace`, a release build, an Android +cross-build and a layering check. None of them measures anything, so there is no baseline to +regress against and no tolerance to exceed. + +What does exist is narrower and genuinely good: `dr-gpu/examples/frame_budget` is a real instrument, +its results are committed in [frame-budget.md](frame-budget.md) with the machine and profile named, +and TD-4's before-and-after was measured with it. But it is run by hand — frame-budget.md's own +instruction is "rerun and diff this file" — and the guard version that does live in CI skips itself +where there is no GPU adapter, which the workflow notes is the normal case on a runner, while +asserting its CPU half only when `debug_assertions` is off, which a dev-profile `cargo test` is not. +In CI it therefore asserts approximately nothing. + +**The claim to take from this is precise.** Nothing here says the performance targets are missed. +Several are plausibly met. It says that if one were broken tomorrow, nobody would find out — which +is the failure mode §8 was written to prevent, and the reason it belongs in this document rather +than in a backlog. + +--- + +## 9. Two core requirements that cannot be closed as written + +**R1 — Cross-platform output within a bounded tolerance.** §2 states that the threshold "must be +fixed before spike S9", because S9 both validates R1 and calibrates what tolerance is achievable. +The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at +all — there is nothing a test could assert. + +Worse, the matrix reports R1 as *covered*. Both of its tags are string literals inside the +traceability tool's own unit tests (`tools/traceability/src/lib.rs`), which the tool scans along with +everything else, because a fixture demonstrating tag extraction is indistinguishable from a tag. +NFR-OPS-1 is covered the same way, from a tag on `compute_coverage` — and no rotating, size-capped +on-disk log exists; logging goes to stderr and logcat. These are two of the cases +[CONTRIBUTING.md](../CONTRIBUTING.md) already warns about, now named. + +**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure +TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated +prefetch margin and cache-hit rate. No figure is stated anywhere in the tree, neither quantity is +measured, and [TD-2](technical-debt.md) and [TD-3](technical-debt.md) both describe the thumbnail +path falling short of it in ways that were measured. R2 was deliberately left untagged on this branch +for that reason: the machinery is substantial and the criterion is unmet and partly undefined. + +Both belong with §8 above. A requirement whose threshold was never chosen and a target nothing +measures fail in the same way — not by being wrong, but by being unfalsifiable. + +--- + +## 10. Spikes + +§9 defines fourteen validation spikes and says of three of them: "S1, S2 and S10 are the three that +can invalidate the architecture." + +Only **S1** (Slint + wgpu zero-copy on Linux) and **S14** (the face pipeline on a real library) have +recorded results. S14's are the best evidence of any spike — a dedicated document, a measured pass +over an 18,143-face library, a named device and a reproducible command — though D13's licensing half +remains open. + +**S6, S9, S10, S11 and S13 show no evidence of having run at all.** Each is referenced only from the +requirement text that asks for it: + +| Spike | Would settle | Blocked on | +|---|---|---| +| S6 | FR-DSP-2, NFR-RES-2 — tiling and images larger than GPU memory | Nothing; needs a device and a large image | +| S9 | R1's tolerance threshold, and therefore R1 | Nothing; the threshold is defined *by* running it | +| S10 | Whether SAF at 10k files meets NFR-P1/P3 | §5 — there is no SAF code to measure | +| S11 | NFR-COMPAT-2, and whether Play makes SAF binding | Nothing | +| S13 | NFR-A11Y-2 on Android | §6 — there is almost nothing to test with | + +S2, S3, S4, S5, S7, S8 and S12 are also unrun, several with acknowledgements in the code that say +so (`dr-sync/src/upload.rs` on S8, `dr-sync-nextcloud/src/lib.rs` on S3). S2 is one of the three +architecture-invalidating spikes and needs Adreno and Mali hardware, which the manifest notes no +emulator represents. + +The pattern is worth stating rather than leaving to be inferred: the spikes that ran are the ones +whose subject was being built anyway. The ones that did not are the ones that would have said +whether something *should* be built — which is the opposite of the order §9 asks for. + +--- + +## 11. D12, which governs all of the above + +[Decision D12 — scope versus pace](requirements.md) is still **OPEN**, and says: + +> The calibration selected an ambitious feature set — full tablet editing, full ingest, culling as a +> differentiator, complete GPU masking, AI denoise, Fuji-first colour, deep sync, sidecar durability +> — against a stated pace of evenings and weekends, indefinitely. +> +> **Those are not compatible as stated.** + +Sections 1 through 10 are what that incompatibility looks like eleven versions later, and they land +almost exactly where D12 predicted: tablet editing carries SAF at unproven scale, background +execution limits and two GPU vendors to validate (§5), and every one of those is unbuilt or unrun. +The parts that *were* built — the develop pipeline, sync, faces, the catalog — are the parts that +did not need a decision first. + +D12 is not resolved by choosing to work faster. It is resolved by moving requirements across the +line into §7, which costs nothing but the admission, and which this document is intended to make +easy: every cluster above is a candidate, and each says what it would take to build and what it +would cost to drop. Resolving D12 sets D3 and [architecture.md §10](architecture.md)'s Phase 2. From 0d9f802f88a0fb8fa30053cdecdbcea105b88907 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:26:07 +0200 Subject: [PATCH 07/10] Regenerate the matrix that this branch is about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight requirements gained a tag in this branch's first commit, so the generated matrix is stale until it is rerun. Coverage moves from 59.8% (107/179) to 64.2% (115/179), and the untagged list drops from 72 entries to 64. Regenerating here rather than leaving it to the pre-commit hook, because this branch's subject *is* the matrix: a reader comparing the two commits should see the number move for a stated reason. The eight are R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and NFR-SEC-3, and none of them is new work — every one was already satisfied by code that had simply never said so. Six of the thirteen tag sites were new `TRACES:` lines rather than additions to existing ones, so the tag count moves 896 → 902 while the covered count moves by eight. Orphan tags remain at zero. Run from source with `cargo run -p traceability -- report`, never from a prebuilt binary, and note that the tool tracks line numbers — the six prepended module tags shift every subsequent line in their own files, which accounts for the churn in this diff that is not a coverage change. Co-Authored-By: Claude Opus 5 (1M context) --- docs/traceability.md | 76 ++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/traceability.md b/docs/traceability.md index c8ddc26..9fcc95d 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -10,18 +10,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| | Source files scanned | 292 | -| TRACES tags found | 896 | +| TRACES tags found | 902 | | Requirements defined | 179 | -| Requirements covered | 107 | -| **Coverage** | **59.8%** (107/179) | +| Requirements covered | 115 | +| **Coverage** | **64.2%** (115/179) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 85 | 124 | -| NFR | 20 | 49 | -| R | 2 | 6 | +| FR | 88 | 124 | +| NFR | 23 | 49 | +| R | 4 | 6 | ## Orphan tags @@ -36,48 +36,49 @@ _None._ | 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:201`](../core/dr-types/src/lib.rs#L201), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`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:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1176`](../ui/dr-ui/src/lib.rs#L1176), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1031`](../ui/dr-ui/ui/library.slint#L1031), [`ui/dr-ui/ui/library.slint:1193`](../ui/dr-ui/ui/library.slint#L1193), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) | | 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:1176`](../ui/dr-ui/src/lib.rs#L1176), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:2375`](../ui/dr-ui/src/library.rs#L2375), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) | +| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119) | | 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:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:456`](../core/dr-sync-nextcloud/src/lib.rs#L456), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:429`](../core/dr-sync/src/scan.rs#L429), [`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:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3869`](../ui/dr-ui/src/library.rs#L3869), [`ui/dr-ui/src/library.rs:3903`](../ui/dr-ui/src/library.rs#L3903), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:756`](../ui/dr-ui/src/library_ui.rs#L756), [`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-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:429`](../core/dr-sync/src/scan.rs#L429), [`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:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3869`](../ui/dr-ui/src/library.rs#L3869), [`ui/dr-ui/src/library.rs:3903`](../ui/dr-ui/src/library.rs#L3903), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:756`](../ui/dr-ui/src/library_ui.rs#L756), [`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:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | 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:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:348`](../ui/dr-ui/ui/app.slint#L348), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`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), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:189`](../ui/dr-ui/src/library.rs#L189), [`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:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`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:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1558`](../ui/dr-ui/src/collections_ui.rs#L1558), [`ui/dr-ui/src/collections_ui.rs:1577`](../ui/dr-ui/src/collections_ui.rs#L1577), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3917`](../ui/dr-ui/src/library.rs#L3917), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6389`](../ui/dr-ui/src/library_ui.rs#L6389), [`ui/dr-ui/src/library_ui.rs:6400`](../ui/dr-ui/src/library_ui.rs#L6400), [`ui/dr-ui/src/library_ui.rs:6413`](../ui/dr-ui/src/library_ui.rs#L6413), [`ui/dr-ui/src/library_ui.rs:6428`](../ui/dr-ui/src/library_ui.rs#L6428), [`ui/dr-ui/src/library_ui.rs:6437`](../ui/dr-ui/src/library_ui.rs#L6437), [`ui/dr-ui/ui/app.slint:293`](../ui/dr-ui/ui/app.slint#L293), [`ui/dr-ui/ui/app.slint:474`](../ui/dr-ui/ui/app.slint#L474), [`ui/dr-ui/ui/library.slint:1242`](../ui/dr-ui/ui/library.slint#L1242), [`ui/dr-ui/ui/library.slint:1245`](../ui/dr-ui/ui/library.slint#L1245), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:859`](../ui/dr-ui/ui/library.slint#L859), [`ui/dr-ui/ui/library.slint:911`](../ui/dr-ui/ui/library.slint#L911) | +| 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:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`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:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/collections_ui.rs:1558`](../ui/dr-ui/src/collections_ui.rs#L1558), [`ui/dr-ui/src/collections_ui.rs:1577`](../ui/dr-ui/src/collections_ui.rs#L1577), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3917`](../ui/dr-ui/src/library.rs#L3917), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6389`](../ui/dr-ui/src/library_ui.rs#L6389), [`ui/dr-ui/src/library_ui.rs:6400`](../ui/dr-ui/src/library_ui.rs#L6400), [`ui/dr-ui/src/library_ui.rs:6413`](../ui/dr-ui/src/library_ui.rs#L6413), [`ui/dr-ui/src/library_ui.rs:6428`](../ui/dr-ui/src/library_ui.rs#L6428), [`ui/dr-ui/src/library_ui.rs:6437`](../ui/dr-ui/src/library_ui.rs#L6437), [`ui/dr-ui/ui/app.slint:293`](../ui/dr-ui/ui/app.slint#L293), [`ui/dr-ui/ui/app.slint:474`](../ui/dr-ui/ui/app.slint#L474), [`ui/dr-ui/ui/library.slint:1242`](../ui/dr-ui/ui/library.slint#L1242), [`ui/dr-ui/ui/library.slint:1245`](../ui/dr-ui/ui/library.slint#L1245), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:859`](../ui/dr-ui/ui/library.slint#L859), [`ui/dr-ui/ui/library.slint:911`](../ui/dr-ui/ui/library.slint#L911) | | 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:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`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:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:4163`](../ui/dr-ui/src/library.rs#L4163), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:393`](../ui/dr-ui/src/library_ui.rs#L393), [`ui/dr-ui/src/library_ui.rs:5203`](../ui/dr-ui/src/library_ui.rs#L5203), [`ui/dr-ui/src/library_ui.rs:5256`](../ui/dr-ui/src/library_ui.rs#L5256), [`ui/dr-ui/src/library_ui.rs:6529`](../ui/dr-ui/src/library_ui.rs#L6529), [`ui/dr-ui/ui/app.slint:296`](../ui/dr-ui/ui/app.slint#L296), [`ui/dr-ui/ui/app.slint:474`](../ui/dr-ui/ui/app.slint#L474), [`ui/dr-ui/ui/app.slint:748`](../ui/dr-ui/ui/app.slint#L748), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1318`](../ui/dr-ui/ui/library.slint#L1318), [`ui/dr-ui/ui/library.slint:911`](../ui/dr-ui/ui/library.slint#L911), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | | 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:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`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:3489`](../ui/dr-ui/src/library_ui.rs#L3489), [`ui/dr-ui/src/library_ui.rs:4182`](../ui/dr-ui/src/library_ui.rs#L4182), [`ui/dr-ui/ui/app.slint:469`](../ui/dr-ui/ui/app.slint#L469), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2585`](../ui/dr-ui/ui/library.slint#L2585), [`ui/dr-ui/ui/library.slint:882`](../ui/dr-ui/ui/library.slint#L882), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901) | -| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:3599`](../ui/dr-ui/src/develop.rs#L3599), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:1830`](../ui/dr-ui/src/lib.rs#L1830), [`ui/dr-ui/src/lib.rs:1965`](../ui/dr-ui/src/lib.rs#L1965), [`ui/dr-ui/src/lib.rs:554`](../ui/dr-ui/src/lib.rs#L554), [`ui/dr-ui/src/lib.rs:979`](../ui/dr-ui/src/lib.rs#L979), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library_ui.rs:4884`](../ui/dr-ui/src/library_ui.rs#L4884), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:3599`](../ui/dr-ui/src/develop.rs#L3599), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:1830`](../ui/dr-ui/src/lib.rs#L1830), [`ui/dr-ui/src/lib.rs:1965`](../ui/dr-ui/src/lib.rs#L1965), [`ui/dr-ui/src/lib.rs:554`](../ui/dr-ui/src/lib.rs#L554), [`ui/dr-ui/src/lib.rs:979`](../ui/dr-ui/src/lib.rs#L979), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library_ui.rs:4884`](../ui/dr-ui/src/library_ui.rs#L4884), [`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:613`](../core/dr-catalog/src/schema.rs#L613), [`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:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:2997`](../ui/dr-ui/src/develop.rs#L2997), [`ui/dr-ui/src/library.rs:148`](../ui/dr-ui/src/library.rs#L148), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4270`](../ui/dr-ui/src/library.rs#L4270), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:774`](../ui/dr-ui/src/library.rs#L774), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1580`](../ui/dr-ui/src/library_ui.rs#L1580), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:1716`](../ui/dr-ui/src/library_ui.rs#L1716), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2332`](../ui/dr-ui/src/library_ui.rs#L2332), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:2770`](../ui/dr-ui/src/library_ui.rs#L2770), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:3270`](../ui/dr-ui/src/library_ui.rs#L3270), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:3540`](../ui/dr-ui/src/library_ui.rs#L3540), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:446`](../ui/dr-ui/src/library_ui.rs#L446), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/library_ui.rs:5301`](../ui/dr-ui/src/library_ui.rs#L5301), [`ui/dr-ui/src/library_ui.rs:5416`](../ui/dr-ui/src/library_ui.rs#L5416), [`ui/dr-ui/src/presets.rs:337`](../ui/dr-ui/src/presets.rs#L337), [`ui/dr-ui/src/presets.rs:349`](../ui/dr-ui/src/presets.rs#L349), [`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:121`](../core/dr-decode/src/preview.rs#L121) | | FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1920`](../ui/dr-ui/src/develop.rs#L1920), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:687`](../ui/dr-ui/src/develop.rs#L687), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1937`](../ui/dr-ui/src/lib.rs#L1937), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:284`](../ui/dr-ui/src/library.rs#L284), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2849`](../ui/dr-ui/src/library.rs#L2849), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953), [`ui/dr-ui/ui/settings.slint:416`](../ui/dr-ui/ui/settings.slint#L416), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | +| FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`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:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`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:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`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:1424`](../ui/dr-ui/src/develop.rs#L1424), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/develop.rs:1920`](../ui/dr-ui/src/develop.rs#L1920), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:1956`](../ui/dr-ui/src/develop.rs#L1956), [`ui/dr-ui/src/develop.rs:2102`](../ui/dr-ui/src/develop.rs#L2102), [`ui/dr-ui/src/develop.rs:2200`](../ui/dr-ui/src/develop.rs#L2200), [`ui/dr-ui/src/develop.rs:3268`](../ui/dr-ui/src/develop.rs#L3268), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3298`](../ui/dr-ui/src/develop.rs#L3298), [`ui/dr-ui/src/develop.rs:3364`](../ui/dr-ui/src/develop.rs#L3364), [`ui/dr-ui/src/develop.rs:3378`](../ui/dr-ui/src/develop.rs#L3378), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:4230`](../ui/dr-ui/src/develop.rs#L4230), [`ui/dr-ui/src/develop.rs:4284`](../ui/dr-ui/src/develop.rs#L4284), [`ui/dr-ui/src/develop.rs:4328`](../ui/dr-ui/src/develop.rs#L4328), [`ui/dr-ui/src/develop.rs:4378`](../ui/dr-ui/src/develop.rs#L4378), [`ui/dr-ui/src/develop.rs:586`](../ui/dr-ui/src/develop.rs#L586), [`ui/dr-ui/src/develop.rs:647`](../ui/dr-ui/src/develop.rs#L647), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:773`](../ui/dr-ui/src/develop.rs#L773), [`ui/dr-ui/src/develop.rs:802`](../ui/dr-ui/src/develop.rs#L802), [`ui/dr-ui/src/lib.rs:1514`](../ui/dr-ui/src/lib.rs#L1514), [`ui/dr-ui/src/lib.rs:2233`](../ui/dr-ui/src/lib.rs#L2233), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:2601`](../ui/dr-ui/src/lib.rs#L2601), [`ui/dr-ui/src/lib.rs:2665`](../ui/dr-ui/src/lib.rs#L2665), [`ui/dr-ui/src/lib.rs:2719`](../ui/dr-ui/src/lib.rs#L2719), [`ui/dr-ui/src/lib.rs:320`](../ui/dr-ui/src/lib.rs#L320), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`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:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:635`](../ui/dr-ui/ui/adjust.slint#L635), [`ui/dr-ui/ui/app.slint:119`](../ui/dr-ui/ui/app.slint#L119), [`ui/dr-ui/ui/app.slint:165`](../ui/dr-ui/ui/app.slint#L165), [`ui/dr-ui/ui/app.slint:1849`](../ui/dr-ui/ui/app.slint#L1849), [`ui/dr-ui/ui/app.slint:858`](../ui/dr-ui/ui/app.slint#L858), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1321`](../ui/dr-ui/src/develop.rs#L1321), [`ui/dr-ui/src/lib.rs:669`](../ui/dr-ui/src/lib.rs#L669), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4957`](../ui/dr-ui/src/develop.rs#L4957) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`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:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`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:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:1697`](../core/dr-pipeline/src/sidecar.rs#L1697), [`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:1424`](../ui/dr-ui/src/develop.rs#L1424), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/develop.rs:1920`](../ui/dr-ui/src/develop.rs#L1920), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:1956`](../ui/dr-ui/src/develop.rs#L1956), [`ui/dr-ui/src/develop.rs:2102`](../ui/dr-ui/src/develop.rs#L2102), [`ui/dr-ui/src/develop.rs:2200`](../ui/dr-ui/src/develop.rs#L2200), [`ui/dr-ui/src/develop.rs:3268`](../ui/dr-ui/src/develop.rs#L3268), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3298`](../ui/dr-ui/src/develop.rs#L3298), [`ui/dr-ui/src/develop.rs:3364`](../ui/dr-ui/src/develop.rs#L3364), [`ui/dr-ui/src/develop.rs:3378`](../ui/dr-ui/src/develop.rs#L3378), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:4230`](../ui/dr-ui/src/develop.rs#L4230), [`ui/dr-ui/src/develop.rs:4284`](../ui/dr-ui/src/develop.rs#L4284), [`ui/dr-ui/src/develop.rs:4328`](../ui/dr-ui/src/develop.rs#L4328), [`ui/dr-ui/src/develop.rs:4378`](../ui/dr-ui/src/develop.rs#L4378), [`ui/dr-ui/src/develop.rs:586`](../ui/dr-ui/src/develop.rs#L586), [`ui/dr-ui/src/develop.rs:647`](../ui/dr-ui/src/develop.rs#L647), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:773`](../ui/dr-ui/src/develop.rs#L773), [`ui/dr-ui/src/develop.rs:802`](../ui/dr-ui/src/develop.rs#L802), [`ui/dr-ui/src/lib.rs:1514`](../ui/dr-ui/src/lib.rs#L1514), [`ui/dr-ui/src/lib.rs:2233`](../ui/dr-ui/src/lib.rs#L2233), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:2601`](../ui/dr-ui/src/lib.rs#L2601), [`ui/dr-ui/src/lib.rs:2665`](../ui/dr-ui/src/lib.rs#L2665), [`ui/dr-ui/src/lib.rs:2719`](../ui/dr-ui/src/lib.rs#L2719), [`ui/dr-ui/src/lib.rs:320`](../ui/dr-ui/src/lib.rs#L320), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`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:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:635`](../ui/dr-ui/ui/adjust.slint#L635), [`ui/dr-ui/ui/app.slint:119`](../ui/dr-ui/ui/app.slint#L119), [`ui/dr-ui/ui/app.slint:165`](../ui/dr-ui/ui/app.slint#L165), [`ui/dr-ui/ui/app.slint:1849`](../ui/dr-ui/ui/app.slint#L1849), [`ui/dr-ui/ui/app.slint:858`](../ui/dr-ui/ui/app.slint#L858), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1321`](../ui/dr-ui/src/develop.rs#L1321), [`ui/dr-ui/src/lib.rs:669`](../ui/dr-ui/src/lib.rs#L669), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4957`](../ui/dr-ui/src/develop.rs#L4957) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:617`](../core/dr-pipeline/src/graph.rs#L617), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | 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:967`](../core/dr-gpu/src/adjust.rs#L967), [`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:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3012`](../ui/dr-ui/src/develop.rs#L3012), [`ui/dr-ui/src/develop.rs:3029`](../ui/dr-ui/src/develop.rs#L3029), [`ui/dr-ui/src/develop.rs:3041`](../ui/dr-ui/src/develop.rs#L3041), [`ui/dr-ui/src/develop.rs:3079`](../ui/dr-ui/src/develop.rs#L3079), [`ui/dr-ui/src/develop.rs:3088`](../ui/dr-ui/src/develop.rs#L3088), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3607`](../ui/dr-ui/src/develop.rs#L3607), [`ui/dr-ui/src/develop.rs:3622`](../ui/dr-ui/src/develop.rs#L3622), [`ui/dr-ui/src/lib.rs:2207`](../ui/dr-ui/src/lib.rs#L2207), [`ui/dr-ui/src/lib.rs:602`](../ui/dr-ui/src/lib.rs#L602), [`ui/dr-ui/src/lib.rs:660`](../ui/dr-ui/src/lib.rs#L660), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:286`](../ui/dr-ui/src/presets.rs#L286), [`ui/dr-ui/ui/adjust.slint:1034`](../ui/dr-ui/ui/adjust.slint#L1034), [`ui/dr-ui/ui/adjust.slint:1116`](../ui/dr-ui/ui/adjust.slint#L1116), [`ui/dr-ui/ui/app.slint:2364`](../ui/dr-ui/ui/app.slint#L2364), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1958`](../core/dr-pipeline/src/sidecar.rs#L1958), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3012`](../ui/dr-ui/src/develop.rs#L3012), [`ui/dr-ui/src/develop.rs:3029`](../ui/dr-ui/src/develop.rs#L3029), [`ui/dr-ui/src/develop.rs:3041`](../ui/dr-ui/src/develop.rs#L3041), [`ui/dr-ui/src/develop.rs:3079`](../ui/dr-ui/src/develop.rs#L3079), [`ui/dr-ui/src/develop.rs:3088`](../ui/dr-ui/src/develop.rs#L3088), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3607`](../ui/dr-ui/src/develop.rs#L3607), [`ui/dr-ui/src/develop.rs:3622`](../ui/dr-ui/src/develop.rs#L3622), [`ui/dr-ui/src/lib.rs:2207`](../ui/dr-ui/src/lib.rs#L2207), [`ui/dr-ui/src/lib.rs:602`](../ui/dr-ui/src/lib.rs#L602), [`ui/dr-ui/src/lib.rs:660`](../ui/dr-ui/src/lib.rs#L660), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:286`](../ui/dr-ui/src/presets.rs#L286), [`ui/dr-ui/ui/adjust.slint:1034`](../ui/dr-ui/ui/adjust.slint#L1034), [`ui/dr-ui/ui/adjust.slint:1116`](../ui/dr-ui/ui/adjust.slint#L1116), [`ui/dr-ui/ui/app.slint:2364`](../ui/dr-ui/ui/app.slint#L2364), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600) | | 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:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:2119`](../ui/dr-ui/src/develop.rs#L2119), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | -| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:335`](../core/dr-gpu/src/lib.rs#L335), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3041`](../ui/dr-ui/src/develop.rs#L3041), [`ui/dr-ui/src/develop.rs:3622`](../ui/dr-ui/src/develop.rs#L3622), [`ui/dr-ui/src/develop.rs:3652`](../ui/dr-ui/src/develop.rs#L3652), [`ui/dr-ui/src/develop.rs:3665`](../ui/dr-ui/src/develop.rs#L3665), [`ui/dr-ui/src/develop.rs:3677`](../ui/dr-ui/src/develop.rs#L3677), [`ui/dr-ui/src/develop.rs:3693`](../ui/dr-ui/src/develop.rs#L3693), [`ui/dr-ui/src/develop.rs:3725`](../ui/dr-ui/src/develop.rs#L3725), [`ui/dr-ui/src/develop.rs:3729`](../ui/dr-ui/src/develop.rs#L3729), [`ui/dr-ui/src/develop.rs:3748`](../ui/dr-ui/src/develop.rs#L3748), [`ui/dr-ui/src/develop.rs:3764`](../ui/dr-ui/src/develop.rs#L3764), [`ui/dr-ui/src/develop.rs:708`](../ui/dr-ui/src/develop.rs#L708), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1462`](../ui/dr-ui/src/lib.rs#L1462), [`ui/dr-ui/src/lib.rs:1492`](../ui/dr-ui/src/lib.rs#L1492), [`ui/dr-ui/src/lib.rs:1500`](../ui/dr-ui/src/lib.rs#L1500), [`ui/dr-ui/src/lib.rs:2403`](../ui/dr-ui/src/lib.rs#L2403), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:336`](../core/dr-gpu/src/lib.rs#L336), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3041`](../ui/dr-ui/src/develop.rs#L3041), [`ui/dr-ui/src/develop.rs:3622`](../ui/dr-ui/src/develop.rs#L3622), [`ui/dr-ui/src/develop.rs:3652`](../ui/dr-ui/src/develop.rs#L3652), [`ui/dr-ui/src/develop.rs:3665`](../ui/dr-ui/src/develop.rs#L3665), [`ui/dr-ui/src/develop.rs:3677`](../ui/dr-ui/src/develop.rs#L3677), [`ui/dr-ui/src/develop.rs:3693`](../ui/dr-ui/src/develop.rs#L3693), [`ui/dr-ui/src/develop.rs:3725`](../ui/dr-ui/src/develop.rs#L3725), [`ui/dr-ui/src/develop.rs:3729`](../ui/dr-ui/src/develop.rs#L3729), [`ui/dr-ui/src/develop.rs:3748`](../ui/dr-ui/src/develop.rs#L3748), [`ui/dr-ui/src/develop.rs:3764`](../ui/dr-ui/src/develop.rs#L3764), [`ui/dr-ui/src/develop.rs:708`](../ui/dr-ui/src/develop.rs#L708), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1462`](../ui/dr-ui/src/lib.rs#L1462), [`ui/dr-ui/src/lib.rs:1492`](../ui/dr-ui/src/lib.rs#L1492), [`ui/dr-ui/src/lib.rs:1500`](../ui/dr-ui/src/lib.rs#L1500), [`ui/dr-ui/src/lib.rs:2403`](../ui/dr-ui/src/lib.rs#L2403), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:249`](../core/dr-pipeline/src/preset.rs#L249), [`core/dr-pipeline/src/preset.rs:282`](../core/dr-pipeline/src/preset.rs#L282), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3564`](../ui/dr-ui/src/develop.rs#L3564), [`ui/dr-ui/src/develop.rs:3585`](../ui/dr-ui/src/develop.rs#L3585), [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:2025`](../ui/dr-ui/src/lib.rs#L2025), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:487`](../ui/dr-ui/src/library.rs#L487), [`ui/dr-ui/src/library_ui.rs:2592`](../ui/dr-ui/src/library_ui.rs#L2592), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:546`](../ui/dr-ui/src/presets.rs#L546), [`ui/dr-ui/src/settings_ui.rs:630`](../ui/dr-ui/src/settings_ui.rs#L630), [`ui/dr-ui/ui/adjust.slint:709`](../ui/dr-ui/ui/adjust.slint#L709), [`ui/dr-ui/ui/adjust.slint:736`](../ui/dr-ui/ui/adjust.slint#L736), [`ui/dr-ui/ui/adjust.slint:779`](../ui/dr-ui/ui/adjust.slint#L779), [`ui/dr-ui/ui/app.slint:1443`](../ui/dr-ui/ui/app.slint#L1443), [`ui/dr-ui/ui/app.slint:2453`](../ui/dr-ui/ui/app.slint#L2453), [`ui/dr-ui/ui/library.slint:1345`](../ui/dr-ui/ui/library.slint#L1345), [`ui/dr-ui/ui/library.slint:1352`](../ui/dr-ui/ui/library.slint#L1352), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:853`](../ui/dr-ui/ui/library.slint#L853), [`ui/dr-ui/ui/library.slint:922`](../ui/dr-ui/ui/library.slint#L922), [`ui/dr-ui/ui/library.slint:937`](../ui/dr-ui/ui/library.slint#L937), [`ui/dr-ui/ui/presets.slint:4`](../ui/dr-ui/ui/presets.slint#L4), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | | FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3693`](../ui/dr-ui/src/develop.rs#L3693), [`ui/dr-ui/src/develop.rs:3725`](../ui/dr-ui/src/develop.rs#L3725), [`ui/dr-ui/src/lib.rs:1500`](../ui/dr-ui/src/lib.rs#L1500), [`ui/dr-ui/src/lib.rs:2403`](../ui/dr-ui/src/lib.rs#L2403), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:387`](../core/dr-gpu/src/detail.rs#L387), [`core/dr-gpu/src/detail.rs:592`](../core/dr-gpu/src/detail.rs#L592), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2271`](../ui/dr-ui/src/develop.rs#L2271), [`ui/dr-ui/src/develop.rs:2309`](../ui/dr-ui/src/develop.rs#L2309), [`ui/dr-ui/src/develop.rs:2374`](../ui/dr-ui/src/develop.rs#L2374), [`ui/dr-ui/src/develop.rs:2457`](../ui/dr-ui/src/develop.rs#L2457), [`ui/dr-ui/src/develop.rs:2471`](../ui/dr-ui/src/develop.rs#L2471), [`ui/dr-ui/src/develop.rs:756`](../ui/dr-ui/src/develop.rs#L756), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1521`](../ui/dr-ui/src/lib.rs#L1521), [`ui/dr-ui/src/lib.rs:2521`](../ui/dr-ui/src/lib.rs#L2521), [`ui/dr-ui/src/lib.rs:325`](../ui/dr-ui/src/lib.rs#L325), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:810`](../ui/dr-ui/ui/adjust.slint#L810), [`ui/dr-ui/ui/app.slint:109`](../ui/dr-ui/ui/app.slint#L109), [`ui/dr-ui/ui/app.slint:1752`](../ui/dr-ui/ui/app.slint#L1752), [`ui/dr-ui/ui/app.slint:1927`](../ui/dr-ui/ui/app.slint#L1927), [`ui/dr-ui/ui/app.slint:2270`](../ui/dr-ui/ui/app.slint#L2270), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:156`](../core/dr-gpu/src/lib.rs#L156), [`core/dr-gpu/src/lib.rs:176`](../core/dr-gpu/src/lib.rs#L176), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:77`](../core/dr-gpu/src/lib.rs#L77), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`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:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`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:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2795`](../ui/dr-ui/src/develop.rs#L2795), [`ui/dr-ui/src/develop.rs:4019`](../ui/dr-ui/src/develop.rs#L4019), [`ui/dr-ui/src/develop.rs:4502`](../ui/dr-ui/src/develop.rs#L4502), [`ui/dr-ui/src/develop.rs:4536`](../ui/dr-ui/src/develop.rs#L4536), [`ui/dr-ui/src/lib.rs:73`](../ui/dr-ui/src/lib.rs#L73), [`ui/dr-ui/src/lib.rs:810`](../ui/dr-ui/src/lib.rs#L810), [`ui/dr-ui/src/lib.rs:869`](../ui/dr-ui/src/lib.rs#L869) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:387`](../core/dr-gpu/src/detail.rs#L387), [`core/dr-gpu/src/detail.rs:592`](../core/dr-gpu/src/detail.rs#L592), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:192`](../core/dr-pipeline/src/graph.rs#L192), [`core/dr-pipeline/src/graph.rs:686`](../core/dr-pipeline/src/graph.rs#L686), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:673`](../core/dr-pipeline/src/sidecar.rs#L673), [`core/dr-pipeline/src/sidecar.rs:809`](../core/dr-pipeline/src/sidecar.rs#L809), [`core/dr-pipeline/src/sidecar.rs:863`](../core/dr-pipeline/src/sidecar.rs#L863), [`core/dr-pipeline/src/sidecar.rs:893`](../core/dr-pipeline/src/sidecar.rs#L893), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2271`](../ui/dr-ui/src/develop.rs#L2271), [`ui/dr-ui/src/develop.rs:2309`](../ui/dr-ui/src/develop.rs#L2309), [`ui/dr-ui/src/develop.rs:2374`](../ui/dr-ui/src/develop.rs#L2374), [`ui/dr-ui/src/develop.rs:2457`](../ui/dr-ui/src/develop.rs#L2457), [`ui/dr-ui/src/develop.rs:2471`](../ui/dr-ui/src/develop.rs#L2471), [`ui/dr-ui/src/develop.rs:756`](../ui/dr-ui/src/develop.rs#L756), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1521`](../ui/dr-ui/src/lib.rs#L1521), [`ui/dr-ui/src/lib.rs:2521`](../ui/dr-ui/src/lib.rs#L2521), [`ui/dr-ui/src/lib.rs:325`](../ui/dr-ui/src/lib.rs#L325), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:810`](../ui/dr-ui/ui/adjust.slint#L810), [`ui/dr-ui/ui/app.slint:109`](../ui/dr-ui/ui/app.slint#L109), [`ui/dr-ui/ui/app.slint:1752`](../ui/dr-ui/ui/app.slint#L1752), [`ui/dr-ui/ui/app.slint:1927`](../ui/dr-ui/ui/app.slint#L1927), [`ui/dr-ui/ui/app.slint:2270`](../ui/dr-ui/ui/app.slint#L2270), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:157`](../core/dr-gpu/src/lib.rs#L157), [`core/dr-gpu/src/lib.rs:177`](../core/dr-gpu/src/lib.rs#L177), [`core/dr-gpu/src/lib.rs:55`](../core/dr-gpu/src/lib.rs#L55), [`core/dr-gpu/src/lib.rs:78`](../core/dr-gpu/src/lib.rs#L78), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:548`](../core/dr-pipeline/src/graph.rs#L548), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`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:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`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:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2795`](../ui/dr-ui/src/develop.rs#L2795), [`ui/dr-ui/src/develop.rs:4019`](../ui/dr-ui/src/develop.rs#L4019), [`ui/dr-ui/src/develop.rs:4502`](../ui/dr-ui/src/develop.rs#L4502), [`ui/dr-ui/src/develop.rs:4536`](../ui/dr-ui/src/develop.rs#L4536), [`ui/dr-ui/src/lib.rs:73`](../ui/dr-ui/src/lib.rs#L73), [`ui/dr-ui/src/lib.rs:810`](../ui/dr-ui/src/lib.rs#L810), [`ui/dr-ui/src/lib.rs:869`](../ui/dr-ui/src/lib.rs#L869) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2825`](../ui/dr-ui/src/develop.rs#L2825), [`ui/dr-ui/src/develop.rs:5794`](../ui/dr-ui/src/develop.rs#L5794), [`ui/dr-ui/src/lib.rs:1477`](../ui/dr-ui/src/lib.rs#L1477), [`ui/dr-ui/src/lib.rs:2840`](../ui/dr-ui/src/lib.rs#L2840) | | 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:2874`](../ui/dr-ui/src/develop.rs#L2874), [`ui/dr-ui/src/develop.rs:5604`](../ui/dr-ui/src/develop.rs#L5604), [`ui/dr-ui/src/develop.rs:5636`](../ui/dr-ui/src/develop.rs#L5636), [`ui/dr-ui/src/develop.rs:719`](../ui/dr-ui/src/develop.rs#L719), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1577`](../ui/dr-ui/src/lib.rs#L1577), [`ui/dr-ui/src/lib.rs:315`](../ui/dr-ui/src/lib.rs#L315), [`ui/dr-ui/ui/app.slint:69`](../ui/dr-ui/ui/app.slint#L69), [`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-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2771`](../ui/dr-ui/src/develop.rs#L2771), [`ui/dr-ui/src/develop.rs:2825`](../ui/dr-ui/src/develop.rs#L2825), [`ui/dr-ui/src/develop.rs:5794`](../ui/dr-ui/src/develop.rs#L5794), [`ui/dr-ui/src/develop.rs:5840`](../ui/dr-ui/src/develop.rs#L5840), [`ui/dr-ui/src/develop.rs:5859`](../ui/dr-ui/src/develop.rs#L5859), [`ui/dr-ui/src/develop.rs:787`](../ui/dr-ui/src/develop.rs#L787), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1451`](../ui/dr-ui/src/lib.rs#L1451), [`ui/dr-ui/src/lib.rs:1477`](../ui/dr-ui/src/lib.rs#L1477), [`ui/dr-ui/src/lib.rs:2817`](../ui/dr-ui/src/lib.rs#L2817), [`ui/dr-ui/src/lib.rs:2840`](../ui/dr-ui/src/lib.rs#L2840), [`ui/dr-ui/ui/app.slint:1612`](../ui/dr-ui/ui/app.slint#L1612), [`ui/dr-ui/ui/app.slint:48`](../ui/dr-ui/ui/app.slint#L48), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:772`](../ui/dr-ui/ui/settings.slint#L772) | | 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:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`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:600`](../core/dr-types/src/settings.rs#L600), [`ui/dr-ui/src/develop.rs:5859`](../ui/dr-ui/src/develop.rs#L5859), [`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:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:538`](../core/dr-pipeline/src/graph.rs#L538), [`core/dr-pipeline/src/graph.rs:592`](../core/dr-pipeline/src/graph.rs#L592), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`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:600`](../core/dr-types/src/settings.rs#L600), [`ui/dr-ui/src/develop.rs:5859`](../ui/dr-ui/src/develop.rs#L5859), [`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:182`](../core/dr-export/src/lib.rs#L182), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`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-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:645`](../core/dr-types/src/settings.rs#L645), [`core/dr-types/src/settings.rs:653`](../core/dr-types/src/settings.rs#L653), [`core/dr-types/src/settings.rs:669`](../core/dr-types/src/settings.rs#L669), [`core/dr-types/src/settings.rs:738`](../core/dr-types/src/settings.rs#L738), [`core/dr-types/src/settings.rs:766`](../core/dr-types/src/settings.rs#L766), [`core/dr-types/src/settings.rs:777`](../core/dr-types/src/settings.rs#L777), [`core/dr-types/src/settings.rs:783`](../core/dr-types/src/settings.rs#L783), [`ui/dr-ui/src/settings_ui.rs:199`](../ui/dr-ui/src/settings_ui.rs#L199), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:212`](../ui/dr-ui/src/settings_ui.rs#L212), [`ui/dr-ui/src/settings_ui.rs:227`](../ui/dr-ui/src/settings_ui.rs#L227), [`ui/dr-ui/src/settings_ui.rs:557`](../ui/dr-ui/src/settings_ui.rs#L557), [`ui/dr-ui/ui/settings.slint:675`](../ui/dr-ui/ui/settings.slint#L675), [`ui/dr-ui/ui/settings.slint:685`](../ui/dr-ui/ui/settings.slint#L685) | | 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) | @@ -87,7 +88,7 @@ _None._ | 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:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2956`](../ui/dr-ui/src/develop.rs#L2956), [`ui/dr-ui/src/lib.rs:415`](../ui/dr-ui/src/lib.rs#L415) | | 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/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) | | FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:477`](../ui/dr-ui/src/lib.rs#L477), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:39`](../core/dr-sync-nextcloud/src/lib.rs#L39), [`core/dr-sync-nextcloud/src/lib.rs:913`](../core/dr-sync-nextcloud/src/lib.rs#L913), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:40`](../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/lib.rs:914`](../core/dr-sync-nextcloud/src/lib.rs#L914), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | | FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`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:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:348`](../ui/dr-ui/ui/app.slint#L348), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | @@ -97,11 +98,12 @@ _None._ | FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`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:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1860`](../ui/dr-ui/src/lib.rs#L1860), [`ui/dr-ui/src/lib.rs:2903`](../ui/dr-ui/src/lib.rs#L2903), [`ui/dr-ui/src/library.rs:1449`](../ui/dr-ui/src/library.rs#L1449), [`ui/dr-ui/src/library.rs:1472`](../ui/dr-ui/src/library.rs#L1472), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1429`](../ui/dr-ui/src/library_ui.rs#L1429), [`ui/dr-ui/src/library_ui.rs:1547`](../ui/dr-ui/src/library_ui.rs#L1547), [`ui/dr-ui/src/library_ui.rs:2074`](../ui/dr-ui/src/library_ui.rs#L2074), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:304`](../ui/dr-ui/src/library_ui.rs#L304), [`ui/dr-ui/src/library_ui.rs:313`](../ui/dr-ui/src/library_ui.rs#L313), [`ui/dr-ui/src/library_ui.rs:405`](../ui/dr-ui/src/library_ui.rs#L405), [`ui/dr-ui/src/library_ui.rs:415`](../ui/dr-ui/src/library_ui.rs#L415), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:520`](../ui/dr-ui/src/library_ui.rs#L520), [`ui/dr-ui/src/library_ui.rs:5318`](../ui/dr-ui/src/library_ui.rs#L5318), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/src/library_ui.rs:551`](../ui/dr-ui/src/library_ui.rs#L551), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`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:2443`](../ui/dr-ui/ui/app.slint#L2443), [`ui/dr-ui/ui/app.slint:463`](../ui/dr-ui/ui/app.slint#L463), [`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:997`](../ui/dr-ui/ui/library.slint#L997) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) | | FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:803`](../core/dr-sync-folder/src/lib.rs#L803), [`core/dr-sync-folder/src/lib.rs:842`](../core/dr-sync-folder/src/lib.rs#L842), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1569`](../ui/dr-ui/src/library.rs#L1569), [`ui/dr-ui/src/library.rs:1659`](../ui/dr-ui/src/library.rs#L1659), [`ui/dr-ui/src/library.rs:3138`](../ui/dr-ui/src/library.rs#L3138), [`ui/dr-ui/src/library.rs:3476`](../ui/dr-ui/src/library.rs#L3476), [`ui/dr-ui/src/library.rs:948`](../ui/dr-ui/src/library.rs#L948), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1337`](../ui/dr-ui/src/library_ui.rs#L1337), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`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-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:104`](../core/dr-sync-nextcloud/src/lib.rs#L104), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384) | +| FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384) | | 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:116`](../core/dr-types/src/settings.rs#L116), [`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:1176`](../ui/dr-ui/src/lib.rs#L1176), [`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:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`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:1176`](../ui/dr-ui/src/lib.rs#L1176) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1830`](../ui/dr-ui/src/lib.rs#L1830), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:998`](../ui/dr-ui/src/library.rs#L998) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`ui/dr-ui/src/lib.rs:1830`](../ui/dr-ui/src/lib.rs#L1830), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:451`](../core/dr-pipeline/src/sidecar.rs#L451), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:998`](../ui/dr-ui/src/library.rs#L998) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | 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), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:919`](../ui/dr-ui/src/lib.rs#L919), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | @@ -117,33 +119,39 @@ _None._ | FR-UI-3 | [`ui/dr-ui/src/develop.rs:2200`](../ui/dr-ui/src/develop.rs#L2200), [`ui/dr-ui/src/develop.rs:2309`](../ui/dr-ui/src/develop.rs#L2309), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`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/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1849`](../ui/dr-ui/ui/app.slint#L1849), [`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), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | | FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/library_ui.rs:4432`](../ui/dr-ui/src/library_ui.rs#L4432), [`ui/dr-ui/src/library_ui.rs:4535`](../ui/dr-ui/src/library_ui.rs#L4535), [`ui/dr-ui/src/library_ui.rs:4563`](../ui/dr-ui/src/library_ui.rs#L4563), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5348`](../ui/dr-ui/src/library_ui.rs#L5348), [`ui/dr-ui/ui/app.slint:1689`](../ui/dr-ui/ui/app.slint#L1689), [`ui/dr-ui/ui/app.slint:469`](../ui/dr-ui/ui/app.slint#L469), [`ui/dr-ui/ui/app.slint:500`](../ui/dr-ui/ui/app.slint#L500), [`ui/dr-ui/ui/library.slint:1219`](../ui/dr-ui/ui/library.slint#L1219), [`ui/dr-ui/ui/library.slint:1226`](../ui/dr-ui/ui/library.slint#L1226), [`ui/dr-ui/ui/library.slint:1232`](../ui/dr-ui/ui/library.slint#L1232), [`ui/dr-ui/ui/library.slint:2335`](../ui/dr-ui/ui/library.slint#L2335), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:882`](../ui/dr-ui/ui/library.slint#L882), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901) | | 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:2486`](../ui/dr-ui/src/lib.rs#L2486), [`ui/dr-ui/src/lib.rs:3024`](../ui/dr-ui/src/lib.rs#L3024), [`ui/dr-ui/src/lib.rs:3209`](../ui/dr-ui/src/lib.rs#L3209), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:90`](../ui/dr-ui/ui/app.slint#L90), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953) | | NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2183`](../ui/dr-ui/src/lib.rs#L2183), [`ui/dr-ui/ui/app.slint:1005`](../ui/dr-ui/ui/app.slint#L1005), [`ui/dr-ui/ui/library.slint:951`](../ui/dr-ui/ui/library.slint#L951) | | 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), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | +| NFR-OPS-3 | [`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) | | 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:121`](../core/dr-decode/src/preview.rs#L121) | | NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:4003`](../ui/dr-ui/src/library.rs#L4003), [`ui/dr-ui/src/library.rs:4795`](../ui/dr-ui/src/library.rs#L4795), [`ui/dr-ui/src/library_ui.rs:4072`](../ui/dr-ui/src/library_ui.rs#L4072), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`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:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | +| NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../core/dr-gpu/src/lib.rs#L1) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) | -| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:176`](../core/dr-gpu/src/lib.rs#L176), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068) | +| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:177`](../core/dr-gpu/src/lib.rs#L177), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068) | | 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:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:73`](../ui/dr-ui/src/lib.rs#L73) | -| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-gpu/src/lib.rs:77`](../core/dr-gpu/src/lib.rs#L77), [`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:2848`](../ui/dr-ui/src/library.rs#L2848) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-gpu/src/lib.rs:78`](../core/dr-gpu/src/lib.rs#L78), [`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:2848`](../ui/dr-ui/src/library.rs#L2848) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | +| NFR-SEC-3 | [`core/dr-sync-nextcloud/src/lib.rs:1`](../core/dr-sync-nextcloud/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1) | | NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#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:335`](../core/dr-gpu/src/lib.rs#L335) | +| R3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-pipeline/src/lib.rs:1`](../core/dr-pipeline/src/lib.rs#L1) | +| R4 | [`core/dr-gpu/src/lib.rs:336`](../core/dr-gpu/src/lib.rs#L336) | +| R6 | [`core/dr-sync-nextcloud/src/lib.rs:1`](../core/dr-sync-nextcloud/src/lib.rs#L1) | ## Not yet tagged -72 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +64 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -152,12 +160,10 @@ _None._ - FR-CULL-5 - FR-CULL-6 - FR-CULL-7 -- FR-DEV-1 - FR-DEV-3g - FR-DSP-2 - FR-DSP-4 - FR-NC-11 -- FR-NC-6d - FR-PLAT-AND-2 - FR-PLAT-AND-4 - FR-PLAT-AND-5 @@ -185,7 +191,6 @@ _None._ - FR-PLG-8 - FR-PLG-9 - FR-RAW-2 -- FR-UI-6 - NFR-A11Y-1 - NFR-A11Y-2 - NFR-A11Y-3 @@ -193,7 +198,6 @@ _None._ - NFR-COMPAT-1 - NFR-COMPAT-2 - NFR-OPS-2 -- NFR-OPS-3 - NFR-OPS-4 - NFR-P10 - NFR-P11 @@ -206,18 +210,14 @@ _None._ - NFR-P6 - NFR-P7 - NFR-P8 -- NFR-PORT-2 - NFR-R3 - NFR-R4 - NFR-R6 - NFR-RES-2 - NFR-RES-3 -- NFR-SEC-3 - NFR-SEC-4 - NFR-SEC-6 - R2 -- R3 - R5 -- R6
From 8d5dc423eafa61fb9b28f5640e0212f51a9b6ea4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 20:36:51 +0200 Subject: [PATCH 08/10] Say that all three of FR-CULL-3's bullets are unbuilt, not one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft got this half right and half wrong. It correctly said the existing histogram and clipping indicators are not FR-CULL-3's, but it described them as adjacent — as though the work left were mostly focus peaking and a relocation. They are not adjacent. The histogram reads AdjustPass's 8-bit output and counts clipping as r == 255, so it describes the frame the display is about to show, after the entire develop chain. FR-CULL-3 asks for the histogram of the sensor data, and gives its reason in the requirement itself: a rendered image "systematically lies about what is recoverable in the raw". A readout taken from the render cannot answer that question however it is presented, which means two of the three bullets need a new measurement rather than a new placement. Found by the agent building focus peaking, who had to go looking at the counters to find out. Co-Authored-By: Claude Opus 5 (1M context) --- docs/outstanding.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/outstanding.md b/docs/outstanding.md index 45c6a8f..3e6011e 100644 --- a/docs/outstanding.md +++ b/docs/outstanding.md @@ -73,13 +73,20 @@ somebody reads the matrix. [D11](requirements.md) names culling "the core differentiator". FR-CULL-1, -2, -4 and -8 through -12 are built. Four are not. -**FR-CULL-3 — Raw-truth overlays.** Focus peaking does not exist anywhere; the string appears zero -times in the tree. A histogram and clipping indicators *do* exist, but they are not the ones this -requirement asks for: they live in the develop view under FR-DSP-7, they are threshold readouts -rather than per-pixel overlays on the image, and `develop.rs` says plainly that what they count is -"the levels the display will show" — the pipeline result, not the sensor data. FR-CULL-3 exists -because a culling decision made against a rendered preview is a decision made against the wrong -image. **⟳ in progress** (focus peaking). +**FR-CULL-3 — Raw-truth overlays. All three bullets, unbuilt.** Focus peaking does not exist +anywhere; the string appears zero times in the tree. + +The other two are easy to mistake for present, and are not. A histogram and clipping indicators do +exist — `dr-gpu/src/histogram.rs`, `ui/dr-ui/src/histogram.rs`, the panel in `histogram.slint` — +but they are tagged FR-DSP-7 and they answer the opposite question. They read `AdjustPass`'s 8-bit +output and count clipping as `r == 255`, which is to say they describe **the frame the display is +about to show**, after the whole develop chain has run. FR-CULL-3 asks for the histogram of the +*sensor data*, on the explicit grounds that a rendered image "systematically lies about what is +recoverable in the raw". A readout that measures the render cannot answer that however it is +presented, so this is not a matter of moving an existing widget into the culling view. + +The requirement exists because a culling decision made against a rendered preview is a decision made +against the wrong image, and the whole of it is still to build. **⟳ in progress** (focus peaking). **FR-CULL-5 — Burst and near-duplicate grouping.** Absent. Worth knowing before it is built: `core/dr-face/src/calibrate.rs` already *assumes* it exists — "since FR-CULL-5 already groups From 2168cdd1c44bf1ca1df3f9cd624d53e0d4f218dd Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 21:43:08 +0200 Subject: [PATCH 09/10] Mark what is in focus, so a frame can be judged without zooming to 100% FR-CULL-3's focus peaking. One compute dispatch measures local contrast in WGSL and writes an overlay texture; on desktop it reaches Slint through the same zero-copy wgpu import the canvas uses, so nothing per-pixel touches the CPU on the frame path. With peaking off the cost is zero and structurally so: focus_overlay opens with `let settings = self.peaking?;` before the frame is touched, and clearing drops both overlay textures, so no VRAM is held either. NFR-P14 is met by construction rather than by measurement -- one dispatch, no second render, no pipeline compile after session open, and a test asserting allocations stay at 2 over eight frames. The budget test asserts 50ms at 4K rather than a tight bound, deliberately: a tight bound fails on a loaded machine and gets deleted, which is worse than a loose one that still catches the regression that matters. TD-1 is amended rather than joined by a TD-6: on Android the overlay rides the readback that already exists there, roughly doubling that transfer while peaking is on, and TD-1's own "Done when" removes both because both are the same missing capability. Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings green, which also compiles peaking.slint through dr-ui's build.rs; 11 focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass. Not verified: the cfg(target_os = "android") arm, which the host-target clippy never compiled. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/src/focus.rs | 1007 +++++++++++++++++++++++ core/dr-gpu/src/lib.rs | 2 + core/dr-gpu/src/shaders/focus_peak.wgsl | 141 ++++ docs/technical-debt.md | 21 + ui/dr-ui/src/develop.rs | 125 ++- ui/dr-ui/src/lib.rs | 127 +++ ui/dr-ui/src/peaking.rs | 160 ++++ ui/dr-ui/ui/app.slint | 49 ++ ui/dr-ui/ui/peaking.slint | 150 ++++ 9 files changed, 1781 insertions(+), 1 deletion(-) create mode 100644 core/dr-gpu/src/focus.rs create mode 100644 core/dr-gpu/src/shaders/focus_peak.wgsl create mode 100644 ui/dr-ui/src/peaking.rs create mode 100644 ui/dr-ui/ui/peaking.slint diff --git a/core/dr-gpu/src/focus.rs b/core/dr-gpu/src/focus.rs new file mode 100644 index 0000000..11818af --- /dev/null +++ b/core/dr-gpu/src/focus.rs @@ -0,0 +1,1007 @@ +//! TRACES: FR-CULL-3 | NFR-P14 +//! Focus peaking — saying what is sharp, so nobody has to zoom in to find out. +//! +//! # What this is for +//! +//! FR-CULL-3 lists three raw-truth overlays and gives the reason for this one +//! plainly: peaking "removes the largest single source of culling latency from +//! the critical path". Checking focus by zooming to 1:1 costs a render, a pan +//! to the subject's eye, and a zoom back, per frame, on a folder of three +//! thousand. D11 keeps 1:1 available for certainty; this is what makes reaching +//! for it the exception. +//! +//! It is *raw*-truth for the same reason the histogram beside it is: what this +//! measures is the frame the develop pipeline rendered from sensor data +//! through the demosaic, not the camera's embedded JPEG. A JPEG has already +//! been sharpened by the body, at a strength and radius nobody outside the +//! manufacturer knows, and peaking on one measures that sharpening at least as +//! much as it measures the lens. +//! +//! # Why a layer, and not a tint in the picture +//! +//! The first shape tried was the obvious one — read the display frame, write +//! the same frame with marked pixels replaced, hand *that* to the compositor. +//! It is wrong, and `app.slint` had already written down why, on the region +//! overlay it composites over the canvas: a diagnostic "must not reach the +//! histogram, an export, or the texture the develop pass hands the +//! compositor". +//! +//! All three would have happened. `HistogramPass` counts whatever texture the +//! session last rendered, so a red mark on every in-focus edge would have +//! arrived in the histogram as a red spike and in the clipping figure as blown +//! highlights. So this pass writes its **own** texture, transparent everywhere +//! except where something is in focus, and the interface lays it over the +//! canvas. The photograph is untouched by construction rather than by care. +//! +//! # Why it stays on the GPU +//! +//! Per-pixel work over the whole frame, every settled frame, is exactly what +//! ARCH §6.1 exists about: the measurement on this project puts a 4K readback +//! at 7.4 ms against a 0.28 ms compute pass, 96% of it transfer. The marks are +//! produced where the pixels already are and handed to the compositor as a +//! texture, and nothing in this file can read a pixel back on the display path +//! — see [`FocusPeakPass::read_overlay`] for the one transfer that exists and +//! who is allowed to call it. +//! +//! # Two textures, alternating +//! +//! For the reason [`crate::AdjustPass`] keeps two: Slint decides whether to +//! repaint by comparing the image property against its previous value, and two +//! images wrapping the same `wgpu::Texture` compare equal. A pass that always +//! wrote one texture would compute a new overlay every frame and never once be +//! asked to show it. +//! +//! # What it costs +//! +//! One dispatch, nine texture loads per pixel, no readback and no +//! reallocation at a steady viewport size. NFR-P14 allows 100 ms after the +//! preview on desktop and 150 ms on Android; this is two orders of magnitude +//! inside that, and the assertion in `overlay_is_ready_well_inside_the_budget` +//! is what keeps the claim honest rather than remembered. + +use wgpu::util::DeviceExt as _; + +use crate::readback::await_mapping; +use crate::{GpuContext, GpuError}; + +/// How much local contrast counts as focus. +/// +/// Three steps rather than a slider, because the number underneath is not one +/// a photographer can reason about and the choice being made is coarse: *this +/// frame is noisy, mark less* or *this subject is low-contrast, mark more*. +/// Every camera that offers peaking offers it this way. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum PeakSensitivity { + /// The high-ISO setting. Marks only edges nothing but focus explains. + Low, + #[default] + Medium, + /// For a low-contrast subject — fur, fabric, distant foliage — at the + /// price of marking noise as well. + High, +} + +impl PeakSensitivity { + /// The luma difference, 0..1, at which a pixel is called in focus. + /// + /// **Where these three numbers come from.** A hard one-pixel step of + /// height *D* produces a response of `0.375 D` (see the shader), so a + /// threshold *t* marks any sharp edge whose contrast exceeds `t / 0.375`. + /// At `Medium` that is 0.107 in luma — about 27 code values — which is a + /// perfectly ordinary edge and well above what a photograph's own texture + /// produces by accident. + /// + /// **And what bounds them from below is noise, not taste.** Sensor noise + /// surviving into an 8-bit render is a few code values; call it a standard + /// deviation of 0.008. The response subtracts a mean of eight independent + /// neighbours from one sample, so its standard deviation is + /// `0.008 * sqrt(1 + 1/8)` = 0.0085. `Medium` sits 4.7 of those out, which + /// a normal tail puts at roughly one pixel in a million; `High` sits 2.4 + /// out, which is about one pixel in a hundred — visible as a dusting on a + /// noisy frame, which is the trade the setting is named for. `Low` is 9.4 + /// out and will not mark noise at all. + /// + /// Noise reduction, if the edit has any, has already run by the time this + /// pass sees the frame, so those figures are the pessimistic end. + pub fn threshold(self) -> f32 { + match self { + PeakSensitivity::Low => 0.080, + PeakSensitivity::Medium => 0.040, + PeakSensitivity::High => 0.020, + } + } +} + +/// What colour the marks are drawn in. +/// +/// A choice rather than a constant, and the reason is NFR-A11Y-3's: status +/// must not rest on hue alone. An overlay's information *is* positional — it +/// says where, not what — so the requirement is not violated by there being a +/// colour; it would be violated by there being only one, because a red mark on +/// a red jersey conveys nothing, and a photographer with a red-green +/// deficiency looking at foliage is in the same position permanently. +/// +/// Four fully saturated choices, no mixtures. A desaturated mark has to +/// compete with the photograph for the same colours, which is the one thing a +/// mark must not do. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum PeakColour { + /// The convention, and what most cameras do. + #[default] + Red, + /// For a red or warm subject, and the most visible of the four on a dark + /// frame. + Yellow, + /// For a warm photograph as a whole, and the choice that survives a + /// red-green deficiency. + Cyan, + /// For a cool or green photograph — the hue photographs contain least. + Magenta, +} + +impl PeakColour { + /// The mark, as the encoded triple the overlay is written in. + pub fn rgb(self) -> [f32; 3] { + match self { + PeakColour::Red => [1.0, 0.0, 0.0], + PeakColour::Yellow => [1.0, 1.0, 0.0], + PeakColour::Cyan => [0.0, 1.0, 1.0], + PeakColour::Magenta => [1.0, 0.0, 1.0], + } + } +} + +/// TRACES: FR-CULL-3 +/// Everything the photographer chose about the overlay. +/// +/// One value rather than two arguments, because the two travel together +/// everywhere — into the session, into the sidecar-free view state, back out +/// to the panel — and a pair that is always passed together is a type. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct FocusPeaking { + pub sensitivity: PeakSensitivity, + pub colour: PeakColour, +} + +/// The dispatch's view of the frame. Padded to std140's 16 bytes before the +/// marker, exactly as the shader's `Params` declares it. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct Params { + width: u32, + height: u32, + threshold: f32, + pad_0: u32, + marker: [f32; 4], +} + +/// One overlay texture. +struct Layer { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, +} + +/// TRACES: FR-CULL-3 | NFR-P14 +/// Produces the focus-peaking overlay for a rendered frame. +pub struct FocusPeakPass { + ctx: GpuContext, + pipeline: wgpu::ComputePipeline, + bind_group_layout: wgpu::BindGroupLayout, + params: wgpu::Buffer, + /// Alternating overlay textures — see the module documentation for why + /// there are two rather than one. + layers: [Option; 2], + current: usize, + /// Textures allocated since this pass was created. Exists to be asserted + /// on: an overlay reallocated per frame instead of per resize costs a + /// great deal of bandwidth and looks identical in the picture, which is + /// the shape of regression only a counter can see. + allocations: usize, + dispatches: usize, +} + +impl FocusPeakPass { + /// The overlay's format. + /// + /// The same `Rgba8Unorm` [`crate::AdjustPass`] writes, and for the same + /// non-negotiable reason: it is one of the two formats Slint's texture + /// import accepts. The alpha channel is what carries the overlay, so the + /// eight bits it has are seven more than this needs. + pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + + pub fn new(ctx: &GpuContext) -> Result { + // A validation error here is a bug in the shader beside this file + // rather than anything a user did, so it is caught in an error scope + // and returned — wgpu's default handler panics. + let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + + let module = ctx + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("focus-peak"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/focus_peak.wgsl").into()), + }); + + let bind_group_layout = + ctx.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("focus-peak-bgl"), + entries: &[ + // The rendered frame, read with `textureLoad` — the + // same texture the compositor is showing, so what is + // measured is what is on screen. + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: Self::FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + + let layout = ctx + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("focus-peak-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = ctx + .device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("focus-peak-pipeline"), + layout: Some(&layout), + module: &module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + + if let Some(err) = pollster::block_on(scope.pop()) { + return Err(GpuError::ShaderCompilation(err.to_string())); + } + + let params = ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("focus-peak-params"), + contents: bytemuck::bytes_of(&Params { + width: 0, + height: 0, + threshold: PeakSensitivity::default().threshold(), + pad_0: 0, + marker: [0.0; 4], + }), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + Ok(Self { + ctx: ctx.clone(), + pipeline, + bind_group_layout, + params, + layers: [None, None], + current: 0, + allocations: 0, + dispatches: 0, + }) + } + + /// TRACES: FR-CULL-3 | NFR-P14 + /// Mark the in-focus regions of `frame`, returning the overlay to lay + /// over it. + /// + /// `frame` must carry `TEXTURE_BINDING`, which [`crate::AdjustPass`]'s + /// output does because the compositor samples it. + /// + /// **Give this a settled frame, not a draft one.** The measure is the + /// energy in the top octave of what it is handed, so it is a statement + /// about a particular sampling grid: at half resolution — which is what a + /// draft frame is rendered at — a defocused edge spanning four pixels + /// spans two, which is the signature of a sharp one. Peaking a draft frame + /// would mark the out-of-focus background of every photograph, briefly, + /// during every drag. The interface runs this where it runs the histogram, + /// on the settled frame, and for the same class of reason. + pub fn render( + &mut self, + frame: &wgpu::Texture, + settings: FocusPeaking, + ) -> Result<&wgpu::Texture, GpuError> { + // No zero-size guard: wgpu will not create a texture with a zero + // extent, so a frame that exists has at least one pixel in it. + let (width, height) = (frame.width(), frame.height()); + self.ensure_layer(width, height); + + let rgb = settings.colour.rgb(); + self.ctx.queue.write_buffer( + &self.params, + 0, + bytemuck::bytes_of(&Params { + width, + height, + threshold: settings.sensitivity.threshold(), + pad_0: 0, + marker: [rgb[0], rgb[1], rgb[2], 1.0], + }), + ); + + let layer = self.layers[self.current] + .as_ref() + .expect("ensure_layer just built it"); + let source = frame.create_view(&Default::default()); + let bind_group = self + .ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("focus-peak-bg"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&layer.view), + }, + ], + }); + + let mut enc = self + .ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("focus-peak-encoder"), + }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("focus-peak-pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + // Submitted on its own queue entry after the render that produced + // `frame`. Submission order is the whole of the synchronisation, as it + // is between the fused pass and the detail chain: one queue, and this + // reads what that wrote. + self.ctx.queue.submit(Some(enc.finish())); + self.dispatches += 1; + + Ok(&self.layers[self.current] + .as_ref() + .expect("just written") + .texture) + } + + /// The overlay the last [`Self::render`] wrote, if there has been one. + pub fn overlay(&self) -> Option<&wgpu::Texture> { + self.layers[self.current].as_ref().map(|l| &l.texture) + } + + /// Forget the overlay, so nothing stale is composited. + /// + /// Called when the photograph changes and when peaking is switched off. + /// The alternative — leaving the last overlay resident and merely not + /// drawing it — is one interface bug away from laying one photograph's + /// focus marks over another's, which is the single worst thing an + /// instrument like this can do. + pub fn clear(&mut self) { + self.layers = [None, None]; + self.current = 0; + } + + /// Overlay textures allocated since this pass was created. + /// + /// For tests. A steady viewport must not move this number, and the only + /// evidence of that is a counter — a per-frame reallocation renders + /// identically to a cached one. + pub fn allocations(&self) -> usize { + self.allocations + } + + /// Dispatches encoded since this pass was created. + /// + /// The other half of [`Self::allocations`]: together they say that eight + /// frames cost eight dispatches and two textures, which is the shape a + /// steady viewport is supposed to have. + pub fn dispatches(&self) -> usize { + self.dispatches + } + + /// Make sure the current slot holds a texture of this size. + /// + /// Rotates first, so consecutive frames land in different textures — see + /// the module documentation. A size change drops both, because neither + /// fits any more and a stale one of the wrong size would be composited + /// stretched over the new frame. + fn ensure_layer(&mut self, width: u32, height: u32) { + self.current ^= 1; + let fits = self.layers[self.current] + .as_ref() + .is_some_and(|l| l.width == width && l.height == height); + if fits { + return; + } + let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("focus-peak-overlay"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: Self::FORMAT, + // STORAGE_BINDING to be written by the dispatch and + // TEXTURE_BINDING to be sampled by the compositor. + // RENDER_ATTACHMENT is not used by anything here and is required + // anyway: Slint rejects an imported texture without it. COPY_SRC + // is for `read_overlay` and its two callers. + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + self.layers[self.current] = Some(Layer { + texture, + view, + width, + height, + }); + self.allocations += 1; + } + + /// TRACES: AC-8 + /// Copy the overlay to the CPU, as RGBA8 rows with no padding. + /// + /// **Two callers, and neither is the desktop display path.** The tests + /// below are one: an overlay is a claim about which pixels are sharp, and + /// there is no way to check that claim without looking at the pixels. The + /// other is the Android develop view, which reads the *frame* back for the + /// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain + /// tears a portrait window, so Slint is not drawing with wgpu there and no + /// texture can be handed over. An overlay that stayed on the device on a + /// platform where the picture underneath it does not would simply never be + /// seen. + /// + /// On desktop nothing calls this, and ARCH §6.1 holds on the path that + /// matters: the overlay reaches the compositor as a texture. + pub fn read_overlay(&self) -> Result<(Vec, u32, u32), GpuError> { + let Some(layer) = self.layers[self.current].as_ref() else { + return Err(GpuError::Readback("no overlay has been rendered".into())); + }; + let (w, h) = (layer.width, layer.height); + + let unpadded = w * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("focus-peak-readback"), + size: (padded * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut enc = self.ctx.device.create_command_encoder(&Default::default()); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &layer.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + self.ctx.queue.submit(Some(enc.finish())); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + await_mapping(&self.ctx, &rx)?; + + let data = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded * h) as usize); + for row in 0..h { + let start = (row * padded) as usize; + out.extend_from_slice(&data[start..start + unpadded as usize]); + } + drop(data); + buf.unmap(); + Ok((out, w, h)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> Option { + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } + } + + /// Upload a greyscale frame the pass can read, the way `AdjustPass` hands + /// its output over. + fn frame(ctx: &GpuContext, grey: &[f32], width: u32, height: u32) -> wgpu::Texture { + let rgba: Vec = grey + .iter() + .flat_map(|v| { + let c = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + [c, c, c, 255] + }) + .collect(); + let tex = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("focus-peak-test-frame"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + ctx.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + ctx.queue.submit(std::iter::empty()); + tex + } + + /// A vertical step from `dark` to `bright`, blurred with a Gaussian of + /// this `sigma` in pixels. `sigma == 0.0` is the hard edge. + /// + /// The profile is evaluated analytically rather than by convolving a + /// sampled image, so "the same edge, out of focus" is exactly that and not + /// a second thing the test would also have to trust. + fn step(width: u32, height: u32, dark: f32, bright: f32, sigma: f32) -> Vec { + let edge = width as f32 / 2.0 - 0.5; + (0..height) + .flat_map(|_| { + (0..width).map(move |x| { + let d = x as f32 - edge; + let t = if sigma <= 0.0 { + if d < 0.0 { + 0.0 + } else { + 1.0 + } + } else { + // The error function, which is what a Gaussian blur of + // a step is, via a tanh approximation good to ~1e-4 — + // ample, since the assertions below are about orders + // of magnitude rather than about the fourth digit. + 0.5 * (1.0 + (1.202_7 * (d / sigma)).tanh()) + }; + dark + t * (bright - dark) + }) + }) + .collect() + } + + /// How many pixels of an overlay carry a mark, and what colour they are. + fn marks(pixels: &[u8]) -> (usize, Vec<[u8; 3]>) { + let mut count = 0; + let mut colours: Vec<[u8; 3]> = Vec::new(); + for px in pixels.chunks_exact(4) { + if px[3] != 0 { + count += 1; + let c = [px[0], px[1], px[2]]; + if !colours.contains(&c) { + colours.push(c); + } + } + } + (count, colours) + } + + fn peak( + ctx: &GpuContext, + pass: &mut FocusPeakPass, + grey: &[f32], + w: u32, + h: u32, + settings: FocusPeaking, + ) -> Vec { + let tex = frame(ctx, grey, w, h); + pass.render(&tex, settings).expect("peak"); + pass.read_overlay().expect("readback").0 + } + + #[test] + fn the_three_sensitivities_are_ordered_and_none_of_them_is_zero() { + // Arithmetic, so no device. A threshold of zero marks every pixel of + // every photograph — including a flat sky, where the response is + // whatever the last bit of the encoder rounded to — and an overlay + // that covers the frame says nothing at all. + let (low, med, high) = ( + PeakSensitivity::Low.threshold(), + PeakSensitivity::Medium.threshold(), + PeakSensitivity::High.threshold(), + ); + assert!(high > 0.0, "a zero threshold marks everything"); + assert!( + low > med && med > high, + "more sensitive must mean a lower bar: {low} {med} {high}" + ); + assert_eq!(PeakSensitivity::default(), PeakSensitivity::Medium); + } + + #[test] + fn every_colour_is_fully_saturated_and_distinct() { + // The overlay competes with the photograph for attention, and a + // desaturated mark loses. Each choice must also be a different colour + // from the others — two entries that render the same would be a menu + // that lies. + let mut seen: Vec<[f32; 3]> = Vec::new(); + for c in [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, + ] { + let rgb = c.rgb(); + assert!( + rgb.iter().all(|v| *v == 0.0 || *v == 1.0), + "{c:?} is not a saturated primary: {rgb:?}" + ); + assert!(rgb.iter().any(|v| *v > 0.0), "{c:?} is black"); + assert!(!seen.contains(&rgb), "{c:?} duplicates another choice"); + seen.push(rgb); + } + } + + #[test] + fn a_flat_frame_is_marked_nowhere() { + // The floor. An overlay that marks an empty sky is one a photographer + // stops believing within a frame, and there is nothing subtle about + // the failure — it would be the whole picture. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + + let flat = vec![0.45f32; 64 * 64]; + let px = peak(&ctx, &mut pass, &flat, 64, 64, FocusPeaking::default()); + assert_eq!(marks(&px).0, 0, "flat grey produced marks"); + } + + #[test] + fn a_sharp_edge_is_marked_and_the_same_edge_defocused_is_not() { + // **The claim the whole overlay rests on**, and the one a gradient + // detector would fail: these two frames have identical contrast and + // differ only in how many pixels the transition is spread over. If + // both are marked, the overlay is an edge detector wearing focus + // peaking's name and it will light up every out-of-focus background + // ever photographed. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (64u32, 16u32); + + let sharp = step(w, h, 0.25, 0.75, 0.0); + let soft = step(w, h, 0.25, 0.75, 2.0); + + let marked_sharp = marks(&peak( + &ctx, + &mut pass, + &sharp, + w, + h, + FocusPeaking::default(), + )) + .0; + let marked_soft = marks(&peak(&ctx, &mut pass, &soft, w, h, FocusPeaking::default())).0; + + // Two columns of the sharp edge respond — the pixel each side of the + // transition — so a full-height edge marks 2 per row. + assert_eq!( + marked_sharp, + (2 * h) as usize, + "a hard edge should mark the column each side of it, on every row" + ); + assert_eq!( + marked_soft, 0, + "the same edge at sigma 2 is out of focus and must not be marked" + ); + } + + #[test] + fn sensitivity_decides_how_faint_an_edge_still_counts() { + // The setting doing what its name says, from both sides. A hard edge + // of 0.08 contrast responds at `0.375 * 0.08` = 0.031 — above `High`'s + // bar of 0.020 and below `Medium`'s of 0.040 — so the same photograph + // must be marked at one setting and clean at the other. Without this, + // three menu entries that all behaved identically would pass every + // other test in this file. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (64u32, 8u32); + let faint = step(w, h, 0.46, 0.54, 0.0); + + let at = |pass: &mut FocusPeakPass, sensitivity| { + marks(&peak( + &ctx, + pass, + &faint, + w, + h, + FocusPeaking { + sensitivity, + ..Default::default() + }, + )) + .0 + }; + + assert!( + at(&mut pass, PeakSensitivity::High) > 0, + "a faint but sharp edge is exactly what High is for" + ); + assert_eq!( + at(&mut pass, PeakSensitivity::Medium), + 0, + "Medium should hold its bar above an 0.08 edge" + ); + assert_eq!(at(&mut pass, PeakSensitivity::Low), 0); + } + + #[test] + fn the_mark_is_the_colour_that_was_asked_for_and_the_rest_is_transparent() { + // Both halves matter. The colour, because a menu that quietly draws + // red whatever is chosen is worse than not offering the choice; and + // the transparency, because the overlay is composited over the + // photograph and an alpha of even 1/255 across the frame is a veil + // over every judgement made through it. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (32u32, 8u32); + let sharp = step(w, h, 0.2, 0.8, 0.0); + + for colour in [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, + ] { + let px = peak( + &ctx, + &mut pass, + &sharp, + w, + h, + FocusPeaking { + colour, + ..Default::default() + }, + ); + let (count, colours) = marks(&px); + assert!(count > 0, "{colour:?} marked nothing"); + let expected: [u8; 3] = colour.rgb().map(|v| (v * 255.0).round() as u8); + assert_eq!(colours, vec![expected], "{colour:?} drew the wrong ink"); + + // Everything unmarked is fully transparent *and* black, which is + // what makes the layer correct whether the compositor treats it as + // premultiplied or not. + for texel in px.chunks_exact(4) { + if texel[3] == 0 { + assert!( + texel[..3].iter().all(|c| *c == 0), + "an unmarked texel carried colour under a zero alpha" + ); + } else { + assert_eq!(texel[3], 255, "a mark was drawn part-way transparent"); + } + } + } + } + + #[test] + fn the_edge_of_the_frame_is_not_marked_by_its_own_edge() { + // The clamp in `neighbour`. Wrapping instead would fold the right-hand + // column of the picture into the left-hand one's neighbourhood, and a + // photograph whose two sides differ — which is most of them — would be + // marked down both borders regardless of focus. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (33u32, 17u32); + + // Dark on the left, bright on the right, with the transition a long + // way from either border. + let split = step(w, h, 0.1, 0.9, 0.0); + let px = peak(&ctx, &mut pass, &split, w, h, FocusPeaking::default()); + + for y in 0..h { + for x in [0u32, w - 1] { + let a = px[(((y * w + x) * 4) + 3) as usize]; + assert_eq!(a, 0, "the frame border at ({x}, {y}) was marked"); + } + } + // A size that is not a multiple of the 8x8 workgroup, so the edge + // groups run off the image: every texel must still have been written. + assert_eq!(px.len(), (w * h * 4) as usize); + } + + #[test] + fn consecutive_overlays_are_different_textures() { + // Slint compares the image property by value to decide whether to + // repaint, so two frames wrapping one texture compare equal and the + // second is never drawn. `AdjustPass` keeps two targets for this + // reason; the overlay beside it has to do the same or it freezes on + // whatever it first showed. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 16 * 16]; + + let tex = frame(&ctx, &grey, 16, 16); + let first = + std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("first")) as usize; + let second = std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("second")) + as usize; + assert_ne!( + first, second, + "two consecutive overlays landed in the same texture" + ); + } + + #[test] + fn a_steady_viewport_allocates_nothing_after_the_first_two_frames() { + // Reallocating a viewport-sized texture per frame costs a great deal + // of bandwidth and renders identically, which is why this is a counter + // and not an assertion about the picture. Two allocations, not one: + // the pair alternates, so the second frame builds the other slot. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 64 * 64]; + let tex = frame(&ctx, &grey, 64, 64); + + for _ in 0..8 { + pass.render(&tex, FocusPeaking::default()).expect("render"); + } + assert_eq!(pass.dispatches(), 8); + assert_eq!( + pass.allocations(), + 2, + "the overlay was reallocated per frame" + ); + + // A resize is the one thing that must reallocate: a stale overlay of + // the wrong size would be composited stretched over the new frame. + let small = vec![0.5f32; 32 * 32]; + let smaller = frame(&ctx, &small, 32, 32); + pass.render(&smaller, FocusPeaking::default()) + .expect("render"); + assert_eq!(pass.allocations(), 3); + } + + #[test] + fn a_cleared_pass_has_no_overlay_to_composite() { + // Switching peaking off, or opening a different photograph, must leave + // nothing behind. One interface bug away from laying one frame's focus + // marks over another's, which is the worst thing an instrument can do: + // be confidently about the wrong picture. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 16 * 16]; + let tex = frame(&ctx, &grey, 16, 16); + pass.render(&tex, FocusPeaking::default()).expect("render"); + assert!(pass.overlay().is_some()); + + pass.clear(); + assert!(pass.overlay().is_none()); + assert!( + pass.read_overlay().is_err(), + "a cleared pass must not hand out a stale overlay" + ); + } + + #[test] + fn the_overlay_is_ready_well_inside_its_budget() { + // NFR-P14: ready within 100 ms of the preview on desktop, 150 ms on + // Android. The bound asserted here is 50 ms at 4K, which is half the + // desktop budget and a third of Android's, and is still around two + // orders of magnitude above what the dispatch actually costs — chosen + // so that an unrelated machine under load does not fail the suite, + // while a change that made this a multi-pass or readback-bound + // operation could not possibly stay under it. + // + // The readback is deliberately outside the timed region. It is a + // property of this test rather than of the overlay, and at 4K it is + // the 7 ms transfer ARCH §6.1 exists to keep off the frame path. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (3840u32, 2160u32); + let tex = frame(&ctx, &step(w, h, 0.2, 0.8, 0.0), w, h); + + // One frame to allocate the layer and warm the pipeline; a resize is + // not what the budget is about. + pass.render(&tex, FocusPeaking::default()).expect("warm"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("idle"); + + let start = std::time::Instant::now(); + pass.render(&tex, FocusPeaking::default()).expect("render"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("idle"); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_millis() < 50, + "the overlay took {elapsed:?} at {w}x{h}; NFR-P14 allows 100 ms" + ); + } +} diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 705ceb1..785b055 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -21,6 +21,7 @@ mod adjust; mod demosaic; mod detail; mod error; +mod focus; mod histogram; mod mask; mod readback; @@ -33,6 +34,7 @@ pub use adjust::AdjustPass; pub use demosaic::{DemosaicedImage, Demosaicer}; pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT; pub use error::GpuError; +pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity}; // Renamed on the way out: `BINS` says enough inside `histogram`, and nothing // at all at a crate root shared with demosaic and segmentation. pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS}; diff --git a/core/dr-gpu/src/shaders/focus_peak.wgsl b/core/dr-gpu/src/shaders/focus_peak.wgsl new file mode 100644 index 0000000..098cba2 --- /dev/null +++ b/core/dr-gpu/src/shaders/focus_peak.wgsl @@ -0,0 +1,141 @@ +// TRACES: FR-CULL-3 | NFR-P14 +// Marking what is sharp, in a layer laid over the frame rather than into it. +// +// # Why the top octave, and not a gradient +// +// The obvious detector is a gradient magnitude — Sobel, or a central +// difference — and it is the wrong one, for a reason that decides whether the +// overlay is useful at all. A gradient answers "is there an edge here", and a +// defocused edge is still an edge: blur a 100-code step with a two-pixel +// Gaussian and the peak gradient is still around 20 codes per pixel, larger +// than a genuinely sharp edge across a low-contrast texture. Peaking built on +// gradients lights up the out-of-focus background of every portrait ever +// taken, which is the frame it exists to reject. +// +// What separates sharp from soft is *scale*, not amplitude. Defocus is a +// low-pass: it removes the top octave and leaves everything below it intact. +// So the detector is a high-pass — this pixel against the mean of its eight +// neighbours, a discrete Laplacian — which by construction responds only to +// the frequencies defocus destroys. +// +// The arithmetic, on a one-dimensional step of height D: +// +// | profile | abs(centre - mean of 8) | +// |--------------------------|-------------------------| +// | hard step, 1 px | 0.375 D | +// | Gaussian blur, sigma 1 | ~0.10 D | +// | Gaussian blur, sigma 2 | ~0.03 D | +// | linear ramp, any slope | 0 | +// +// The ramp row is the property being bought: the smooth luminance falloff +// across an out-of-focus highlight scores zero however bright it is. +// +// # Why luma, and why the histogram's luma +// +// One channel rather than three, because a colour edge carrying no luminance +// difference is both rare and, at the acuity an overlay is read at, invisible. +// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709 +// weighting on the same encoded values — so the two instruments in this +// application agree about what "luma" means. Two definitions of brightness in +// one panel is the kind of disagreement nobody finds until it has already +// misled someone. +// +// # Why the frame is read where it is encoded, and not in linear light +// +// This runs on the output of the display transform, on encoded values, and +// that is deliberate: a fixed difference in sRGB code values is roughly +// equally visible wherever it sits in the range, which is what a transfer +// curve is for. Measured in linear light the same detector would need a +// threshold that varied with exposure, and a shadow texture the photographer +// can plainly see would score a hundredth of the identical texture in the +// highlights. The encoding has already done the normalisation, so the +// threshold is one number. +// +// # Why this writes a layer and not the picture +// +// The frame the compositor is handed is also what the histogram counts and +// what an export renders (`app.slint`, on the region overlay: a diagnostic +// "must not reach the histogram, an export, or the texture the develop pass +// hands the compositor"). So the marks go in their own texture — transparent +// everywhere except where something is in focus — and the compositor blends +// them. Nothing about the photograph changes, and the peaking overlay cannot +// leak into a measurement or a file. +// +// Alpha is written as exactly 0 or exactly 1, never between. The importing +// compositor's convention for whether colour arrives premultiplied is not +// something this shader can see, and at those two values the two conventions +// agree — which is a cheaper guarantee than being right about which one it is. + +struct Params { + width: u32, + height: u32, + // Luma difference at which a pixel is called in focus. See + // `PeakSensitivity::threshold` for where the three values come from. + threshold: f32, + // std140 rounds the scalar block up to 16 bytes before the vec4; named so + // the Rust struct's padding is visibly the same shape. + pad_0: u32, + // The mark's colour, fully saturated. Its alpha is ignored — see above. + marker: vec4, +} + +@group(0) @binding(0) var frame: texture_2d; +@group(0) @binding(1) var params: Params; +@group(0) @binding(2) var marks: texture_storage_2d; + +/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl` +/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity. +fn luma(c: vec3) -> f32 { + return dot(c, vec3(54.0, 183.0, 19.0) / 256.0); +} + +/// A neighbour, with the frame edge held rather than wrapped. +/// +/// Clamping duplicates the edge pixel into the missing half of the +/// neighbourhood, which pulls the mean towards the centre and so biases the +/// response *down* on the outermost row and column. That is the right +/// direction to be wrong in: the failure is a missing mark at the frame edge, +/// where nobody is judging focus, rather than a false mark produced by +/// folding the opposite side of the picture into the kernel. +fn neighbour(x: i32, y: i32) -> f32 { + let cx = clamp(x, 0i, i32(params.width) - 1i); + let cy = clamp(y, 0i, i32(params.height) - 1i); + return luma(textureLoad(frame, vec2(cx, cy), 0).rgb); +} + +// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine +// invocations and no workgroup-memory tile is built to avoid that: at viewport +// resolution the reads are perfectly coherent and the texture cache serves +// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch +// measured in tenths of a millisecond, so there is nothing here worth the +// complexity of a tiled load. +@compute @workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= params.width || gid.y >= params.height) { + return; + } + let x = i32(gid.x); + let y = i32(gid.y); + + // The eight neighbours, centre excluded. Excluded rather than folded in + // because it makes the response readable: `abs(c - mean8)` is the height + // of this pixel above its surroundings in the same units as the step it + // sits on, so the threshold can be quoted as a luma difference rather than + // as eight-ninths of one. + var sum = 0.0; + for (var dy = -1; dy <= 1; dy = dy + 1) { + for (var dx = -1; dx <= 1; dx = dx + 1) { + if (dx != 0 || dy != 0) { + sum = sum + neighbour(x + dx, y + dy); + } + } + } + let centre = luma(textureLoad(frame, vec2(x, y), 0).rgb); + let response = abs(centre - sum / 8.0); + + if (response >= params.threshold) { + textureStore(marks, vec2(x, y), vec4(params.marker.rgb, 1.0)); + } else { + textureStore(marks, vec2(x, y), vec4(0.0, 0.0, 0.0, 0.0)); + } +} diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 057157c..fb972b7 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -55,6 +55,27 @@ readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in not the bill. **It has not been measured on the device**, which is the first thing to do if the develop view feels heavy on the tablet; do not assume this is the cause without a number. +### And a second transfer, while focus peaking is on + +Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own +`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is +no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on +the device while the picture underneath it did not would simply never be seen. So +`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path +carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one. + +This is recorded under TD-1 rather than as its own entry because it is not an independent choice. +It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the +canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it: +whichever of the three fixes above lands removes the readback for the frame and the overlay +together, because both are the same missing capability. + +Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same +gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement. +And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay` +returns on its first line when peaking is off, so with it off there is no dispatch and no transfer, +and the Android frame path is exactly what it was before this feature existed. + ### Paying it off Any one of these removes it: diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 199dac9..d900dba 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -14,7 +14,8 @@ use std::sync::Arc; use dr_decode::RawImage; use dr_gpu::{ - AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass, + AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram, + HistogramPass, MaskPass, }; use dr_pipeline::mask::{MaskLayer, MaskSource}; @@ -722,6 +723,25 @@ pub struct DevelopSession { /// old driver, a device without the storage-buffer atomics it needs — the /// photographer loses the histogram and keeps the photograph. histogram: Option, + /// TRACES: FR-CULL-3 + /// The focus-peaking overlay, on the same terms as the histogram above: + /// optional, because a device that cannot compile the pass is still a + /// device that can develop the photograph. What is lost is an instrument, + /// not the picture. + peak: Option, + /// TRACES: FR-CULL-3 + /// What the photographer asked the overlay to look like, or `None` for + /// off. + /// + /// **Interface state, not part of the edit** — the same category as + /// `show_overlay` beside it. It changes no pixel of the photograph, it is + /// not in the sidecar, and it is not on the undo stack: pressing undo + /// after switching peaking on should take back the last *edit*, not the + /// last thing looked at. + /// + /// An `Option` rather than a bool plus a settings field, so that "off" and + /// "on, in some configuration" cannot disagree with each other. + peaking: Option, /// TRACES: FR-DEV-3 /// The region map local masks select from, once it has been computed. @@ -889,6 +909,10 @@ impl DevelopSession { histogram: HistogramPass::new(ctx) .inspect_err(|e| log::warn!("no histogram on this device: {e}")) .ok(), + peak: FocusPeakPass::new(ctx) + .inspect_err(|e| log::warn!("no focus peaking on this device: {e}")) + .ok(), + peaking: None, segmentation: None, masks: None, subjects: None, @@ -2903,6 +2927,105 @@ impl DevelopSession { .ok() } + /// TRACES: FR-CULL-3 + /// Whether this device could build the focus-peaking overlay. + /// + /// Asked by the interface so that it can say the overlay is unavailable + /// rather than offer a switch that does nothing. The same courtesy the + /// histogram is not paid, and should be: a control that silently does + /// nothing is worse than one that is visibly absent. + pub fn peaking_available(&self) -> bool { + self.peak.is_some() + } + + /// TRACES: FR-CULL-3 + /// What the overlay is set to, or `None` when it is off. + pub fn peaking(&self) -> Option { + self.peaking + } + + /// TRACES: FR-CULL-3 + /// Switch the overlay on with these settings, or off. + /// + /// Asking for peaking on a device that could not build the pass leaves it + /// off, so that [`Self::peaking`] never claims something is being drawn + /// that is not. Switching off drops the overlay textures rather than + /// merely stopping drawing them: a resident overlay from the last frame is + /// one interface bug away from being laid over the next photograph. + pub fn set_peaking(&mut self, settings: Option) { + self.peaking = settings.filter(|_| self.peak.is_some()); + if self.peaking.is_none() { + if let Some(pass) = self.peak.as_mut() { + pass.clear(); + } + } + } + + /// TRACES: FR-CULL-3 | NFR-P14 + /// Mark the in-focus regions of the frame that is currently on the canvas. + /// + /// **Reads the frame [`Self::render`] last produced**, exactly as + /// [`Self::histogram`] does and for the same reason: the overlay has to + /// describe what the photographer is looking at, and rendering a second + /// time to measure it would cost a pass and admit the possibility of the + /// two disagreeing about the picture. + /// + /// That the frame is the displayed one is what makes the marks land where + /// the eye is. It is at viewport resolution, cropped and zoomed as the + /// view is, and — the point of FR-CULL-3 — descended from sensor data + /// through the demosaic rather than from the camera's embedded JPEG, whose + /// in-body sharpening this would otherwise be measuring at least as much + /// as the lens. + /// + /// **Call this only after a settled render.** See + /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame + /// cannot be measured for sharpness. + /// + /// `None` where nothing has been rendered, where peaking is off, or where + /// the device could not build the pass. + pub fn focus_overlay(&mut self) -> Option { + let settings = self.peaking?; + // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, + // and holding a shared borrow of `self.adjust` across the mutable + // borrow of `self.peak` would cost a `Self { .. }` destructure to say + // something the clone says in one word. + let frame = self.adjust.output()?.clone(); + let pass = self.peak.as_mut()?; + let overlay = pass + .render(&frame, settings) + .inspect_err(|e| log::warn!("focus peaking failed: {e}")) + .ok()? + .clone(); + + #[cfg(not(target_os = "android"))] + { + // A layer over the canvas rather than a tint in it, so nothing + // here reaches the histogram or an export — see `FocusPeakPass` + // for the whole of that argument. + slint::Image::try_from(overlay) + .inspect_err(|e| log::warn!("the focus overlay is not importable: {e}")) + .ok() + } + + // Android draws with Skia over OpenGL and cannot sample a + // `wgpu::Texture`, so the overlay follows the frame it belongs to back + // through memory (technical-debt.md TD-1). The measurement still + // happens on the GPU; only this last hop does not. + #[cfg(target_os = "android")] + { + let _ = overlay; + let (rgba, w, h) = pass + .read_overlay() + .inspect_err(|e| log::warn!("reading the focus overlay back: {e}")) + .ok()?; + let mut buf = slint::SharedPixelBuffer::::new(w, h); + let wanted = (w as usize) * (h as usize) * 4; + let src = &rgba[..wanted.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + Some(slint::Image::from_rgba8(buf)) + } + } + /// Render the *whole* frame for the crop overlay to be drawn over. /// /// Crop mode cannot use [`Self::render`]: that applies the crop, so the diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 70fd502..361a9c2 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -39,6 +39,7 @@ mod library_ui; mod live_style; mod masks_ui; mod net_runtime; +mod peaking; mod presets; mod remote; mod segmentation; @@ -316,6 +317,12 @@ fn reset_view_state(window: &AppWindow) { // beside the next one's filename is a confident, precise lie, and the gap // before the new frame settles is exactly long enough to read it. window.set_histogram(histogram::empty()); + // TRACES: FR-CULL-3 + // The marks go down with it, and for the same reason. What is *not* reset + // is whether peaking is switched on: that is a way of looking at a folder + // rather than a property of one photograph, so it survives to the next + // frame — see `chosen_peaking` for the whole of that argument. + window.set_focus_overlay_ready(false); // TRACES: FR-DEV-3 // The region map belongs to one photograph. Carrying the stack, the // overlay or the crosshair to the next one would offer a selection of @@ -1464,11 +1471,24 @@ pub fn run(paths: Vec) -> Result<()> { // is redrawn, and those are very different rates. let drawn_history: Rc>> = Rc::new(Cell::new(None)); + // TRACES: FR-CULL-3 + // How the photographer wants focus peaking drawn, or `None` for off. + // + // **Held here rather than on the session, which is the opposite of where + // every edit lives.** A session is one photograph; peaking is a way of + // *looking* at a folder of them. Someone culling three thousand frames + // switches it on once, and a flag that reset with the session would ask + // them to switch it on three thousand times — which is why + // `reset_view_state` deliberately leaves it alone while emptying the + // histogram beside it. + let chosen_peaking: Rc>> = Rc::new(Cell::new(None)); + let render_now: Render = { let session = session.clone(); let viewport = viewport.clone(); let drawn_history = drawn_history.clone(); let display = display.clone(); + let chosen_peaking = chosen_peaking.clone(); Rc::new(move |window: &AppWindow, draft: bool| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; @@ -1529,6 +1549,16 @@ pub fn run(paths: Vec) -> Result<()> { // arrival takes. spots_ui::sync_panel(window, s); + // TRACES: FR-CULL-3 + // The session owns the pass and the interface owns the choice, so + // they are joined here — on the one path every frame takes, which + // is also what makes a photograph opened with peaking already on + // arrive with its marks rather than without them. + if s.peaking() != chosen_peaking.get() { + s.set_peaking(chosen_peaking.get()); + } + window.set_peaking_available(s.peaking_available()); + let (mut w, mut h) = *viewport.borrow(); // **Half resolution while the gesture is still moving.** @@ -1591,6 +1621,34 @@ pub fn run(paths: Vec) -> Result<()> { .map_or_else(histogram::empty, histogram::view), ); } + + // TRACES: FR-CULL-3 | NFR-P14 + // **Marked on the settled frame and no other**, and unlike + // the histogram beside it the marks are taken *down* in + // between rather than left standing. + // + // The reason is not budget — the dispatch is a fraction of + // a millisecond and would fit inside a draft frame + // comfortably. It is that peaking measures the top octave + // of the frame it is given, and a draft frame is rendered + // at half resolution: a defocused edge that spans four + // pixels there spans two, which is the signature of a + // sharp one. Measuring it would mark the out-of-focus + // background of every photograph, briefly, during every + // drag. A stale overlay is no better, because a pan moves + // the picture out from under it. + // + // So the marks pause while a control is moving and return + // when it stops, which the panel says out loud rather than + // leaving to be discovered. + let overlay = (!draft).then(|| s.focus_overlay()).flatten(); + match overlay { + Some(image) => { + window.set_focus_overlay(image); + window.set_focus_overlay_ready(true); + } + None => window.set_focus_overlay_ready(false), + } } Err(e) => { log::warn!("render failed: {e}"); @@ -1598,6 +1656,11 @@ pub fn run(paths: Vec) -> Result<()> { // No frame, so nothing to describe. The stale plot would // otherwise sit beside the error message looking current. window.set_histogram(histogram::empty()); + // TRACES: FR-CULL-3 + // And nothing to mark. Focus marks over the last frame + // that rendered, beside a message saying this one did not, + // is the same confident lie in a second instrument. + window.set_focus_overlay_ready(false); } } }) @@ -2818,6 +2881,70 @@ pub fn run(paths: Vec) -> Result<()> { }); } + // TRACES: FR-CULL-3 + // The peaking switch and its two choices. + // + // All three write `chosen_peaking` and then redraw, because the marks are + // produced by a compute pass over the rendered frame: there is nothing the + // interface can change about the overlay that does not require the frame + // to be measured again. Turning peaking *off* redraws for the same reason + // — that render is what drops the overlay textures and clears the flag. + { + let weak = window.as_weak(); + let chosen = chosen_peaking.clone(); + let redraw = redraw.clone(); + window.on_peaking_toggled(move |on| { + let Some(w) = weak.upgrade() else { return }; + // Built from the chips as they currently stand rather than from a + // remembered value: they are what the photographer can see, and an + // overlay that came back in a configuration the panel is not + // showing would be the panel lying about itself. + let next = on.then(|| dr_gpu::FocusPeaking { + sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()), + colour: peaking::colour(w.get_peaking_colour()), + }); + chosen.set(next); + w.set_peaking_on(next.is_some()); + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let chosen = chosen_peaking.clone(); + let redraw = redraw.clone(); + window.on_peaking_sensitivity_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + w.set_peaking_sensitivity(index); + // Only reachable while peaking is on — the chips are not drawn + // otherwise — but written as a conditional rather than an + // `expect`, because a panel is free to change its mind about that + // and nothing here should fall over when it does. + if let Some(mut current) = chosen.get() { + current.sensitivity = peaking::sensitivity(index); + chosen.set(Some(current)); + redraw(&w); + } + }); + } + { + let weak = window.as_weak(); + let chosen = chosen_peaking.clone(); + let redraw = redraw.clone(); + window.on_peaking_colour_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + w.set_peaking_colour(index); + if let Some(mut current) = chosen.get() { + current.colour = peaking::colour(index); + chosen.set(Some(current)); + redraw(&w); + } + }); + } + // The chips open on whatever the vocabulary calls its default, so the + // panel and the pass agree before anything has been pressed. + window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default())); + window.set_peaking_colour(peaking::colour_index(Default::default())); + // TRACES: FR-DSP-8 | FR-DSP-6 // And which display that canvas is on, from now until the window closes. display_ui::attach(&window, &display, &viewport, redraw.clone()); diff --git a/ui/dr-ui/src/peaking.rs b/ui/dr-ui/src/peaking.rs new file mode 100644 index 0000000..70666d6 --- /dev/null +++ b/ui/dr-ui/src/peaking.rs @@ -0,0 +1,160 @@ +//! TRACES: FR-CULL-3 +//! The focus-peaking vocabulary, as the indices a chip row can carry. +//! +//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the +//! marks. This decides how a menu of three sensitivities and four colours +//! crosses the boundary into Slint, which has no notion of a Rust enum and +//! carries the choice as an `int` into an array of labels. +//! +//! That translation is small and it is the kind of small that goes wrong +//! silently. An index the interface sends that Rust reads as a different +//! variant produces a control that changes something other than what it says, +//! which nobody notices as a bug — they notice it as peaking behaving oddly. +//! So the order lives in one place here, both directions are asserted to round +//! trip, and a test checks that the labels in `ui/peaking.slint` still number +//! the same as the vocabularies they claim to name. +//! +//! Free-standing functions over plain integers, deliberately, for the reason +//! `crate::histogram` gives: none of this needs a GPU, a window or a +//! photograph to be checked, and all of it is invisible when wrong. + +use dr_gpu::{PeakColour, PeakSensitivity}; + +/// The sensitivities, in the order the chip row shows them. +/// +/// Least sensitive first, so the row reads left to right as "mark less" to +/// "mark more" — the axis the photographer is actually moving along. +pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [ + PeakSensitivity::Low, + PeakSensitivity::Medium, + PeakSensitivity::High, +]; + +/// The mark colours, in the order the chip row shows them. +pub(crate) const COLOURS: [PeakColour; 4] = [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, +]; + +/// The sensitivity an index names. +/// +/// Out of range falls back to the default rather than panicking. The index +/// arrives from the interface, and the interface is the half of this that can +/// be recompiled without recompiling the other — a chip row that grew an entry +/// should degrade to a sane setting, not take the application down mid-cull. +pub(crate) fn sensitivity(index: i32) -> PeakSensitivity { + usize::try_from(index) + .ok() + .and_then(|i| SENSITIVITIES.get(i).copied()) + .unwrap_or_default() +} + +/// The colour an index names, on the same terms. +pub(crate) fn colour(index: i32) -> PeakColour { + usize::try_from(index) + .ok() + .and_then(|i| COLOURS.get(i).copied()) + .unwrap_or_default() +} + +/// Which chip is lit for this sensitivity. +pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 { + SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32 +} + +/// Which chip is lit for this colour. +pub(crate) fn colour_index(value: PeakColour) -> i32 { + COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_variant_appears_exactly_once_in_its_row() { + // A variant missing from the row is a setting the photographer cannot + // reach; one listed twice is two chips that do the same thing, of + // which only the first can ever look selected. Both are invisible in + // the running application until somebody presses the wrong chip. + for s in SENSITIVITIES { + assert_eq!( + SENSITIVITIES.iter().filter(|x| **x == s).count(), + 1, + "{s:?} is listed more than once" + ); + } + for c in COLOURS { + assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1); + } + // Named rather than counted, so adding a variant to `dr_gpu` without + // adding it here fails to compile instead of passing quietly. + assert!(SENSITIVITIES.contains(&PeakSensitivity::Low)); + assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium)); + assert!(SENSITIVITIES.contains(&PeakSensitivity::High)); + assert!(COLOURS.contains(&PeakColour::Red)); + assert!(COLOURS.contains(&PeakColour::Yellow)); + assert!(COLOURS.contains(&PeakColour::Cyan)); + assert!(COLOURS.contains(&PeakColour::Magenta)); + } + + #[test] + fn an_index_and_its_variant_agree_in_both_directions() { + // The failure this catches is a chip that lights up under the pointer + // while a different setting takes effect — the two directions drifting + // apart is exactly what one shared array is here to prevent, and the + // only way to see it is to go round. + for (i, s) in SENSITIVITIES.iter().enumerate() { + assert_eq!(sensitivity(i as i32), *s); + assert_eq!(sensitivity_index(*s), i as i32); + } + for (i, c) in COLOURS.iter().enumerate() { + assert_eq!(colour(i as i32), *c); + assert_eq!(colour_index(*c), i as i32); + } + } + + #[test] + fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() { + // Slint has no bound on the `int` it sends and Rust has no way to + // refuse one. A panic here would be an application that closes because + // a chip row was edited. + assert_eq!(sensitivity(-1), PeakSensitivity::default()); + assert_eq!(sensitivity(99), PeakSensitivity::default()); + assert_eq!(colour(-1), PeakColour::default()); + assert_eq!(colour(99), PeakColour::default()); + } + + #[test] + fn the_panel_offers_exactly_the_choices_this_module_knows_about() { + // **The one seam neither compiler checks.** The labels live in + // `ui/peaking.slint` and the meanings live here, joined only by an + // integer; a fifth colour added to the chip row would send index 4 to + // `colour`, which would quietly answer Red. Reading the file is + // clumsier than a derive, and it is what there is. + let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint")) + .expect("the panel this module serves"); + + let listed = |line_start: &str| -> usize { + let line = src + .lines() + .map(str::trim) + .find(|l| l.starts_with(line_start)) + .unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint")); + line.matches('"').count() / 2 + }; + + assert_eq!( + listed("options: [\"Low\""), + SENSITIVITIES.len(), + "the sensitivity chips and `SENSITIVITIES` disagree" + ); + assert_eq!( + listed("options: [\"Red\""), + COLOURS.len(), + "the colour chips and `COLOURS` disagree" + ); + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index cc693c5..b160981 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -10,6 +10,7 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint"; import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; +import { FocusMarks, FocusPanel } from "peaking.slint"; import { SettingsPage } from "settings.slint"; import { ImportPage } from "import.slint"; import { StatusBar, InfoPanel } from "develop.slint"; @@ -70,6 +71,22 @@ export component AppWindow inherits Window { /// of a draft frame is a histogram of an image nobody is reading. in property histogram; + /// TRACES: FR-CULL-3 + /// Focus peaking: the marks, whether they describe *this* frame, and the + /// three things the photographer chose. All of them are Rust's, because + /// the marks come from a compute pass — see `peaking.slint` for why + /// `focus-overlay-ready` is a separate question from `peaking-on`. + in property focus-overlay; + in property focus-overlay-ready: false; + in property peaking-on: false; + in property peaking-available: true; + in property peaking-sensitivity: 1; + in property peaking-colour: 0; + + callback peaking-toggled(bool); + callback peaking-sensitivity-picked(int); + callback peaking-colour-picked(int); + // --- zoom, pan and crop (FR-DEV-4) --- // // Zoom is a *viewing* state, not an edit: it changes the resolution the @@ -1617,6 +1634,18 @@ in property panel-visible: true; image-rendering: ImageRendering.pixelated; } + // TRACES: FR-CULL-3 + // The focus marks, over the same fitted rect. See + // `peaking.slint` for why they are a layer over the canvas + // rather than a tint in it. + if root.focus-overlay-ready && root.total > 0: FocusMarks { + x: parent.shown-x; + y: parent.shown-y; + width: parent.shown-w; + height: parent.shown-h; + marks: root.focus-overlay; + } + // Where the photograph actually sits inside this box. // // `image-fit: contain` letterboxes, and Slint does not report @@ -2150,6 +2179,26 @@ in property panel-visible: true; background: Theme.rule; } + // TRACES: FR-CULL-3 + // Under the histogram, because the two are the same + // kind of thing: instruments that report on the + // photograph rather than change it. Kept in every + // mode for the same reason the histogram is. + FocusPanel { + available: root.peaking-available; + showing: root.peaking-on; + sensitivity: root.peaking-sensitivity; + colour: root.peaking-colour; + toggled(v) => { root.peaking-toggled(v); } + sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); } + colour-picked(i) => { root.peaking-colour-picked(i); } + } + + Rectangle { + height: 1px; + background: Theme.rule; + } + // Framing above the colour work, matching how the edit is // made rather than how it is applied: the frame is decided // by eye first and the pipeline runs it last (see diff --git a/ui/dr-ui/ui/peaking.slint b/ui/dr-ui/ui/peaking.slint new file mode 100644 index 0000000..3821e55 --- /dev/null +++ b/ui/dr-ui/ui/peaking.slint @@ -0,0 +1,150 @@ +// TRACES: FR-CULL-3 +// The focus-peaking switch, and the two choices it exposes. +// +// **An instrument, not an operation**, exactly as the histogram above it is: +// it has no parameters in the edit graph, changes nothing about the +// photograph, and answers a question rather than asking one. So it is written +// by hand rather than generated from a descriptor, and FR-DEV-3a is untroubled +// by it — nothing here names an operation or reads a parameter out of one. +// +// **Both choices are words, not swatches.** The colour picker is the obvious +// place to draw four coloured squares, and NFR-A11Y-3 is the reason not to: +// a control for choosing between hues, presented only as hues, is unusable by +// the person most likely to need to change it. The chips say "Red" and "Cyan". +// +// **Why the two chip rows only exist while peaking is on.** They are settings +// for something that is not happening, and the develop column is the +// photographer's instrument panel — every row it holds is a slider pushed +// below the fold. The panel's own height is bound to its content, so the +// column reflows rather than leaving a gap. + +import { Theme } from "theme.slint"; +import { Button, PanelHeading, Caption } from "widgets.slint"; +import { Segmented } from "controls.slint"; + +// TRACES: FR-CULL-3 +// The marks themselves, composited over the canvas. +// +// **A layer over the photograph and not a tint in it**, which is the same rule +// `app.slint` states on the region map: a diagnostic "must not reach the +// histogram, an export, or the texture the develop pass hands the compositor". +// `HistogramPass` counts whatever the develop pass last rendered, so marks +// painted into that frame would arrive in the histogram as a spike and in the +// clipping figure as blown highlights. The layer is transparent everywhere +// except where something is in focus. +// +// **No `source-clip` and no rotation**, unlike the region map. That is a +// source-space picture being windowed down to the visible part; this was +// measured on the rendered frame itself, so it is already cropped, zoomed and +// turned exactly as the canvas is. One fewer thing that can drift out of +// registration. +// +// The caller places it on `canvas-area`'s fitted rect, which is the shared +// contract for anything that lands on the picture. +export component FocusMarks inherits Image { + /// The overlay `DevelopSession::focus_overlay` produced for this frame. + in property marks; + + source: root.marks; + image-fit: fill; + // Nearest-neighbour: a mark is one pixel wide, and smoothing spreads it + // into a grey haze that reads as softness — the opposite of what it is + // reporting. + image-rendering: ImageRendering.pixelated; +} + +// TRACES: FR-CULL-3 +export component FocusPanel inherits Rectangle { + /// Whether this device could build the overlay at all. + /// + /// A compute pass can fail to compile on a driver nobody here has, and the + /// honest response is to say so rather than to offer a switch that does + /// nothing when pressed. The develop view keeps working without it; only + /// this panel changes. + in property available: true; + /// Whether the overlay is currently being drawn. + /// + /// `showing` rather than the obvious `on`: Slint has no reserved word + /// there today, and a one-word property that might become one is not worth + /// the bet on a panel this small. + in property showing: false; + /// Index into `PeakSensitivity`, in the order Rust declares it. + in property sensitivity: 1; + /// Index into `PeakColour`, likewise. + in property colour: 0; + + callback toggled(bool); + callback sensitivity-picked(int); + callback colour-picked(int); + + background: Theme.surface; + + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it clips itself. The develop + /// column is the largest of these and nothing else; see `SpotPanel` and + /// `HistogramPanel` for the whole protocol. + /// + /// Both chip rows wrap at three, which is what keeps this number at the + /// narrowest column the application supports rather than at four chips + /// abreast — a single row of four would set the width of the entire + /// sidebar for every other panel in it. + out property content-width: layout.preferred-width; + min-width: root.content-width; + + // Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both + // give: a nested conditional layout under-reports its height here and the + // rows below it get drawn on top of one another. Every row carries its own + // `if`. + layout := VerticalLayout { + padding: Theme.gap; + spacing: Theme.gap-sm; + alignment: start; + + PanelHeading { text: "FOCUS"; } + + if !root.available: Caption { + text: "This device could not build the overlay."; + wrap: word-wrap; + } + + if root.available: Button { + // The label states the action rather than the state, as the mask + // overlay's does: a photographer reads a button for what pressing + // it will do. + text: root.showing ? "Hide focus peaking" : "Show focus peaking"; + active: root.showing; + clicked => { root.toggled(!root.showing); } + } + + if root.available && root.showing: Segmented { + label: "Sensitivity"; + hint: "lower on a noisy frame"; + options: ["Low", "Medium", "High"]; + selected: root.sensitivity; + columns: 3; + picked(i) => { root.sensitivity-picked(i); } + } + + if root.available && root.showing: Segmented { + label: "Marks"; + hint: "pick what the subject is not"; + options: ["Red", "Yellow", "Cyan", "Magenta"]; + selected: root.colour; + columns: 3; + picked(i) => { root.colour-picked(i); } + } + + if root.available && root.showing: Caption { + // Said once, here, rather than left to be discovered: the marks go + // away while a control is moving because a half-resolution draft + // frame cannot be measured for sharpness (see `FocusPeakPass`). + // + // Kept to one short sentence on purpose. A wrapping Text reports + // its *unwrapped* width as its preferred one, and this panel's + // `content-width` is what the develop column sizes itself from — + // a paragraph here would hold the whole sidebar open. + text: "Marks pause while a control is dragged."; + wrap: word-wrap; + } + } +} From 3e19628324c642d24ee565dfc7f6d7a92b84c013 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 29 Aug 2026 21:49:33 +0200 Subject: [PATCH 10/10] Stop a subject's name from setting the width of the develop column The column moved sideways the moment segmentation finished. It takes the widest minimum any panel declares, each panel publishes `layout.preferred-width` as that minimum, and `MaskPanel` gained rows whose width came from the model's output -- so a photograph the user was looking at jumped because a label said "traffic light". `overflow: elide` did not prevent it and was never going to. Eliding is what a `Text` does when it draws; at layout time it still asks for the width of its whole string, and it is the asking that reaches the column. Both the subject rows and the mask entries are bounded, because a mask is itself a segmentation result -- without the second half the column moved when a subject was clicked instead of when one was found. The bound is stated for the reason `ChipGrid` declares its width from its column count rather than from its options (ff6c313): what a panel asks for must follow from its structure, never from its data. 160px is the same judgement as that commit's 88px chip, and it is a policy rather than a measurement -- there is no harness in the tree that measures a panel's width, and the 482-to-351 figure in ff6c313 was read off the running app. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/ui/masks.slint | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index 1851779..aacccea 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -137,10 +137,18 @@ component MaskEntry inherits Rectangle { alignment: center; spacing: 0px; + // Both lines below are bounded for the reason the subject + // row is: a mask's label and kind come from the model, and an + // unbounded `Text` asks for its whole string at layout time + // even when `elide` means it will never draw it. A mask *is* a + // segmentation result, so without this the column moved when a + // subject was clicked as well as when one was found. Label { text: root.data.label; emphasised: root.data.selected || touch.has-hover; overflow: elide; + min-width: 0px; + max-width: 160px; } Caption { @@ -148,6 +156,8 @@ component MaskEntry inherits Rectangle { // targets in a row, and wrapping would give the rows of a // stack different heights for no gain. overflow: elide; + min-width: 0px; + max-width: 160px; // Three states worth distinguishing, and each has a // different remedy: stale needs the segmentation re-run, // unadjusted needs a slider moved, and the ordinary case @@ -418,6 +428,30 @@ export component MaskPanel inherits Rectangle { emphasised: subject-row.has-hover; horizontal-stretch: 1; overflow: elide; + // **`elide` is a paint-time behaviour, and this is a + // layout-time problem.** A `Text` asks for the width of + // its whole string whether or not it will draw all of + // it, so without a stated maximum this row asked for + // whatever the model happened to return, that became + // `layout.preferred-width`, the panel publishes that as + // its `min-width`, and the develop column takes the + // widest minimum any panel declares. The column + // therefore moved the instant segmentation finished — + // a photograph the user was looking at, jumping + // sideways because a label said "traffic light". + // + // Stated as a maximum for the reason `ChipGrid` + // declares its width from its column count rather than + // from its options: what a panel asks for must follow + // from its structure, never from its data. Past this + // the row elides, which is what `elide` was for. + // + // 160px is the same judgement as `ChipGrid`'s 88px + // chip — comfortable for the class names this model + // returns, and narrow enough that a subject list + // cannot be what sets the column. + min-width: 0px; + max-width: 160px; } Value { text: round(subject.score * 100) + "%"; } }