render: real texture binding, generator layers and iteration feedback in shader passes

- process_shader_job: bind all texture params by name, recurse into nested
  shader payloads (depth cap 8), fall back to frame size without inputs
- run_effect: take iterative_input so dropshadow previous_iteration_in works
- merge: actually composite inputs; keyer mask, opacity modulation, math
  texture ops and mrg generator layers now bind their textures
- transform distort: real fragment-side inverse-matrix sampling
- time offset / time remap: wire NodeBehavior time adjustment hooks
- plugin: fix first-node identity colliding with unbound sentinel
This commit is contained in:
2026-09-09 16:33:29 +08:00
parent cde4dddf32
commit 5ab12b937f
28 changed files with 1157 additions and 434 deletions
+8 -2
View File
@@ -337,6 +337,7 @@ impl NodeBehavior for ClipBlockBehavior {
/// in-point. Other inputs pass through unchanged.
fn input_time_adjustment(
&self,
_core: &NodeCore,
input: &str,
_element: i32,
time: TimeRange,
@@ -666,7 +667,9 @@ mod tests {
#[test]
fn clip_input_time_adjustment_maps_timeline_to_media() {
let time = TimeRange::new(Rational::new(12, 1), Rational::new(13, 1));
let map = |c: &ClipBlockBehavior| c.input_time_adjustment(clip_input::TEXTURE_INPUT, -1, time, false);
let map = |c: &ClipBlockBehavior| {
c.input_time_adjustment(&NodeCore::new(), clip_input::TEXTURE_INPUT, -1, time, false)
};
// Speed 1: media = (12 - 10) + 5 = 7.
assert_eq!(
@@ -695,7 +698,10 @@ mod tests {
TimeRange::new(Rational::new(11, 1), Rational::new(12, 1))
);
// Non-`tex_in` inputs pass through untouched.
assert_eq!(clip_with(2.0, true).input_time_adjustment("other_in", -1, time, false), time);
assert_eq!(
clip_with(2.0, true).input_time_adjustment(&NodeCore::new(), "other_in", -1, time, false),
time
);
}
/// The clip copies the connected `tex_in` texture to its output;
+17 -3
View File
@@ -27,12 +27,16 @@ use crate::nodes::plugin::PluginJobPayload;
use crate::value::NodeValueRow;
/// Job types
///
/// The payloads travel boxed inside `Texture` values during graph
/// evaluation (the renderer probes the box's payload type at resolve
/// time); the graph-shaped job model lands in v0.6.
pub enum Job{
FootageJob(FootageJobPayload),
ShaderJob(ShaderJobPayload),
PluginJob(PluginJobPayload),
ColorTransformJob
ColorTransformJob(ColorTransformJobPayload)
}
/// C++ `FootageJob` payload: the decode request a footage node emits at
@@ -77,9 +81,19 @@ pub struct ShaderJobPayload {
pub iterative_input: String,
}
/// C++ `ColorTransformJob` payload: the OCIO processor application a
/// color node emits at its output. The processor is shared by `Arc` —
/// the node keeps its cached processor while in-flight jobs reference
/// the same immutable instance. The input texture value rides along
/// (C++ `t->to_job(job)` wraps the texture the job applies to).
#[derive(Clone)]
pub struct ColorTransformJobPayload{
pub color_processor: ColorProcessor,
/// The OCIO processor to apply (C++ `ColorTransformJob::processor`).
pub color_processor: std::sync::Arc<ColorProcessor>,
/// The input texture value (C++ the texture `to_job` was called on).
pub input: crate::value::NodeValue,
/// Request time in media seconds.
pub time: Rational,
}
impl Default for FootageJobPayload {
+7 -3
View File
@@ -553,27 +553,31 @@ pub trait NodeBehavior: Send {
/// Time adjustment through this node (C++
/// `input_time_adjustment()`/`output_time_adjustment()`; clips
/// override for speed/reverse).
/// override for speed/reverse, the time nodes for offset/remap).
/// `core` is the node's own data — the C++ implementations read
/// member inputs (e.g. the offset/remap value) at call time.
fn input_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
let _ = (core, input, element, traverse);
time
}
/// Output-side time adjustment.
fn output_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
let _ = (core, input, element, traverse);
time
}
+17 -5
View File
@@ -673,13 +673,25 @@ mod tests {
}
#[test]
fn value_gaussian_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(10.0));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
fn value_gaussian_pushes_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(RADIUS_INPUT.to_string(), NodeValue::Float(10.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.blur");
assert_eq!(
payload.params.get(RADIUS_INPUT),
Some(&NodeValue::Float(10.0)),
"the blur radius rides in the job params"
);
}
#[test]
@@ -617,7 +617,16 @@ mod tests {
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// A corner off its default (y-only is enough — C++ `is_null()`
// requires BOTH components zero) pushes the job; a pass-through
// would push the input's own (null) handle instead.
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
assert!(
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }.is_some(),
"a moved corner must push the cornerpin shader job"
);
}
#[test]
+12 -4
View File
@@ -368,17 +368,25 @@ mod tests {
}
#[test]
fn value_pushes_deferred_job_with_processor() {
fn value_pushes_color_transform_job_with_processor() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let n = node();
n.base.set_processor(Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
)));
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());
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
panic!("job row expected");
};
assert!(unsafe {
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
}
.is_some());
}
#[test]
+29
View File
@@ -258,6 +258,35 @@ fn matrix_rotate_z(m: [f64; 16], degrees: f64) -> [f64; 16] {
matrix_mul(m, r)
}
/// Invert a 2D affine transform (C++ `Matrix4x4::inverted` on the 2D
/// path). The matrices this crate generates are affine —
/// `[a b 0 tx; c d 0 ty; 0 0 sz 0; 0 0 0 1]` in the row-major
/// `m[r*4+c]` layout — so the inverse is analytic. `None` when the
/// affine part is singular (a zero scale collapses the plane; the C++
/// callers fall back to identity there).
pub fn matrix_invert_2d(m: [f64; 16]) -> Option<[f64; 16]> {
let (a, b, tx) = (m[0], m[1], m[3]);
let (c, d, ty) = (m[4], m[5], m[7]);
let det = a * d - b * c;
if det.abs() < 1e-12 {
return None;
}
let inv = 1.0 / det;
let (ia, ib, ic, id) = (d * inv, -b * inv, -c * inv, a * inv);
let mut out = super::mathbase::identity_matrix();
out[0] = ia;
out[1] = ib;
out[3] = -(ia * tx + ib * ty);
out[4] = ic;
out[5] = id;
out[7] = -(ic * tx + id * ty);
let sz = m[10];
if sz.abs() > 1e-12 {
out[10] = 1.0 / sz;
}
Some(out)
}
/// Resolve a vec2 input from the render row or the keyframed/standard
/// value (C++ `value.at(id).to_vec2()`; missing values read as
/// `(0, 0)`).
+75 -56
View File
@@ -49,60 +49,60 @@ pub const TEXTURE_INPUT: &str = "tex_in";
/// generation helpers reach the manager through
/// `crate::colormanager`/oakrender at call time.
pub struct OcioBase {
/// Owned color processor handle (C++ `processor_`, an
/// `OakColorProcessor`); `None`/empty while no valid processor has
/// been generated. Released with the node (C++ destructor calls
/// `oakrender_color_processor_free`).
processor: Option<crate::handle::CHandle>,
/// Owned color processor (C++ `processor_`, an `OakColorProcessor`),
/// shared by `Arc` because the [`crate::jobs::ColorTransformJobPayload`]
/// emitted at evaluation time carries the same immutable processor.
/// `None` while no valid processor has been generated. Released with
/// the node (C++ destructor calls `oakrender_color_processor_free`).
///
/// Behind a mutex because the C++ regenerates the processor from
/// `value()`-time paths (`ensure_processor()`'s mutable-in-const-
/// method pattern), so interior mutability is required.
processor: std::sync::Mutex<Option<std::sync::Arc<oak_core::color::ColorProcessor>>>,
}
// The processor handle wraps a refcounted C object that is only
// dereferenced from the render path (the C++ base likewise passes its
// `OakColorProcessor` across threads by value); moving the struct
// between threads does not introduce sharing the C++ side does not
// already have.
unsafe impl Send for OcioBase {}
impl OcioBase {
/// Construct the shared base state (C++ `OCIOBaseNode::OCIOBaseNode()`):
/// the processor starts empty; the constructor side that adds
/// [`TEXTURE_INPUT`], marks it the effect input and sets the
/// video-effect flag happens in each node's `create()`.
pub fn new() -> Self {
OcioBase { processor: None }
OcioBase {
processor: std::sync::Mutex::new(None),
}
}
/// Borrowed view of the owned processor handle (C++
/// Shared reference to the owned processor (C++
/// `OCIOBaseNode::processor()`; callers must NOT free it).
pub fn processor(&self) -> Option<&crate::handle::CHandle> {
self.processor.as_ref()
pub fn processor(&self) -> Option<std::sync::Arc<oak_core::color::ColorProcessor>> {
self.processor
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Take ownership of a new processor handle, releasing the old one
/// Take ownership of a new processor, releasing the old one
/// (C++ `OCIOBaseNode::set_processor()`, which frees the previous
/// `OakColorProcessor` before storing the new one).
pub fn set_processor(
&mut self,
processor: Option<crate::handle::CHandle>,
&self,
processor: Option<std::sync::Arc<oak_core::color::ColorProcessor>>,
) {
// The C++ frees the previous processor via
// `oakrender_color_processor_free`; the Rust handle is a
// refcounted `CHandle` released on drop, so replacing the field
// drops the old one automatically.
self.processor = processor;
// `oakrender_color_processor_free`; the Rust processor is an
// `Arc` released on drop, so replacing the field drops the old
// one automatically once any in-flight jobs release it.
*self.processor.lock().unwrap_or_else(|e| e.into_inner()) = processor;
}
/// Shared output evaluation (C++ `OCIOBaseNode::value()`): no texture
/// on [`TEXTURE_INPUT`] -> push nothing; texture present and
/// processor ready -> push a `ColorTransformJob` built from the
/// processor and the input texture; texture present but processor not
/// ready (e.g. still being generated asynchronously) -> pass the
/// input texture through unchanged.
///
/// The Rust model has no color-transform job payload: the ready case
/// pushes a null texture handle marking a renderer-deferred job
/// (the C++ `t->to_job(ColorTransformJob)` resolved by the renderer
/// via the processor); the not-ready case pushes the input texture.
/// processor ready -> push a boxed
/// [`crate::jobs::ColorTransformJobPayload`] built from the processor
/// and the input texture (the C++ `t->to_job(ColorTransformJob)`,
/// resolved by the render seam via the processor); texture present
/// but processor not ready (e.g. still being generated
/// asynchronously) -> pass the input texture through unchanged.
/// `// CPP-PARITY: ociobase.cpp` `value()`.
pub fn value(
&self,
@@ -111,20 +111,23 @@ impl OcioBase {
time: oak_core::Rational,
table: &mut NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(tex @ NodeValue::Texture(_)) => {
if self.processor.is_some() {
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
}
}
_ => {}
let _ = core;
let Some(tex @ NodeValue::Texture(_)) = inputs.get(TEXTURE_INPUT) else {
return;
};
match self.processor() {
Some(processor) => table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::make_owned(
crate::jobs::ColorTransformJobPayload {
color_processor: processor,
input: tex.clone(),
time,
},
)),
None,
),
None => table.push(crate::value::ValueType::Texture, tex.clone(), None),
}
}
@@ -156,9 +159,11 @@ mod tests {
#[test]
fn processor_state_transitions() {
let mut base = OcioBase::new();
let base = OcioBase::new();
assert!(base.processor().is_none());
base.set_processor(Some(crate::handle::CHandle::null()));
base.set_processor(Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
)));
assert!(base.processor().is_some());
base.set_processor(None);
assert!(base.processor().is_none());
@@ -194,21 +199,35 @@ mod tests {
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let mut base = OcioBase::new();
base.set_processor(Some(crate::handle::CHandle::null()));
fn value_pushes_color_transform_job_with_processor() {
let base = OcioBase::new();
base.set_processor(Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
)));
let mut table = NodeValueTable::default();
let inputs = NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let tex = NodeValue::Texture(crate::handle::make_owned(1u8));
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
base.value(
&NodeCore::new(),
&inputs,
oak_core::Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
// The ready case pushes the boxed ColorTransformJobPayload (the
// C++ `t->to_job(ColorTransformJob)`).
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
panic!("job row expected");
};
let payload = unsafe {
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
}
.expect("a boxed ColorTransformJobPayload");
assert!(std::sync::Arc::ptr_eq(
&payload.color_processor,
&base.processor().unwrap()
));
assert_eq!(payload.input, tex);
assert_eq!(payload.time, oak_core::Rational::new(0, 1));
}
#[test]
+101 -43
View File
@@ -56,8 +56,8 @@ struct ProcessorState {
/// `last_direction_`, starts `-1`).
last_direction: i64,
/// Cached processor for change detection (C++ `last_processor_`);
/// released with the node.
last_processor: Option<crate::handle::CHandle>,
/// shared with the base's active processor slot.
last_processor: Option<std::sync::Arc<oak_core::color::ColorProcessor>>,
/// Human-readable reason no LUT processor is active (C++
/// `last_error_`); empty when a valid processor is in use or no LUT
/// file has been selected yet.
@@ -77,10 +77,6 @@ impl Default for ProcessorState {
}
}
// The cached processor handle wraps a refcounted C object that is only
// dereferenced from the render path; see `OcioBase` for the rationale.
unsafe impl Send for ProcessorState {}
/// OCIO LUT node. Applies a LUT file through OpenColorIO.
pub struct OCIOLutNode {
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
@@ -175,7 +171,7 @@ impl OCIOLutNode {
{
let state = self.state.lock().unwrap();
if !state.dirty
&& state.last_processor.as_ref().is_some_and(|p| !p.is_null())
&& state.last_processor.is_some()
&& Self::file_path(core) == state.last_path
&& Self::read_direction_input(core) == state.last_direction
{
@@ -186,34 +182,63 @@ impl OCIOLutNode {
}
/// Rebuild the processor from the LUT path and direction (C++
/// `create_processor_from_inputs()`): no manager, empty path,
/// non-regular file, or unsupported extension -> clear both
/// processors, reset the cache markers, record the error, and return
/// false; unchanged path+direction with a live cached processor ->
/// clear the dirty flag and return false (reuse); otherwise create
/// the LUT processor via `oakrender_color_processor_create_lut`
/// (direction 0 = forward), update the cache markers and both
/// processor slots, and return true.
/// `create_processor_from_inputs()`): empty path, non-regular file,
/// or a failed OCIO FileTransform -> clear both processor slots,
/// reset the cache markers, record the error, and return false;
/// otherwise create the LUT processor via
/// [`oak_core::color::ColorProcessor::create_lut`] on the process-wide
/// default config (direction 0 = forward), update the cache markers
/// and both processor slots, and return true. A missing default
/// config yields `None` from `create_lut`, landing in the error
/// branch (the node then passes its input through unchanged).
/// `// CPP-PARITY: ociolut.cpp` `create_processor_from_inputs()`.
fn create_processor_from_inputs(&self, core: &NodeCore) -> bool {
let _ = core;
let mut state = self.state.lock().unwrap();
let path = Self::file_path(core);
let direction = Self::read_direction_input(core);
// C++ branch 1: no color manager. The Rust model reaches the
// manager through the oakrender bridge (absent here), so this
// branch is always taken: reset the cache markers and report
// false. The C++ additionally clears the standard processor and
// frees `last_processor_` — the Rust base processor is only
// reachable through `&mut self` and can never hold a processor
// without the render bridge, so those clears are no-ops here.
// The empty-path/non-regular-file/unsupported-extension error
// branches are unreachable without a manager and are not
// representable. `// CPP-PARITY: ociolut.cpp`
// create_processor_from_inputs.
state.last_processor = None;
state.last_path.clear();
state.last_direction = -1;
state.dirty = false;
false
// Reset both processor slots and the cache markers, recording
// `error` (empty = no error: no LUT selected yet).
let clear = |node: &Self, error: String| {
let mut state = node.state.lock().unwrap();
state.last_processor = None;
state.last_path.clear();
state.last_direction = -1;
state.dirty = false;
state.last_error = error;
node.base.set_processor(None);
false
};
if path.is_empty() {
return clear(self, String::new());
}
if !std::path::Path::new(&path).is_file() {
return clear(self, format!("LUT file not found: {path}"));
}
let dir = if direction == 1 {
oak_core::color::Direction::Inverse
} else {
oak_core::color::Direction::Normal
};
match oak_core::color::ColorProcessor::create_lut(&path, dir) {
Some(processor) => {
let processor = std::sync::Arc::new(processor);
{
let mut state = self.state.lock().unwrap();
state.last_processor = Some(processor.clone());
state.last_path = path;
state.last_direction = direction;
state.dirty = false;
state.last_error.clear();
}
self.base.set_processor(Some(processor));
true
}
None => clear(
self,
format!("Failed to create a LUT processor from {path}"),
),
}
}
/// OCIO config change hook (C++ `config_changed()` override):
@@ -506,7 +531,7 @@ mod tests {
}
#[test]
fn create_processor_from_inputs_resets_markers_without_manager() {
fn create_processor_from_inputs_resets_markers_without_lut() {
let n = node();
let core = NodeCore::new();
{
@@ -514,9 +539,13 @@ mod tests {
state.dirty = true;
state.last_path = "/tmp/foo.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(crate::handle::CHandle::null());
state.last_processor = Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
));
state.last_error = "stale error".to_string();
}
// No FILE_INPUT on the core -> empty path branch: markers reset
// and the stale error clears (no LUT selected is not an error).
let created = n.create_processor_from_inputs(&core);
assert!(!created);
let state = n.state.lock().unwrap();
@@ -524,8 +553,7 @@ mod tests {
assert_eq!(state.last_path, "");
assert_eq!(state.last_direction, -1);
assert!(state.last_processor.is_none());
// The no-manager branch does not touch the recorded error (C++).
assert_eq!(state.last_error, "stale error");
assert_eq!(state.last_error, "");
}
#[test]
@@ -600,7 +628,9 @@ mod tests {
state.dirty = false;
state.last_path = "/tmp/x.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(crate::handle::make_owned::<u8>(1));
state.last_processor = Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
));
}
// Unchanged path+direction with a live processor: early return,
// markers are preserved (create_processor_from_inputs would have
@@ -660,17 +690,45 @@ mod tests {
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
fn value_pushes_color_transform_job_with_processor() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
FILE_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text(String::new()),
));
core.add_input(crate::input::Input::new(
DIRECTION_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
core.set_standard_value(FILE_INPUT, -1, NodeValue::Text("/tmp/x.cube".into()));
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Combo(1));
let n = node();
let processor = std::sync::Arc::new(oak_core::color::ColorProcessor::pass_through());
{
// Prime the cache so value()'s ensure_processor early-returns
// (unchanged path/direction, live cached processor).
let mut state = n.state.lock().unwrap();
state.dirty = false;
state.last_path = "/tmp/x.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(processor.clone());
}
n.base.set_processor(Some(processor));
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());
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
panic!("job row expected");
};
assert!(unsafe {
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
}
.is_some());
}
#[test]
+31 -6
View File
@@ -308,7 +308,7 @@ mod tests {
}
#[test]
fn value_opacity_scaled_pushes_job_placeholder() {
fn value_opacity_scaled_pushes_shader_job() {
let (mut core, behavior) = create();
core.set_standard_value(VALUE_INPUT, -1, NodeValue::Float(0.5));
let inputs = crate::value::NodeValueRow::from([(
@@ -317,11 +317,20 @@ mod tests {
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// The scaled-opacity path pushes the default-variant shader job
// (a pass-through would push the input's null handle).
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.opacity");
assert_eq!(payload.shader_id, "");
assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
fn value_opacity_in_row_scaled_pushes_job_placeholder() {
fn value_opacity_in_row_scaled_pushes_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
@@ -332,7 +341,17 @@ mod tests {
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.opacity");
assert_eq!(
payload.params.get(VALUE_INPUT),
Some(&NodeValue::Float(0.5)),
"the opacity value rides in the job params"
);
}
#[test]
@@ -349,7 +368,7 @@ mod tests {
}
#[test]
fn value_texture_opacity_pushes_rgbmult_placeholder() {
fn value_texture_opacity_pushes_rgbmult_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
@@ -363,7 +382,13 @@ mod tests {
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// A texture opacity input selects the rgbmult shader variant.
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.shader_id, "rgbmult");
}
#[test]
+16 -6
View File
@@ -487,21 +487,31 @@ mod tests {
}
#[test]
fn value_resolves_source_clip_first() {
fn value_pushes_job_for_source_clip_only_input() {
// Gate: a plugin with only the simple-source clip connected still
// emits its job (the C++ resolution tries `Source` before
// `tex_in`). With no texture at all, nothing is pushed.
let n = node();
let core = NodeCore::new();
let mut row = NodeValueRow::default();
row.insert(
SOURCE_CLIP.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
row.insert(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_))));
assert!(
matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(h)) if !h.is_null()),
"a source-clip-only row must still produce the plugin job"
);
let mut table = NodeValueTable::default();
n.value(&core, &NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(
table.get(ValueType::Texture).is_none(),
"no texture anywhere -> no job"
);
}
#[test]
+20 -2
View File
@@ -459,7 +459,7 @@ mod tests {
}
#[test]
fn value_with_base_merges() {
fn value_with_base_pushes_merge_job_with_nested_blend() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
super::super::generatorwithmerge::BASE_INPUT.to_string(),
@@ -467,7 +467,25 @@ mod tests {
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// The base-merge path pushes the "mrg" alpha-over job whose
// blend_in is the generator's own "rgb" job nested as a texture
// value (same shape as shapenode's merge test).
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let mrg = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("merge job pushed");
assert_eq!(mrg.shader_id, "mrg");
assert_eq!(mrg.effect_input, super::super::generatorwithmerge::BASE_INPUT);
assert!(mrg.params.contains_key(super::super::generatorwithmerge::BASE_INPUT));
let blend = match mrg.params.get(crate::nodes::merge::BLEND_INPUT) {
Some(NodeValue::Texture(b)) => *b,
other => panic!("blend_in must carry the nested job: {other:?}"),
};
let blend_job = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(&blend) }
.expect("nested generator job");
assert_eq!(blend_job.shader_id, "rgb");
assert_eq!(blend_job.type_id, "org.olivevideoeditor.Olive.polygon");
}
#[test]
+10 -17
View File
@@ -66,9 +66,9 @@ impl TimeOffsetNode {
/// `time_in` value evaluated at that endpoint; all other inputs fall
/// through to the base-class identity behavior.
///
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
/// `NodeCore`, so this value-resolving variant is what render-time call
/// sites (and the tests) use; the trait method documents that gap.
/// The [`NodeBehavior::input_time_adjustment`] trait method receives the
/// core and delegates here; kept as an associated fn so the tests can
/// drive the mapping without a behavior instance.
pub fn input_time_adjustment_with(
core: &NodeCore,
input: &str,
@@ -146,42 +146,35 @@ impl NodeBehavior for TimeOffsetNode {
/// `input_in`, both ends of the range are shifted forward by the current
/// `time_in` value (C++ `get_remapped_time()`: `input + time_in`);
/// all other inputs fall through to the base-class identity behavior.
///
/// The C++ evaluation reads the keyframable `time_in` input, which
/// requires the node's data ([`NodeCore`]) — not carried by this trait
/// signature. The exact remap is ported in
/// [`Self::input_time_adjustment_with`] (and tested there); until the
/// adjustment API gains core access, the identity range is returned
/// `core` carries the node's data (the keyframable `time_in` input),
/// matching the C++ member read
/// (`// CPP-PARITY: timeoffsetnode.cpp` `input_time_adjustment`).
fn input_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
Self::input_time_adjustment_with(core, input, element, time, traverse)
}
/// Output-side time remap (C++ `output_time_adjustment()`): the exact
/// inverse of the input adjustment — for `input_in`, both ends of the
/// range are shifted back by subtracting the `time_in` value (C++
/// `get_remapped_output_time()`: `input - time_in`); all other inputs
/// fall through to the base-class identity behavior.
///
/// As with the input side, the value read needs the node's data; the
/// exact remap is ported in [`Self::output_time_adjustment_with`]
/// fall through to the base-class identity behavior
/// (`// CPP-PARITY: timeoffsetnode.cpp` `output_time_adjustment`).
fn output_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
Self::output_time_adjustment_with(core, input, element, time, traverse)
}
/// Evaluate outputs (C++ `value()`): pushes the value arriving at
+17 -15
View File
@@ -56,9 +56,9 @@ impl TimeRemapNode {
/// original time); all other inputs fall through to the base-class
/// identity behavior.
///
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
/// `NodeCore`, so this value-resolving variant is what render-time call
/// sites (and the tests) use; the trait method documents that gap.
/// The [`NodeBehavior::input_time_adjustment`] trait method receives the
/// core and delegates here; kept as an associated fn so the tests can
/// drive the mapping without a behavior instance.
pub fn input_time_adjustment_with(
core: &NodeCore,
input: &str,
@@ -115,23 +115,18 @@ impl NodeBehavior for TimeRemapNode {
/// `input_in`, both ends of the range are replaced by the `time_in`
/// value at that time (C++ `get_remapped_time()`: `time_in` evaluated at
/// `input`, discarding the original time); all other inputs fall through
/// to the base-class identity behavior.
///
/// The C++ evaluation reads the keyframable `time_in` input, which
/// requires the node's data ([`NodeCore`]) — not carried by this trait
/// signature. The exact remap is ported in
/// [`Self::input_time_adjustment_with`] (and tested there); until the
/// adjustment API gains core access, the identity range is returned
/// to the base-class identity behavior. `core` carries the node's data
/// (the keyframable `time_in` input), matching the C++ member read
/// (`// CPP-PARITY: timeremap.cpp` `input_time_adjustment`).
fn input_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
Self::input_time_adjustment_with(core, input, element, time, traverse)
}
/// Output-side time remap (C++ `output_time_adjustment()`): the C++
@@ -140,12 +135,13 @@ impl NodeBehavior for TimeRemapNode {
/// base-class identity behavior; declared here for parity.
fn output_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
let _ = (core, input, element, traverse);
time
}
@@ -328,8 +324,14 @@ mod tests {
fn output_time_adjustment_is_identity() {
let n = TimeRemapNode;
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
assert_eq!(n.output_time_adjustment(INPUT_INPUT, -1, t, true), t);
assert_eq!(n.output_time_adjustment("other_in", -1, t, true), t);
assert_eq!(
n.output_time_adjustment(&NodeCore::new(), INPUT_INPUT, -1, t, true),
t
);
assert_eq!(
n.output_time_adjustment(&NodeCore::new(), "other_in", -1, t, true),
t
);
}
#[test]
@@ -21,7 +21,29 @@
//! `pos_in`/`rot_in`/`scale_in`/`uniform_scale_in`/`anchor_in` inputs
//! and the `generate_matrix` helper.
/// The transform fragment shader: samples the input through the inverse
/// of the node's pixel-space transform (`transform_in` carries the
/// CPU-inverted matrix; `resolution_in` is auto-filled by the runner).
/// The C++ path transforms vertices (`ove_mvpmat` in transform.vert);
/// an affine transform is equivalently applied fragment-side by
/// inverse-mapping the sample position — the fixed fullscreen vertex
/// stage stays unchanged.
const TRANSFORM_FRAG: &str = r#"uniform sampler2D tex_in;
uniform mat4 transform_in;
uniform vec2 resolution_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec2 px = ove_texcoord * resolution_in;
vec2 src = (transform_in * vec4(px, 0.0, 1.0)).xy;
frag_color = texture(tex_in, src / resolution_in);
}
"#;
use crate::factory::NodeMeta;
use crate::jobs::ShaderJobPayload;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
@@ -366,21 +388,37 @@ impl NodeBehavior for TransformDistortNode {
);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
// The shader-job box (C++ `Texture::Job(globals.vparams(),
// job)`): the behavior's type id selects the fragment
// source, and the effect input key locates the main
// texture inside the params row. C++ also inserts
// `ove_mvpmat` (the auto-scaled real matrix) and sets the
// `ove_maintex` interpolation, but the matrix needs the
// texture's params and the sequence resolution — neither
// available here — so it is left absent and the runner
// fills an identity `ove_mvpmat`; the C++ identity check
// (pass-through when the real matrix is identity) is not
// representable either, so the job is always queued with a
// texture (`// CPP-PARITY: transformdistortnode.cpp`
// value(); TODO: inject the real matrix from the renderer
// seam, where the resolution data is available).
Some(tex @ crate::value::NodeValue::Texture(_)) => {
// The identity transform passes the texture through (C++
// skips the job when the real matrix is identity).
let identity = super::mathbase::identity_matrix();
let is_identity = generated_matrix
.iter()
.zip(identity)
.all(|(a, b)| (*a - b).abs() <= 1e-9);
if is_identity {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
return;
}
// The job carries the inverse transform in pixel space
// (C++ inserts `ove_mvpmat` — the forward matrix for the
// vertex stage; the fragment-side implementation samples
// through the inverse, see TRANSFORM_FRAG). A singular
// matrix (zero scale) passes the input through, matching
// the C++ inverted()-fails fallback. The auto-scaled
// variant stays unrepresentable: it needs the texture's
// params and the sequence resolution at job-build time
// (`// CPP-PARITY: transformdistortnode.cpp` value()).
let Some(inverse) = super::matrix::matrix_invert_2d(generated_matrix) else {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
return;
};
let mut params = inputs.clone();
params.insert(
"transform_in".to_string(),
crate::value::NodeValue::Matrix(inverse),
);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
@@ -390,7 +428,7 @@ impl NodeBehavior for TransformDistortNode {
type_id: self.type_id().to_string(),
shader_id: String::new(),
effect_input: core.effect_input.clone(),
params: inputs.clone(),
params,
iterative_input: TEXTURE_INPUT.to_string(),
})),
None,
@@ -404,12 +442,12 @@ impl NodeBehavior for TransformDistortNode {
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and returns a default (empty) `ShaderCode` — the
/// node relies on the renderer's default vertex/fragment shaders,
/// so this maps to `None`.
/// request id and returns the transform fragment shader — the
/// inverse-sampling equivalent of the C++ default vertex-shader
/// transform (see [`TRANSFORM_FRAG`]).
fn shader_code(&self, request: &str) -> Option<String> {
let _ = request;
None
Some(TRANSFORM_FRAG.to_string())
}
/// Gizmo transform/positions (C++ `update_gizmo_positions()` and
@@ -715,11 +753,31 @@ mod tests {
}
#[test]
fn value_pushes_matrix_and_job_with_texture() {
fn value_identity_transform_passes_texture_through() {
// Default inputs (position 0, rotation 0, scale 1) generate the
// identity matrix: the texture passes through without a job (C++
// skips the job when the real matrix is identity).
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Matrix).is_some());
assert_eq!(table.get(ValueType::Texture), Some(&tex()));
}
#[test]
fn value_pushes_matrix_and_job_with_texture() {
let (mut core, behavior) = create();
// A real transform (position +50 in x) generates a non-identity
// matrix, so the shader job is queued.
core.set_standard_value(
super::super::matrix::POSITION_INPUT,
-1,
NodeValue::Vec2([50.0, 0.0]),
);
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
// Matrix output always pushed; texture job queued for the seam.
assert!(table.get(ValueType::Matrix).is_some());
let handle = match table.get(ValueType::Texture).unwrap() {
@@ -734,6 +792,12 @@ mod tests {
assert_eq!(payload.effect_input, TEXTURE_INPUT);
assert_eq!(payload.time, Rational::new(0, 1));
assert!(payload.params.contains_key(TEXTURE_INPUT));
// The job carries the inverse of the +50px translation
// (fragment-side inverse sampling).
match payload.params.get("transform_in") {
Some(NodeValue::Matrix(m)) => assert_eq!(m[3], -50.0),
other => panic!("transform_in must carry the inverse matrix: {other:?}"),
}
}
#[test]
@@ -771,9 +835,11 @@ mod tests {
}
#[test]
fn shader_code_returns_none() {
fn shader_code_returns_inverse_sampling_frag() {
let n = empty_node();
assert!(n.shader_code("anything").is_none());
let frag = n.shader_code("anything").expect("real transform shader");
assert!(frag.contains("transform_in"));
assert!(frag.contains("resolution_in"));
}
#[test]
+1 -1
View File
@@ -221,7 +221,7 @@ fn adjusted_time(
) -> Rational {
entry
.behavior
.input_time_adjustment(input, element, TimeRange::new(time, time), true)
.input_time_adjustment(&entry.core, input, element, TimeRange::new(time, time), true)
.in_()
}