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.
440 lines
16 KiB
Rust
440 lines
16 KiB
Rust
//! TRACES: FR-PLG-2
|
|
//! The generated path and the interpreted path produce the same operation.
|
|
//!
|
|
//! # Why this test is the point
|
|
//!
|
|
//! FR-PLG-2 says a bundled operation and a third-party plugin are the same
|
|
//! kind of thing, differing only in where the file was found. Two
|
|
//! implementations sit behind that claim: `build.rs` compiles `ops/*.yaml`
|
|
//! into Rust, and [`DeclaredOp`] interprets the same declaration at run time.
|
|
//!
|
|
//! **If the two ever disagree, the claim fails quietly.** A plugin would be a
|
|
//! second-class kind of node — one whose colours come out fractionally
|
|
//! different, or whose fragment lands in the shader with a different comment,
|
|
//! or whose uniform arrives in a different slot — and nothing would say so.
|
|
//! The photographer would see a look they could not reproduce with a built-in
|
|
//! and would have no way to find out why.
|
|
//!
|
|
//! So this parses every built-in declaration at run time and asserts the
|
|
//! composed WGSL is **byte for byte** what the generated implementation
|
|
//! produces, with the uniform block bit for bit identical, at a spread of
|
|
//! parameter values.
|
|
//!
|
|
//! # Byte-for-byte, and bit-for-bit, on purpose
|
|
//!
|
|
//! Not "equivalent", not "within an epsilon". A tolerance is where a real
|
|
//! divergence hides: the arithmetic in a declaration is `f32` at both ends and
|
|
//! there is no reason for a single bit to differ, so any difference at all is
|
|
//! a bug in one of the two backends and should read as one. The one place
|
|
//! this bites is number literals, which is why `expr::as_f32` rounds a decimal
|
|
//! exactly once — see its own documentation.
|
|
//!
|
|
//! # 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`,
|
|
//! `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
|
|
//! drop out of this file's coverage.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use dr_pipeline::declared::{builtin_helpers, decl, DeclaredOp, Node};
|
|
use dr_pipeline::descriptor::ParamKind;
|
|
use dr_pipeline::operation::{compose, ComposedShader, Operation};
|
|
use dr_pipeline::ops;
|
|
use dr_pipeline::ParamId;
|
|
|
|
/// The declarations this crate ships, read from disk rather than embedded.
|
|
///
|
|
/// From disk deliberately: `build.rs` reads these very files, so reading the
|
|
/// same bytes is what makes the comparison a comparison of the two *readers*
|
|
/// rather than of two snapshots that were taken at different times.
|
|
fn ops_dir() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("ops")
|
|
}
|
|
|
|
/// Every `<id>.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`.
|
|
fn declaration_files() -> BTreeMap<String, String> {
|
|
let mut out = BTreeMap::new();
|
|
for entry in std::fs::read_dir(ops_dir()).expect("ops/ is readable") {
|
|
let path = entry.expect("a directory entry").path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
|
continue;
|
|
}
|
|
let stem = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.expect("a file name")
|
|
.to_string();
|
|
if stem.starts_with('_') {
|
|
continue;
|
|
}
|
|
out.insert(stem, std::fs::read_to_string(&path).expect("readable"));
|
|
}
|
|
assert!(!out.is_empty(), "ops/ declares no nodes");
|
|
out
|
|
}
|
|
|
|
/// Parse one declaration the way both backends do.
|
|
fn read(id: &str, text: &str) -> Node {
|
|
let library = builtin_helpers();
|
|
decl::read_node(text, &format!("ops/{id}.yaml"), &library.names())
|
|
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"))
|
|
}
|
|
|
|
/// The generated implementation of one node, taken out of the default chain.
|
|
///
|
|
/// Out of `chain()` rather than constructed by name, because that is how the
|
|
/// application gets one: if the two ever differed, the chain's version is the
|
|
/// one a photograph would be developed with.
|
|
fn generated(id: &str) -> Box<dyn Operation> {
|
|
let chain = ops::chain();
|
|
let index = chain
|
|
.iter()
|
|
.position(|o| o.descriptor().id.0 == id)
|
|
.unwrap_or_else(|| panic!("`{id}` is not in the default chain"));
|
|
let mut chain = chain;
|
|
chain.remove(index)
|
|
}
|
|
|
|
/// Values worth setting a parameter to, spanning its declared range.
|
|
///
|
|
/// Both ends, because that is where a rounding difference in a `min:` or a
|
|
/// `max:` would show; the default, because that is the neutral every
|
|
/// `is_active` is written against; and two interior points that are not
|
|
/// round numbers, because a value like 37.5 exercises the arithmetic in a way
|
|
/// 0 and 100 do not.
|
|
fn probe_values(kind: &ParamKind, default: f32) -> Vec<f32> {
|
|
match kind {
|
|
ParamKind::Scalar { min, max, .. } => {
|
|
let span = max - min;
|
|
vec![
|
|
default,
|
|
*min,
|
|
*max,
|
|
min + span * 0.375,
|
|
min + span * 0.8125,
|
|
// A value that is not representable as a short decimal, to
|
|
// catch a backend that round-trips a uniform through text.
|
|
min + span / 3.0,
|
|
]
|
|
}
|
|
ParamKind::Bool => vec![0.0, 1.0],
|
|
ParamKind::Enum { variants } => (0..variants.len()).map(|i| i as f32).collect(),
|
|
}
|
|
}
|
|
|
|
/// Put every parameter back where it started.
|
|
fn reset(op: &mut dyn Operation) {
|
|
let descriptor = op.descriptor();
|
|
for p in &descriptor.params {
|
|
op.set_param(p.id, p.default);
|
|
}
|
|
}
|
|
|
|
/// Assert the two operations are indistinguishable at their current settings.
|
|
fn assert_same(id: &str, setting: &str, generated: &dyn Operation, declared: &dyn Operation) {
|
|
let g = generated.descriptor();
|
|
let d = declared.descriptor();
|
|
assert_eq!(*g, *d, "{id} [{setting}]: the descriptors differ");
|
|
|
|
assert_eq!(
|
|
generated.is_active(),
|
|
declared.is_active(),
|
|
"{id} [{setting}]: the two disagree about whether the operation is doing anything"
|
|
);
|
|
|
|
assert_eq!(
|
|
generated.wgsl_body(),
|
|
declared.wgsl_body(),
|
|
"{id} [{setting}]: the fragment bodies differ"
|
|
);
|
|
|
|
assert_eq!(
|
|
generated.helpers(),
|
|
declared.helpers(),
|
|
"{id} [{setting}]: the helper sets differ"
|
|
);
|
|
|
|
let (gu, du) = (generated.uniforms(), declared.uniforms());
|
|
assert_eq!(
|
|
gu.len(),
|
|
du.len(),
|
|
"{id} [{setting}]: different numbers of uniforms"
|
|
);
|
|
for (a, b) in gu.iter().zip(&du) {
|
|
assert_eq!(
|
|
a.name, b.name,
|
|
"{id} [{setting}]: uniforms in a different order"
|
|
);
|
|
// Bits, not values: `assert_eq!` on `f32` would call two NaNs unequal
|
|
// and would call `0.0` and `-0.0` equal, and both of those are
|
|
// differences worth failing on.
|
|
assert_eq!(
|
|
a.value.to_bits(),
|
|
b.value.to_bits(),
|
|
"{id} [{setting}]: uniform `{}` is {} generated and {} declared",
|
|
a.name,
|
|
a.value,
|
|
b.value
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Assert two compositions are the same shader.
|
|
fn assert_same_shader(what: &str, a: &ComposedShader, b: &ComposedShader) {
|
|
// The source first, and compared as whole strings: a diff in the middle of
|
|
// several kilobytes of WGSL is unreadable as an assertion message, so the
|
|
// failure below points at the first differing line instead.
|
|
if a.source != b.source {
|
|
let line = a
|
|
.source
|
|
.lines()
|
|
.zip(b.source.lines())
|
|
.position(|(x, y)| x != y);
|
|
match line {
|
|
Some(n) => panic!(
|
|
"{what}: the composed WGSL differs at line {}:\n generated: {:?}\n declared: {:?}",
|
|
n + 1,
|
|
a.source.lines().nth(n).unwrap_or(""),
|
|
b.source.lines().nth(n).unwrap_or(""),
|
|
),
|
|
None => panic!(
|
|
"{what}: the composed WGSL differs in length: {} generated, {} declared",
|
|
a.source.len(),
|
|
b.source.len()
|
|
),
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
a.uniforms.len(),
|
|
b.uniforms.len(),
|
|
"{what}: different uniform block sizes"
|
|
);
|
|
for (i, (x, y)) in a.uniforms.iter().zip(&b.uniforms).enumerate() {
|
|
assert_eq!(
|
|
x.to_bits(),
|
|
y.to_bits(),
|
|
"{what}: uniform slot {i} is {x} generated and {y} declared"
|
|
);
|
|
}
|
|
|
|
// The hash is taken over the source, so it follows — but it is what the
|
|
// pipeline cache keys on, and asserting it says that a declared node and
|
|
// its generated twin would share a compiled pipeline rather than quietly
|
|
// splitting the cache in two.
|
|
assert_eq!(a.structure_hash, b.structure_hash, "{what}: structure hash");
|
|
assert_eq!(a.output_mode, b.output_mode, "{what}: output mode");
|
|
}
|
|
|
|
/// Compose a single operation, the way the display path composes a chain.
|
|
fn compose_one(op: Box<dyn Operation>) -> (ComposedShader, Box<dyn Operation>) {
|
|
let shader = compose(std::slice::from_ref(&op));
|
|
(shader, op)
|
|
}
|
|
|
|
/// TRACES: FR-PLG-2
|
|
/// Every declared built-in composes to the same shader either way.
|
|
#[test]
|
|
fn a_declaration_read_at_run_time_composes_byte_for_byte_as_the_generated_one() {
|
|
let library = builtin_helpers();
|
|
let mut checked = 0;
|
|
|
|
for (id, text) in declaration_files() {
|
|
let Node::Declared(declaration) = read(&id, &text) else {
|
|
continue;
|
|
};
|
|
let mut declared =
|
|
DeclaredOp::new(&declaration, library).unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"));
|
|
let mut generated = generated(&id);
|
|
|
|
// The neutral state first: it is the state every image starts in, and
|
|
// an operation that is inactive in one path and active in the other
|
|
// would put a whole fragment into one shader and not the other.
|
|
assert_same(&id, "neutral", generated.as_ref(), &declared);
|
|
|
|
let descriptor = declared.descriptor();
|
|
for p in &descriptor.params {
|
|
for value in probe_values(&p.kind, p.default) {
|
|
reset(generated.as_mut());
|
|
reset(&mut declared);
|
|
generated.set_param(p.id, value);
|
|
declared.set_param(p.id, value);
|
|
|
|
let setting = format!("{} = {value}", p.id);
|
|
assert_same(&id, &setting, generated.as_ref(), &declared);
|
|
|
|
let (gs, back) = compose_one(generated);
|
|
generated = back;
|
|
let (ds, _) = compose_one(Box::new(declared.clone()));
|
|
assert_same_shader(&format!("{id} [{setting}]"), &gs, &ds);
|
|
checked += 1;
|
|
}
|
|
}
|
|
|
|
// And every parameter moved at once, which is the only case that
|
|
// exercises the *order* uniforms are emitted in.
|
|
reset(generated.as_mut());
|
|
reset(&mut declared);
|
|
for p in &descriptor.params {
|
|
let value =
|
|
probe_values(&p.kind, p.default)[3.min(probe_values(&p.kind, p.default).len() - 1)];
|
|
generated.set_param(p.id, value);
|
|
declared.set_param(p.id, value);
|
|
}
|
|
assert_same(&id, "all parameters moved", generated.as_ref(), &declared);
|
|
let (gs, _) = compose_one(generated);
|
|
let (ds, _) = compose_one(Box::new(declared));
|
|
assert_same_shader(&format!("{id} [all parameters moved]"), &gs, &ds);
|
|
checked += 1;
|
|
}
|
|
|
|
// A test that silently checked nothing would pass forever. There are eight
|
|
// declared nodes and several settings each, so this is a floor rather than
|
|
// a count anybody has to maintain.
|
|
assert!(
|
|
checked > 20,
|
|
"only {checked} comparisons ran; the declarations were not found"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-PLG-2
|
|
/// The whole chain composes identically with the declared nodes swapped in.
|
|
///
|
|
/// The single-operation test above is the sharper one — it isolates each node
|
|
/// — but it cannot see an interaction. This composes the *default develop
|
|
/// chain*, with every declared node replaced by its interpreted twin and the
|
|
/// `rust:` nodes left alone, so it covers uniform slot ordering across
|
|
/// operations, helper de-duplication between them, and the order the fragments
|
|
/// land in the shader.
|
|
#[test]
|
|
fn the_whole_chain_composes_identically_with_interpreted_nodes() {
|
|
let library = builtin_helpers();
|
|
let files = declaration_files();
|
|
|
|
let mut generated_chain = ops::chain();
|
|
let mut declared_chain = ops::chain();
|
|
let mut swapped = 0;
|
|
|
|
for i in 0..declared_chain.len() {
|
|
let id = declared_chain[i].descriptor().id.0.to_string();
|
|
let text = files
|
|
.get(&id)
|
|
.unwrap_or_else(|| panic!("`{id}` is in the chain but has no ops/{id}.yaml"));
|
|
if let Node::Declared(declaration) = read(&id, text) {
|
|
declared_chain[i] = Box::new(
|
|
DeclaredOp::new(&declaration, library)
|
|
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")),
|
|
);
|
|
swapped += 1;
|
|
}
|
|
// Move every operation off neutral, declared or not, so the chain is
|
|
// not a list of fragments that were all omitted. A neutral chain
|
|
// composes to a shader with no operation blocks in it at all, which
|
|
// would make this test pass while asserting nothing.
|
|
let descriptor = generated_chain[i].descriptor();
|
|
for p in &descriptor.params {
|
|
let value = probe_values(&p.kind, p.default)[1];
|
|
generated_chain[i].set_param(p.id, value);
|
|
declared_chain[i].set_param(p.id, value);
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
swapped >= 8,
|
|
"only {swapped} nodes were swapped for declared ones"
|
|
);
|
|
assert_same_shader(
|
|
"the default chain",
|
|
&compose(&generated_chain),
|
|
&compose(&declared_chain),
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-PLG-2
|
|
/// Nothing in `ops/` escapes this file unnoticed.
|
|
///
|
|
/// The coverage guard. A declaration that stopped parsing, or a node that
|
|
/// quietly became `rust:`, would otherwise reduce what the parity test covers
|
|
/// without anything failing — which is exactly the silent divergence the whole
|
|
/// file exists to prevent.
|
|
#[test]
|
|
fn every_declared_node_is_checked() {
|
|
let files = declaration_files();
|
|
let mut declared = Vec::new();
|
|
let mut hand_written = Vec::new();
|
|
|
|
for (id, text) in &files {
|
|
match read(id, text) {
|
|
Node::Declared(_) => declared.push(id.clone()),
|
|
Node::Rust { ty, .. } => hand_written.push((id.clone(), ty)),
|
|
}
|
|
}
|
|
|
|
// Every file is one or the other, and the chain holds exactly them.
|
|
assert_eq!(declared.len() + hand_written.len(), files.len());
|
|
assert_eq!(
|
|
ops::DECLARED_IDS.len(),
|
|
files.len(),
|
|
"the chain and ops/ hold different numbers of nodes"
|
|
);
|
|
for id in ops::DECLARED_IDS {
|
|
assert!(
|
|
files.contains_key(*id),
|
|
"`{id}` is in the chain but not in ops/"
|
|
);
|
|
}
|
|
|
|
// Named rather than counted, so that a node changing sides is a failure
|
|
// somebody reads rather than a number they update.
|
|
let hand: Vec<&str> = hand_written.iter().map(|(id, _)| id.as_str()).collect();
|
|
assert_eq!(
|
|
hand,
|
|
[
|
|
"capture_sharpen",
|
|
"clarity",
|
|
"colour_mixer",
|
|
"dehaze",
|
|
"film_sim",
|
|
"noise_reduction",
|
|
"texture",
|
|
"tone_curve",
|
|
"view_transform",
|
|
"vignetting",
|
|
],
|
|
"the set of hand-written nodes changed; if that is deliberate, update \
|
|
this list and the module documentation above"
|
|
);
|
|
assert!(
|
|
declared.len() >= 8,
|
|
"only {} declared nodes: {declared:?}",
|
|
declared.len()
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-PLG-2
|
|
/// A declared operation is addressed by the ids the generated one uses.
|
|
///
|
|
/// The practical form of "indistinguishable downstream": the sidecar stores
|
|
/// parameters by `(op_id, param_id)` text, so a declared node whose interned
|
|
/// ids did not compare equal to the generated constants would load an edit
|
|
/// that silently did nothing.
|
|
#[test]
|
|
fn an_interpreted_node_answers_to_the_generated_parameter_ids() {
|
|
let mut declared = DeclaredOp::from_yaml(
|
|
&std::fs::read_to_string(ops_dir().join("exposure.yaml")).expect("readable"),
|
|
"ops/exposure.yaml",
|
|
builtin_helpers(),
|
|
)
|
|
.expect("exposure is a declaration");
|
|
|
|
declared.set_param(ops::exposure::EXPOSURE, 1.5);
|
|
assert_eq!(declared.param(ParamId("exposure")), 1.5);
|
|
assert_eq!(declared.descriptor().id, ops::exposure::ID);
|
|
}
|