nodes: real OCIO color grading (linear + log) on the GPU

The OCIO grading nodes previously pushed null texture handles; they now
push real ShaderJobPayloads whose GLSL is the OCIO-generated dynamic
grading-primary GPU shader — the exact code the C++ path applies, so no
approximation:

- color: grading_primary_function_shader(style) builds a dynamic
  GradingPrimaryTransform (LIN/LOG) on the default config, extracts the
  GLSL via GpuShaderDesc (function 'ove_grading_primary', resource
  prefix ocio_, no LUT textures) and caches it per style + config id.

- eval: OCIO_GRADING_STUBS maps the two node type ids to the grading
  style; process_shader_job resolves the stub and splices it into the
  node's %1 marker (same wiring as the chromakey OCIO stub); the
  pipeline cache key folds the stub text so a config change recompiles.

- nodes: value() pushes a ShaderJobPayload with the C++ value()
  rewrite applied to the row — vec4 (RGBM x=master) grading inputs to
  the vec3 GPU uniform form (lin: contrast RGB=c*m, offset RGB=c+m,
  exposure RGB=2^(c+m); log: lift RGB=c+m, gain c*m, gamma c*m), plus
  pivot/saturation floats, the log pivotBlack/pivotWhite normalization
  range (0/1), clamp sentinels (NoClampBlack -1 / NoClampWhite 2),
  white>black enforcement per frame, and localBypass=false. Generated
  uniform names bind by name (the log node's OCIO_NAMESPACE_ id text
  normalizes to the ocio_ resource prefix).

- Tests: grading stub generation (analytic GLSL, cache) in color,
  end-to-end GPU exposure doubling for lin (+1 stop on 0.2 gray -> 0.4)
  and lift for log, node payload rewrite assertions, and the
  all-shaders sweep now retries grading stubs. oak-render 181,
  oak-node 441, oak-app 272 lib tests pass.
