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
+6
View File
@@ -2371,6 +2371,12 @@ mod tests {
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; let id = desc.id.0;
// A stock is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither half (FR-DEV-3j).
if id == dr_pipeline::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
let point = shader.source.contains(&format!("---- {id} ----")); let point = shader.source.contains(&format!("---- {id} ----"));
let neighbourhood = detail let neighbourhood = detail
.passes .passes
+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 ## What is not a node, and why
Three things act on every pixel and are deliberately not in this directory: Two things act on every pixel and are deliberately not in this directory: the
the as-shot white balance, the camera matrix, and the **view transform** as-shot white balance and the camera matrix. They are emitted by
(FR-DEV-3j). They are emitted by [`../src/operation.rs`](../src/operation.rs) [`../src/operation.rs`](../src/operation.rs) into the composed shader around the
into the composed shader around the block of nodes. block of nodes. They are properties of the *file*, at the same standing as the
The first two 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 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 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 undoing it.
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 The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
values. It replaced the per-body **base curve**, which was looked up by camera `rust:` one — and that is a change of mind worth knowing about. It replaced the
model and flat past 1.0, so it clipped every recovered highlight. 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 ## 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 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 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 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 ## 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() { fn a_fresh_graph_is_neutral() {
// Opening an unedited image must produce the image, not an // Opening an unedited image must produce the image, not an
// interpretation of it. // 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(); let g = EditGraph::default_chain();
assert!(g.is_neutral()); assert!(g.is_neutral());
let source = g.compose().source;
assert_eq!( assert_eq!(
g.compose().source.matches("---- ").count(), source.matches("---- ").count(),
0, 1,
"a neutral graph must generate no operation blocks" "a neutral graph must generate no adjustment blocks"
); );
assert!(source.contains("---- view_transform ----"));
} }
#[test] #[test]
@@ -1207,13 +1215,15 @@ mod tests {
#[test] #[test]
fn only_active_operations_reach_the_shader() { fn only_active_operations_reach_the_shader() {
// The composition property, end to end: two adjustments out of seven // 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(); let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TINT, 25.0); g.set_param(white_balance::ID, white_balance::TINT, 25.0);
let shader = g.compose(); 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("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----")); assert!(shader.source.contains("---- white_balance ----"));
assert!(!shader.source.contains("---- saturation ----")); assert!(!shader.source.contains("---- saturation ----"));
+7
View File
@@ -169,6 +169,13 @@ mod tests {
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; 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 point = shader.source.contains(&format!("---- {id} ----"));
let neighbourhood = detail let neighbourhood = detail
.passes .passes
+77 -63
View File
@@ -468,6 +468,16 @@ pub enum Stage {
/// Working-space colour: linear Rec.709 primaries, scene-referred, /// Working-space colour: linear Rec.709 primaries, scene-referred,
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14). /// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
Scene, 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. /// A named WGSL helper function, deduplicated across operations.
@@ -849,16 +859,9 @@ fn compose_inner(
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0); uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
// TRACES: FR-DEV-3j // TRACES: FR-DEV-3j
// The view transform's function, whenever the composer emits the view // Whether the composer emits a view transform — which is every render but
// transform — which is every render but the camera-space tap and one a // the camera-space tap and one a rendering operation has taken over.
// rendering operation has taken over. See `rendering_tail` below.
let views = !op_renders && output_mode != OutputMode::CameraLinear; 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 // 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, // reason: the prologue is emitted whether or not any operation is active,
@@ -910,22 +913,71 @@ fn compose_inner(
.map(|o| o.as_ref()) .map(|o| o.as_ref())
.filter(move |o| o.detail().is_none() && o.stage() == stage) .filter(move |o| o.detail().is_none() && o.stage() == stage)
}; };
let mut in_working_space = false; // The view operation, or a default one when the caller's chain holds
for op in point(Stage::Camera).chain(point(Stage::Scene)) { // none — `compose(&[])` in a test, or a probe. Absent altogether where
if !in_working_space && op.stage() == Stage::Scene { // nothing is to be rendered: see `views`.
body.push_str(CAMERA_MATRIX); let default_view = crate::ops::ViewTransform::new();
in_working_space = true; 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 id = op.descriptor().id.0;
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect(); 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; continue;
} }
let prefix = sanitise(id); let prefix = sanitise(id);
let mut fragment = String::new(); let mut fragment = String::new();
let mut op_uniforms = Vec::new(); let mut op_uniforms = Vec::new();
if op.is_active() { if global {
// Each op's uniforms are prefixed, so two operations may both // Each op's uniforms are prefixed, so two operations may both
// declare a field called `amount` without colliding. // declare a field called `amount` without colliding.
op_uniforms = op.uniforms(); op_uniforms = op.uniforms();
@@ -964,27 +1016,6 @@ fn compose_inner(
body.push_str(&local_block(&fragment, &local)); 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 // TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform // Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`. // 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. // Where the view transform is not, say why, for whoever reads the
let rendering_tail = if !views { // 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" " // 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() .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 // Formatted with Rust's `Display` so the shader reads the same threshold
@@ -1833,6 +1845,8 @@ mod tests {
fn an_inactive_operation_contributes_nothing() { fn an_inactive_operation_contributes_nothing() {
// The point of composing rather than branching: an op at neutral // The point of composing rather than branching: an op at neutral
// must not appear in the source at all. // 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 ops = vec![fake(DESC_A.clone(), 0.0, false)];
let shader = compose(&ops); let shader = compose(&ops);
assert!( assert!(
@@ -1841,7 +1855,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
shader.uniforms.len(), shader.uniforms.len(),
PREAMBLE_FIELDS, compose(&[]).uniforms.len(),
"it must contribute no uniforms either" "it must contribute no uniforms either"
); );
} }
+2
View File
@@ -74,6 +74,7 @@ pub mod distortion;
pub mod film_sim; pub mod film_sim;
pub mod local_contrast; pub mod local_contrast;
pub mod noise_reduction; pub mod noise_reduction;
pub mod view_transform;
pub mod vignetting; pub mod vignetting;
pub use aberration::Aberration; 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. // documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture}; pub use local_contrast::{Clarity, Texture};
pub use noise_reduction::NoiseReduction; pub use noise_reduction::NoiseReduction;
pub use view_transform::ViewTransform;
pub use vignetting::Vignetting; pub use vignetting::Vignetting;
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by // 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 //! # What is not covered, and why that is honest
//! //!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`, //! 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 //! 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 //! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly //! `ops/` between them, so a node that stops being declared cannot quietly
@@ -403,6 +404,7 @@ fn every_declared_node_is_checked() {
"noise_reduction", "noise_reduction",
"texture", "texture",
"tone_curve", "tone_curve",
"view_transform",
"vignetting", "vignetting",
], ],
"the set of hand-written nodes changed; if that is deliberate, update \ "the set of hand-written nodes changed; if that is deliberate, update \
File diff suppressed because one or more lines are too long
+8
View File
@@ -203,12 +203,20 @@ fn catalogued(key: &str) -> Option<&'static str> {
// it is a tick box, and a checkbox labelled with a verb reads as a // it is a tick box, and a checkbox labelled with a verb reads as a
// button that does something once rather than a state that is on. // button that does something once rather than a state that is on.
"op.lens_profile" => "Lens Profile", "op.lens_profile" => "Lens Profile",
// The view transform (FR-DEV-3j). "Tone Mapping" rather than the
// "View Transform" `derive` would give: the id names where it sits in
// the pipeline, and the photographer is choosing how the scene's range
// is fitted onto the screen.
"op.view_transform" => "Tone Mapping",
// Parameters // Parameters
// Named for what it does rather than what it is, since a lone // Named for what it does rather than what it is, since a lone
// parameter is titled by its operation and this one never reaches the // parameter is titled by its operation and this one never reaches the
// panel under its own name — see `rows_filtered`. // panel under its own name — see `rows_filtered`.
"param.lens_profile.apply" => "Apply", "param.lens_profile.apply" => "Apply",
"param.view_transform.contrast" => "Contrast",
// In stops above middle grey: where the scene reaches display white.
"param.view_transform.white" => "White Point",
"param.temperature" => "Temperature", "param.temperature" => "Temperature",
"param.tint" => "Tint", "param.tint" => "Tint",
"param.exposure" => "Exposure", "param.exposure" => "Exposure",