`Version::update` is the write path an automatic save goes through. It copied the parameters and the masks and said nothing about the film, so a photograph developed on a stock was written back without it and opened the next time without its emulsion. Nothing reported a failure — the line was simply not there. It is the third of the three routines that captured "the edit" and the only one that got it wrong, which is the argument for not having three. All of them now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot disagree about what an edit consists of, and the next part of one cannot be lost by anybody writing a line too few. Two tests, both of which fail without the fix: the field survives `update`, and the stock survives the round trip through the file. `apply` returns the `FilmRebake` it always implicitly owed, so `apply_version` now reads the debt off the call rather than off `version.film` — and pays it in both directions, since a version with no film has to clear the adjust pass too or it keeps textures bound that nothing will sample. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
204 lines
7.2 KiB
Rust
204 lines
7.2 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)
|
|
.expect_no_film();
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
}
|