diff --git a/.cargo/config.toml b/.cargo/config.toml
deleted file mode 100644
index 024cc9b58..000000000
--- a/.cargo/config.toml
+++ /dev/null
@@ -1,24 +0,0 @@
-# Oak Video Editor - Non-Linear Video Editor
-# Copyright (C) 2026 Oak Team
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-# The project FFmpeg (built by tooling/ffmpeg/build-ffmpeg.sh into
-# .cache/ffmpeg) is the only supported FFmpeg: ffmpeg-sys-next reads
-# FFMPEG_DIR at build-script time, which cannot come from a .env file —
-# a relative [env] entry here is the only machine-agnostic way to set
-# it. Run tooling/ffmpeg/build-ffmpeg.sh once before the first build.
-
-[env]
-FFMPEG_DIR = { value = ".cache/ffmpeg", relative = true }
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index f1e0c098f..a21892f31 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -62,6 +62,7 @@ jobs:
echo "RUSTUP_HOME=/opt/rust/rustup" >> "$GITHUB_ENV"
echo "CARGO_HOME=/opt/rust/cargo" >> "$GITHUB_ENV"
echo "PATH=$PATH:/opt/rust/cargo/bin" >> "$GITHUB_ENV"
+ echo "FFMPEG_DIR=.cache/ffmpeg" >> "$GITHUB_ENV"
# ------------------------------------------------------------------
# Caches
@@ -227,6 +228,7 @@ jobs:
echo "OCIO_RS_ENABLE_REAL=1" >> "$GITHUB_ENV"
echo "OCIO_INSTALL_DIR=/ucrt64" >> "$GITHUB_ENV"
echo "OCIO_RS_LINK=dynamic" >> "$GITHUB_ENV"
+ echo "FFMPEG_DIR=.cache/ffmpeg" >> "$GITHUB_ENV"
# ocio-sys' build.rs force-adds the MSVC + Windows SDK include
# dirs on Windows (meant for MSVC hosts); with the GNU toolchain
# that drags MSVC-only headers into the g++ compile. Unpack the
diff --git a/.gitignore b/.gitignore
index 2eefd58ba..b84cd1968 100644
--- a/.gitignore
+++ b/.gitignore
@@ -121,3 +121,5 @@ tarpaulin-out/
.env
# CD packaging artifacts
/*.dmg
+.cargo/config.toml
+perf.data
diff --git a/Cargo.lock b/Cargo.lock
index 3b0500ce5..1cc244d56 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4424,6 +4424,7 @@ dependencies = [
"log",
"num-traits",
"once_cell",
+ "pp-rs",
"rustc-hash 1.1.0",
"spirv 0.3.0+sdk-1.3.268.0",
"strum 0.26.3",
@@ -4840,6 +4841,7 @@ name = "oak-render"
version = "0.5.0"
dependencies = [
"libc",
+ "naga 25.0.1",
"oak-codec",
"oak-common",
"oak-core",
@@ -4905,6 +4907,7 @@ name = "oak-worker"
version = "0.5.0"
dependencies = [
"libc",
+ "oak-codec",
"oak-core",
"oak-node",
"oak-plugin",
@@ -5634,6 +5637,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "pp-rs"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
+dependencies = [
+ "unicode-xid",
+]
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs
index 9fd72aba0..6bd2b2865 100644
--- a/crates/oak-app/src/oakui/real.rs
+++ b/crates/oak-app/src/oakui/real.rs
@@ -2626,6 +2626,17 @@ impl RealEngine {
let _ = graphops::push_multi_command(children, "Synchronize Clips by Waveform");
}
+ /// M16 S1 graph mode: pushes the current project state to the worker
+ /// pool (snapshot serialized once per undo-stack revision; the worker
+ /// renders node-graph tickets from it).
+ fn push_graph_snapshot(&self) {
+ let Some(project) = self.project.clone() else { return };
+ if let Some(m) = RenderManager::global() {
+ let revision = oak_undo::global::index().unwrap_or(0).max(0) as u64;
+ let _ = m.set_graph_snapshot(&project, revision);
+ }
+ }
+
/// Adopts a newly created/loaded project, dropping any previous one,
/// and rebuilds every snapshot. The undo stack is cleared (a project
/// switch starts a fresh history, mirroring the facade's
@@ -2670,6 +2681,7 @@ impl RealEngine {
self.workarea = Some(AuxHandle(graphops::workarea_create()));
self.refresh_sequence_info();
self.rebuild_timeline();
+ self.push_graph_snapshot();
// The project's stored OCIO override (if any) drives the display
// color pipeline from here on.
@@ -2689,6 +2701,9 @@ impl RealEngine {
*self.renderer.lock().unwrap() = RendererSlot::Untried;
*self.source_renderer.lock().unwrap() = RendererSlot::Untried;
oak_undo::global::clear().ok();
+ if let Some(m) = RenderManager::global() {
+ m.clear_graph_snapshot();
+ }
if let Some(mut markers) = self.markers.take() {
graphops::release_handle(&mut markers.0);
}
@@ -2847,6 +2862,7 @@ impl RealEngine {
self.refresh_sequence_info();
self.rebuild_timeline();
self.invalidate_rendered_frames();
+ self.push_graph_snapshot();
cx.notify();
}
@@ -3093,6 +3109,7 @@ impl RealEngine {
// and so are any in-flight full-res renders and pre-render windows
// (M12 P5a / M15 S2).
self.invalidate_rendered_frames();
+ self.push_graph_snapshot();
cx.notify();
}
}
diff --git a/crates/oak-app/src/oakui/renderops.rs b/crates/oak-app/src/oakui/renderops.rs
index 4a896b38b..4af2ce6bc 100644
--- a/crates/oak-app/src/oakui/renderops.rs
+++ b/crates/oak-app/src/oakui/renderops.rs
@@ -297,8 +297,13 @@ pub fn multicam_angle_frame_params(
) -> Result {
validate_geometry(width, height, tb)?;
let time = Rational::new(frame_ts * tb.0, tb.1);
+ // Bind the uuid before the literal: the struct expression is the tail of
+ // the block, so an inline `lock(p)` temporary would outlive the field
+ // initializers and deadlock the reentrant lock in `single_track_video_montage`.
+ let project = lock(p).uuid.clone();
Ok(VideoTicketParams {
viewer: seq.identity(),
+ project,
time,
force_size: Some((width, height)),
force_format: None,
@@ -551,8 +556,13 @@ pub fn sequence_frame_params(
) -> Result {
validate_geometry(width, height, tb)?;
let time = Rational::new(frame_ts * tb.0, tb.1);
+ // Bind the uuid before the literal: an inline `lock(p)` temporary in the
+ // block-tail struct expression would still be alive when `video_montage`
+ // re-locks the project, deadlocking the same thread.
+ let project = lock(p).uuid.clone();
Ok(VideoTicketParams {
viewer: seq.identity(),
+ project,
time,
force_size: Some((width, height)),
force_format: None,
@@ -598,8 +608,12 @@ pub fn footage_frame_params(
.ok_or_else(|| "the node is not footage".to_string())?
};
let time = Rational::new(frame_ts * tb.0, tb.1);
+ // Bind the uuid before the literal (same tail-expression temporary rule
+ // as the montage paths; harmless here but keeps the pattern uniform).
+ let project = lock(p).uuid.clone();
Ok(VideoTicketParams {
viewer: footage.identity(),
+ project,
time,
force_size: Some((width, height)),
force_format: None,
@@ -989,6 +1003,7 @@ mod tests {
let render = |montage: Vec| {
let params = VideoTicketParams {
viewer: 0,
+ project: String::new(),
time,
force_size: Some((64, 64)),
force_format: Some(oak_core::PixelFormat::F32),
diff --git a/crates/oak-cli/src/engine.rs b/crates/oak-cli/src/engine.rs
index 27c419ac3..f659d9b5e 100644
--- a/crates/oak-cli/src/engine.rs
+++ b/crates/oak-cli/src/engine.rs
@@ -646,6 +646,7 @@ pub fn render_frame(
let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
let params = VideoTicketParams {
viewer: seq_id.identity(),
+ project: String::new(),
time,
force_size: Some((width, height)),
force_format: None,
diff --git a/crates/oak-node/src/block.rs b/crates/oak-node/src/block.rs
index 1612a98de..76488e686 100644
--- a/crates/oak-node/src/block.rs
+++ b/crates/oak-node/src/block.rs
@@ -22,7 +22,7 @@ use oak_core::{Rational, TimeRange};
use crate::id::NodeId;
use crate::input::Input;
use crate::node::{Category, NodeBehavior, NodeCore};
-use crate::value::{NodeValue, ValueType};
+use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
/// Block core data (C++ `Block` members): timeline span + media range.
#[derive(Clone)]
@@ -330,6 +330,57 @@ impl NodeBehavior for ClipBlockBehavior {
});
true
}
+
+ /// Timeline -> media time mapping on the texture input (C++
+ /// `ClipBlock::InputTimeAdjustment`): `media = (time - in) * speed`
+ /// (reversed flips inside the block span), offset by the media
+ /// in-point. Other inputs pass through unchanged.
+ fn input_time_adjustment(
+ &self,
+ input: &str,
+ _element: i32,
+ time: TimeRange,
+ _traverse: bool,
+ ) -> TimeRange {
+ if input != clip_input::TEXTURE_INPUT {
+ return time;
+ }
+ let mut media = time.in_() - self.core.in_();
+ if (self.core.speed - 1.0).abs() > 1e-9 {
+ if self.core.speed.abs() < 1e-12 {
+ media = Rational::new(0, 1);
+ } else {
+ media = Rational::from_double(media.to_f64() * self.core.speed);
+ }
+ }
+ if self.core.reversed {
+ media = self.core.length() - media;
+ }
+ media = media + self.core.media_in;
+ TimeRange::new(media, media + (time.out() - time.in_()))
+ }
+
+ /// Pass the connected texture through (C++ `ClipBlock::ProcessFrame`
+ /// copies `tex_in` to the output). An unconnected `tex_in` yields
+ /// nothing, so a bare clip is inert.
+ fn value(
+ &self,
+ _core: &NodeCore,
+ inputs: &NodeValueRow,
+ _time: Rational,
+ table: &mut NodeValueTable,
+ ) {
+ if !self.core.enabled {
+ return;
+ }
+ let Some(value) = inputs.get(clip_input::TEXTURE_INPUT) else {
+ return;
+ };
+ // `NodeValue::clone` addrefs the texture handle so the table row
+ // owns its own reference (released on drop); a plain handle copy
+ // would double-release the input's reference.
+ table.push(ValueType::Texture, value.clone(), None);
+ }
}
impl NodeBehavior for GapBlockBehavior {
@@ -375,6 +426,16 @@ impl NodeBehavior for GapBlockBehavior {
load_block_core(reader, &mut self.core, &mut |_, _| false);
true
}
+
+ /// No video output (the compositor skips uncovered spans).
+ fn value(
+ &self,
+ _core: &NodeCore,
+ _inputs: &NodeValueRow,
+ _time: Rational,
+ _table: &mut NodeValueTable,
+ ) {
+ }
}
impl NodeBehavior for TransitionBlockBehavior {
@@ -435,6 +496,17 @@ impl NodeBehavior for TransitionBlockBehavior {
});
true
}
+
+ /// No video output yet (C++ transition crossfades are not ported; a
+ /// transition renders as a hole for now).
+ fn value(
+ &self,
+ _core: &NodeCore,
+ _inputs: &NodeValueRow,
+ _time: Rational,
+ _table: &mut NodeValueTable,
+ ) {
+ }
}
/// Constructor for a clip block (C++ `ClipBlock::ClipBlock()`): adds the
@@ -448,9 +520,9 @@ pub fn clip_create() -> (NodeCore, Box) {
// The texture input (C++ `ClipBlock` prepends it ahead of the static
// inputs): this is where the effect chain attaches, so it sits right
// after the inherited `enabled_in` and stays connectable. An unconnected
- // `tex_in` is inert — the traverser only feeds rows from actual edges
- // and `ClipBlockBehavior` never reads inputs, so a bare clip (no
- // effects) evaluates exactly as before.
+ // `tex_in` is inert — [`ClipBlockBehavior::value`] passes only a
+ // connected texture through (the traverser feeds rows from actual
+ // edges), so a bare clip (no effects) emits no output.
let mut tex = Input::new(
clip_input::TEXTURE_INPUT,
ValueType::Texture,
@@ -575,6 +647,97 @@ mod tests {
);
}
+ fn clip_with(speed: f64, reversed: bool) -> ClipBlockBehavior {
+ ClipBlockBehavior {
+ core: BlockCore {
+ range: TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
+ media_in: Rational::new(5, 1),
+ speed,
+ reversed,
+ ..BlockCore::default()
+ },
+ footage: None,
+ }
+ }
+
+ /// Timeline -> media time mapping on `tex_in` (C++
+ /// `ClipBlock::InputTimeAdjustment`): speed first, then reverse, then
+ /// the media in-point offset. Other inputs pass through unchanged.
+ #[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);
+
+ // Speed 1: media = (12 - 10) + 5 = 7.
+ assert_eq!(
+ map(&clip_with(1.0, false)),
+ TimeRange::new(Rational::new(7, 1), Rational::new(8, 1))
+ );
+ // Speed 2: media = (12 - 10) * 2 + 5 = 9.
+ assert_eq!(
+ map(&clip_with(2.0, false)),
+ TimeRange::new(Rational::new(9, 1), Rational::new(10, 1))
+ );
+ // Speed 0 clamps to the media in-point.
+ assert_eq!(
+ map(&clip_with(0.0, false)),
+ TimeRange::new(Rational::new(5, 1), Rational::new(6, 1))
+ );
+ // Reversed flips inside the block span before the media offset:
+ // (10 - (12 - 10)) + 5 = 13.
+ assert_eq!(
+ map(&clip_with(1.0, true)),
+ TimeRange::new(Rational::new(13, 1), Rational::new(14, 1))
+ );
+ // Reversed + speed 2: (10 - (12 - 10) * 2) + 5 = 11.
+ assert_eq!(
+ map(&clip_with(2.0, true)),
+ 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);
+ }
+
+ /// The clip copies the connected `tex_in` texture to its output;
+ /// disabled clips, unconnected clips and non-clip blocks emit nothing
+ /// (a bare clip is inert, matching C++ `ClipBlock::ProcessFrame`).
+ #[test]
+ fn clip_value_passes_connected_texture_only() {
+ let mut inputs = NodeValueRow::new();
+ let handle = crate::handle::make_owned(42i32);
+ // The value owns the `make_owned` reference; the inserted row is a
+ // proper `NodeValue` clone (addref'd), never a bare handle copy.
+ let tex_value = NodeValue::Texture(handle);
+ inputs.insert(clip_input::TEXTURE_INPUT.to_string(), tex_value.clone());
+
+ let mut table = NodeValueTable::default();
+ clip_with(1.0, false).value(&NodeCore::empty(), &inputs, Rational::new(0, 1), &mut table);
+ assert_eq!(table.count(), 1);
+ let NodeValue::Texture(out) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ assert_eq!(out.ctx, handle.ctx, "the same texture box passes through");
+
+ let mut disabled = clip_with(1.0, false);
+ disabled.core.enabled = false;
+ let mut table = NodeValueTable::default();
+ disabled.value(&NodeCore::empty(), &inputs, Rational::new(0, 1), &mut table);
+ assert_eq!(table.count(), 0, "disabled clip emits nothing");
+
+ let mut table = NodeValueTable::default();
+ clip_with(1.0, false).value(&NodeCore::empty(), &NodeValueRow::new(), Rational::new(0, 1), &mut table);
+ assert_eq!(table.count(), 0, "unconnected clip emits nothing");
+
+ for behavior in [
+ Box::new(GapBlockBehavior::new()) as Box,
+ Box::new(TransitionBlockBehavior::new()) as Box,
+ ] {
+ let mut table = NodeValueTable::default();
+ behavior.value(&NodeCore::empty(), &NodeValueRow::new(), Rational::new(0, 1), &mut table);
+ assert_eq!(table.count(), 0, "gap/transition emit nothing");
+ }
+ }
+
/// An effect node can be chained onto the clip through `tex_in`: the
/// connection succeeds and resolves back to the effect.
#[test]
diff --git a/crates/oak-node/src/footage.rs b/crates/oak-node/src/footage.rs
index 65988b6d5..c7abf87c7 100644
--- a/crates/oak-node/src/footage.rs
+++ b/crates/oak-node/src/footage.rs
@@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use crate::input::Input;
use crate::node::{Category, NodeBehavior, NodeCore};
-use crate::value::{AudioParams, NodeValue, ValueType, VideoParams};
+use crate::value::{AudioParams, NodeValue, NodeValueRow, NodeValueTable, ValueType, VideoParams};
/// One media stream inside a footage file.
#[derive(Clone, Debug)]
@@ -347,6 +347,35 @@ impl NodeBehavior for FootageBehavior {
}))
}
+ /// Emit the decode request (C++ `Footage::ProcessFootageRequest`): a
+ /// boxed [`crate::nodes::jobs::FootageJobPayload`] the render hooks
+ /// resolve to the decoded frame. A footage with no probed video stream
+ /// (or no filename) outputs nothing.
+ fn value(
+ &self,
+ _core: &NodeCore,
+ _inputs: &NodeValueRow,
+ time: oak_core::Rational,
+ table: &mut NodeValueTable,
+ ) {
+ if self.filename.is_empty() {
+ return;
+ }
+ let Some(stream) = self.streams.iter().find(|s| s.is_video) else {
+ return;
+ };
+ let payload = crate::nodes::jobs::FootageJobPayload {
+ filename: self.filename.clone(),
+ stream_index: stream.index,
+ time,
+ };
+ table.push(
+ ValueType::Texture,
+ NodeValue::Texture(crate::handle::make_owned(payload)),
+ None,
+ );
+ }
+
/// Custom project save (C++ `Footage::SaveCustom`): the file name,
/// media timestamp, proxy state and the probed streams. ``
/// and `` are Rust additions (C++ reads the name from the
@@ -790,4 +819,64 @@ mod tests {
assert!(f.streams.is_empty());
assert_eq!(f.timestamp, 0);
}
+
+ /// A footage with a probed video stream emits a boxed footage job
+ /// payload carrying the decode request (C++ `Footage::ProcessFootageRequest`);
+ /// a footage with no filename or no video stream outputs nothing.
+ #[test]
+ fn footage_value_emits_footage_job_payload() {
+ let mut f = FootageBehavior::new("clip.mov");
+ f.streams.push(StreamInfo {
+ index: 1,
+ is_video: true,
+ video: None,
+ audio: None,
+ duration: oak_core::Rational::new(1, 1),
+ });
+
+ let mut table = NodeValueTable::default();
+ f.value(
+ &NodeCore::empty(),
+ &NodeValueRow::new(),
+ oak_core::Rational::new(3, 1),
+ &mut table,
+ );
+ assert_eq!(table.count(), 1);
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let payload = unsafe { crate::handle::get_checked::(handle) }
+ .expect("footage output boxes a FootageJobPayload");
+ assert_eq!(payload.filename, "clip.mov");
+ assert_eq!(payload.stream_index, 1);
+ assert_eq!(payload.time, oak_core::Rational::new(3, 1));
+
+ // No filename: nothing.
+ let mut table = NodeValueTable::default();
+ FootageBehavior::new("").value(
+ &NodeCore::empty(),
+ &NodeValueRow::new(),
+ oak_core::Rational::new(1, 1),
+ &mut table,
+ );
+ assert_eq!(table.count(), 0);
+
+ // Only an audio stream: nothing.
+ let mut audio_only = FootageBehavior::new("clip.mov");
+ audio_only.streams.push(StreamInfo {
+ index: 0,
+ is_video: false,
+ video: None,
+ audio: None,
+ duration: oak_core::Rational::new(1, 1),
+ });
+ let mut table = NodeValueTable::default();
+ audio_only.value(
+ &NodeCore::empty(),
+ &NodeValueRow::new(),
+ oak_core::Rational::new(1, 1),
+ &mut table,
+ );
+ assert_eq!(table.count(), 0);
+ }
}
diff --git a/crates/oak-node/src/nodes/blur.rs b/crates/oak-node/src/nodes/blur.rs
index 1bc1265ec..270b8e070 100644
--- a/crates/oak-node/src/nodes/blur.rs
+++ b/crates/oak-node/src/nodes/blur.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -144,6 +145,11 @@ int determine_mode() {
if (ove_iteration == 1) {
return MODE_VERTICAL;
}
+
+ // Unreachable in practice (the branches above are exhaustive), but
+ // naga's validator rejects functions with a fallthrough path —
+ // deviation from the verbatim C++ shader text.
+ return MODE_NONE;
}
vec4 add_to_composite(vec4 composite, vec2 pixel_coord, float weight)
@@ -322,9 +328,10 @@ impl NodeBehavior for BlurFilterNode {
/// running 2 iterations for box/gaussian when both horiz and vert are
/// checked (1 otherwise).
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value and the iteration count) is deferred to the
- /// renderer seam (`// CPP-PARITY: blur.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; `resolution_in`
+ /// is filled by the runner from the input texture's size, matching the
+ /// C++ `tex->virtual_resolution()` (`// CPP-PARITY: blur.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -367,10 +374,32 @@ impl NodeBehavior for BlurFilterNode {
can_push_job = false;
}
+ // Iterate twice for the two-pass box/gaussian blur (once per axis);
+ // all other methods are single-pass (C++ `iterations = 2` only for
+ // the double-pass case).
+ let mut iterations = 1;
+ if method == Method::Box as i64 || method == Method::Gaussian as i64 {
+ if horiz && vert {
+ iterations = 2;
+ }
+ }
+
if can_push_job {
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates
+ // the main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
diff --git a/crates/oak-node/src/nodes/chromakey.rs b/crates/oak-node/src/nodes/chromakey.rs
index 7de778aa8..229c54cca 100644
--- a/crates/oak-node/src/nodes/chromakey.rs
+++ b/crates/oak-node/src/nodes/chromakey.rs
@@ -75,18 +75,24 @@ pub struct ChromaKeyNode {
/// Fragment shader (C++ loads the `:/shaders/chromakey.frag` resource
/// in `get_shader_code`). Text copied verbatim from
-/// `engine/shaders/chromakey.frag`. The `%1` marker is replaced with
+/// `engine/shaders/chromakey.frag`, except the tolerance uniforms,
+/// which are spelled `upper_tolerance_in`/`lower_tolerance_in` to
+/// match the input ids: the renderer binds uniforms by matching the
+/// shader-declared name against the job's value keys, so the C++
+/// misspelling (`upper_tolerence_in`) never receives the renamed
+/// input's value and the tolerances stay 0. `// CPP-PARITY: the C++
+/// frag still declares the misspelled names (the input rename commit
+/// bec52b46b did not update it), which makes chromakey broken there;
+/// the fix is applied here only.` The `%1` marker is replaced with
/// the OCIO-generated shader stub (`request.stub`) at request time;
/// the shader calls `SceneLinearToCIEXYZ_d65`, which the stub must
-/// define. Note the shader still uses the legacy misspelled uniform
-/// names `upper_tolerence_in`/`lower_tolerence_in`, matching the old
-/// input ids remapped by `map_legacy_input_id`.
+/// define.
const SHADER_FRAG: &str = r#"// Main texture input
uniform sampler2D tex_in;
uniform vec4 color_key;
uniform bool mask_only_in;
-uniform float upper_tolerence_in;
-uniform float lower_tolerence_in;
+uniform float upper_tolerance_in;
+uniform float lower_tolerance_in;
uniform sampler2D garbage_in;
uniform sampler2D core_in;
@@ -154,7 +160,7 @@ void main() {
vec4 cie_xyz_key = SceneLinearToCIEXYZ_d65(color_key);
vec4 lab_key = CIExyz_to_Lab(cie_xyz_key);
- float mask = colorclose(lab, lab_key, lower_tolerence_in, upper_tolerence_in);
+ float mask = colorclose(lab, lab_key, lower_tolerance_in, upper_tolerance_in);
mask = clamp(mask, 0.0, 1.0);
diff --git a/crates/oak-node/src/nodes/cornerpindistortnode.rs b/crates/oak-node/src/nodes/cornerpindistortnode.rs
index cae6a3916..e71864fc4 100644
--- a/crates/oak-node/src/nodes/cornerpindistortnode.rs
+++ b/crates/oak-node/src/nodes/cornerpindistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -277,16 +278,24 @@ impl NodeBehavior for CornerPinDistortNode {
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// all four corner sliders at their `(0, 0)` default -> pass-through
- /// push of the input texture unchanged; otherwise build a shader job
- /// with `resolution_in` inserted and custom vertex coordinates: each
- /// corner offset is converted to pixels via `value_to_pixel` and then
- /// to clip space (`/ half_resolution - 1.0`) and pushed as two
- /// triangles (TL, TR, BR / TL, BL, BR).
+ /// push of the input texture unchanged; otherwise push a shader job.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value and the adjusted vertex coordinates) is
- /// deferred to the renderer seam (`// CPP-PARITY:
- /// cornerpindistortnode.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params row
+ /// carries the input texture and uniforms, keyed by the effect input,
+ /// and `resolution_in` is filled by the runner from the input
+ /// texture's size, matching the C++ insert of the texture's virtual
+ /// resolution (`// CPP-PARITY: cornerpindistortnode.cpp` value()).
+ ///
+ /// TODO(vertex-shader): C++ additionally overrides the vertex
+ /// coordinates — each corner offset converted to pixels via
+ /// `value_to_pixel` and then to clip space (`/ half_resolution - 1.0`),
+ /// pushed as two triangles (TL, TR, BR / TL, BL, BR) via
+ /// `job.SetVertexCoordinates(...)` — and the quad warp needs the custom
+ /// vertex shader `cornerpin.vert` (`ove_mvpmat`), which the
+ /// [`ShaderJobPayload`] (no vertex field) and the single-fragment
+ /// `shader_code()` seam cannot carry. The fragment path (perspective
+ /// interpolation) is testable without it.
fn value(
&self,
core: &NodeCore,
@@ -317,9 +326,21 @@ impl NodeBehavior for CornerPinDistortNode {
&& corner_is_null(BOTTOM_RIGHT_INPUT)
&& corner_is_null(BOTTOM_LEFT_INPUT))
{
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates the
+ // main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
@@ -570,17 +591,25 @@ mod tests {
}
#[test]
- fn value_moved_corner_pushes_deferred_job() {
+ fn value_moved_corner_pushes_job_payload() {
let (mut core, behavior) = create();
core.set_standard_value(TOP_RIGHT_INPUT, -1, NodeValue::Vec2([10.0, 5.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);
- assert!(table.get(ValueType::Texture).is_some());
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let payload = unsafe { crate::handle::get_checked::(handle) }
+ .expect("cornerpin output boxes a ShaderJobPayload");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.cornerpin");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
- fn value_corner_moved_on_y_only_pushes_deferred_job() {
+ fn value_corner_moved_on_y_only_pushes_job_payload() {
// C++ `is_null()` requires both components zero: a corner at
// (0, 5) is not at its default.
let (mut core, behavior) = create();
diff --git a/crates/oak-node/src/nodes/cropdistortnode.rs b/crates/oak-node/src/nodes/cropdistortnode.rs
index 5812d3ad5..76aa7cbea 100644
--- a/crates/oak-node/src/nodes/cropdistortnode.rs
+++ b/crates/oak-node/src/nodes/cropdistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -174,9 +175,12 @@ impl NodeBehavior for CropDistortNode {
/// shader job; all zero -> pass-through push of the input texture
/// unchanged.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value) is deferred to the renderer seam
- /// (`// CPP-PARITY: cropdistortnode.cpp` value()).
+ /// The job case boxes a [`ShaderJobPayload`] that the renderer's
+ /// resolve hook executes and replaces with the result texture; the
+ /// params row carries the input texture and uniforms, keyed by the
+ /// effect input, and `resolution_in` is filled by the runner from the
+ /// input texture's size, matching the C++ `texture->params()`
+ /// insertion (`// CPP-PARITY: cropdistortnode.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -207,9 +211,21 @@ impl NodeBehavior for CropDistortNode {
};
if left != 0.0 || right != 0.0 || top != 0.0 || bottom != 0.0 {
+ // The shader-job box (C++ `texture->toJob(job)`): the behavior's
+ // type id selects the fragment source, and the effect input key
+ // locates the main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: TEXTURE_INPUT.to_string(),
+ })),
None,
);
} else {
@@ -477,13 +493,24 @@ mod tests {
}
#[test]
- fn value_any_crop_pushes_deferred_job() {
+ fn value_any_crop_pushes_shader_job_payload() {
let (mut core, behavior) = create();
core.set_standard_value(LEFT_INPUT, -1, NodeValue::Float(0.25));
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());
+ let handle = match table.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe { crate::handle::get_checked::(&handle) }
+ .expect("shader job payload expected");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.crop");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ assert_eq!(payload.time, Rational::new(0, 1));
+ assert!(payload.params.contains_key(TEXTURE_INPUT));
}
#[test]
diff --git a/crates/oak-node/src/nodes/despill.rs b/crates/oak-node/src/nodes/despill.rs
index a915de4ea..e90684060 100644
--- a/crates/oak-node/src/nodes/despill.rs
+++ b/crates/oak-node/src/nodes/despill.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -38,6 +39,13 @@ pub const METHOD_INPUT: &str = "method_in";
/// `_in`). Type: boolean; default `false`.
pub const PRESERVE_LUMINANCE_INPUT: &str = "preserve_luminance_input";
+/// Luma coefficients uniform id (C++ uses the `luma_coeffs` literal in
+/// `value()`). Not a declared node input — it is the shader uniform name
+/// fed per frame in `value()` with the project color manager's default
+/// luma coefficients (Rec. 709 `{0.2126, 0.7152, 0.0722}` fallback).
+/// Type: vec3.
+pub const LUMA_COEFFS_INPUT: &str = "luma_coeffs";
+
/// Despill node: removes green/blue screen spill from the keyed
/// foreground using one of several channel-averaging methods. The C++
/// class has no own members.
@@ -183,7 +191,6 @@ impl NodeBehavior for DespillNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
@@ -194,12 +201,27 @@ impl NodeBehavior for DespillNode {
// color manager's default luma coefficients when one is attached —
// the Rust model has no project/manager access, so the fallback
// always applies) into a ShaderJob over the whole input row and
- // pushes `tex->to_job(job)`. The Rust model has no shader-job
- // payload: the renderer seam resolves the deferred job from this
- // null handle.
+ // pushes `tex->to_job(job)`. The job is boxed here as a
+ // [`ShaderJobPayload`] that the renderer's resolve hook executes
+ // and replaces with the result texture; the params row carries the
+ // luma coefficients under the shader uniform name.
+ let mut params = inputs.clone();
+ params.insert(
+ LUMA_COEFFS_INPUT.to_string(),
+ crate::value::NodeValue::Vec3([0.2126, 0.7152, 0.0722]),
+ );
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -324,7 +346,7 @@ mod tests {
}
#[test]
- fn value_with_texture_pushes_deferred_shader_job() {
+ fn value_with_texture_pushes_shader_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
@@ -332,7 +354,22 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ panic!("expected a texture-typed value");
+ };
+ let payload =
+ unsafe { crate::handle::get_checked::(handle) }
+ .expect("payload boxed behind the handle");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.despill");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ // The luma coefficients are injected under the shader uniform name
+ // (C++ `job.Insert("luma_coeffs", ...)`).
+ assert_eq!(
+ payload.params.get(LUMA_COEFFS_INPUT),
+ Some(&NodeValue::Vec3([0.2126, 0.7152, 0.0722]))
+ );
}
#[test]
diff --git a/crates/oak-node/src/nodes/dropshadowfilter.rs b/crates/oak-node/src/nodes/dropshadowfilter.rs
index 6ccc1b505..d29e25949 100644
--- a/crates/oak-node/src/nodes/dropshadowfilter.rs
+++ b/crates/oak-node/src/nodes/dropshadowfilter.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -49,6 +50,12 @@ pub const OPACITY_INPUT: &str = "opacity_in";
/// default `false`.
pub const FAST_INPUT: &str = "fast_in";
+/// Iterative-input texture id (C++ ShaderJob param
+/// `previous_iteration_in`). Not a real node input: the C++ `value()`
+/// inserts it into the job params directly, so it only exists here as
+/// the feedback slot between the two blur passes and the merge step.
+pub const ITERATIVE_INPUT: &str = "previous_iteration_in";
+
/// Drop shadow filter node. Adds a colored, blurred, offset copy of the
/// input's alpha behind the image. The C++ class declares no own member
/// fields.
@@ -222,10 +229,14 @@ impl NodeBehavior for DropShadowFilter {
/// the input texture; when softness is non-zero the job runs 3
/// iterations feeding back through `previous_iteration_in`.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` / `previous_iteration_in` bindings and the 3-iteration
- /// feedback when softness != 0) is deferred to the renderer seam
- /// (`// CPP-PARITY: dropshadowfilter.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; `resolution_in`
+ /// is filled by the runner from the input texture's size. The
+ /// feedback is carried both as metadata (`iterations` = 3 and
+ /// `iterative_input` = `previous_iteration_in` when softness != 0,
+ /// else 1 / empty) and as an unconditional `previous_iteration_in`
+ /// entry in `params`, mirroring the C++ unconditional `SetParam`
+ /// (`// CPP-PARITY: dropshadowfilter.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -233,16 +244,45 @@ impl NodeBehavior for DropShadowFilter {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- if !matches!(
- inputs.get(TEXTURE_INPUT),
- Some(crate::value::NodeValue::Texture(_))
- ) {
- return;
- }
- let _ = (core, time, inputs);
+ let tex = match inputs.get(TEXTURE_INPUT) {
+ Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
+ _ => return,
+ };
+ let softness = match inputs.get(SOFTNESS_INPUT) {
+ Some(v) => v.to_double(),
+ None => core.value_at_time(SOFTNESS_INPUT, -1, time).to_double(),
+ };
+
+ // C++ unconditionally binds the input texture to
+ // `previous_iteration_in` (SetParam), so the params row always
+ // carries the entry even when the job runs a single iteration.
+ let mut params = inputs.clone();
+ params.insert(ITERATIVE_INPUT.to_string(), tex);
+
+ // C++ `if (!qIsNull(softness)) job.SetIterations(3)`: the merge
+ // step (ove_iteration == 2) needs two blur passes first, so a
+ // non-zero softness runs 3 iterations feeding back through
+ // `previous_iteration_in`.
+ let softness_nonzero = softness != 0.0;
+ let iterations = if softness_nonzero { 3 } else { 1 };
+ let iterative_input = if softness_nonzero {
+ ITERATIVE_INPUT.to_string()
+ } else {
+ String::new()
+ };
+
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input,
+ })),
None,
);
}
@@ -384,12 +424,50 @@ mod tests {
}
#[test]
- fn value_with_texture_pushes_deferred_job() {
+ fn value_with_softness_pushes_three_iteration_job() {
let (core, behavior) = create();
- let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
+ let inputs = crate::value::NodeValueRow::from([
+ (TEXTURE_INPUT.to_string(), tex()),
+ (SOFTNESS_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());
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => {
+ let payload = unsafe { crate::handle::get_checked::(h) }
+ .expect("shader job payload boxed");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.dropshadow");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 3);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ assert_eq!(payload.iterative_input, ITERATIVE_INPUT);
+ assert!(payload.params.contains_key(ITERATIVE_INPUT));
+ }
+ _ => panic!("texture expected"),
+ }
+ }
+
+ #[test]
+ fn value_zero_softness_pushes_single_iteration_job() {
+ let (core, behavior) = create();
+ let inputs = crate::value::NodeValueRow::from([
+ (TEXTURE_INPUT.to_string(), tex()),
+ (SOFTNESS_INPUT.to_string(), NodeValue::Float(0.0)),
+ ]);
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => {
+ let payload = unsafe { crate::handle::get_checked::(h) }
+ .expect("shader job payload boxed");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.iterative_input, "");
+ // The params row still carries the unconditional
+ // `previous_iteration_in` binding (C++ SetParam).
+ assert!(payload.params.contains_key(ITERATIVE_INPUT));
+ }
+ _ => panic!("texture expected"),
+ }
}
#[test]
diff --git a/crates/oak-node/src/nodes/flipdistortnode.rs b/crates/oak-node/src/nodes/flipdistortnode.rs
index 65e207cd6..7a858989a 100644
--- a/crates/oak-node/src/nodes/flipdistortnode.rs
+++ b/crates/oak-node/src/nodes/flipdistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -105,6 +106,11 @@ impl NodeBehavior for FlipDistortNode {
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// either flip flag set -> shader job over the whole value row;
/// neither set -> pass-through push of the input texture unchanged.
+ ///
+ /// The job case boxes a [`ShaderJobPayload`] that the renderer's
+ /// resolve hook executes and replaces with the result texture; the
+ /// params row carries the input texture and uniforms, keyed by the
+ /// effect input (`// CPP-PARITY: flipdistortnode.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -127,13 +133,21 @@ impl NodeBehavior for FlipDistortNode {
};
if horiz || vert {
- // C++ pushes `tex->to_job(ShaderJob(value))` (the whole row as
- // job values); the Rust model defers the job to the renderer
- // seam, so a null handle marks "renderer must produce this
- // texture" (`// CPP-PARITY: flipdistortnode.cpp` value()).
+ // The shader-job box (C++ `tex->toJob(ShaderJob(value))`): the
+ // behavior's type id selects the fragment source, and the effect
+ // input key locates the main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: TEXTURE_INPUT.to_string(),
+ })),
None,
);
} else {
@@ -247,13 +261,24 @@ mod tests {
}
#[test]
- fn value_flip_pushes_deferred_job() {
+ fn value_flip_pushes_shader_job_payload() {
let (mut core, behavior) = create();
core.set_standard_value(VERTICAL_INPUT, -1, NodeValue::Boolean(true));
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());
+ let handle = match table.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe { crate::handle::get_checked::(&handle) }
+ .expect("shader job payload expected");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.flip");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ assert_eq!(payload.time, Rational::new(0, 1));
+ assert!(payload.params.contains_key(TEXTURE_INPUT));
}
#[test]
@@ -265,7 +290,14 @@ 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 handle = match table.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe { crate::handle::get_checked::(&handle) }
+ .expect("shader job payload expected");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.flip");
+ assert_eq!(payload.iterations, 1);
}
#[test]
diff --git a/crates/oak-node/src/nodes/generatorwithmerge.rs b/crates/oak-node/src/nodes/generatorwithmerge.rs
index 97d8957ea..90b0c7170 100644
--- a/crates/oak-node/src/nodes/generatorwithmerge.rs
+++ b/crates/oak-node/src/nodes/generatorwithmerge.rs
@@ -22,6 +22,7 @@
//! base texture. Not instantiable, so this is a helper module, not a
//! [`NodeBehavior`] implementation.
+use crate::nodes::jobs::ShaderJobPayload;
use crate::value::NodeValue;
/// Base texture input id (C++ `k_base_input`). Type: texture; flags:
@@ -87,12 +88,15 @@ impl GeneratorWithMerge {
/// `MergeNode::k_blend_in`, pushing `base->to_job(merge)`; without
/// a base, pushes the generated job unchanged.
///
- /// The Rust model has no shader-job payload (see
- /// [`crate::nodes::mathbase`]): the merged case pushes a null
- /// texture handle marking a renderer-deferred `"mrg"` shader job,
- /// and the un-merged case pushes `job` itself. `job` is an opaque
- /// oakrender texture handle (cross-module payload; null in the
- /// deferred-job model) — see [`crate::value::NodeValue::Texture`].
+ /// `job` boxes the generated job's [`ShaderJobPayload`]. Without a
+ /// base the box is pushed through as-is (addref'd, so the table's
+ /// reference outlives the caller's handle). With a base, a new
+ /// `"mrg"` payload is boxed whose params row carries the base
+ /// texture under [`BASE_INPUT`] and the generated job (the blend
+ /// layer) under [`crate::nodes::merge::BLEND_INPUT`], with
+ /// `type_id`/`time` taken from the generated job — mirroring the
+ /// C++ `merge` shader job. A null or foreign `job` handle (the
+ /// legacy deferred model) keeps the old placeholder behavior.
pub fn push_mergable_job(
inputs: &crate::value::NodeValueRow,
job: crate::handle::CHandle,
@@ -101,19 +105,54 @@ impl GeneratorWithMerge {
match inputs.get(BASE_INPUT) {
Some(NodeValue::Texture(_)) => {
// A base is connected: the C++ pushes
- // `base->to_job(ShaderJob("mrg"))` — a deferred alpha-over
- // merge of the generated texture over the base.
+ // `base->to_job(ShaderJob("mrg"))` — an alpha-over merge
+ // of the generated texture over the base.
// `// CPP-PARITY: generatorwithmerge.cpp` push_mergable_job.
- table.push(
- crate::value::ValueType::Texture,
- NodeValue::Texture(crate::handle::CHandle::null()),
- None,
- );
+ match unsafe { crate::handle::get_checked::(&job) } {
+ Some(gen_job) => {
+ let mut params = crate::value::NodeValueRow::new();
+ params.insert(
+ BASE_INPUT.to_string(),
+ inputs
+ .get(BASE_INPUT)
+ .cloned()
+ .expect("base input matched above"),
+ );
+ params.insert(
+ crate::nodes::merge::BLEND_INPUT.to_string(),
+ NodeValue::Texture(unsafe { job.addref() }),
+ );
+ table.push(
+ crate::value::ValueType::Texture,
+ NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time: gen_job.time,
+ iterations: 1,
+ type_id: gen_job.type_id.clone(),
+ shader_id: "mrg".to_string(),
+ effect_input: BASE_INPUT.to_string(),
+ params,
+ iterative_input: String::new(),
+ })),
+ None,
+ );
+ }
+ None => {
+ // Legacy null-handle deferred model: keep the
+ // placeholder — TODO: drop once every generator
+ // boxes a job payload.
+ table.push(
+ crate::value::ValueType::Texture,
+ NodeValue::Texture(crate::handle::CHandle::null()),
+ None,
+ );
+ }
+ }
}
_ => {
table.push(
crate::value::ValueType::Texture,
- NodeValue::Texture(job),
+ NodeValue::Texture(unsafe { job.addref() }),
None,
);
}
@@ -125,9 +164,37 @@ impl GeneratorWithMerge {
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
+ use oak_core::Rational;
#[test]
- fn push_job_without_base_pushes_job() {
+ fn push_job_without_base_pushes_job_unchanged() {
+ let job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.solidgenerator".to_string(),
+ time: Rational::new(2, 1),
+ shader_id: "1".to_string(),
+ ..Default::default()
+ });
+ let mut table = NodeValueTable::default();
+ GeneratorWithMerge::push_mergable_job(
+ &crate::value::NodeValueRow::default(),
+ job,
+ &mut table,
+ );
+ let handle = match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe {
+ crate::handle::get_checked::(&handle)
+ }
+ .expect("job payload boxed");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.solidgenerator");
+ assert_eq!(payload.shader_id, "1");
+ assert_eq!(payload.time, Rational::new(2, 1));
+ }
+
+ #[test]
+ fn push_job_without_base_null_job_pushes_null() {
let job = crate::handle::CHandle::null();
let mut table = NodeValueTable::default();
GeneratorWithMerge::push_mergable_job(
@@ -142,7 +209,46 @@ mod tests {
}
#[test]
- fn push_job_with_base_pushes_deferred_merge() {
+ fn push_job_with_base_boxes_merge_payload() {
+ let gen_job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.solidgenerator".to_string(),
+ time: Rational::new(2, 1),
+ shader_id: "1".to_string(),
+ ..Default::default()
+ });
+ let base = NodeValue::Texture(crate::handle::make_owned::(7));
+ let inputs = crate::value::NodeValueRow::from([(BASE_INPUT.to_string(), base.clone())]);
+ let mut table = NodeValueTable::default();
+ GeneratorWithMerge::push_mergable_job(&inputs, gen_job, &mut table);
+
+ let handle = match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => *h,
+ _ => panic!("texture expected"),
+ };
+ let merge = unsafe {
+ crate::handle::get_checked::(&handle)
+ }
+ .expect("merge job payload boxed");
+ assert_eq!(merge.shader_id, "mrg");
+ assert_eq!(merge.type_id, "org.olivevideoeditor.Olive.solidgenerator");
+ assert_eq!(merge.time, Rational::new(2, 1));
+ assert_eq!(merge.iterations, 1);
+ assert_eq!(merge.effect_input, BASE_INPUT);
+ assert_eq!(merge.params.get(BASE_INPUT), Some(&base));
+ let blend = match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
+ Some(NodeValue::Texture(h)) => *h,
+ _ => panic!("texture expected"),
+ };
+ let blend_job = unsafe {
+ crate::handle::get_checked::(&blend)
+ }
+ .expect("blend job payload boxed");
+ assert_eq!(blend_job.type_id, "org.olivevideoeditor.Olive.solidgenerator");
+ assert_eq!(blend_job.shader_id, "1");
+ }
+
+ #[test]
+ fn push_job_with_base_null_job_keeps_placeholder() {
let job = crate::handle::CHandle::null();
let inputs = crate::value::NodeValueRow::from([(
BASE_INPUT.to_string(),
@@ -150,9 +256,9 @@ mod tests {
)]);
let mut table = NodeValueTable::default();
GeneratorWithMerge::push_mergable_job(&inputs, job, &mut table);
- assert!(
- table.get(ValueType::Texture).is_some(),
- "merge job placeholder pushed"
- );
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => assert!(h.is_null()),
+ _ => panic!("texture expected"),
+ }
}
}
diff --git a/crates/oak-node/src/nodes/jobs.rs b/crates/oak-node/src/nodes/jobs.rs
new file mode 100644
index 000000000..bae99caaa
--- /dev/null
+++ b/crates/oak-node/src/nodes/jobs.rs
@@ -0,0 +1,93 @@
+// Oak Video Editor - Non-Linear Video Editor
+// Copyright (C) 2026 Oak Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+//! Render-job payloads (C++ `app/render/job/footagejob.h`,
+//! `app/render/job/shaderjob.h`): boxed inside `Texture` values during
+//! graph evaluation, then resolved to real textures by the render hooks
+//! ([`crate::traverser::RenderHooks::resolve`]).
+//! `// CPP-PARITY: app/render/job/footagejob.h, shaderjob.h`.
+
+use oak_core::Rational;
+
+use crate::id::NodeId;
+use crate::value::NodeValueRow;
+
+/// C++ `FootageJob` payload: the decode request a footage node emits at
+/// its output instead of a texture. The render hooks decode it at the
+/// request time and replace it with the resulting frame.
+#[derive(Clone, Debug)]
+pub struct FootageJobPayload {
+ /// Footage file path.
+ pub filename: String,
+ /// Container stream index.
+ pub stream_index: i32,
+ /// Request time in media seconds.
+ pub time: Rational,
+}
+
+/// C++ `ShaderJob` payload: the GPU shader pass a node emits at its
+/// output. The fragment shader is looked up by `type_id`/`shader_id` in
+/// the node behavior; the param row carries the uniforms (including the
+/// effect input texture, keyed by `effect_input`).
+#[derive(Clone, Debug)]
+pub struct ShaderJobPayload {
+ /// Emitting node identity (for diagnostics).
+ pub node_id: NodeId,
+ /// Request time in media seconds.
+ pub time: Rational,
+ /// Pass iterations (C++ `ShaderJob::iterations`).
+ pub iterations: i32,
+ /// Node behavior type id — the pipeline cache key and the lookup key
+ /// for the emitting node (C++ `job.node`).
+ pub type_id: String,
+ /// Shader variant id passed to the behavior's `shader_code()` (C++
+ /// `ShaderJob::shader_id`); empty for the default variant.
+ pub shader_id: String,
+ /// Effect input id: the param row key carrying the main input texture
+ /// (C++ `node->GetEffectInput()`).
+ pub effect_input: String,
+ /// The param row at evaluation time (C++ `ShaderJob::params`): uniform
+ /// values keyed by input id, the effect input texture among them.
+ pub params: NodeValueRow,
+ /// The texture the iterative passes feed back into (C++ `ShaderJob::
+ /// iterative_input`; empty = the effect input).
+ pub iterative_input: String,
+}
+
+impl Default for FootageJobPayload {
+ fn default() -> Self {
+ FootageJobPayload {
+ filename: String::new(),
+ stream_index: 0,
+ time: Rational::new(0, 1),
+ }
+ }
+}
+
+impl Default for ShaderJobPayload {
+ fn default() -> Self {
+ ShaderJobPayload {
+ node_id: NodeId::INVALID,
+ time: Rational::new(0, 1),
+ iterations: 1,
+ type_id: String::new(),
+ shader_id: String::new(),
+ effect_input: String::new(),
+ params: NodeValueRow::new(),
+ iterative_input: String::new(),
+ }
+ }
+}
diff --git a/crates/oak-node/src/nodes/mask.rs b/crates/oak-node/src/nodes/mask.rs
index 92ee7bc60..1362404e5 100644
--- a/crates/oak-node/src/nodes/mask.rs
+++ b/crates/oak-node/src/nodes/mask.rs
@@ -323,6 +323,14 @@ impl NodeBehavior for MaskDistortNode {
/// feather value, `resolution_in` from the texture or the global
/// square resolution); without a base texture pushes the matte
/// itself.
+ ///
+ /// The chain starts with a CPU rasterization of the polygon matte
+ /// (C++ `get_generate_job`), which a [`ShaderJobPayload`] cannot
+ /// express — the payload has no generate phase. The output is kept
+ /// as a null texture handle marking "renderer must produce this
+ /// texture"; expressing the rasterize -> (optional `"invert"`) ->
+ /// (optional `"feather"` nested in) `"mrg"` multiply chain as
+ /// payloads is a renderer TODO (`// CPP-PARITY: mask.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -330,17 +338,7 @@ impl NodeBehavior for MaskDistortNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
- let _ = inputs;
- // `// CPP-PARITY: mask.cpp` `value()` — the C++ rasterizes the
- // polygon matte via the inherited `get_generate_job`, optionally
- // wraps it in an `"invert"` shader job, then pushes an `"mrg"`
- // multiply merge over `base_in` (nesting a two-iteration
- // gaussian `"feather"` blur job when `feather_in` > 0.0) — or
- // the bare matte job when there is no base texture. Every
- // outcome is a renderer-deferred job in the Rust model (the
- // Rust polygon base provides no generate job either), so a
- // single null texture handle marks the result.
+ let _ = (core, inputs, time);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
diff --git a/crates/oak-node/src/nodes/math.rs b/crates/oak-node/src/nodes/math.rs
index 87ca792f6..a61275bf8 100644
--- a/crates/oak-node/src/nodes/math.rs
+++ b/crates/oak-node/src/nodes/math.rs
@@ -145,6 +145,8 @@ impl NodeBehavior for MathNode {
&calc.most_likely_value_a,
PARAM_B_INPUT,
&calc.most_likely_value_b,
+ time,
+ self.type_id(),
core,
inputs,
table,
@@ -398,4 +400,61 @@ mod tests {
assert_eq!(out.sample_value(0, 0), 3.0);
assert_eq!(out.sample_value(0, 1), 6.0);
}
+
+ #[test]
+ fn value_texture_multiplied_pushes_job_payload() {
+ let (mut core, behavior) = create();
+ core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2)); // Multiply
+ let tex = NodeValue::Texture(crate::handle::make_owned::(7));
+ let inputs = row(&[
+ (PARAM_A_INPUT, tex.clone()),
+ (PARAM_B_INPUT, NodeValue::Float(2.0)),
+ ]);
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(7, 1), &mut table);
+ let handle = match table.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe {
+ crate::handle::get_checked::(&handle)
+ }
+ .expect("shader job payload boxed in the pushed texture");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.math");
+ // op=2 (multiply), pairing=8 (texture_number), a=10 (texture),
+ // b=2 (float).
+ assert_eq!(payload.shader_id, "2.8.10.2");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, "");
+ assert_eq!(payload.time, Rational::new(7, 1));
+ assert_eq!(payload.params.get(PARAM_A_INPUT), Some(&tex));
+ assert_eq!(
+ payload.params.get(PARAM_B_INPUT),
+ Some(&NodeValue::Float(2.0))
+ );
+ }
+
+ #[test]
+ fn value_null_texture_pushes_no_payload() {
+ let (mut core, behavior) = create();
+ core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2)); // Multiply
+ let inputs = row(&[
+ (
+ PARAM_A_INPUT,
+ NodeValue::Texture(crate::handle::CHandle::null()),
+ ),
+ (PARAM_B_INPUT, NodeValue::Float(2.0)),
+ ]);
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ // Null texture operand -> no-op push-through, not a job payload.
+ let handle = match table.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe {
+ crate::handle::get_checked::(&handle)
+ };
+ assert!(payload.is_none(), "no shader job for a null texture");
+ }
}
diff --git a/crates/oak-node/src/nodes/mathbase.rs b/crates/oak-node/src/nodes/mathbase.rs
index f4efa06cf..d8abd564b 100644
--- a/crates/oak-node/src/nodes/mathbase.rs
+++ b/crates/oak-node/src/nodes/mathbase.rs
@@ -22,6 +22,7 @@
//! by `MathNode` (and conceptually other binary math nodes).
use crate::node::NodeCore;
+use crate::nodes::jobs::ShaderJobPayload;
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
/// Binary operation (C++ `MathNodeBase::Operation`).
@@ -380,14 +381,18 @@ impl MathNodeBase {
/// vec/vec (zero-padding divide guard), matrix*vec, vec/number,
/// matrix/matrix, color+/-color, color*number, sample buffers
/// (elementwise, longer tail memcpy'd), texture pairings (shader job
- /// with `"op.pairing.ta.tb"` id; no-op push-through when the texture
- /// is null, the number is identity, or the matrix is identity), and
- /// sample*number (static: in-place SIMD loop; dynamic: sample job).
+ /// payload boxed in the texture value with `"op.pairing.ta.tb"` id;
+ /// no-op push-through when the texture is null, the number is
+ /// identity, or the matrix is identity), and sample*number (static:
+ /// in-place SIMD loop; dynamic: sample job).
///
/// `core`/`inputs` carry the node's input state so the sample*number
/// branch can tell a static number (in-place transform) from a
/// dynamic one (deferred sample job), mirroring the C++ `this`
- /// member access in `is_input_static(number_param)`.
+ /// member access in `is_input_static(number_param)`. `time` is the
+ /// job's request timestamp and `type_id` the emitting behavior's
+ /// type id — the caller (e.g. `MathNode::value`) forwards its `time`
+ /// argument and `self.type_id()`.
pub fn value_internal(
operation: Operation,
pairing: Pairing,
@@ -395,6 +400,8 @@ impl MathNodeBase {
val_a: &NodeValue,
param_b_in: &str,
val_b: &NodeValue,
+ time: oak_core::Rational,
+ type_id: &str,
core: &NodeCore,
inputs: &NodeValueRow,
output: &mut NodeValueTable,
@@ -607,19 +614,37 @@ impl MathNodeBase {
// Just push texture as-is.
output.push(ValueType::Texture, texture_val.clone(), None);
} else {
- // Push a texture-typed value representing the deferred
- // shader job. The C++ pushes `Texture::job(...)`
- // carrying the `ShaderJob` (with the
- // `"op.pairing.type_a.type_b"` id), which the renderer
- // resolves via `get_shader_code`; the Rust model defers
- // the job to the renderer seam
- // (`traverser::RenderHooks::resolve`), so the job
- // payload is not representable and a null handle marks
- // "renderer must produce this texture".
+ // Push a texture value boxing the deferred shader job
+ // (C++ `Texture::job(ShaderJob)`), which the renderer
+ // seam (`traverser::RenderHooks::resolve`) downcasts
+ // and resolves via `get_shader_code`. The id encodes
+ // the operation, the pairing, and the actual operand
+ // types; the params row carries the two operands under
+ // their input ids, mirroring C++ `job.Insert(param_a_in,
+ // val_a); job.Insert(param_b_in, val_b)`.
// `// CPP-PARITY: mathbase.cpp` texture pairings.
+ let shader_id = format!(
+ "{}.{}.{}.{}",
+ operation as i32,
+ pairing as i32,
+ val_a.value_type().to_cpp_discriminant(),
+ val_b.value_type().to_cpp_discriminant()
+ );
output.push(
ValueType::Texture,
- NodeValue::Texture(crate::handle::CHandle::null()),
+ NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: type_id.to_string(),
+ shader_id,
+ effect_input: core.effect_input.clone(),
+ params: NodeValueRow::from([
+ (param_a_in.to_string(), val_a.clone()),
+ (param_b_in.to_string(), val_b.clone()),
+ ]),
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -1050,6 +1075,8 @@ mod tests {
&NodeValue::Float(2.0),
"b",
&NodeValue::Float(3.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1067,6 +1094,8 @@ mod tests {
&NodeValue::Rational(Rational::new(1, 2)),
"b",
&NodeValue::Rational(Rational::new(1, 3)),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1084,6 +1113,8 @@ mod tests {
&NodeValue::Rational(Rational::new(2, 1)),
"b",
&NodeValue::Rational(Rational::new(3, 1)),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1101,6 +1132,8 @@ mod tests {
&NodeValue::Vec2([1.0, 4.0]),
"b",
&NodeValue::Vec2([2.0, 2.0]),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1118,6 +1151,8 @@ mod tests {
&NodeValue::Vec3([1.0, 2.0, 3.0]),
"b",
&NodeValue::Float(2.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1147,6 +1182,8 @@ mod tests {
&NodeValue::Matrix(m),
"b",
&NodeValue::Vec2([10.0, 20.0]),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1168,6 +1205,8 @@ mod tests {
&NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
"b",
&NodeValue::Color([0.5, 0.5, 0.0, 0.0]),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1185,6 +1224,8 @@ mod tests {
&NodeValue::Float(0.5),
"b",
&NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1223,6 +1264,8 @@ mod tests {
&a,
"b",
&b,
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1252,6 +1295,8 @@ mod tests {
&samples,
"number_in",
&NodeValue::Float(2.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&core,
&inputs,
&mut out,
@@ -1280,6 +1325,8 @@ mod tests {
&samples,
"number_in",
&NodeValue::Float(1.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1379,6 +1426,8 @@ mod tests {
&tex,
"num_in",
&NodeValue::Float(1.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1403,6 +1452,8 @@ mod tests {
&tex,
"num_in",
&NodeValue::Float(1.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1415,7 +1466,7 @@ mod tests {
}
#[test]
- fn value_texture_number_job_placeholder() {
+ fn value_texture_number_pushes_job_payload() {
let tex = crate::value::NodeValue::Texture(crate::handle::make_owned::(7));
let mut out = NodeValueTable::default();
MathNodeBase::value_internal(
@@ -1425,14 +1476,28 @@ mod tests {
&tex,
"num_in",
&NodeValue::Float(2.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
);
- match out.get(ValueType::Texture).unwrap() {
- NodeValue::Texture(h) => assert!(h.is_null(), "deferred job placeholder"),
- _ => panic!("texture"),
+ let handle = match out.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe {
+ crate::handle::get_checked::(&handle)
}
+ .expect("shader job payload boxed in the pushed texture");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.math");
+ // op=2 (multiply), pairing=8 (texture_number), a=10 (texture),
+ // b=2 (float).
+ assert_eq!(payload.shader_id, "2.8.10.2");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, "");
+ assert_eq!(payload.params.get("tex_in"), Some(&tex));
+ assert_eq!(payload.params.get("num_in"), Some(&NodeValue::Float(2.0)));
}
#[test]
@@ -1446,6 +1511,8 @@ mod tests {
&tex,
"mat_in",
&NodeValue::Matrix(identity_matrix()),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1483,6 +1550,8 @@ mod tests {
&samples,
"num_in",
&NodeValue::Float(2.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&core,
&NodeValueRow::default(),
&mut out,
@@ -1504,6 +1573,8 @@ mod tests {
&NodeValue::Vec2([1.0, 2.0]),
"b",
&NodeValue::Vec3([1.0, 1.0, 1.0]),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1524,6 +1595,8 @@ mod tests {
&NodeValue::Vec2([4.0, 8.0]),
"b",
&NodeValue::Float(2.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1545,6 +1618,8 @@ mod tests {
&NodeValue::Matrix(a),
"b",
&NodeValue::Matrix(b),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1592,6 +1667,8 @@ mod tests {
&NodeValue::Rational(a),
"b",
&NodeValue::Rational(b),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1719,6 +1796,8 @@ mod tests {
&NodeValue::Color([1.0, 2.0, 3.0, 4.0]),
"b",
&NodeValue::Color([0.5, 0.5, 0.5, 0.5]),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
@@ -1739,6 +1818,8 @@ mod tests {
&NodeValue::Float(1.0),
"b",
&NodeValue::Float(2.0),
+ Rational::new(0, 1),
+ "org.olivevideoeditor.Olive.math",
&NodeCore::new(),
&NodeValueRow::default(),
&mut out,
diff --git a/crates/oak-node/src/nodes/merge.rs b/crates/oak-node/src/nodes/merge.rs
index a6d12e3e5..191ffb582 100644
--- a/crates/oak-node/src/nodes/merge.rs
+++ b/crates/oak-node/src/nodes/merge.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Base (background) texture input id (C++ `k_base_in`). Type:
/// texture; flags: not-keyframable.
@@ -117,13 +118,17 @@ impl NodeBehavior for MergeNode {
/// present, push a shader job over the base texture with the whole
/// input row as job values; if neither, push nothing.
///
- /// The Rust model has no shader-job payload: the both-present case
- /// pushes a null texture handle marking a renderer-deferred
- /// alpha-over job resolved via [`Self::shader_code`]
- /// (`// CPP-PARITY: merge.cpp` `value()`). The "blend has fewer
- /// than 4 channels" check needs the texture's channel count, which
- /// the Rust texture handle does not carry, so the alpha-less blend
- /// case is only distinguishable by presence here.
+ /// The both-present case boxes a [`ShaderJobPayload`] that the
+ /// renderer's resolve hook executes and replaces with the result
+ /// texture; the params row carries both input textures, keyed by
+ /// their input ids. The C++ `MergeNode` constructor never sets an
+ /// effect input, so `effect_input` is empty and the runner has no
+ /// main texture to bind — binding `base_in`/`blend_in` explicitly is
+ /// a renderer TODO. The "blend has fewer than 4 channels" check
+ /// needs the texture's channel count, which the Rust texture handle
+ /// does not carry, so the alpha-less blend case is only
+ /// distinguishable by presence here (`// CPP-PARITY: merge.cpp`
+ /// `value()`).
fn value(
&self,
core: &NodeCore,
@@ -131,7 +136,6 @@ impl NodeBehavior for MergeNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
let base = inputs.get(BASE_INPUT);
let blend = inputs.get(BLEND_INPUT);
@@ -140,15 +144,25 @@ impl NodeBehavior for MergeNode {
Some(b @ crate::value::NodeValue::Texture(_)),
Some(bl @ crate::value::NodeValue::Texture(_)),
) => {
- // Both present: alpha-over shader job. The C++ checks
- // the blend channel count here (RGBA required for an
- // alpha to over with) and pushes the blend as-is when it
- // has no alpha channel — not representable without the
- // texture params (`// CPP-PARITY: merge.cpp`).
+ // Both present: alpha-over shader job (C++
+ // `base_tex->toJob(ShaderJob(value))`). The C++ checks the
+ // blend channel count here (RGBA required for an alpha to over
+ // with) and pushes the blend as-is when it has no alpha
+ // channel — not representable without the texture params
+ // (`// CPP-PARITY: merge.cpp`).
let _ = (b, bl);
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -262,7 +276,7 @@ mod tests {
}
#[test]
- fn value_both_pushes_deferred_job() {
+ fn value_both_pushes_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(BASE_INPUT.to_string(), tex()),
@@ -270,7 +284,19 @@ mod tests {
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
- assert!(table.get(ValueType::Texture).is_some());
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => {
+ let payload = unsafe { crate::handle::get_checked::(h) }
+ .expect("shader job payload boxed");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.merge");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.iterative_input, "");
+ assert!(payload.params.contains_key(BASE_INPUT));
+ assert!(payload.params.contains_key(BLEND_INPUT));
+ }
+ _ => panic!("texture expected"),
+ }
}
#[test]
diff --git a/crates/oak-node/src/nodes/mod.rs b/crates/oak-node/src/nodes/mod.rs
index a1e277b36..4777ed4e9 100644
--- a/crates/oak-node/src/nodes/mod.rs
+++ b/crates/oak-node/src/nodes/mod.rs
@@ -19,7 +19,7 @@
//! [`crate::sequence`]). Each registers with the factory via
//! [`register_all`].
-mod blur;
+pub mod blur;
mod chromakey;
mod colordifferencekey;
mod cornerpindistortnode;
@@ -30,6 +30,7 @@ mod dropshadowfilter;
mod flipdistortnode;
mod generatorwithmerge;
pub mod group;
+pub mod jobs;
mod mask;
mod math;
mod mathbase;
@@ -42,7 +43,7 @@ mod ociobase;
mod ociogradingtransformlinear;
mod ociogradingtransformlog;
mod ociolut;
-mod opacity;
+pub mod opacity;
mod pan;
pub mod plugin;
mod polygon;
diff --git a/crates/oak-node/src/nodes/mosaicfilternode.rs b/crates/oak-node/src/nodes/mosaicfilternode.rs
index bc293618c..13455ef4d 100644
--- a/crates/oak-node/src/nodes/mosaicfilternode.rs
+++ b/crates/oak-node/src/nodes/mosaicfilternode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -111,15 +112,13 @@ impl NodeBehavior for MosaicFilterNode {
}
}
- /// Evaluate outputs (C++ `value()`): no texture -> push nothing; if
- /// the block counts already equal the texture's pixel dimensions ->
- /// pass-through push of the input texture; otherwise push a shader
- /// job with bilinear interpolation forced on `tex_in` (mipmapping
- /// makes block colors look wrong).
+ /// Evaluate outputs (C++ `value()`): no texture -> push nothing;
+ /// otherwise queue the input texture as a shader job.
///
- /// The "block counts equal the pixel dimensions" check compares the
- /// input values against the texture's width/height, which the Rust
- /// texture handle does not carry — so the pass-through optimization
+ /// The C++ pass-through optimization — when the block counts already
+ /// equal the texture's pixel dimensions, push the input texture
+ /// unchanged — compares the input values against the texture's
+ /// width/height, which the Rust texture handle does not carry, so it
/// is not representable and a shader job is always queued when a
/// texture is present (`// CPP-PARITY: mosaicfilternode.cpp` value()).
fn value(
@@ -135,10 +134,33 @@ impl NodeBehavior for MosaicFilterNode {
) {
return;
}
- let _ = (core, time, inputs);
+
+ // `// CPP-PARITY: mosaicfilternode.cpp` `value()` — the C++ pushes
+ // `tex->to_job(job)` when the block counts differ from the texture's
+ // pixel dimensions and passes the texture through when they match;
+ // the Rust texture handle carries no width/height, so the
+ // pass-through branch is not representable and a shader job is
+ // always queued here. The C++ also forces bilinear interpolation on
+ // `tex_in` (`job.SetInterpolation(tex_in, kLinear)`) so mipmapping
+ // does not smear block colors; [`ShaderJobPayload`] has no
+ // interpolation field and the renderer does not support it yet
+ // (TODO: carry interpolation on the payload and apply it in the
+ // renderer). The job is boxed here as a [`ShaderJobPayload`] that
+ // the renderer's resolve hook executes and replaces with the result
+ // texture; the params row is the whole input row.
+ let params = inputs.clone();
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -244,12 +266,35 @@ mod tests {
}
#[test]
- fn value_with_texture_pushes_deferred_job() {
+ fn value_with_texture_pushes_shader_job_payload() {
let (core, behavior) = create();
- let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
+ let inputs = crate::value::NodeValueRow::from([
+ (TEXTURE_INPUT.to_string(), tex()),
+ (HORIZ_INPUT.to_string(), NodeValue::Float(32.0)),
+ (VERT_INPUT.to_string(), NodeValue::Float(18.0)),
+ ]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
- assert!(table.get(ValueType::Texture).is_some());
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ panic!("expected a texture-typed value");
+ };
+ let payload =
+ unsafe { crate::handle::get_checked::(handle) }
+ .expect("payload boxed behind the handle");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.mosaicfilter");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ // The whole input row travels as the params; horiz/vert stay put
+ // under their input ids.
+ assert_eq!(
+ payload.params.get(HORIZ_INPUT),
+ Some(&NodeValue::Float(32.0))
+ );
+ assert_eq!(
+ payload.params.get(VERT_INPUT),
+ Some(&NodeValue::Float(18.0))
+ );
}
#[test]
diff --git a/crates/oak-node/src/nodes/noise.rs b/crates/oak-node/src/nodes/noise.rs
index 559b7fc04..391bc1a65 100644
--- a/crates/oak-node/src/nodes/noise.rs
+++ b/crates/oak-node/src/nodes/noise.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Base texture input id (C++ `k_base_in`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -127,15 +128,16 @@ impl NodeBehavior for NoiseGeneratorNode {
}
}
- /// Evaluate outputs (C++ `value()`): builds a shader job from the
- /// input row, additionally inserting `time_in` (current time in
- /// seconds as a float), then pushes a texture job using the base
- /// texture's params when connected, else the sequence video params.
+ /// Evaluate outputs (C++ `value()`): always pushes a shader job —
+ /// with a base texture connected it runs at the base's params,
+ /// otherwise at the sequence params. The params row is the whole
+ /// input row plus `time_in` (the request time in seconds as a
+ /// float, C++ `globals.time().in().toDouble()`).
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `time_in` value) is deferred to the renderer seam, so a null
- /// texture handle marks "renderer must produce this texture"
- /// (`// CPP-PARITY: noise.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the runner
+ /// fills the shader's `base_in_enabled` flag from the presence of
+ /// the `base_in` texture (`// CPP-PARITY: noise.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -143,12 +145,23 @@ impl NodeBehavior for NoiseGeneratorNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- // C++ always pushes a job — with a base texture connected it runs
- // at the base's params, otherwise at the sequence params.
- let _ = (core, inputs, time);
+ let mut params = inputs.clone();
+ params.insert(
+ "time_in".to_string(),
+ crate::value::NodeValue::Float(time.to_f64()),
+ );
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -238,25 +251,48 @@ mod tests {
}
#[test]
- fn value_always_pushes_deferred_job() {
+ fn value_pushes_shader_job() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
- Rational::new(0, 1),
+ Rational::new(3, 1),
&mut table,
);
- assert!(table.get(ValueType::Texture).is_some());
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let job =
+ unsafe { crate::handle::get_checked::(handle) }
+ .expect("noise output boxes a ShaderJobPayload");
+ assert_eq!(job.type_id, "org.olivevideoeditor.Olive.noise");
+ assert_eq!(job.shader_id, "");
+ assert_eq!(job.iterations, 1);
+ assert_eq!(job.effect_input, BASE_INPUT);
+ // The params row carries the request time as the `time_in` float
+ // (C++ `globals.time().in().toDouble()`).
+ assert_eq!(job.params.get("time_in").map(|v| v.to_double()), Some(3.0));
+ }
- // With a base texture connected the job is still pushed.
+ #[test]
+ fn value_with_base_texture_pushes_shader_job() {
+ let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
- assert!(table.get(ValueType::Texture).is_some());
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let job =
+ unsafe { crate::handle::get_checked::(handle) }
+ .expect("noise output boxes a ShaderJobPayload");
+ assert_eq!(job.type_id, "org.olivevideoeditor.Olive.noise");
+ assert!(job.params.contains_key(BASE_INPUT));
+ assert!(job.params.contains_key("time_in"));
}
#[test]
diff --git a/crates/oak-node/src/nodes/opacity.rs b/crates/oak-node/src/nodes/opacity.rs
index 9d9aac48b..23a1aa1db 100644
--- a/crates/oak-node/src/nodes/opacity.rs
+++ b/crates/oak-node/src/nodes/opacity.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -135,10 +136,10 @@ impl NodeBehavior for OpacityEffect {
/// != 1.0 -> plain shader job; opacity == 1.0 -> pass-through push
/// of the input texture unchanged.
///
- /// The Rust model has no shader-job payload: the two job cases push
- /// a null texture handle marking a renderer-deferred job resolved
- /// via [`Self::shader_code`] (`// CPP-PARITY: opacityeffect.cpp`
- /// `value()`).
+ /// The job cases box a [`ShaderJobPayload`] that the renderer's
+ /// resolve hook executes and replaces with the result texture; the
+ /// params row carries the input texture and uniforms, keyed by the
+ /// effect input (`// CPP-PARITY: opacityeffect.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -146,31 +147,39 @@ impl NodeBehavior for OpacityEffect {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
+ // The shader-job box (C++ ShaderJob): the behavior's type id plus
+ // the shader-variant id select the fragment source, and the effect
+ // input key locates the main texture inside the params row. `time`
+ // is diagnostics-only (C++ jobs keep the request timestamp).
+ let job = |shader_id: &str| -> crate::value::NodeValue {
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: shader_id.to_string(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ }))
+ };
+
match inputs.get(VALUE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
// Texture opacity input: rgbmult shader job.
- table.push(
- crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
- None,
- );
+ table.push(crate::value::ValueType::Texture, job("rgbmult"), None);
}
Some(v) => {
let opacity = v.to_double();
// Same semantics as `!qFuzzyCompare(opacity, 1.0)`
// (double overload).
if (opacity - 1.0).abs() * 1e12 > opacity.abs().min(1.0) {
- table.push(
- crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
- None,
- );
+ table.push(crate::value::ValueType::Texture, job(""), None);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
@@ -178,11 +187,7 @@ impl NodeBehavior for OpacityEffect {
None => {
let opacity = core.value_at_time(VALUE_INPUT, -1, time).to_double();
if (opacity - 1.0).abs() * 1e12 > opacity.abs().min(1.0) {
- table.push(
- crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
- None,
- );
+ table.push(crate::value::ValueType::Texture, job(""), None);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
diff --git a/crates/oak-node/src/nodes/polygon.rs b/crates/oak-node/src/nodes/polygon.rs
index 4e0f37148..aec8448b4 100644
--- a/crates/oak-node/src/nodes/polygon.rs
+++ b/crates/oak-node/src/nodes/polygon.rs
@@ -107,10 +107,14 @@ impl NodeBehavior for PolygonGenerator {
/// texture at the sequence video params and pushes it through
/// `push_mergable_job` (merged over `base_in` when connected).
///
- /// The Rust model has no generate/shader-job payloads: the deferred
- /// job chain (rasterize -> `"rgb"` recolor -> optional `"mrg"`
- /// alpha-over) is resolved by the renderer seam, so a null texture
- /// handle marks "renderer must produce this texture"
+ /// The C++ chain starts with `get_generate_job()` — a CPU
+ /// rasterization of the polygon path (QPainterPath bezier fill into
+ /// an RGBA8888 frame via `generate_frame()`), which a
+ /// [`ShaderJobPayload`] cannot express: the payload has no generate
+ /// phase, and the rasterize -> `"rgb"` recolor -> optional `"mrg"`
+ /// alpha-over chain has no Rust equivalent. The output is kept as a
+ /// null texture handle marking "renderer must produce this texture";
+ /// expressing the chain as payloads is a renderer TODO
/// (`// CPP-PARITY: polygon.cpp` `value()`).
fn value(
&self,
@@ -119,11 +123,11 @@ impl NodeBehavior for PolygonGenerator {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
- super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
- inputs,
- crate::handle::CHandle::null(),
- table,
+ let _ = (core, inputs, time);
+ table.push(
+ crate::value::ValueType::Texture,
+ crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ None,
);
}
diff --git a/crates/oak-node/src/nodes/rippledistortnode.rs b/crates/oak-node/src/nodes/rippledistortnode.rs
index 0e9dd20b5..428cd52ea 100644
--- a/crates/oak-node/src/nodes/rippledistortnode.rs
+++ b/crates/oak-node/src/nodes/rippledistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -142,14 +143,16 @@ impl NodeBehavior for RippleDistortNode {
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
- /// intensity != 0.0 -> shader job over the whole value row with
- /// `resolution_in` inserted from the texture's virtual resolution;
+ /// intensity != 0.0 -> shader job over the whole value row;
/// intensity == 0.0 -> pass-through push of the input texture
/// unchanged.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value) is deferred to the renderer seam
- /// (`// CPP-PARITY: rippledistortnode.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params row
+ /// carries the input texture and uniforms, keyed by the effect input,
+ /// and `resolution_in` is filled by the runner from the input
+ /// texture's size, matching the C++ insert of the texture's virtual
+ /// resolution (`// CPP-PARITY: rippledistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
@@ -168,9 +171,21 @@ impl NodeBehavior for RippleDistortNode {
};
if intensity != 0.0 {
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates the
+ // main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
@@ -351,7 +366,7 @@ mod tests {
}
#[test]
- fn value_nonzero_intensity_pushes_deferred_job() {
+ fn value_nonzero_intensity_pushes_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
@@ -359,7 +374,15 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let payload = unsafe { crate::handle::get_checked::(handle) }
+ .expect("ripple output boxes a ShaderJobPayload");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.ripple");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
diff --git a/crates/oak-node/src/nodes/shapenode.rs b/crates/oak-node/src/nodes/shapenode.rs
index 12c466cca..244b031b9 100644
--- a/crates/oak-node/src/nodes/shapenode.rs
+++ b/crates/oak-node/src/nodes/shapenode.rs
@@ -21,6 +21,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Shape type input id (C++ `k_type_input`). Type: combo; prepended
/// ahead of the base inputs; combo strings (matching the C++ `Type`
@@ -192,11 +193,15 @@ impl NodeBehavior for ShapeNode {
/// the sequence video params), and pushes it through
/// `push_mergable_job` (merged over `base_in` when connected).
///
- /// The Rust model has no shader-job payload: the deferred job
- /// (including the `resolution_in` value and the `"shape"` shader id)
- /// is resolved by the renderer seam, so a null texture handle marks
- /// "renderer must produce this texture" (`// CPP-PARITY: shapenode.cpp`
- /// `value()`).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; `resolution_in`
+ /// is filled by the runner from the frame size, so it is not part of
+ /// the params here. With a `base_in` texture connected, the C++
+ /// `push_mergable_job` instead pushes a `"mrg"` alpha-over job whose
+ /// `blend_in` is the shape job nested as a texture value — mirrored
+ /// here as a nested payload, which the renderer cannot yet
+ /// recursively resolve (TODO; `// CPP-PARITY: shapenode.cpp`
+ /// `value()`, `generatorwithmerge.cpp` `push_mergable_job`).
fn value(
&self,
core: &NodeCore,
@@ -204,12 +209,59 @@ impl NodeBehavior for ShapeNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
- super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
- inputs,
- crate::handle::CHandle::null(),
- table,
- );
+ // The `"shape"` shader job (C++ `value()`: `ShaderJob job(value);`
+ // `Insert("resolution_in")`; `SetShaderID("shape")`).
+ let shape_job = crate::value::NodeValue::Texture(crate::handle::make_owned(
+ ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: "shape".to_string(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ },
+ ));
+
+ match inputs.get(super::generatorwithmerge::BASE_INPUT) {
+ Some(base @ crate::value::NodeValue::Texture(_)) => {
+ // A base is connected: the C++ `push_mergable_job` pushes
+ // `base->toJob(ShaderJob("mrg"))` with `base_in` = the base
+ // texture and `blend_in` = the shape job nested as a texture
+ // value; the merge's params are a fresh row holding exactly
+ // those two keys, and the default `ShaderJob` has
+ // `iterations = 1` and no iterative input. Recursively
+ // resolving the nested payload is a renderer TODO
+ // (`// CPP-PARITY: generatorwithmerge.cpp`
+ // `push_mergable_job`).
+ let mut params = crate::value::NodeValueRow::new();
+ params.insert(
+ super::generatorwithmerge::BASE_INPUT.to_string(),
+ base.clone(),
+ );
+ params.insert(crate::nodes::merge::BLEND_INPUT.to_string(), shape_job);
+ table.push(
+ crate::value::ValueType::Texture,
+ crate::value::NodeValue::Texture(crate::handle::make_owned(
+ ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: "mrg".to_string(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input: String::new(),
+ },
+ )),
+ None,
+ );
+ }
+ _ => {
+ table.push(crate::value::ValueType::Texture, shape_job, None);
+ }
+ }
}
/// Shader code request (C++ `get_shader_code()`): `"shape"` returns
@@ -378,7 +430,7 @@ mod tests {
}
#[test]
- fn value_pushes_deferred_job() {
+ fn value_pushes_shape_job_payload() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
@@ -387,7 +439,54 @@ mod tests {
Rational::new(0, 1),
&mut table,
);
- assert!(table.get(ValueType::Texture).is_some());
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => {
+ let payload = unsafe { crate::handle::get_checked::(h) }
+ .expect("shader job payload boxed");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.shape");
+ assert_eq!(payload.shader_id, "shape");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(
+ payload.effect_input,
+ super::super::generatorwithmerge::BASE_INPUT
+ );
+ assert_eq!(payload.iterative_input, "");
+ }
+ _ => panic!("texture expected"),
+ }
+ }
+
+ #[test]
+ fn value_with_base_merges_nested_shape_job() {
+ let (core, behavior) = create();
+ let inputs = crate::value::NodeValueRow::from([(
+ super::super::generatorwithmerge::BASE_INPUT.to_string(),
+ NodeValue::Texture(crate::handle::CHandle::null()),
+ )]);
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => {
+ let merge = unsafe { crate::handle::get_checked::(h) }
+ .expect("merge job payload boxed");
+ assert_eq!(merge.shader_id, "mrg");
+ assert_eq!(merge.iterations, 1);
+ assert!(merge
+ .params
+ .contains_key(super::super::generatorwithmerge::BASE_INPUT));
+ // The shape job is nested as the merge's blend texture.
+ match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
+ Some(NodeValue::Texture(blend)) => {
+ let shape =
+ unsafe { crate::handle::get_checked::(blend) }
+ .expect("nested shape job payload boxed");
+ assert_eq!(shape.shader_id, "shape");
+ }
+ _ => panic!("nested blend job expected"),
+ }
+ }
+ _ => panic!("texture expected"),
+ }
}
#[test]
diff --git a/crates/oak-node/src/nodes/solid.rs b/crates/oak-node/src/nodes/solid.rs
index e8e2363e2..1abac09fd 100644
--- a/crates/oak-node/src/nodes/solid.rs
+++ b/crates/oak-node/src/nodes/solid.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Color input id (C++ `k_color_input`). Type: color; default
/// `(1.0, 0.0, 0.0, 1.0)` (red — "a color that isn't black").
@@ -77,12 +78,14 @@ impl NodeBehavior for SolidGenerator {
}
}
- /// Evaluate outputs (C++ `value()`): pushes a texture job built
- /// from the whole input row at the sequence video params.
+ /// Evaluate outputs (C++ `value()`): always pushes a shader job
+ /// built from the whole input row, run at the sequence video params
+ /// (the generator has no texture input to source params from).
///
- /// The Rust model has no shader-job payload: the job is deferred to
- /// the renderer seam, so a null texture handle marks "renderer must
- /// produce this texture" (`// CPP-PARITY: solid.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params
+ /// row carries the color uniform keyed by `color_in`
+ /// (`// CPP-PARITY: solid.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -90,10 +93,18 @@ impl NodeBehavior for SolidGenerator {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, inputs, time);
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -156,16 +167,32 @@ mod tests {
}
#[test]
- fn value_pushes_deferred_job() {
+ fn value_pushes_shader_job() {
let (core, behavior) = create();
+ let inputs = crate::value::NodeValueRow::from([(
+ COLOR_INPUT.to_string(),
+ NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
+ )]);
let mut table = NodeValueTable::default();
- behavior.value(
- &core,
- &crate::value::NodeValueRow::default(),
- Rational::new(0, 1),
- &mut table,
+ behavior.value(&core, &inputs, Rational::new(3, 1), &mut table);
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let job = unsafe {
+ crate::handle::get_checked::(handle)
+ }
+ .expect("solid output boxes a ShaderJobPayload");
+ assert_eq!(
+ job.type_id,
+ "org.olivevideoeditor.Olive.solidgenerator"
+ );
+ assert_eq!(job.shader_id, "");
+ assert_eq!(job.iterations, 1);
+ assert_eq!(job.effect_input, "");
+ assert_eq!(
+ job.params.get(COLOR_INPUT).map(|v| v.to_double()),
+ Some(0.0)
);
- assert!(table.get(ValueType::Texture).is_some());
}
#[test]
diff --git a/crates/oak-node/src/nodes/stroke.rs b/crates/oak-node/src/nodes/stroke.rs
index 957d6edc7..955a38fe3 100644
--- a/crates/oak-node/src/nodes/stroke.rs
+++ b/crates/oak-node/src/nodes/stroke.rs
@@ -19,6 +19,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -177,9 +178,10 @@ impl NodeBehavior for StrokeFilterNode {
/// texture; otherwise push a shader job with `resolution_in` set to
/// the texture's virtual resolution.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value) is deferred to the renderer seam
- /// (`// CPP-PARITY: stroke.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; `resolution_in`
+ /// is filled by the runner from the frame size, matching the C++
+ /// `tex->virtual_resolution()` (`// CPP-PARITY: stroke.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
@@ -202,9 +204,21 @@ impl NodeBehavior for StrokeFilterNode {
};
if radius > 0.0 && opacity > 0.0 {
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates the
+ // main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
@@ -360,7 +374,7 @@ mod tests {
}
#[test]
- fn value_positive_radius_and_opacity_pushes_deferred_job() {
+ fn value_positive_radius_and_opacity_pushes_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
@@ -369,7 +383,19 @@ mod tests {
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
- assert!(table.get(ValueType::Texture).is_some());
+ match table.get(ValueType::Texture) {
+ Some(NodeValue::Texture(h)) => {
+ let payload = unsafe { crate::handle::get_checked::(h) }
+ .expect("shader job payload boxed");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.stroke");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ assert_eq!(payload.iterative_input, "");
+ assert!(payload.params.contains_key(RADIUS_INPUT));
+ }
+ _ => panic!("texture expected"),
+ }
}
#[test]
diff --git a/crates/oak-node/src/nodes/swirldistortnode.rs b/crates/oak-node/src/nodes/swirldistortnode.rs
index 3a6fa8096..6c47ec106 100644
--- a/crates/oak-node/src/nodes/swirldistortnode.rs
+++ b/crates/oak-node/src/nodes/swirldistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -123,13 +124,14 @@ impl NodeBehavior for SwirlDistortNode {
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// angle != 0.0 AND radius != 0.0 -> shader job over the whole value
- /// row with `resolution_in` inserted from the texture's virtual
- /// resolution; otherwise pass-through push of the input texture
- /// unchanged.
+ /// row; otherwise pass-through push of the input texture unchanged.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value) is deferred to the renderer seam
- /// (`// CPP-PARITY: swirldistortnode.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params row
+ /// carries the input texture and uniforms, keyed by the effect input,
+ /// and `resolution_in` is filled by the runner from the input
+ /// texture's size, matching the C++ insert of the texture's virtual
+ /// resolution (`// CPP-PARITY: swirldistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
@@ -152,9 +154,21 @@ impl NodeBehavior for SwirlDistortNode {
};
if angle != 0.0 && radius != 0.0 {
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates the
+ // main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
@@ -336,7 +350,7 @@ mod tests {
}
#[test]
- fn value_angle_and_radius_pushes_deferred_job() {
+ fn value_angle_and_radius_pushes_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
@@ -345,7 +359,15 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let payload = unsafe { crate::handle::get_checked::(handle) }
+ .expect("swirl output boxes a ShaderJobPayload");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.swirl");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
diff --git a/crates/oak-node/src/nodes/threewaycolor.rs b/crates/oak-node/src/nodes/threewaycolor.rs
index 68cba577e..dba271b25 100644
--- a/crates/oak-node/src/nodes/threewaycolor.rs
+++ b/crates/oak-node/src/nodes/threewaycolor.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -167,7 +168,6 @@ impl NodeBehavior for ThreeWayColorNode {
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
- let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
@@ -178,12 +178,27 @@ impl NodeBehavior for ThreeWayColorNode {
// project color manager's default luma coefficients when one is
// attached — the Rust model has no project/manager access, so the
// fallback always applies) into a ShaderJob over the whole input
- // row and pushes `tex->to_job(job)`. The Rust model has no
- // shader-job payload: the renderer seam resolves the deferred job
- // from this null handle.
+ // row and pushes `tex->to_job(job)`. The job is boxed here as a
+ // [`ShaderJobPayload`] that the renderer's resolve hook executes
+ // and replaces with the result texture; the params row carries the
+ // luma coefficients under the shader uniform name.
+ let mut params = inputs.clone();
+ params.insert(
+ LUMA_COEFFICIENTS_INPUT.to_string(),
+ crate::value::NodeValue::Vec3([0.2126, 0.7152, 0.0722]),
+ );
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -348,7 +363,7 @@ mod tests {
}
#[test]
- fn value_with_texture_pushes_deferred_shader_job() {
+ fn value_with_texture_pushes_shader_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
@@ -356,7 +371,22 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ panic!("expected a texture-typed value");
+ };
+ let payload =
+ unsafe { crate::handle::get_checked::(handle) }
+ .expect("payload boxed behind the handle");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.threewaycolor");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ // The luma coefficients are injected under the shader uniform name
+ // (C++ `job.Insert("luma_coefficients_in", ...)`).
+ assert_eq!(
+ payload.params.get(LUMA_COEFFICIENTS_INPUT),
+ Some(&NodeValue::Vec3([0.2126, 0.7152, 0.0722]))
+ );
}
#[test]
diff --git a/crates/oak-node/src/nodes/tiledistortnode.rs b/crates/oak-node/src/nodes/tiledistortnode.rs
index 91b264e3d..0cc2d7ce0 100644
--- a/crates/oak-node/src/nodes/tiledistortnode.rs
+++ b/crates/oak-node/src/nodes/tiledistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -218,13 +219,15 @@ impl NodeBehavior for TileDistortNode {
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// scale differs from 1.0 (an approximate-equality epsilon test:
/// `abs(scale-1)*1e12 > min(abs(scale), 1)`) -> shader job over the
- /// whole value row with `resolution_in` inserted from the texture's
- /// virtual resolution; scale ~== 1.0 -> pass-through push of the
- /// input texture unchanged.
+ /// whole value row; scale ~== 1.0 -> pass-through push of the input
+ /// texture unchanged.
///
- /// The Rust model has no shader-job payload: the job (including the
- /// `resolution_in` value) is deferred to the renderer seam
- /// (`// CPP-PARITY: tiledistortnode.cpp` value()).
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params row
+ /// carries the input texture and uniforms, keyed by the effect input,
+ /// and `resolution_in` is filled by the runner from the input
+ /// texture's size, matching the C++ insert of the texture's virtual
+ /// resolution (`// CPP-PARITY: tiledistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
@@ -245,9 +248,21 @@ impl NodeBehavior for TileDistortNode {
// `!qFuzzyCompare(scale, 1.0)` (double overload) — job when the
// scale is not approximately 1.0.
if (scale_value - 1.0).abs() * 1e12 > scale_value.abs().min(1.0) {
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates the
+ // main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
@@ -432,13 +447,21 @@ mod tests {
}
#[test]
- fn value_non_unit_scale_pushes_deferred_job() {
+ fn value_non_unit_scale_pushes_job_payload() {
let (mut core, behavior) = create();
core.set_standard_value(SCALE_INPUT, -1, NodeValue::Float(0.5));
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());
+ let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let payload = unsafe { crate::handle::get_checked::(handle) }
+ .expect("tile output boxes a ShaderJobPayload");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.tile");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
diff --git a/crates/oak-node/src/nodes/transformdistortnode.rs b/crates/oak-node/src/nodes/transformdistortnode.rs
index 91546b2bf..92de2aeb8 100644
--- a/crates/oak-node/src/nodes/transformdistortnode.rs
+++ b/crates/oak-node/src/nodes/transformdistortnode.rs
@@ -23,6 +23,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Parent matrix input id (C++ `k_parent_input`). Type: matrix; no
/// default (identity when unconnected).
@@ -330,12 +331,15 @@ impl NodeBehavior for TransformDistortNode {
/// identity matrix (or no texture) -> pass-through push of the
/// input texture value.
///
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params row
+ /// carries the input texture and uniforms, keyed by the effect input.
/// The real matrix needs the texture's params and the sequence
/// resolution (the Rust texture handle carries no params and the
/// value() signature no globals), so the identity check — and thus
/// the pass-through-vs-job decision — is not representable here: with
- /// a texture the job is always queued for the renderer seam
- /// (`// CPP-PARITY: transformdistortnode.cpp` value()).
+ /// a texture the job is always queued (`// CPP-PARITY:
+ /// transformdistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
@@ -362,16 +366,33 @@ impl NodeBehavior for TransformDistortNode {
);
match inputs.get(TEXTURE_INPUT) {
- Some(tex @ crate::value::NodeValue::Texture(_)) => {
- // C++ builds the auto-scaled real matrix and pushes a job
- // at the global video params binding `ove_maintex` /
- // `ove_mvpmat`; the deferred job is resolved by the
- // renderer seam (`// CPP-PARITY: transformdistortnode.cpp`
- // value()).
- let _ = tex;
+ 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).
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: TEXTURE_INPUT.to_string(),
+ })),
None,
);
}
@@ -701,7 +722,18 @@ mod tests {
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());
- assert!(table.get(ValueType::Texture).is_some());
+ let handle = match table.get(ValueType::Texture).unwrap() {
+ NodeValue::Texture(h) => *h,
+ _ => panic!("texture expected"),
+ };
+ let payload = unsafe { crate::handle::get_checked::(&handle) }
+ .expect("shader job payload expected");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.transform");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ assert_eq!(payload.time, Rational::new(0, 1));
+ assert!(payload.params.contains_key(TEXTURE_INPUT));
}
#[test]
diff --git a/crates/oak-node/src/nodes/wavedistortnode.rs b/crates/oak-node/src/nodes/wavedistortnode.rs
index ebf06afb9..3ba7ea9c4 100644
--- a/crates/oak-node/src/nodes/wavedistortnode.rs
+++ b/crates/oak-node/src/nodes/wavedistortnode.rs
@@ -20,6 +20,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -136,6 +137,13 @@ impl NodeBehavior for WaveDistortNode {
/// intensity != 0.0 -> shader job over the whole value row rendered
/// at the texture's own params; intensity == 0.0 -> pass-through
/// push of the input texture unchanged.
+ ///
+ /// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
+ /// hook executes and replaces with the result texture; the params row
+ /// carries the input texture and uniforms, keyed by the effect input.
+ /// C++ renders the job at the texture's own params and inserts no
+ /// `resolution_in` (the wave shader declares none), so the runner has
+ /// nothing extra to fill (`// CPP-PARITY: wavedistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
@@ -154,12 +162,21 @@ impl NodeBehavior for WaveDistortNode {
};
if intensity != 0.0 {
- // C++ pushes `Texture::job(texture->params(), ShaderJob(value))`;
- // the deferred job is resolved by the renderer seam
- // (`// CPP-PARITY: wavedistortnode.cpp` value()).
+ // The shader-job box (C++ ShaderJob): the behavior's type id
+ // selects the fragment source; the effect input key locates the
+ // main texture inside the params row.
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params: inputs.clone(),
+ iterative_input: String::new(),
+ })),
None,
);
} else {
@@ -287,7 +304,7 @@ mod tests {
}
#[test]
- fn value_nonzero_intensity_pushes_deferred_job() {
+ fn value_nonzero_intensity_pushes_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
@@ -295,7 +312,15 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ unreachable!()
+ };
+ let payload = unsafe { crate::handle::get_checked::(handle) }
+ .expect("wave output boxes a ShaderJobPayload");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.wave");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
diff --git a/crates/oak-node/src/nodes/whitebalance.rs b/crates/oak-node/src/nodes/whitebalance.rs
index a7c9e3e41..d0a926586 100644
--- a/crates/oak-node/src/nodes/whitebalance.rs
+++ b/crates/oak-node/src/nodes/whitebalance.rs
@@ -26,6 +26,7 @@
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
+use crate::nodes::jobs::ShaderJobPayload;
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
@@ -183,17 +184,27 @@ impl NodeBehavior for WhiteBalanceNode {
None => core.value_at_time(TINT_INPUT, -1, time).to_double(),
};
let gain = Self::gain_for_temperature(temperature, tint);
- let _ = gain;
// `// CPP-PARITY: whitebalance.cpp` `value()` — the C++ builds a
// ShaderJob from the whole input row, inserts `wb_gain_in` as the
- // per-frame vec3 gain, and pushes `tex->to_job(job)`. The Rust
- // model has no shader-job payload: the renderer seam resolves the
- // deferred job from this null handle, recomputing the gain from
- // the same inputs.
+ // per-frame vec3 gain, and pushes `tex->to_job(job)`. The job is
+ // boxed here as a [`ShaderJobPayload`] that the renderer's resolve
+ // hook executes and replaces with the result texture; the params
+ // row carries the computed gain under the shader uniform name.
+ let mut params = inputs.clone();
+ params.insert(GAIN_INPUT.to_string(), crate::value::NodeValue::Vec3(gain));
table.push(
crate::value::ValueType::Texture,
- crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
+ crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
+ node_id: crate::id::NodeId::INVALID,
+ time,
+ iterations: 1,
+ type_id: self.type_id().to_string(),
+ shader_id: String::new(),
+ effect_input: core.effect_input.clone(),
+ params,
+ iterative_input: String::new(),
+ })),
None,
);
}
@@ -374,7 +385,7 @@ mod tests {
}
#[test]
- fn value_with_texture_pushes_deferred_shader_job() {
+ fn value_with_texture_pushes_shader_job_payload() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
@@ -382,7 +393,23 @@ 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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
+ panic!("expected a texture-typed value");
+ };
+ let payload =
+ unsafe { crate::handle::get_checked::(handle) }
+ .expect("payload boxed behind the handle");
+ assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.whitebalance");
+ assert_eq!(payload.shader_id, "");
+ assert_eq!(payload.iterations, 1);
+ assert_eq!(payload.effect_input, TEXTURE_INPUT);
+ // The computed per-frame gain is injected under the shader uniform
+ // name, overriding any row value (C++ `job.Insert(k_gain_input, ...)`).
+ let gain = payload.params.get(GAIN_INPUT).unwrap();
+ let NodeValue::Vec3(g) = gain else {
+ panic!("expected a vec3 gain");
+ };
+ assert_eq!(g, &WhiteBalanceNode::gain_for_temperature(6500.0, 0.0));
}
#[test]
diff --git a/crates/oak-node/src/serializer.rs b/crates/oak-node/src/serializer.rs
index 2c5c93f8c..d94988f4d 100644
--- a/crates/oak-node/src/serializer.rs
+++ b/crates/oak-node/src/serializer.rs
@@ -493,6 +493,21 @@ fn writer_text_chars(writer: &mut dyn XmlWrite, text: &str) {
/// Load a project from XML text. Applies version upgrades in order;
/// rejects versions newer than the build (C++ `k_project_too_new`).
pub fn load(xml: &str) -> crate::error::Result>> {
+ Ok(load_with_id_map(xml)?.0)
+}
+
+/// Deserialize a project and also return the source-identity -> loaded-id
+/// translation map built while loading (XML `ptr` -> [`NodeId`]).
+///
+/// `load` rebuilds the graph in file order and assigns fresh arena slots,
+/// so a node's identity in the saved project does not generally match its
+/// identity after loading (a gap left by any deleted slot shifts every
+/// later node). Callers that hold identities from the *saved* project —
+/// e.g. a render worker resolving a ticket's viewer node against the
+/// snapshot it loaded — must translate through this map.
+pub fn load_with_id_map(
+ xml: &str,
+) -> crate::error::Result<(Arc>, std::collections::HashMap)> {
use crate::error::Error;
let mut reader = XmlReaderBridge::new(xml).ok_or(Error::Failed(
"oakcommon XML reader unavailable".to_string(),
@@ -534,11 +549,11 @@ pub fn load(xml: &str) -> crate::error::Result>> {
}
let project = Project::new();
- {
+ let id_map = {
let mut guard = lock(&project);
- load_project_body(&mut reader, &mut guard)?;
- }
- Ok(project)
+ load_project_body(&mut reader, &mut guard)?
+ };
+ Ok((project, id_map))
}
/// Parse the `` body: uuid, nodes, settings. C++ full saves
@@ -546,7 +561,10 @@ pub fn load(xml: &str) -> crate::error::Result>> {
/// ......` — `// CPP-PARITY:
/// serializer230220.cpp`); a nested `` element is descended
/// into transparently instead of skipped.
-fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::error::Result<()> {
+fn load_project_body(
+ reader: &mut dyn XmlRead,
+ project: &mut Project,
+) -> crate::error::Result> {
use crate::error::Error;
// Identity -> NodeId map for connection resolution.
let mut id_map: std::collections::HashMap = std::collections::HashMap::new();
@@ -644,7 +662,7 @@ fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::
// reattach each child to its bin folder.
resolve_folder_children(&mut project.graph, &id_map);
- Ok(())
+ Ok(id_map)
}
/// Parse one `` into the graph; returns its id.
diff --git a/crates/oak-node/src/traverser.rs b/crates/oak-node/src/traverser.rs
index d07aeef69..f41966cf4 100644
--- a/crates/oak-node/src/traverser.rs
+++ b/crates/oak-node/src/traverser.rs
@@ -19,9 +19,22 @@
//! Key change from C++: no inheritance. C++ `RenderProcessor :
//! NodeTraverser` overrode virtuals to plug rendering in; here the
//! traverser is a free engine and oakrender supplies [`RenderHooks`].
-//! The graph is walked iteratively in topological order with an
-//! explicit value stack (the C++ recursive path could blow the stack
-//! on deep graphs — same order, no recursion).
+//!
+//! Evaluation is **time-aware and memoized per (node, time)**: a node's
+//! inputs may pull upstream values at adjusted times (the consuming
+//! node's `input_time_adjustment` — clips map sequence time to media
+//! time, tracks clamp to the covering block, C++
+//! `traverser.cpp` `ProcessInput`), so one evaluation pass can evaluate
+//! the same node at several times (keyed like the C++ `value_cache_`,
+//! which is per (node, range)). The walk is an explicit-stack DFS —
+//! 10k-deep chains must not blow the call stack (the earlier
+//! topological-order pass was recursion-free for the same reason).
+//!
+//! Input rows carry the C++ `GenerateRowValue` semantics: connected
+//! inputs take the upstream output (evaluated at the adjusted time);
+//! unconnected inputs take `NodeCore::value_at_time` — keyframe
+//! interpolation when the track is non-empty, else the standard value
+//! (C++ `ProcessInputElement` → `GetValueAtTime`).
//! `// CPP-PARITY: src/node/src/traverser.cpp`.
use std::collections::{HashMap, HashSet};
@@ -75,18 +88,22 @@ impl EvalRequest {
/// The traversal engine.
pub struct Traverser {
- /// Value stack / per-node row cache for this pass.
- stack: Vec<(NodeId, NodeValueTable)>,
/// Nodes touched by the last [`Traverser::invalidate_downstream`]
/// walk (observable for tests; the C++ fan-out has no return value).
last_invalidation: Vec,
}
+/// DFS stack frame: `Enter` queues the upstream nodes, `Exit` builds the
+/// row and evaluates.
+enum Frame {
+ Enter(NodeId, Rational),
+ Exit(NodeId, Rational),
+}
+
impl Traverser {
/// New empty engine (reusable across evaluations).
pub fn new() -> Self {
Traverser {
- stack: Vec::new(),
last_invalidation: Vec::new(),
}
}
@@ -99,9 +116,9 @@ impl Traverser {
/// Evaluate `request` against `graph`, calling `hooks` at the
/// backend seams. Returns the root's output table.
///
- /// Errors: `State` on cancellation, `Failed` on node evaluation
- /// errors (C++ returned empty tables; we surface the error —
- /// `// CPP-PARITY: traverser.cpp` behavior notes inline).
+ /// Errors: `State` on cancellation, `NotFound` on an invalid root.
+ /// Only nodes upstream of the root are evaluated (lazy — the C++
+ /// recursion shares this property).
pub fn evaluate(
&mut self,
graph: &Graph,
@@ -113,72 +130,58 @@ impl Traverser {
return Err(Error::NotFound);
}
- self.stack.clear();
- let order = graph.topological_order();
+ // Per-pass memo: (node, time) -> evaluated output table. A shared
+ // upstream evaluates once per requested time (C++ value_cache_).
+ let mut cache: HashMap<(NodeId, Rational), NodeValueTable> = HashMap::new();
+ let mut queued: HashSet<(NodeId, Rational)> = HashSet::new();
+ let mut stack: Vec = vec![Frame::Enter(request.root, request.time)];
+ queued.insert((request.root, request.time));
- // Per-node output tables for this pass (memoization: a shared
- // upstream evaluates once — `// CPP-PARITY: traverser.cpp`
- // process_node_children).
- let mut tables: HashMap = HashMap::new();
-
- for node in order {
+ while let Some(frame) = stack.pop() {
if hooks.is_cancelled() {
return Err(Error::State);
}
-
- let entry = graph.get(node).ok_or(Error::NotFound)?;
-
- // Build this node's input row from its upstream outputs. The
- // C++ picks the last value of the matching type per input;
- // the Rust model keys rows by input id. Inputs declared as
- // texture take the upstream texture directly (the scalar
- // chain below would otherwise hand a plugin node's tagged
- // param passthrough to a downstream clip input).
- let mut row: NodeValueRow = std::collections::BTreeMap::new();
- for (from, input_id, element) in graph.input_connections(node) {
- let _ = element;
- if let Some(from_table) = tables.get(&from) {
- let value = if entry.core.input_data_type(&input_id)
- == Some(ValueType::Texture)
- {
- from_table
- .get(ValueType::Texture)
- .cloned()
- .unwrap_or(NodeValue::None)
- } else {
- from_table
- .get(ValueType::Float)
- .or_else(|| from_table.get(ValueType::Int))
- .or_else(|| from_table.get(ValueType::Color))
- .or_else(|| from_table.get(ValueType::Vec2))
- .or_else(|| from_table.get(ValueType::Vec3))
- .or_else(|| from_table.get(ValueType::Vec4))
- .or_else(|| from_table.get(ValueType::Boolean))
- .or_else(|| from_table.get(ValueType::Rational))
- .or_else(|| from_table.get(ValueType::Text))
- .or_else(|| from_table.get(ValueType::Combo))
- .or_else(|| from_table.get(ValueType::StrCombo))
- .or_else(|| from_table.get(ValueType::Texture))
- .cloned()
- .unwrap_or(NodeValue::None)
+ match frame {
+ Frame::Enter(node, time) => {
+ if cache.contains_key(&(node, time)) {
+ continue;
+ }
+ let Some(entry) = graph.get(node) else {
+ continue;
};
- row.insert(input_id, value);
+ stack.push(Frame::Exit(node, time));
+ // Queue every connected upstream at its adjusted time.
+ for (from, input, element) in graph.input_connections(node) {
+ let from = entry
+ .behavior
+ .connected_render_output(&entry.core, &input, element)
+ .unwrap_or(from);
+ let adjusted = adjusted_time(entry, &input, element, time);
+ let key = (from, adjusted);
+ if !cache.contains_key(&key) && queued.insert(key) {
+ stack.push(Frame::Enter(from, adjusted));
+ }
+ }
+ }
+ Frame::Exit(node, time) => {
+ if cache.contains_key(&(node, time)) {
+ continue;
+ }
+ let Some(entry) = graph.get(node) else {
+ continue;
+ };
+ let row = build_row(graph, &cache, entry, node, time);
+ let mut table = NodeValueTable::default();
+ entry.behavior.value(&entry.core, &row, time, &mut table);
+ hooks.resolve(node, &row, &mut table);
+ cache.insert((node, time), table);
}
}
-
- // Evaluate the node's behavior into its output table.
- let mut table = NodeValueTable::default();
- // The behavior writes outputs; the default no-op leaves the
- // table empty (C++ `Node::value` default).
- entry
- .behavior
- .value(&entry.core, &row, request.time, &mut table);
- hooks.resolve(node, &row, &mut table);
-
- tables.insert(node, table);
}
- Ok(tables.remove(&request.root).unwrap_or_default())
+ Ok(cache
+ .remove(&(request.root, request.time))
+ .unwrap_or_default())
}
/// Invalidate walk: mark downstream caches dirty after an input
@@ -201,10 +204,109 @@ impl Traverser {
impl Default for Traverser {
fn default() -> Self {
- Traverser::new()
+ Self::new()
}
}
+/// The consuming node's time adjustment for `input` (C++
+/// `Node::InputTimeAdjustment` with `traverse = true`): clips map
+/// sequence time to media time, tracks clamp to the covering block. The
+/// trait speaks ranges; a video frame evaluates at a point, so the
+/// adjusted range's `in` is the upstream time.
+fn adjusted_time(
+ entry: &crate::graph::NodeEntry,
+ input: &str,
+ element: i32,
+ time: Rational,
+) -> Rational {
+ entry
+ .behavior
+ .input_time_adjustment(input, element, TimeRange::new(time, time), true)
+ .in_()
+}
+
+/// Build the input row of `node` at `time` from the memoized upstream
+/// tables plus the standard/keyframed values of unconnected inputs
+/// (C++ `GenerateRowValue` + `ProcessInputElement`).
+fn build_row(
+ graph: &Graph,
+ cache: &HashMap<(NodeId, Rational), NodeValueTable>,
+ entry: &crate::graph::NodeEntry,
+ node: NodeId,
+ time: Rational,
+) -> NodeValueRow {
+ let mut row: NodeValueRow = std::collections::BTreeMap::new();
+ let connections = graph.input_connections(node);
+ for input in &entry.core.inputs {
+ let id = input.id.as_str();
+ let mut conns: Vec<(NodeId, i32)> = connections
+ .iter()
+ .filter(|(_, i, _)| i == id)
+ .map(|(from, _, element)| (*from, *element))
+ .collect();
+ if conns.is_empty() {
+ // Unconnected: keyframe interpolation when the track is
+ // non-empty, else the standard value (C++ GetValueAtTime).
+ row.insert(id.to_string(), entry.core.value_at_time(id, -1, time));
+ continue;
+ }
+ // Array inputs (element >= 0): the consuming node may restrict
+ // which elements are live at this time (C++
+ // `GetActiveElementsAtTime` — a track pulls only the blocks
+ // covering the frame). An empty answer means "no restriction".
+ if conns.iter().any(|(_, e)| *e >= 0) {
+ let active = entry.behavior.active_elements_at_time(id, time);
+ if !active.is_empty() {
+ conns.retain(|(_, e)| active.contains(e));
+ }
+ conns.sort_by_key(|(_, e)| *e);
+ }
+ for (from, element) in conns {
+ let from = entry
+ .behavior
+ .connected_render_output(&entry.core, id, element)
+ .unwrap_or(from);
+ let upstream_time = adjusted_time(entry, id, element, time);
+ let value = cache
+ .get(&(from, upstream_time))
+ .map(|t| pick_value(t, entry.core.input_data_type(id)))
+ .unwrap_or(NodeValue::None);
+ row.insert(id.to_string(), value);
+ }
+ }
+ row
+}
+
+/// Pick the row value for an input of `data_type` from an upstream
+/// output table. Texture inputs take the upstream texture directly (the
+/// scalar chain would otherwise hand a plugin node's tagged param
+/// passthrough to a downstream clip input); everything else takes the
+/// last value of the first matching scalar type (C++ value-hint
+/// resolution's common case).
+fn pick_value(table: &NodeValueTable, data_type: Option) -> NodeValue {
+ if data_type == Some(ValueType::Texture) {
+ return table
+ .get(ValueType::Texture)
+ .cloned()
+ .unwrap_or(NodeValue::None);
+ }
+ table
+ .get(ValueType::Float)
+ .or_else(|| table.get(ValueType::Int))
+ .or_else(|| table.get(ValueType::Color))
+ .or_else(|| table.get(ValueType::Vec2))
+ .or_else(|| table.get(ValueType::Vec3))
+ .or_else(|| table.get(ValueType::Vec4))
+ .or_else(|| table.get(ValueType::Boolean))
+ .or_else(|| table.get(ValueType::Rational))
+ .or_else(|| table.get(ValueType::Text))
+ .or_else(|| table.get(ValueType::Combo))
+ .or_else(|| table.get(ValueType::StrCombo))
+ .or_else(|| table.get(ValueType::Texture))
+ .cloned()
+ .unwrap_or(NodeValue::None)
+}
+
/// A value database: per-node input rows over a time range (C++
/// `NodeValueDatabase`), exposed by the traverser ffi family.
pub struct ValueDatabase {
diff --git a/crates/oak-node/tests/serializer_test.rs b/crates/oak-node/tests/serializer_test.rs
index b68e3ca38..5f2f68711 100644
--- a/crates/oak-node/tests/serializer_test.rs
+++ b/crates/oak-node/tests/serializer_test.rs
@@ -919,6 +919,99 @@ fn multicam_node_round_trip_preserves_current_in() {
);
}
+/// A runtime-registered (dynamic) node type — the OFX plugin seam — is
+/// rebuilt from a snapshot by the serializer: a type id that lives only
+/// in the factory's dynamic table (the C++ `register_plugin_nodes`
+/// library entries) is rejected before registration and round-trips
+/// once registered. The worker process runs the identical plugin scan
+/// at startup (worker.rs `register_plugin_nodes`), so a snapshot
+/// carrying plugin nodes deserializes there the same way.
+#[test]
+fn dynamic_plugin_node_round_trips_across_load() {
+ use oak_node::factory::{DynamicNodeMeta, Factory};
+ use oak_node::input::Input;
+ use oak_node::node::{Category, NodeBehavior, NodeCore};
+ use oak_node::project::Project;
+ use oak_node::value::{NodeValue, ValueType};
+
+ const TYPE_ID: &str = "org.test.dynamic-rebuild-probe";
+ const INPUT_ID: &str = "probe_in";
+
+ // A pure-Rust stand-in for a discovered OFX plugin node: its type id
+ // is unknown to the static menu table, so only the dynamic path of
+ // `create_any` can construct it (mirroring the plugin closure that
+ // captures the identifier).
+ struct Probe;
+ impl NodeBehavior for Probe {
+ fn name(&self) -> &str {
+ "Dynamic Probe"
+ }
+ fn type_id(&self) -> &str {
+ TYPE_ID
+ }
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ Some(Box::new(Probe))
+ }
+ }
+
+ // Build a project with one probe node carrying a standard value.
+ let project = Project::new();
+ // The dynamic constructor must rebuild the same declared inputs as
+ // the original (a real plugin's `create_plugin_node` builds the core
+ // from its OFX params).
+ let probe_core = || {
+ let mut core = NodeCore::new();
+ core.inputs
+ .push(Input::new(INPUT_ID, ValueType::Float, NodeValue::Float(0.0)));
+ core
+ };
+ {
+ let mut p = project.lock().unwrap();
+ let id = p.graph.add_node(probe_core(), Box::new(Probe));
+ p.graph
+ .get_mut(id)
+ .unwrap()
+ .core
+ .set_standard_value(INPUT_ID, -1, NodeValue::Float(2.5));
+ }
+ let xml = {
+ let p = project.lock().unwrap();
+ oak_node::serializer::save(&p).unwrap()
+ };
+ assert!(xml.contains(TYPE_ID), "the dynamic type id is persisted");
+
+ // Before the plugin scan registers the entry the snapshot is
+ // unreadable (the plugin is not installed in this process).
+ assert!(oak_node::serializer::load(&xml).is_err());
+
+ // The scan registers the dynamic entry...
+ let registered = Factory::global().register_dynamic(DynamicNodeMeta {
+ type_id: TYPE_ID.to_string(),
+ name: "Dynamic Probe".to_string(),
+ categories: vec![Category::OpenFx],
+ sub_category: "Filter".to_string(),
+ description: "test probe".to_string(),
+ create: std::sync::Arc::new(move || (probe_core(), Box::new(Probe))),
+ });
+ assert!(registered, "the probe type id was not registered before");
+
+ // ...and the same snapshot now rebuilds the node in-process, with
+ // its type id and standard value intact.
+ let loaded = oak_node::serializer::load(&xml).unwrap();
+ let l = loaded.lock().unwrap();
+ let probe = l
+ .graph
+ .node_ids()
+ .into_iter()
+ .find(|id| l.graph.get(*id).map(|e| e.behavior.type_id()) == Some(TYPE_ID))
+ .expect("loaded project has the dynamic node");
+ assert_eq!(
+ l.graph.get(probe).unwrap().core.standard_value(INPUT_ID, -1),
+ NodeValue::Float(2.5),
+ "the standard value survives the rebuild"
+ );
+}
+
/// A clip with multicam enabled round-trips: the `current_in` value, the
/// `sequence_in` edge, and the `sequence_type_in` selector all survive.
#[test]
diff --git a/crates/oak-node/tests/traverser_test.rs b/crates/oak-node/tests/traverser_test.rs
index 4a7143875..8507aea34 100644
--- a/crates/oak-node/tests/traverser_test.rs
+++ b/crates/oak-node/tests/traverser_test.rs
@@ -232,3 +232,124 @@ fn invalidation_fanout() {
assert!(walked.contains(&a) && walked.contains(&d));
let _ = NodeId::INVALID;
}
+
+/// A behavior that echoes its `val_in` row value into the table (probes
+/// what the traverser fed it).
+struct Echo;
+impl NodeBehavior for Echo {
+ fn name(&self) -> &str {
+ "Echo"
+ }
+ fn type_id(&self) -> &str {
+ "test.echo"
+ }
+ fn duplicate(&self, _c: &NodeCore) -> Option> {
+ Some(Box::new(Echo))
+ }
+ fn value(&self, _c: &NodeCore, inputs: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
+ if let Some(v) = inputs.get("val_in") {
+ table.push(ValueType::Float, v.clone(), None);
+ }
+ }
+}
+
+/// Unconnected inputs are filled with the keyframe-interpolated value at
+/// the evaluation time (C++ GetValueAtTime): a linear 0→10 keyframe
+/// track read at its midpoint feeds 5.
+#[test]
+fn unconnected_input_evaluates_keyframes_at_time() {
+ use oak_node::keyframe::{Keyframe, KeyframeTrack};
+ let mut g = Graph::new();
+ let mut core = NodeCore::new();
+ core.add_input(Input::new("val_in", ValueType::Float, NodeValue::Float(0.0)));
+ core.keyframe_track_mut("val_in", -1).set_key(Keyframe {
+ time: Rational::new(0, 1),
+ value: NodeValue::Float(0.0),
+ interpolation: oak_node::keyframe::Interpolation::Linear,
+ bezier_in: (0.0, 0.0),
+ bezier_out: (0.0, 0.0),
+ });
+ core.keyframe_track_mut("val_in", -1).set_key(Keyframe {
+ time: Rational::new(10, 1),
+ value: NodeValue::Float(10.0),
+ interpolation: oak_node::keyframe::Interpolation::Linear,
+ bezier_in: (0.0, 0.0),
+ bezier_out: (0.0, 0.0),
+ });
+ let id = g.add_node(core, Box::new(Echo));
+
+ let mut t = Traverser::new();
+ let mut hooks = Noop;
+ let table = t
+ .evaluate(&g, &EvalRequest::new(id, Rational::new(5, 1)), &mut hooks)
+ .unwrap();
+ assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(5.0)));
+}
+
+/// A connected input is evaluated at the consumer's adjusted time (C++
+/// InputTimeAdjustment with traverse=true): the consumer doubles the
+/// time, the upstream time-echo reports what it was evaluated at.
+#[test]
+fn connected_input_uses_adjusted_time() {
+ struct TimeEcho;
+ impl NodeBehavior for TimeEcho {
+ fn name(&self) -> &str {
+ "TimeEcho"
+ }
+ fn type_id(&self) -> &str {
+ "test.timeecho"
+ }
+ fn duplicate(&self, _c: &NodeCore) -> Option> {
+ Some(Box::new(TimeEcho))
+ }
+ fn value(&self, _c: &NodeCore, _i: &NodeValueRow, t: Rational, table: &mut NodeValueTable) {
+ table.push(ValueType::Rational, NodeValue::Rational(t), None);
+ }
+ }
+ struct Doubler;
+ impl NodeBehavior for Doubler {
+ fn name(&self) -> &str {
+ "Doubler"
+ }
+ fn type_id(&self) -> &str {
+ "test.doubler"
+ }
+ fn duplicate(&self, _c: &NodeCore) -> Option> {
+ Some(Box::new(Doubler))
+ }
+ fn input_time_adjustment(
+ &self,
+ input: &str,
+ _element: i32,
+ time: TimeRange,
+ traverse: bool,
+ ) -> TimeRange {
+ if input == "val_in" && traverse {
+ TimeRange::new(time.in_() * Rational::new(2, 1), time.out() * Rational::new(2, 1))
+ } else {
+ time
+ }
+ }
+ fn value(&self, _c: &NodeCore, inputs: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
+ if let Some(v) = inputs.get("val_in") {
+ table.push(ValueType::Rational, v.clone(), None);
+ }
+ }
+ }
+
+ let mut g = Graph::new();
+ let src = node_with_input(&mut g, Box::new(TimeEcho));
+ let consumer = node_with_input(&mut g, Box::new(Doubler));
+ g.connect(src, consumer, "val_in", -1).unwrap();
+
+ let mut t = Traverser::new();
+ let mut hooks = Noop;
+ let table = t
+ .evaluate(&g, &EvalRequest::new(consumer, Rational::new(3, 1)), &mut hooks)
+ .unwrap();
+ assert_eq!(
+ table.get(ValueType::Rational),
+ Some(&NodeValue::Rational(Rational::new(6, 1))),
+ "the upstream was evaluated at the doubled time"
+ );
+}
diff --git a/crates/oak-render/Cargo.toml b/crates/oak-render/Cargo.toml
index e6dd74da5..9eb247796 100644
--- a/crates/oak-render/Cargo.toml
+++ b/crates/oak-render/Cargo.toml
@@ -20,6 +20,11 @@ oak-node = { path = "../oak-node" }
# direct replacement for the C++ liboakgl2/liboakvulkan backend plugins.
# Version 25 (2025 stable line); the only GPU dependency.
wgpu = "25"
+# naga: GLSL → WGSL translation for the node shaders (the embedded GLSL
+# stays the single source of truth, matching the C++ Vulkan backend's
+# mechanical-conversion approach). Pinned to wgpu 25's naga generation;
+# only the GLSL frontend and WGSL writer are compiled.
+naga = { version = "25", default-features = false, features = ["glsl-in", "wgsl-out"] }
# ocio-rs: safe Rust bindings for OpenColorIO v2.5.2 — the ColorProcessor
# implementation; OCIO is never rewritten. The `bundled` feature compiles
# the vendored OpenColorIO C++ sources (cmake/ninja required); without it
diff --git a/crates/oak-render/examples/bench_playback.rs b/crates/oak-render/examples/bench_playback.rs
index 0b998fb09..02e6c464b 100644
--- a/crates/oak-render/examples/bench_playback.rs
+++ b/crates/oak-render/examples/bench_playback.rs
@@ -106,6 +106,7 @@ fn main() {
time: Rational::new(frame, 25),
params: Arc::new(VideoTicketParams {
viewer: 1,
+ project: String::new(),
time: Rational::new(frame, 25),
force_size: Some((width, height)),
force_format: None,
diff --git a/crates/oak-render/examples/bench_process.rs b/crates/oak-render/examples/bench_process.rs
index 7986fe42d..0e7aeb4b8 100644
--- a/crates/oak-render/examples/bench_process.rs
+++ b/crates/oak-render/examples/bench_process.rs
@@ -108,6 +108,7 @@ fn main() {
time: Rational::new(frame, 25),
params: Arc::new(VideoTicketParams {
viewer: 1,
+ project: String::new(),
time: Rational::new(frame, 25),
force_size: Some((width, height)),
force_format: None,
diff --git a/crates/oak-render/src/autocacher.rs b/crates/oak-render/src/autocacher.rs
index 85ab2eb56..3e305d02a 100644
--- a/crates/oak-render/src/autocacher.rs
+++ b/crates/oak-render/src/autocacher.rs
@@ -143,6 +143,7 @@ impl PreviewAutoCacher {
let id = self.arena.submit_video(
VideoTicketParams {
viewer: owner,
+ project: String::new(),
time: range.in_(),
force_size: None,
force_format: None,
@@ -180,6 +181,7 @@ impl PreviewAutoCacher {
let id = self.arena.submit_video(
VideoTicketParams {
viewer,
+ project: String::new(),
time,
force_size: None,
force_format: None,
diff --git a/crates/oak-render/src/backend.rs b/crates/oak-render/src/backend.rs
index 419a5e5fc..ef489ea24 100644
--- a/crates/oak-render/src/backend.rs
+++ b/crates/oak-render/src/backend.rs
@@ -188,6 +188,12 @@ pub struct GpuContext {
textures: Mutex>,
next_token: AtomicU64,
blit: Mutex