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:
@@ -2371,6 +2371,12 @@ mod tests {
|
||||
let mut fused_blocks = 0;
|
||||
for desc in g.descriptors() {
|
||||
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 neighbourhood = detail
|
||||
.passes
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ----"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user