Make the view transform an operation the photographer can set

FR-DEV-3j gives the view transform two controls, contrast and the white
point in stops above middle grey, persisted and held per mask layer like
any other setting. A scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.

`view_transform` is a hand-written node in `Stage::View`, a new stage
the composer emits last and emits whatever the node's state: a neutral
operation is otherwise left out of the shader, but a photograph with no
view transform is a scan. "Active" keeps meaning "moved from the
defaults", so an untouched photograph writes nothing for it and
`every_node_starts_neutral` still holds. A caller whose chain holds no
view operation gets a default one.

The composer's loop becomes an ordered list of steps — camera nodes, the
matrix, scene nodes, layer-only nodes, the view — so each is emitted in
exactly one place. The base curve could not be a node because it
belonged to the camera; the ops README records why that argument went
with it (D19). The panel shows it in the Light group as "Tone Mapping".

Tests that counted the blocks of a neutral graph now count one, the
view transform, and the two chain-wide tests that load a film expect the
view transform to be absent, since a stock replaces it.
This commit is contained in:
2026-09-27 16:52:54 -04:00
parent 37a6d99dc4
commit 92afaebd34
11 changed files with 386 additions and 99 deletions
+21 -12
View File
@@ -293,19 +293,25 @@ in raw pixels is a different photograph on screen and in the exported file.
## What is not a node, and why
Three things act on every pixel and are deliberately not in this directory:
the as-shot white balance, the camera matrix, and the **view transform**
(FR-DEV-3j). They are emitted by [`../src/operation.rs`](../src/operation.rs)
into the composed shader around the block of nodes.
The first two are properties of the *file*, at the same standing as the
Two things act on every pixel and are deliberately not in this directory: the
as-shot white balance and the camera matrix. They are emitted by
[`../src/operation.rs`](../src/operation.rs) into the composed shader around the
block of nodes. They are properties of the *file*, at the same standing as the
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
chose the sensor's green sensitivity, and reading the file correctly means
undoing it. The view transform is different in kind — it is the one stage that
maps scene-linear colour to a display range (D19, ARCH §6.14) — and it runs
after every node, because corrections to capture are only meaningful on linear
values. It replaced the per-body **base curve**, which was looked up by camera
model and flat past 1.0, so it clipped every recovered highlight.
undoing it.
The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
`rust:` one — and that is a change of mind worth knowing about. It replaced the
per-body base curve, which was kept out of this directory because it belonged
to the camera: as a node it would have carried one body's rendering onto
another body's file through a shared sidecar. D19 retired the per-body curves,
and with them the argument. One view transform serves every body, so its
settings are a decision about the picture like any other. What is still
special about it is `Stage::View`: the composer emits it at the end of the
chain *whatever its state*, because a photograph with no view transform is a
scan and not a picture. Its neutral is its defaults, like every other node's,
so an untouched photograph writes nothing for it.
## Stages
@@ -315,7 +321,10 @@ primaries, scene-referred and unbounded. White balance is the only camera
node, because its multipliers scale the sensor's own channels. Everything else
belongs in the scene, where a hue or a luminance weight means the same thing
whichever body took the frame (D19). The composer emits the camera nodes, then
the matrix, then the scene nodes, each group in `order:`.
the matrix, then the scene nodes, each group in `order:`, and the view
transform last. `stage: view` is not offered to a declaration: a node that
maps into a display range is exactly what ARCH §6.14 forbids of everything
before the end, and the one that is allowed to is hand-written.
## Errors
+18
View File
@@ -0,0 +1,18 @@
id: view_transform
order: 200
# A `rust:` node publishes its own descriptor; its attributes are on the type
# in `../src/ops/view_transform.rs`.
rust: ViewTransform
why_rust: |
It is composed at its defaults — a photograph with no view transform is a
scan, not a picture — which is `Stage::View`, and a declaration has no way to
say it. Its three uniforms are also the solution of two equations rather than
expressions over its parameters (`dr_pipeline::view::Sigmoid::new`).
placement: |
Last, after every scene operation and, when there is one, after the detail
stage (D19, FR-DEV-3j). It is the one stage allowed to map scene-linear colour
to a display range, so anything after it would be working on a rendering.
The order here only places it in the panel; the composer puts every
`Stage::View` node at the end whatever its number says.
+15 -5
View File
@@ -1122,13 +1122,21 @@ mod tests {
fn a_fresh_graph_is_neutral() {
// Opening an unedited image must produce the image, not an
// interpretation of it.
//
// One block, and it is the view transform: a view operation is
// composed at its defaults, because a photograph with no view
// transform is a scan rather than a picture (FR-DEV-3j). It is still
// neutral in the sense that matters here — nothing moved, nothing is
// written — and every adjustment is absent.
let g = EditGraph::default_chain();
assert!(g.is_neutral());
let source = g.compose().source;
assert_eq!(
g.compose().source.matches("---- ").count(),
0,
"a neutral graph must generate no operation blocks"
source.matches("---- ").count(),
1,
"a neutral graph must generate no adjustment blocks"
);
assert!(source.contains("---- view_transform ----"));
}
#[test]
@@ -1207,13 +1215,15 @@ mod tests {
#[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.
// available must generate a shader doing exactly two things — and
// the view transform, which every render has (FR-DEV-3j).
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_eq!(shader.source.matches("---- ").count(), 3);
assert!(shader.source.contains("---- view_transform ----"));
assert!(shader.source.contains("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----"));
assert!(!shader.source.contains("---- saturation ----"));
+7
View File
@@ -169,6 +169,13 @@ mod tests {
let mut fused_blocks = 0;
for desc in g.descriptors() {
let id = desc.id.0;
// The film is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither stage (FR-DEV-3f,
// FR-DEV-3j).
if id == crate::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
let point = shader.source.contains(&format!("---- {id} ----"));
let neighbourhood = detail
.passes
+77 -63
View File
@@ -468,6 +468,16 @@ pub enum Stage {
/// Working-space colour: linear Rec.709 primaries, scene-referred,
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
Scene,
/// The view transform (FR-DEV-3j): after every scene operation, and the
/// one stage allowed to map the scene to a display range.
///
/// Composed **whatever its state**. A neutral operation is otherwise left
/// out, but a photograph with no view transform is a scan rather than a
/// picture, so a view operation at its defaults still renders — with its
/// defaults. "Active" keeps its meaning of "moved from the defaults",
/// which is what the sidecar and the panel read. When the chain holds no
/// view operation at all the composer supplies a default one.
View,
}
/// A named WGSL helper function, deduplicated across operations.
@@ -849,16 +859,9 @@ fn compose_inner(
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
// TRACES: FR-DEV-3j
// The view transform's function, whenever the composer emits the view
// transform — which is every render but the camera-space tap and one a
// rendering operation has taken over. See `rendering_tail` below.
// Whether the composer emits a view transform — which is every render but
// the camera-space tap and one a rendering operation has taken over.
let views = !op_renders && output_mode != OutputMode::CameraLinear;
if views {
helpers.push(Helper {
name: "view_sigmoid",
source: crate::view::VIEW_SIGMOID_WGSL,
});
}
// Framing's block follows the base one at a fixed offset, for the same
// reason: the prologue is emitted whether or not any operation is active,
@@ -910,22 +913,71 @@ fn compose_inner(
.map(|o| o.as_ref())
.filter(move |o| o.detail().is_none() && o.stage() == stage)
};
let mut in_working_space = false;
for op in point(Stage::Camera).chain(point(Stage::Scene)) {
if !in_working_space && op.stage() == Stage::Scene {
body.push_str(CAMERA_MATRIX);
in_working_space = true;
}
// The view operation, or a default one when the caller's chain holds
// none — `compose(&[])` in a test, or a probe. Absent altogether where
// nothing is to be rendered: see `views`.
let default_view = crate::ops::ViewTransform::new();
let view: Option<&dyn Operation> = views.then(|| {
point(Stage::View)
.next()
.unwrap_or(&default_view as &dyn Operation)
});
// What is emitted, in order: camera RGB, the matrix out of it, the scene,
// any layer-only operations, and the view transform last (D19).
enum Step<'a> {
Op(&'a dyn Operation),
Matrix,
Orphans,
}
let steps = point(Stage::Camera)
.map(Step::Op)
.chain(std::iter::once(Step::Matrix))
.chain(point(Stage::Scene).map(Step::Op))
.chain(std::iter::once(Step::Orphans))
.chain(view.map(Step::Op));
for step in steps {
let op = match step {
Step::Op(op) => op,
Step::Matrix => {
body.push_str(CAMERA_MATRIX);
continue;
}
Step::Orphans => {
// A layer's operation the global chain does not hold at
// all. Not a case any editor produces — both chains come from
// `ops::chain` — but a layer must not lose an edit because a
// caller composed a shorter chain.
let mut orphans: Vec<&'static str> = Vec::new();
for l in &layers.ops {
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op)
{
orphans.push(l.op);
}
}
for id in orphans {
let local: Vec<&crate::mask::LocalOp> =
layers.ops.iter().filter(|l| l.op == id).collect();
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
body.push_str(&local_block("", &local));
}
continue;
}
};
let id = op.descriptor().id.0;
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
if !op.is_active() && local.is_empty() {
// The global side of the blend. A view operation always has one; see
// `Stage::View`.
let global = op.is_active() || op.stage() == Stage::View;
if !global && local.is_empty() {
continue;
}
let prefix = sanitise(id);
let mut fragment = String::new();
let mut op_uniforms = Vec::new();
if op.is_active() {
if global {
// Each op's uniforms are prefixed, so two operations may both
// declare a field called `amount` without colliding.
op_uniforms = op.uniforms();
@@ -964,27 +1016,6 @@ fn compose_inner(
body.push_str(&local_block(&fragment, &local));
}
// Emitted here when no scene operation was listed at all, which is what
// makes an empty chain still convert out of camera space.
if !in_working_space {
body.push_str(CAMERA_MATRIX);
}
// A layer's operation the global chain does not hold at all. Not a case
// any editor produces — both chains come from `ops::chain` — but a layer
// must not lose an edit because a caller composed a shorter chain.
let mut orphans: Vec<&'static str> = Vec::new();
for l in &layers.ops {
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) {
orphans.push(l.op);
}
}
for id in orphans {
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
body.push_str(&local_block("", &local));
}
// TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`.
@@ -1114,32 +1145,13 @@ fn compose_inner(
),
};
// The view transform, which an operation may have taken over.
let rendering_tail = if !views {
// Where the view transform is not, say why, for whoever reads the
// generated source. Where it is, it is the last operation block above.
let rendering_tail = if views {
String::new()
} else {
" // No view transform: an operation declaring `Operation::renders` has\n // mapped the scene to a display range itself, or this is the\n // camera-space tap, which stores the sensor's own numbers.\n"
.to_string()
} else {
let curve = crate::view::Sigmoid::default_curve();
format!(
" // ==== the view transform (FR-DEV-3j) ====
//
// Marked with `====` and not the `----` an operation block carries: this
// is not one, and the difference is what several tests count on to tell
// an edit apart from the rendering of one.
//
// The one stage allowed to map scene-linear colour to a display range
// (D19, ARCH §6.14), after every operation. Everything above it is
// unbounded; everything the shoulder has not brought under 1.0 is
// clipped by the output transform, at the last moment.
//
// Skipped for an already-rendered source — a JPEG is a display rendering
// already, and rendering it again would compress it twice.
if (!non_linear) {{
c = view_sigmoid(c, {:?}, {:?}, {:?});
}}
",
curve.n, curve.inv_k, curve.w
)
};
// Formatted with Rust's `Display` so the shader reads the same threshold
@@ -1833,6 +1845,8 @@ mod tests {
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.
// Measured against an empty chain rather than the preamble alone,
// since every render carries the view transform's uniforms too.
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
let shader = compose(&ops);
assert!(
@@ -1841,7 +1855,7 @@ mod tests {
);
assert_eq!(
shader.uniforms.len(),
PREAMBLE_FIELDS,
compose(&[]).uniforms.len(),
"it must contribute no uniforms either"
);
}
+2
View File
@@ -74,6 +74,7 @@ pub mod distortion;
pub mod film_sim;
pub mod local_contrast;
pub mod noise_reduction;
pub mod view_transform;
pub mod vignetting;
pub use aberration::Aberration;
@@ -87,6 +88,7 @@ pub use film_sim::{FilmSim, FilmTables, PaperTables};
// documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture};
pub use noise_reduction::NoiseReduction;
pub use view_transform::ViewTransform;
pub use vignetting::Vignetting;
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
+211
View File
@@ -0,0 +1,211 @@
//! TRACES: FR-DEV-3j
//! The view transform as an operation: the photographer's two numbers for
//! the curve [`crate::view`] defines.
//!
//! # Why it is a node now, when the base curve could not be
//!
//! The base curve was kept out of the chain for reasons that were all about
//! the *body*: it was looked up by camera model, so as a node it would have
//! carried one camera's rendering onto another camera's file through a shared
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
//! one reporting itself modified. D19 removed the premise. There is one view
//! transform for every body, so its settings are a decision about the picture
//! like any other, and they belong in the sidecar, the history and a mask
//! layer.
//!
//! # Why it is composed at its defaults
//!
//! A neutral operation is normally left out of the shader, and "active" means
//! "moved from its defaults". Both stay true here — an untouched photograph
//! writes no view transform parameters, and `every_node_starts_neutral` still
//! holds — but the composer emits this node whatever its state, because a
//! photograph with no view transform is a scan, not a picture. See
//! [`crate::operation::Stage::View`].
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Operation, Stage, Uniform};
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
pub const ID: OpId = OpId("view_transform");
pub const CONTRAST: ParamId = ParamId("contrast");
pub const WHITE: ParamId = ParamId("white");
static HELPERS: [Helper; 1] = [Helper {
name: "view_sigmoid",
source: crate::view::VIEW_SIGMOID_WGSL,
}];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Tone: it is the tone response of the whole picture, and the panel's
// Light group is where a photographer looks for the white point.
attributes: vec![Attribute::Tone],
id: ID,
label: LocalizedKey("op.view_transform"),
params: vec![
ParamDescriptor::scalar(
"contrast",
"param.view_transform.contrast",
CONTRAST_RANGE.0,
CONTRAST_RANGE.1,
DEFAULT_CONTRAST,
Unit::None,
Scale::Linear,
2,
),
ParamDescriptor::scalar(
"white",
"param.view_transform.white",
WHITE_RANGE.0,
WHITE_RANGE.1,
DEFAULT_WHITE,
Unit::Stops,
Scale::Linear,
1,
),
],
})
});
#[derive(Debug, Clone)]
pub struct ViewTransform {
contrast: f32,
white: f32,
}
impl Default for ViewTransform {
fn default() -> Self {
Self {
contrast: DEFAULT_CONTRAST,
white: DEFAULT_WHITE,
}
}
}
impl ViewTransform {
pub fn new() -> Self {
Self::default()
}
/// The curve these settings solve to.
pub fn sigmoid(&self) -> Sigmoid {
Sigmoid::new(self.contrast, self.white)
}
}
impl Operation for ViewTransform {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
CONTRAST => self.contrast = value,
WHITE => self.white = value,
_ => log::warn!("view_transform: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
CONTRAST => self.contrast,
WHITE => self.white,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
}
fn stage(&self) -> Stage {
Stage::View
}
fn wgsl_body(&self) -> String {
"\
// Skipped for an already-rendered source: a JPEG is a display rendering
// already, and rendering it again would compress it twice.
if (!non_linear) {
c = view_sigmoid(c, slope, inv_k, peak);
}"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let s = self.sigmoid();
vec![
Uniform {
name: "slope",
value: s.n,
},
Uniform {
name: "inv_k",
value: s.inv_k,
},
Uniform {
name: "peak",
value: s.w,
},
]
}
fn helpers(&self) -> &[Helper] {
&HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_starts_neutral_and_says_so() {
// TRACES: FR-DEV-3j
// Neutral in the sense every other node is: nothing moved, so nothing
// is written. It is still composed — see the module documentation.
let op = ViewTransform::new();
assert!(!op.is_active());
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
}
#[test]
fn moving_either_slider_makes_it_active() {
let mut op = ViewTransform::new();
op.set_param(WHITE, 6.0);
assert!(op.is_active());
let mut op = ViewTransform::new();
op.set_param(CONTRAST, 2.0);
assert!(op.is_active());
}
#[test]
fn the_uniforms_are_the_solved_curve() {
let mut op = ViewTransform::new();
op.set_param(CONTRAST, 2.0);
op.set_param(WHITE, 6.0);
let s = Sigmoid::new(2.0, 6.0);
let u = op.uniforms();
assert_eq!(
u.iter().map(|u| u.value).collect::<Vec<_>>(),
vec![s.n, s.inv_k, s.w]
);
}
#[test]
fn the_descriptor_defaults_are_the_curve_defaults() {
// The sidecar treats a value equal to the descriptor's default as
// unedited; the two disagreeing would make every photograph open
// reporting a view transform edit it never had.
let d = ViewTransform::new().descriptor();
assert_eq!(
d.param(CONTRAST).expect("contrast").default,
DEFAULT_CONTRAST
);
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
}
}
+3 -1
View File
@@ -32,7 +32,8 @@
//! # What is not covered, and why that is honest
//!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` —
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
//! `view_transform` —
//! names a hand-written type and has no declaration to interpret. It is not skipped
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly
@@ -403,6 +404,7 @@ fn every_declared_node_is_checked() {
"noise_reduction",
"texture",
"tone_curve",
"view_transform",
"vignetting",
],
"the set of hand-written nodes changed; if that is deliberate, update \