Merge master into partial-preset-scope
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/ui/app.slint
This commit is contained in:
2026-08-29 22:02:03 +02:00
31 changed files with 2855 additions and 92 deletions
+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
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));
}
}
+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
+1
View File
@@ -1,3 +1,4 @@
//! TRACES: R3
//! The develop pipeline — operations, descriptors, and shader composition.
//!
//! # What this crate is
+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
+1 -1
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
+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
+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