`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
203 lines
7.1 KiB
Rust
203 lines
7.1 KiB
Rust
//! TRACES: FR-DEV-3
|
|
//! The tone curve's four curves, seen from outside the crate.
|
|
//!
|
|
//! The unit tests beside the operation assert what one `ToneCurve` does. These
|
|
//! assert the two properties that only show up once it is a node in a chain
|
|
//! with a sidecar under it: that a file written before the per-channel curves
|
|
//! existed still describes the edit it described, and that the three new
|
|
//! curves cost a photograph that does not use them precisely nothing.
|
|
|
|
use dr_pipeline::ops::curve::{self, Axis, Channel};
|
|
use dr_pipeline::{EditGraph, Sidecar};
|
|
|
|
/// A version block carrying `params`, in the on-disk spelling.
|
|
fn sidecar_with(params: &[(&str, f32)]) -> String {
|
|
let mut text =
|
|
String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
|
|
for (key, value) in params {
|
|
text.push_str(&format!("{key} = {value}\n"));
|
|
}
|
|
text
|
|
}
|
|
|
|
fn apply(text: &str) -> EditGraph {
|
|
let sidecar = Sidecar::parse(text).expect("a valid sidecar");
|
|
let mut graph = EditGraph::default_chain();
|
|
sidecar
|
|
.default_version()
|
|
.expect("a default version")
|
|
.apply(&mut graph);
|
|
graph
|
|
}
|
|
|
|
/// TRACES: FR-CAT-8
|
|
/// **The compatibility guarantee, end to end.**
|
|
///
|
|
/// An edit made before this build existed has to produce the same image now.
|
|
/// Asserted on the generated shader rather than on the parameter values,
|
|
/// because that is what the photograph is actually made of: same source, same
|
|
/// uniforms, same picture.
|
|
#[test]
|
|
fn an_edit_written_before_the_channel_curves_renders_as_it_did() {
|
|
let from_file = apply(&sidecar_with(&[
|
|
("tone_curve.p1_y", 0.15),
|
|
("tone_curve.p3_y", 0.85),
|
|
]));
|
|
|
|
let mut by_hand = EditGraph::default_chain();
|
|
by_hand.set_param(curve::ID, curve::P1_Y, 0.15);
|
|
by_hand.set_param(curve::ID, curve::P3_Y, 0.85);
|
|
|
|
let restored = from_file.compose();
|
|
let expected = by_hand.compose();
|
|
assert_eq!(restored.source, expected.source);
|
|
assert_eq!(restored.uniforms, expected.uniforms);
|
|
assert_eq!(restored.structure_hash, expected.structure_hash);
|
|
}
|
|
|
|
/// **Neutral means absent, and stays absent with four times as much to be
|
|
/// neutral about.**
|
|
///
|
|
/// The curve carries forty parameters now. An image edited with an S-curve and
|
|
/// nothing else must generate the shader it generated when it carried ten:
|
|
/// three untouched curves are not three identity evaluations, they are nothing
|
|
/// at all.
|
|
#[test]
|
|
fn three_untouched_curves_cost_nothing() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(curve::ID, curve::P2_Y, 0.62);
|
|
let shader = graph.compose();
|
|
|
|
assert!(
|
|
shader.source.contains("---- tone_curve ----"),
|
|
"the master curve must reach the shader"
|
|
);
|
|
assert!(
|
|
!shader.source.contains("channel_curve"),
|
|
"an untouched channel curve reached the shader:\n{}",
|
|
shader.source
|
|
);
|
|
for prefix in ["r_", "g_", "b_"] {
|
|
assert!(
|
|
!shader.source.contains(&format!("tone_curve_{prefix}")),
|
|
"an untouched channel curve declared uniforms:\n{}",
|
|
shader.source
|
|
);
|
|
}
|
|
}
|
|
|
|
/// And with none of them touched, the operation is not there at all — the
|
|
/// property `a_fresh_graph_is_neutral` asserts for the chain, restated for the
|
|
/// node that just quadrupled in size.
|
|
#[test]
|
|
fn a_curve_at_its_defaults_is_absent_from_the_shader() {
|
|
let graph = EditGraph::default_chain();
|
|
assert!(graph.is_neutral());
|
|
assert!(!graph.compose().source.contains("tone_curve"));
|
|
|
|
// Including when every one of the forty parameters has been explicitly
|
|
// written to its own default, which is what a sidecar round trip through
|
|
// a build with a different idea of "default" would produce.
|
|
let mut written = EditGraph::default_chain();
|
|
for cap in written.capabilities() {
|
|
if cap.id != curve::ID {
|
|
continue;
|
|
}
|
|
for p in &cap.params {
|
|
written.set_param(curve::ID, p.id, p.default);
|
|
}
|
|
}
|
|
assert!(written.is_neutral());
|
|
}
|
|
|
|
/// A grade with no tonal work is a real edit, and it must not drag the
|
|
/// luminance path in behind it.
|
|
#[test]
|
|
fn a_channel_curve_reaches_the_shader_on_its_own() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(
|
|
curve::ID,
|
|
curve::coordinate(Channel::Blue, 0, Axis::Y),
|
|
0.08,
|
|
);
|
|
|
|
let shader = graph.compose();
|
|
assert!(shader.source.contains("c.b = channel_curve(c.b,"));
|
|
assert!(
|
|
!shader.source.contains("apply_tone_gain"),
|
|
"the identity master curve reached the shader:\n{}",
|
|
shader.source
|
|
);
|
|
// Blue's ten points, and nothing else: the other two curves are at the
|
|
// identity and contribute no slot to the uniform block.
|
|
for prefix in ["r_", "g_"] {
|
|
assert!(
|
|
!shader.source.contains(&format!("tone_curve_{prefix}")),
|
|
"an untouched channel curve declared uniforms:\n{}",
|
|
shader.source
|
|
);
|
|
}
|
|
for point in 0..curve::POINTS {
|
|
assert!(
|
|
shader.source.contains(&format!("tone_curve_b_x{point}")),
|
|
"blue's point {point} is missing from the uniform block"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Each curve is its own shader, so the pipeline cache cannot hand the red
|
|
/// curve's compiled program to an edit that moved the green one.
|
|
#[test]
|
|
fn every_curve_generates_a_distinct_shader() {
|
|
let mut hashes: Vec<u64> = Vec::new();
|
|
for channel in Channel::ALL {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.62);
|
|
hashes.push(graph.compose().structure_hash);
|
|
}
|
|
let before = hashes.len();
|
|
hashes.sort_unstable();
|
|
hashes.dedup();
|
|
assert_eq!(before, hashes.len(), "two curves share a compiled shader");
|
|
}
|
|
|
|
/// The forty parameters all persist, and the file names each one once.
|
|
#[test]
|
|
fn every_point_of_every_curve_round_trips_through_a_sidecar() {
|
|
let mut graph = EditGraph::default_chain();
|
|
// A different y per coordinate, so a point wired to the wrong channel
|
|
// cannot pass by holding the value it was supposed to hold anyway.
|
|
// Thirty-secondths because they survive both the decimal the file is
|
|
// written in and the binary32 it is read back into exactly — this test is
|
|
// about which parameter a value lands in, and a rounding difference here
|
|
// would fail it for an unrelated reason.
|
|
let mut step = 1;
|
|
for channel in Channel::ALL {
|
|
for point in 0..curve::POINTS {
|
|
graph.set_param(
|
|
curve::ID,
|
|
curve::coordinate(channel, point, Axis::Y),
|
|
step as f32 / 32.0,
|
|
);
|
|
step += 1;
|
|
}
|
|
}
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(dr_pipeline::sidecar::Version::from_graph(
|
|
"u1", "Default", &graph,
|
|
));
|
|
let restored = apply(&sidecar.to_text());
|
|
|
|
for channel in Channel::ALL {
|
|
for point in 0..curve::POINTS {
|
|
let id = curve::coordinate(channel, point, Axis::Y);
|
|
assert_eq!(
|
|
restored.param(curve::ID, id),
|
|
graph.param(curve::ID, id),
|
|
"{id} did not survive the sidecar"
|
|
);
|
|
}
|
|
}
|
|
}
|