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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! 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
|
||||
//! moment on Android (ARCH §6.10), and recovery is only tractable because
|
||||
//! everything needed to re-render lives here rather than in GPU memory.
|
||||
//!
|
||||
//! Order is data, not code: operations run in the sequence this holds them,
|
||||
//! so reordering the pipeline needs no code change.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
|
||||
use crate::operation::{compose, ComposedShader, Operation};
|
||||
use crate::ops;
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// What one operation offers, as plain data.
|
||||
///
|
||||
/// Deliberately owned rather than borrowed, and free of any trait objects:
|
||||
/// the UI receives a snapshot it can hold across a frame without borrowing
|
||||
/// the graph, and nothing in it hints at how the operation is implemented.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OpCapability {
|
||||
pub id: OpId,
|
||||
/// A key for the UI's own catalogue. Never a display string — resolving
|
||||
/// it needs a localiser, which `core/` must not depend on.
|
||||
pub label: LocalizedKey,
|
||||
/// Whether this operation currently alters the image. A UI may use it to
|
||||
/// mark a section as modified, or to offer a per-operation reset.
|
||||
pub active: bool,
|
||||
pub params: Vec<ParamCapability>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
||||
/// What one parameter offers.
|
||||
///
|
||||
/// [`Self::kind`] is what selects the control: the UI maps each `ParamKind`
|
||||
/// to a widget appropriate to the current input modality (ARCH §4.3), and
|
||||
/// never switches on the parameter's identity.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParamCapability {
|
||||
pub id: ParamId,
|
||||
pub label: LocalizedKey,
|
||||
pub kind: ParamKind,
|
||||
pub default: f32,
|
||||
/// The current setting, so the control opens where the edit actually is.
|
||||
pub value: f32,
|
||||
}
|
||||
|
||||
impl ParamCapability {
|
||||
/// Whether this parameter is away from its default.
|
||||
pub fn is_modified(&self) -> bool {
|
||||
self.value != self.default
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordered pipeline of operations.
|
||||
pub struct EditGraph {
|
||||
ops: Vec<Box<dyn Operation>>,
|
||||
}
|
||||
|
||||
impl EditGraph {
|
||||
/// The default develop chain, in pipeline order (ARCH §5.2).
|
||||
///
|
||||
/// Order is not arbitrary. White balance and exposure come first because
|
||||
/// they are corrections to how the scene was captured, and the tonal
|
||||
/// operations that follow should act on a correctly exposed image.
|
||||
/// Colour comes last, so vibrance responds to the tones the user has
|
||||
/// actually settled on rather than the ones they started with.
|
||||
pub fn default_chain() -> Self {
|
||||
Self {
|
||||
ops: vec![
|
||||
Box::new(ops::WhiteBalance::new()),
|
||||
Box::new(ops::Exposure::new()),
|
||||
Box::new(ops::HighlightsShadows::new()),
|
||||
Box::new(ops::BlacksWhites::new()),
|
||||
Box::new(ops::Brilliance::new()),
|
||||
Box::new(ops::Vibrance::new()),
|
||||
Box::new(ops::Saturation::new()),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptors for every operation, in order. Drives panel generation
|
||||
/// (FR-DEV-3a).
|
||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
||||
/// Everything a UI needs to build its controls.
|
||||
///
|
||||
/// **This is the only thing the UI should read.** It must not know that
|
||||
/// exposure exists, that saturation is implemented with a mix, or that
|
||||
/// any of this becomes a shader — it walks this list and instantiates a
|
||||
/// control per entry according to the [`ParamKind`]. A new operation
|
||||
/// therefore appears in the interface with no UI change at all
|
||||
/// (FR-DEV-3c), and an operation removed from the chain disappears from
|
||||
/// it just as automatically.
|
||||
///
|
||||
/// Current values are included so the UI has no separate initialisation
|
||||
/// step, and so reopening an edited image shows where the sliders
|
||||
/// actually are.
|
||||
pub fn capabilities(&self) -> Vec<OpCapability> {
|
||||
self.ops
|
||||
.iter()
|
||||
.map(|op| {
|
||||
let desc = op.descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: op.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: op.param(p.id),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set a parameter, clamping to the descriptor's declared range.
|
||||
///
|
||||
/// Clamping here rather than in each operation means an operation never
|
||||
/// has to defend against an out-of-range value, and a corrupt sidecar
|
||||
/// cannot reach a shader.
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
||||
// A sidecar naming an operation this build does not have. The
|
||||
// rest of the edit must still apply.
|
||||
log::warn!("unknown operation {op}; ignoring");
|
||||
return;
|
||||
};
|
||||
let clamped = match operation.descriptor().param(param) {
|
||||
Some(d) => d.clamp(value),
|
||||
None => {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
}
|
||||
};
|
||||
operation.set_param(param, clamped);
|
||||
}
|
||||
|
||||
/// Read a parameter back.
|
||||
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
||||
self.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id == op)
|
||||
.map(|o| o.param(param))
|
||||
}
|
||||
|
||||
/// Reset every parameter of every operation to its default.
|
||||
pub fn reset(&mut self) {
|
||||
for op in &mut self.ops {
|
||||
for p in op.descriptor().params {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any operation currently changes the image.
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
!self.ops.iter().any(|o| o.is_active())
|
||||
}
|
||||
|
||||
/// Generate the fused shader for the current state.
|
||||
pub fn compose(&self) -> ComposedShader {
|
||||
compose(&self.ops)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditGraph {
|
||||
fn default() -> Self {
|
||||
Self::default_chain()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ops::{exposure, white_balance};
|
||||
|
||||
#[test]
|
||||
fn a_fresh_graph_is_neutral() {
|
||||
// Opening an unedited image must produce the image, not an
|
||||
// interpretation of it.
|
||||
let g = EditGraph::default_chain();
|
||||
assert!(g.is_neutral());
|
||||
assert_eq!(
|
||||
g.compose().source.matches("---- ").count(),
|
||||
0,
|
||||
"a neutral graph must generate no operation blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_chain_exposes_every_operation() {
|
||||
let g = EditGraph::default_chain();
|
||||
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
||||
for expected in [
|
||||
"white_balance",
|
||||
"exposure",
|
||||
"highlights_shadows",
|
||||
"blacks_whites",
|
||||
"brilliance",
|
||||
"vibrance",
|
||||
"saturation",
|
||||
] {
|
||||
assert!(ids.contains(&expected), "{expected} missing from the chain");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_balance_and_exposure_precede_the_tonal_operations() {
|
||||
// Corrections to capture must come before interpretation of tone, or
|
||||
// the tonal controls act on a wrongly exposed image.
|
||||
let g = EditGraph::default_chain();
|
||||
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
||||
let pos = |id: &str| ids.iter().position(|x| *x == id).expect(id);
|
||||
assert!(pos("white_balance") < pos("highlights_shadows"));
|
||||
assert!(pos("exposure") < pos("highlights_shadows"));
|
||||
assert!(pos("highlights_shadows") < pos("vibrance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_a_parameter_activates_its_operation() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
||||
assert!(!g.is_neutral());
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
|
||||
assert!(g.compose().source.contains("---- exposure ----"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_clamped_to_the_descriptor() {
|
||||
// The guarantee that lets each operation skip range checks.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_operation_is_ignored_rather_than_panicking() {
|
||||
// A sidecar from a newer version names operations this build lacks.
|
||||
// The rest of the edit must still load.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(OpId("time_machine"), ParamId("year"), 1994.0);
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_parameter_is_ignored() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, ParamId("nonexistent"), 3.0);
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_every_operation_to_neutral() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
||||
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 50.0);
|
||||
assert!(!g.is_neutral());
|
||||
|
||||
g.reset();
|
||||
assert!(g.is_neutral(), "reset must clear every operation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_operations_reach_the_shader() {
|
||||
// The composition property, end to end: two adjustments out of seven
|
||||
// available must generate a shader doing exactly two things.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||
|
||||
let shader = g.compose();
|
||||
assert_eq!(shader.source.matches("---- ").count(), 2);
|
||||
assert!(shader.source.contains("---- exposure ----"));
|
||||
assert!(shader.source.contains("---- white_balance ----"));
|
||||
assert!(!shader.source.contains("---- saturation ----"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_slider_does_not_change_the_shader_structure() {
|
||||
// What makes the pipeline cache worth having: dragging a slider must
|
||||
// reuse the compiled pipeline and upload uniforms only.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
let first = g.compose();
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
||||
let second = g.compose();
|
||||
|
||||
assert_eq!(first.structure_hash, second.structure_hash);
|
||||
assert_eq!(first.source, second.source);
|
||||
assert_ne!(first.uniforms, second.uniforms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_describe_every_operation_and_parameter() {
|
||||
// The UI builds its whole panel from this. Anything missing here is
|
||||
// something the UI would have to hardcode.
|
||||
let g = EditGraph::default_chain();
|
||||
let caps = g.capabilities();
|
||||
assert_eq!(caps.len(), g.descriptors().len());
|
||||
|
||||
for cap in &caps {
|
||||
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
||||
for p in &cap.params {
|
||||
// A control cannot be built without a range.
|
||||
match &p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
assert!(min < max, "{}.{} has an empty range", cap.id, p.id);
|
||||
assert!(
|
||||
(*min..=*max).contains(&p.default),
|
||||
"{}.{} default is outside its range",
|
||||
cap.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
ParamKind::Bool => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_report_current_values_not_just_defaults() {
|
||||
// So reopening an edited image shows the sliders where the edit left
|
||||
// them, with no separate initialisation path in the UI.
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
|
||||
|
||||
let cap = g
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == exposure::ID)
|
||||
.expect("exposure is in the chain");
|
||||
let p = &cap.params[0];
|
||||
assert_eq!(p.value, 1.25);
|
||||
assert_eq!(p.default, 0.0);
|
||||
assert!(p.is_modified());
|
||||
assert!(cap.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_graph_reports_nothing_modified() {
|
||||
for cap in EditGraph::default_chain().capabilities() {
|
||||
assert!(!cap.active, "{} should start inactive", cap.id);
|
||||
for p in &cap.params {
|
||||
assert!(
|
||||
!p.is_modified(),
|
||||
"{}.{} should start at default",
|
||||
cap.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_survive_a_round_trip_through_set_param() {
|
||||
// The UI reads a capability, writes the value back, and must get the
|
||||
// same thing out — no hidden scaling between the two.
|
||||
let mut g = EditGraph::default_chain();
|
||||
for cap in g.capabilities() {
|
||||
for p in &cap.params {
|
||||
if let ParamKind::Scalar { max, .. } = p.kind {
|
||||
let target = max * 0.5;
|
||||
g.set_param(cap.id, p.id, target);
|
||||
assert_eq!(g.param(cap.id, p.id), Some(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_an_operation_needs_no_ui_change() {
|
||||
// FR-DEV-3c, asserted structurally: everything a control needs is
|
||||
// reachable from the capability list, so a new operation appears
|
||||
// without the UI naming it. If this test needs editing to add an
|
||||
// operation, the abstraction has leaked.
|
||||
let g = EditGraph::default_chain();
|
||||
let rendered: Vec<String> = g
|
||||
.capabilities()
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.params.iter().map(move |p| match p.kind {
|
||||
ParamKind::Scalar {
|
||||
min,
|
||||
max,
|
||||
precision,
|
||||
..
|
||||
} => format!(
|
||||
"{}/{}: slider {min}..{max} @{precision} = {}",
|
||||
c.label.0, p.label.0, p.value
|
||||
),
|
||||
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(rendered.len(), 10, "seven operations, ten parameters");
|
||||
assert!(rendered.iter().all(|r| !r.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_another_operation_does_change_the_structure() {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
let before = g.compose().structure_hash;
|
||||
|
||||
g.set_param(
|
||||
crate::ops::colour::SATURATION_ID,
|
||||
crate::ops::colour::SATURATION,
|
||||
30.0,
|
||||
);
|
||||
assert_ne!(before, g.compose().structure_hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The develop pipeline — operations, descriptors, and shader composition.
|
||||
//!
|
||||
//! # What this crate is
|
||||
//!
|
||||
//! An operation here is three things: a **descriptor** saying what its
|
||||
//! parameters are, a **WGSL fragment** saying what it does to a colour, and
|
||||
//! the **state** of its current settings. It is not a shader, not a pipeline,
|
||||
//! and not a control — those belong to `dr-gpu` and `dr-ui` respectively.
|
||||
//!
|
||||
//! That split is what lets this crate have no GPU dependency at all: the
|
||||
//! generated WGSL is a string, and everything about it can be tested without
|
||||
//! a device (ARCH §6.5a).
|
||||
//!
|
||||
//! # Composable shaders
|
||||
//!
|
||||
//! The interesting property. Operations are separate in Rust but *fused* on
|
||||
//! the GPU: [`operation::compose`] concatenates the enabled operations'
|
||||
//! fragments into one compute shader, so an edit with three active
|
||||
//! adjustments runs as one dispatch with one texture read and one write.
|
||||
//!
|
||||
//! An operation at neutral settings contributes nothing — no code, no
|
||||
//! uniform, no branch. The shader for a given op-set compiles once and is
|
||||
//! cached by [`operation::ComposedShader::structure_hash`], which covers the
|
||||
//! operations and their order but not their values; moving a slider uploads
|
||||
//! uniforms and reuses the pipeline.
|
||||
//!
|
||||
//! # Where this sits
|
||||
//!
|
||||
//! Input is the demosaiced texture from `dr-gpu`: linear, scene-referred,
|
||||
//! camera colour space. Working in linear light is what makes exposure a
|
||||
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod descriptor;
|
||||
pub mod graph;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
|
||||
pub use descriptor::{
|
||||
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
||||
};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use operation::{compose, Affects, ComposedShader, Helper, Operation, Uniform};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every operation in the default chain, activated.
|
||||
fn fully_active() -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for p in desc.params {
|
||||
// A value away from the default, within range.
|
||||
let v = match p.kind {
|
||||
ParamKind::Scalar { max, .. } => max * 0.5,
|
||||
ParamKind::Bool => 1.0,
|
||||
};
|
||||
g.set_param(desc.id, p.id, v);
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_operation_can_be_activated_together() {
|
||||
let g = fully_active();
|
||||
assert!(!g.is_neutral());
|
||||
let shader = g.compose();
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
7,
|
||||
"all seven operations should appear"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_chain_generates_a_well_formed_uniform_block() {
|
||||
let shader = fully_active().compose();
|
||||
assert_eq!(
|
||||
shader.uniforms.len() % 4,
|
||||
0,
|
||||
"the uniform block must be 16-byte aligned"
|
||||
);
|
||||
assert!(shader.uniforms.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_operation_declares_a_duplicate_id() {
|
||||
// Two operations sharing an id would collide in the generated
|
||||
// uniform struct and produce a shader that does not compile.
|
||||
let g = EditGraph::default_chain();
|
||||
let mut ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(before, ids.len(), "operation ids must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_operation_declares_a_duplicate_parameter() {
|
||||
for desc in EditGraph::default_chain().descriptors() {
|
||||
let mut ids: Vec<&str> = desc.params.iter().map(|p| p.id.0).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(before, ids.len(), "{} has a duplicate parameter", desc.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_declared_range() {
|
||||
// A default outside its own range would mean a fresh image opens
|
||||
// with a value the UI cannot represent.
|
||||
for desc in EditGraph::default_chain().descriptors() {
|
||||
for p in desc.params {
|
||||
assert_eq!(
|
||||
p.clamp(p.default),
|
||||
p.default,
|
||||
"{}.{} default {} is outside its range",
|
||||
desc.id,
|
||||
p.id,
|
||||
p.default
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_leave_every_operation_inactive() {
|
||||
// The invariant behind "opening an image shows the image": every
|
||||
// operation must read its own default as neutral.
|
||||
let g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for p in desc.params {
|
||||
assert_eq!(
|
||||
g.param(desc.id, p.id),
|
||||
Some(p.default),
|
||||
"{}.{} does not start at its default",
|
||||
desc.id,
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_uniform_names_are_valid_wgsl_identifiers() {
|
||||
let shader = fully_active().compose();
|
||||
for line in shader.source.lines() {
|
||||
let trimmed = line.trim();
|
||||
let Some((name, _)) = trimmed.split_once(": f32,") else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& !name.starts_with(|c: char| c.is_ascii_digit()),
|
||||
"{name} is not a valid WGSL identifier"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_chain_declares_each_helper_once() {
|
||||
// Six of the seven operations want `luminance`. A duplicate function
|
||||
// definition fails to compile, so this is the property that keeps
|
||||
// helper sharing safe as operations are added.
|
||||
let source = fully_active().compose().source;
|
||||
for helper in ["luminance", "tone_position", "colour_saturation"] {
|
||||
let count = source.matches(&format!("fn {helper}(")).count();
|
||||
assert!(count <= 1, "{helper} declared {count} times");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
//! The `Operation` trait and WGSL fragment composition.
|
||||
//!
|
||||
//! # Composable shaders
|
||||
//!
|
||||
//! Each operation contributes a **WGSL fragment**: a function taking a linear
|
||||
//! RGB colour and returning one. The pipeline concatenates the fragments of
|
||||
//! the enabled operations into a single generated shader, run as one compute
|
||||
//! dispatch. This buys the performance of a fused pass without the coupling:
|
||||
//!
|
||||
//! - **One texture read and one write per frame**, not one pair per operation.
|
||||
//! At 24 MP the difference is the whole frame budget.
|
||||
//! - **Operations stay independent.** Adding one is a new file implementing
|
||||
//! this trait; no central shader to edit and no ordering table to update.
|
||||
//! - **A disabled operation vanishes from the source** rather than costing a
|
||||
//! branch, so an image with two active adjustments compiles to a shader
|
||||
//! doing exactly two things.
|
||||
//! - **Each distinct op-set compiles once** and is cached by the hash of its
|
||||
//! generated source (ARCH §5.6).
|
||||
//!
|
||||
//! The cost is that WGSL compile errors point at generated source, so the
|
||||
//! generator emits readable, commented output — see [`compose`].
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
|
||||
/// What an operation's parameters affect, for cache invalidation scoping.
|
||||
///
|
||||
/// Adjusting exposure must not invalidate the demosaic result; this is what
|
||||
/// lets the tile cache reuse everything up to the first changed stage
|
||||
/// (ARCH §5.3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Affects {
|
||||
/// Per-pixel colour only. Everything in this milestone.
|
||||
Colour,
|
||||
/// Pixel positions — crop, rotate. Invalidates geometry-dependent caches.
|
||||
Geometry,
|
||||
}
|
||||
|
||||
/// A single scalar a fragment reads from the generated uniform block.
|
||||
///
|
||||
/// Operations declare uniforms by name and value; the composer assigns them
|
||||
/// slots and emits the struct. An operation never knows its own offset, which
|
||||
/// is what allows fragments to be reordered or omitted freely.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Uniform {
|
||||
/// Field name as it appears in WGSL. Prefixed with the op id by the
|
||||
/// composer, so two operations may both declare `amount`.
|
||||
pub name: &'static str,
|
||||
pub value: f32,
|
||||
}
|
||||
|
||||
/// A develop operation.
|
||||
///
|
||||
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
|
||||
/// order is data rather than code (ARCH §3.4).
|
||||
pub trait Operation: Send + Sync {
|
||||
/// Static description, driving UI generation (FR-DEV-3a).
|
||||
fn descriptor(&self) -> &'static OpDescriptor;
|
||||
|
||||
/// Set a parameter. Values arrive already clamped to the descriptor.
|
||||
fn set_param(&mut self, id: ParamId, value: f32);
|
||||
|
||||
/// Read a parameter back, for the sidecar and for the UI's initial state.
|
||||
fn param(&self, id: ParamId) -> f32;
|
||||
|
||||
/// Whether this operation currently changes the image.
|
||||
///
|
||||
/// An operation at its neutral settings returns `false` and is omitted
|
||||
/// from the generated shader entirely. This is what makes the common case
|
||||
/// — a handful of active adjustments out of many available — cost only
|
||||
/// what is actually used.
|
||||
fn is_active(&self) -> bool;
|
||||
|
||||
/// The WGSL body of this operation's transform.
|
||||
///
|
||||
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
|
||||
/// result in `c`. Uniforms are addressed by the names declared in
|
||||
/// [`Self::uniforms`], accessed as `u.<prefixed_name>`; the composer
|
||||
/// rewrites them, so a fragment writes the bare name.
|
||||
///
|
||||
/// The fragment runs inside its own block, so locals need no unique
|
||||
/// names.
|
||||
fn wgsl_body(&self) -> String;
|
||||
|
||||
/// Uniform values this operation's fragment reads.
|
||||
fn uniforms(&self) -> Vec<Uniform>;
|
||||
|
||||
/// What this operation's parameters affect.
|
||||
fn affects(&self) -> Affects {
|
||||
Affects::Colour
|
||||
}
|
||||
|
||||
/// Any WGSL helper functions the fragment calls.
|
||||
///
|
||||
/// Emitted once per *distinct* function name even if several operations
|
||||
/// request it, so shared helpers (luminance, soft clipping) are declared
|
||||
/// exactly once.
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// A named WGSL helper function, deduplicated across operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Helper {
|
||||
pub name: &'static str,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// The result of composing a set of operations into one shader.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ComposedShader {
|
||||
/// Complete, compilable WGSL.
|
||||
pub source: String,
|
||||
/// Uniform values in the order the generated struct declares them.
|
||||
pub uniforms: Vec<f32>,
|
||||
/// Identifies this shader's *structure* — the op-set and their order,
|
||||
/// not their values. Two edits differing only in slider positions share
|
||||
/// a compiled pipeline and differ only in the uniform upload.
|
||||
pub structure_hash: u64,
|
||||
}
|
||||
|
||||
/// Fields the generated uniform struct always carries, before op uniforms.
|
||||
///
|
||||
/// WGSL requires a uniform struct to be non-empty and 16-byte aligned; these
|
||||
/// are needed by every generated shader in any case.
|
||||
const BASE_UNIFORM_FIELDS: usize = 16;
|
||||
|
||||
/// Compose enabled operations into a single compute shader.
|
||||
///
|
||||
/// Inactive operations are skipped entirely — they contribute no code, no
|
||||
/// uniforms, and nothing to the structure hash.
|
||||
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
|
||||
let active: Vec<&dyn Operation> = ops
|
||||
.iter()
|
||||
.map(|o| o.as_ref())
|
||||
.filter(|o| o.is_active())
|
||||
.collect();
|
||||
|
||||
let mut uniform_fields = String::new();
|
||||
let mut uniform_values: Vec<f32> = Vec::new();
|
||||
let mut body = String::new();
|
||||
let mut helpers: Vec<Helper> = Vec::new();
|
||||
|
||||
// The base block: the camera matrix and output settings every generated
|
||||
// shader needs. Declared first so their slots are fixed regardless of
|
||||
// which operations are present.
|
||||
uniform_fields.push_str(
|
||||
" // Camera RGB -> linear sRGB. Rows padded to vec4 for std140\n\
|
||||
\x20 // alignment; a bare mat3x3 is laid out as three vec4 anyway.\n\
|
||||
\x20 cam_to_srgb_0: vec4<f32>,\n\
|
||||
\x20 cam_to_srgb_1: vec4<f32>,\n\
|
||||
\x20 cam_to_srgb_2: vec4<f32>,\n\
|
||||
\x20 // As-shot white balance, the neutral point for the WB control.\n\
|
||||
\x20 as_shot_wb: vec4<f32>,\n",
|
||||
);
|
||||
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
|
||||
|
||||
for op in &active {
|
||||
let id = op.descriptor().id.0;
|
||||
let prefix = sanitise(id);
|
||||
|
||||
// Each op's uniforms are prefixed, so two operations may both declare
|
||||
// a field called `amount` without colliding.
|
||||
let op_uniforms = op.uniforms();
|
||||
if !op_uniforms.is_empty() {
|
||||
let _ = writeln!(uniform_fields, " // {id}");
|
||||
}
|
||||
for u in &op_uniforms {
|
||||
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name);
|
||||
uniform_values.push(u.value);
|
||||
}
|
||||
|
||||
for h in op.helpers() {
|
||||
if !helpers.iter().any(|existing| existing.name == h.name) {
|
||||
helpers.push(*h);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite bare uniform names to their prefixed struct fields, so a
|
||||
// fragment is written without knowing about any other operation.
|
||||
let mut fragment = op.wgsl_body();
|
||||
for u in &op_uniforms {
|
||||
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
|
||||
}
|
||||
|
||||
let _ = writeln!(body, "\n // ---- {id} ----");
|
||||
let _ = writeln!(body, " {{");
|
||||
for line in fragment.lines() {
|
||||
let _ = writeln!(body, " {line}");
|
||||
}
|
||||
let _ = writeln!(body, " }}");
|
||||
}
|
||||
|
||||
// Pad the uniform block to a 16-byte boundary. A struct whose size is not
|
||||
// a multiple of 16 is rejected by the WGSL uniform address space rules.
|
||||
let pad = (4 - (uniform_values.len() % 4)) % 4;
|
||||
for i in 0..pad {
|
||||
let _ = writeln!(uniform_fields, " _pad{i}: f32,");
|
||||
uniform_values.push(0.0);
|
||||
}
|
||||
|
||||
let mut helper_src = String::new();
|
||||
for h in &helpers {
|
||||
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
||||
}
|
||||
|
||||
let source = format!(
|
||||
"// GENERATED — do not edit.
|
||||
//
|
||||
// Composed by dr-pipeline from {} active operation(s). Each block below is
|
||||
// one operation's fragment, run in graph order over a linear scene-referred
|
||||
// colour. Operations at neutral settings are omitted rather than branched
|
||||
// over, so this shader does exactly the work the current edit requires.
|
||||
|
||||
struct Params {{
|
||||
{uniform_fields}}}
|
||||
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<uniform> u: Params;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
{helper_src}// Linear sRGB to the display transfer function.
|
||||
//
|
||||
// The one place quantisation happens: everything above runs in linear f16,
|
||||
// and this is the final encode (ARCH §5.2).
|
||||
fn encode_srgb(c: vec3<f32>) -> vec3<f32> {{
|
||||
let lo = c * 12.92;
|
||||
let hi = 1.055 * pow(max(c, vec3<f32>(0.0031308)), vec3<f32>(1.0 / 2.4)) - 0.055;
|
||||
return select(hi, lo, c <= vec3<f32>(0.0031308));
|
||||
}}
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
let dims = textureDimensions(output);
|
||||
if (gid.x >= dims.x || gid.y >= dims.y) {{
|
||||
return;
|
||||
}}
|
||||
|
||||
// Source is the demosaiced image: linear, scene-referred, camera space.
|
||||
let src_dims = textureDimensions(source);
|
||||
let coord = vec2<i32>(
|
||||
i32(gid.x * src_dims.x / dims.x),
|
||||
i32(gid.y * src_dims.y / dims.y),
|
||||
);
|
||||
var c = textureLoad(source, coord, 0).rgb;
|
||||
|
||||
// As-shot white balance. Applied unconditionally, before any operation,
|
||||
// because it is part of *interpreting* the sensor rather than an edit: a
|
||||
// Bayer sensor's green photosites collect far more signal than its red
|
||||
// and blue, so raw camera-space values are strongly green and no amount
|
||||
// of later correction recovers a neutral image from them. The white
|
||||
// balance operation, when active, applies its own offset on top of this.
|
||||
c = c * u.as_shot_wb.rgb;
|
||||
{body}
|
||||
// Camera space -> linear sRGB. Applied after the adjustments so white
|
||||
// balance and exposure act on sensor-native values, which is where they
|
||||
// are physically meaningful.
|
||||
c = vec3<f32>(
|
||||
dot(u.cam_to_srgb_0.rgb, c),
|
||||
dot(u.cam_to_srgb_1.rgb, c),
|
||||
dot(u.cam_to_srgb_2.rgb, c),
|
||||
);
|
||||
|
||||
// Clip to the display gamut and encode.
|
||||
c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(encode_srgb(c), 1.0));
|
||||
}}
|
||||
",
|
||||
active.len()
|
||||
);
|
||||
|
||||
let structure_hash = hash_structure(&active);
|
||||
|
||||
ComposedShader {
|
||||
source,
|
||||
uniforms: uniform_values,
|
||||
structure_hash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash the op-set and order — the structure, not the values.
|
||||
///
|
||||
/// Two edits with the same operations at different slider positions produce
|
||||
/// the same hash and reuse one compiled pipeline (ARCH §6.13: the hash is
|
||||
/// over integer state only, so it is exactly deterministic).
|
||||
fn hash_structure(active: &[&dyn Operation]) -> u64 {
|
||||
// FNV-1a: no dependency, stable across runs and platforms, which the
|
||||
// shader cache key requires.
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for op in active {
|
||||
for byte in op.descriptor().id.0.as_bytes() {
|
||||
h ^= u64::from(*byte);
|
||||
h = h.wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
// A separator, so ["ab", "c"] and ["a", "bc"] differ.
|
||||
h ^= 0xff;
|
||||
h = h.wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Replace whole-word occurrences of `name` with `replacement`.
|
||||
///
|
||||
/// Whole-word matching matters: an operation with uniforms `amount` and
|
||||
/// `amount_hi` must not have the first rewrite corrupt the second.
|
||||
fn rewrite_uniform(src: &str, name: &str, replacement: &str) -> String {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let bytes = src.as_bytes();
|
||||
let mut i = 0;
|
||||
|
||||
while i < src.len() {
|
||||
if src[i..].starts_with(name) {
|
||||
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
|
||||
let after = i + name.len();
|
||||
let after_ok = after >= src.len() || !is_ident_byte(bytes[after]);
|
||||
if before_ok && after_ok {
|
||||
out.push_str(replacement);
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Push one full character, not one byte, so non-ASCII in a comment
|
||||
// does not split a UTF-8 sequence.
|
||||
let ch = src[i..].chars().next().expect("in bounds");
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_ident_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'_'
|
||||
}
|
||||
|
||||
/// Make an operation id safe to embed in a WGSL identifier.
|
||||
fn sanitise(id: &str) -> String {
|
||||
id.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
|
||||
|
||||
static DESC_A: OpDescriptor = OpDescriptor {
|
||||
id: OpId("op_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
||||
};
|
||||
static DESC_B: OpDescriptor = OpDescriptor {
|
||||
id: OpId("op_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
||||
};
|
||||
|
||||
struct Fake {
|
||||
desc: &'static OpDescriptor,
|
||||
amount: f32,
|
||||
helper: Option<Helper>,
|
||||
}
|
||||
|
||||
impl Operation for Fake {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
self.desc
|
||||
}
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
}
|
||||
fn param(&self, _id: ParamId) -> f32 {
|
||||
self.amount
|
||||
}
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
fn wgsl_body(&self) -> String {
|
||||
"c = c * amount;".into()
|
||||
}
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "amount",
|
||||
value: self.amount,
|
||||
}]
|
||||
}
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
match self.helper {
|
||||
Some(_) => SHARED,
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SHARED: &[Helper] = &[Helper {
|
||||
name: "luma",
|
||||
source: "fn luma(c: vec3<f32>) -> f32 { return c.g; }",
|
||||
}];
|
||||
|
||||
fn fake(desc: &'static OpDescriptor, amount: f32, helper: bool) -> Box<dyn Operation> {
|
||||
Box::new(Fake {
|
||||
desc,
|
||||
amount,
|
||||
helper: helper.then_some(SHARED[0]),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inactive_operation_contributes_nothing() {
|
||||
// The point of composing rather than branching: an op at neutral
|
||||
// must not appear in the source at all.
|
||||
let ops = vec![fake(&DESC_A, 0.0, false)];
|
||||
let shader = compose(&ops);
|
||||
assert!(
|
||||
!shader.source.contains("op_a"),
|
||||
"a neutral operation must not reach the generated shader"
|
||||
);
|
||||
assert_eq!(
|
||||
shader.uniforms.len(),
|
||||
BASE_UNIFORM_FIELDS,
|
||||
"it must contribute no uniforms either"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_operation_appears_once() {
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let shader = compose(&ops);
|
||||
assert!(shader.source.contains("---- op_a ----"));
|
||||
assert!(shader.source.contains("u.op_a_amount"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_are_prefixed_so_operations_cannot_collide() {
|
||||
// Both fakes declare a uniform called `amount`. Without prefixing,
|
||||
// the generated struct would have a duplicate field and fail to
|
||||
// compile — the failure mode that makes naive concatenation fragile.
|
||||
let ops = vec![fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)];
|
||||
let shader = compose(&ops);
|
||||
assert!(shader.source.contains("op_a_amount: f32"));
|
||||
assert!(shader.source.contains("op_b_amount: f32"));
|
||||
assert!(shader.source.contains("c = c * u.op_a_amount;"));
|
||||
assert!(shader.source.contains("c = c * u.op_b_amount;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_values_follow_declaration_order() {
|
||||
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS], 1.5);
|
||||
assert_eq!(shader.uniforms[BASE_UNIFORM_FIELDS + 1], 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_helper_is_emitted_once() {
|
||||
// Two operations wanting the same helper must not produce a
|
||||
// duplicate function definition.
|
||||
let ops = vec![fake(&DESC_A, 1.0, true), fake(&DESC_B, 1.0, true)];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(
|
||||
shader.source.matches("fn luma(").count(),
|
||||
1,
|
||||
"a helper requested twice must be declared once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_block_is_16_byte_aligned() {
|
||||
// WGSL rejects a uniform struct whose size is not a multiple of 16.
|
||||
for n in 0..6 {
|
||||
let ops: Vec<Box<dyn Operation>> = (0..n)
|
||||
.map(|i| fake(if i % 2 == 0 { &DESC_A } else { &DESC_B }, 1.0, false))
|
||||
.collect();
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(
|
||||
shader.uniforms.len() % 4,
|
||||
0,
|
||||
"{n} operations produced {} floats, not a multiple of 4",
|
||||
shader.uniforms.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
||||
// The property the shader cache depends on: moving a slider must not
|
||||
// trigger a recompile, but enabling an operation must.
|
||||
let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash;
|
||||
let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash;
|
||||
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
|
||||
|
||||
let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_hash_is_order_sensitive() {
|
||||
// Operation order is data (ARCH §3.4); two orders are different
|
||||
// shaders and must not share a cache entry.
|
||||
let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]);
|
||||
assert_ne!(ab.structure_hash, ba.structure_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewriting_respects_word_boundaries() {
|
||||
// `amount` must not corrupt `amount_hi` — the bug a naive
|
||||
// string replace would introduce.
|
||||
let got = rewrite_uniform("x = amount + amount_hi;", "amount", "u.p_amount");
|
||||
assert_eq!(got, "x = u.p_amount + amount_hi;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewriting_leaves_substrings_alone() {
|
||||
let got = rewrite_uniform("total_amount = 1.0;", "amount", "u.a");
|
||||
assert_eq!(got, "total_amount = 1.0;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_shot_white_balance_is_applied_even_with_no_operations() {
|
||||
// The bug this catches, seen on a real CR2: a Bayer sensor's green
|
||||
// photosites collect roughly twice the signal of its red and blue,
|
||||
// so an image rendered without the as-shot multipliers comes out
|
||||
// violently green. It must not depend on the white balance operation
|
||||
// being active — that one carries only the user's offset.
|
||||
let shader = compose(&[]);
|
||||
assert!(
|
||||
shader.source.contains("u.as_shot_wb"),
|
||||
"a neutral edit must still apply as-shot white balance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_balance_is_applied_before_the_operations() {
|
||||
// Exposure and the tonal controls act on white-balanced values; if
|
||||
// the multiply came afterwards, every operation would be reasoning
|
||||
// about a green-cast image.
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let source = compose(&ops).source;
|
||||
let wb = source.find("u.as_shot_wb").expect("wb applied");
|
||||
let op = source.find("---- op_a ----").expect("op present");
|
||||
assert!(wb < op, "as-shot white balance must precede the operations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_camera_matrix_is_applied_after_the_operations() {
|
||||
// Adjustments are meaningful in sensor-native space, where highlight
|
||||
// headroom still exists; converting first would clip it away.
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let source = compose(&ops).source;
|
||||
let op = source.find("---- op_a ----").expect("op present");
|
||||
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
|
||||
assert!(op < matrix, "the camera matrix must come after operations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_source_carries_a_do_not_edit_banner() {
|
||||
// Someone will eventually find this in a debugger and try to fix it
|
||||
// in place.
|
||||
let shader = compose(&[fake(&DESC_A, 1.0, false)]);
|
||||
assert!(shader.source.starts_with("// GENERATED"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Colour operations: vibrance, saturation, and brilliance.
|
||||
//!
|
||||
//! # Vibrance versus saturation
|
||||
//!
|
||||
//! Saturation scales every colour's distance from grey equally. Vibrance
|
||||
//! scales it *more for muted colours than for already-saturated ones*, and
|
||||
//! protects skin tones. The difference matters: pushing saturation on a
|
||||
//! portrait turns faces orange long before the background improves, which is
|
||||
//! precisely the problem vibrance was invented to solve.
|
||||
//!
|
||||
//! # Brilliance
|
||||
//!
|
||||
//! Apple's control, and a genuinely different idea from either: it lifts
|
||||
//! shadows and pulls highlights *simultaneously*, applying the opposite
|
||||
//! correction at each end of the range while leaving mid-tones alone. The
|
||||
//! result reads as "more light in the scene" rather than "less contrast",
|
||||
//! because local relationships survive where a plain contrast reduction
|
||||
//! flattens them.
|
||||
//!
|
||||
//! It overlaps with highlights/shadows deliberately — one control doing both
|
||||
//! in a fixed relationship is easier to reach for than two controls needing
|
||||
//! to be balanced against each other.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
use crate::ops::tone::TONE_HELPERS;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saturation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const SATURATION_ID: OpId = OpId("saturation");
|
||||
pub const SATURATION: ParamId = ParamId("saturation");
|
||||
|
||||
static SAT_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: SATURATION_ID,
|
||||
label: LocalizedKey("op.saturation"),
|
||||
params: &[ParamDescriptor::amount("saturation", "param.saturation")],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Saturation {
|
||||
amount: f32,
|
||||
}
|
||||
|
||||
impl Saturation {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Saturation {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&SAT_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
SATURATION => self.amount = value,
|
||||
_ => log::warn!("saturation: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
SATURATION => self.amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
// Interpolate away from the luminance-preserving grey. A factor of 0 is
|
||||
// monochrome, 1 is unchanged, above 1 is more saturated.
|
||||
let luma = luminance(c);
|
||||
c = mix(vec3<f32>(luma), c, factor);
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
// -100 reaches exactly monochrome; +100 doubles the distance from
|
||||
// grey. The floor at zero matters: a negative factor would push a
|
||||
// colour past grey into its complement, inverting hues.
|
||||
vec![Uniform {
|
||||
name: "factor",
|
||||
value: (1.0 + self.amount / 100.0).max(0.0),
|
||||
}]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
TONE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vibrance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const VIBRANCE_ID: OpId = OpId("vibrance");
|
||||
pub const VIBRANCE: ParamId = ParamId("vibrance");
|
||||
|
||||
static VIB_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: VIBRANCE_ID,
|
||||
label: LocalizedKey("op.vibrance"),
|
||||
params: &[ParamDescriptor::amount("vibrance", "param.vibrance")],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Vibrance {
|
||||
amount: f32,
|
||||
}
|
||||
|
||||
impl Vibrance {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Vibrance {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&VIB_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
VIBRANCE => self.amount = value,
|
||||
_ => log::warn!("vibrance: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
VIBRANCE => self.amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
let sat = colour_saturation(c);
|
||||
|
||||
// The vibrance curve: full effect on grey, tapering to nothing on colours
|
||||
// that are already saturated. Squaring the falloff keeps the mid-range
|
||||
// responsive while still protecting the extremes.
|
||||
let falloff = (1.0 - sat) * (1.0 - sat);
|
||||
|
||||
// Skin protection. Skin sits in a narrow band of hue where red leads green
|
||||
// leads blue; pushing it is what makes vibrance look wrong on portraits.
|
||||
// Detected by channel ordering rather than a hue angle, which costs a
|
||||
// conversion and buys nothing here.
|
||||
let is_skin = f32(c.r > c.g && c.g > c.b);
|
||||
let skin_guard = 1.0 - is_skin * 0.5;
|
||||
|
||||
let strength = amount * falloff * skin_guard;
|
||||
c = mix(vec3<f32>(luma), c, 1.0 + strength);
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "amount",
|
||||
value: self.amount / 100.0,
|
||||
}]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
// Needs both luminance (from tone) and the saturation measure.
|
||||
// Duplicates across the two lists are deduplicated by the composer.
|
||||
COLOUR_AND_TONE
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper set vibrance needs: luminance plus the saturation measure.
|
||||
static COLOUR_AND_TONE: &[Helper] = crate::ops::helpers::COLOUR;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brilliance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const BRILLIANCE_ID: OpId = OpId("brilliance");
|
||||
pub const BRILLIANCE: ParamId = ParamId("brilliance");
|
||||
|
||||
static BRIL_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: BRILLIANCE_ID,
|
||||
label: LocalizedKey("op.brilliance"),
|
||||
params: &[ParamDescriptor::amount("brilliance", "param.brilliance")],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Brilliance {
|
||||
amount: f32,
|
||||
}
|
||||
|
||||
impl Brilliance {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Brilliance {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&BRIL_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
BRILLIANCE => self.amount = value,
|
||||
_ => log::warn!("brilliance: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
BRILLIANCE => self.amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Opposite corrections at the two ends: shadows up, highlights down, both
|
||||
// tapering to nothing at the mid-point. This is what separates brilliance
|
||||
// from a contrast control — mid-tones keep their local relationships, so
|
||||
// the image gains apparent light rather than losing structure.
|
||||
let lift = (1.0 - smoothstep(0.0, 0.5, pos)) * amount;
|
||||
let pull = smoothstep(0.5, 1.0, pos) * amount;
|
||||
|
||||
// A mild saturation compensation. Flattening the tonal range washes colour
|
||||
// out; without this, brilliance looks faded at useful settings.
|
||||
let gain = exp2(lift - pull);
|
||||
c = c * gain;
|
||||
|
||||
let luma_after = luminance(c);
|
||||
c = mix(vec3<f32>(luma_after), c, 1.0 + max(amount, 0.0) * 0.15);
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "amount",
|
||||
// Half a stop at each end at full travel — the two ends move
|
||||
// apart by a stop in total, which is a strong but not
|
||||
// destructive flattening.
|
||||
value: self.amount / 100.0 * 0.5,
|
||||
}]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
TONE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::operation::compose;
|
||||
|
||||
#[test]
|
||||
fn all_three_start_neutral() {
|
||||
assert!(!Saturation::new().is_active());
|
||||
assert!(!Vibrance::new().is_active());
|
||||
assert!(!Brilliance::new().is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_negative_saturation_reaches_monochrome() {
|
||||
// The property that makes -100 meaningful: it must land exactly on
|
||||
// grey, not merely near it.
|
||||
let mut s = Saturation::new();
|
||||
s.set_param(SATURATION, -100.0);
|
||||
assert_eq!(s.uniforms()[0].value, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_saturation_increases_the_factor() {
|
||||
let mut s = Saturation::new();
|
||||
s.set_param(SATURATION, 100.0);
|
||||
assert!((s.uniforms()[0].value - 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saturation_factor_never_goes_negative() {
|
||||
// A negative factor would invert hues — a colour past monochrome
|
||||
// becomes its complement, which is never wanted here.
|
||||
let mut s = Saturation::new();
|
||||
s.set_param(SATURATION, -200.0);
|
||||
assert!(s.uniforms()[0].value >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibrance_protects_skin_in_its_fragment() {
|
||||
// The distinguishing behaviour; if the guard is dropped, portraits
|
||||
// go orange and the control is indistinguishable from saturation.
|
||||
let mut v = Vibrance::new();
|
||||
v.set_param(VIBRANCE, 50.0);
|
||||
let body = v.wgsl_body();
|
||||
assert!(body.contains("skin_guard"), "skin protection must survive");
|
||||
assert!(
|
||||
body.contains("falloff"),
|
||||
"the roll-off is what makes it vibrance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibrance_and_saturation_are_distinct_operations() {
|
||||
// They must not share an id, or the composer would emit one and the
|
||||
// UI would show one control for two behaviours.
|
||||
assert_ne!(VIBRANCE_ID, SATURATION_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brilliance_moves_the_ends_in_opposite_directions() {
|
||||
let mut b = Brilliance::new();
|
||||
b.set_param(BRILLIANCE, 100.0);
|
||||
let body = b.wgsl_body();
|
||||
assert!(body.contains("lift"), "shadows must rise");
|
||||
assert!(body.contains("pull"), "highlights must fall");
|
||||
assert!(
|
||||
body.contains("lift - pull"),
|
||||
"the two must oppose, or this is just an exposure control"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brilliance_travel_is_bounded() {
|
||||
let mut b = Brilliance::new();
|
||||
b.set_param(BRILLIANCE, 100.0);
|
||||
let v = b.uniforms()[0].value;
|
||||
assert!((0.0..=0.6).contains(&v), "amount {v} is too aggressive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_are_shared_across_every_colour_operation() {
|
||||
// Vibrance declares its own helper list; if its luminance source
|
||||
// drifted from tone's, the composer would emit whichever came first
|
||||
// and the two operations would disagree about luminance.
|
||||
let ops: Vec<Box<dyn Operation>> = vec![
|
||||
Box::new({
|
||||
let mut o = Vibrance::new();
|
||||
o.set_param(VIBRANCE, 40.0);
|
||||
o
|
||||
}),
|
||||
Box::new({
|
||||
let mut o = Saturation::new();
|
||||
o.set_param(SATURATION, 20.0);
|
||||
o
|
||||
}),
|
||||
Box::new({
|
||||
let mut o = Brilliance::new();
|
||||
o.set_param(BRILLIANCE, 30.0);
|
||||
o
|
||||
}),
|
||||
];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(
|
||||
shader.source.matches("fn luminance(").count(),
|
||||
1,
|
||||
"luminance must be declared exactly once"
|
||||
);
|
||||
assert_eq!(shader.source.matches("fn colour_saturation(").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_and_colour_agree_on_luminance() {
|
||||
// Both sets reference the shared definition. If someone reintroduces
|
||||
// a local copy, the composer would emit whichever operation came
|
||||
// first and the two would compute luminance differently.
|
||||
let from_tone = TONE_HELPERS
|
||||
.iter()
|
||||
.find(|h| h.name == "luminance")
|
||||
.expect("tone declares luminance");
|
||||
let from_colour = COLOUR_AND_TONE
|
||||
.iter()
|
||||
.find(|h| h.name == "luminance")
|
||||
.expect("colour declares luminance");
|
||||
assert_eq!(from_tone.source, from_colour.source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Exposure — a linear gain, expressed in stops.
|
||||
//!
|
||||
//! The simplest operation in the pipeline and the one that most justifies
|
||||
//! working in linear light: a stop is a doubling, so exposure is a single
|
||||
//! multiply. Applied to gamma-encoded data it would be neither a doubling nor
|
||||
//! reversible, which is why this stage sits where it does (ARCH §5.2).
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("exposure");
|
||||
pub const EXPOSURE: ParamId = ParamId("exposure");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.exposure"),
|
||||
// ±5 stops. Wider than most edits need, but recovering a badly
|
||||
// underexposed frame is a real use and raw data often supports it.
|
||||
params: &[ParamDescriptor::stops(
|
||||
"exposure",
|
||||
"param.exposure",
|
||||
-5.0,
|
||||
5.0,
|
||||
)],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Exposure {
|
||||
stops: f32,
|
||||
}
|
||||
|
||||
impl Exposure {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The linear gain for the current setting.
|
||||
fn gain(&self) -> f32 {
|
||||
f32::exp2(self.stops)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Exposure {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
EXPOSURE => self.stops = value,
|
||||
_ => log::warn!("exposure: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
EXPOSURE => self.stops,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.stops != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"c = c * gain;".into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "gain",
|
||||
value: self.gain(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn neutral_does_nothing() {
|
||||
let e = Exposure::new();
|
||||
assert!(!e.is_active());
|
||||
assert_eq!(e.gain(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stop_is_a_doubling() {
|
||||
// The definition of a stop. If this is wrong, every exposure
|
||||
// adjustment is subtly off and no test of "looks right" would catch
|
||||
// it.
|
||||
let mut e = Exposure::new();
|
||||
e.set_param(EXPOSURE, 1.0);
|
||||
assert!((e.gain() - 2.0).abs() < 1e-6);
|
||||
|
||||
e.set_param(EXPOSURE, -1.0);
|
||||
assert!((e.gain() - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_compose_additively() {
|
||||
// +2 stops must equal +1 applied twice.
|
||||
let mut e = Exposure::new();
|
||||
e.set_param(EXPOSURE, 2.0);
|
||||
assert!((e.gain() - 4.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_range_covers_a_badly_exposed_frame() {
|
||||
let d = &DESCRIPTOR.params[0];
|
||||
assert_eq!(d.clamp(-9.0), -5.0);
|
||||
assert_eq!(d.clamp(9.0), 5.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! WGSL helper functions shared between operations.
|
||||
//!
|
||||
//! **Single source of truth.** Several operations need the same helpers, and
|
||||
//! each declares a `&'static [Helper]` naming the ones it uses. The composer
|
||||
//! deduplicates by *name*, so if two lists carried different source for the
|
||||
//! same name it would silently emit whichever came first — and two operations
|
||||
//! would compute, say, luminance differently depending on graph order. That
|
||||
//! is a genuinely hard bug to see, so the sources are defined exactly once
|
||||
//! here and referenced everywhere else.
|
||||
|
||||
use crate::operation::Helper;
|
||||
|
||||
/// Rec. 709 luminance.
|
||||
pub const LUMINANCE: Helper = Helper {
|
||||
name: "luminance",
|
||||
source: "\
|
||||
// Rec. 709 luminance, the weighting that matches sRGB primaries.
|
||||
//
|
||||
// Applied to camera-space values it is an approximation — the true weights
|
||||
// depend on the camera matrix — but using it here keeps the tonal operations
|
||||
// working on sensor-native data, where highlight headroom still exists.
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}",
|
||||
};
|
||||
|
||||
/// Linear luminance mapped to a perceptual 0..1 position.
|
||||
pub const TONE_POSITION: Helper = Helper {
|
||||
name: "tone_position",
|
||||
source: "\
|
||||
// Map linear luminance onto a perceptual 0..1 position.
|
||||
//
|
||||
// Tonal controls must feel evenly spaced to the eye, and linear light is
|
||||
// not: middle grey sits at 0.18, so a linear weight would call almost
|
||||
// everything a shadow. The cube root approximates lightness cheaply and
|
||||
// behaves well near zero, where a log would diverge.
|
||||
fn tone_position(luma: f32) -> f32 {
|
||||
return clamp(pow(max(luma, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
}",
|
||||
};
|
||||
|
||||
/// Hue-preserving gain.
|
||||
pub const APPLY_TONE_GAIN: Helper = Helper {
|
||||
name: "apply_tone_gain",
|
||||
source: "\
|
||||
// Scale a colour by a gain while preserving its hue.
|
||||
//
|
||||
// Multiplying the three channels equally keeps chromaticity fixed, so
|
||||
// lifting shadows does not desaturate them the way an additive lift would.
|
||||
fn apply_tone_gain(c: vec3<f32>, gain: f32) -> vec3<f32> {
|
||||
return c * gain;
|
||||
}",
|
||||
};
|
||||
|
||||
/// Distance from grey, as HSV chroma.
|
||||
pub const COLOUR_SATURATION: Helper = Helper {
|
||||
name: "colour_saturation",
|
||||
source: "\
|
||||
// How far a colour sits from grey, in 0..1.
|
||||
//
|
||||
// The max-minus-min definition (HSV chroma) rather than a standard
|
||||
// deviation: it matches what the eye reads as 'colourfulness' and it is what
|
||||
// makes vibrance's roll-off land where users expect.
|
||||
fn colour_saturation(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
if (hi <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (hi - lo) / hi;
|
||||
}",
|
||||
};
|
||||
|
||||
/// The set the tonal operations need.
|
||||
pub static TONE: &[Helper] = &[LUMINANCE, TONE_POSITION, APPLY_TONE_GAIN];
|
||||
|
||||
/// The set the colour operations need.
|
||||
pub static COLOUR: &[Helper] = &[LUMINANCE, TONE_POSITION, COLOUR_SATURATION];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_helper_defines_the_function_it_names() {
|
||||
// A mismatch between the dedup key and the function actually emitted
|
||||
// would produce either a duplicate definition or a missing one.
|
||||
for h in TONE.iter().chain(COLOUR.iter()) {
|
||||
assert!(
|
||||
h.source.contains(&format!("fn {}(", h.name)),
|
||||
"helper {} does not define fn {}",
|
||||
h.name,
|
||||
h.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_shared_between_sets_are_the_same_value() {
|
||||
// The drift this module exists to prevent: same name, different
|
||||
// source, and the composer silently picks one.
|
||||
let tone_luma = TONE.iter().find(|h| h.name == "luminance").unwrap();
|
||||
let colour_luma = COLOUR.iter().find(|h| h.name == "luminance").unwrap();
|
||||
assert_eq!(tone_luma.source, colour_luma.source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_set_lists_the_same_helper_twice() {
|
||||
for set in [TONE, COLOUR] {
|
||||
let mut names: Vec<&str> = set.iter().map(|h| h.name).collect();
|
||||
let before = names.len();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(before, names.len(), "a helper set lists a duplicate");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! The develop operations.
|
||||
//!
|
||||
//! Each operation is a self-contained file implementing
|
||||
//! [`crate::operation::Operation`]. Adding one means writing that file and
|
||||
//! adding it to [`crate::graph::EditGraph::default_chain`] — no central
|
||||
//! shader to edit, no UI change (FR-DEV-3c).
|
||||
|
||||
pub mod colour;
|
||||
pub mod exposure;
|
||||
pub mod helpers;
|
||||
pub mod tone;
|
||||
pub mod white_balance;
|
||||
|
||||
pub use colour::{Brilliance, Saturation, Vibrance};
|
||||
pub use exposure::Exposure;
|
||||
pub use tone::{BlacksWhites, HighlightsShadows};
|
||||
pub use white_balance::WhiteBalance;
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Tonal range operations: highlights/shadows and blacks/whites.
|
||||
//!
|
||||
//! Both work by building a smooth weight over the luminance range and
|
||||
//! applying a gain where that weight is high. The distinction between the two
|
||||
//! pairs is *where* they act and *how sharply*:
|
||||
//!
|
||||
//! - **Highlights and shadows** are broad and overlapping, recovering detail
|
||||
//! across the upper and lower thirds. They are the controls used to tame a
|
||||
//! contrasty scene.
|
||||
//! - **Blacks and whites** act at the very ends, setting where the image
|
||||
//! clips. They are the controls used to place the endpoints.
|
||||
//!
|
||||
//! Weights are built from smoothstep rather than a hard threshold: a sharp
|
||||
//! boundary produces visible banding on a gradient — a sky is the worst case,
|
||||
//! and it is also the most common subject for these controls.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
|
||||
/// The helpers both tonal operations need.
|
||||
///
|
||||
/// Sources live in [`crate::ops::helpers`] — see that module for why they are
|
||||
/// defined exactly once.
|
||||
pub use crate::ops::helpers::TONE as TONE_HELPERS;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlights and shadows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const HIGHLIGHTS_SHADOWS_ID: OpId = OpId("highlights_shadows");
|
||||
pub const HIGHLIGHTS: ParamId = ParamId("highlights");
|
||||
pub const SHADOWS: ParamId = ParamId("shadows");
|
||||
|
||||
static HS_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: HIGHLIGHTS_SHADOWS_ID,
|
||||
label: LocalizedKey("op.highlights_shadows"),
|
||||
params: &[
|
||||
// Negative recovers highlights, the overwhelmingly common direction,
|
||||
// matching the convention every other developer uses.
|
||||
ParamDescriptor::amount("highlights", "param.highlights"),
|
||||
ParamDescriptor::amount("shadows", "param.shadows"),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct HighlightsShadows {
|
||||
highlights: f32,
|
||||
shadows: f32,
|
||||
}
|
||||
|
||||
impl HighlightsShadows {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for HighlightsShadows {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&HS_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
HIGHLIGHTS => self.highlights = value,
|
||||
SHADOWS => self.shadows = value,
|
||||
_ => log::warn!("highlights_shadows: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
HIGHLIGHTS => self.highlights,
|
||||
SHADOWS => self.shadows,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.highlights != 0.0 || self.shadows != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Broad, overlapping weights. Highlights ramp in over the upper half,
|
||||
// shadows out over the lower half, so a mid-tone is barely touched by
|
||||
// either and the two controls blend rather than fighting at the join.
|
||||
let hi_w = smoothstep(0.5, 1.0, pos);
|
||||
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
|
||||
|
||||
// Each control contributes up to a stop of gain at full deflection.
|
||||
// exp2 keeps the effect symmetric: -100 and +100 are inverse.
|
||||
let hi_gain = exp2(hi_amount * hi_w);
|
||||
let lo_gain = exp2(lo_amount * lo_w);
|
||||
|
||||
c = apply_tone_gain(c, hi_gain * lo_gain);"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![
|
||||
Uniform {
|
||||
name: "hi_amount",
|
||||
// A full stop at the extreme; enough to recover a bright sky
|
||||
// without inverting the tonal relationship.
|
||||
value: self.highlights / 100.0,
|
||||
},
|
||||
Uniform {
|
||||
name: "lo_amount",
|
||||
value: self.shadows / 100.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
TONE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blacks and whites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const BLACKS_WHITES_ID: OpId = OpId("blacks_whites");
|
||||
pub const BLACKS: ParamId = ParamId("blacks");
|
||||
pub const WHITES: ParamId = ParamId("whites");
|
||||
|
||||
static BW_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: BLACKS_WHITES_ID,
|
||||
label: LocalizedKey("op.blacks_whites"),
|
||||
params: &[
|
||||
ParamDescriptor::amount("blacks", "param.blacks"),
|
||||
ParamDescriptor::amount("whites", "param.whites"),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BlacksWhites {
|
||||
blacks: f32,
|
||||
whites: f32,
|
||||
}
|
||||
|
||||
impl BlacksWhites {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for BlacksWhites {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&BW_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
BLACKS => self.blacks = value,
|
||||
WHITES => self.whites = value,
|
||||
_ => log::warn!("blacks_whites: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
BLACKS => self.blacks,
|
||||
WHITES => self.whites,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.blacks != 0.0 || self.whites != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Narrow weights concentrated at each end — this is what separates these
|
||||
// controls from highlights/shadows, which are broad. Whites act only in the
|
||||
// top quarter, blacks only in the bottom quarter.
|
||||
let white_w = smoothstep(0.75, 1.0, pos);
|
||||
let black_w = 1.0 - smoothstep(0.0, 0.25, pos);
|
||||
|
||||
// Whites scale the top end multiplicatively, moving the clipping point.
|
||||
let white_gain = exp2(white_amount * white_w);
|
||||
c = apply_tone_gain(c, white_gain);
|
||||
|
||||
// Blacks shift the floor. This one is deliberately *additive*: the point of
|
||||
// a blacks control is to set where the image reaches zero, and a multiply
|
||||
// can never bring a non-zero value to zero nor lift a true black off it.
|
||||
c = c + vec3<f32>(black_amount * black_w);
|
||||
|
||||
// The subtractive direction can push below zero, which is not light.
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![
|
||||
Uniform {
|
||||
name: "white_amount",
|
||||
value: self.whites / 100.0,
|
||||
},
|
||||
Uniform {
|
||||
name: "black_amount",
|
||||
// A small linear offset. Scene-referred black sits near zero,
|
||||
// so the useful range here is far smaller than a stop — 0.02
|
||||
// is already a visible lift on a dark frame.
|
||||
value: self.blacks / 100.0 * 0.02,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
TONE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_operations_start_neutral() {
|
||||
assert!(!HighlightsShadows::new().is_active());
|
||||
assert!(!BlacksWhites::new().is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_parameter_is_enough_to_activate() {
|
||||
let mut hs = HighlightsShadows::new();
|
||||
hs.set_param(HIGHLIGHTS, -50.0);
|
||||
assert!(hs.is_active());
|
||||
|
||||
let mut bw = BlacksWhites::new();
|
||||
bw.set_param(WHITES, 20.0);
|
||||
assert!(bw.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_recovery_is_the_negative_direction() {
|
||||
// The convention users expect: dragging left recovers.
|
||||
let mut hs = HighlightsShadows::new();
|
||||
hs.set_param(HIGHLIGHTS, -100.0);
|
||||
let u = hs.uniforms();
|
||||
assert!(
|
||||
u[0].value < 0.0,
|
||||
"negative highlights must produce a gain below 1"
|
||||
);
|
||||
assert!((u[0].value + 1.0).abs() < 1e-6, "full travel is one stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonal_amounts_are_symmetric() {
|
||||
let mut up = HighlightsShadows::new();
|
||||
up.set_param(SHADOWS, 100.0);
|
||||
let mut down = HighlightsShadows::new();
|
||||
down.set_param(SHADOWS, -100.0);
|
||||
// exp2 of equal and opposite exponents multiplies to 1.
|
||||
assert!((up.uniforms()[1].value + down.uniforms()[1].value).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blacks_offset_stays_small() {
|
||||
// Scene-referred black is near zero; a full-stop control here would
|
||||
// be unusable, moving the image to grey at a fraction of its travel.
|
||||
let mut bw = BlacksWhites::new();
|
||||
bw.set_param(BLACKS, 100.0);
|
||||
let offset = bw
|
||||
.uniforms()
|
||||
.iter()
|
||||
.find(|u| u.name == "black_amount")
|
||||
.expect("black_amount")
|
||||
.value;
|
||||
assert!(
|
||||
(0.0..=0.05).contains(&offset),
|
||||
"offset {offset} is too large for scene-referred data"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_operations_share_helpers_without_duplicating_them() {
|
||||
// Both request TONE_HELPERS; the composer must emit each once.
|
||||
let ops: Vec<Box<dyn Operation>> = vec![
|
||||
Box::new({
|
||||
let mut o = HighlightsShadows::new();
|
||||
o.set_param(HIGHLIGHTS, -30.0);
|
||||
o
|
||||
}),
|
||||
Box::new({
|
||||
let mut o = BlacksWhites::new();
|
||||
o.set_param(BLACKS, 30.0);
|
||||
o
|
||||
}),
|
||||
];
|
||||
let shader = crate::operation::compose(&ops);
|
||||
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
|
||||
assert_eq!(shader.source.matches("fn tone_position(").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_parameters_are_ignored_rather_than_panicking() {
|
||||
// A sidecar written by a newer version may name a parameter this
|
||||
// build does not have; the image must still open.
|
||||
let mut hs = HighlightsShadows::new();
|
||||
hs.set_param(ParamId("from_the_future"), 50.0);
|
||||
assert!(!hs.is_active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! White balance — temperature and tint, relative to as-shot.
|
||||
//!
|
||||
//! Expressed as an offset from what the camera chose rather than an absolute
|
||||
//! kelvin value. Neutral means "as shot", so the control starts where the
|
||||
//! image already is and a reset returns there. An absolute scale would make
|
||||
//! the neutral position depend on the file, which is exactly the confusion
|
||||
//! Lightroom's temperature slider creates on non-raw files.
|
||||
//!
|
||||
//! The as-shot multipliers themselves are applied here too, folded into the
|
||||
//! same multiply — they come from the uniform block rather than the fragment,
|
||||
//! because every image has them even when this operation is neutral.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("white_balance");
|
||||
pub const TEMPERATURE: ParamId = ParamId("temperature");
|
||||
pub const TINT: ParamId = ParamId("tint");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.white_balance"),
|
||||
params: &[
|
||||
// Warmer is positive, matching every other raw developer: dragging
|
||||
// right makes the image warmer, even though that means *lowering*
|
||||
// the colour temperature being corrected for.
|
||||
ParamDescriptor::scalar(
|
||||
"temperature",
|
||||
"param.temperature",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::amount("tint", "param.tint"),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WhiteBalance {
|
||||
temperature: f32,
|
||||
tint: f32,
|
||||
}
|
||||
|
||||
impl WhiteBalance {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Per-channel multipliers for the current settings.
|
||||
///
|
||||
/// Temperature trades red against blue; tint trades green against
|
||||
/// magenta. Both are scaled so the full range is a strong but not
|
||||
/// destructive correction, and green is held near unity so the control
|
||||
/// does not double as an exposure slider.
|
||||
fn multipliers(&self) -> [f32; 3] {
|
||||
// ±0.5 in log2 at the extremes — half a stop of channel shift, which
|
||||
// covers ordinary illuminant error without letting the slider blow a
|
||||
// channel on its own.
|
||||
let t = self.temperature / 100.0 * 0.5;
|
||||
let g = self.tint / 100.0 * 0.5;
|
||||
|
||||
[
|
||||
f32::exp2(t),
|
||||
f32::exp2(-g),
|
||||
// Blue moves opposite red, so a neutral grey stays grey as the
|
||||
// control moves.
|
||||
f32::exp2(-t),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for WhiteBalance {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
TEMPERATURE => self.temperature = value,
|
||||
TINT => self.tint = value,
|
||||
_ => log::warn!("white_balance: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
TEMPERATURE => self.temperature,
|
||||
TINT => self.tint,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.temperature != 0.0 || self.tint != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// as_shot_wb comes from the base uniform block: it applies to every
|
||||
// image regardless of whether this operation is active, so the adjust
|
||||
// pass folds it in separately. Here we apply only the user's offset.
|
||||
"c = c * vec3<f32>(mul_r, mul_g, mul_b);".into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let m = self.multipliers();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "mul_r",
|
||||
value: m[0],
|
||||
},
|
||||
Uniform {
|
||||
name: "mul_g",
|
||||
value: m[1],
|
||||
},
|
||||
Uniform {
|
||||
name: "mul_b",
|
||||
value: m[2],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn neutral_is_as_shot() {
|
||||
let wb = WhiteBalance::new();
|
||||
assert!(!wb.is_active(), "a fresh control must not alter the image");
|
||||
assert_eq!(wb.multipliers(), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warming_raises_red_and_lowers_blue() {
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TEMPERATURE, 100.0);
|
||||
let m = wb.multipliers();
|
||||
assert!(m[0] > 1.0, "red should rise, got {}", m[0]);
|
||||
assert!(m[2] < 1.0, "blue should fall, got {}", m[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooling_is_the_inverse_of_warming() {
|
||||
let mut warm = WhiteBalance::new();
|
||||
warm.set_param(TEMPERATURE, 60.0);
|
||||
let mut cool = WhiteBalance::new();
|
||||
cool.set_param(TEMPERATURE, -60.0);
|
||||
|
||||
let (w, c) = (warm.multipliers(), cool.multipliers());
|
||||
// Warming by n then cooling by n must return to neutral.
|
||||
assert!((w[0] * c[0] - 1.0).abs() < 1e-5);
|
||||
assert!((w[2] * c[2] - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_leaves_green_alone() {
|
||||
// Otherwise the control doubles as an exposure slider, because green
|
||||
// carries most of the luminance.
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TEMPERATURE, 100.0);
|
||||
assert_eq!(wb.multipliers()[1], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_moves_green_against_magenta() {
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TINT, 100.0);
|
||||
let m = wb.multipliers();
|
||||
assert!(m[1] < 1.0, "positive tint reduces green (toward magenta)");
|
||||
assert_eq!(m[0], 1.0, "tint must not touch red");
|
||||
assert_eq!(m[2], 1.0, "tint must not touch blue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_extremes_stay_within_half_a_stop() {
|
||||
// A white balance control that can blow a channel by itself is a
|
||||
// trap; correction belongs in a range where highlights survive.
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TEMPERATURE, 100.0);
|
||||
wb.set_param(TINT, 100.0);
|
||||
for m in wb.multipliers() {
|
||||
assert!(
|
||||
(0.70..=1.42).contains(&m),
|
||||
"multiplier {m} exceeds half a stop"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user