diff --git a/crates/oak-node/src/nodes/ociogradingtransformlinear.rs b/crates/oak-node/src/nodes/ociogradingtransformlinear.rs index 84a7d423d..ed755ff93 100644 --- a/crates/oak-node/src/nodes/ociogradingtransformlinear.rs +++ b/crates/oak-node/src/nodes/ociogradingtransformlinear.rs @@ -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 { + 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::(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::(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] diff --git a/crates/oak-node/src/nodes/ociogradingtransformlog.rs b/crates/oak-node/src/nodes/ociogradingtransformlog.rs index cb96ccbb8..7027a9d99 100644 --- a/crates/oak-node/src/nodes/ociogradingtransformlog.rs +++ b/crates/oak-node/src/nodes/ociogradingtransformlog.rs @@ -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 { + 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::(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] diff --git a/crates/oak-render/src/color.rs b/crates/oak-render/src/color.rs index 5d0f7dd93..b2cdcc2fe 100644 --- a/crates/oak-render/src/color.rs +++ b/crates/oak-render/src/color.rs @@ -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>>> = + 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 { + 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 { + 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(); diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index 7f5110497..ec68b3805 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -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 { 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 { + 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, diff --git a/crates/oak-render/src/shaderfx.rs b/crates/oak-render/src/shaderfx.rs index 0586c9d6a..436a131e0 100644 --- a/crates/oak-render/src/shaderfx.rs +++ b/crates/oak-render/src/shaderfx.rs @@ -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)), + } } } }