Merge master into partial-preset-scope
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s
# Conflicts: # docs/traceability.md # ui/dr-ui/ui/app.slint
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -21,6 +22,7 @@ mod adjust;
|
||||
mod demosaic;
|
||||
mod detail;
|
||||
mod error;
|
||||
mod focus;
|
||||
mod histogram;
|
||||
mod mask;
|
||||
mod readback;
|
||||
@@ -33,6 +35,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};
|
||||
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<uniform> params: Params;
|
||||
@group(0) @binding(2) var marks: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
/// 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>) -> f32 {
|
||||
return dot(c, vec3<f32>(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<i32>(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<u32>) {
|
||||
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<i32>(x, y), 0).rgb);
|
||||
let response = abs(centre - sum / 8.0);
|
||||
|
||||
if (response >= params.threshold) {
|
||||
textureStore(marks, vec2<i32>(x, y), vec4<f32>(params.marker.rgb, 1.0));
|
||||
} else {
|
||||
textureStore(marks, vec2<i32>(x, y), vec4<f32>(0.0, 0.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! TRACES: R3
|
||||
//! The develop pipeline — operations, descriptors, and shader composition.
|
||||
//!
|
||||
//! # What this crate is
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! TRACES: R6 | NFR-SEC-3
|
||||
//! Nextcloud connector.
|
||||
//!
|
||||
//! One of two [`RemoteBackend`] implementations, registered through
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,358 @@
|
||||
# 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. 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
|
||||
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.
|
||||
@@ -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:
|
||||
|
||||
+71
-71
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright 2026 Duncan Tourolle -->
|
||||
<component type="desktop-application">
|
||||
<!--
|
||||
The component id, the .desktop basename and the Flatpak application id are
|
||||
one string, not three that happen to agree. `dr_ui::run` sets the same
|
||||
string as the Wayland app_id and winit's WM_CLASS, so a rename that misses
|
||||
one of them costs the icon in the shell, the association in the software
|
||||
centre, or both, and neither failure names itself.
|
||||
-->
|
||||
<id>paris.tourolle.darkroom</id>
|
||||
|
||||
<!--
|
||||
Two licence fields, two different things, and they are meant to differ.
|
||||
`metadata_license` covers *this file* — software centres redistribute and
|
||||
reformat catalogue metadata, so it has to be under something that permits
|
||||
that unconditionally, which GPLv3 does not. `project_license` is the
|
||||
application's own licence and is the one that must read GPL-3.0-or-later
|
||||
to match D8 and the workspace manifest.
|
||||
-->
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<name>DarkRoom</name>
|
||||
<summary>Non-destructive RAW photo library and editor</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>Where it differs from the tools it sits beside:</p>
|
||||
<ul>
|
||||
<li>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</li>
|
||||
<li>Sidecars are the record of an edit; the catalog is a cache that can be deleted and rebuilt</li>
|
||||
<li>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</li>
|
||||
<li>Faces are detected and grouped locally — nothing is uploaded to identify anybody</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">paris.tourolle.darkroom.desktop</launchable>
|
||||
<provides>
|
||||
<binary>darkroom-desktop</binary>
|
||||
</provides>
|
||||
|
||||
<url type="homepage">https://gitea.tourolle.paris/dtourolle/DarkRoom</url>
|
||||
<url type="bugtracker">https://gitea.tourolle.paris/dtourolle/DarkRoom/issues</url>
|
||||
<url type="vcs-browser">https://gitea.tourolle.paris/dtourolle/DarkRoom</url>
|
||||
|
||||
<developer id="paris.tourolle">
|
||||
<name>Duncan Tourolle</name>
|
||||
</developer>
|
||||
|
||||
<categories>
|
||||
<category>Graphics</category>
|
||||
<category>Photography</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>RAW</keyword>
|
||||
<keyword>photography</keyword>
|
||||
<keyword>develop</keyword>
|
||||
<keyword>darkroom</keyword>
|
||||
<keyword>catalog</keyword>
|
||||
</keywords>
|
||||
|
||||
<!--
|
||||
D15 decided the target devices are a 12-inch tablet and a desktop, with no
|
||||
phone. Stating that here is what stops a software centre offering the
|
||||
application on hardware the interface was never laid out for: the develop
|
||||
view puts a photograph beside a parameter panel, and below roughly 768
|
||||
logical pixels there is no arrangement of the two that is worth using.
|
||||
`recommends` rather than `requires` for the input devices — touch alone is
|
||||
usable, it is simply not what the sliders were designed around.
|
||||
-->
|
||||
<requires>
|
||||
<display_length compare="ge">768</display_length>
|
||||
</requires>
|
||||
<recommends>
|
||||
<control>pointing</control>
|
||||
<control>keyboard</control>
|
||||
<control>touch</control>
|
||||
</recommends>
|
||||
|
||||
<content_rating type="oars-1.1"/>
|
||||
|
||||
<releases>
|
||||
<release version="0.9.0" date="2026-08-29"/>
|
||||
</releases>
|
||||
</component>
|
||||
+124
-1
@@ -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<HistogramPass>,
|
||||
/// 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<FocusPeakPass>,
|
||||
/// 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<FocusPeaking>,
|
||||
|
||||
/// 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<FocusPeaking> {
|
||||
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<FocusPeaking>) {
|
||||
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<slint::Image> {
|
||||
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::<slint::Rgba8Pixel>::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
|
||||
|
||||
@@ -39,6 +39,7 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod masks_ui;
|
||||
mod net_runtime;
|
||||
mod peaking;
|
||||
mod preset_store;
|
||||
mod presets;
|
||||
mod remote;
|
||||
@@ -317,6 +318,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
|
||||
@@ -1465,11 +1472,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// is redrawn, and those are very different rates.
|
||||
let drawn_history: Rc<Cell<Option<u64>>> = 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<Cell<Option<dr_gpu::FocusPeaking>>> = 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 };
|
||||
@@ -1530,6 +1550,16 @@ pub fn run(paths: Vec<PathBuf>) -> 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.**
|
||||
@@ -1592,6 +1622,34 @@ pub fn run(paths: Vec<PathBuf>) -> 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}");
|
||||
@@ -1599,6 +1657,11 @@ pub fn run(paths: Vec<PathBuf>) -> 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);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2844,6 +2907,70 @@ pub fn run(paths: Vec<PathBuf>) -> 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());
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, Progres
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { StatusBar, InfoPanel } from "develop.slint";
|
||||
@@ -71,6 +72,22 @@ export component AppWindow inherits Window {
|
||||
/// of a draft frame is a histogram of an image nobody is reading.
|
||||
in property <HistogramView> 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 <image> focus-overlay;
|
||||
in property <bool> focus-overlay-ready: false;
|
||||
in property <bool> peaking-on: false;
|
||||
in property <bool> peaking-available: true;
|
||||
in property <int> peaking-sensitivity: 1;
|
||||
in property <int> 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
|
||||
@@ -1669,6 +1686,18 @@ in property <bool> 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
|
||||
@@ -2202,6 +2231,26 @@ in property <bool> 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
|
||||
|
||||
@@ -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) + "%"; }
|
||||
}
|
||||
|
||||
@@ -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 <image> 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 <bool> 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 <bool> showing: false;
|
||||
/// Index into `PeakSensitivity`, in the order Rust declares it.
|
||||
in property <int> sensitivity: 1;
|
||||
/// Index into `PeakColour`, likewise.
|
||||
in property <int> 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 <length> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user