Add the develop pipeline: demosaic and seven raw adjustments

Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:37:58 +02:00
co-authored by Claude Opus 5
parent cc1c5c892d
commit 78e3e6b846
29 changed files with 5865 additions and 68 deletions
+215
View File
@@ -0,0 +1,215 @@
//! Parameter descriptors — operations described as data (ARCH §3.3).
//!
//! The core never builds a control. It publishes what its parameters *are*,
//! and `dr-ui` maps each `ParamKind` to a widget appropriate to the current
//! input modality (ARCH §4.3). Adding an operation therefore needs no UI
//! change (FR-DEV-3c).
//!
//! Labels are keys, not strings: resolving them needs a localiser, and
//! `core/` must not depend on one (NFR-A11Y-1).
use std::fmt;
/// Identifies a parameter within an operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ParamId(pub &'static str);
impl fmt::Display for ParamId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
/// Identifies an operation kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OpId(pub &'static str);
impl fmt::Display for OpId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
/// A localisation key. The UI resolves it; the core never sees the string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalizedKey(pub &'static str);
/// What a slider's travel means.
///
/// Photographic controls are rarely linear in their underlying quantity:
/// exposure is linear in stops but exponential in light, and a temperature
/// slider that is linear in kelvin feels wrong at both ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scale {
Linear,
/// Even *perceptual* steps across the range, for controls whose effect
/// concentrates near one end.
Perceptual,
}
/// The unit a value carries, for display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unit {
None,
/// Exposure value — photographers think in stops, not multipliers.
Stops,
Kelvin,
Percent,
}
/// The shape of a parameter's value.
#[derive(Debug, Clone, PartialEq)]
pub enum ParamKind {
Scalar {
min: f32,
max: f32,
scale: Scale,
unit: Unit,
precision: u8,
},
Bool,
}
/// One parameter of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct ParamDescriptor {
pub id: ParamId,
pub label: LocalizedKey,
pub kind: ParamKind,
pub default: f32,
}
impl ParamDescriptor {
/// A scalar in stops — the exposure-like controls.
pub const fn stops(id: &'static str, label: &'static str, min: f32, max: f32) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min,
max,
scale: Scale::Linear,
unit: Unit::Stops,
precision: 2,
},
default: 0.0,
}
}
/// A symmetric −100…+100 control, the familiar shape for tone and colour
/// adjustments. Neutral at zero, so a double-tap reset is meaningful.
pub const fn amount(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min: -100.0,
max: 100.0,
scale: Scale::Linear,
unit: Unit::None,
precision: 0,
},
default: 0.0,
}
}
/// A general scalar with an explicit range and default.
//
// Eight arguments, and a builder would be the usual answer — but this has
// to be `const` so descriptors can be `static`, and `const` functions
// cannot use a builder's method chain. The two common shapes have their
// own constructors above; this is the escape hatch for the rest.
#[allow(clippy::too_many_arguments)]
pub const fn scalar(
id: &'static str,
label: &'static str,
min: f32,
max: f32,
default: f32,
unit: Unit,
scale: Scale,
precision: u8,
) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min,
max,
scale,
unit,
precision,
},
default,
}
}
/// Clamp a value into this parameter's declared range.
///
/// Applied before the value reaches a shader: a slider dragged past its
/// bounds, or a sidecar written by a newer version with a wider range,
/// must not produce out-of-range uniforms.
pub fn clamp(&self, value: f32) -> f32 {
match self.kind {
ParamKind::Scalar { min, max, .. } => {
if value.is_finite() {
value.clamp(min, max)
} else {
// A NaN from a corrupt sidecar would otherwise poison the
// uniform block and blank the image.
self.default
}
}
ParamKind::Bool => {
if value != 0.0 {
1.0
} else {
0.0
}
}
}
}
}
/// The static description of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct OpDescriptor {
pub id: OpId,
pub label: LocalizedKey,
pub params: &'static [ParamDescriptor],
}
impl OpDescriptor {
pub fn param(&self, id: ParamId) -> Option<&ParamDescriptor> {
self.params.iter().find(|p| p.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
const P: ParamDescriptor = ParamDescriptor::amount("test", "test.label");
#[test]
fn values_clamp_into_range() {
assert_eq!(P.clamp(150.0), 100.0);
assert_eq!(P.clamp(-150.0), -100.0);
assert_eq!(P.clamp(42.0), 42.0);
}
#[test]
fn a_nan_falls_back_to_the_default_rather_than_poisoning_the_uniform() {
// A corrupt sidecar must not blank the image: one NaN in a uniform
// block propagates through every pixel.
assert_eq!(P.clamp(f32::NAN), P.default);
assert_eq!(P.clamp(f32::INFINITY), P.default);
}
#[test]
fn amount_controls_are_neutral_at_zero() {
// Double-tap-to-reset and "is this op doing anything" both depend on
// neutral being zero.
assert_eq!(P.default, 0.0);
}
}