This commit is contained in:
2026-09-02 20:04:55 +08:00
parent b06c4fbbb6
commit 49fed365a4
5 changed files with 871 additions and 145 deletions
@@ -82,6 +82,68 @@ pub struct OCIOGradingTransformLinearNode {
base: OcioBase,
}
/// Fragment shader. The `%1` marker is replaced at request time with the
/// OCIO-generated grading-primary GLSL stub for the linear style (the
/// renderer's OCIO_GRADING_STUBS entry; the stub declares
/// the `ocio_grading_primary_*` uniforms and `ove_grading_primary`), and
/// the body applies it to the sampled input. The uniform names match the
/// grading input ids, so the rewritten params row binds by name — the
/// C++ job's `values` assigned to the GPU uniforms by name-match the same
/// way.
const SHADER_FRAG: &str = r#"// Main texture input
uniform sampler2D tex_in;
// Main texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
// Program will replace this with OCIO's auto-generated shader code
%1
void main() {
vec4 col = texture(tex_in, ove_texcoord);
frag_color = ove_grading_primary(col);
}
"#;
/// Row value for `id` (the traverser convention), else the node's own
/// standard/keyframe value (C++ `GetValueAtTime` parity for direct
/// calls).
fn row_or_standard(
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
id: &str,
time: oak_core::Rational,
) -> crate::value::NodeValue {
match inputs.get(id) {
Some(v) => v.clone(),
None => core.value_at_time(id, -1, time),
}
}
/// A vec4 input value as `[x, y, z, w]` (x = master per the RGBM
/// convention), collecting a generic numeric value into a padded array.
fn to_vec4(v: crate::value::NodeValue) -> [f64; 4] {
match v {
crate::value::NodeValue::Vec4(v) => v,
crate::value::NodeValue::Vec3(v) => [v[0], v[1], v[2], 0.0],
crate::value::NodeValue::Vec2(v) => [v[0], v[1], 0.0, 0.0],
crate::value::NodeValue::Float(v) => [v, v, v, v],
_ => [0.0; 4],
}
}
/// The per-channel vec3 the OCIO GPU uniforms expect: `rgb[i] =
/// f(channel[i], master)`.
fn channel_merge(v: [f64; 4], f: impl Fn(f64, f64) -> f64) -> crate::value::NodeValue {
let m = v[0];
crate::value::NodeValue::Vec3([
f(v[1], m),
f(v[2], m),
f(v[3], m),
])
}
/// Set or replace an input property (C++ `set_input_property`).
fn set_input_property(core: &mut NodeCore, input: &str, key: &str, value: crate::value::NodeValue) {
if let Some(input) = core.get_input_mut(input) {
@@ -291,15 +353,20 @@ impl NodeBehavior for OCIOGradingTransformLinearNode {
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// processor not ready -> push nothing (unlike the base, there is no
/// pass-through branch). Otherwise builds a `ColorTransformJob` from
/// the whole input row and rewrites the vec4 (RGBM: x = master)
/// inputs into the vec3 form the GPU uniforms expect: offset RGB =
/// channel + master; exposure RGB = 2^(channel + master); contrast
/// RGB = channel * master. Disabled clamps are pushed as
/// `GradingPrimary::NoClampBlack()/NoClampWhite()`, and when both
/// clamps are enabled the white clamp is raised to black + 0.000001
/// per frame if keyframed/connected values violate white > black.
/// otherwise pushes a REAL [`ShaderJobPayload`] whose params carry the
/// input row rewritten into the vec3 form the OCIO-generated GPU
/// uniforms expect — the C++ job's `values` rewrite, applied to the
/// uniform names the renderer's grading stub declares:
/// `ocio_grading_primary_contrast` RGB = channel * master, `offset`
/// RGB = channel + master, `exposure` RGB = 2^(channel + master).
/// Disabled clamps are pushed as `GradingPrimary::NoClampBlack()`
/// (-1.0) / `NoClampWhite()` (2.0), and when both clamps are enabled
/// the white clamp is raised to black + 0.000001 per frame if
/// keyframed/connected values violate white > black. The transform
/// itself is the OCIO shader the renderer splices in — the processor
/// is generated at render time from the default config, so the
/// evaluation-time guard (C++ processor gate) is replaced by the
/// node's own presence check on the input texture.
fn value(
&self,
core: &NodeCore,
@@ -307,33 +374,97 @@ impl NodeBehavior for OCIOGradingTransformLinearNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
if self.base.processor().is_some() {
// `// CPP-PARITY: ociogradingtransformlinear.cpp`
// `value()` — the C++ builds a ColorTransformJob and
// rewrites the vec4 (RGBM: x = master) inputs into the
// vec3 GPU uniform form: offset RGB = channel +
// master, exposure RGB = 2^(channel + master),
// contrast RGB = channel * master. Disabled clamps
// are pushed as `ocio::GradingPrimary::NoClampBlack()`
// (-1.0) / `NoClampWhite()` (2.0), and when both
// clamps are enabled the white clamp is raised to
// black + 0.000001 per frame if keyframed/connected
// values violate white > black. The Rust model has no
// color-transform job payload: the renderer seam
// resolves the deferred job from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
// Processor not ready: push nothing (no pass-through).
}
_ => {}
let _ = self;
if !matches!(
inputs.get(crate::nodes::ociobase::TEXTURE_INPUT),
Some(crate::value::NodeValue::Texture(_))
) {
return;
}
let mut params = inputs.clone();
// vec4 (RGBM: x = master) inputs -> the vec3 GPU uniform form.
let contrast = to_vec4(row_or_standard(core, inputs, CONTRAST_INPUT, time));
let offset = to_vec4(row_or_standard(core, inputs, OFFSET_INPUT, time));
let exposure = to_vec4(row_or_standard(core, inputs, EXPOSURE_INPUT, time));
params.insert(
CONTRAST_INPUT.to_string(),
channel_merge(contrast, |c, m| c * m),
);
params.insert(
OFFSET_INPUT.to_string(),
channel_merge(offset, |c, m| c + m),
);
params.insert(
EXPOSURE_INPUT.to_string(),
channel_merge(exposure, |c, m| 2f64.powf(c + m)),
);
// Scalar uniforms (the IDs are the generated uniform names).
params.insert(
SATURATION_INPUT.to_string(),
row_or_standard(core, inputs, SATURATION_INPUT, time),
);
params.insert(
PIVOT_INPUT.to_string(),
row_or_standard(core, inputs, PIVOT_INPUT, time),
);
// Clamps: enabled -> the value, disabled -> the OCIO sentinels
// (NoClampBlack -1 / NoClampWhite 2 keep the GPU clamp a no-op).
let black_enabled = row_or_standard(core, inputs, CLAMP_BLACK_ENABLE_INPUT, time)
.to_double() != 0.0;
let white_enabled = row_or_standard(core, inputs, CLAMP_WHITE_ENABLE_INPUT, time)
.to_double() != 0.0;
let mut black =
row_or_standard(core, inputs, CLAMP_BLACK_INPUT, time).to_double();
let mut white =
row_or_standard(core, inputs, CLAMP_WHITE_INPUT, time).to_double();
if black_enabled && white_enabled {
// ocio::GradingPrimary::validate: white > black (per frame,
// when the static UI minimum cannot follow animated values).
white = white.max(black + 0.000001);
}
params.insert(
CLAMP_BLACK_INPUT.to_string(),
crate::value::NodeValue::Float(if black_enabled { black } else { -1.0 }),
);
params.insert(
CLAMP_WHITE_INPUT.to_string(),
crate::value::NodeValue::Float(if white_enabled { white } else { 2.0 }),
);
params.insert(
"ocio_grading_primary_localBypass".to_string(),
crate::value::NodeValue::Boolean(false),
);
let job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload {
node_id: crate::id::NodeId::INVALID,
time,
iterations: 1,
type_id: self.type_id().to_string(),
shader_id: "rgb".to_string(),
effect_input: crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
params,
iterative_input: String::new(),
});
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(job),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): reads the fragment
/// shader and replaces every `%1` marker with `request` — the OCIO
/// auto-generated grading-primary shader text (C++ `GetShaderCode`
/// receives the stub from the renderer's color context; the Rust
/// renderer resolves it from the `OCIO_GRADING_STUBS` table before
/// calling, see [`crate::nodes::ociogradingtransformlog`]'s sibling
/// wiring).
fn shader_code(&self, request: &str) -> Option<String> {
Some(SHADER_FRAG.replace("%1", request))
}
/// Added to a graph (C++ base `AddedToGraphEvent`): captures the
@@ -767,31 +898,116 @@ mod tests {
}
#[test]
fn value_texture_without_processor_pushes_nothing() {
// Unlike the OCIO base, grading has no pass-through branch.
let core = NodeCore::new();
let n = node();
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
fn value_texture_pushes_real_grading_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
(CONTRAST_INPUT.to_string(), NodeValue::Vec4([0.5, 0.25, 0.5, 0.75])),
(OFFSET_INPUT.to_string(), NodeValue::Vec4([0.25, -0.125, 0.0, 0.125])),
(EXPOSURE_INPUT.to_string(), NodeValue::Vec4([1.0, 0.0, 0.0, 0.0])),
]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let tex = table.get(ValueType::Texture).expect("texture pushed");
let NodeValue::Texture(handle) = tex else {
panic!("pushed value is not a texture handle");
};
let job = unsafe {
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle)
}
.expect("real shader job");
assert_eq!(job.shader_id, "rgb");
assert_eq!(
job.type_id,
"org.olivevideoeditor.Olive.ociogradingtransformlinear"
);
assert_eq!(job.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
// vec4 (RGBM: x = master) rewrites: contrast RGB = c*m, offset
// RGB = c+m, exposure RGB = 2^(c+m).
assert_eq!(
job.params.get(CONTRAST_INPUT),
Some(&NodeValue::Vec3([0.125, 0.25, 0.375]))
);
assert_eq!(
job.params.get(OFFSET_INPUT),
Some(&NodeValue::Vec3([0.125, 0.25, 0.375]))
);
assert_eq!(
job.params.get(EXPOSURE_INPUT),
Some(&NodeValue::Vec3([2.0, 2.0, 2.0]))
);
// Scalar defaults from the node's standard values.
assert_eq!(
job.params.get(SATURATION_INPUT),
Some(&NodeValue::Float(1.0))
);
assert_eq!(job.params.get(PIVOT_INPUT), Some(&NodeValue::Float(0.18)));
// Clamps disabled -> OCIO sentinels (NoClampBlack/NoClampWhite).
assert_eq!(
job.params.get(CLAMP_BLACK_INPUT),
Some(&NodeValue::Float(-1.0))
);
assert_eq!(
job.params.get(CLAMP_WHITE_INPUT),
Some(&NodeValue::Float(2.0))
);
assert_eq!(
job.params.get("ocio_grading_primary_localBypass"),
Some(&NodeValue::Boolean(false))
);
}
#[test]
fn value_texture_with_processor_pushes_deferred_job() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
fn value_enables_clamp_when_flagged_of_white_and_black() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
(
CLAMP_BLACK_ENABLE_INPUT.to_string(),
NodeValue::Boolean(true),
),
(
CLAMP_WHITE_ENABLE_INPUT.to_string(),
NodeValue::Boolean(true),
),
(CLAMP_BLACK_INPUT.to_string(), NodeValue::Float(0.5)),
(CLAMP_WHITE_INPUT.to_string(), NodeValue::Float(0.4)),
]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let tex = table.get(ValueType::Texture).expect("texture pushed");
let NodeValue::Texture(handle) = tex else {
panic!("pushed value is not a texture handle");
};
let job = unsafe {
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle)
}
.expect("real shader job");
// White <= black is raised to black + 0.000001.
assert_eq!(
job.params.get(CLAMP_BLACK_INPUT),
Some(&NodeValue::Float(0.5))
);
assert_eq!(
job.params.get(CLAMP_WHITE_INPUT),
Some(&NodeValue::Float(0.500001))
);
}
#[test]
fn shader_code_splices_stub_marker() {
let n = node();
let stub = "vec4 ove_grading_primary(vec4 c) { return c; }";
let code = n.shader_code(stub).unwrap();
assert!(!code.contains("%1"));
assert!(code.contains("ove_grading_primary(col)"));
assert!(code.contains(stub));
}
#[test]
@@ -93,6 +93,76 @@ pub struct OCIOGradingTransformLogNode {
base: OcioBase,
}
/// Fragment shader. The `%1` marker is replaced at request time with the
/// OCIO-generated grading-primary GLSL stub for the log style (the
/// renderer's OCIO_GRADING_STUBS entry; the stub declares
/// the `ocio_grading_primary_*` uniforms and `ove_grading_primary`), and
/// the body applies it to the sampled input.
const SHADER_FRAG: &str = r#"// Main texture input
uniform sampler2D tex_in;
// Main texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
// Program will replace this with OCIO's auto-generated shader code
%1
void main() {
vec4 col = texture(tex_in, ove_texcoord);
frag_color = ove_grading_primary(col);
}
"#;
/// The generated OCIO uniform name for an input id: the C++ log node's
/// ids carry the literal `OCIO_NAMESPACE_` macro text while the
/// generated GPU shader uses the `ocio_` resource prefix, so the prefix
/// is normalized at params-build time.
fn uniform_name(id: &str) -> String {
match id.strip_prefix("OCIO_NAMESPACE_") {
Some(rest) => format!("ocio_{rest}"),
None => id.to_string(),
}
}
/// Row value for `id` (the traverser convention), else the node's own
/// standard/keyframe value (C++ `GetValueAtTime` parity for direct
/// calls).
fn row_or_standard(
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
id: &str,
time: oak_core::Rational,
) -> crate::value::NodeValue {
match inputs.get(id) {
Some(v) => v.clone(),
None => core.value_at_time(id, -1, time),
}
}
/// A vec4 input value as `[x, y, z, w]` (x = master per the RGBM
/// convention), collecting a generic numeric value into a padded array.
fn to_vec4(v: crate::value::NodeValue) -> [f64; 4] {
match v {
crate::value::NodeValue::Vec4(v) => v,
crate::value::NodeValue::Vec3(v) => [v[0], v[1], v[2], 0.0],
crate::value::NodeValue::Vec2(v) => [v[0], v[1], 0.0, 0.0],
crate::value::NodeValue::Float(v) => [v, v, v, v],
_ => [0.0; 4],
}
}
/// The per-channel vec3 the OCIO GPU uniforms expect: `rgb[i] =
/// f(channel[i], master)`.
fn channel_merge(v: [f64; 4], f: impl Fn(f64, f64) -> f64) -> crate::value::NodeValue {
let m = v[0];
crate::value::NodeValue::Vec3([
f(v[1], m),
f(v[2], m),
f(v[3], m),
])
}
/// Set or replace an input property (C++ `set_input_property`).
fn set_input_property(core: &mut NodeCore, input: &str, key: &str, value: crate::value::NodeValue) {
if let Some(input) = core.get_input_mut(input) {
@@ -302,15 +372,21 @@ impl NodeBehavior for OCIOGradingTransformLogNode {
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// processor not ready -> push nothing (no pass-through branch).
/// Otherwise builds a `ColorTransformJob` from the whole input row
/// and rewrites the vec4 (RGBM: x = master) inputs into the vec3
/// form the GPU uniforms expect: lift RGB = channel + master
/// (additive); gain and gamma RGB = channel * master
/// otherwise pushes a REAL [`ShaderJobPayload`] whose params carry the
/// input row rewritten into the vec3 form the OCIO-generated GPU
/// uniforms expect — under the generated uniform names (`ocio_`
/// prefix, the input ids' `OCIO_NAMESPACE_` text normalized):
/// lift/brightness RGB = channel + master (additive), gain/contrast
/// RGB = channel * master, gamma RGB = channel * master
/// (multiplicative). Disabled clamps are pushed as
/// `GradingPrimary::NoClampBlack()/NoClampWhite()`, and when both
/// clamps are enabled the white clamp is raised to black + 0.000001
/// per frame if keyframed/connected values violate white > black.
/// `GradingPrimary::NoClampBlack()` (-1.0) / `NoClampWhite()` (2.0),
/// and when both clamps are enabled the white clamp is raised to
/// black + 0.000001 per frame if keyframed/connected values violate
/// white > black. The transform itself is the OCIO shader the
/// renderer splices in — the processor is generated at render time
/// from the default config, so the evaluation-time guard (C++
/// processor gate) is replaced by the node's own presence check on
/// the input texture.
fn value(
&self,
core: &NodeCore,
@@ -318,33 +394,107 @@ impl NodeBehavior for OCIOGradingTransformLogNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
if self.base.processor().is_some() {
// `// CPP-PARITY: ociogradingtransformlog.cpp`
// `value()` — the C++ builds a ColorTransformJob and
// rewrites the vec4 (RGBM: x = master) inputs into the
// vec3 GPU uniform form: lift RGB = channel + master
// (additive); gain and gamma RGB = channel * master
// (multiplicative). Disabled clamps are pushed as
// `OCIO_NAMESPACE::GradingPrimary::NoClampBlack()`
// (-1.0) / `NoClampWhite()` (2.0), and when both
// clamps are enabled the white clamp is raised to
// black + 0.000001 per frame if keyframed/connected
// values violate white > black. The Rust model has no
// color-transform job payload: the renderer seam
// resolves the deferred job from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
// Processor not ready: push nothing (no pass-through).
}
_ => {}
let _ = self;
if !matches!(
inputs.get(crate::nodes::ociobase::TEXTURE_INPUT),
Some(crate::value::NodeValue::Texture(_))
) {
return;
}
let mut params = inputs.clone();
// vec4 (RGBM: x = master) inputs -> the vec3 GPU uniform form
// (normalized to the generated `ocio_` names).
let lift = to_vec4(row_or_standard(core, inputs, LIFT_INPUT, time));
let gain = to_vec4(row_or_standard(core, inputs, GAIN_INPUT, time));
let gamma = to_vec4(row_or_standard(core, inputs, GAMMA_INPUT, time));
params.insert(
uniform_name(LIFT_INPUT),
channel_merge(lift, |c, m| c + m),
);
params.insert(
uniform_name(GAIN_INPUT),
channel_merge(gain, |c, m| c * m),
);
params.insert(
uniform_name(GAMMA_INPUT),
channel_merge(gamma, |c, m| c * m),
);
// Scalar uniforms. pivotBlack/pivotWhite are the log-log-scaled
// normalization range (identity at gamma = 1 with the defaults
// 0/1); the pivot default (-0.2) is the GRADING_LOG pivot.
params.insert(
uniform_name(SATURATION_INPUT),
row_or_standard(core, inputs, SATURATION_INPUT, time),
);
params.insert(
uniform_name(PIVOT_INPUT),
row_or_standard(core, inputs, PIVOT_INPUT, time),
);
params.insert(
uniform_name("OCIO_NAMESPACE_grading_primary_pivotBlack"),
crate::value::NodeValue::Float(0.0),
);
params.insert(
uniform_name("OCIO_NAMESPACE_grading_primary_pivotWhite"),
crate::value::NodeValue::Float(1.0),
);
// Clamps: enabled -> the value, disabled -> the OCIO sentinels
// (NoClampBlack -1 / NoClampWhite 2 keep the GPU clamp a no-op).
let black_enabled = row_or_standard(core, inputs, CLAMP_BLACK_ENABLE_INPUT, time)
.to_double() != 0.0;
let white_enabled = row_or_standard(core, inputs, CLAMP_WHITE_ENABLE_INPUT, time)
.to_double() != 0.0;
let mut black =
row_or_standard(core, inputs, CLAMP_BLACK_INPUT, time).to_double();
let mut white =
row_or_standard(core, inputs, CLAMP_WHITE_INPUT, time).to_double();
if black_enabled && white_enabled {
// ocio::GradingPrimary::validate: white > black (per frame,
// when the static UI minimum cannot follow animated values).
white = white.max(black + 0.000001);
}
params.insert(
uniform_name(CLAMP_BLACK_INPUT),
crate::value::NodeValue::Float(if black_enabled { black } else { -1.0 }),
);
params.insert(
uniform_name(CLAMP_WHITE_INPUT),
crate::value::NodeValue::Float(if white_enabled { white } else { 2.0 }),
);
params.insert(
"ocio_grading_primary_localBypass".to_string(),
crate::value::NodeValue::Boolean(false),
);
let job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload {
node_id: crate::id::NodeId::INVALID,
time,
iterations: 1,
type_id: self.type_id().to_string(),
shader_id: "rgb".to_string(),
effect_input: crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
params,
iterative_input: String::new(),
});
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(job),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): reads the fragment
/// shader and replaces every `%1` marker with `request` — the OCIO
/// auto-generated grading-primary shader text (the renderer resolves
/// it from the OCIO_GRADING_STUBS table before calling; this node's
/// type id matches the C++ dtype, which carries the unexpanded
/// OCIO_NAMESPACE macro text).
fn shader_code(&self, request: &str) -> Option<String> {
Some(SHADER_FRAG.replace("%1", request))
}
/// Added to a graph (C++ base `AddedToGraphEvent`): captures the
@@ -735,30 +885,88 @@ mod tests {
}
#[test]
fn value_texture_without_processor_pushes_nothing() {
let core = NodeCore::new();
let n = node();
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
fn value_texture_pushes_real_grading_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
(LIFT_INPUT.to_string(), NodeValue::Vec4([0.25, -0.125, 0.0, 0.125])),
(GAIN_INPUT.to_string(), NodeValue::Vec4([0.5, 0.25, 0.5, 0.75])),
(GAMMA_INPUT.to_string(), NodeValue::Vec4([2.0, 1.0, 1.5, 2.0])),
]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let tex = table.get(ValueType::Texture).expect("texture pushed");
let NodeValue::Texture(handle) = tex else {
panic!("pushed value is not a texture handle");
};
let job = unsafe {
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle)
}
.expect("real shader job");
assert_eq!(job.shader_id, "rgb");
assert_eq!(
job.type_id,
"org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog"
);
assert_eq!(job.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
// vec4 (RGBM: x = master) rewrites under the generated `ocio_`
// uniform names: lift RGB = c+m, gain/gamma RGB = c*m.
assert_eq!(
job.params.get("ocio_grading_primary_brightness"),
Some(&NodeValue::Vec3([0.125, 0.25, 0.375]))
);
assert_eq!(
job.params.get("ocio_grading_primary_contrast"),
Some(&NodeValue::Vec3([0.125, 0.25, 0.375]))
);
assert_eq!(
job.params.get("ocio_grading_primary_gamma"),
Some(&NodeValue::Vec3([2.0, 3.0, 4.0]))
);
// Scalar defaults from the node's standard values (pivot -0.2 for
// the log style) plus the log normalization range.
assert_eq!(
job.params.get("ocio_grading_primary_saturation"),
Some(&NodeValue::Float(1.0))
);
assert_eq!(
job.params.get("ocio_grading_primary_pivot"),
Some(&NodeValue::Float(-0.2))
);
assert_eq!(
job.params.get("ocio_grading_primary_pivotBlack"),
Some(&NodeValue::Float(0.0))
);
assert_eq!(
job.params.get("ocio_grading_primary_pivotWhite"),
Some(&NodeValue::Float(1.0))
);
// Clamps disabled -> OCIO sentinels (NoClampBlack/NoClampWhite).
assert_eq!(
job.params.get("ocio_grading_primary_clampBlack"),
Some(&NodeValue::Float(-1.0))
);
assert_eq!(
job.params.get("ocio_grading_primary_clampWhite"),
Some(&NodeValue::Float(2.0))
);
assert_eq!(
job.params.get("ocio_grading_primary_localBypass"),
Some(&NodeValue::Boolean(false))
);
}
#[test]
fn value_texture_with_processor_pushes_deferred_job() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
fn shader_code_splices_stub_marker() {
let n = node();
let stub = "vec4 ove_grading_primary(vec4 c) { return c; }";
let code = n.shader_code(stub).unwrap();
assert!(!code.contains("%1"));
assert!(code.contains("ove_grading_primary(col)"));
assert!(code.contains(stub));
}
#[test]
+78
View File
@@ -675,6 +675,64 @@ fn build_ocio_function_shader(
desc.shader_text()
}
/// Cache of generated grading GLSL stubs, keyed by style + config cache id
/// (same shape as [`OCIO_STUB_CACHE`]; grading is analytic so the result is
/// deterministic per key).
static OCIO_GRADING_CACHE: LazyLock<Mutex<HashMap<String, Option<String>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// The GLSL text of the dynamic grading-primary processor for `style`
/// (C++ oakrender creates the same processor via
/// `oakrender_color_processor_create_grading_primary` and the renderer
/// applies it between passes; the Rust equivalent generates the OCIO GPU
/// shader the node splices at its `%1` marker). `None` when no default
/// config exists or the processor is LUT-based (the same guard as
/// [`ocio_function_shader`] — grading is analytic, so this is purely a
/// config-availability check).
pub fn grading_primary_function_shader(style: GradingStyle) -> Option<String> {
let config = default_config()?;
let style_id = match style {
GradingStyle::Lin => "lin",
GradingStyle::Log => "log",
};
let cache_key = format!("{style_id}:{}", config.cache_id().unwrap_or_default());
if let Some(hit) = OCIO_GRADING_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&cache_key)
{
return hit.clone();
}
let stub = build_ocio_grading_shader(&config, style);
OCIO_GRADING_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(cache_key, stub.clone());
stub
}
/// Build (but do not cache) the grading GLSL stub: a dynamic
/// grading-primary transform on `config`, extracted exactly like
/// [`build_ocio_function_shader`].
fn build_ocio_grading_shader(config: &SafeConfig, style: GradingStyle) -> Option<String> {
let transform = ocio_rs::transform::GradingPrimaryTransform::create(style.to_ocio()).ok()?;
transform.make_dynamic();
transform.set_direction(ocio_rs::TransformDirection::Forward);
let processor = config
.processor_from_transform(&transform, ocio_rs::TransformDirection::Forward)
.ok()?;
let gpu = processor.default_gpu_processor().ok()?;
let mut desc = ocio_rs::GpuShaderDesc::create().ok()?;
desc.set_language(ocio_rs::GpuLanguage::GlslEs3_0).ok()?;
desc.set_function_name("ove_grading_primary").ok()?;
desc.set_resource_prefix("ocio_").ok()?;
gpu.try_extract_shader_info(&mut desc).ok()?;
if desc.num_textures() > 0 || desc.num_3d_textures() > 0 {
return None;
}
desc.shader_text()
}
// ---- LUT library (C++ LUTLibrary) ------------------------------------------
/// Supported LUT extensions (C++ `LUTLibrary::supported_extensions()`).
@@ -1098,6 +1156,26 @@ mod tests {
}
}
#[test]
fn grading_primary_shader_generates_analytic_glsl() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return; // Bundled OCIO missing (e.g. stub build): skip.
}
for style in [GradingStyle::Lin, GradingStyle::Log] {
let stub = grading_primary_function_shader(style)
.expect("default config generates an analytic grading shader");
assert!(
stub.contains("ove_grading_primary"),
"function name present"
);
assert!(!stub.contains("sampler"), "no LUT upload expected for grading");
// Cache hit: a repeated call returns the same text.
let again = grading_primary_function_shader(style).unwrap();
assert_eq!(stub, again);
}
}
#[test]
fn create_lut_missing_file_is_invalid() {
let _lock = config_lock();
+57 -15
View File
@@ -60,6 +60,22 @@ pub const OCIO_SHADER_STUBS: &[(&str, &str, &str, &str)] = &[(
"cie_xyz_d65_interchange",
)];
/// Static mapping of the OCIO grading nodes to the grading-primary style
/// whose dynamic GPU shader they splice at their `%1` marker (C++
/// `oakrender_color_processor_create_grading_primary` with the node's
/// `GRADING_LIN`/`GRADING_LOG` style; the processor is built against the
/// default config at render time).
pub const OCIO_GRADING_STUBS: &[(&str, crate::color::GradingStyle)] = &[
(
"org.olivevideoeditor.Olive.ociogradingtransformlinear",
crate::color::GradingStyle::Lin,
),
(
"org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog",
crate::color::GradingStyle::Log,
),
];
/// The OCIO GPU function shader for `type_id` (the `%1` stub), or `None`
/// when the node is not OCIO-based or the processor cannot be generated
/// (no default config, or a LUT processor the renderer cannot upload).
@@ -71,6 +87,16 @@ pub fn ocio_stub_for(type_id: &str) -> Option<String> {
crate::color::ocio_function_shader(fn_name, from, to)
}
/// The OCIO grading GPU shader for `type_id` (the `%1` stub), or `None`
/// when the node is not a grading node or no default config is set up.
pub fn grading_stub_for(type_id: &str) -> Option<String> {
let (_, style) = OCIO_GRADING_STUBS
.iter()
.find(|(id, _)| *id == type_id)
.copied()?;
crate::color::grading_primary_function_shader(style)
}
/// Job specification: the closed set of C++ `*Job` payloads
/// (AcceleratedJob family) as internal evaluation records — jobs no
/// longer travel inside values across module boundaries.
@@ -573,8 +599,24 @@ impl RenderEvalHooks {
.iter()
.find(|(id, ..)| *id == payload.type_id)
.copied();
let glsl = match ocio_entry {
Some((_, fn_name, from, to)) => {
let grading_entry = OCIO_GRADING_STUBS
.iter()
.find(|(id, _)| *id == payload.type_id)
.copied();
let glsl = match (grading_entry, ocio_entry) {
(Some((_, style)), _) => {
let Some(stub) = crate::color::grading_primary_function_shader(style) else {
return None;
};
match behavior.shader_code(&stub) {
Some(glsl) => glsl,
None => {
warn("shader not found");
return None;
}
}
}
(None, Some((_, fn_name, from, to))) => {
let Some(stub) = crate::color::ocio_function_shader(fn_name, from, to) else {
return None;
};
@@ -586,7 +628,7 @@ impl RenderEvalHooks {
}
}
}
None => match behavior.shader_code(&payload.shader_id) {
(None, None) => match behavior.shader_code(&payload.shader_id) {
Some(glsl) => glsl,
None => {
warn("shader not found");
@@ -598,18 +640,18 @@ impl RenderEvalHooks {
// Pipeline cache key: the type id plus the shader-variant id (the
// OCIO stub text folds in too, so a config change recompiles
// instead of reusing a stale variant).
let key = match ocio_entry {
Some(_) => {
let mut h = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&glsl, &mut h);
format!(
"{}:{}:ocio:{}",
payload.type_id,
payload.shader_id,
std::hash::Hasher::finish(&h)
)
}
None => format!("{}:{}", payload.type_id, payload.shader_id),
let spliced_ocio = grading_entry.is_some() || ocio_entry.is_some();
let key = if spliced_ocio {
let mut h = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&glsl, &mut h);
format!(
"{}:{}:ocio:{}",
payload.type_id,
payload.shader_id,
std::hash::Hasher::finish(&h)
)
} else {
format!("{}:{}", payload.type_id, payload.shader_id)
};
let compiled = match compile_effect(&ctx, &key, &glsl, ctx.is_filterable()) {
Ok(effect) => effect,
+203 -21
View File
@@ -1064,6 +1064,179 @@ void main() { frag_color = texture(tex_in, ove_texcoord); }
ctx.destroy_texture(dst);
}
/// The linear grading node's OCIO-spliced shader doubles gray under
/// +1 stop of master exposure (2^1 * 0.2 = 0.4) and gates the
/// transform behind the localBypass uniform off.
#[test]
fn gpu_grading_linear_applies_exposure() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let Some(stub) = crate::eval::grading_stub_for(
"org.olivevideoeditor.Olive.ociogradingtransformlinear",
) else {
eprintln!("no OCIO config; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.ociogradingtransformlinear")
.unwrap();
let glsl = behavior.shader_code(&stub).unwrap();
let effect = compile_effect(&ctx, "test/grading-lin", &glsl, false).unwrap();
let src = ctx.create_texture(8, 4).unwrap();
let dst = ctx.create_texture(8, 4).unwrap();
let gray = crate::shaderfx::tests::f32_frame(8, 4, |_| [0.2, 0.2, 0.2, 1.0]);
ctx.upload(src, &gray).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert(
"ocio_grading_primary_exposure".into(),
oak_node::value::NodeValue::Vec3([2.0, 2.0, 2.0]),
);
row.insert(
"ocio_grading_primary_contrast".into(),
oak_node::value::NodeValue::Vec3([1.0, 1.0, 1.0]),
);
row.insert(
"ocio_grading_primary_offset".into(),
oak_node::value::NodeValue::Vec3([0.0, 0.0, 0.0]),
);
row.insert(
"ocio_grading_primary_saturation".into(),
oak_node::value::NodeValue::Float(1.0),
);
row.insert(
"ocio_grading_primary_pivot".into(),
oak_node::value::NodeValue::Float(0.18),
);
row.insert(
"ocio_grading_primary_clampBlack".into(),
oak_node::value::NodeValue::Float(-1.0),
);
row.insert(
"ocio_grading_primary_clampWhite".into(),
oak_node::value::NodeValue::Float(2.0),
);
row.insert(
"ocio_grading_primary_localBypass".into(),
oak_node::value::NodeValue::Boolean(false),
);
run_effect(
&ctx,
&effect,
&row,
&[("tex_in".to_string(), src)],
dst,
(8, 4),
1,
)
.unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..8usize {
let got = pixel(&out, px);
let want = [0.4f32, 0.4, 0.4, 1.0];
assert!(
(got[0] - want[0]).abs() < 1e-3,
"px {px}: exposure must double gray, got {got:?}"
);
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The log grading node's OCIO-spliced shader: brightness (lift) of
/// +0.1 shifts 0.2 gray to 0.3 with the identity gain/gamma/pivot
/// defaults.
#[test]
fn gpu_grading_log_applies_lift() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let Some(stub) = crate::eval::grading_stub_for(
"org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog",
) else {
eprintln!("no OCIO config; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog")
.unwrap();
let glsl = behavior.shader_code(&stub).unwrap();
let effect = compile_effect(&ctx, "test/grading-log", &glsl, false).unwrap();
let src = ctx.create_texture(8, 4).unwrap();
let dst = ctx.create_texture(8, 4).unwrap();
let gray = crate::shaderfx::tests::f32_frame(8, 4, |_| [0.2, 0.2, 0.2, 1.0]);
ctx.upload(src, &gray).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert(
"ocio_grading_primary_brightness".into(),
oak_node::value::NodeValue::Vec3([0.1, 0.1, 0.1]),
);
row.insert(
"ocio_grading_primary_contrast".into(),
oak_node::value::NodeValue::Vec3([1.0, 1.0, 1.0]),
);
row.insert(
"ocio_grading_primary_gamma".into(),
oak_node::value::NodeValue::Vec3([1.0, 1.0, 1.0]),
);
row.insert(
"ocio_grading_primary_saturation".into(),
oak_node::value::NodeValue::Float(1.0),
);
row.insert(
"ocio_grading_primary_pivot".into(),
oak_node::value::NodeValue::Float(-0.2),
);
row.insert(
"ocio_grading_primary_pivotBlack".into(),
oak_node::value::NodeValue::Float(0.0),
);
row.insert(
"ocio_grading_primary_pivotWhite".into(),
oak_node::value::NodeValue::Float(1.0),
);
row.insert(
"ocio_grading_primary_clampBlack".into(),
oak_node::value::NodeValue::Float(-1.0),
);
row.insert(
"ocio_grading_primary_clampWhite".into(),
oak_node::value::NodeValue::Float(2.0),
);
row.insert(
"ocio_grading_primary_localBypass".into(),
oak_node::value::NodeValue::Boolean(false),
);
run_effect(
&ctx,
&effect,
&row,
&[("tex_in".to_string(), src)],
dst,
(8, 4),
1,
)
.unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..8usize {
let got = pixel(&out, px);
assert!(
(got[0] - 0.3).abs() < 1e-3,
"px {px}: lift must shift gray, got {got:?}"
);
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// Every registered node type that ships a shader must translate (the
/// all-shaders sweep). OCIO-stubbed shaders (`%1` markers needing the
/// OCIO-generated function text) retry with the real OCIO stub first;
@@ -1089,29 +1262,38 @@ void main() { frag_color = texture(tex_in, ove_texcoord); }
// OCIO config is available (stub build), fall back to
// a pass-through function so the sweep still covers
// the node's own shader body.
let retried = crate::eval::ocio_stub_for(meta.type_id)
.or_else(|| {
crate::eval::OCIO_SHADER_STUBS
.iter()
.find(|(id, ..)| *id == meta.type_id)
.map(|(_, fn_name, ..)| {
format!("vec4 {fn_name}(vec4 c) {{ return c; }}")
})
});
match retried {
Some(stub) => match translate(&behavior.shader_code(&stub).unwrap()) {
Ok(_) => ok.push(meta.type_id),
Err(e) => {
failed.push((meta.type_id, format!("with OCIO stub: {e:?}")))
}
},
None if msg.contains("SceneLinear") || msg.contains("UnknownFunction") => {
// Not in the stub table but still OCIO-shaped:
// report separately, not as a regression.
ocio_stubbed.push(meta.type_id);
let retried = crate::eval::ocio_stub_for(meta.type_id)
.or_else(|| {
crate::eval::OCIO_SHADER_STUBS
.iter()
.find(|(id, ..)| *id == meta.type_id)
.map(|(_, fn_name, ..)| {
format!("vec4 {fn_name}(vec4 c) {{ return c; }}")
})
})
.or_else(|| crate::eval::grading_stub_for(meta.type_id))
.or_else(|| {
crate::eval::OCIO_GRADING_STUBS
.iter()
.find(|(id, _)| *id == meta.type_id)
.map(|_| "vec4 ove_grading_primary(vec4 c) { return c; }".to_string())
});
match retried {
Some(stub) => match translate(&behavior.shader_code(&stub).unwrap()) {
Ok(_) => ok.push(meta.type_id),
Err(e) => {
failed.push((meta.type_id, format!("with OCIO stub: {e:?}")))
}
None => failed.push((meta.type_id, msg)),
},
None if msg.contains("SceneLinear")
|| msg.contains("UnknownFunction")
|| msg.contains("ove_grading_primary") => {
// Not in the stub table but still OCIO-shaped:
// report separately, not as a regression.
ocio_stubbed.push(meta.type_id);
}
None => failed.push((meta.type_id, msg)),
}
}
}
}