Merge master into tablet-selection

Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:52:42 +02:00
co-authored by Claude Opus 5
54 changed files with 7566 additions and 166 deletions
+7
View File
@@ -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/
+1
View File
@@ -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.
+48 -13
View File
@@ -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
+41 -1
View File
@@ -51,7 +51,47 @@ fn android_main(app: slint::android::AndroidApp) {
// After the data dir and before anything asks whether a model is present.
install_bundled_face_models(&app);
if let Err(e) = slint::android::init(app) {
// TRACES: FR-PLAT-AND-5
// The listener is the whole reason this is not the one-line
// `slint::android::init(app)`. Slint owns the event loop on Android, so
// the platform's lifecycle and memory events reach the application only if
// it asks for them here — and it must ask *before* the loop starts, which
// is why this sits between the data directory and `dr_ui::run`.
//
// The listener runs inside `poll_events`, on the same thread the event
// loop and every interface cache live on, which is what lets
// `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a
// cross-thread channel (see its module documentation).
//
// # Why two events and not eight
//
// FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade
// how badly the system wants the memory back. Those levels do not exist
// here: `ComponentCallbacks2` is a Java interface implemented by an
// `Activity` or `Application`, and this app has neither — it is a bare
// `NativeActivity`, whose native callback table offers only the ungraded
// `onLowMemory`. android-activity surfaces exactly that as `LowMemory`.
// Reading the grades would mean shipping a Java subclass to forward them,
// which is a distribution-manifest change and not this one.
//
// `Stop` recovers the one grade that matters most anyway, and for free.
// It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN`
// in all but name — and it is the cheapest possible time to give memory
// back, because nothing that is freed has to be drawn again before anyone
// sees it. `Pause` deliberately does not qualify: a permission dialog or
// the share sheet pauses an activity that is still on screen behind it,
// and throwing away its render pipeline would make every such interruption
// cost a full re-render.
use slint::android::android_activity::{MainEvent, PollEvent};
if let Err(e) = slint::android::init_with_event_listener(app, |event| match event {
PollEvent::Main(MainEvent::LowMemory) => {
dr_ui::memory::relieve(dr_ui::memory::Level::Critical);
}
PollEvent::Main(MainEvent::Stop) => {
dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden);
}
_ => {}
}) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -16,6 +16,7 @@
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
//! - [`faces`] — detected faces, the people they belong to, and who said so
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
//! - [`jobs`] — the durable background work queue
//! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
@@ -33,6 +34,7 @@ use std::path::Path;
use dr_types::{Availability, ImageId};
use rusqlite::Connection;
pub mod bursts;
pub mod cache;
pub mod collections;
pub mod dedup;
@@ -63,7 +65,7 @@ pub use query::{Query, Sort};
pub use rating::{Judgement, MAX_RATING};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
pub use walk::{ensure_root, mark_root_offline, scan_root, RootKind, ScanProgress, ScanReport};
/// One row of the library grid.
///
+80 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 10;
pub const SCHEMA_VERSION: i64 = 11;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 11 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V11)?;
tx.pragma_update(None, "user_version", 11)?;
tx.commit()?;
}
Ok(from)
}
@@ -205,6 +212,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
/// creating it over there would fail on columns that are not there. Nothing is
/// lost by its absence — it exists to make the *grid* page quickly, and the
/// grid never reads across an attachment.
///
/// V11 is excluded on the same grounds and for the plainer reason that a merge
/// has nothing to do with it: burst grouping is rebuilt locally from local
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
pub fn for_attached(schema_name: &str) -> String {
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
// columns are spelled out. A remote genuinely older than V10 is a real
@@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
ALTER TABLE faces ADD COLUMN crop BLOB;
"#;
/// TRACES: FR-CULL-5
/// Burst grouping: which frames are one moment, and which one stands for it.
///
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
/// belongs here is why it is stored in three pieces rather than one.
///
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
/// `content_hash` is: it is one number per image, NULL until something has had
/// the pixels in hand, and every query that wants it is already reading the
/// image row. It is local derived state — a rebuilt catalog recomputes it from
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
/// the shadowing and trashing columns V2 through V5 add.
///
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
/// group is named by the image id of its earliest frame, so a burst that has not
/// changed keeps its name across a regroup and the interface can remember that
/// this one is open. There is no `bursts` table to go with it because a group
/// has no properties beyond its members — inventing a row for it would create an
/// identity that survives the grouping being rebuilt, which is precisely what
/// must not happen.
///
/// **`burst_pick` is the one thing here that is not derived**, and it is a
/// separate table so that rewriting the grouping cannot erase it. A
/// representative stored on `burst_members` would be forgotten every time a
/// frame arrived; the user would be asked the same question after every scan.
/// The same argument `people.ignored` makes in V10, one subsystem over.
///
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
/// so what a collapsed burst hides has to be decided by the query, or the row
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
/// is emptied of stale groups by every pass.
const V11: &str = r#"
-- A 64-bit perceptual signature. Local derived state: NULL until something has
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
-- comparable only to signatures produced by the same build (`bursts`).
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
CREATE TABLE burst_members (
-- One burst at most per image: a frame belongs to the moment it was taken
-- in, and nothing else.
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
-- The image id of the burst's earliest frame. Not a foreign key by
-- accident: the leader is itself a member, so this genuinely references
-- images(id), and cascading its deletion is right.
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
-- The frame the group collapses to. Exactly one per burst.
representative INTEGER NOT NULL DEFAULT 0
);
-- Counting a burst's frames and listing them are what the grid asks for, once
-- per window; without this both are a scan of every grouped frame in the
-- library.
CREATE INDEX burst_members_burst ON burst_members(burst_id);
-- The user's own choice of representative. User data, never rewritten by a
-- grouping pass -- see the module doc above.
CREATE TABLE burst_pick (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
-- Bursts the grid is currently showing in full.
CREATE TABLE burst_expanded (
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
+61 -3
View File
@@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
Ok(next)
}
/// TRACES: FR-CAT-9
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Mark every image under a root as unreachable.
///
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
@@ -445,14 +445,38 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
/// the root now claims something about the files that is no longer known to be
/// true, and only reading the directories again can settle it. Pruning would
/// skip them all and leave a plugged-in library showing as offline forever.
fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
///
/// # Why the ETag goes with the mtime
///
/// The three columns are the same fact told by three kinds of storage: a local
/// directory proves it is unchanged with its mtime and entry count, and a
/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of
/// them and leaving the third would disarm the re-listing on exactly the
/// libraries this is most likely to be called for — a remote scan prunes on
/// the ETag alone, so a root that came back would be walked, found unchanged
/// at every level, pruned whole, and left with every row still marked offline
/// and nothing that would ever clear the mark.
///
/// # Public, because losing a root is not only the local walk's business
///
/// This began as the private end of [`scan_root`]'s root-failure branches,
/// which is the only route a library reached through [`Storage`] can take.
/// The application does not currently take that route at all: it opens
/// libraries through `dr-sync`'s connectors, so the discovery happens in a
/// crate that cannot see this one's internals, and the correct response is
/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the
/// caller that found out — a second copy would be a second thing to remember
/// when the ETag rule below changes.
///
/// [`Storage`]: dr_plat::Storage
pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
let root_id = root.0 as i64;
conn.execute(
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
rusqlite::params![availability_code(Availability::Offline), root_id],
)?;
conn.execute(
"UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1",
"UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1",
[root_id],
)?;
Ok(())
@@ -995,6 +1019,40 @@ mod tests {
);
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
#[test]
fn marking_a_root_offline_forgets_the_remote_validator_too() {
// The half of the marking that only a remote library can notice, and
// the reason it has to be here rather than beside the connector: a
// remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear
// the local mtime and leave the ETag standing and a library that came
// back would be walked, found unchanged at every level, pruned whole,
// and left with every row still marked offline — with nothing that
// would ever clear the mark, because clearing it is something only a
// listing can do.
//
// Written directly because this module never writes an ETag; it is
// `ui/dr-ui/src/library.rs`'s scan that does, against the same table.
let lib = Library::new("etag-forgotten");
lib.file("2026/IMG.CR3", b"raw");
lib.scan();
lib.conn()
.execute(
"UPDATE folders SET etag = 'e1' WHERE root_id = ?1",
[lib.root.0 as i64],
)
.expect("etag");
assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0);
mark_root_offline(lib.conn(), lib.root).expect("mark");
assert_eq!(
lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"),
0,
"an unreachable library must be re-listed, not pruned as unchanged"
);
}
#[test]
fn a_root_that_comes_back_is_available_again() {
// The other half: a drive plugged back in must return the library to
+1 -1
View File
@@ -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
+38
View File
@@ -1026,6 +1026,44 @@ impl AdjustPass {
h
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back every allocation this pass is holding only to be fast again.
///
/// What goes, and why each is safe to lose:
///
/// - **The compiled pipelines**, here and in the detail stage. A pure
/// lookup keyed by structure hash with a compile-on-miss behind it, and
/// unbounded until now — nothing ever removed an entry, so a session
/// that visited enough distinct edit structures accumulated shader
/// objects for the life of the process.
/// - **The detail intermediates**, which are viewport-sized `Rgba16Float`
/// and, as `detail.rs` says of them, grow but never shrink.
/// - **The two output textures.** Dropping these does not take the picture
/// off the screen: whatever was handed to the compositor holds its own
/// reference to the `wgpu::Texture`, so releasing ours only means the
/// *next* render allocates rather than reuses. `ensure_target` already
/// treats an empty slot as "allocate", because that is the state it
/// starts in.
///
/// **`colour_key` must be cleared with them, and this is the part that
/// would bite.** The key is the promise that slot 0 of the detail pool
/// still holds the fused colour result, and it is what lets a sharpening
/// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the
/// promise stood would make the next detail-only render sample a
/// just-allocated texture with nothing in it — a silently wrong frame, not
/// a failure, and one that would only appear on a device under memory
/// pressure.
///
/// What deliberately stays: the demosaiced source is not this pass's to
/// drop, the film tables are set once by a caller that will not be asked
/// again, and the bind group layouts are bytes rather than megabytes.
pub fn release_caches(&mut self) {
self.cache.clear();
self.detail.release_caches();
self.targets = [None, None];
self.colour_key = None;
}
/// How many distinct pipelines are compiled. Exposed for tests asserting
/// that slider movement does not recompile.
pub fn cached_pipelines(&self) -> usize {
+31
View File
@@ -157,6 +157,24 @@ impl Intermediates {
self.allocations += 1;
}
}
/// TRACES: FR-PLAT-AND-5
/// Drop the pool, leaving it as [`Intermediates::new`] left it.
///
/// The size is reset along with the slots, not merely because it is tidy:
/// [`Self::ensure`] only refills when the count is short *or* the size
/// differs, so a pool cleared while still claiming its old dimensions is
/// indistinguishable from one that never held anything — which is fine
/// here, and would stop being fine the moment `ensure` grew a fast path
/// that trusted the stored size. `allocations` deliberately keeps
/// counting: it exists so a test can see textures being made, and a
/// counter reset on eviction would hide a reallocation storm rather than
/// report one.
fn release(&mut self) {
self.slots.clear();
self.width = 0;
self.height = 0;
}
}
/// Runs the detail stage.
@@ -526,6 +544,19 @@ impl DetailRunner {
self.cache.len()
}
/// TRACES: FR-PLAT-AND-5
/// Give back everything this stage is only holding to be fast.
///
/// Both pools and the pipeline cache. Nothing here is state: a pool slot
/// is re-created by the next [`Intermediates::ensure`] and a pipeline by
/// the next compile-on-miss, so the only cost of this call is the work of
/// doing both again.
pub(crate) fn release_caches(&mut self) {
self.cache.clear();
self.pool.release();
self.reduced.release();
}
/// How many intermediate textures have been allocated since this pass was
/// created. For tests — see [`crate::MaskPass::allocations`] for the
/// regression this shape of counter exists to catch.
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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};
+141
View File
@@ -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));
}
}
+53
View File
@@ -413,3 +413,56 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
assert_eq!(pass.detail_dispatches(), 0);
assert_eq!(pass.detail_allocations(), 0);
}
/// TRACES: FR-PLAT-AND-5
#[test]
fn eviction_gives_the_pools_back_without_changing_a_pixel() {
// The half of memory-pressure eviction that cannot be checked by looking
// at a counter. `release_caches` frees the detail pool, and slot 0 of that
// pool is where the fused colour result lives between frames — so the
// render after an eviction has to notice that the promise recorded in
// `colour_key` no longer holds and run the colour chain again.
//
// Leave the key standing and this test does not error: it draws. It draws
// whatever a freshly-allocated texture happens to contain, which is the
// failure worth building a test around, because on a device it would
// appear only under memory pressure and only as a wrong-looking photograph.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = step_edge(&ctx, SIZE);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.05);
let before = render(&ctx, &mut pass, &graph, &source, SIZE);
assert!(
pass.cached_pipelines() > 0,
"the colour pass compiled something"
);
assert!(
pass.cached_detail_pipelines() > 0,
"so did the detail stage"
);
let allocations = pass.detail_allocations();
assert!(allocations > 0, "and the pool holds textures");
pass.release_caches();
assert_eq!(pass.cached_pipelines(), 0);
assert_eq!(pass.cached_detail_pipelines(), 0);
// The same edit at the same size. Nothing about the picture changed, so
// nothing about the pixels may change either — only what it cost.
let after = render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(before.len(), after.len());
for (i, (a, b)) in before.iter().zip(&after).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"byte {i}: {a} before eviction, {b} after — the colour chain did \
not re-run, so this frame is reading an empty intermediate"
);
}
assert!(
pass.detail_allocations() > allocations,
"the pool was rebuilt, which is the evidence it was really given back"
);
}
+1
View File
@@ -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
+2 -1
View File
@@ -1,3 +1,4 @@
//! TRACES: R3
//! The develop pipeline — operations, descriptors, and shader composition.
//!
//! # What this crate is
@@ -62,7 +63,7 @@ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
};
pub use preset::{Preset, Scope};
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef};
+516
View File
@@ -39,6 +39,8 @@
//! is the whole claim the action makes.
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::descriptor::{OpId, ParamId};
use crate::graph::EditGraph;
@@ -240,6 +242,328 @@ pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId,
Some((cap.id, p.id))
}
// ---------------------------------------------------------------------------
// Named presets
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// Format version of a preset library file.
///
/// Present where `settings.json` has no version field, and the difference is
/// not an inconsistency. Settings are a flat bag of `#[serde(default)]`
/// fields, so an older file is *missing* keys rather than wrong about them and
/// additive change needs no version. This file has structure — blocks, and a
/// name carried in a block header — and a change to what a block *means* is
/// not something a reader can detect by noticing an absent key.
pub const LIBRARY_FORMAT_VERSION: u32 = 1;
/// The file extension for a DarkRoom preset library.
pub const LIBRARY_EXTENSION: &str = "drpl";
/// Why a name was refused.
///
/// A closed set rather than a string, so the interface can say something
/// specific about each and the message is not written here — this crate
/// depends on nothing and has no business holding user-facing prose.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameError {
/// Empty, or nothing but whitespace.
Empty,
/// Contains a character the block header cannot carry: `[`, `]`, or a
/// line break.
///
/// A round-trip constraint rather than a matter of taste. The header is
/// `[preset <name>]`, so a `]` inside the name would make the file parse
/// back as a *different* library, and a newline would make it parse back
/// as two.
Unrepresentable,
}
/// TRACES: FR-DEV-6
/// A set of named presets, as stored.
///
/// # Why one file rather than one file per preset
///
/// A preset per file makes the name a *path*, and every name then has to
/// survive a filesystem: a `/` becomes a directory, a name that differs only
/// in case collides on one platform and not another, and renaming becomes two
/// operations that can half-fail. Here the name is a key in a document, so
/// renaming is a map operation, deleting cannot leave an orphan, and the whole
/// library is written atomically by the same tmp-and-rename the settings store
/// uses.
///
/// The cost is that the file is rewritten whole on every change. A preset is a
/// few dozen floats and a photographer has tens of them, not thousands, so the
/// file is kilobytes; the trade would look different at a scale this is not.
///
/// # Why the sidecar's shape rather than JSON
///
/// A preset *is* the non-default half of a version (see the module note), so
/// the lines here are the lines a sidecar carries, keyed the same way. That
/// makes the two files diffable against each other and lets someone debugging
/// an edit paste a block from one into the other. It also keeps this crate
/// dependency-free, which is the property that lets it be tested without a
/// device (ARCH §6.5a).
///
/// # Ordering
///
/// By name, so the same library always writes the same bytes and a caller may
/// compare content to decide whether a write is needed — the same
/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the
/// same reason.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PresetLibrary {
presets: BTreeMap<String, Preset>,
/// Lines inside a `[preset]` block that were not `op.param = float`.
///
/// Keyed by preset name and written back verbatim, so a build that
/// predates whatever wrote them round-trips the file without discarding
/// it. Unknown *parameters* need no such machinery: [`Preset`] holds
/// whatever keys it was given and resolves them against the descriptors
/// only at apply time, so a parameter this build has never heard of
/// survives simply by being stored.
unknown: BTreeMap<String, Vec<String>>,
}
impl PresetLibrary {
/// Whether a name is storable, and why not if it is not.
///
/// Leading and trailing whitespace is trimmed rather than refused — it is
/// almost always a stray keystroke, and refusing it would mean a dialogue
/// about a space.
pub fn check_name(name: &str) -> Result<String, NameError> {
let name = name.trim();
if name.is_empty() {
return Err(NameError::Empty);
}
if name.contains([']', '[', '\n', '\r']) {
return Err(NameError::Unrepresentable);
}
Ok(name.to_string())
}
/// Store `preset` under `name`, replacing any preset already there.
///
/// Returns whether something was replaced.
///
/// Replacing rather than refusing, with [`Self::contains`] beside it for a
/// caller that wants to ask first: whether overwriting needs a
/// confirmation is a question about the interface, and answering it here
/// would force every caller into the same answer.
///
/// An *empty* preset is stored like any other. A neutral edit is a real
/// thing to save — applying it returns an image to default, which is the
/// fastest "undo everything on these forty frames" there is — and the
/// module note above is the same argument made about the clipboard.
pub fn insert(&mut self, name: &str, preset: Preset) -> Result<bool, NameError> {
let name = Self::check_name(name)?;
let replaced = self.presets.insert(name, preset).is_some();
Ok(replaced)
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<&Preset> {
self.presets.get(name)
}
/// Whether a preset is stored under `name`.
pub fn contains(&self, name: &str) -> bool {
self.presets.contains_key(name)
}
/// Remove the preset stored under `name`, reporting whether there was one.
pub fn remove(&mut self, name: &str) -> bool {
self.unknown.remove(name);
self.presets.remove(name).is_some()
}
/// Rename `from` to `to`.
///
/// `Ok(false)` means there was nothing called `from` — not an error, since
/// the caller may be acting on a list another window has already changed.
/// Renaming onto an existing name replaces it, for the same reason
/// [`Self::insert`] does.
pub fn rename(&mut self, from: &str, to: &str) -> Result<bool, NameError> {
let to = Self::check_name(to)?;
let Some(preset) = self.presets.remove(from) else {
return Ok(false);
};
if let Some(unknown) = self.unknown.remove(from) {
self.unknown.insert(to.clone(), unknown);
}
self.presets.insert(to, preset);
Ok(true)
}
/// Every stored name, in the order they are written.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.presets.keys().map(String::as_str)
}
/// Every stored preset with its name, in the order they are written.
pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
self.presets.iter().map(|(n, p)| (n.as_str(), p))
}
/// How many presets are stored.
pub fn len(&self) -> usize {
self.presets.len()
}
/// Whether nothing is stored.
pub fn is_empty(&self) -> bool {
self.presets.is_empty()
}
/// Serialise to the on-disk form.
///
/// Deterministic, like the sidecar's: the same library always produces the
/// same bytes.
pub fn to_text(&self) -> String {
let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n");
for (name, preset) in &self.presets {
let _ = write!(out, "\n[preset {name}]\n");
for ((op, param), value) in preset.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
for line in self.unknown.get(name).into_iter().flatten() {
let _ = writeln!(out, "{line}");
}
}
out
}
/// Parse the on-disk form.
///
/// Tolerant on the same terms as the sidecar's parser, and for a weaker
/// version of the same reason: a preset library is not the authoritative
/// store an edit lives in, but it is still work the user did by hand, and
/// one bad line must cost that line rather than the collection. The only
/// hard failures are a file that is not a preset library at all and one
/// written by a newer build, where continuing would mean guessing.
pub fn parse(text: &str) -> Result<Self, LibraryParseError> {
let mut lines = text.lines();
let header = lines.next().unwrap_or_default().trim();
let Some(version) = header.strip_prefix("drpl ") else {
return Err(LibraryParseError::NotALibrary);
};
match version.trim().parse::<u32>() {
Ok(v) if v <= LIBRARY_FORMAT_VERSION => {}
Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)),
Err(_) => return Err(LibraryParseError::NotALibrary),
}
let mut library = Self::default();
let mut current: Option<String> = None;
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(head) = line
.strip_prefix("[preset ")
.and_then(|l| l.strip_suffix(']'))
{
if let Some(name) = current.take() {
library.presets.insert(name, Preset::from_params(params));
params = BTreeMap::new();
}
// A name the writer should never have produced is dropped
// rather than taken: accepting it would mean writing a file
// back out that no longer parses as this one.
match Self::check_name(head) {
Ok(name) => current = Some(name),
Err(_) => {
log::warn!("preset library: unusable preset name {head:?}; skipping");
current = None;
}
}
continue;
}
let Some(name) = current.clone() else {
log::warn!("preset library: line outside any preset: {line}");
continue;
};
match line.split_once('=') {
Some((key, value)) => {
let key = key.trim();
let value = value.trim();
match (key.split_once('.'), value.parse::<f32>()) {
(Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => {
params.insert((op.to_string(), param.to_string()), v);
}
_ => library
.unknown
.entry(name)
.or_default()
.push(line.to_string()),
}
}
None => library
.unknown
.entry(name)
.or_default()
.push(line.to_string()),
}
}
if let Some(name) = current {
library.presets.insert(name, Preset::from_params(params));
}
// A block whose every line was unreadable still produced a preset, and
// an unknown block belonging to no preset would be written back into
// whichever one happened to sort first. Drop the orphans.
library
.unknown
.retain(|k, _| library.presets.contains_key(k));
Ok(library)
}
}
/// Format a value the way the sidecar does — no trailing `.0`, no exponent —
/// so the two files stay comparable line for line.
fn format_value(v: f32) -> String {
let mut s = format!("{v:.6}");
if s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if s == "-0" {
s = "0".to_string();
}
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LibraryParseError {
/// The header line was missing or not a `drpl` header.
NotALibrary,
/// Written by a newer build, in a format this one cannot read.
UnsupportedVersion(u32),
}
impl fmt::Display for LibraryParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotALibrary => f.write_str("not a DarkRoom preset library"),
Self::UnsupportedVersion(v) => {
write!(
f,
"preset library format version {v} is newer than this build"
)
}
}
}
}
impl std::error::Error for LibraryParseError {}
#[cfg(test)]
mod tests {
use super::*;
@@ -563,4 +887,196 @@ mod tests {
.collect();
assert_eq!(excluded, vec![framing::ID.0]);
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
fn named() -> PresetLibrary {
let mut lib = PresetLibrary::default();
lib.insert("Warm portrait", Preset::capture(&edited()))
.unwrap();
lib.insert("Neutral", Preset::default()).unwrap();
lib
}
#[test]
fn a_library_round_trips_through_its_text_form() {
let lib = named();
let back = PresetLibrary::parse(&lib.to_text()).unwrap();
assert_eq!(back, lib);
}
#[test]
fn the_same_library_always_writes_the_same_bytes() {
// What lets a caller skip a write by comparing content. Built in the
// opposite order to `named()` so insertion order cannot be what makes
// this pass.
let mut other = PresetLibrary::default();
other.insert("Neutral", Preset::default()).unwrap();
other
.insert("Warm portrait", Preset::capture(&edited()))
.unwrap();
assert_eq!(other.to_text(), named().to_text());
}
#[test]
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
// The empty preset is the "clear these forty frames" action, so it has
// to be a real entry rather than an absence — and a block with no
// lines under it has to parse back as a preset rather than vanish.
let back = PresetLibrary::parse(&named().to_text()).unwrap();
assert_eq!(back.get("Neutral"), Some(&Preset::default()));
}
#[test]
fn an_unreadable_line_costs_that_line_and_not_the_library() {
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
this line is not a setting\nsaturation.amount = not a number\n"
);
let lib = PresetLibrary::parse(&text).unwrap();
let preset = lib.get("Keep").expect("the preset survived");
assert_eq!(
preset.params().get(&("exposure".into(), "exposure".into())),
Some(&0.5)
);
}
#[test]
fn lines_this_build_cannot_read_are_written_back_untouched() {
// The sidecar's version-skew promise, applied here: a build running
// behind must not silently strip what a newer one wrote.
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
something_new_entirely\n"
);
let out = PresetLibrary::parse(&text).unwrap().to_text();
assert!(out.contains("something_new_entirely"), "{out}");
}
#[test]
fn an_unknown_parameter_survives_without_any_machinery_for_it() {
// `Preset` stores whatever keys it is given and resolves them against
// the descriptors only at apply time, so a parameter from a newer
// build needs no preservation path of its own.
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n"
);
let out = PresetLibrary::parse(&text).unwrap().to_text();
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
}
#[test]
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
assert_eq!(
PresetLibrary::parse(&text),
Err(LibraryParseError::UnsupportedVersion(
LIBRARY_FORMAT_VERSION + 1
))
);
}
#[test]
fn something_that_is_not_a_preset_library_is_refused() {
assert_eq!(
PresetLibrary::parse("drsc 1\n\n[version abc]\n"),
Err(LibraryParseError::NotALibrary)
);
assert_eq!(
PresetLibrary::parse(""),
Err(LibraryParseError::NotALibrary)
);
}
#[test]
fn a_name_that_would_not_parse_back_is_refused() {
// Round-trip safety, not taste: a `]` would close the header early and
// the file would read back as a different library.
let mut lib = PresetLibrary::default();
assert_eq!(
lib.insert("bracket] inside", Preset::default()),
Err(NameError::Unrepresentable)
);
assert_eq!(
lib.insert("two\nlines", Preset::default()),
Err(NameError::Unrepresentable)
);
assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty));
}
#[test]
fn surrounding_whitespace_is_trimmed_rather_than_refused() {
let mut lib = PresetLibrary::default();
lib.insert(" Warm ", Preset::default()).unwrap();
assert!(lib.contains("Warm"));
}
#[test]
fn saving_over_a_name_replaces_it_and_says_so() {
let mut lib = named();
assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true));
assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false));
assert_eq!(lib.get("Warm portrait"), Some(&Preset::default()));
}
#[test]
fn renaming_moves_the_preset_and_leaves_nothing_behind() {
let mut lib = named();
let before = lib.get("Warm portrait").cloned().unwrap();
assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true));
assert!(!lib.contains("Warm portrait"));
assert_eq!(lib.get("Cool portrait"), Some(&before));
}
#[test]
fn renaming_something_that_is_gone_is_not_an_error() {
// Another window may have deleted it since this list was drawn.
let mut lib = named();
assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false));
}
#[test]
fn deleting_reports_whether_there_was_anything_to_delete() {
let mut lib = named();
assert!(lib.remove("Neutral"));
assert!(!lib.remove("Neutral"));
assert_eq!(lib.len(), 1);
}
#[test]
fn a_stored_preset_applies_exactly_as_a_pasted_one_does() {
// The whole point of sharing one representation: a named preset is not
// a second kind of thing with a second apply path.
let lib = named();
let stored = PresetLibrary::parse(&lib.to_text()).unwrap();
let preset = stored.get("Warm portrait").unwrap();
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
// The target keeps its own framing on the default scope.
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
}
#[test]
fn a_stored_preset_amends_a_sidecar_without_a_graph() {
// The batch path: applying to forty images must not build forty
// graphs, so this is the call the library's batch apply makes.
let lib = named();
let preset = lib.get("Warm portrait").unwrap();
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
params.insert(("framing".into(), "angle".into()), 5.0);
params.insert(("exposure".into(), "exposure".into()), -1.0);
preset.amend(&mut params, Scope::Adjustments);
assert_eq!(
params.get(&("exposure".into(), "exposure".into())),
Some(&0.75)
);
// Out of scope, so the target's own crop is untouched.
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
}
}
+1
View File
@@ -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 -1
View File
@@ -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
+21 -6
View File
@@ -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
@@ -216,10 +216,17 @@ impl std::fmt::Debug for FolderBackend {
impl FolderBackend {
/// Open the folder at `root`.
///
/// The directory must exist now. It may stop existing later — a drive
/// unplugged, a mount dropped — and that surfaces per-operation as
/// [`RemoteError::Network`], which is what puts the app into offline mode
/// and leaves the catalog readable, exactly as a dead server does.
/// The directory must exist now, and not existing is
/// [`RemoteError::RootUnavailable`] — the library folder could not be
/// opened, which is the whole of what this knows. A drive unplugged
/// between sessions and a path typed wrongly at setup are the same
/// observation from here, and both are answered the same way: keep the
/// catalog, say which folder, and offer it again (FR-PLAT-AND-2).
///
/// A mount dropped *during* a session surfaces per-operation as
/// [`RemoteError::Network`] instead, which is what puts the app into
/// offline mode and leaves the catalog readable, exactly as a dead server
/// does.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
Self::with_vfs(root, Arc::new(NoVfs))
}
@@ -232,7 +239,15 @@ impl FolderBackend {
pub fn with_vfs(root: impl Into<PathBuf>, vfs: Arc<dyn Vfs>) -> Result<Self, RemoteError> {
let root = root.into();
if !root.is_dir() {
return Err(RemoteError::Configuration(format!(
// TRACES: FR-PLAT-AND-2
// Not `Configuration`, which is where this lived while there was
// nothing better. The distinction that matters is not "was the
// account written wrongly" — which nothing here can know — but
// "can this library be opened", and a caller that knows the
// library was working yesterday can act on the second answer:
// mark what it holds as offline rather than deleting it, and ask
// for the folder again (FR-CAT-9).
return Err(RemoteError::RootUnavailable(format!(
"{} is not a folder",
root.display()
)));
+13 -4
View File
@@ -48,13 +48,22 @@ fn names(entries: &[RemoteEntry]) -> Vec<String> {
// --- opening --------------------------------------------------------------
/// TRACES: FR-PLAT-AND-2
#[test]
fn a_missing_folder_is_a_configuration_error_not_a_network_one() {
// It must not put the app into offline mode: nothing was unreachable, the
// account names somewhere that is not a folder.
fn a_missing_folder_is_an_unavailable_root_not_a_network_failure() {
// Still not offline mode — nothing was unreachable over a wire, and
// reporting it as a network failure would tell the user to wait for a
// connection that is working.
//
// `RootUnavailable` rather than `Configuration`, because the caller that
// has to act on this is the one whose library worked yesterday: an
// ejected card is indistinguishable from a mistyped path here, and only
// the first of those has a catalog full of ratings to protect.
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}");
assert!(matches!(err, RemoteError::RootUnavailable(_)), "{err:?}");
assert!(err.indicates_lost_root());
assert!(!err.indicates_offline());
assert!(err.to_string().contains("/definitely/not/here"), "{err}");
}
// --- listing --------------------------------------------------------------
+1 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
//! TRACES: R6 | NFR-SEC-3
//! Nextcloud connector.
//!
//! One of two [`RemoteBackend`] implementations, registered through
+1 -1
View File
@@ -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
+62 -8
View File
@@ -61,14 +61,18 @@ pub enum RemoteError {
/// connector for.
///
/// **Not a network failure and not an auth failure**, which is why it is
/// its own variant. A folder library whose directory has been unmounted,
/// or an account naming a backend a cut-down build was not compiled with,
/// produces a request that never leaves the process — reporting either as
/// `Network` would put the app into offline mode and tell the user their
/// connection is down, and reporting them as `AuthFailed` would send them
/// to re-enter a credential that is fine. The message names what is wrong
/// with the configuration, because that is the only thing that will fix
/// it.
/// its own variant. An account naming a backend a cut-down build was not
/// compiled with, or a path that would leave the library folder, produces
/// a request that never leaves the process — reporting either as `Network`
/// would put the app into offline mode and tell the user their connection
/// is down, and reporting them as `AuthFailed` would send them to re-enter
/// a credential that is fine. The message names what is wrong with the
/// configuration, because that is the only thing that will fix it.
///
/// A folder library whose directory is not there was once reported here
/// too, and is now [`RootUnavailable`](Self::RootUnavailable): it is not
/// something wrong with the configuration, it is the library being gone,
/// and only the second of those has a catalog to protect.
#[error("account misconfigured: {0}")]
Configuration(String),
@@ -105,6 +109,43 @@ pub enum RemoteError {
#[error("operation cancelled")]
Cancelled,
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// The library root itself could not be opened.
///
/// **The one failure that is about the library rather than about a file in
/// it**, and it is a separate variant because every other classification
/// of it is wrong in a way that costs the user something:
///
/// - As [`NotFound`](Self::NotFound) it is indistinguishable from a folder
/// deleted between listing its parent and reaching it, which the walk
/// correctly steps over — so a whole library going away is reported as a
/// successful scan that found nothing.
/// - As [`PermissionDenied`](Self::PermissionDenied) it inherits a message
/// about Nextcloud share permissions and sidecar writes, which is
/// accurate for the case it was written for and nonsense for a tree
/// grant the user revoked in system settings.
/// - As [`Network`](Self::Network) it would claim the connection is down,
/// which is a promise that waiting will fix it.
///
/// Today this is a Nextcloud root that answers 404 or 403 — deleted, or a
/// share withdrawn — or a folder library whose directory is not there. It
/// is also, exactly, the shape a revoked Android tree permission will have
/// when the Storage Access Framework connector FR-PLAT-AND-1 asks for
/// exists: the tree URI still stored, the permission behind it gone, every
/// read failing at the root and nowhere else. **That connector is not
/// built**, so no SAF grant can be lost yet; what this variant does is put
/// the recovery FR-PLAT-AND-2 requires in the one place all three causes
/// pass through, so the third needs no new handling above it.
///
/// The response is the same for all of them and is the point of the
/// variant: mark what the catalog holds as offline, keep every row, and
/// say which library and why (FR-CAT-9).
///
/// The string is the underlying failure, not a rewrite of it. What the
/// user is told is composed where the library's name is known.
#[error("the library folder could not be opened: {0}")]
RootUnavailable(String),
}
impl RemoteError {
@@ -150,6 +191,19 @@ impl RemoteError {
pub fn indicates_offline(&self) -> bool {
matches!(self, RemoteError::Network(_))
}
/// TRACES: FR-PLAT-AND-2
/// Whether the *library* is gone, as opposed to the server or one file.
///
/// Kept beside [`Self::indicates_offline`] because the two answer the same
/// shape of question and must not be confused. Both put the app into a
/// degraded mode that keeps working from the catalog, but they differ in
/// what the user is told and in what would end it: an offline library
/// comes back when the network does, and an unavailable root comes back
/// only when someone grants access again.
pub fn indicates_lost_root(&self) -> bool {
matches!(self, RemoteError::RootUnavailable(_))
}
}
#[cfg(test)]
+134
View File
@@ -171,6 +171,37 @@ where
let entries = match backend.list(&dir, None).await {
Ok(e) => e,
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The root is the one directory the walk may not step over, and
// `depth == 0` is the only place it can be — nothing is ever
// pushed at that depth but the root itself.
//
// Below, a directory that has gone is a directory that went away
// between its parent being listed and it being reached, and
// continuing is right. At the root the identical error means the
// *library* is gone, and continuing is catastrophic in a way that
// is completely silent: the walk ends, the scan succeeds having
// found nothing, and the app reports a healthy library with no new
// images while every path in the catalog now points nowhere.
//
// Refused rather than reclassified. Only these two causes are —
// a `Network` failure at the root is still a network failure, and
// must stay one or an unplugged network cable would present itself
// as a revoked permission and offline mode would never engage.
Err(RemoteError::NotFound(_)) if depth == 0 => {
return Err(RemoteError::RootUnavailable(format!(
"{root} is no longer there"
)));
}
Err(RemoteError::PermissionDenied) if depth == 0 => {
// Deliberately not the variant's own message, which describes
// a Nextcloud share that refuses to *update* a sidecar. At the
// root nothing has been read at all.
return Err(RemoteError::RootUnavailable(format!(
"{root} can no longer be read"
)));
}
Err(RemoteError::NotFound(_)) => {
// Deleted between listing its parent and reaching it.
log::debug!("scan: {dir} vanished during the walk");
@@ -239,6 +270,20 @@ mod tests {
caps: Capabilities,
lists: RefCell<usize>,
probes: RefCell<usize>,
/// Directories whose listing fails, and how.
///
/// An absent directory is not enough to model this: the fake answers
/// an unknown path with an empty listing, which is exactly the shape
/// the walk must *not* confuse with a library that has gone away.
deny: HashMap<String, Deny>,
}
/// The two ways a real backend refuses a directory that is still named in
/// the catalog: it is not there, or it may not be read.
#[derive(Clone, Copy)]
enum Deny {
Missing,
Forbidden,
}
// The fake is single-threaded; tests never share it across threads.
@@ -307,8 +352,15 @@ mod tests {
},
lists: RefCell::new(0),
probes: RefCell::new(0),
deny: HashMap::new(),
}
}
/// Make one directory refuse to be listed.
fn denying(mut self, path: &str, how: Deny) -> Self {
self.deny.insert(path.to_string(), how);
self
}
}
#[async_trait]
@@ -325,6 +377,11 @@ mod tests {
_since: Option<&Validator>,
) -> Result<Vec<RemoteEntry>, RemoteError> {
*self.lists.borrow_mut() += 1;
match self.deny.get(dir.as_str()) {
Some(Deny::Missing) => return Err(RemoteError::NotFound(dir.to_string())),
Some(Deny::Forbidden) => return Err(RemoteError::PermissionDenied),
None => {}
}
Ok(self.tree.get(dir.as_str()).cloned().unwrap_or_default())
}
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
@@ -404,6 +461,83 @@ mod tests {
assert_eq!(r.progress.directories_listed, 3);
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
#[tokio::test]
async fn a_root_that_is_gone_is_a_failure_and_not_an_empty_library() {
// The silent one. A vanished directory below the root is stepped over,
// and before this the root was stepped over on the same terms — which
// ended the walk immediately, returned `Ok` with nothing in it, and
// let the app report a successful scan of a library that no longer
// exists. Nothing in that path is ever told the library went away, so
// nothing marks it offline and nothing tells the user.
let b =
FakeBackend::sample(ChangeDetection::PropagatingEtags).denying("Photos", Deny::Missing);
let e = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect_err("a library that is not there is not a library with no photographs");
assert!(e.indicates_lost_root(), "got {e:?}");
assert!(!e.indicates_offline(), "waiting will not bring this back");
assert!(e.to_string().contains("Photos"), "names the library: {e}");
}
/// TRACES: FR-PLAT-AND-2
#[tokio::test]
async fn a_root_that_may_not_be_read_reports_the_root_and_not_the_share_advice() {
// A Nextcloud share withdrawn, a directory the process may no longer
// read — and the shape a revoked Android tree grant will have when one
// can be held at all. Reported as plain `PermissionDenied` it would
// have carried that variant's message, which is several lines about a
// Nextcloud share refusing to *update* an existing sidecar: advice for
// a case where reads work, offered to a user whose reads have stopped
// entirely.
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags)
.denying("Photos", Deny::Forbidden);
let e = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect_err("a root that cannot be read is a failed scan");
assert!(e.indicates_lost_root(), "got {e:?}");
assert!(
!e.to_string().contains("sidecar"),
"the share-permission advice does not belong here: {e}"
);
}
/// TRACES: FR-PLAT-AND-2
#[tokio::test]
async fn a_folder_that_goes_away_below_the_root_is_still_stepped_over() {
// The other side of the split, and the reason the root is keyed on
// depth rather than on the error. A subfolder deleted between its
// parent being listed and it being reached is ordinary, and failing
// the scan over it would abandon every photograph beside it.
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags)
.denying("Photos/2025", Deny::Missing);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect("one folder going away is not the library going away");
assert_eq!(r.images.len(), 1, "2026 was still walked");
}
/// A library with a trash folder holding a soft-deleted image.
fn with_trash() -> FakeBackend {
let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
+1 -1
View File
@@ -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
+55
View File
@@ -810,6 +810,60 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
---
## 10a. Bursts and near-duplicates
Specified by FR-CULL-5, implemented in `dr_catalog::bursts` (a v11 migration) with the pass that
feeds it in `dr_ui::bursts`.
A burst is a run of frames that are **adjacent in time and look like the frame before them**. Both
halves are load-bearing. Time alone groups a whole wedding ceremony, because a photographer working
steadily never leaves the gap that would end the run. Similarity alone groups a studio setup shot
across two days, which is a project rather than a moment. The bounds are two seconds and eight bits
of a 64-bit difference hash, and the reasoning for each figure is in the module.
**Two seconds, for a burst that fires ten frames in one.** `images.captured_at` is whole seconds:
EXIF's `DateTimeOriginal` has no sub-second field, and `SubSecTimeOriginal` is optional and widely
omitted. Ten frames of a burst therefore arrive sharing a timestamp, and any threshold finer than a
second is a threshold on information the catalog does not have. Where the pace really is faster than
two seconds, the similarity bound is what separates the frames.
**The signal is a perceptual hash of the thumbnail, not of the original.** `images.perceptual_hash`
is filled from the 256px thumbnails §7 already stores — vastly more resolution than a 9×8 reduction
uses — so a library that has been browsed has already paid for its signatures and no RAW is decoded
for this. The consequence is stated rather than hidden: an image with no thumbnail gets no
signature, and a frame with no signature never joins a burst. It is picked up by the next pass.
**It is a pass, not a job kind**, for exactly the reason §10.2 gives for face clustering: a burst is
a property of a *run* of frames and has no natural `subject_id`, so a per-image job would rebuild
the world once per photograph. It runs when the thumbnail sweep finishes, which is the first moment
the signatures can all be computed.
**A newly found burst arrives open.** The pass marks frames; it never takes them off the screen.
Collapsing on discovery would be tidier and would also mean a background pass removing photographs
from under someone part way through a cull. Folding a burst up is the user's act, it is remembered
(`burst_expanded`), and a burst that is already known keeps whatever state it is in — so the pass
that follows the next import does not spring open a morning's work.
**Nothing here ranks a frame.** The representative of a collapsed burst is its *earliest* frame,
which is a fact about the clock rather than a judgement about the photograph. FR-CULL-5 names the
failure this avoids — rejecting the only frame of an important moment because someone blinked — and
the only judgement in the subsystem is the user's own choice of representative, which lives in its
own table (`burst_pick`) so that rebuilding the grouping cannot erase it. Same argument as
`people.ignored` in §10.
**What the collapse costs the grid.** Which rows a collapsed burst hides has to be decided by the
query rather than by the cells, because the grid is a window (`LIMIT n OFFSET k`) and the frames it
hides are mostly not loaded. So the predicate joins `VISIBLE` in every query that lists or counts
cells, under the same discipline: present in four places of five, the header's count, the
scrollbar, the shift-click range and the scrub's ordinal stop describing the same list.
**What this does not settle.** Bursts are local: the tables ride along in the uploaded catalog
snapshot and nothing on the far side reads them, so a second device rebuilds its own grouping from
its own signatures. Making `burst_pick` cross-device is a merge question of the same shape as §8.4's
and is not answered here.
---
## 11. Requirements touched
| ID | How this document addresses it |
@@ -828,6 +882,7 @@ confidence is unavailable. It does not fall back to an untuned default dressed u
| NFR-P1 | §3.1 one stat per directory, not per file |
| NFR-P3 | §7.1 on-demand generation |
| NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler |
| FR-CULL-5 | §10a burst grouping: capture-time proximity and image similarity, collapse without selection |
| NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation |
| NFR-RES-4 | §7.3 LRU cap, eviction order |
| FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier |
+251
View File
@@ -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.
+358
View File
@@ -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.
+21
View File
@@ -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:
+84 -84
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -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>
+531
View File
@@ -0,0 +1,531 @@
//! TRACES: FR-CULL-5
//! Burst grouping, as the library screen uses it.
//!
//! [`dr_catalog::bursts`] holds the grouping itself and knows nothing about
//! pixels. This is the other half: where the similarity signal comes from, and
//! how a group reaches a grid cell.
//!
//! # The signal comes out of the thumbnail store
//!
//! A perceptual signature needs pixels, and the cheapest pixels in the app are
//! the ones already sitting in `dr-thumbs`: a 256px JPEG per photograph, built
//! for the grid, shared between devices, and vastly more resolution than a 9×8
//! reduction can use. So this pass decodes thumbnails, never originals. A
//! library that has been browsed — or that has synced somebody else's shards —
//! has already paid for every signature it is about to get.
//!
//! The consequence, stated rather than hidden: **an image with no thumbnail
//! gets no signature, and a frame with no signature never joins a burst.** That
//! is self-correcting rather than permanent — the next pass finds the thumbnail
//! the sweep has since built — and it is the reason this runs when the
//! thumbnail sweep finishes rather than on a timer.
//!
//! # Why a pass and not a job
//!
//! Hashing is per-image and would make a perfectly good job kind. Grouping is
//! not: a burst is a property of a *run* of frames, so a per-image job would
//! regroup the library once per photograph. Since the two have to happen in that
//! order and the second cannot be split, both live in one pass — the same
//! argument docs/catalog.md §10.2 makes for face clustering.
//!
//! # It is never on the UI thread
//!
//! Decoding tens of thousands of thumbnails is bounded only by library size, and
//! the one thing that must not grow with library size is how long the window
//! stops answering (NFR-P9). Cancellation is dropping the receiver; a pass
//! abandoned half way leaves the signatures it did compute — they are permanent
//! and correct — and the previous grouping intact.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use dr_catalog::bursts::{self, Rules, Signature};
use dr_catalog::Catalog;
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::ImageId;
use slint::Model as _;
use crate::AppWindow;
/// How many signatures are written per transaction.
///
/// One transaction per image costs a WAL commit per thumbnail and turns a pass
/// over a real library into minutes of fsync; one transaction for the whole pass
/// holds a write lock for the duration and loses everything if the app closes.
/// A few hundred is the usual answer to that trade.
const WRITE_BATCH: usize = 256;
/// Progress from a grouping pass.
#[derive(Debug, Clone, PartialEq)]
pub enum BurstMessage {
/// How many images still need a signature. Sent once, before any decoding.
Started { to_hash: usize },
/// Cumulative signatures written.
Progress { hashed: usize },
/// The pass finished, and this is what the library now looks like.
Finished {
hashed: usize,
bursts: usize,
frames: usize,
},
/// It did not.
Failed(String),
}
/// Hash whatever is missing a signature, then rebuild the grouping.
///
/// Both halves run on a worker thread. The catalog is opened here rather than
/// shared with the UI's connection: SQLite connections are not `Send`, and WAL
/// is what makes a second one safe while the grid reads (NFR-R1).
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(BurstMessage::Failed(format!("cannot open catalog: {e}")));
return;
}
};
let conn = catalog.connection();
let outstanding = match bursts::images_without_signature(conn) {
Ok(v) => v,
Err(e) => {
let _ = tx.send(BurstMessage::Failed(e.to_string()));
return;
}
};
if tx
.send(BurstMessage::Started {
to_hash: outstanding.len(),
})
.is_err()
{
return;
}
// A broken or absent store costs signatures, never correctness: the
// grouping still runs over whatever is already hashed, and the images
// that missed out are picked up by the next pass.
let store = match ThumbStore::open(&thumbs_dir) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("thumbnail store unavailable, not hashing: {e}");
None
}
};
let hashed = match store {
Some(store) => hash_all(conn, &store, &outstanding, &tx),
None => 0,
};
match bursts::regroup(conn, Rules::default()) {
Ok(report) => {
log::info!(
"bursts: {hashed} signature(s) added, {} group(s) over {} frame(s), \
largest {}",
report.bursts,
report.frames,
report.largest
);
let _ = tx.send(BurstMessage::Finished {
hashed,
bursts: report.bursts,
frames: report.frames,
});
}
Err(e) => {
let _ = tx.send(BurstMessage::Failed(e.to_string()));
}
}
});
rx
}
thread_local! {
/// The running pass's drain timer, and whether one is running.
///
/// Module-local rather than a pair of fields on the library controller, so
/// that everything this feature needs to run lives in this file and the
/// screen that starts it is left holding nothing. Safe as a thread local
/// because Slint's event loop is single-threaded (NFR-P9) and this is only
/// ever touched from it.
///
/// The flag is separate because stopping a timer does not drop it: a slot
/// tested for emptiness would refuse every pass after the first.
static DRAIN: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
static RUNNING: Cell<bool> = const { Cell::new(false) };
}
/// Hash what has become hashable, then rebuild the library's burst grouping.
///
/// Fired when the thumbnail sweep finishes, because that is the moment the
/// signatures can all be computed: the pass reads thumbnails, and until the
/// sweep has run most images have none. Not on a timer, and not after every
/// scan — a regroup is cheap but not free, and nothing is waiting on it.
///
/// `grouped` is called once, with the number of bursts the library now has, if
/// the pass finishes. It is where the caller reloads the grid: the cells hold
/// the same photographs they held before — a new burst arrives open — but every
/// run of frames now carries a mark it did not have a moment ago, and only a
/// reload carries it.
///
/// Deliberately silent otherwise. The sweeps around it report progress because
/// they run for tens of minutes; this is seconds, and a status line for it would
/// be a line the user must read in order to learn nothing.
pub fn start_pass(catalog_path: PathBuf, thumbs_dir: PathBuf, grouped: impl Fn(usize) + 'static) {
// A second pass would read the same rows and write the same answer over the
// first one's transactions.
if RUNNING.get() {
return;
}
RUNNING.set(true);
let rx = spawn_grouping(catalog_path, thumbs_dir);
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(400),
move || loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
finish();
return;
}
};
match msg {
BurstMessage::Started { to_hash } => {
log::info!("burst grouping: {to_hash} image(s) still to hash");
}
// Nothing on screen is showing this. Drained rather than
// ignored, because an unread channel is a worker that stalls.
BurstMessage::Progress { hashed } => {
log::debug!("burst grouping: {hashed} hashed so far");
}
BurstMessage::Finished {
hashed,
bursts,
frames,
} => {
log::info!(
"burst grouping: {hashed} signature(s) added, \
{bursts} group(s) over {frames} frame(s)"
);
finish();
grouped(bursts);
return;
}
BurstMessage::Failed(e) => {
log::warn!("burst grouping: {e}");
finish();
return;
}
}
},
);
DRAIN.with(|slot| *slot.borrow_mut() = Some(timer));
}
/// Stop draining, and let another pass start.
///
/// Stops the timer without dropping it — dropping one from inside its own
/// callback is not something to rely on — which is why the flag beside it is
/// what actually says whether a pass is running.
fn finish() {
RUNNING.set(false);
DRAIN.with(|slot| {
if let Some(timer) = slot.borrow().as_ref() {
timer.stop();
}
});
}
/// Decode each image's stored thumbnail and record its signature.
///
/// Returns how many were written. Anything without a stored thumbnail, or whose
/// blob will not decode, is simply skipped — it keeps its NULL and comes back
/// next time, which is the same treatment `spawn_thumbnails` gives a corrupt
/// blob.
fn hash_all(
conn: &rusqlite::Connection,
store: &ThumbStore,
outstanding: &[ImageId],
tx: &std::sync::mpsc::Sender<BurstMessage>,
) -> usize {
let keys = thumbnail_keys(conn);
let mut pending: Vec<(ImageId, Signature)> = Vec::with_capacity(WRITE_BATCH);
let mut written = 0usize;
for image in outstanding {
let Some(file_id) = keys.get(image).copied() else {
continue;
};
// The grid class, not the large one: 256px is already thirty times the
// detail the reduction keeps, and asking for `Large` would miss most of
// the store, which is filled at `Grid`.
let Ok(Some(thumb)) = store.get(file_id, ThumbSize::Grid) else {
continue;
};
let Ok((w, h, rgba)) = dr_thumbs::decode_rgba(&thumb.bytes) else {
continue;
};
let Some(signature) = bursts::signature_of_rgba(&rgba, w, h) else {
continue;
};
pending.push((*image, signature));
if pending.len() >= WRITE_BATCH {
written += flush(conn, &mut pending);
if tx.send(BurstMessage::Progress { hashed: written }).is_err() {
// The receiver is gone: the screen has moved on, and finishing
// the pass would be work nobody is waiting for.
return written;
}
}
}
written += flush(conn, &mut pending);
written
}
/// Write one batch of signatures, emptying `pending`.
fn flush(conn: &rusqlite::Connection, pending: &mut Vec<(ImageId, Signature)>) -> usize {
if pending.is_empty() {
return 0;
}
let n = pending.len();
let tx = match conn.unchecked_transaction() {
Ok(t) => t,
Err(e) => {
log::warn!("recording signatures: {e}");
pending.clear();
return 0;
}
};
for (image, signature) in pending.drain(..) {
if let Err(e) = bursts::set_signature(&tx, image, signature) {
log::debug!("recording signature for {}: {e}", image.0);
}
}
match tx.commit() {
Ok(()) => n,
Err(e) => {
log::warn!("committing signatures: {e}");
0
}
}
}
/// Every image's thumbnail-store key, in one query.
///
/// Read whole rather than asked per image: the table is one small row per
/// photograph, and a query per image would be tens of thousands of statements
/// to save a megabyte.
fn thumbnail_keys(conn: &rusqlite::Connection) -> HashMap<ImageId, u64> {
let mut out = HashMap::new();
let Ok(mut stmt) = conn.prepare("SELECT image_id, file_id FROM remote") else {
return out;
};
let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))) else {
return out;
};
for (image, file) in rows.flatten() {
out.insert(ImageId(image as u64), file as u64);
}
out
}
/// Fill in the burst badge on every cell of the loaded window.
///
/// One query for the window, in the same style as the collection badges and the
/// rating counts beside it — a grid that asks the catalog a question per cell is
/// a grid that stutters under a finger.
///
/// `ids` is the window in grid order, so the row index into the model is the
/// index into it.
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
if ids.is_empty() {
return;
}
let found = match bursts::memberships(catalog.connection(), ids) {
Ok(m) => m,
Err(e) => {
log::debug!("reading burst membership: {e}");
return;
}
};
let model = window.get_library_cells();
for (row, id) in ids.iter().enumerate() {
let (count, expanded) = match found.get(id) {
Some(m) => (m.size as i32, m.expanded),
// Not in a burst at all, which is most of a library.
None => (0, false),
};
if let Some(mut cell) = model.row_data(row) {
if cell.burst_count != count || cell.burst_expanded != expanded {
cell.burst_count = count;
cell.burst_expanded = expanded;
model.set_row_data(row, cell);
}
}
}
}
/// Open or close the burst one cell belongs to.
///
/// Which direction is decided from what the catalog says the group is currently
/// doing, rather than from the cell's own `burst-expanded`. The cell is a copy
/// of that state and can be one reload behind; the table cannot.
///
/// The caller reloads the grid rather than repainting it, because collapsing
/// changes what the grid's *query* returns: the row count, the scrollbar and
/// the ordinal a scrub resolves all move together, and they can only stay in
/// step by being read again together.
///
/// Returns whether anything changed, so a click on a cell that is in no burst —
/// an entirely normal thing to happen — costs no round trip through the grid.
pub fn toggle(catalog: &Catalog, image: ImageId) -> bool {
let conn = catalog.connection();
let Ok(found) = bursts::memberships(conn, &[image]) else {
return false;
};
let Some(membership) = found.get(&image) else {
return false;
};
match bursts::set_expanded(conn, membership.burst_id, !membership.expanded) {
Ok(()) => true,
Err(e) => {
log::warn!("toggling burst {}: {e}", membership.burst_id.0);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A catalog with two frames of one burst, and a thumbnail store holding a
/// picture for each.
///
/// `name` keeps two tests from sharing a directory, since they run in
/// parallel. Same shape as the store tests in `library`, which is also why
/// this reaches for `temp_dir` rather than a crate: nothing else here needs
/// one.
fn library(name: &str) -> (Catalog, PathBuf) {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for (id, at) in [(1i64, 1000i64), (2, 1001)] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, camera, added_at)
VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', 0)",
rusqlite::params![id, format!("IMG_{id}.CR3"), at],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![id, 100 + id],
)
.unwrap();
}
let dir = std::env::temp_dir().join(format!("dr-ui-bursts-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let mut store = ThumbStore::open(&dir).unwrap();
for (id, shift) in [(1u64, 0usize), (2, 1)] {
let rgba = picture(shift);
let bytes = dr_thumbs::encode_rgba(64, 64, &rgba).unwrap();
store
.put(
100 + id,
ThumbSize::Grid,
&dr_thumbs::Thumbnail {
width: 64,
height: 64,
bytes,
},
)
.unwrap();
}
}
(cat, dir)
}
/// A blocky scene, moved sideways by `shift` pixels — one frame of a burst
/// and then the next.
fn picture(shift: usize) -> Vec<u8> {
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let v = (((x + shift) / 8) * 37 + (y / 8) * 91) as u8;
let p = (y * 64 + x) * 4;
out[p] = v;
out[p + 1] = v;
out[p + 2] = v;
out[p + 3] = 255;
}
}
out
}
#[test]
fn the_pass_hashes_from_thumbnails_and_groups_what_it_hashed() {
let (cat, dir) = library("hashes");
let conn = cat.connection();
let store = ThumbStore::open(&dir).unwrap();
let outstanding = bursts::images_without_signature(conn).unwrap();
assert_eq!(outstanding.len(), 2);
let (tx, _rx) = std::sync::mpsc::channel();
let hashed = hash_all(conn, &store, &outstanding, &tx);
assert_eq!(hashed, 2, "both thumbnails should have yielded a signature");
let report = bursts::regroup(conn, Rules::default()).unwrap();
assert_eq!(report.bursts, 1, "the two frames were not grouped");
assert_eq!(report.frames, 2);
}
#[test]
fn an_image_with_no_thumbnail_keeps_its_null() {
let (cat, dir) = library("no-thumb");
let conn = cat.connection();
conn.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, added_at)
VALUES (9, 1, 'IMG_9.CR3', 1002, 0)",
[],
)
.unwrap();
let store = ThumbStore::open(&dir).unwrap();
let outstanding = bursts::images_without_signature(conn).unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2);
// And it is still offered next time, rather than being written off.
assert_eq!(
bursts::images_without_signature(conn).unwrap(),
vec![ImageId(9)]
);
}
#[test]
fn toggling_a_cell_that_is_in_no_burst_changes_nothing() {
let (cat, _dir) = library("no-burst");
assert!(!toggle(&cat, ImageId(1)));
}
}
+151 -1
View File
@@ -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
@@ -2943,6 +3066,33 @@ impl DevelopSession {
Ok((image, rw, rh))
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back the GPU memory this session is holding only to be fast.
///
/// The edit is untouched: the graph and its history are CPU-side by
/// design (ARCH §6.1), so the photograph, the undo stack and the viewport
/// all survive and the next frame simply costs what the first one did.
///
/// # What is not released, and what it is waiting on
///
/// The demosaiced source is the largest single allocation a session holds
/// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is
/// deliberately kept. Dropping it would need the session to be able to
/// rebuild itself from the file, and rebuilding a session from a durable
/// record is FR-PLAT-AND-3, which is not built. Freeing it now would not
/// be an eviction; it would be closing the photograph without telling
/// anyone. Likewise the subject distance fields and the segmentation map:
/// each is guarded by a key recording what it was built from, and freeing
/// one without invalidating its key is the failure `AdjustPass` documents
/// under `colour_key`.
///
/// So this is the part of the GPU tier that can be given back and asked
/// for again with no other machinery, which is exactly as far as an
/// eviction should go.
pub fn release_gpu_caches(&mut self) {
self.adjust.release_caches();
}
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
+15
View File
@@ -111,6 +111,21 @@ impl IdentityController {
fn clear_picks(&self) {
self.picked.borrow_mut().clear();
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Drop the decoded rail portraits.
///
/// The one in-memory image cache in this crate that is unbounded by
/// anything but the library: one decoded portrait per person, kept for as
/// long as the person exists. On a library with a few hundred named people
/// that is worth tens of megabytes of nothing but a saved decode.
///
/// Costless to lose. `refresh` rebuilds any portrait it does not find, so
/// the only consequence is the JPEG decode this cache exists to skip, and
/// only for the people the rail is actually showing at the time.
pub fn clear_covers(&self) {
self.covers.borrow_mut().clear();
}
}
/// Push the people rail and the face grid into the window.
+190
View File
@@ -20,6 +20,7 @@
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity;
mod bursts;
mod collections_ui;
mod derived_sync;
mod develop;
@@ -38,7 +39,10 @@ mod library_ui;
#[cfg(live_style)]
mod live_style;
mod masks_ui;
pub mod memory;
mod net_runtime;
mod peaking;
mod preset_store;
mod presets;
mod remote;
mod segmentation;
@@ -316,6 +320,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
@@ -1016,6 +1026,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// each save over the other.
let settings = settings_ui::SettingsController::new();
// TRACES: FR-PLAT-AND-5
// The thumbnail tier. Registered here, beside the thing it frees, so that
// a controller which grows another cache is one line from offering it up.
//
// Weak, not strong: `run` returns when the window closes, and a registry
// holding the last reference to a controller would keep it — and every
// decoded portrait in it — alive past the interface it belonged to.
{
let identity = std::rc::Rc::downgrade(&identity);
memory::evict_at(memory::Tier::Thumbnails, move || {
if let Some(ctl) = identity.upgrade() {
ctl.clear_covers();
}
});
}
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
// a folder goes straight to their images (FR-NC-1).
@@ -1429,6 +1455,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
// TRACES: FR-PLAT-AND-5
// The GPU tier — the first thing given back under memory pressure, and on
// Android the only thing given back merely for going into the background.
//
// `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error
// worth reporting. A memory warning can land in the middle of a render, at
// which point the slot is already borrowed and freeing its textures under
// the code drawing with them is not something to do politely — skipping is
// correct, because the pass that is running will have finished by the time
// the platform asks again, and a warning that has not been acted on is
// always followed by another one.
{
let session = Rc::downgrade(&session);
memory::evict_at(memory::Tier::Gpu, move || {
let Some(session) = session.upgrade() else {
return;
};
let Ok(mut slot) = session.try_borrow_mut() else {
return;
};
if let Some(open) = slot.as_mut() {
open.release_gpu_caches();
}
});
}
// TRACES: FR-DEV-6 | FR-CAT-8
// The settings clipboard, and where the open image's edit is stored.
//
@@ -1468,11 +1520,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 };
@@ -1533,6 +1598,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.**
@@ -1595,6 +1670,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}");
@@ -1602,6 +1705,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);
}
}
})
@@ -2025,6 +2133,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
collections.clone(),
);
// TRACES: FR-DEV-6
// The saved half of the same requirement, wired from the same bundle:
// applying a named preset to the open image is the paste path with a
// different source.
presets::wire_named(
&window,
presets::NamedPresets::open(),
presets::Develop {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
open: open_image.clone(),
},
settings.clone(),
library.clone(),
collections.clone(),
);
// Close the knot left open beside `open_from_library`: the grid's
// "‹ Library" button was wired before there was a session to save.
let weak = window.as_weak();
@@ -2822,6 +2948,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());
+192 -11
View File
@@ -80,7 +80,20 @@ pub enum ScanMessage {
/// amount of string matching on the far side can reliably recover it.
/// Without the flag a dead connection and a bad password produce the same
/// banner, which sends the user to re-enter a credential that was fine.
Failed { message: String, offline: bool },
///
/// `lost_root` is the same idea one step further out, and it is carried
/// separately from `offline` rather than folded into it because the two
/// end differently. An offline library comes back when the network does,
/// with nothing asked of anyone; a library whose root cannot be opened
/// comes back only when someone restores access to it — a share put back
/// on the server, a drive plugged in, and in time a document tree granted
/// again once one can be (FR-PLAT-AND-2). Both show the same grid of what
/// is stored locally, and they must not offer the same explanation.
Failed {
message: String,
offline: bool,
lost_root: bool,
},
}
/// One decoded thumbnail, ready for the grid.
@@ -186,6 +199,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
/// TRACES: FR-CULL-5
/// The clause that hides the frames a collapsed burst is standing in for.
///
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
/// reason: the header's count, the scrollbar's size, the run a shift-click
/// resolves and the ordinal a scrub lands on are four answers about one list.
/// A burst folded away in the cells but still counted in the total would leave
/// the grid ending in rows that draw nothing, with no clue why.
///
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
/// interface and the pass that writes the table cannot come to disagree about
/// what collapsed means.
///
/// A function rather than a constant because it has to name the image table,
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
/// is a table name from this file and never anything a user supplied.
fn uncollapsed(image: &str) -> String {
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
}
/// TRACES: FR-CAT-4
/// The order the grid lists photographs in: when they were taken.
///
@@ -1146,6 +1179,7 @@ pub fn spawn_scan(
let _ = tx.send(ScanMessage::Failed {
message: e.message,
offline: e.offline,
lost_root: e.lost_root,
});
}
});
@@ -1160,6 +1194,7 @@ pub fn spawn_scan(
struct ScanFailure {
message: String,
offline: bool,
lost_root: bool,
}
impl ScanFailure {
@@ -1169,6 +1204,7 @@ impl ScanFailure {
Self {
message: message.to_string(),
offline: false,
lost_root: false,
}
}
}
@@ -1177,11 +1213,48 @@ impl From<dr_sync::RemoteError> for ScanFailure {
fn from(e: dr_sync::RemoteError) -> Self {
Self {
offline: e.indicates_offline(),
lost_root: e.indicates_lost_root(),
message: e.to_string(),
}
}
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Record that a library can no longer be opened, without losing it.
///
/// Called on the worker, before the failure crosses the channel, because this
/// is where the catalog handle is — and because the marking must be durable
/// whether or not anyone is left to draw a banner. A process killed between
/// the failure and the next launch must still come back knowing what it could
/// not reach.
///
/// Nothing is deleted. Every rating, every edit and every row stays exactly
/// where it was; what changes is that the images now say they are offline, so
/// the grid can show them as held-not-here rather than as ordinary
/// photographs whose thumbnails happen to be failing one at a time.
///
/// A root with no row yet is the first scan of a library that has never
/// succeeded, and there is nothing to mark — the failure alone is the whole
/// story, and the launch screen is where it is told.
fn mark_library_offline(catalog: &Catalog, root: &str) {
let conn = catalog.connection();
let root_id: Option<i64> = conn
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.ok();
let Some(root_id) = root_id else {
log::info!("library {root} has no catalog root yet; nothing to mark offline");
return;
};
match dr_catalog::mark_root_offline(conn, dr_types::RootId(root_id as u64)) {
Ok(()) => log::warn!("library {root} is unreachable; its images are marked offline"),
Err(e) => log::error!("could not mark {root} offline: {e}"),
}
}
fn run_scan(
tx: &Sender<ScanMessage>,
conn: Connection,
@@ -1199,21 +1272,50 @@ fn run_scan(
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
rt.block_on(async {
let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?;
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// Classified rather than flattened to a local failure, because the
// removed-card case never gets as far as a request: the folder
// connector checks its root when it is constructed, so a library on an
// ejected card fails here and not in the walk. Reported as an ordinary
// error it left the grid showing a healthy library of images that
// could no longer be opened, one silent thumbnail failure at a time.
let backend = match crate::remote::connect(&conn) {
Ok(b) => b,
Err(e) => {
if e.indicates_lost_root() {
mark_library_offline(&catalog, &root);
}
return Err(e.into());
}
};
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
// first run this is empty and the walk is complete; on every run after
// it is what keeps cost proportional to what changed (ARCH §8.4).
let known = load_folder_etags(&catalog, &root);
let result = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
let scanned = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
let _ = tx.send(ScanMessage::Progress {
directories: p.directories_listed,
pruned: p.directories_pruned,
images: p.images_found,
});
})
.await?;
.await;
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// Written before the failure is reported, not after: the banner is a
// consequence of the catalog state and not the other way round, and a
// process that dies between the two must come back knowing.
let result = match scanned {
Ok(r) => r,
Err(e) => {
if e.indicates_lost_root() {
mark_library_offline(&catalog, &root);
}
return Err(e.into());
}
};
persist(&catalog, &root, &result).map_err(ScanFailure::local)?;
@@ -1306,13 +1408,27 @@ fn persist(
.ok()
});
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The `availability` arm is what ends an offline library, and it does
// it one photograph at a time. 3 is `Availability::Offline` and 0 is
// `MetadataOnly`, the same code this statement inserts new rows with —
// so a row that was marked offline when the root became unreachable is
// returned to exactly the state a fresh scan would have given it, and
// a row that was never marked is not touched at all.
//
// Conditional rather than a blanket reset for the same reason
// `dr_catalog::walk` restores per file rather than per root: the only
// thing that may clear "I could not reach this" is having reached it,
// and this statement runs precisely once per file the scan listed.
tx.execute(
"INSERT INTO images(root_id, folder_id, source_ref, format, file_size,
availability, metadata_state, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6)
ON CONFLICT(root_id, source_ref) DO UPDATE SET
file_size = excluded.file_size,
folder_id = excluded.folder_id",
folder_id = excluded.folder_id,
availability = CASE WHEN images.availability = 3
THEN 0 ELSE images.availability END",
rusqlite::params![
root_id,
folder_id,
@@ -3816,10 +3932,11 @@ pub fn read_cells_scoped(
.join(",");
let rated = filter.sql();
let (order, order_params) = grid_order_for(catalog, Some(scope));
let folded = uncollapsed("i");
let sql = format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
{order}
@@ -3854,11 +3971,12 @@ fn read_cells_all(
limit: usize,
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let mut rows = {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT {CELL_COLUMNS}
FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
{GRID_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
@@ -3964,10 +4082,15 @@ pub fn read_ids_span(
// different ORDER BY names a different photograph.
let (order, order_params) = grid_order_for(catalog, scope);
params.extend(order_params);
// And the same folding, for the same reason one step further on: a
// collapsed burst is one cell in the grid, so an ordinal counted over
// a list that still held every frame of it would name a photograph
// several places away from the one the user pointed at.
let folded = uncollapsed("i");
(
format!(
"SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{clause}
WHERE {VISIBLE}{rated}{folded}{clause}
{order}
LIMIT ? OFFSET ?"
),
@@ -4096,9 +4219,10 @@ pub fn total_images_scoped(
// Counted through `images` rather than over `collection_members` alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i");
let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated}
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
);
@@ -4409,8 +4533,9 @@ fn total_images_filtered(
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql();
let folded = uncollapsed("i");
let n: i64 = catalog.connection().query_row(
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"),
&format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"),
[],
|r| r.get(0),
)?;
@@ -4956,12 +5081,16 @@ mod tests {
#[test]
fn the_window_read_walks_the_ordering_index() {
let catalog = with_images(20);
// Including the burst clause, because the grid includes it: a
// predicate that quietly cost the ordering index would put the sort
// back and this is the only place that would notice.
let folded = uncollapsed("i");
let plan: Vec<String> = catalog
.connection()
.prepare(&format!(
"EXPLAIN QUERY PLAN
SELECT {CELL_COLUMNS} FROM images i
WHERE {VISIBLE}
WHERE {VISIBLE}{folded}
{GRID_ORDER}
LIMIT 10 OFFSET 5"
))
@@ -5014,6 +5143,58 @@ mod tests {
catalog
}
/// TRACES: FR-CULL-5
/// A folded burst takes rows out of the cells, the count and the range a
/// shift-click resolves — all three, together.
///
/// This is the test that would fail if the clause were added to four of
/// the five queries that need it. That failure has no other symptom: the
/// header claims images the grid will not draw, the scrollbar sizes itself
/// for rows that are not there, and neither number looks wrong on its own.
#[test]
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
use dr_catalog::bursts::{self, Rules, Signature};
let catalog = with_images(4);
// Three of the four are one burst: a second apart, one signature.
let ids = image_ids(&catalog);
for (n, id) in ids.iter().enumerate() {
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
catalog
.connection()
.execute(
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
perceptual_hash = ?3
WHERE id = ?1",
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
)
.unwrap();
}
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
let filter = RatingFilter::default();
// Open, as a new burst is: nothing has been taken away yet.
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
let cells = read_cells(&catalog, 0, 50).unwrap();
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
assert_eq!(
total_images_filtered(&catalog, &filter).unwrap(),
cells.len(),
"the header's count and the cells disagree"
);
assert_eq!(
read_ids_span(&catalog, None, &filter, false, 0, 49)
.unwrap()
.len(),
cells.len(),
"a shift-click over the whole grid would select frames it cannot show"
);
}
fn image_ids(catalog: &Catalog) -> Vec<dr_types::ImageId> {
let mut stmt = catalog
.connection()
+164 -20
View File
@@ -270,6 +270,22 @@ pub struct LibraryController {
/// judgement, and carrying the old one over would report a server down
/// that was never contacted.
reachability: RefCell<dr_sync::Reachability>,
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Why the library folder itself could not be opened, if it could not.
///
/// Beside [`Self::reachability`] rather than inside it, because
/// `dr_sync::Reachability` models *the server*, and it is deliberately
/// unmoved by a refusal — a forbidden file must not report the network as
/// down (see its own tests). A revoked tree grant is a refusal, so folding
/// it in would either break that rule or need an exception carved through
/// it.
///
/// Set only by a scan that failed at the root, and cleared only by one
/// that succeeded. Both states drive the same banner as being offline
/// does, because what the user can do is the same — carry on with what is
/// stored on the device — but the sentence under it is different, and so
/// is what will end it.
root_lost: RefCell<Option<String>>,
/// TRACES: FR-NC-6a
/// Drains the pin downloader. Held so a second pin replaces the timer
/// rather than leaving two draining the same finished channel.
@@ -372,6 +388,7 @@ impl LibraryController {
sidecar_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
root_lost: RefCell::new(None),
outbox_timer: RefCell::new(None),
outbox_maybe_dirty: std::cell::Cell::new(true),
geometry_timer: RefCell::new(None),
@@ -443,10 +460,15 @@ impl LibraryController {
}
}
/// TRACES: FR-CAT-9
/// Whether the app currently believes the server is unreachable.
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Whether the library cannot be reached, for either of the two reasons.
///
/// One answer rather than two because every caller asks it for the same
/// purpose: to decide whether starting a transfer is worth attempting.
/// A revoked grant fails that question exactly as a dead network does, and
/// a sync started against it would spend its retries proving it.
pub fn is_offline(&self) -> bool {
self.reachability.borrow().is_offline()
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
}
/// Whether the grid is narrowed to locally-stored originals.
@@ -941,6 +963,17 @@ fn drain_scan(
{
log::info!("back online");
}
// TRACES: FR-PLAT-AND-2
// And it is the only evidence that clears a lost root,
// for the same reason: the walk began by listing the
// root, so a scan that finished is a root that opened.
// The rows it marked offline are restored one at a
// time by `library::persist`, as each file is listed
// again — this only stops the banner claiming what is
// no longer true.
if ctl.root_lost.borrow_mut().take().is_some() {
log::info!("library folder is readable again");
}
refresh_offline(&w, ctl);
// An incremental rescan lists almost nothing, so
@@ -995,7 +1028,11 @@ fn drain_scan(
stop(&ctl.scan_timer);
return;
}
ScanMessage::Failed { message, offline } => {
ScanMessage::Failed {
message,
offline,
lost_root,
} => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
// Recorded as a failure even where it is only the
@@ -1004,7 +1041,26 @@ fn drain_scan(
// stopped because of it.
job.fail(message.clone());
if offline {
if lost_root {
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The library folder itself could not be opened —
// a share withdrawn, an unplugged drive, and in
// time a revoked document-tree grant. The worker
// has already marked every row under this root
// offline and deleted none of them; this is the
// half the user sees.
//
// Tested first because it is also true that the
// library is unreachable, and the generic answer
// would be reached first and be less useful.
*ctl.root_lost.borrow_mut() = Some(message);
refresh_offline(&w, ctl);
// Same reason as the offline arm below: without
// this a launch that began with a revoked grant
// shows an empty grid, which is the one impression
// this whole path exists to avoid.
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
} else if offline {
// Not an error state. The catalog from the last
// successful scan is still on disk and still
// accurate for everything already indexed, so the
@@ -1585,17 +1641,35 @@ fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
/// which is what keeps it testable without a display server.
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let reach = ctl.reachability.borrow();
let offline = reach.is_offline();
// TRACES: FR-PLAT-AND-2
// A lost root wins over a dead network, and does so even when both are
// true — which is the ordinary case, since the scan that discovered the
// grant was gone was also the last request the app made. Reported the
// other way round the user is told to wait for a connection that is
// working, and the thing that would actually fix it is never mentioned.
let lost = ctl.root_lost.borrow();
let offline = reach.is_offline() || lost.is_some();
window.set_library_offline(offline);
window.set_library_offline_reason(reach.reason().unwrap_or_default().into());
window.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.set_library_offline_since(
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into(),
// A duration is what a network outage has and a revoked permission
// does not: "for 4 minutes" invites waiting, and waiting is precisely
// what will not help here.
if lost.is_some() {
slint::SharedString::default()
} else {
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into()
},
);
drop(lost);
// A stale scan error under an offline banner reports one problem twice.
if offline {
@@ -2214,6 +2288,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// window, not one per cell.
rating: 0,
flag: 0,
// And by `bursts::sync_badges`, in one more query for the
// window. Zero is "not in a burst", which is what almost every
// photograph in a library is.
burst_count: 0,
burst_expanded: false,
}
})
.collect();
@@ -2246,6 +2325,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
.collect();
crate::collections_ui::sync_badges(window, catalog, &ids);
sync_ratings(window, catalog, &ids);
// How many frames each cell stands for, where it stands for several
// (FR-CULL-5).
crate::bursts::sync_badges(window, catalog, &ids);
// The rebuilt cells all carry `selected: false`, but the selection itself
// is a set of image ids and survives untouched. Without this the ticks
// vanished on every scroll — the selection was still there and still acted
@@ -3769,6 +3851,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
if !offline {
start_derived_sync(&w, &ctl_cb);
}
// And now every photograph in the library has a
// thumbnail, which is the only moment all of its burst
// signatures can be computed. See `bursts::start_pass`,
// which owns the pass and everything it needs to drain
// itself; what it wants from here is the paths and a
// way to say the grid has something new to draw.
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
let weak_after = w.as_weak();
let ctl_after = ctl_cb.clone();
crate::bursts::start_pass(
library::catalog_path(&conn.account),
library::thumbs_dir(&conn.account),
move |bursts| {
let Some(w) = weak_after.upgrade() else {
return;
};
if bursts > 0 && w.get_show_library() {
schedule_reload(&w, &ctl_after);
}
},
);
}
return;
}
}
@@ -4119,10 +4223,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
&format!(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
AND {}
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
dr_catalog::bursts::not_collapsed_away("images")
),
[ordinal as i64],
|r| r.get::<_, i64>(0),
)
@@ -4153,13 +4261,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them.
//
// A burst folded up occupies one row of the grid, so it must occupy one
// row of this count as well: an ordinal taken over the unfolded library
// would overshoot by every frame hidden earlier in it.
catalog
.connection()
.query_row(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
&format!(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1
AND {}",
dr_catalog::bursts::not_collapsed_away("images")
),
[when],
|r| r.get::<_, i64>(0),
)
@@ -4953,6 +5069,34 @@ pub fn wire<F>(
});
}
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
});
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty.
{
+276
View File
@@ -0,0 +1,276 @@
//! TRACES: FR-PLAT-AND-5 | NFR-RES-1 | FR-NC-6b
//! Giving memory back when the platform asks for it.
//!
//! Android kills the process that will not shrink. It does not negotiate and
//! it does not warn twice, and the app it kills is the one holding the most —
//! which, on a photo editor, is always this one. So the question this module
//! answers is not "how much can be freed" but "in what order", because the
//! caches differ enormously in what losing them costs.
//!
//! # The order, and why it is that order
//!
//! FR-PLAT-AND-5 states it: GPU tiles first, then proxies, then thumbnails.
//! Read as a rule rather than a list, it is *cheapest to rebuild goes first* —
//! a GPU allocation is remade from data already in memory, a proxy is remade
//! from a file already on disk, and a thumbnail may cost a network fetch.
//! [`Tier`] is that order written down where the code can be held to it, so
//! adding a cache means choosing its tier rather than choosing its position in
//! a hand-maintained sequence.
//!
//! What each tier actually reaches in this build is documented on the variant,
//! including where it reaches nothing yet. An empty tier is worth keeping
//! visible: it says the order is complete and the coverage is not.
//!
//! # Why this is a registry rather than a function that frees things
//!
//! Every cache worth evicting lives behind an `Rc<RefCell<…>>` owned by a
//! local in [`crate::run`], which is a two-thousand-line function whose
//! callbacks each hold their own handle. There is no central object to reach
//! them through, and inventing one to serve eviction alone would be a large
//! change to how the interface is wired for a small change in what it does.
//!
//! So `run` hands this module a closure per cache as it builds each one, and
//! this module owns only the ordering. The registration is next to the thing
//! being registered, which is also the property that keeps it honest: a cache
//! added later is one line away from being evictable, and a cache removed
//! takes its sink with it.
//!
//! # Everything here is single-threaded, and that is not a limitation
//!
//! The registry is a `thread_local`, holding `Fn()` rather than `Fn() + Send`,
//! because the pressure signal already arrives on the thread that owns the
//! caches. Slint's Android backend calls the event listener from inside
//! `poll_events`, which runs on the same thread as the event loop, which is
//! the thread `run` built everything on. Marshalling through
//! `invoke_from_event_loop` would add a hop and a lifetime question to solve a
//! problem that does not exist — and would arrive *after* the moment the
//! system asked, which for a memory warning is the one thing that matters.
//!
//! Anything reached from a worker thread — the thumbnail store, the original
//! cache — is on disk and bounded by its own budget (NFR-RES-4), and is not
//! what a memory warning is about.
use std::cell::RefCell;
/// How hard the platform is asking.
///
/// Two levels rather than Android's eight, because two is what the platform
/// actually delivers to this app. `ComponentCallbacks2.onTrimMemory` and its
/// `TRIM_MEMORY_*` grades are a Java callback on an `Activity` or
/// `Application`; a `NativeActivity` receives only `ANativeActivityCallbacks`,
/// whose memory callback is the ungraded `onLowMemory` — which is what
/// android-activity surfaces as `MainEvent::LowMemory`. Modelling grades the
/// entry point cannot observe would be modelling a wish.
///
/// [`Self::UiHidden`] recovers the one distinction that *is* observable and is
/// worth acting on, because it is the cheapest moment to give memory back:
/// nothing is on screen, so nothing that is freed has to be drawn again before
/// the user notices. It corresponds to `TRIM_MEMORY_UI_HIDDEN` in intent and
/// is derived from the activity being stopped rather than from a memory
/// warning at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
/// The app is no longer on screen. Free what only a visible window needs.
UiHidden,
/// The system says it is short of memory. Free everything that can be
/// rebuilt.
Critical,
}
/// What a cache costs to lose, as an order.
///
/// Declared in eviction order and iterated in declaration order by
/// [`Tier::ORDER`], so the sequence FR-PLAT-AND-5 specifies is a property of
/// this type rather than of each call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
/// GPU allocations that are rebuilt from data the process still holds.
///
/// The develop session's compiled pipelines, its detail intermediates and
/// its output textures. Rebuilt by the next render from the demosaiced
/// source, which is still resident — see
/// [`DevelopSession::release_gpu_caches`](crate::DevelopSession::release_gpu_caches)
/// for what is deliberately kept and what that is waiting on.
///
/// First because it is both the largest evictable pool on a mobile GPU and
/// the cheapest to refill: no I/O, no network, one frame's work.
Gpu,
/// Decoded image data rebuilt by reading a file again.
///
/// **Nothing registers here in this build, and the tier is kept anyway.**
/// There is no in-memory proxy cache: the only decoded full-size frame in
/// the process is the open develop session's, which belongs to
/// [`Tier::Gpu`] and cannot be dropped until a session can be rebuilt from
/// a durable record (FR-PLAT-AND-3). The on-disk original cache is a
/// different thing wearing the same word — freeing disk relieves no memory
/// pressure, and it already has a budget and an LRU of its own
/// (`dr_catalog::Cache`, NFR-RES-4).
Proxies,
/// Decoded thumbnails, rebuilt by decoding a stored JPEG again — or, at
/// worst, by fetching one.
///
/// Last because this is the tier a user sees losing: an evicted portrait
/// is a rail that redraws, and an evicted grid cell is a photograph that
/// greys out and comes back.
Thumbnails,
}
impl Tier {
/// The eviction order, in one place.
pub const ORDER: [Tier; 3] = [Tier::Gpu, Tier::Proxies, Tier::Thumbnails];
/// Whether this tier is given up at this level of pressure.
///
/// Hiding the window frees the GPU tier and nothing else. That is not
/// caution about the rest — it is that a backgrounded app has no window to
/// draw and therefore no use at all for a render pipeline, while its
/// thumbnails are exactly what the user will be looking at half a second
/// after they come back. Under [`Level::Critical`] the process is being
/// measured against being killed, and a slow return beats no return.
fn evicted_at(self, level: Level) -> bool {
match level {
Level::UiHidden => matches!(self, Tier::Gpu),
Level::Critical => true,
}
}
}
/// A cache that has offered itself up, and the tier it goes in.
///
/// Named because the registry is a `Vec` of these and the nested type is hard
/// to read at the use site rather than because either half means anything on
/// its own.
type Sink = (Tier, Box<dyn Fn()>);
thread_local! {
/// Registered sinks, in the order they were registered within a tier.
///
/// Within a tier the order is registration order and nothing depends on
/// it; between tiers it is [`Tier::ORDER`], which everything depends on.
static SINKS: RefCell<Vec<Sink>> = const { RefCell::new(Vec::new()) };
}
/// Offer a cache up for eviction at `tier`.
///
/// Called as each cache is built, so that the registration reads next to the
/// thing it is about. The closure is kept for the life of the thread; it must
/// therefore hold weak or shared handles rather than borrow anything, which is
/// the natural shape here because everything it can reach is already an `Rc`.
pub(crate) fn evict_at(tier: Tier, sink: impl Fn() + 'static) {
SINKS.with_borrow_mut(|sinks| sinks.push((tier, Box::new(sink))));
}
/// TRACES: FR-PLAT-AND-5
/// Give memory back, in [`Tier::ORDER`], as far down as `level` calls for.
///
/// Safe to call when nothing is registered — before the window is built, or on
/// a platform that never asks — in which case it does nothing at all.
///
/// The registry is taken out of the cell for the duration rather than borrowed
/// across the calls. A sink runs arbitrary interface code, and interface code
/// that registered another cache, or called this again, would otherwise meet a
/// `RefCell` it had already borrowed and abort the process. Freeing memory is
/// the wrong moment to be brittle about re-entry.
pub fn relieve(level: Level) {
let taken: Vec<(Tier, Box<dyn Fn()>)> = SINKS.with_borrow_mut(std::mem::take);
let mut run = 0usize;
for tier in Tier::ORDER {
if !tier.evicted_at(level) {
continue;
}
for (t, sink) in &taken {
if *t == tier {
sink();
run += 1;
}
}
}
// Put them back, keeping anything a sink registered while it ran — after,
// so the order within a tier stays registration order.
SINKS.with_borrow_mut(|sinks| {
let added = std::mem::replace(sinks, taken);
sinks.extend(added);
});
log::info!("memory pressure ({level:?}): ran {run} eviction(s)");
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
/// Registers one sink per tier, backwards, and hands back what they saw.
fn recorder() -> Rc<RefCell<Vec<Tier>>> {
let seen = Rc::new(RefCell::new(Vec::new()));
for tier in [Tier::Thumbnails, Tier::Proxies, Tier::Gpu] {
let seen = seen.clone();
evict_at(tier, move || seen.borrow_mut().push(tier));
}
seen
}
fn reset() {
SINKS.with_borrow_mut(|s| s.clear());
}
#[test]
fn eviction_runs_cheapest_to_rebuild_first() {
// Registered deliberately backwards, because the guarantee is about
// the tier and not about who registered first. A handler that simply
// ran its list would pass every other assertion here and fail this
// one — and on a device it would throw away thumbnails to keep a
// render pipeline that nothing was going to draw.
reset();
let seen = recorder();
relieve(Level::Critical);
assert_eq!(
*seen.borrow(),
vec![Tier::Gpu, Tier::Proxies, Tier::Thumbnails]
);
reset();
}
#[test]
fn hiding_the_window_costs_only_the_gpu() {
// The cheap moment: give back what a window that is not on screen
// cannot use, and keep what the user will be looking at when they come
// back. Widening this to everything would make every task switch a
// reload of the grid.
reset();
let seen = recorder();
relieve(Level::UiHidden);
assert_eq!(*seen.borrow(), vec![Tier::Gpu]);
reset();
}
#[test]
fn pressure_before_anything_is_registered_is_not_a_failure() {
// The launch window: `android_main` installs the listener before
// `run` builds a single cache, so the first minutes of a cold start
// can deliver a warning to an empty registry.
reset();
relieve(Level::Critical);
}
#[test]
fn a_sink_may_register_another_without_deadlocking() {
// Guards the re-entry the take-and-restore exists for: a sink is
// interface code, and interface code that reached this module again
// would otherwise meet a borrow it already held.
reset();
let seen = Rc::new(RefCell::new(0usize));
{
let seen = seen.clone();
evict_at(Tier::Gpu, move || {
*seen.borrow_mut() += 1;
evict_at(Tier::Thumbnails, || {});
});
}
relieve(Level::Critical);
assert_eq!(*seen.borrow(), 1);
// And the one it added survived, rather than being dropped with the
// temporary list.
assert_eq!(SINKS.with_borrow(|sinks| sinks.len()), 2);
reset();
}
}
+160
View File
@@ -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"
);
}
}
+192
View File
@@ -0,0 +1,192 @@
//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1
//! Reads and writes the named preset library beside the other config.
//!
//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and
//! separate from it for the reason that one is: two files, two lifetimes.
//! Resetting preferences must not destroy a photographer's presets, and a
//! preset library is the one file here that represents work rather than
//! configuration — it is what someone would carry to another machine.
//!
//! # Why the library is loaded whole and saved whole
//!
//! There is no incremental path. The document is kilobytes (see
//! [`PresetLibrary`]), the caller is holding the copy the user just edited,
//! and a merge would let a preset the window has not drawn yet resurrect
//! after a delete. The same argument the settings store makes about fields,
//! made about entries.
use std::path::{Path, PathBuf};
use dr_pipeline::PresetLibrary;
/// Loads and saves the named preset library.
pub struct PresetStore {
path: PathBuf,
}
impl PresetStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/presets.drpl`, falling back to
/// `~/.config` — the same resolution `SettingsStore` does, so the files sit
/// together and a user backing up one takes all of them.
pub fn open() -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(dir.join(format!(
"presets.{}",
dr_pipeline::preset::LIBRARY_EXTENSION
)))
}
/// Open at an explicit path — for tests, and for a non-default location.
pub fn open_at(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
/// The stored library, or an empty one.
///
/// A missing file is a first run. An unparseable one is answered with an
/// empty library rather than an error, on the same reasoning the settings
/// store gives — the alternative is an app that will not start until the
/// user hand-edits a file.
///
/// The difference worth stating: settings are regenerated on the next
/// save, where a preset library is *work*, and rewriting it whole would
/// destroy whatever was in there. So a library that failed to parse is
/// held empty in memory and **not** written back over until the user saves
/// a preset, at which point they have chosen to. Nothing here deletes the
/// file, and the warning names the path so it can be recovered by hand.
pub fn load(&self) -> PresetLibrary {
match std::fs::read_to_string(&self.path) {
Ok(text) => match PresetLibrary::parse(&text) {
Ok(library) => library,
Err(e) => {
log::warn!(
"{} is not a readable preset library ({e}); \
starting empty, the file is left alone",
self.path.display()
);
PresetLibrary::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
Err(e) => {
log::warn!("reading {}: {e}; starting empty", self.path.display());
PresetLibrary::default()
}
}
}
/// Persist the library, replacing whatever was there.
pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
// Write and rename, so an interrupted save cannot truncate the
// existing file — the same discipline the settings and session stores
// use, and it matters more here because what would be truncated is
// every preset the user has ever made rather than a set of
// preferences that rebuild themselves.
let tmp = self.path.with_extension("tmp");
std::fs::write(&tmp, library.to_text())?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PresetStoreError {
#[error("preset library io: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::Preset;
/// The same hand-rolled temp directory the settings store's tests use —
/// unique per process and thread, so a parallel run cannot collide.
fn tempdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-presets-test-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn store(name: &str) -> (PresetStore, PathBuf) {
let dir = tempdir(name);
(
PresetStore::open_at(dir.join("nested").join("presets.drpl")),
dir,
)
}
fn library() -> PresetLibrary {
let mut lib = PresetLibrary::default();
let mut params = std::collections::BTreeMap::new();
params.insert(("exposure".to_string(), "exposure".to_string()), 0.75);
lib.insert("Warm", Preset::from_params(params)).unwrap();
lib.insert("Neutral", Preset::default()).unwrap();
lib
}
#[test]
fn a_library_round_trips_through_the_store() {
let (store, _dir) = store("a-library-round-trips-through-the-store");
store.save(&library()).unwrap();
assert_eq!(store.load(), library());
}
#[test]
fn a_first_run_loads_an_empty_library() {
let (store, _dir) = store("a-first-run-loads-an-empty-library");
assert!(!store.path().exists());
assert!(store.load().is_empty());
}
#[test]
fn saving_creates_the_config_directory() {
let (store, _dir) = store("saving-creates-the-config-directory");
store.save(&library()).unwrap();
assert!(store.path().exists());
}
#[test]
fn an_unreadable_file_is_left_alone_rather_than_overwritten() {
// The difference from the settings store, and the reason this test
// exists: what is on disk is work, so a parse failure must not be the
// moment it is destroyed.
let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten");
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
std::fs::write(store.path(), "this is not a preset library").unwrap();
assert!(store.load().is_empty());
assert_eq!(
std::fs::read_to_string(store.path()).unwrap(),
"this is not a preset library"
);
}
#[test]
fn a_neutral_preset_survives_a_save_and_load() {
// It is a real entry, not an absence — see `PresetLibrary::insert`.
let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load");
store.save(&library()).unwrap();
assert_eq!(store.load().get("Neutral"), Some(&Preset::default()));
}
}
+421 -6
View File
@@ -35,10 +35,11 @@ use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use dr_pipeline::{Preset, Scope, Sidecar};
use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar};
use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::preset_store::PresetStore;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
/// Where the develop view's current edit is stored.
@@ -121,11 +122,21 @@ impl Clipboard {
let Some(preset) = self.preset.borrow().clone() else {
return String::new();
};
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
describe(&preset, scope)
}
}
/// A short description of a preset's contents, for a label.
///
/// Free rather than a method on [`Clipboard`] because the named-preset sheet
/// describes what *saving* would capture, and a second phrasing of the same
/// count is a second thing to keep in step — the sheet saying "3 settings"
/// beside a panel saying "3 adjustments" would read as two different numbers.
pub fn describe(preset: &Preset, scope: Scope) -> String {
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
}
@@ -528,6 +539,308 @@ pub fn wire(
}
}
// ---------------------------------------------------------------------------
// Named presets (FR-DEV-6)
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// The saved preset library, and the file it lives in.
///
/// # Why the library is held in memory as well as on disk
///
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
/// copy is what the sheet is drawn from and what the next edit is applied to.
/// Reading the file back after each change would be the same bytes and one
/// more chance for a failed read to empty a list the user is looking at.
///
/// # A failed save is reported, not swallowed
///
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
/// directory that is not writable. Losing a preset the user just named, with
/// the sheet cheerfully listing it, would be discovered at the worst possible
/// moment, so the write's result reaches the window.
pub struct NamedPresets {
store: PresetStore,
library: RefCell<PresetLibrary>,
}
impl NamedPresets {
/// Load the library from its usual place.
pub fn open() -> Rc<Self> {
Self::at(PresetStore::open())
}
/// Load from an explicit store — for tests, and for a non-default location.
#[cfg(test)]
pub fn open_at(path: PathBuf) -> Rc<Self> {
Self::at(PresetStore::open_at(path))
}
fn at(store: PresetStore) -> Rc<Self> {
let library = RefCell::new(store.load());
Rc::new(Self { store, library })
}
/// The stored names, in the order they are written.
pub fn names(&self) -> Vec<String> {
self.library.borrow().names().map(String::from).collect()
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<Preset> {
self.library.borrow().get(name).cloned()
}
/// Store `preset` under `name` and persist.
///
/// The in-memory library is updated first and rolled back if the write
/// fails, so what the sheet lists is always what is on disk. The
/// alternative — writing first — would mean holding a preset the file does
/// not have on every failure path.
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
let previous = {
let mut library = self.library.borrow_mut();
let existing = library.get(name.trim()).cloned();
library.insert(name, preset).map_err(SaveError::Name)?;
existing
};
self.persist(|library| match previous {
Some(p) => {
let _ = library.insert(name, p);
}
None => {
library.remove(name.trim());
}
})
}
/// Save the library, undoing the in-memory change if the write fails.
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
let result = self.store.save(&self.library.borrow());
match result {
Ok(()) => Ok(()),
Err(e) => {
rollback(&mut self.library.borrow_mut());
// Named, the way the settings page names its file: "could not
// save" without saying where leaves the user nothing to check
// and nothing to fix.
Err(SaveError::Write(format!(
"{}: {e}",
self.store.path().display()
)))
}
}
}
}
/// Why a preset could not be saved.
enum SaveError {
/// The name itself was refused.
Name(NameError),
/// The library could not be written.
Write(String),
}
impl SaveError {
/// What to put in front of the user.
///
/// The prose lives here rather than in `dr-pipeline`, which depends on
/// nothing and has no business holding user-facing strings.
fn message(&self) -> String {
match self {
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
Self::Name(NameError::Unrepresentable) => {
"A preset name cannot contain brackets or line breaks.".to_string()
}
Self::Write(e) => format!("Could not save presets: {e}"),
}
}
}
/// Push the stored names onto the window.
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
}
/// Wire saving, applying, renaming and deleting named presets.
///
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
/// same reason: applying a preset to the open image changes the graph, so it
/// has to rebuild the panel, redraw the canvas and know where to save.
pub fn wire_named(
window: &AppWindow,
named: Rc<NamedPresets>,
develop: Develop,
settings: Rc<settings_ui::SettingsController>,
library: Rc<library_ui::LibraryController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let Develop {
session,
rows,
redraw,
open,
} = develop;
render_named(window, &named);
// --- save the open edit under a name ---------------------------------
{
let weak = window.as_weak();
let named = named.clone();
let session = session.clone();
window.on_save_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
w.set_preset_name_error("Open a photograph first.".into());
return;
};
// Captured at full scope, exactly as a copy is: the scope is a
// decision about applying, and a preset that had already discarded
// the crop could never grow it back (see `Preset::capture`).
match named.insert(&name, preset) {
Ok(()) => {
w.set_preset_name_error(Default::default());
render_named(&w, &named);
}
Err(e) => w.set_preset_name_error(e.message().into()),
}
});
}
// A refusal the user has started correcting is stale, and a message that
// outlives its cause is one the user learns to ignore.
{
let weak = window.as_weak();
window.on_preset_name_edited(move |_| {
if let Some(w) = weak.upgrade() {
w.set_preset_name_error(Default::default());
}
});
}
// --- what saving would capture ----------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
window.on_presets_opened(move || {
let Some(w) = weak.upgrade() else { return };
// At the scope a save would use, which is full: a preset keeps
// the framing it was captured with and drops it at apply time.
let summary = session
.borrow()
.as_ref()
.map(|s| describe(&s.copy_settings(), Scope::Everything))
.unwrap_or_default();
w.set_preset_capture_summary(summary.into());
});
}
// --- apply ------------------------------------------------------------
//
// One callback for both targets. Which one is meant is not a guess: the
// sheet was opened from a view that set `preset-apply-count`, and the
// label the user just read said "Applies to 12 selected photographs" or
// said nothing. Deciding here from the same number keeps the promise.
{
let weak = window.as_weak();
let named = named.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
window.on_apply_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = named.get(&name) else {
// Another window may have deleted it since this list was drawn.
render_named(&w, &named);
return;
};
let scope = scope_for(&settings.snapshot());
if w.get_preset_apply_count() > 0 {
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
} else {
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The same three steps a paste takes, for the same reasons:
// many controls moved without any of them being touched, and
// a deliberate discrete action is saved immediately.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
save_open_edit(&w, &open.borrow(), &session, &library);
}
w.set_presets_open(false);
});
}
// --- rename -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_rename_preset(move |from, to| {
let Some(w) = weak.upgrade() else { return };
let renamed = {
let mut library = named.library.borrow_mut();
library.rename(&from, &to)
};
match renamed {
Ok(_) => {
// Nothing to roll back to on a failed write beyond the
// name it had, which is what this restores.
let from = from.to_string();
let to = to.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.rename(&to, &from);
}) {
w.set_preset_name_error(e.message().into());
}
}
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
}
render_named(&w, &named);
});
}
// --- delete -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_delete_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let removed = {
let mut library = named.library.borrow_mut();
let previous = library.get(&name).cloned();
library.remove(&name);
previous
};
if let Some(previous) = removed {
let name = name.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.insert(&name, previous);
}) {
w.set_preset_name_error(e.message().into());
}
}
render_named(&w, &named);
});
}
}
/// Seconds since the epoch, or zero if the clock is before it.
///
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
@@ -804,4 +1117,106 @@ mod tests {
assert!(clipboard.is_armed());
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
}
#[test]
fn a_saved_preset_is_on_disk_before_the_call_returns() {
// Not on the way out, and not on a timer: a preset the user named and
// then lost to a crash is the one failure this feature cannot have.
let (presets, dir) = named("saved-immediately");
presets
.insert("Warm", Preset::capture(&edited()))
.ok()
.expect("saved");
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
}
#[test]
fn a_saved_preset_carries_the_edit_it_captured() {
let (presets, _dir) = named("carries-the-edit");
presets.insert("Warm", Preset::capture(&edited())).ok();
let preset = presets.get("Warm").expect("stored");
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
assert_eq!(
target.param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE
),
Some(1.5)
);
}
#[test]
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
let (presets, _dir) = named("refused-name");
assert!(presets.insert("", Preset::default()).is_err());
assert!(presets.insert("bracket]", Preset::default()).is_err());
assert!(presets.names().is_empty());
}
#[test]
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
// The rollback. A sheet listing a preset the file does not have is a
// loss the user discovers later, at the moment they reach for it.
let dir = tempdir("failed-write");
// A *file* where the store wants a directory, so `create_dir_all`
// fails and the save cannot succeed.
let blocked = dir.join("blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
assert!(presets.insert("Warm", Preset::default()).is_err());
assert!(
presets.names().is_empty(),
"the failed save left a preset behind"
);
}
#[test]
fn a_failed_overwrite_puts_the_original_back() {
// The other half of the rollback, and the one that loses work if it is
// wrong: overwriting is destructive, so a failed overwrite has to
// restore what was there rather than leave the name holding the new
// value the file never received.
use std::os::unix::fs::PermissionsExt;
let dir = tempdir("failed-overwrite");
let path = dir.join("presets.drpl");
let presets = NamedPresets::open_at(path.clone());
presets.insert("Warm", Preset::capture(&edited())).ok();
let original = presets.get("Warm").expect("stored");
// The directory exists, so `create_dir_all` still succeeds and it is
// the write of the temporary file that fails — which is the path a
// full disk takes.
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&dir, perms.clone()).unwrap();
let failed = presets.insert("Warm", Preset::default()).is_err();
// Restore the permissions before asserting, so a failure here does not
// leave an undeletable directory behind for the next run.
perms.set_mode(0o700);
std::fs::set_permissions(&dir, perms).unwrap();
assert!(failed, "the write should have failed");
assert_eq!(
presets.get("Warm"),
Some(original),
"the failed overwrite kept the new value"
);
}
}
+1 -1
View File
@@ -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)
+14
View File
@@ -733,6 +733,10 @@ export component TransferPanel inherits VerticalLayout {
callback copy();
callback paste();
/// TRACES: FR-DEV-6
/// Open the named-preset sheet. Beside copy and paste because it is the
/// same thought given a name — this edit, kept.
callback open-presets();
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself — the
@@ -772,6 +776,16 @@ export component TransferPanel inherits VerticalLayout {
}
}
// TRACES: FR-DEV-6
// The saved half. On its own row rather than a third of the one above,
// because copy and paste are a pair — one arms the other — and a button
// that does neither sitting between them would read as part of that pair.
Button {
text: "Presets…";
enabled: root.enabled;
clicked => { root.open-presets(); }
}
if root.armed: Caption {
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
}
+120
View File
@@ -10,6 +10,8 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { PresetSheet } 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";
@@ -70,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
@@ -585,6 +603,8 @@ export component AppWindow inherits Window {
callback library-cell-rated(int, int);
/// The trash target was clicked on one cell, by row.
callback library-cell-trashed(int);
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
callback library-burst-toggled(int);
/// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of
@@ -661,6 +681,35 @@ export component AppWindow inherits Window {
/// Apply the clipboard to every selected image in the grid.
callback paste-settings-to-selection();
// --- named presets (FR-DEV-6) ---
//
// The saved half of the same requirement. On the window rather than in a
// view for the reason the clipboard is: a preset is saved in develop,
// where there is an edit to capture, and applied most often in the grid,
// where there is a selection to apply it to.
in-out property <bool> presets-open: false;
in property <[string]> preset-names;
/// Whether there is an edit in hand to save, set by whichever view opened
/// the sheet. `in-out` because that is where the answer is known.
in-out property <bool> preset-can-save: false;
/// What applying would act on: 0 is the open photograph, higher is that
/// many selected. Set at open, and read back by Rust when one is picked —
/// so the action matches the count the user read on the way in.
in-out property <int> preset-apply-count: 0;
/// What saving would capture, from the routine the clipboard summary uses.
in property <string> preset-capture-summary;
/// Why the last name was refused, cleared by the next keystroke.
in property <string> preset-name-error;
callback save-preset(string);
callback apply-preset(string);
callback rename-preset(string, string);
callback delete-preset(string);
callback preset-name-edited(string);
/// Raised as the sheet opens over an open photograph, so Rust can say what
/// saving would capture. A property refreshed on every slider drag would
/// be recomputing a string nobody is looking at.
callback presets-opened();
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
@@ -1444,6 +1493,15 @@ in property <bool> panel-visible: true;
paste-settings-to-selection => {
root.paste-settings-to-selection();
}
// TRACES: FR-DEV-6
// Opened with the selection's size, which is both what the
// sheet says it would act on and what the apply handler
// reads back to decide it means the batch.
open-presets => {
root.preset-apply-count = root.library-selected-count;
root.preset-can-save = false;
root.presets-open = true;
}
// TRACES: FR-EXP-7
exporting: root.library-exporting;
@@ -1527,6 +1585,7 @@ in property <bool> panel-visible: true;
cell-rated(i, n) => { root.library-cell-rated(i, n); }
cell-trashed(i) => { root.library-cell-trashed(i); }
burst-toggled(i) => { root.library-burst-toggled(i); }
trash-selection() => { root.library-trash-selection(); }
// Derived from the sidebar's own selection rather than
// mirrored in a second property: `-1` is already the sentinel
@@ -1654,6 +1713,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
@@ -2187,6 +2258,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
@@ -2240,6 +2331,15 @@ in property <bool> panel-visible: true;
framing-withheld: root.settings-framing-withheld;
copy => { root.copy-settings(); }
paste => { root.paste-settings(); }
// Opened with no count, which is what tells
// the apply handler this means the open
// photograph rather than a selection.
open-presets => {
root.preset-apply-count = 0;
root.preset-can-save = root.adjust-enabled;
root.presets-opened();
root.presets-open = true;
}
}
Rectangle {
@@ -2439,6 +2539,26 @@ in property <bool> panel-visible: true;
// Below the load bar, above everything else: a download started from
// here shows its progress in that bar, and the bar must not be the
// thing the dialogue covers.
// TRACES: FR-DEV-6
// Over the shell rather than inside a view, because both views open
// it — and because the develop column is 320px wide, which is not
// enough to list presets and rename one in.
if root.presets-open: PresetSheet {
width: 100%;
height: 100%;
names: root.preset-names;
can-save: root.preset-can-save;
apply-count: root.preset-apply-count;
capture-summary: root.preset-capture-summary;
name-error: root.preset-name-error;
save(name) => { root.save-preset(name); }
apply(name) => { root.apply-preset(name); }
rename(from, to) => { root.rename-preset(from, to); }
remove(name) => { root.delete-preset(name); }
name-edited(text) => { root.preset-name-edited(text); }
dismiss => { root.presets-open = false; }
}
OfflinePrompt {
width: 100%;
height: 100%;
+105
View File
@@ -468,6 +468,16 @@ export struct LibraryCell {
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
// four-star frame is a normal thing to do mid-cull.
flag: int,
// Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it
// belongs to none, which is most of a library. A burst that is collapsed
// draws only its representative, so on that cell this is the count of what
// is hidden behind it — the reason it is shown at all.
burst-count: int,
// Whether the group is currently open. Drawn differently rather than
// hidden: a burst the user has expanded is the one thing on screen that
// needs a way back, and a control that disappears once used is a control
// nobody finds twice.
burst-expanded: bool,
}
// The photo roll: the grid's loaded window along the foot of the develop view.
@@ -860,6 +870,9 @@ component HeaderActions inherits HorizontalLayout {
callback export-selection();
callback cancel-export();
callback paste-settings-to-selection();
/// TRACES: FR-DEV-6
/// Open the named-preset sheet over the selection.
callback open-presets();
callback remove-from-collection();
/// Open the sheet that files the selection in a collection.
callback add-to-collection();
@@ -941,6 +954,20 @@ component HeaderActions inherits HorizontalLayout {
clicked => { root.paste-settings-to-selection(); }
}
// TRACES: FR-DEV-6
// The saved settings, beside the copied ones.
//
// Gated on the selection alone, unlike the paste beside it: that button
// needs a clipboard *this session*, where the preset list is whatever the
// photographer saved last month. Requiring an armed clipboard here would
// hide the saved presets behind an unrelated action — which is the shape
// of bug that makes a feature only its author knows about (FR-UI-4).
if root.selected-count > 0: Button {
text: "Presets";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-presets(); }
}
// TRACES: FR-EXP-7 | NFR-ARCH-3
// Export the selection, and stop the batch that is running.
//
@@ -1148,6 +1175,11 @@ export component LibraryGrid inherits Rectangle {
callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int);
/// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back
/// up. Which of the two is decided in Rust, from what the catalog says the
/// group is currently doing, so the mark cannot get out of step with the
/// query that actually hides the frames.
callback burst-toggled(int);
/// Whether the grid is currently listing the trash rather than the
/// library. Suppresses the per-cell trash target, which would be inert
/// there — `plan_trash` skips an already-trashed image — and offering a
@@ -1433,6 +1465,8 @@ export component LibraryGrid inherits Rectangle {
in property <bool> settings-armed: false;
in property <string> settings-summary;
callback paste-settings-to-selection();
/// TRACES: FR-DEV-6
callback open-presets();
// TRACES: FR-EXP-7
// Exporting the selection. The grid owns neither the settings that decide
@@ -1819,6 +1853,7 @@ export component LibraryGrid inherits Rectangle {
export-selection => { root.export-selection(); }
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
open-presets => { root.open-presets(); }
remove-from-collection => { root.remove-from-collection(); }
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
@@ -1904,6 +1939,7 @@ export component LibraryGrid inherits Rectangle {
export-selection => { root.export-selection(); }
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
open-presets => { root.open-presets(); }
remove-from-collection => { root.remove-from-collection(); }
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
@@ -3226,6 +3262,75 @@ export component LibraryGrid inherits Rectangle {
rate(n) => { root.cell-rated(i, n); }
trash() => { root.cell-trashed(i); }
}
// The burst mark (FR-CULL-5): how many frames this
// moment holds,
// and the way in and out of them.
//
// A child of `cell-touch` for exactly the reason the
// stars above are: a click here must not also reach
// `cell-clicked` and throw the user into develop, and
// children are hit-tested before the element they sit
// in. Unlike the stars it is never hidden — a collapsed
// burst is standing in for frames that are not on
// screen, and there has to be something visible saying
// so whether or not a pointer is anywhere near.
//
// Bottom left, clear of the centred star strip and of
// both top corners, which the flag and the collection
// badge already have.
if cell.burst-count > 1: Rectangle {
x: 6px;
y: parent.height - self.height - 26px;
width: 30px;
height: 18px;
// The pile behind the top card, drawn only while the
// group is folded up. It is the whole of the "there
// is more than one of these" cue; once the burst is
// open the frames themselves say it.
Rectangle {
x: 3px;
y: -3px;
width: parent.width - 3px;
height: parent.height;
visible: !cell.burst-expanded;
background: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.rule;
}
Rectangle {
width: 100%;
height: 100%;
background: cell.burst-expanded ? Theme.selected
: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: cell.burst-expanded ? Theme.selected-ring
: Theme.rule;
Text {
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
// No "of": the number is the size of the
// group, and a cell this small cannot
// afford a word to say so.
text: cell.burst-count;
color: Theme.ink;
font-size: 10px;
font-weight: 700;
}
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.burst-toggled(i); }
}
}
}
}
// Where the run would land. A bar in the gutter beside the
+34
View File
@@ -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) + "%"; }
}
+150
View File
@@ -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;
}
}
}
+210
View File
@@ -0,0 +1,210 @@
import { Theme } from "theme.slint";
import { Button, Field, Caption } from "widgets.slint";
// TRACES: FR-DEV-6
// The named preset sheet: save the edit in hand, and apply a saved one.
//
// # One sheet for both views
//
// A preset is saved in develop, where there is an edit to capture, and applied
// most often in the library, where there is a selection to apply it to. Those
// are two different moments and it is the same list, so this is one component
// mounted at the shell rather than a panel in each view — the same reasoning
// the clipboard's properties are declared on the window for.
//
// What differs between the two is not the sheet but its *answers*: `can-save`
// is false with nothing open, and `apply-count` says whether applying means
// this photograph or those forty. Both are facts the shell already holds.
//
// # The same card, scrim and dismissal as the filing and keywording sheets
//
// Deliberately. A user who has filed a selection knows how this works, and a
// second idiom for the same gesture would be a second thing to learn for no
// gain.
export component PresetSheet inherits Rectangle {
/// The saved names, in the order they are stored.
in property <[string]> names;
/// Whether there is an edit in hand to save. False in the library, where
/// nothing is open, and with an image that failed to decode.
in property <bool> can-save: false;
/// What a preset would be applied to: 0 means the open photograph, and
/// anything higher means that many selected ones.
in property <int> apply-count: 0;
/// What saving would capture — "3 adjustments" — from the same routine the
/// clipboard's summary comes from, so the two cannot disagree.
in property <string> capture-summary;
/// Set while a name is refused, and cleared by the next keystroke. Prose
/// rather than a code, because the shell knows why and this does not.
in property <string> name-error;
callback save(string);
callback apply(string);
callback rename(string, string);
callback remove(string);
callback dismiss();
/// Every keystroke in the name field, so the shell can clear a refusal the
/// user has started correcting.
callback name-edited(string);
background: #000000CC;
// Swallows the taps that miss the card, and closes. First, so the card's
// own controls sit above it.
TouchArea {
clicked => { root.dismiss(); }
}
// Which row is being renamed, by name. Empty means none.
//
// A name rather than an index: the list is rebuilt from Rust after every
// change, and an index would point at whatever moved into that slot.
property <string> renaming: "";
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
// Stops a press on the card reaching the scrim behind it.
TouchArea { }
sheet := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "Presets";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
}
// Saving, first: it is the half that has something to say about
// the photograph currently open, and it disappears entirely in the
// library rather than sitting there disabled — a permanently dead
// control teaches the reader that the sheet lies.
if root.can-save: VerticalLayout {
spacing: Theme.gap-sm;
name := Field {
placeholder: "Name this edit and press return";
accepted(text) => {
root.save(text);
// Cleared only once the shell has accepted it. A
// refused name the user has to retype is a refusal
// that costs more than the mistake did, so the field
// keeps the text and `name-error` says why.
if (root.name-error == "") {
self.text = "";
}
}
edited(text) => { root.name-edited(text); }
}
if root.name-error != "": Caption {
text: root.name-error;
warn: true;
}
// The bare summary, phrased exactly as the develop panel
// phrases the clipboard's — same routine, same words, so the
// two cannot appear to disagree about one edit.
if root.name-error == "": Caption {
text: root.capture-summary;
}
}
if root.can-save: Rectangle { height: 1px; background: Theme.rule; }
Flickable {
vertical-stretch: 1;
// A floor, so the list is not squeezed out of existence by the
// field and the button around it on a short window.
min-height: 120px;
viewport-height: root.names.length * (Theme.touch-target + 2px);
for entry[i] in root.names: Rectangle {
y: i * (Theme.touch-target + 2px);
width: parent.width;
height: Theme.touch-target;
if root.renaming != entry: HorizontalLayout {
spacing: Theme.gap-sm;
// The name is the apply button rather than a label
// beside one. Applying is what this list is for, and a
// row whose largest target does nothing is a row that
// gets pressed by accident and then distrusted.
Button {
text: entry;
horizontal-stretch: 1;
clicked => { root.apply(entry); }
}
Button {
text: "Rename";
clicked => { root.renaming = entry; }
}
Button {
text: "Delete";
clicked => { root.remove(entry); }
}
}
// Renaming in place rather than in a second sheet: a
// dialogue over a dialogue is where a user loses track of
// which one Escape closes.
if root.renaming == entry: HorizontalLayout {
spacing: Theme.gap-sm;
rename-field := Field {
text: entry;
horizontal-stretch: 1;
accepted(text) => {
root.rename(entry, text);
root.renaming = "";
}
}
Button {
text: "Cancel";
clicked => { root.renaming = ""; }
}
}
}
}
if root.names.length == 0: Text {
text: root.can-save
? "No presets yet. Name the edit above to make the first."
: "No presets yet. Open a photograph and save one from the develop panel.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
width: parent.width;
}
// Says what applying would do *before* it is done, the same way
// the grid's "Paste to 40" does — a count in the label is worth
// more than a confirmation asking the same question afterwards.
if root.names.length > 0 && root.apply-count > 0: Caption {
text: root.apply-count == 1
? "Applies to 1 selected photograph"
: "Applies to " + root.apply-count + " selected photographs";
}
Rectangle { height: 1px; background: Theme.rule; }
Button {
text: "Done";
clicked => { root.dismiss(); }
}
}
}
}
+1
View File
@@ -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