render: translate node GLSL shaders to WGSL and run them as wgpu passes
CI / Build & test (Linux) (push) Failing after 16m57s
CI / Build & test (Windows) (push) Successful in 31m46s

This commit is contained in:
2026-08-27 07:07:09 +08:00
parent 4f6c44a0d0
commit c51a349070
63 changed files with 5417 additions and 472 deletions
-24
View File
@@ -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 <http://www.gnu.org/licenses/>.
# 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 }
+2
View File
@@ -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
+2
View File
@@ -121,3 +121,5 @@ tarpaulin-out/
.env
# CD packaging artifacts
/*.dmg
.cargo/config.toml
perf.data
Generated
+12
View File
@@ -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"
+17
View File
@@ -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();
}
}
+15
View File
@@ -297,8 +297,13 @@ pub fn multicam_angle_frame_params(
) -> Result<VideoTicketParams, String> {
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<VideoTicketParams, String> {
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<MontageClip>| {
let params = VideoTicketParams {
viewer: 0,
project: String::new(),
time,
force_size: Some((64, 64)),
force_format: Some(oak_core::PixelFormat::F32),
+1
View File
@@ -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,
+167 -4
View File
@@ -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<dyn NodeBehavior>) {
// 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<dyn NodeBehavior>,
Box::new(TransitionBlockBehavior::new()) as Box<dyn NodeBehavior>,
] {
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]
+90 -1
View File
@@ -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. `<filename>`
/// and `<streams>` 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::<crate::nodes::jobs::FootageJobPayload>(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);
}
}
+33 -4
View File
@@ -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 {
+13 -7
View File
@@ -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);
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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();
+33 -6
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(&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]
+44 -7
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+93 -15
View File
@@ -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::<ShaderJobPayload>(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::<ShaderJobPayload>(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]
+40 -8
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(&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::<crate::nodes::jobs::ShaderJobPayload>(&handle) }
.expect("shader job payload expected");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.flip");
assert_eq!(payload.iterations, 1);
}
#[test]
+126 -20
View File
@@ -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::<ShaderJobPayload>(&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::<crate::nodes::jobs::ShaderJobPayload>(&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::<u8>(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::<crate::nodes::jobs::ShaderJobPayload>(&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::<crate::nodes::jobs::ShaderJobPayload>(&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"),
}
}
}
+93
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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(),
}
}
}
+9 -11
View File
@@ -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()),
+59
View File
@@ -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::<u8>(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::<crate::nodes::jobs::ShaderJobPayload>(&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::<crate::nodes::jobs::ShaderJobPayload>(&handle)
};
assert!(payload.is_none(), "no shader job for a null texture");
}
}
+99 -18
View File
@@ -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::<u8>(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::<crate::nodes::jobs::ShaderJobPayload>(&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,
+42 -16
View File
@@ -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::<ShaderJobPayload>(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]
+3 -2
View File
@@ -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;
+58 -13
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+53 -17
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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::<crate::nodes::jobs::ShaderJobPayload>(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]
+25 -20
View File
@@ -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);
}
+13 -9
View File
@@ -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,
);
}
+31 -8
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+112 -13
View File
@@ -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::<ShaderJobPayload>(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::<ShaderJobPayload>(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::<ShaderJobPayload>(blend) }
.expect("nested shape job payload boxed");
assert_eq!(shape.shader_id, "shape");
}
_ => panic!("nested blend job expected"),
}
}
_ => panic!("texture expected"),
}
}
#[test]
+41 -14
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+32 -6
View File
@@ -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::<ShaderJobPayload>(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]
+31 -9
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+37 -7
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+32 -9
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(&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]
+31 -6
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+35 -8
View File
@@ -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::<crate::nodes::jobs::ShaderJobPayload>(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]
+24 -6
View File
@@ -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<Arc<Mutex<Project>>> {
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<Mutex<Project>>, std::collections::HashMap<u64, NodeId>)> {
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<Arc<Mutex<Project>>> {
}
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 `<project>` body: uuid, nodes, settings. C++ full saves
@@ -546,7 +561,10 @@ pub fn load(xml: &str) -> crate::error::Result<Arc<Mutex<Project>>> {
/// ...</project><layout>...</layout></project>` — `// CPP-PARITY:
/// serializer230220.cpp`); a nested `<project>` 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<std::collections::HashMap<u64, NodeId>> {
use crate::error::Error;
// Identity -> NodeId map for connection resolution.
let mut id_map: std::collections::HashMap<u64, NodeId> = 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 `<node>` into the graph; returns its id.
+169 -67
View File
@@ -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<NodeId>,
}
/// 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<Frame> = 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<NodeId, NodeValueTable> = 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<ValueType>) -> 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 {
+93
View File
@@ -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<Box<dyn NodeBehavior>> {
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]
+121
View File
@@ -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<Box<dyn NodeBehavior>> {
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<Box<dyn NodeBehavior>> {
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<Box<dyn NodeBehavior>> {
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"
);
}
+5
View File
@@ -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
@@ -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,
@@ -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,
+2
View File
@@ -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,
+426 -2
View File
@@ -188,6 +188,12 @@ pub struct GpuContext {
textures: Mutex<HashMap<u64, GpuTexture>>,
next_token: AtomicU64,
blit: Mutex<Option<wgpu::RenderPipeline>>,
/// FLOAT32_FILTERABLE was available (linear sampling on F32 textures).
filterable: bool,
/// Compiled effect pipelines, keyed by the shaderfx cache key.
programs: Mutex<HashMap<String, Arc<ShaderProgram>>>,
/// The lazily created 1×1 placeholder texture (unconnected inputs).
placeholder: Mutex<Option<u64>>,
}
// SAFETY check: wgpu Device/Queue/Instance are Send+Sync; the rest is
@@ -215,10 +221,19 @@ impl GpuContext {
Err(_) => continue, // try the next backend in the fallback order
};
let info = adapter.get_info();
// Linear sampling on Rgba32Float needs FLOAT32_FILTERABLE
// (widely available on desktop GPUs); without it effect
// shaders sample nearest — a quality degradation, not a
// failure (logged once by the shaderfx runner).
let filterable = adapter.features().contains(wgpu::Features::FLOAT32_FILTERABLE);
let mut required_features = wgpu::Features::empty();
if filterable {
required_features |= wgpu::Features::FLOAT32_FILTERABLE;
}
let (device, queue) =
match pollster_block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("oakrender"),
required_features: wgpu::Features::empty(),
required_features,
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::default(),
trace: wgpu::Trace::Off,
@@ -239,6 +254,9 @@ impl GpuContext {
textures: Mutex::new(HashMap::new()),
next_token: AtomicU64::new(1),
blit: Mutex::new(None),
filterable,
programs: Mutex::new(HashMap::new()),
placeholder: Mutex::new(None),
}));
}
None
@@ -556,8 +574,332 @@ impl GpuContext {
});
Ok(pipeline)
}
/// The process-wide shared context (lazy; `None` when no adapter is
/// available — callers then take the CPU fallback). The effect/montage
/// evaluation path renders through this context; the backend choice
/// follows the user's `GraphicsBackend` config (`OAK_RENDER_BACKEND`
/// overrides). `DisplayRenderer::init` adopts it too, so a process
/// owns exactly one wgpu device.
pub fn shared() -> Option<Arc<GpuContext>> {
static SHARED: std::sync::OnceLock<Option<Arc<GpuContext>>> = std::sync::OnceLock::new();
SHARED
.get_or_init(|| Self::create(BackendKind::from_user_config()))
.clone()
}
/// True when the device can linear-sample Rgba32Float textures
/// (FLOAT32_FILTERABLE). Effect shaders use a filtering sampler when
/// true, nearest otherwise.
pub fn is_filterable(&self) -> bool {
self.filterable
}
/// The context's shared 1×1 transparent placeholder texture, created
/// on first use: unconnected effect inputs bind it (C++ binds texture
/// id 0 — an empty texture — the same way).
pub fn placeholder_texture(&self) -> Result<u64> {
let mut slot = lock(&self.placeholder);
if let Some(token) = *slot {
return Ok(token);
}
let token = self.create_texture(1, 1)?;
let mut pod = VideoParamsPod::default();
pod.width = 1;
pod.height = 1;
pod.format = PixelFormat::F32 as i32;
let mut frame = Frame::new();
frame.set_video_params(pod);
frame.allocate();
self.upload(token, &frame)?;
*slot = Some(token);
Ok(token)
}
/// Compile (or fetch from the cache) an effect pass: the translated
/// fragment WGSL (`shaderfx::translate` output, entry point `main`)
/// paired with the fixed fullscreen-triangle vertex stage. `key`
/// identifies the shader program in the context cache (include the
/// filtering mode when it varies for the same shader). Pipeline
/// creation runs under a validation error scope so a bad shader is a
/// fallible result, not a device loss.
pub fn compile_shader_pass(
&self,
key: &str,
wgsl: &str,
texture_count: u32,
has_uniforms: bool,
filtering: bool,
) -> Result<Arc<ShaderProgram>> {
if let Some(p) = lock(&self.programs).get(key) {
return Ok(p.clone());
}
// Bind group layout, mirroring shaderfx's binding assignment:
// binding 0 = the uniform block (when present), then each input
// texture as a (texture, sampler) pair.
let mut entries = Vec::new();
if has_uniforms {
entries.push(wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
}
let sample_type = wgpu::TextureSampleType::Float {
filterable: self.filterable && filtering,
};
for i in 0..texture_count {
entries.push(wgpu::BindGroupLayoutEntry {
binding: 1 + 2 * i,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
});
entries.push(wgpu::BindGroupLayoutEntry {
binding: 2 + 2 * i,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(if self.filterable && filtering {
wgpu::SamplerBindingType::Filtering
} else {
wgpu::SamplerBindingType::NonFiltering
}),
count: None,
});
}
let layout = self
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("oakrender-fx-layout"),
entries: &entries,
});
let vs_module = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("oakrender-fx-vs"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(EFFECT_VS_WGSL)),
});
let fs_module = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("oakrender-fx-fs"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Owned(wgsl.to_string())),
});
let pipeline_layout = self
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("oakrender-fx-pipeline-layout"),
bind_group_layouts: &[&layout],
push_constant_ranges: &[],
});
self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let pipeline = self
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("oakrender-fx"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vs_module,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &fs_module,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
if let Some(err) = pollster_block_on(self.device.pop_error_scope()) {
return Err(Error::Failed(format!("effect pipeline validation failed: {err}")));
}
let program = Arc::new(ShaderProgram {
pipeline,
layout,
texture_count,
has_uniforms,
filtering,
});
lock(&self.programs).insert(key.to_string(), program.clone());
Ok(program)
}
/// Run one effect pass: fragment-shade `dst` from `textures[0]` (the
/// main input) plus any extra input textures, with `uniforms` as the
/// packed std140 block (see [`crate::shaderfx::pack_uniforms`]).
pub fn run_shader_pass(
&self,
program: &ShaderProgram,
uniforms: &[u8],
textures: &[u64],
dst: u64,
) -> Result<()> {
if textures.len() != program.texture_count as usize {
return Err(Error::Invalid);
}
let dst_tex = lock(&self.textures)
.get(&dst)
.cloned()
.ok_or(Error::NotFound)?;
let uniform_buffer = if program.has_uniforms {
let size = uniforms.len().max(16) as u64;
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("oakrender-fx-uniforms"),
size,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if !uniforms.is_empty() {
self.queue.write_buffer(&buffer, 0, uniforms);
}
Some(buffer)
} else {
None
};
let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("oakrender-fx-sampler"),
mag_filter: if self.filterable && program.filtering {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
},
min_filter: if self.filterable && program.filtering {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
},
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let mut bg_entries: Vec<wgpu::BindGroupEntry> = Vec::new();
if let Some(buffer) = &uniform_buffer {
bg_entries.push(wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
});
}
// Texture views must outlive the bind group creation.
let views: Vec<wgpu::TextureView> = textures
.iter()
.map(|t| {
let reg = lock(&self.textures);
let tex = reg.get(t).ok_or(Error::NotFound)?;
Ok(tex
.texture
.create_view(&wgpu::TextureViewDescriptor::default()))
})
.collect::<Result<Vec<_>>>()?;
for (i, view) in views.iter().enumerate() {
bg_entries.push(wgpu::BindGroupEntry {
binding: 1 + 2 * i as u32,
resource: wgpu::BindingResource::TextureView(view),
});
bg_entries.push(wgpu::BindGroupEntry {
binding: 2 + 2 * i as u32,
resource: wgpu::BindingResource::Sampler(&sampler),
});
}
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("oakrender-fx-bg"),
layout: &program.layout,
entries: &bg_entries,
});
let dst_view = dst_tex
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("oakrender-fx"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("oakrender-fx-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &dst_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&program.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
self.queue.submit(Some(encoder.finish()));
Ok(())
}
}
/// A compiled effect pass: the translated fragment WGSL paired with the
/// fixed fullscreen-triangle vertex stage, plus its bind group layout.
pub struct ShaderProgram {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
/// Input texture count (each binds a (texture, sampler) pair).
pub texture_count: u32,
/// Whether the shader declares the uniform block (binding 0).
pub has_uniforms: bool,
/// Whether the input samplers filter (subject to FLOAT32_FILTERABLE).
pub filtering: bool,
}
/// The fixed vertex stage for effect passes: a fullscreen triangle
/// emitting `ove_texcoord`-convention UVs at location 0. UV v=0 is the
/// first texture data row (the upload/download row order), so effect
/// passes are pixel-identity with the CPU pipeline — no vertical flip
/// anywhere in the chain.
const EFFECT_VS_WGSL: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = pos[vi];
return VsOut(vec4<f32>(p, 0.0, 1.0), vec2<f32>(0.5 + 0.5 * p.x, 0.5 - 0.5 * p.y));
}
"#;
impl GpuContextLike for GpuContext {
fn kind(&self) -> BackendKind {
self.kind()
@@ -675,11 +1017,21 @@ impl DisplayRenderer {
/// Initialize: create the GPU context for the configured backend.
/// `gl_context` must be null — a foreign OpenGL context cannot be
/// adopted by wgpu (documented limitation).
///
/// When the requested backend matches the user's configured choice
/// (the common case), the process-wide shared context
/// ([`GpuContext::shared`]) is adopted so the process owns exactly
/// one wgpu device; an explicit different backend gets its own
/// context.
pub fn init(&mut self, gl_context: *mut std::ffi::c_void) -> Result<()> {
if !gl_context.is_null() {
return Err(Error::Invalid);
}
self.ctx = GpuContext::create(self.backend);
self.ctx = if self.backend == BackendKind::from_user_config() {
GpuContext::shared()
} else {
GpuContext::create(self.backend)
};
if self.ctx.is_none() {
return Err(Error::Failed("no GPU adapter available".into()));
}
@@ -1025,6 +1377,78 @@ mod tests {
ctx.destroy_texture(dst);
}
/// End-to-end effect pass: a translated node shader (gain multiply)
/// runs through `compile_shader_pass`/`run_shader_pass` and the
/// readback matches the expected pixels exactly.
#[test]
fn gpu_effect_pass_runs_translated_shader() {
let Some(ctx) = any_gpu() else {
eprintln!("no adapter; skipping effect pass");
return;
};
let glsl = r#"
uniform sampler2D tex_in;
uniform float gain_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
frag_color = texture(tex_in, ove_texcoord) * gain_in;
}
"#;
let translated = crate::shaderfx::translate(glsl).unwrap();
let program = ctx
.compile_shader_pass(
"test-gain",
&translated.wgsl,
translated.textures.len() as u32,
!translated.uniforms.is_empty(),
false,
)
.unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert("gain_in".into(), oak_node::value::NodeValue::Float(0.5));
let uniforms = crate::shaderfx::pack_uniforms(&translated, &row);
let w = 4;
let h = 2;
let src = ctx.create_texture(w, h).unwrap();
let dst = ctx.create_texture(w, h).unwrap();
let mut frame = Frame::new();
let mut pod = VideoParamsPod::default();
pod.width = w;
pod.height = h;
frame.set_video_params(pod);
frame.allocate();
// Distinct values per pixel (F32 RGBA): 0.2/0.4/0.6/1.0 shifted
// per pixel, so a UV mixup would be visible.
for px in 0..(w * h) as usize {
for c in 0..4 {
let v = 0.2 + 0.1 * (px + c) as f32;
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
.copy_from_slice(&v.to_le_bytes());
}
}
ctx.upload(src, &frame).unwrap();
ctx.run_shader_pass(&program, &uniforms, &[src], dst).unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..(w * h) as usize {
for c in 0..4 {
let at = (px * 4 + c) * 4;
let got = f32::from_le_bytes(out.data[at..at + 4].try_into().unwrap());
let want = (0.2 + 0.1 * (px + c) as f32) * 0.5;
assert!(
(got - want).abs() < 1e-6,
"px {px} ch {c}: got {got}, want {want}"
);
}
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
#[test]
fn gpu_missing_texture_errors() {
let Some(ctx) = any_gpu() else {
+120 -2
View File
@@ -21,6 +21,7 @@
//! OpenColorIO v2.5.2, bundled real-OCIO build). OCIO is never rewritten —
//! this module maps the C++ call surface onto ocio-rs.
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use oak_core::PixelFormat;
@@ -524,6 +525,78 @@ pub fn config_path() -> Option<String> {
))
}
// ---- OCIO GPU function shaders (C++ colormanagement.cpp GetColorContext) --
/// Cache of generated GLSL stubs, keyed by function name + color spaces +
/// config cache id (so a swapped test config re-generates). `None` entries
/// are cached too — the lookup result is deterministic per key.
static OCIO_STUB_CACHE: LazyLock<Mutex<HashMap<String, Option<String>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// The GLSL ES 3.0 shader text implementing the OCIO function `fn_name`
/// between `from_space` and `to_space` (both resolved as color-space names
/// or roles, exactly like the C++ `getProcessor(src, dst)` calls).
///
/// This mirrors the C++ OCIO-node shader path: the node's
/// `GenerateProcessor` builds a `ColorTransform` between two spaces and
/// `GetShaderCode` receives the auto-generated stub from
/// `Renderer::GetColorContext` (colormanagement.cpp), which the node then
/// splices into its fragment shader at the `%1` marker.
///
/// `None` when no default config exists or the processor is LUT-based:
/// `extractGpuShaderInfo` reports lookup textures that the C++ renderer
/// uploads per LUT (the loops right after `GetColorContext`), but the Rust
/// renderer has no such upload path, so those transforms are skipped and
/// the node passes through.
pub fn ocio_function_shader(fn_name: &str, from_space: &str, to_space: &str) -> Option<String> {
let config = default_config()?;
let cache_key = format!(
"{}:{}:{}:{}",
fn_name,
from_space,
to_space,
config.cache_id().unwrap_or_default()
);
if let Some(hit) = OCIO_STUB_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&cache_key)
{
return hit.clone();
}
let stub = build_ocio_function_shader(&config, fn_name, from_space, to_space);
OCIO_STUB_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(cache_key, stub.clone());
stub
}
/// Build (but do not cache) the GLSL stub for `fn_name` between the two
/// spaces (C++ `GpuShaderDesc::CreateShaderDesc` + `setLanguage` +
/// `extractGpuShaderInfo`, colormanagement.cpp `GetColorContext`).
fn build_ocio_function_shader(
config: &SafeConfig,
fn_name: &str,
from_space: &str,
to_space: &str,
) -> Option<String> {
let processor = config.processor(from_space, to_space).ok()?;
let gpu = processor.default_gpu_processor().ok()?;
let mut desc = ocio_rs::GpuShaderDesc::create().ok()?;
desc.set_language(ocio_rs::GpuLanguage::GlslEs3_0).ok()?;
desc.set_function_name(fn_name).ok()?;
desc.set_resource_prefix("ocio_").ok()?;
gpu.try_extract_shader_info(&mut desc).ok()?;
// LUT-based processors need their 1D/3D textures uploaded (the C++
// `GetColorContext` caller loops over `getNum3DTextures`/`getNumTextures`
// right after extraction); without a LUT upload path these cannot render.
if desc.num_textures() > 0 || desc.num_3d_textures() > 0 {
return None;
}
desc.shader_text()
}
// ---- LUT library (C++ LUTLibrary) ------------------------------------------
/// Supported LUT extensions (C++ `LUTLibrary::supported_extensions()`).
@@ -627,8 +700,7 @@ mod tests {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
// Create via the built-in config; a valid processor must exist for
} // Create via the built-in config; a valid processor must exist for
// the ACES scene→display-encoded pairing and must preserve alpha.
let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal);
let p = p.expect("processor handle always returned");
@@ -891,5 +963,51 @@ mod tests {
let p = ColorProcessor::create_lut("/nonexistent/never.cube", Direction::Normal).unwrap();
assert!(!p.is_valid(), "unreadable LUT → pass-through processor");
}
#[test]
fn ocio_function_shader_generates_glsl_for_chromakey() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return; // Bundled OCIO missing (e.g. stub build): skip.
}
let stub = ocio_function_shader(
"SceneLinearToCIEXYZ_d65",
"scene_linear",
"cie_xyz_d65_interchange",
)
.expect("default config generates an analytic shader");
assert!(stub.contains("SceneLinearToCIEXYZ_d65"), "function name present");
assert!(!stub.contains("sampler"), "no LUT upload expected in the default config");
// Cache hit: a repeated call returns the same text.
let again = ocio_function_shader(
"SceneLinearToCIEXYZ_d65",
"scene_linear",
"cie_xyz_d65_interchange",
)
.unwrap();
assert_eq!(stub, again);
}
#[test]
fn ocio_function_shader_rejects_lut_processors() {
let _lock = config_lock();
// studio-config's Rec.709 display is a CLF LUT chain (unlike the
// analytic sRGB one); without a LUT upload path it must be refused.
let Ok(cfg) = ocio_rs::Config::create_from_builtin_config(
"studio-config-v2.1.0_aces-v1.3_ocio-v2.3",
) else {
return;
};
let cfg = SafeConfig(cfg);
assert!(
build_ocio_function_shader(&cfg, "probe", "ACEScg", "Rec.709 - Display").is_none(),
"LUT-based processor must be refused"
);
// A pure-matrix pairing on the same config still generates.
assert!(
build_ocio_function_shader(&cfg, "probe", "ACEScg", "ACES2065-1").is_some(),
"analytic processor still generates"
);
}
}
+712 -13
View File
@@ -19,15 +19,15 @@
//! implementing oaknode's `RenderHooks`. Each C++ `process_*` virtual
//! is one hook method.
//!
//! This pass implements the CPU-side, graph-free parts of the hooks:
//! frame generation and color transforms run fully; plugin jobs
//! dispatch through the executor slot oakplugin installs
//! ([`set_plugin_executor`]); footage decode, shader execution and the
//! disk frame-cache payload I/O depend on the oakcodec / oakplugin C
//! ABIs and fail with explainable errors (their success-path tests are
//! `#[ignore]`d).
//! This pass implements the graph hooks: frame generation runs fully;
//! plugin jobs dispatch through the executor slot oakplugin installs
//! ([`set_plugin_executor`]); footage jobs decode through the oakcodec
//! decoder bridge; shader jobs execute on the shared GPU context
//! ([`crate::backend::GpuContext`], falling back to an input pass-
//! through when no adapter is available); color transforms by identity
//! and the disk frame-cache payload I/O remain deferred.
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use oak_codec::decoder::{
CodecStream, Decoder as _, RenderMode, RetrieveAudioStatus, RetrieveVideoParams,
@@ -35,12 +35,42 @@ use oak_codec::decoder::{
};
use oak_codec::ffmpeg::FFmpegDecoder;
use oak_core::{PixelFormat, Rational, TimeRange};
use oak_node::nodes::jobs::{FootageJobPayload, ShaderJobPayload};
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable};
use crate::error::{Error, Result};
use crate::frame::VideoParamsPod;
use crate::shaderfx::{compile_effect, run_effect};
use crate::texture::{Frame, Texture};
/// Static mapping of OCIO-based node shaders to the OCIO function they
/// splice at their `%1` marker: (node type id, OCIO function name, source
/// space/role, destination space/role) — the C++ `GenerateProcessor`
/// `ColorTransform` pairs, resolved by the OCIO config at runtime.
///
/// Only the node's *function* (the GPU stub) is requested here; the
/// processor is built against the manager's reference color space
/// (C++ chromakey.cpp `GenerateProcessor` uses `GetReferenceColorSpace`,
/// which `ColorManager` sets to `OCIO::ROLE_SCENE_LINEAR`,
/// colormanager.cpp).
pub const OCIO_SHADER_STUBS: &[(&str, &str, &str, &str)] = &[(
"org.olivevideoeditor.Olive.chromakey",
"SceneLinearToCIEXYZ_d65",
"scene_linear",
"cie_xyz_d65_interchange",
)];
/// The OCIO GPU function shader for `type_id` (the `%1` stub), or `None`
/// when the node is not OCIO-based or the processor cannot be generated
/// (no default config, or a LUT processor the renderer cannot upload).
pub fn ocio_stub_for(type_id: &str) -> Option<String> {
let (_, fn_name, from, to) = OCIO_SHADER_STUBS
.iter()
.find(|(id, ..)| *id == type_id)
.copied()?;
crate::color::ocio_function_shader(fn_name, from, to)
}
/// Job specification: the closed set of C++ `*Job` payloads
/// (AcceleratedJob family) as internal evaluation records — jobs no
/// longer travel inside values across module boundaries.
@@ -101,6 +131,11 @@ pub struct RenderEvalHooks {
pub use_cache: bool,
/// Active ticket identity (for cancellation polling).
pub ticket: Option<crate::ticket::TicketId>,
/// Forced output size for resolved footage jobs; `None` decodes at
/// the media's native size (C++ `RenderProcessor` requests the
/// texture at the output resolution). The graph-sequence driver sets
/// this to the sequence frame size.
pub frame_size: Option<(i32, i32)>,
}
// ---------------------------------------------------------------------------
@@ -202,6 +237,7 @@ impl RenderEvalHooks {
Self {
use_cache: false,
ticket: None,
frame_size: None,
}
}
@@ -405,6 +441,260 @@ impl RenderEvalHooks {
}
}
}
/// Resolve the footage payloads a footage node pushed into its output
/// table (C++ FootageJob processing in jobmanager.cpp): decodes each
/// boxed [`FootageJobPayload`] at its request time and replaces the
/// box with the resulting texture. Genuine textures pass through.
fn resolve_footage_jobs(&mut self, table: &mut NodeValueTable) {
let size = self.frame_size.unwrap_or((0, 0));
for (_, value, _) in table.rows_mut() {
let NodeValue::Texture(handle) = value else {
continue;
};
if handle.ctx.is_null() {
continue;
}
let payload = unsafe {
oak_node::handle::get_checked::<FootageJobPayload>(handle)
}
.cloned();
let Some(payload) = payload else {
continue;
};
match render_footage_frame(
&payload.filename,
payload.stream_index,
payload.time,
size,
PixelFormat::F32,
) {
Ok(texture) => {
*value = NodeValue::Texture(oak_node::handle::make_owned(texture));
}
Err(err) => {
eprintln!("footage job decode failed: {err:#}");
}
}
}
}
/// Resolve the shader payloads an effect node pushed into its output
/// table (C++ ShaderJob processing in jobmanager.cpp): execute each
/// boxed [`ShaderJobPayload`] on the shared GPU context and replace
/// the box with the result texture. Failed or un-runnable jobs fall
/// back to the effect input texture from the params row (a pass-
/// through — C++ leaves the failed shader's output as its input);
/// a missing input resolves to `NodeValue::None`.
fn resolve_shader_jobs(&mut self, table: &mut NodeValueTable) {
// Collect the boxes up front: replacing a row while iterating
// `rows_mut` would alias the table.
let jobs: Vec<(usize, ShaderJobPayload)> = table
.rows_mut()
.iter_mut()
.enumerate()
.filter_map(|(i, (_, value, _))| {
let NodeValue::Texture(handle) = value else {
return None;
};
if handle.ctx.is_null() {
return None;
}
let payload = unsafe {
oak_node::handle::get_checked::<ShaderJobPayload>(handle)
}
.cloned();
payload.map(|p| (i, p))
})
.collect();
for (i, payload) in jobs {
let resolved = match self.process_shader_job(&payload) {
Some(texture) => NodeValue::Texture(oak_node::handle::make_owned(texture)),
None => payload
.params
.get(&payload.effect_input)
.cloned()
.unwrap_or(NodeValue::None),
};
table.rows_mut()[i].1 = resolved;
}
}
/// Execute one shader payload (C++ process_shader run by the render
/// worker): compile the emitting behavior's fragment shader on the
/// shared GPU context, upload a CPU input frame when needed, run the
/// requested iterations and return the result texture. `None` when the
/// job cannot run (no GPU context, unknown node type, missing shader,
/// or a compile/upload/run failure) — the caller then falls back to
/// the effect input texture.
fn process_shader_job(&self, payload: &ShaderJobPayload) -> Option<Texture> {
// One log line per shader per process instead of one per frame.
let warn = |reason: &str| {
let key = format!("shader:{}:{}", payload.type_id, payload.shader_id);
if unsupported_warned().insert(key) {
eprintln!(
"shader job \"{}\" (shader \"{}\") failed: {reason}",
payload.type_id, payload.shader_id
);
}
};
// The main input texture: the param row entry under the effect
// input id (usually "tex_in"), already resolved by the traverser
// (footage decode runs before the shader pass in `resolve`).
let input = match payload.params.get(&payload.effect_input) {
Some(NodeValue::Texture(handle)) if !handle.ctx.is_null() => {
(unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
}
_ => None,
};
let Some(ctx) = crate::backend::GpuContext::shared() else {
warn("no GPU context");
return None;
};
// The emitting node behavior: the type id selects the fragment
// source (C++ `node->get_shader_code(shader_id)`).
let Some((_, behavior)) =
oak_node::factory::Factory::global().create_any(&payload.type_id)
else {
warn("unknown node type");
return None;
};
// OCIO-based nodes splice the auto-generated OCIO function into
// their `%1` marker (C++ `GetShaderCode({shader_id, stub})`, the
// stub built in colormanagement.cpp `GetColorContext`). A stub
// that cannot be generated — no default config, or a LUT
// processor with no upload path — falls back to the effect input
// pass-through.
let ocio_entry = OCIO_SHADER_STUBS
.iter()
.find(|(id, ..)| *id == payload.type_id)
.copied();
let glsl = match ocio_entry {
Some((_, fn_name, from, to)) => {
let Some(stub) = crate::color::ocio_function_shader(fn_name, from, to) else {
return None;
};
match behavior.shader_code(&stub) {
Some(glsl) => glsl,
None => {
warn("shader not found");
return None;
}
}
}
None => match behavior.shader_code(&payload.shader_id) {
Some(glsl) => glsl,
None => {
warn("shader not found");
return None;
}
},
};
// Pipeline cache key: the type id plus the shader-variant id (the
// OCIO stub text folds in too, so a config change recompiles
// instead of reusing a stale variant).
let key = match ocio_entry {
Some(_) => {
let mut h = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&glsl, &mut h);
format!(
"{}:{}:ocio:{}",
payload.type_id,
payload.shader_id,
std::hash::Hasher::finish(&h)
)
}
None => format!("{}:{}", payload.type_id, payload.shader_id),
};
let compiled = match compile_effect(&ctx, &key, &glsl, ctx.is_filterable()) {
Ok(effect) => effect,
Err(err) => {
warn(&format!("compile failed: {err:#}"));
return None;
}
};
// Inputs + pass size: GPU input textures bind directly; CPU frames
// upload into a scratch texture first (`uploaded` is freed on every
// exit path).
let (inputs, size, uploaded): (Vec<(String, u64)>, (i32, i32), Option<u64>) =
match &input {
Some(Texture::Gpu {
token,
width,
height,
..
}) => (
vec![(payload.effect_input.clone(), *token)],
(*width, *height),
None,
),
Some(Texture::Cpu(frame)) => {
let token = match ctx.create_texture(frame.width, frame.height) {
Ok(t) => t,
Err(err) => {
warn(&format!("input texture: {err:#}"));
return None;
}
};
if let Err(err) = ctx.upload(token, frame) {
ctx.destroy_texture(token);
warn(&format!("input upload failed: {err:#}"));
return None;
}
(
vec![(payload.effect_input.clone(), token)],
(frame.width, frame.height),
Some(token),
)
}
None => (Vec::new(), (1, 1), None),
};
let dst = match ctx.create_texture(size.0.max(1), size.1.max(1)) {
Ok(t) => t,
Err(err) => {
if let Some(t) = uploaded {
ctx.destroy_texture(t);
}
warn(&format!("output texture: {err:#}"));
return None;
}
};
let result = run_effect(
&ctx,
&compiled,
&payload.params,
&inputs,
dst,
size,
payload.iterations.max(1) as u32,
);
if let Some(t) = uploaded {
ctx.destroy_texture(t);
}
match result {
Ok(()) => Some(Texture::Gpu {
token: dst,
backend: ctx.kind(),
width: size.0.max(1),
height: size.1.max(1),
format: PixelFormat::F32,
ctx: ctx.clone(),
}),
Err(err) => {
ctx.destroy_texture(dst);
warn(&format!("run failed: {err:#}"));
None
}
}
}
}
impl oak_node::traverser::RenderHooks for RenderEvalHooks {
@@ -420,11 +710,14 @@ impl oak_node::traverser::RenderHooks for RenderEvalHooks {
fn resolve(
&mut self,
_node: oak_node::id::NodeId,
node: oak_node::id::NodeId,
_row: &NodeValueRow,
table: &mut NodeValueTable,
) {
let _ = node;
self.resolve_plugin_jobs(table);
self.resolve_footage_jobs(table);
self.resolve_shader_jobs(table);
}
}
@@ -589,14 +882,18 @@ pub fn render_footage_frame(
return Err(Error::Failed("footage decode: bad decoded frame".into()));
}
let mut dst = generate_frame(time, (w, h), format)?;
// `(0, 0)` means "native size": decode without scaling and produce a
// frame matching the decoded dimensions (M12: the graph sequence
// path requests native frames and scales at composite time).
let (dw, dh) = if w > 0 && h > 0 { (w, h) } else { (src_w, src_h) };
let mut dst = generate_frame(time, (dw, dh), format)?;
let dst_linesize = dst.linesize_bytes() as i32;
let src_data = match decoded.data() {
Some(d) => d,
None => return Err(Error::Failed("footage decode: no frame data".into())),
};
if src_w == w && src_h == h && src_linesize == dst_linesize {
if src_w == dw && src_h == dh && src_linesize == dst_linesize {
let bytes = (src_h as usize)
.checked_mul(src_linesize as usize)
.ok_or(Error::NoMem)?;
@@ -609,13 +906,215 @@ pub fn render_footage_frame(
src_h,
&mut dst.data,
dst_linesize,
w,
h,
dw,
dh,
);
}
Ok(Texture::wrap_frame(dst))
}
// ---------------------------------------------------------------------------
// Graph-driven sequence rendering
// ---------------------------------------------------------------------------
/// WGSL fragment for the graph compositor's alpha-over pass (the C++
/// viewer shader is `:/shaders/alphaover.frag`; same premultiplied-over
/// math on raw texture loads). Bindings: 1 = destination (accumulator),
/// 3 = source (the clip frame) — the layout [`GpuContext::compile_shader_pass`]
/// assigns to texture pairs.
const COMP_WGSL: &str = r#"
@group(0) @binding(1) var dst_tex: texture_2d<f32>;
@group(0) @binding(3) var src_tex: texture_2d<f32>;
@fragment
fn main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let dims = textureDimensions(dst_tex);
let coord = clamp(vec2<u32>(u32(i32(frag.x)), u32(i32(frag.y))), vec2<u32>(0u, 0u), dims - vec2<u32>(1u, 1u));
let s = textureLoad(src_tex, coord, 0);
let d = textureLoad(dst_tex, coord, 0);
let a = clamp(s.a, 0.0, 1.0);
return clamp(vec4<f32>(s.rgb * a + d.rgb * (1.0 - a), a + d.a * (1.0 - a)), vec4<f32>(0.0), vec4<f32>(1.0));
}
"#;
/// GPU composite of `frames` into one `(w, h)` frame: bottom (last) to
/// top (first), alpha-over into a ping-pong accumulator pair. Frames that
/// do not match `(w, h)` are skipped (the caller scales at decode time;
/// mismatches are defensive).
fn composite_tracks_gpu(
ctx: &crate::backend::GpuContext,
frames: &[Frame],
size: (i32, i32),
) -> Result<Frame> {
let (w, h) = size;
if w <= 0 || h <= 0 {
return Err(Error::Invalid);
}
let program = ctx.compile_shader_pass("oak/builtin/alpha-over", COMP_WGSL, 2, false, false)?;
let mut acc = ctx.create_texture(w, h)?;
let mut out = ctx.create_texture(w, h)?;
let src = ctx.create_texture(w, h)?;
let result = (|| {
// The accumulator starts fully transparent.
let clear = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32)?;
ctx.upload(acc, &clear)?;
for frame in frames.iter().rev().filter(|f| f.width == w && f.height == h) {
ctx.upload(src, frame)?;
ctx.run_shader_pass(&program, &[], &[acc, src], out)?;
std::mem::swap(&mut acc, &mut out);
}
ctx.download(acc)
})();
ctx.destroy_texture(acc);
ctx.destroy_texture(out);
ctx.destroy_texture(src);
result
}
/// CPU composite of `frames` into one `size` frame — the fallback when no
/// GPU device is available (or the pass fails): bottom (last) to top
/// (first) via [`composite_over`].
fn composite_tracks(frames: Vec<Frame>, size: (i32, i32)) -> Frame {
let (w, h) = size;
if w <= 0 || h <= 0 {
return Frame::dummy();
}
if let Some(ctx) = crate::backend::GpuContext::shared() {
match composite_tracks_gpu(&ctx, &frames, (w, h)) {
Ok(frame) => return frame,
Err(err) => eprintln!("GPU track composite failed, using CPU: {err:#}"),
}
}
let Ok(mut acc) = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32) else {
return Frame::dummy();
};
let acc_stride = acc.linesize_bytes() as i32;
for frame in frames.iter().rev().filter(|f| f.width == w && f.height == h) {
composite_over(
&mut acc.data,
acc_stride,
w,
h,
&frame.data,
frame.linesize_bytes() as i32,
1.0,
);
}
acc
}
/// Render one frame of `viewer` (a sequence) at `time`: evaluate every
/// enabled clip overlapping `time` through the node graph (one traverser
/// pass per clip; the hooks' decoder cache is shared across clips) and
/// composite the resulting frames topmost-first — in the video track
/// list, track 0 is the topmost stack element (C++ `TrackList` order).
///
/// `size` is the decode target for every clip, so all frames composite
/// without per-frame scaling. Errors: `Invalid` for a non-F32 format or a
/// non-positive size, `NotFound` for a missing viewer or non-sequence.
pub fn render_graph_frame(
project: &Mutex<oak_node::project::Project>,
viewer: oak_node::id::NodeId,
time: Rational,
size: (i32, i32),
format: PixelFormat,
) -> Result<Texture> {
if format != PixelFormat::F32 {
return Err(Error::Invalid);
}
let (w, h) = size;
if w <= 0 || h <= 0 {
return Err(Error::Invalid);
}
let graph = &project.lock().unwrap().graph;
let entry = graph.get(viewer).ok_or(Error::NotFound)?;
let sequence = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oak_node::sequence::SequenceBehavior>())
.ok_or(Error::NotFound)?;
// Collect the clips covering `time`: the sequence's track lists (video
// then audio — C++ `Sequence` keeps them in the `k_track_input_format`
// array order), the video list's tracks in stack order, then each
// track's blocks.
let mut clips: Vec<oak_node::id::NodeId> = Vec::new();
for tl_id in &sequence.track_lists {
let Some(tl) = graph.get(*tl_id) else {
continue;
};
let Some(tl) = tl
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oak_node::track::TrackListBehavior>())
else {
continue;
};
if tl.kind != oak_node::track::TrackType::Video {
continue;
}
for track_id in &tl.tracks {
let Some(track) = graph.get(*track_id) else {
continue;
};
let Some(track) = track
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oak_node::track::TrackBehavior>())
else {
continue;
};
for block_id in &track.blocks {
let Some(block) = graph.get(*block_id) else {
continue;
};
let Some(clip) = block
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oak_node::block::ClipBlockBehavior>())
else {
continue;
};
if clip.core.enabled && time >= clip.core.in_() && time < clip.core.out() {
clips.push(*block_id);
}
}
}
}
let mut traverser = oak_node::traverser::Traverser::new();
let mut hooks = RenderEvalHooks::new();
hooks.frame_size = Some(size);
let mut frames: Vec<Frame> = Vec::new();
for clip in clips {
let request = oak_node::traverser::EvalRequest::new(clip, time);
let table = traverser.evaluate(graph, &request, &mut hooks).map_err(|e| {
Error::Failed(format!("graph evaluation of clip {clip:?} failed: {e:?}"))
})?;
let Some(NodeValue::Texture(handle)) = table.get(oak_node::value::ValueType::Texture)
else {
continue;
};
if handle.ctx.is_null() {
continue;
}
let Some(texture) = (unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
else {
continue;
};
match texture.to_frame() {
Ok(frame) => frames.push(frame),
Err(err) => eprintln!("graph sequence: texture read-back failed: {err:#}"),
}
}
let mut frame = composite_tracks(frames, size);
frame.timestamp = time;
Ok(Texture::wrap_frame(frame))
}
/// Render the audio montage over `params.range` (M12 P1): every clip
/// overlapping the range is decoded (interleaved f32 at the output rate
/// and layout) and mixed with its gain; uncovered parts stay silent.
@@ -1056,6 +1555,7 @@ mod tests {
fn produced_frame_honors_ticket_params() {
let params = crate::ticket::VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(2, 1),
force_size: Some((16, 9)),
force_format: Some(PixelFormat::F32),
@@ -1485,4 +1985,203 @@ mod tests {
let out = apply_clip_effects(solid_texture(0.8, 0.4, 0.2, 1.0), &clip, Rational::new(0, 1));
assert_eq!(first_pixel(&out), [0.8, 0.4, 0.2, 1.0]);
}
// ---- Graph-driven sequence (M12 phase 2) ----------------------------
/// Native-size decode: a `(0, 0)` request produces the footage's
/// intrinsic dimensions (the graph sequence path decodes native and
/// scales at composite time).
#[test]
fn footage_native_size_decode() {
let path = std::env::temp_dir().join(format!("oakrender_graph_native_{}.mp4", std::process::id()));
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
let tex = render_footage_frame(&path.to_string_lossy(), 0, Rational::new(0, 1), (0, 0), PixelFormat::F32)
.expect("native-size decode");
assert_eq!(tex.size(), (64, 64));
let _ = std::fs::remove_file(&path);
}
/// The resolve seam decodes each boxed [`FootageJobPayload`] into a
/// texture and leaves genuine texture boxes untouched (in-place row
/// replacement, C++ FootageJob processing).
#[test]
fn resolve_footage_jobs_decodes_payload_box() {
let path = std::env::temp_dir().join(format!("oakrender_graph_resolve_{}.mp4", std::process::id()));
oak_codec::testmedia::write_test_clip(&path, 32, 32, 10, 10).expect("test clip generation");
let mut table = NodeValueTable::default();
let payload = FootageJobPayload {
filename: path.to_string_lossy().into_owned(),
stream_index: 0,
time: Rational::new(0, 1),
};
table.push(
oak_node::value::ValueType::Texture,
NodeValue::Texture(oak_node::handle::make_owned(payload)),
None,
);
let genuine = Texture::wrap_frame(generate_frame(Rational::new(0, 1), (4, 4), PixelFormat::F32).unwrap());
table.push(
oak_node::value::ValueType::Texture,
NodeValue::Texture(oak_node::handle::make_owned(genuine)),
None,
);
let mut hooks = RenderEvalHooks::new();
hooks.frame_size = Some((32, 32));
hooks.resolve_footage_jobs(&mut table);
assert_eq!(table.count(), 2, "both rows stay, only the payload is replaced");
let rows = table.rows();
let NodeValue::Texture(decoded_handle) = &rows[0].1 else {
unreachable!()
};
let decoded = unsafe { oak_node::handle::get_checked::<Texture>(decoded_handle) }
.expect("payload box replaced by the decoded texture");
let Texture::Cpu(frame) = decoded else {
unreachable!()
};
assert_eq!((frame.width, frame.height), (32, 32));
assert!(
frame.data.iter().any(|&b| b != 0),
"decoded frame must contain non-black pixels"
);
let NodeValue::Texture(genuine_handle) = &rows[1].1 else {
unreachable!()
};
let genuine = unsafe { oak_node::handle::get_checked::<Texture>(genuine_handle) }
.expect("genuine texture box stays untouched");
assert_eq!(genuine.size(), (4, 4));
let _ = std::fs::remove_file(&path);
}
/// The composite seam matches the C++ alpha-over math: bottom (last)
/// into transparent, then top (first) over it — `out = src*a +
/// dst*(1-a)`, `out_a = a + dst_a*(1-a)` (premultiplied source).
#[test]
fn composite_tracks_matches_alpha_over_math() {
let Texture::Cpu(top) = &solid_texture(0.5, 0.25, 0.125, 0.5) else {
unreachable!()
};
let Texture::Cpu(bottom) = &solid_texture(1.0, 1.0, 1.0, 0.75) else {
unreachable!()
};
let frames = vec![top.clone(), bottom.clone()];
let expected = [0.625f32, 0.5, 0.4375, 0.875];
// bottom over transparent: (0.75, 0.75, 0.75, 0.75), then top over:
// r = 0.5*0.5 + 0.75*0.5, g = 0.25*0.5 + 0.75*0.5,
// b = 0.125*0.5 + 0.75*0.5, a = 0.5 + 0.75*0.5.
let out = composite_tracks(frames.clone(), (2, 1));
let pixel = first_pixel(&Texture::wrap_frame(out));
for (got, want) in pixel.iter().zip(expected) {
assert!((got - want).abs() < 1e-4, "CPU composite: expected {want}, got {got}");
}
// Same math through the GPU pass when a device is available.
if let Some(ctx) = crate::backend::GpuContext::shared() {
let gpu_out = composite_tracks_gpu(&ctx, &frames, (2, 1)).expect("GPU composite");
let pixel = first_pixel(&Texture::wrap_frame(gpu_out));
for (got, want) in pixel.iter().zip(expected) {
assert!((got - want).abs() < 1e-3, "GPU composite: expected {want}, got {got}");
}
}
}
/// End-to-end chromakey through the real GPU path: a solid opaque
/// green frame keyed on the node's default green key leaves every
/// pixel at zero (the C++ `ColorTransformJob` + `chromakey.frag`
/// math: `colorclose` returns 0 at the key color, so the
/// shadows/highlights transform yields `mask = 0` and `col *= mask`
/// blanks the frame). Exercises the OCIO splice in
/// [`super::process_shader_job`]: the shader's `%1` marker is filled
/// with the real `SceneLinearToCIEXYZ_d65` GLSL generated from the
/// default OCIO config, and the tolerance uniforms reach the shader
/// under their (fixed) input-id spelling.
#[test]
fn gpu_chromakey_keys_green_with_ocio_stub() {
let Some(ctx) = crate::backend::GpuContext::shared() else {
eprintln!("no adapter; skipping");
return;
};
// Install the process-wide default config (the C++
// `ColorManager::SetUpDefaultConfig` startup step; color.rs tests
// do the same). Without it the OCIO stub cannot be generated and
// the job falls back to the input pass-through.
if crate::color::set_up_default_config().is_err() {
eprintln!("bundled OCIO missing; skipping");
return;
}
if crate::color::ocio_function_shader(
"SceneLinearToCIEXYZ_d65",
"scene_linear",
"cie_xyz_d65_interchange",
)
.is_none()
{
eprintln!("no OCIO config; skipping");
return;
}
let size = (16, 16);
let mut frame = generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
for px in frame.data.chunks_exact_mut(16) {
for (c, v) in px.chunks_exact_mut(4).zip([0.0f32, 1.0, 0.0, 1.0]) {
c.copy_from_slice(&v.to_le_bytes());
}
}
let src = ctx.create_texture(size.0, size.1).unwrap();
ctx.upload(src, &frame).unwrap();
let input = Texture::Gpu {
token: src,
backend: ctx.kind(),
width: size.0,
height: size.1,
format: PixelFormat::F32,
ctx: ctx.clone(),
};
let mut params = NodeValueRow::new();
params.insert("tex_in".into(), NodeValue::Texture(oak_node::handle::make_owned(input)));
params.insert("color_key".into(), NodeValue::Color([0.0, 1.0, 0.0, 1.0]));
params.insert("lower_tolerance_in".into(), NodeValue::Float(5.0));
params.insert("upper_tolerance_in".into(), NodeValue::Float(25.0));
params.insert("mask_only_in".into(), NodeValue::Boolean(false));
params.insert("invert_in".into(), NodeValue::Boolean(false));
params.insert("shadows_in".into(), NodeValue::Float(100.0));
params.insert("highlights_in".into(), NodeValue::Float(100.0));
let payload = ShaderJobPayload {
node_id: oak_node::id::NodeId::from_identity(1).unwrap(),
time: Rational::new(0, 1),
iterations: 1,
type_id: "org.olivevideoeditor.Olive.chromakey".into(),
shader_id: String::new(),
effect_input: "tex_in".into(),
params,
iterative_input: String::new(),
};
let rendered = RenderEvalHooks::new()
.process_shader_job(&payload)
.expect("chromakey renders on the GPU");
let Texture::Gpu { token: dst, .. } = rendered else {
panic!("expected a GPU texture");
};
let out = ctx.download(dst).unwrap();
for px in out.data.chunks_exact(16) {
for (c, v) in px.chunks_exact(4).enumerate() {
let got = f32::from_le_bytes(v.try_into().unwrap());
assert!(
got.abs() < 1e-4,
"channel {c}: keyed green must be fully transparent (got {got})"
);
}
}
ctx.destroy_texture(dst);
ctx.destroy_texture(src);
}
}
+58
View File
@@ -480,6 +480,13 @@ pub struct BatchTicketSpec {
pub footage_stream: i32,
/// Sequence montage (ordered topmost-last; empty = none).
pub montage: Vec<WireMontageClip>,
/// Sequence viewer node identity (0 = montage mode; nonzero = render
/// the viewer's graph frame from the worker's loaded snapshot).
pub viewer_node: u64,
/// The owning project's uuid (M16 S1): the worker renders the viewer's
/// graph frame only when this matches the loaded snapshot's project
/// ("" = no graph mode).
pub project_key: String,
}
/// `render_batch` (main->worker) — a batch of frame tickets with
@@ -1224,6 +1231,22 @@ pub struct SharedMemoryRegion {
shm_name: String,
}
/// Owned segment keys that are still mapped when the process exits. Test
/// binaries finish via `std::process::exit` (libtest), which skips Rust
/// static destructors — the process-wide render-manager singleton never
/// runs `Drop`, its `shm_unlink` never fires, and every test run leaks one
/// ~66 MiB segment per worker until `/dev/shm` fills up (the next create
/// then `memset`s a mapping backed by a full tmpfs and faults with SIGBUS).
/// `libc::atexit` handlers DO run under `process::exit`, so each `Create`
/// registers its key here and [`SharedMemoryRegion::atexit_cleanup_owned_shm`]
/// unlinks them all at exit. Unlinking while a peer still maps the segment
/// is safe — POSIX only removes the name; the mapping lives until the last
/// `munmap` (the workers attach without owning, so they never register).
#[cfg(unix)]
static OWNED_SHM_KEYS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
#[cfg(unix)]
static ATEXIT_REGISTERED: std::sync::Once = std::sync::Once::new();
impl SharedMemoryRegion {
/// An empty (invalid) region.
pub fn new() -> SharedMemoryRegion {
@@ -1269,6 +1292,36 @@ impl SharedMemoryRegion {
}
}
/// Remember `key` so it is unlinked at process exit (see
/// [`OWNED_SHM_KEYS`]). Safe to call from any thread; duplicate keys
/// are harmless (the unlink is idempotent).
#[cfg(unix)]
fn track_owned_key(key: &str) {
ATEXIT_REGISTERED.call_once(|| {
// SAFETY: `atexit_cleanup_owned_shm` is a plain extern "C" fn
// that is valid for the whole process lifetime, which is what
// libc::atexit requires.
unsafe {
libc::atexit(Self::atexit_cleanup_owned_shm);
}
});
if let Ok(mut keys) = OWNED_SHM_KEYS.lock() {
keys.get_or_insert_with(Vec::new).push(key.to_string());
}
}
/// Unlink every segment this process created, at process exit — runs
/// even when the exit path is `std::process::exit` (test binaries).
#[cfg(unix)]
extern "C" fn atexit_cleanup_owned_shm() {
let keys = OWNED_SHM_KEYS.lock().ok().and_then(|mut keys| keys.take());
if let Some(keys) = keys {
for key in keys {
Self::unlink_key(&key);
}
}
}
/// Open the segment identified by `key` with the given `size` in bytes.
///
/// `key` is a short identifier (no leading slash needed; the platform
@@ -1358,6 +1411,7 @@ impl SharedMemoryRegion {
self.error.clear();
if mode == ShmMode::Create {
Self::track_owned_key(&self.key);
unsafe { ptr::write_bytes(self.data, 0, size) };
}
true
@@ -1845,6 +1899,8 @@ mod tests {
footage_file: "a.mp4".into(),
footage_stream: 0,
montage: vec![],
viewer_node: 0,
project_key: String::new(),
},
BatchTicketSpec {
ticket: 42,
@@ -1877,6 +1933,8 @@ mod tests {
}],
}],
}],
viewer_node: 0,
project_key: String::new(),
},
],
};
+1
View File
@@ -57,6 +57,7 @@ pub mod ipc;
pub mod manager;
pub mod procpool;
pub mod scheduler;
pub mod shaderfx;
pub mod texture;
pub mod ticket;
pub mod worker;
+76 -1
View File
@@ -33,7 +33,7 @@ use crate::error::{Error, Result};
use crate::eval;
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef};
use crate::ticket::{TicketArena, TicketId};
use crate::worker::{InlineDispatcher, JobDispatch};
use crate::worker::{GraphSnapshotStore, InlineDispatcher, JobDispatch};
static MANAGER: Mutex<Option<Arc<RenderManager>>> = Mutex::new(None);
@@ -73,6 +73,20 @@ pub struct RenderManager {
pub autocacher: Mutex<Option<PreviewAutoCacher>>,
/// Aggressive decoder GC toggle.
aggressive_gc: AtomicBool,
/// Graph snapshot files shared with worker processes (M16 S1).
snapshots: GraphSnapshotStore,
/// The snapshot path currently shipped to the worker pool (None until
/// the app pushes one).
current_snapshot: Mutex<Option<String>>,
/// The (project uuid, undo revision) the current snapshot was written
/// for (M16 S1: dedup key — revisions alone collide across projects,
/// since every fresh project shares small revision numbers).
current_key: Mutex<Option<(String, u64)>>,
/// Teardown in progress (M16 S1): set first thing in
/// [`RenderManager::shutdown`]; `set_graph_snapshot` /
/// `clear_graph_snapshot` become no-ops afterwards so a stale push
/// from a dying test/app cannot re-arm the worker pool mid-shutdown.
stopping: AtomicBool,
}
impl RenderManager {
@@ -133,6 +147,10 @@ impl RenderManager {
requested_backend: backend,
autocacher: Mutex::new(None),
aggressive_gc: AtomicBool::new(false),
snapshots: GraphSnapshotStore::new(),
current_snapshot: Mutex::new(None),
current_key: Mutex::new(None),
stopping: AtomicBool::new(false),
}));
Ok(())
}
@@ -155,15 +173,72 @@ impl RenderManager {
pub fn shutdown() {
let manager = lock(&MANAGER).take();
if let Some(manager) = manager {
// Mark stopping FIRST: from here on `set_graph_snapshot`,
// `clear_graph_snapshot` and `poll` become no-ops, so a
// concurrent app thread racing the teardown cannot re-arm the
// dispatcher after it is drained.
manager.stopping.store(true, Ordering::Release);
manager.tickets.cancel_all();
// Drain after the cancels so queued completions fire. Both
// dispatches are idempotent.
manager.dispatch.shutdown();
manager.audio_dispatch.shutdown();
// Release the graph snapshot (the file is retained: a worker
// may still hold the path for a late load_graph).
if let Some(path) = lock(&manager.current_snapshot).take() {
manager.snapshots.release(&path);
}
// The store directory is cleared here and only here — no worker
// can reference a snapshot file once the dispatchers are down.
manager.snapshots.cleanup();
drop(manager);
}
}
/// M16 S1 graph mode: snapshot the project to the worker pool. The
/// snapshot is serialized once per (project, revision) — the undo-stack
/// position; the key includes the project's uuid because fresh projects
/// reuse small identity numbers and two projects at the same revision
/// would otherwise collide on one file (the cross-project snapshot race
/// that shipped before M16 S1). A new key rewrites the file and
/// re-sends `load_graph` to every live worker, releasing the previous
/// snapshot (file retained at zero refs).
pub fn set_graph_snapshot(
&self,
project: &std::sync::Mutex<oak_node::project::Project>,
revision: u64,
) -> Result<()> {
if self.stopping.load(Ordering::Acquire) {
return Ok(()); // teardown: no re-arm after the drain
}
let uuid = lock(project).uuid.clone();
if *lock(&self.current_key) == Some((uuid.clone(), revision)) {
return Ok(()); // unchanged state: no rewrite, no re-send
}
let path = self.snapshots.acquire(project, revision)?;
if let Some(old) = lock(&self.current_snapshot).replace(path.clone()) {
self.snapshots.release(&old);
}
self.dispatch.set_graph_snapshot(Some(path));
*lock(&self.current_key) = Some((uuid, revision));
Ok(())
}
/// M16 S1 graph mode: drop the current snapshot (project closed). The
/// protocol has no clear message, so alive workers keep their loaded
/// graph; new/restarted workers no longer load it and the snapshot file
/// is retained (removed wholesale at manager shutdown).
pub fn clear_graph_snapshot(&self) {
if self.stopping.load(Ordering::Acquire) {
return;
}
if let Some(old) = lock(&self.current_snapshot).take() {
self.snapshots.release(&old);
}
*lock(&self.current_key) = None;
self.dispatch.set_graph_snapshot(None);
}
/// Pump the video backend's control plane (M15 S2): the process
/// dispatcher delivers ticket completions from its poll loop, so the
/// UI tick and any blocking wait must call this regularly. No-op on
+55 -1
View File
@@ -49,6 +49,14 @@
//! worker dead: its claimed frames are re-queued to the scheduler
//! (any healthy worker may claim them), the child is reaped, the
//! segment recreated and the process respawned (bounded restarts).
//! - **Where rendering runs.** Each `oak-worker` child is a
//! single-threaded NDJSON loop (no render thread inside the worker):
//! a `render_batch` message renders every ticket synchronously on the
//! child's loop thread (`WorkerSession::handle_render_batch_stream`
//! in `crates/oak-worker/src/worker.rs`). Parallelism comes from
//! the pool of worker processes spawned here (`spawn_worker`),
//! never from threads inside a worker — see "Where the rendering
//! happens" in `crates/oak-worker/README.md`.
//! - **S2 model.** The in-process [`crate::worker::WorkerPool`] is
//! gone (M15 S2 mandate); [`crate::manager::RenderManager`] defaults
//! to this backend. The ticket arena also routes **playback-window**
@@ -950,10 +958,15 @@ impl ProcessDispatcher {
/// Pump the control plane: drain worker events, restart the dead,
/// claim + dispatch batches. Non-blocking; call from the UI tick (or
/// after any submit/release). Completions fire after the lock drops.
/// A no-op once shutting down (M16 S1: a stale poll must not restart
/// dead workers during/after teardown).
pub fn poll(&self) {
let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
{
let mut inner = lock(&self.inner);
if inner.shutting_down {
return;
}
self.pump(&mut inner, &mut fired);
}
for (done, result) in fired {
@@ -1035,6 +1048,32 @@ impl ProcessDispatcher {
}
}
/// Set (or clear) the graph snapshot path shipped to every worker via
/// `load_graph` (M16 S1). A new path is sent to every alive worker —
/// reloading a snapshot is idempotent, and the manager only re-sends
/// when the snapshot revision actually changes. Clearing only updates
/// the config: the protocol has no clear message, so alive workers
/// keep their loaded graph and only new/restarted workers skip it.
pub fn set_graph_snapshot(&self, path: Option<String>) {
let mut inner = lock(&self.inner);
if inner.shutting_down {
return;
}
inner.config.graph_snapshot = path.clone();
let Some(path) = path else { return };
for handle in inner.workers.iter_mut() {
if matches!(handle.state, WorkerState::Alive | WorkerState::Starting) {
handle.graph_sent = true;
if self
.send_json(handle, &json!({ "type": "load_graph", "path": path }))
.is_err()
{
handle.state = WorkerState::Dead;
}
}
}
}
// ---- internals ------------------------------------------------------
fn pump(&self, inner: &mut Inner, fired: &mut Vec<(Completion, TicketResult)>) {
@@ -1476,7 +1515,10 @@ impl ProcessDispatcher {
let shm = ShmRegionView::create(&key, inner.slots, inner.slot_bytes)?;
let mut child = Command::new(&inner.bin)
.args(["--backend", "cpu"])
// Auto backend: prefer the GPU, fall back to the CPU renderer
// (M16 S1 — the worker tolerates a GPU init failure and keeps
// evaluating headless).
.args(["--backend", "auto"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
@@ -1810,6 +1852,12 @@ impl JobDispatch for ProcessDispatcher {
});
}
/// Ship a graph snapshot to the worker pool (M16 S1; delegates to the
/// inherent [`ProcessDispatcher::set_graph_snapshot`]).
fn set_graph_snapshot(&self, path: Option<String>) {
self.set_graph_snapshot(path);
}
/// Release a consumed frame's slot (delegates to the inherent
/// release — see [`ProcessDispatcher::release_frame`]).
fn release_frame(&self, frame: &ShmFrameRef) {
@@ -1964,6 +2012,12 @@ fn build_ticket_spec(
footage_file,
footage_stream,
montage,
// M16 S1 graph mode: the worker renders the viewer's graph frame
// when nonzero (else the montage path above) — and only when the
// ticket's project matches the worker's loaded snapshot (the
// `project_key` uuid guard).
viewer_node: params.viewer,
project_key: params.project.clone(),
}
}
+882
View File
@@ -0,0 +1,882 @@
// 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 <http://www.gnu.org/licenses/>.
//! Effect shader translation: the nodes' embedded GLSL fragment shaders
//! (the C++ `:/shaders/*.frag` corpus, kept verbatim in oak-node) are
//! converted to WGSL at runtime through naga and run as wgpu fullscreen
//! passes.
//!
//! The conversion mirrors the C++ Vulkan backend's mechanical rewrite
//! (`vulkanrenderer.cpp` `ConvertGlslToVulkan` + `ExtractUniforms`):
//!
//! - a `#version 450 core` prelude is prepended;
//! - legacy `texture2D(`/`texture3D(` calls are renamed to `texture(`;
//! - the pipeline I/O globals get explicit locations
//! (`layout(location = 0) in vec2 ove_texcoord;`,
//! `layout(location = 0) out vec4 frag_color;`);
//! - loose `uniform <type> <name>;` declarations are extracted: samplers
//! get explicit `set`/`binding` qualifiers, and value uniforms are
//! collected into one anonymous `std140` uniform block (GLSL 450
//! anonymous block members stay accessible by their bare names, so the
//! shader body needs no rewriting).
//!
//! Uniform values are packed by the caller following std140 rules
//! (float/int/bool 4/4, vec2 8/8, vec3 12/16, vec4 16/16, mat4 64/16 —
//! the same table the C++ `GetStd140Size/Alignment` used).
use crate::error::{Error, Result};
/// A value uniform's GLSL type (std140 packing + `NodeValue` mapping).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UniformType {
/// `float`.
Float,
/// `int`.
Int,
/// `bool` (stored as `int` in the block; WGSL has no shareable bool).
Bool,
/// `vec2`.
Vec2,
/// `vec3`.
Vec3,
/// `vec4`.
Vec4,
/// `mat4`.
Mat4,
}
impl UniformType {
/// The GLSL type keyword, or `None` when it is not a value uniform
/// (samplers, arrays and unknown types are not packable).
fn from_keyword(kw: &str) -> Option<UniformType> {
Some(match kw {
"float" => UniformType::Float,
"int" => UniformType::Int,
"bool" => UniformType::Bool,
"vec2" => UniformType::Vec2,
"vec3" => UniformType::Vec3,
"vec4" => UniformType::Vec4,
"mat4" => UniformType::Mat4,
_ => return None,
})
}
/// The GLSL keyword back (block re-emission).
fn keyword(self) -> &'static str {
match self {
UniformType::Float => "float",
UniformType::Int => "int",
UniformType::Bool => "bool",
UniformType::Vec2 => "vec2",
UniformType::Vec3 => "vec3",
UniformType::Vec4 => "vec4",
UniformType::Mat4 => "mat4",
}
}
/// std140 base alignment in bytes (C++ `GetStd140Alignment`).
pub fn align(self) -> usize {
match self {
UniformType::Float | UniformType::Int | UniformType::Bool => 4,
UniformType::Vec2 => 8,
UniformType::Vec3 | UniformType::Vec4 | UniformType::Mat4 => 16,
}
}
/// std140 storage size in bytes (C++ `GetStd140Size`).
pub fn size(self) -> usize {
match self {
UniformType::Float | UniformType::Int | UniformType::Bool => 4,
UniformType::Vec2 => 8,
UniformType::Vec3 => 12,
UniformType::Vec4 => 16,
UniformType::Mat4 => 64,
}
}
}
/// A value uniform (std140-packed into the uniform block).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UniformDecl {
/// Uniform name (= the node input id, the Olive convention).
pub name: String,
/// Its type.
pub ty: UniformType,
/// Byte offset in the packed block (assigned by [`translate`]).
pub offset: usize,
}
/// The result of translating one effect fragment shader.
#[derive(Clone, Debug)]
pub struct TranslatedShader {
/// The WGSL fragment module (entry point `main`).
pub wgsl: String,
/// Value uniforms in block order (offsets assigned, block tail-padded
/// to 16).
pub uniforms: Vec<UniformDecl>,
/// The packed uniform block size in bytes (0 = no value uniforms).
pub uniform_block_bytes: usize,
/// Texture input names in binding order (combined `sampler2D` etc.;
/// the first one is the effect's main input by Olive convention).
pub textures: Vec<String>,
/// Fragment input varyings in location order (`ove_texcoord` first,
/// then any effect-specific varyings like cornerpin's perspective
/// helpers). The runner's vertex stage must produce all of them.
pub varyings: Vec<String>,
}
// ---------------------------------------------------------------------------
// The effect runner
// ---------------------------------------------------------------------------
/// A compiled effect: the translated shader plus its cached pipeline.
pub struct CompiledEffect {
/// The translation result (uniform layout + texture bindings).
pub translated: TranslatedShader,
/// The compiled pipeline in the context cache.
pub program: std::sync::Arc<crate::backend::ShaderProgram>,
}
/// Translate `glsl` and compile the pipeline on `ctx`. `key` is the
/// pipeline cache key (the effect type id plus any shader-variant id).
pub fn compile_effect(
ctx: &crate::backend::GpuContext,
key: &str,
glsl: &str,
filtering: bool,
) -> Result<CompiledEffect> {
let translated = translate(glsl)?;
let program = ctx.compile_shader_pass(
key,
&translated.wgsl,
translated.textures.len() as u32,
!translated.uniforms.is_empty(),
filtering,
)?;
Ok(CompiledEffect { translated, program })
}
/// Run an effect: shade `dst` from the input textures with `params` as
/// the uniform values.
///
/// - `inputs` maps the shader's texture names to context texture tokens;
/// declared textures without an input bind the context's 1×1
/// placeholder and get `<name>_enabled = 0` (C++ Blit's texture
/// binding + enable-flag convention). The first declared texture is
/// the main input — and the iterative one when `iterations` > 1
/// (C++ `ShaderJob::SetIterations` with `tex_in`).
/// - `iterations` runs the shader that many times, feeding each pass's
/// output back as the main input (C++ `OpenGLRenderer::Blit`'s
/// ping-pong; the `ove_iteration` uniform tracks the pass index).
/// - Well-known uniforms are auto-filled when declared but absent from
/// `params`: `resolution_in` (the frame size), `ove_iteration`,
/// `ove_mvpmat` (identity).
pub fn run_effect(
ctx: &crate::backend::GpuContext,
effect: &CompiledEffect,
params: &oak_node::value::NodeValueRow,
inputs: &[(String, u64)],
dst: u64,
size: (i32, i32),
iterations: u32,
) -> Result<()> {
use oak_node::value::NodeValue;
let declares = |name: &str| effect.translated.uniforms.iter().any(|u| u.name == name);
// Resolve every declared texture to a token (placeholder when the
// effect's input is unconnected).
let mut tokens: Vec<u64> = Vec::with_capacity(effect.translated.textures.len());
let mut row = params.clone();
for (i, name) in effect.translated.textures.iter().enumerate() {
let token = inputs
.iter()
.find(|(n, _)| n == name)
.map(|(_, t)| *t)
.or_else(|| if i == 0 { inputs.first().map(|(_, t)| *t) } else { None });
match token {
Some(t) => {
tokens.push(t);
let flag = format!("{name}_enabled");
if declares(&flag) && !row.contains_key(&flag) {
row.insert(flag, NodeValue::Boolean(true));
}
}
None => tokens.push(ctx.placeholder_texture()?),
}
}
// Well-known uniforms (C++ inserts resolution_in at job-build time;
// ove_mvpmat defaults to identity in Blit).
if declares("resolution_in") && !row.contains_key("resolution_in") {
row.insert(
"resolution_in".to_string(),
NodeValue::Vec2([size.0 as f64, size.1 as f64]),
);
}
if declares("ove_mvpmat") && !row.contains_key("ove_mvpmat") {
let mut m = [0.0f64; 16];
for i in 0..4 {
m[i * 4 + i] = 1.0;
}
row.insert("ove_mvpmat".to_string(), NodeValue::Matrix(m));
}
let iterations = iterations.max(1);
if iterations == 1 {
let uniforms = pack_uniforms(&effect.translated, &row);
return ctx.run_shader_pass(&effect.program, &uniforms, &tokens, dst);
}
// Ping-pong (C++ Blit): one scratch texture for two passes, two for
// longer chains; the last pass always lands in `dst`.
let scratch_a = ctx.create_texture(size.0, size.1)?;
let scratch_b = if iterations > 2 {
Some(ctx.create_texture(size.0, size.1)?)
} else {
None
};
let result = (|| -> Result<()> {
let mut input_tokens = tokens.clone();
for i in 0..iterations {
let mut pass_row = row.clone();
if declares("ove_iteration") {
pass_row.insert("ove_iteration".to_string(), NodeValue::Int(i as i64));
}
let target = if i == iterations - 1 {
dst
} else if i % 2 == 0 {
scratch_a
} else {
scratch_b.unwrap()
};
let uniforms = pack_uniforms(&effect.translated, &pass_row);
ctx.run_shader_pass(&effect.program, &uniforms, &input_tokens, target)?;
input_tokens[0] = target;
}
Ok(())
})();
ctx.destroy_texture(scratch_a);
if let Some(b) = scratch_b {
ctx.destroy_texture(b);
}
result
}
/// Replace whole-word occurrences of `name` in `s` with `replacement`
/// (identifier boundaries: alphanumerics and `_`). The node-shader
/// corpus uses plain identifiers, so this simple scan suffices — no
/// regex dependency.
fn replace_ident(s: &str, name: &str, replacement: &str) -> String {
fn is_ident_char(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
let bytes = s.as_bytes();
let name = name.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(name)
&& (i == 0 || !is_ident_char(bytes[i - 1]))
&& !bytes
.get(i + name.len())
.is_some_and(|&c| is_ident_char(c))
{
out.push_str(replacement);
i += name.len();
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Parse a standalone `in`/`out` varying declaration line body (after
/// the direction keyword), e.g. `vec2 ove_texcoord;` → `(name, type)`.
/// Function parameter lists and anything else are rejected.
fn parse_plain_global(rest: &str) -> Option<(String, String)> {
let rest = rest.trim().strip_suffix(';')?.trim();
let (ty, name) = rest.split_once(char::is_whitespace)?;
let name = name.trim();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some((name.to_string(), ty.to_string()))
}
/// Binding 0 is the uniform block; textures/samplers follow.
const UNIFORM_BLOCK_BINDING: u32 = 0;
/// Pack the uniform block for `shader` from `params` (the effect's
/// parameter row; uniform names are the node input ids by Olive
/// convention). Packing follows the declared uniform types, converting
/// from whatever `NodeValue` shape arrived (the C++ Blit dispatched on
/// the value type with GL's implicit conversions; here the declared
/// type wins). Undeclared params are skipped; missing values stay zero.
pub fn pack_uniforms(
shader: &TranslatedShader,
params: &oak_node::value::NodeValueRow,
) -> Vec<u8> {
use oak_node::value::NodeValue;
let mut buf = vec![0u8; shader.uniform_block_bytes];
for decl in &shader.uniforms {
let Some(value) = params.get(&decl.name) else {
continue;
};
let f32s: Vec<f32> = match (decl.ty, value) {
(UniformType::Float, NodeValue::Float(v)) => vec![*v as f32],
(UniformType::Float, NodeValue::Int(v) | NodeValue::Combo(v)) => vec![*v as f32],
(UniformType::Float, NodeValue::Boolean(v)) => vec![f32::from(u8::from(*v))],
(UniformType::Int, NodeValue::Int(v) | NodeValue::Combo(v)) => {
write_i32(&mut buf, decl.offset, *v as i32);
continue;
}
(UniformType::Int, NodeValue::Float(v)) => {
write_i32(&mut buf, decl.offset, *v as i32);
continue;
}
(UniformType::Bool, NodeValue::Boolean(v)) => {
write_i32(&mut buf, decl.offset, i32::from(*v));
continue;
}
(UniformType::Bool, NodeValue::Int(v) | NodeValue::Combo(v)) => {
write_i32(&mut buf, decl.offset, i32::from(*v != 0));
continue;
}
(UniformType::Vec2, NodeValue::Vec2(v)) => v.iter().map(|x| *x as f32).collect(),
(UniformType::Vec3, NodeValue::Vec3(v)) => v.iter().map(|x| *x as f32).collect(),
// A color packs into a vec3 slot as its RGB (C++ Color →
// glUniform4f only for vec4; a vec3 target takes rgb).
(UniformType::Vec3, NodeValue::Color(v)) => {
v[..3].iter().map(|x| *x as f32).collect()
}
(UniformType::Vec4, NodeValue::Vec4(v) | NodeValue::Color(v)) => {
v.iter().map(|x| *x as f32).collect()
}
// Matrices: GLSL mat4 is column-major; the NodeValue comment
// marks the layout row-major, so transpose on the way in.
(UniformType::Mat4, NodeValue::Matrix(m)) => {
let mut cols = Vec::with_capacity(16);
for c in 0..4 {
for r in 0..4 {
cols.push(m[r * 4 + c] as f32);
}
}
cols
}
_ => continue,
};
for (i, v) in f32s.iter().enumerate() {
let at = decl.offset + i * 4;
if at + 4 <= buf.len() {
buf[at..at + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
buf
}
fn write_i32(buf: &mut [u8], offset: usize, v: i32) {
if offset + 4 <= buf.len() {
buf[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
}
}
/// Whether a GLSL type keyword is a combined sampler (C++
/// `IsSamplerType`: sampler\*D / samplerCube / sampler2DArray).
fn is_sampler_type(kw: &str) -> bool {
kw.starts_with("sampler")
}
/// Parse a `uniform <type> <name>;` declaration line (the constrained
/// style of the node shader corpus: one declaration per line, no layout
/// qualifiers, no initializers, no arrays — arrays are reported as
/// unsupported, matching the C++ Blit). Returns `(type, name)`.
fn parse_uniform_line(line: &str) -> Option<(&str, &str)> {
let t = line.trim_start();
let rest = t.strip_prefix("uniform")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
let rest = rest.trim_start();
let (ty, rest) = rest.split_once(char::is_whitespace)?;
let name = rest.trim().strip_suffix(';')?.trim();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some((ty, name))
}
/// Translate one GLSL fragment shader to WGSL (naga glsl-in → wgsl-out).
/// The source keeps the Olive node-shader conventions; see the module
/// docs for the rewrite steps.
pub fn translate(glsl: &str) -> Result<TranslatedShader> {
let mut body_lines: Vec<String> = Vec::new();
let mut uniforms: Vec<(UniformType, String)> = Vec::new();
let mut textures: Vec<String> = Vec::new();
for line in glsl.lines() {
if let Some((ty, name)) = parse_uniform_line(line) {
if is_sampler_type(ty) {
textures.push(name.to_string());
continue;
}
match UniformType::from_keyword(ty) {
Some(t) => {
uniforms.push((t, name.to_string()));
continue;
}
None => {
return Err(Error::Failed(format!(
"unsupported uniform type in shader: {ty} {name}"
)));
}
}
}
body_lines.push(line.to_string());
}
let mut src = String::from("#version 450 core\n");
let mut in_loc = 0u32;
let mut out_loc = 0u32;
let mut varyings: Vec<String> = Vec::new();
// Re-emit the extracted uniforms BEFORE the body (GLSL requires
// declarations to precede use): the value block first (binding 0),
// then the samplers. The block is anonymous — GLSL 450 anonymous
// block members stay accessible by their bare names, so the shader
// body needs no rewriting.
if !uniforms.is_empty() {
src.push_str("layout(std140, set = 0, binding = ");
src.push_str(&UNIFORM_BLOCK_BINDING.to_string());
src.push_str(") uniform OakParams {\n");
for (ty, name) in &uniforms {
// bools are declared as int (WGSL has no host-shareable
// bool); the body's uses were rewritten to `bool(x)`.
let kw = if *ty == UniformType::Bool {
"int"
} else {
ty.keyword()
};
src.push_str(&format!(" {kw} {name};\n"));
}
src.push_str("};\n");
}
for (i, name) in textures.iter().enumerate() {
// Split the combined sampler2D: texture at an odd binding, its
// sampler right after (the binding map is reported through
// [`TranslatedShader::textures`] in declaration order).
src.push_str(&format!(
"layout(set = 0, binding = {}) uniform texture2D {};\n\
layout(set = 0, binding = {}) uniform sampler {}_s;\n",
1 + 2 * i,
name,
2 + 2 * i,
name
));
}
for line in &body_lines {
let mut l = line.clone();
// Strip a pre-existing #version (the prelude pins 450 core).
if l.trim_start().starts_with("#version") {
continue;
}
// Legacy sampling entry points.
l = l.replace("texture2D(", "texture(");
l = l.replace("texture3D(", "texture(");
l = l.replace("textureCube(", "texture(");
// naga's GLSL frontend has no combined sampler2D uniforms: split
// each into (texture2D, sampler) and combine at the call site
// (`texture(sampler2D(tex, tex_s), uv)` — the same style naga's
// own GLSL tests use).
for name in &textures {
l = l.replace(
&format!("texture({name},"),
&format!("texture(sampler2D({name}, {name}_s),"),
);
}
// WGSL has no host-shareable bool: bool uniforms live in the
// block as `int`, so their uses become `bool(x)` (nonzero test).
for (ty, name) in &uniforms {
if *ty == UniformType::Bool {
l = replace_ident(&l, name, &format!("bool({name})"));
}
}
// Explicit interface locations (C++ ConvertGlslToVulkan did this
// for the two known globals; shaders with extra varyings — e.g.
// cornerpin's perspective helpers — get sequential locations so
// nothing collides at location 0).
let trimmed = l.trim_start();
if let Some(rest) = trimmed.strip_prefix("in ") {
if let Some((name, _ty)) = parse_plain_global(rest) {
let indent = &l[..l.len() - trimmed.len()];
l = format!("{indent}layout(location = {in_loc}) in {}", rest.trim());
varyings.push(name);
in_loc += 1;
}
} else if let Some(rest) = trimmed.strip_prefix("out ") {
if parse_plain_global(rest).is_some() {
let indent = &l[..l.len() - trimmed.len()];
l = format!("{indent}layout(location = {out_loc}) out {}", rest.trim());
out_loc += 1;
}
}
src.push_str(&l);
src.push('\n');
}
let module = naga::front::glsl::Frontend::default()
.parse(&naga::front::glsl::Options::from(naga::ShaderStage::Fragment), &src)
.map_err(|e| Error::Failed(format!("GLSL parse failed: {e:?}")))?;
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.map_err(|e| Error::Failed(format!("translated shader failed validation: {e:?}")))?;
let wgsl = naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty())
.map_err(|e| Error::Failed(format!("WGSL emission failed: {e:?}")))?;
// std140 offsets (declaration order; the block tail pads to 16).
let mut offset = 0usize;
let mut decls = Vec::with_capacity(uniforms.len());
for (ty, name) in uniforms {
let align = ty.align();
offset = offset.next_multiple_of(align);
decls.push(UniformDecl { name, ty, offset });
offset += ty.size();
}
let uniform_block_bytes = if decls.is_empty() {
0
} else {
offset.next_multiple_of(16)
};
Ok(TranslatedShader {
wgsl,
uniforms: decls,
uniform_block_bytes,
textures,
varyings,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The minimal node-shader shape: texture input + one float uniform.
#[test]
fn translates_minimal_effect_shader() {
let glsl = r#"
uniform sampler2D tex_in;
uniform float gain_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
frag_color = texture(tex_in, ove_texcoord) * gain_in;
}
"#;
let out = translate(glsl).expect("translate");
assert_eq!(out.textures, vec!["tex_in"]);
assert_eq!(out.uniforms.len(), 1);
assert_eq!(out.uniforms[0].name, "gain_in");
assert_eq!(out.uniforms[0].ty, UniformType::Float);
assert_eq!(out.uniforms[0].offset, 0);
assert_eq!(out.uniform_block_bytes, 16);
assert!(out.wgsl.contains("fn main"), "WGSL entry point: {}", out.wgsl);
}
/// bool/int/vec/color-shaped uniforms pack with std140 offsets.
#[test]
fn std140_offsets_match_the_cpp_table() {
let glsl = r#"
uniform sampler2D tex_in;
uniform bool flag_in;
uniform vec2 center_in;
uniform float radius_in;
uniform vec4 color_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
vec4 c = texture(tex_in, ove_texcoord);
frag_color = flag_in ? color_in * radius_in : vec4(c.xy + center_in, c.zw);
}
"#;
let out = translate(glsl).expect("translate");
let offsets: Vec<(&str, usize)> = out
.uniforms
.iter()
.map(|u| (u.name.as_str(), u.offset))
.collect();
// bool 4/4 @0; vec2 align 8 @8; float 4 @16; vec4 align 16 @32.
assert_eq!(
offsets,
vec![
("flag_in", 0),
("center_in", 8),
("radius_in", 16),
("color_in", 32)
]
);
assert_eq!(out.uniform_block_bytes, 48);
}
/// Array uniforms are unsupported (C++ Blit skipped them too).
#[test]
fn array_uniforms_are_rejected() {
let glsl = "uniform float taps_in[8];\nvoid main() {}\n";
// The array syntax is not a parseable `uniform <type> <name>;`
// line for our parser, so the declaration is left in the body and
// naga sees it — either way the translation must not panic.
let _ = translate(glsl);
}
/// Uniform packing follows the declared types and std140 offsets.
#[test]
fn pack_uniforms_maps_node_values() {
use oak_node::value::{NodeValue, NodeValueRow};
let glsl = r#"
uniform sampler2D tex_in;
uniform float gain_in;
uniform bool flag_in;
uniform vec4 color_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main() { frag_color = texture(tex_in, ove_texcoord); }
"#;
let out = translate(glsl).unwrap();
let mut row = NodeValueRow::new();
row.insert("gain_in".into(), NodeValue::Float(0.5));
row.insert("flag_in".into(), NodeValue::Boolean(true));
row.insert("color_in".into(), NodeValue::Color([0.1, 0.2, 0.3, 0.4]));
let buf = pack_uniforms(&out, &row);
assert_eq!(buf.len(), out.uniform_block_bytes);
let gain = out.uniforms.iter().find(|u| u.name == "gain_in").unwrap();
assert_eq!(
f32::from_le_bytes(buf[gain.offset..gain.offset + 4].try_into().unwrap()),
0.5
);
let flag = out.uniforms.iter().find(|u| u.name == "flag_in").unwrap();
assert_eq!(
i32::from_le_bytes(buf[flag.offset..flag.offset + 4].try_into().unwrap()),
1
);
let color = out.uniforms.iter().find(|u| u.name == "color_in").unwrap();
for (i, want) in [0.1f32, 0.2, 0.3, 0.4].iter().enumerate() {
let at = color.offset + i * 4;
assert_eq!(f32::from_le_bytes(buf[at..at + 4].try_into().unwrap()), *want);
}
}
// ---- GPU runner tests (skipped without an adapter) -------------------
fn gpu() -> Option<std::sync::Arc<crate::backend::GpuContext>> {
crate::backend::GpuContext::create(crate::backend::BackendKind::Auto)
}
fn f32_frame(w: i32, h: i32, fill: impl Fn(usize) -> [f32; 4]) -> crate::texture::Frame {
use crate::texture::Frame;
let mut frame = Frame::new();
let mut pod = crate::frame::VideoParamsPod::default();
pod.width = w;
pod.height = h;
pod.format = oak_core::PixelFormat::F32 as i32;
frame.set_video_params(pod);
frame.allocate();
for px in 0..(w * h) as usize {
let rgba = fill(px);
for (c, v) in rgba.iter().enumerate() {
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
.copy_from_slice(&v.to_le_bytes());
}
}
frame
}
fn pixel(out: &crate::texture::Frame, x: usize) -> [f32; 4] {
let mut rgba = [0.0f32; 4];
for (c, v) in rgba.iter_mut().enumerate() {
*v = f32::from_le_bytes(out.data[(x * 4 + c) * 4..(x * 4 + c) * 4 + 4].try_into().unwrap());
}
rgba
}
/// The real opacity shader through the full runner: pixels are
/// multiplied by the factor (and the UV convention is identity —
/// a flip would move the non-uniform pixels around).
#[test]
fn gpu_opacity_effect_scales_pixels() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.opacity")
.unwrap();
let glsl = behavior.shader_code("").unwrap();
let effect = compile_effect(&ctx, "test/opacity", &glsl, false).unwrap();
let frame = f32_frame(16, 4, |px| {
[0.2 + 0.01 * px as f32, 0.4, 0.6, 1.0]
});
let src = ctx.create_texture(16, 4).unwrap();
ctx.upload(src, &frame).unwrap();
let dst = ctx.create_texture(16, 4).unwrap();
let mut row = oak_node::value::NodeValueRow::new();
row.insert("opacity_in".into(), oak_node::value::NodeValue::Float(0.5));
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 4), 1).unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..16usize {
let want = (0.2 + 0.01 * px as f32) * 0.5;
let got = pixel(&out, px)[0];
assert!((got - want).abs() < 1e-4, "px {px}: got {got}, want {want}");
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// The real blur shader: radius 0 is an exact passthrough (the
/// shader's MODE_NONE branch), and a 2px horizontal box blur on a
/// step edge lands exactly half-and-half at the boundary pixels.
#[test]
fn gpu_blur_effect_passthrough_and_step() {
let Some(ctx) = gpu() else {
eprintln!("no adapter; skipping");
return;
};
let (_core, behavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.blur")
.unwrap();
let glsl = behavior.shader_code("").unwrap();
let effect = compile_effect(&ctx, "test/blur", &glsl, false).unwrap();
let src = ctx.create_texture(16, 1).unwrap();
let dst = ctx.create_texture(16, 1).unwrap();
let step = f32_frame(16, 1, |px| {
if px < 8 {
[0.0, 0.0, 0.0, 1.0]
} else {
[1.0, 1.0, 1.0, 1.0]
}
});
ctx.upload(src, &step).unwrap();
// radius 0: passthrough.
let mut row = oak_node::value::NodeValueRow::new();
row.insert("method_in".into(), oak_node::value::NodeValue::Combo(0));
row.insert("radius_in".into(), oak_node::value::NodeValue::Float(0.0));
row.insert("horiz_in".into(), oak_node::value::NodeValue::Boolean(true));
row.insert("vert_in".into(), oak_node::value::NodeValue::Boolean(false));
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1).unwrap();
let out = ctx.download(dst).unwrap();
assert_eq!(out.data, step.data, "radius 0 is a passthrough");
// radius 2 horizontal box: out(x) = 0.5 * (in[x-1] + in[x+1]).
row.insert("radius_in".into(), oak_node::value::NodeValue::Float(2.0));
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1).unwrap();
let out = ctx.download(dst).unwrap();
for x in 0..16usize {
let got = pixel(&out, x)[0];
let want = match x {
0 => 0.0, // first tap out of bounds (repeat_edge off)
7 | 8 => 0.5,
15 => 0.5, // second tap out of bounds
_ if x < 7 => 0.0,
_ => 1.0,
};
assert!(
(got - want).abs() < 1e-4,
"px {x}: got {got}, want {want}"
);
}
ctx.destroy_texture(src);
ctx.destroy_texture(dst);
}
/// Every registered node type that ships a shader must translate (the
/// all-shaders sweep). OCIO-stubbed shaders (`%1` markers needing the
/// OCIO-generated function text) retry with the real OCIO stub first;
/// only nodes whose stub is unavailable *and* unknown fail outright.
#[test]
fn all_registered_shaders_translate() {
let mut ok = Vec::new();
let mut ocio_stubbed = Vec::new();
let mut failed = Vec::new();
for meta in oak_node::factory::Factory::global().entries() {
let (_core, behavior) = (meta.create)();
let Some(glsl) = behavior.shader_code("") else {
continue;
};
match translate(&glsl) {
Ok(_) => ok.push(meta.type_id),
Err(e) => {
let msg = format!("{e:?}");
// The unresolved OCIO stub is the one sanctioned
// failure mode (chromakey & co. call into
// OCIO-generated functions). Retry with the real OCIO
// stub: the wiring must make these translate. When no
// OCIO config is available (stub build), fall back to
// a pass-through function so the sweep still covers
// the node's own shader body.
let retried = crate::eval::ocio_stub_for(meta.type_id)
.or_else(|| {
crate::eval::OCIO_SHADER_STUBS
.iter()
.find(|(id, ..)| *id == meta.type_id)
.map(|(_, fn_name, ..)| {
format!("vec4 {fn_name}(vec4 c) {{ return c; }}")
})
});
match retried {
Some(stub) => match translate(&behavior.shader_code(&stub).unwrap()) {
Ok(_) => ok.push(meta.type_id),
Err(e) => {
failed.push((meta.type_id, format!("with OCIO stub: {e:?}")))
}
},
None if msg.contains("SceneLinear") || msg.contains("UnknownFunction") => {
// Not in the stub table but still OCIO-shaped:
// report separately, not as a regression.
ocio_stubbed.push(meta.type_id);
}
None => failed.push((meta.type_id, msg)),
}
}
}
}
eprintln!("shader sweep: {} ok, {} ocio-stubbed", ok.len(), ocio_stubbed.len());
for id in &ocio_stubbed {
eprintln!(" ocio-stubbed: {id}");
}
for (id, msg) in &failed {
eprintln!(" FAILED: {id}: {}", &msg[..msg.len().min(200)]);
}
assert!(failed.is_empty(), "{} shaders failed to translate", failed.len());
assert!(!ok.is_empty(), "no shaders translated at all");
}
}
+10
View File
@@ -104,6 +104,11 @@ pub struct AudioTicketParams {
pub struct VideoTicketParams {
/// Node graph context (copied project identity).
pub viewer: u64,
/// The owning project's uuid (M16 S1): the worker renders the viewer's
/// graph frame from the loaded snapshot ONLY when this matches the
/// snapshot's project — empty means "no graph mode" (montage/footage/
/// generated frames never match a loaded graph).
pub project: String,
/// Frame time.
pub time: Rational,
/// Forced size override (None = sequence size).
@@ -559,6 +564,7 @@ impl TicketArena {
time: range.in_(),
params: Arc::new(VideoTicketParams {
viewer,
project: String::new(),
time: range.in_(),
force_size: None,
force_format: None,
@@ -728,6 +734,7 @@ mod tests {
let id = arena.submit_video(
VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(0, 1),
force_size: Some((4, 4)),
force_format: None,
@@ -778,6 +785,7 @@ mod tests {
let id = arena.submit_video(
VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(0, 1),
force_size: None,
force_format: None,
@@ -863,6 +871,7 @@ mod tests {
let a = arena.submit_video(
VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(0, 1),
force_size: None,
force_format: None,
@@ -878,6 +887,7 @@ mod tests {
let b = arena.submit_video(
VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(1, 1),
force_size: None,
force_format: None,
+82 -27
View File
@@ -160,6 +160,11 @@ pub trait JobDispatch: Send + Sync {
/// flight; the completion fires `Error::State`). Default no-op: only
/// the process backend schedules.
fn cancel_preview_frame(&self, _sequence: u64, _frame: i64, _version: u64) {}
/// Set (or clear) the graph snapshot path sent to workers via
/// `load_graph` (M16 S1). Default no-op: only the process backend
/// ships snapshots.
fn set_graph_snapshot(&self, _path: Option<String>) {}
}
/// Thread-free job dispatcher (M15 S2). Executes jobs on the calling
@@ -267,7 +272,10 @@ impl JobDispatch for InlineDispatcher {
/// Graph snapshot files shared with worker processes (C++
/// write_graph_snapshot + path refcounting): a snapshot is written once
/// and reference-counted; the file is unlinked at zero.
/// per project (uuid) and undo revision, reference-counted, and NEVER
/// unlinked at zero refs (M16 S1: a worker may still hold the path for
/// a late `load_graph`). The whole store directory is cleared by
/// [`GraphSnapshotStore::cleanup`] at manager shutdown.
pub struct GraphSnapshotStore {
entries: Mutex<HashMap<String, SnapshotEntry>>,
dir: std::path::PathBuf,
@@ -294,21 +302,46 @@ impl GraphSnapshotStore {
&self.dir
}
/// Write (or reuse) the snapshot for a project copy; returns the path
/// token with the reference count incremented.
pub fn acquire(&mut self, project_copy: u64) -> Result<String> {
let path = self.dir.join(format!("{project_copy}.json"));
/// Write (or reuse) the snapshot for `revision` of `project`; returns
/// the path token with the reference count incremented. The project is
/// serialized to the store's XML snapshot format; the same (uuid,
/// revision) pair is never rewritten. The write is atomic: the XML is
/// staged to a temp file and renamed over the final path, so a worker
/// reading the file sees a complete snapshot — never a torn write.
pub fn acquire(
&self,
project: &std::sync::Mutex<oak_node::project::Project>,
revision: u64,
) -> Result<String> {
let (uuid, xml) = {
let g = lock(project);
let xml = oak_node::serializer::save(&g)
.map_err(|e| Error::Failed(format!("save graph snapshot: {e}")))?;
(g.uuid.clone(), xml)
};
// Per-project filename: two projects never share a snapshot path,
// so a stale load of another project's graph can never be mistaken
// for this project's (identity collisions are otherwise the norm —
// fresh projects reuse small identity numbers).
let path = self.dir.join(format!("graph-{uuid}-{revision}.xml"));
let path_str = path.to_string_lossy().into_owned();
let mut entries = lock(&self.entries);
if let Some(entry) = entries.get_mut(&path_str) {
entry.refs += 1;
return Ok(path_str);
}
// Minimal snapshot payload: the copied-project identity. The real
// graph serialization is owned by oaknode.
let payload = format!("{{\"project_copy\":{project_copy}}}\n");
std::fs::write(&path, payload)
.map_err(|e| Error::Failed(format!("write snapshot: {e}")))?;
// Atomic staging: temp file + rename. The rename is a single
// directory entry swap, so a concurrent worker load observes
// either the old file or the complete new one.
let tmp = self
.dir
.join(format!("graph-{uuid}-{revision}.{}.tmp", std::process::id()));
std::fs::write(&tmp, &xml)
.map_err(|e| Error::Failed(format!("write snapshot temp: {e}")))?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Failed(format!("rename snapshot: {e}")));
}
entries.insert(
path_str.clone(),
SnapshotEntry {
@@ -319,24 +352,31 @@ impl GraphSnapshotStore {
Ok(path_str)
}
/// Drop one reference; unlinks the file at zero.
pub fn release(&mut self, path: &str) {
/// Drop one reference. The FILE IS NOT UNLINKED: a worker may still
/// hold the path for a late `load_graph` (M16 S1), and per-project
/// filenames mean a stale snapshot is never confused with a live one.
/// Entries leave the table at zero refs; the files themselves are
/// removed wholesale by [`GraphSnapshotStore::cleanup`].
pub fn release(&self, path: &str) {
let mut entries = lock(&self.entries);
let remove = if let Some(entry) = entries.get_mut(path) {
if let Some(entry) = entries.get_mut(path) {
entry.refs = entry.refs.saturating_sub(1);
entry.refs == 0
} else {
false
};
if remove {
entries.remove(path);
let _ = std::fs::remove_file(path);
if entry.refs == 0 {
entries.remove(path);
}
}
}
/// Remove the store's whole directory — called from manager shutdown
/// only, when no worker can still reference a snapshot file.
pub fn cleanup(&self) {
let _ = std::fs::remove_dir_all(&self.dir);
lock(&self.entries).clear();
}
/// Mark a snapshot as already uploaded to all live children
/// (C++ set_graph_path_cached).
pub fn mark_cached(&mut self, path: &str, cached: bool) {
pub fn mark_cached(&self, path: &str, cached: bool) {
if let Some(entry) = lock(&self.entries).get_mut(path) {
entry.cached = cached;
}
@@ -384,6 +424,7 @@ mod tests {
time: Rational::new(tag as i64, 1),
params: Arc::new(VideoTicketParams {
viewer: 0,
project: String::new(),
time: Rational::new(0, 1),
force_size: None,
force_format: None,
@@ -454,6 +495,7 @@ mod tests {
time: Rational::new(0, 1),
params: Arc::new(VideoTicketParams {
viewer: 0,
project: String::new(),
time: Rational::new(0, 1),
force_size: None,
force_format: None,
@@ -491,6 +533,7 @@ mod tests {
let ok: Producer = Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
let params = Arc::new(VideoTicketParams {
viewer: 0,
project: String::new(),
time: Rational::new(0, 1),
force_size: None,
force_format: None,
@@ -534,10 +577,11 @@ mod tests {
}
#[test]
fn snapshot_store_refcount_and_unlink() {
let mut store = GraphSnapshotStore::new();
let p1 = store.acquire(42).unwrap();
let p2 = store.acquire(42).unwrap();
fn snapshot_store_refcount_and_cleanup() {
let store = GraphSnapshotStore::new();
let project = oak_node::project::Project::new();
let p1 = store.acquire(&project, 1).unwrap();
let p2 = store.acquire(&project, 1).unwrap();
assert_eq!(p1, p2, "second acquire reuses the file");
assert!(std::path::Path::new(&p1).exists());
store.mark_cached(&p1, true);
@@ -550,7 +594,18 @@ mod tests {
"refcount 1: still alive"
);
store.release(&p1);
assert!(!std::path::Path::new(&p1).exists(), "refcount 0: unlinked");
assert_eq!(store.refs(&p1), 0);
assert_eq!(store.refs(&p1), 0, "entry removed at zero refs");
// M16 S1: release no longer unlinks the file (a worker may still
// hold the path for a late load_graph); the store directory is
// cleared wholesale at shutdown instead.
assert!(
std::path::Path::new(&p1).exists(),
"refcount 0: file retained for late loads"
);
store.cleanup();
assert!(
!std::path::Path::new(&p1).exists(),
"cleanup removes the snapshot directory"
);
}
}
+483
View File
@@ -0,0 +1,483 @@
// 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 <http://www.gnu.org/licenses/>.
//! M12 phase 2: the graph-driven sequence renderer.
//!
//! Builds a real node graph (sequence -> video track list -> video tracks
//! -> clip blocks -> footage), evaluates the clip overlapping the request
//! time through the traverser and composites the decoded frames — the same
//! path the engine's viewer uses. Track 0 is the topmost stack element.
use std::sync::{Arc, Mutex};
use oak_core::{PixelFormat, Rational, TimeRange};
use oak_node::block::ClipBlockBehavior;
use oak_node::footage::FootageBehavior;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::project::Project;
use oak_node::sequence::SequenceBehavior;
use oak_node::track::{TrackBehavior, TrackListBehavior};
use oak_render::texture::Texture;
mod common;
/// Unique temp path per test (the process id disambiguates parallel test
/// binaries; the tag separates tests inside one binary).
fn clip_path(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("oakrender_graph_{tag}_{}.mp4", std::process::id()))
}
/// One sequence + one video track list with one track per clip
/// `(filename, [in, out))`. Track 0 (the first entry) composites on top.
fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc<Mutex<Project>>, NodeId) {
let project = Project::new();
let seq;
{
let mut p = project.lock().unwrap();
let (score, sbehavior) = SequenceBehavior::create();
seq = p.graph.add_node(score, sbehavior);
let (tcore, tbehavior) = TrackListBehavior::create();
let tl = p.graph.add_node(tcore, tbehavior);
for &(path, in_, out) in clips {
let (tcore, tbehavior) = TrackBehavior::create();
let track = p.graph.add_node(tcore, tbehavior);
let mut footage = FootageBehavior::new(path);
footage.probe().expect("probe the generated clip");
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
let (ccore, cbehavior) = oak_node::block::clip_create();
let clip = p.graph.add_node(ccore, cbehavior);
p.graph
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
let clip_behavior = p
.graph
.get_mut(clip)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.expect("clip block");
clip_behavior.core.range = TimeRange::new(in_, out);
p.graph
.get_mut(track)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.expect("video track")
.append_block(clip);
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.expect("video track list")
.tracks
.push(track);
}
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.expect("sequence")
.track_lists
.push(tl);
}
(project, seq)
}
/// The raw CPU frame bytes of a rendered texture.
fn frame_data(texture: &Texture) -> &[u8] {
let Texture::Cpu(frame) = texture else {
panic!("graph render produced a non-CPU texture");
};
&frame.data
}
/// Two clips on two tracks, non-overlapping in time: at each request time
/// exactly one clip covers, and its output must match the single-track
/// render byte for byte (same decode + same composite path).
#[test]
fn graph_sequence_renders_two_tracks() {
let path_a = clip_path("two_tracks_a");
let path_b = clip_path("two_tracks_b");
oak_codec::testmedia::write_test_clip(&path_a, 64, 64, 10, 10).expect("clip A generation");
oak_codec::testmedia::write_test_clip(&path_b, 32, 32, 10, 10).expect("clip B generation");
let (project, seq) = build_project(&[
(&path_a.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
(&path_b.to_string_lossy(), Rational::new(1, 1), Rational::new(2, 1)),
]);
let t05 = oak_render::eval::render_graph_frame(
&project,
seq,
Rational::new(1, 2),
(64, 64),
PixelFormat::F32,
)
.expect("render t=0.5");
let t15 = oak_render::eval::render_graph_frame(
&project,
seq,
Rational::new(3, 2),
(64, 64),
PixelFormat::F32,
)
.expect("render t=1.5");
assert_eq!(t05.size(), (64, 64));
assert_eq!(t15.size(), (64, 64));
// Solo renders of each clip for byte comparison.
let (solo_a, seq_a) = build_project(&[(&path_a.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1))]);
let solo_a_tex = oak_render::eval::render_graph_frame(
&solo_a,
seq_a,
Rational::new(1, 2),
(64, 64),
PixelFormat::F32,
)
.expect("solo A render");
let (solo_b, seq_b) = build_project(&[(&path_b.to_string_lossy(), Rational::new(1, 1), Rational::new(2, 1))]);
let solo_b_tex = oak_render::eval::render_graph_frame(
&solo_b,
seq_b,
Rational::new(3, 2),
(64, 64),
PixelFormat::F32,
)
.expect("solo B render");
// Each time picks exactly the clip covering it, unchanged by the other
// track (B is 32x32 and must scale up to the 64x64 target).
assert_eq!(frame_data(&t05), frame_data(&solo_a_tex), "t=0.5 renders clip A");
assert_eq!(frame_data(&t15), frame_data(&solo_b_tex), "t=1.5 renders clip B");
assert_ne!(frame_data(&t05), frame_data(&t15), "the two clips differ");
// Both frames carry real content.
assert!(frame_data(&t05).iter().any(|&b| b != 0), "t=0.5 is not black");
assert!(frame_data(&t15).iter().any(|&b| b != 0), "t=1.5 is not black");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
/// The driver rejects bad arguments explainably: non-F32 format, a
/// non-positive size, and a missing viewer.
#[test]
fn graph_render_rejects_bad_inputs() {
let (project, seq) = build_project(&[]);
let err = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (64, 64), PixelFormat::U8)
.err()
.expect("non-F32 format rejected");
assert_eq!(err.code(), oak_render::error::Error::Invalid.code());
let err = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (0, 64), PixelFormat::F32)
.err()
.expect("non-positive size rejected");
assert_eq!(err.code(), oak_render::error::Error::Invalid.code());
let err = oak_render::eval::render_graph_frame(&project, NodeId::INVALID, Rational::new(0, 1), (64, 64), PixelFormat::F32)
.err()
.expect("missing viewer rejected");
assert_eq!(err.code(), oak_render::error::Error::NotFound.code());
}
/// One sequence + one track with a single clip, with an effect node
/// inserted between the footage and the clip block: `insert_effect`
/// receives the project lock plus the footage and clip node ids, rewires
/// the graph, and returns the effect node id. The clip keeps the
/// `(in, out)` range from `clip`.
fn build_effect_project(
clip: (&str, Rational, Rational),
insert_effect: impl FnOnce(&mut Project, NodeId, NodeId) -> NodeId,
) -> (Arc<Mutex<Project>>, NodeId) {
let project = Project::new();
let seq;
{
let mut p = project.lock().unwrap();
let (score, sbehavior) = SequenceBehavior::create();
seq = p.graph.add_node(score, sbehavior);
let (tcore, tbehavior) = TrackListBehavior::create();
let tl = p.graph.add_node(tcore, tbehavior);
let (tcore, tbehavior) = TrackBehavior::create();
let track = p.graph.add_node(tcore, tbehavior);
let mut footage = FootageBehavior::new(clip.0);
footage.probe().expect("probe the generated clip");
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
let (ccore, cbehavior) = oak_node::block::clip_create();
let clip_node = p.graph.add_node(ccore, cbehavior);
p.graph
.connect(footage, clip_node, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
let clip_behavior = p
.graph
.get_mut(clip_node)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.expect("clip block");
clip_behavior.core.range = TimeRange::new(clip.1, clip.2);
let _effect = insert_effect(&mut p, footage, clip_node);
p.graph
.get_mut(track)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.expect("video track")
.append_block(clip_node);
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.expect("video track list")
.tracks
.push(track);
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.expect("sequence")
.track_lists
.push(tl);
}
(project, seq)
}
/// The F32 RGBA channel of a 64x64 frame at `(x, y)`.
fn channel(data: &[u8], x: usize, y: usize, c: usize) -> f32 {
let off = (y * 64 + x) * 16 + c * 4;
f32::from_le_bytes(data[off..off + 4].try_into().unwrap())
}
/// M12 phase 3a: an opacity shader job (scalar 0.5) pushed by the effect
/// node is resolved on the shared GPU context and composited — each color
/// channel ends up as the plain render halved twice (the shader scales the
/// straight-alpha vec4 by 0.5, then the alpha-over composite applies the
/// halved alpha again), i.e. a 0.25 channel ratio. Skipped (with a note)
/// when no GPU adapter exists.
#[test]
fn shader_job_opacity_halves_pixels() {
if oak_render::backend::GpuContext::shared().is_none() {
eprintln!("skipping shader_job_opacity_halves_pixels: no GPU adapter");
return;
}
let path = clip_path("opacity_job");
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("clip generation");
let (plain_project, plain_seq) = build_project(&[(
&path.to_string_lossy(),
Rational::new(0, 1),
Rational::new(1, 1),
)]);
let plain_tex = oak_render::eval::render_graph_frame(
&plain_project,
plain_seq,
Rational::new(0, 1),
(64, 64),
PixelFormat::F32,
)
.expect("plain render");
let plain = frame_data(&plain_tex).to_vec();
let (effect_project, effect_seq) = build_effect_project(
(&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
|p, footage, clip| {
let (ecore, ebehavior) = oak_node::nodes::opacity::create();
let effect = p.graph.add_node(ecore, ebehavior);
p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1);
p.graph
.connect(footage, effect, oak_node::nodes::opacity::TEXTURE_INPUT, -1)
.expect("connect footage to effect");
p.graph
.connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect effect to clip");
p.graph
.get_mut(effect)
.unwrap()
.core
.set_standard_value(
oak_node::nodes::opacity::VALUE_INPUT,
-1,
oak_node::value::NodeValue::Float(0.5),
);
effect
},
);
let effect_tex = oak_render::eval::render_graph_frame(
&effect_project,
effect_seq,
Rational::new(0, 1),
(64, 64),
PixelFormat::F32,
)
.expect("opacity render");
let blurred = frame_data(&effect_tex).to_vec();
// Sample away from the x=32 half boundary (MPEG-2 chroma bleed and
// luma ringing stay within a few pixels of it).
let mut ratios: Vec<f32> = Vec::new();
for y in 4..60 {
for x in (4..24).chain(40..60) {
for c in 0..3 {
let a = channel(&plain, x, y, c);
if a > 0.02 {
ratios.push(channel(&blurred, x, y, c) / a);
}
}
}
}
assert!(ratios.len() >= 512, "too few comparable samples: {}", ratios.len());
let mean = ratios.iter().sum::<f32>() / ratios.len() as f32;
assert!(
(mean - 0.25).abs() < 0.02,
"opacity channel ratio {mean} is not 0.25"
);
let _ = std::fs::remove_file(&path);
}
/// M12 phase 3a: a box-blur shader job (radius 2, both axes) is resolved
/// on the shared GPU context — the output differs from the plain render
/// byte-wise, the hard left/right half boundary softens (the per-pixel
/// step at the boundary shrinks), and left-half content bleeds into the
/// boundary pixel on the right half. Skipped when no GPU adapter exists.
#[test]
fn shader_job_blur_smooths_edge() {
if oak_render::backend::GpuContext::shared().is_none() {
eprintln!("skipping shader_job_blur_smooths_edge: no GPU adapter");
return;
}
let path = clip_path("blur_job");
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("clip generation");
let (plain_project, plain_seq) = build_project(&[(
&path.to_string_lossy(),
Rational::new(0, 1),
Rational::new(1, 1),
)]);
let plain_tex = oak_render::eval::render_graph_frame(
&plain_project,
plain_seq,
Rational::new(0, 1),
(64, 64),
PixelFormat::F32,
)
.expect("plain render");
let plain = frame_data(&plain_tex).to_vec();
let (effect_project, effect_seq) = build_effect_project(
(&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
|p, footage, clip| {
let (ecore, ebehavior) = oak_node::nodes::blur::create();
let effect = p.graph.add_node(ecore, ebehavior);
p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1);
p.graph
.connect(footage, effect, oak_node::nodes::blur::TEXTURE_INPUT, -1)
.expect("connect footage to effect");
p.graph
.connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect effect to clip");
let core = &mut p.graph.get_mut(effect).unwrap().core;
core.set_standard_value(
oak_node::nodes::blur::METHOD_INPUT,
-1,
oak_node::value::NodeValue::Combo(0),
);
core.set_standard_value(
oak_node::nodes::blur::RADIUS_INPUT,
-1,
oak_node::value::NodeValue::Float(2.0),
);
core.set_standard_value(
oak_node::nodes::blur::HORIZ_INPUT,
-1,
oak_node::value::NodeValue::Boolean(true),
);
core.set_standard_value(
oak_node::nodes::blur::VERT_INPUT,
-1,
oak_node::value::NodeValue::Boolean(true),
);
effect
},
);
let effect_tex = oak_render::eval::render_graph_frame(
&effect_project,
effect_seq,
Rational::new(0, 1),
(64, 64),
PixelFormat::F32,
)
.expect("blur render");
let blurred = frame_data(&effect_tex).to_vec();
// The blur must actually change pixels (a silently dropped job would
// fall back to the pass-through input and byte-match the plain frame).
assert_ne!(plain, blurred, "the blur job must actually change pixels");
// Row y=32 (vertically uniform): the boundary step x=31 -> x=32 must
// shrink, and the right-side boundary pixel picks up left-half content.
let r = |data: &[u8], x: usize| channel(data, x, 32, 0);
let plain_step = (r(&plain, 32) - r(&plain, 31)).abs();
let blurred_step = (r(&blurred, 32) - r(&blurred, 31)).abs();
assert!(
blurred_step < plain_step,
"boundary step {blurred_step} not below the plain {plain_step}"
);
assert!(
r(&blurred, 32) > r(&plain, 32),
"blurred boundary pixel {} not above the plain {}",
r(&blurred, 32),
r(&plain, 32)
);
let _ = std::fs::remove_file(&path);
}
+17 -8
View File
@@ -21,7 +21,6 @@
mod common;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::time::Duration;
@@ -58,6 +57,7 @@ fn ok_producer() -> oak_render::ticket::Producer {
fn params(time: Rational) -> VideoTicketParams {
VideoTicketParams {
viewer: 1,
project: String::new(),
time,
force_size: Some((8, 4)),
force_format: None,
@@ -212,13 +212,16 @@ fn ticket_id_monotonic() {
d.shutdown();
}
/// GraphSnapshotStore: acquire twice shares one file; release to zero
/// unlinks it (no orphaned snapshots after shutdown).
/// GraphSnapshotStore: acquire twice shares one file; release never
/// unlinks at zero refs (M16 S1 — a worker may still hold the path for
/// a late load_graph); the whole store is removed by cleanup() at
/// manager shutdown.
#[test]
fn snapshot_store_refcount() {
let mut store = GraphSnapshotStore::new();
let p1 = store.acquire(42).unwrap();
let p2 = store.acquire(42).unwrap();
let store = GraphSnapshotStore::new();
let project = oak_node::project::Project::new();
let p1 = store.acquire(&project, 1).unwrap();
let p2 = store.acquire(&project, 1).unwrap();
assert_eq!(p1, p2);
assert!(std::path::Path::new(&p1).exists());
assert_eq!(store.refs(&p1), 2);
@@ -227,11 +230,17 @@ fn snapshot_store_refcount() {
store.mark_cached(&p1, true);
assert!(store.is_cached(&p1));
store.release(&p1);
// Zero refs: file intentionally kept (late worker loads), entry dropped.
assert_eq!(store.refs(&p1), 0);
assert!(
std::path::Path::new(&p1).exists(),
"file kept after release at refcount 0"
);
store.cleanup();
assert!(
!std::path::Path::new(&p1).exists(),
"unlinked at refcount 0"
"store directory removed by cleanup"
);
assert_eq!(store.refs(&p1), 0);
}
/// TimeRange sanity (used above).
+11
View File
@@ -492,6 +492,15 @@ impl RenderTask {
/// `start_video_ticket` param marshalling).
fn build_video_ticket(&self, time: Rational) -> Result<VideoTicketParams> {
let (project, viewer_id) = &self.viewer;
// The owning project's uuid (M16 S1: graph-mode tickets carry it so
// workers render only from their own project's snapshot). Read
// without holding the lock across the montage build below
// (`std::sync::Mutex` is not reentrant).
let project_uuid = project
.lock()
.unwrap_or_else(|e| e.into_inner())
.uuid
.clone();
// Inspect the viewer node WITHOUT holding the project lock across
// the montage build below: `video_montage` locks the same project,
// and `std::sync::Mutex` is not reentrant — holding it here
@@ -522,6 +531,7 @@ impl RenderTask {
match footage {
Some(filename) => Ok(VideoTicketParams {
viewer: viewer_id.identity(),
project: project_uuid.clone(),
time,
force_size: self.force_size(),
force_format: self.force_format(),
@@ -537,6 +547,7 @@ impl RenderTask {
let montage = Self::video_montage(project, *viewer_id, time);
Ok(VideoTicketParams {
viewer: viewer_id.identity(),
project: project_uuid.clone(),
time,
force_size: self.force_size(),
force_format: self.force_format(),
+7
View File
@@ -51,3 +51,10 @@ oak-plugin = { path = "../oak-plugin" }
# re-exporting oakrender::ipc). No liboakengine dylib, no C ABI, no
# build.rs link step.
oak-render = { path = "../oak-render" }
[dev-dependencies]
# oakcodec's test-media generator (`testmedia::write_test_clip`) for the
# graph-mode procpool integration tests — self-contained generated clips
# instead of repo fixtures. oakcodec already links in transitively via
# oaknode; this re-entry makes it nameable from the test binary.
oak-codec = { path = "../oak-codec" }
+65 -11
View File
@@ -47,14 +47,16 @@ CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release
Same flow as the C++ main, in the same order:
1. **parse `--backend <name>`** (default `opengl`; `none` skips
renderer creation and the process exits 1, like the C++ main;
`cpu` is the M15 headless render mode — no renderer, but the session
stays fully operational and renders through the CPU evaluation path).
1. **parse `--backend <name>`** (the pool spawns workers with `auto`;
`none` skips renderer creation and the process exits 1, like the C++
main; `cpu` is the M15 headless render mode — no renderer, but the
session stays fully operational and renders through the CPU
evaluation path).
2. **initialize the render backend** (inside `src/worker.rs`): the
oakrender `DisplayRenderer` direct Rust API, falling back to the direct
OpenGL renderer exactly like the C++ `create_renderer()` fallback
chain. Then the runtime services load (color-manager default config,
oakrender `DisplayRenderer` direct Rust API. Under `auto` a failed
initialization degrades to a cpu-mode session (logged) instead of
exiting — GPU-less machines keep rendering through the CPU fallback.
Then the runtime services load (color-manager default config,
the oakplugin render executor).
3. **write the startup handshake** (protocol version 1, empty shared-memory
geometry — same as the C++ worker's startup handshake; the parent
@@ -70,6 +72,56 @@ Same flow as the C++ main, in the same order:
are dispatched by the session. Responses are one compact JSON line
per message.
## Where the rendering happens (no render thread)
A frequent reading trap: **oak-worker spawns no render thread.** The
binary is a deliberately single-threaded NDJSON loop — `main.rs`
`worker::worker_main` (`src/worker.rs`) reads one control line from
stdin, handles it, writes the response, repeat. Rendering happens
**synchronously on that loop thread** the moment a batch arrives:
```
stdin "render_batch"
→ WorkerSession::handle_render_batch_stream (src/worker.rs)
→ render_ticket_to_slot (acquire shm slot)
→ render_spec_pixels (the actual render)
→ stdout "frame_ready" / "frame_failed" (one line per ticket)
```
`render_spec_pixels` picks between two render paths per ticket:
- **Graph path** (the default when a project snapshot is loaded):
tickets carry `viewer_node` (the sequence's node identity), the worker
evaluates the deserialized project through `oak_node::traverser`
(time-aware, keyframe-resolving), and every node `value()` pushes a
job payload that `oakrender::eval`'s resolve hooks execute — footage
decode, **shader effects as wgpu fullscreen passes**
(`oakrender::shaderfx`: the nodes' embedded GLSL is translated to WGSL
through naga, uniforms packed std140), OFX plugins through the
oakplugin render driver. Textures stay GPU-resident across the effect
chain; the frame is read back once, at the end, into the shm slot.
- **Montage path** (fallback: no snapshot loaded / legacy tickets): the
flat wire-spec CPU pipeline (`render_montage_frame_into`).
The parallelism is **across processes, not threads**: the main process
(app side) spawns the pool of `oak-worker` children in
`oakrender::procpool::ProcessDispatcher` (`spawn_worker` in
`crates/oak-render/src/procpool.rs`), one shared-memory segment and one
reader thread per child, and shards ticket batches across them. So
"no worker ⇒ no rendering" does not imply a hidden render thread — the
dispatcher has no in-process rendering path at all (M15 S2 deleted it;
only the test-only inline backend renders in-process). Audio follows
the same shape via `render_audio_batch``render_audio_ticket_to_slot`
`oakrender::eval::render_audio_samples_into`.
Consequences worth knowing before editing this file:
- A frame render blocks the control loop: `cancel` is observed only
between batches (batch granularity), which is why the main process
also stops the render loop on its side.
- A crash mid-render kills the whole loop — that is the point (OFX
crash isolation); the dispatcher reaps, re-queues and respawns.
## Implemented vs stubbed (nothing is faked)
**Real:** argument parsing, render backend initialization (real wgpu
@@ -86,10 +138,12 @@ slots** (`render_frame` v1 + `render_batch` v2: generated frames,
footage decode, montage compositing, end-of-pipe F32→BGRA8 conversion),
unknown-type/malformed-message errors, shutdown/EOF termination.
**Deferred to M15 S2/S3 (documented in `src/worker.rs`):** the loaded
project's node-graph render path (plugin-node evaluation per graph
snapshot update) — today tickets render from their wire spec
(montage/footage/generate), which covers the preview pipeline.
**Deferred / known limits:** the graph path is live (tickets with
`viewer_node` evaluate the loaded project through the node graph). Still
open: transition blocks render as plain cuts, polygon/mask's CPU
rasterization stage (the matte generators pass through with a TODO), and
audio still renders from the montage wire spec (graph audio evaluation
is later work).
**Deviation from the C++:** the startup handshake omits `gl_major`/
`gl_minor` — the oakrender module exposes no GL context version (the C++
+142 -27
View File
@@ -33,7 +33,12 @@
//! - **The main loop.** [`worker_main`] creates the session, loads the
//! runtime config (including the oakplugin render executor), writes
//! the startup handshake, and serves the stdin/stdout NDJSON loop
//! until a `shutdown` message or EOF.
//! until a `shutdown` message or EOF. The worker is single-threaded
//! by design — there is no render thread: a `render_batch` renders
//! its tickets synchronously on this loop thread, and parallelism
//! comes from the main process's worker pool
//! (`oak_render::procpool`), not from threads here (see "Where the
//! rendering happens" in this crate's README).
//!
//! Real rendering landed in M15 S1: `load_graph` deserializes the graph
//! snapshot file (oaknode project XML, with the minimal
@@ -66,16 +71,27 @@ use crate::{log_error, PROTOCOL_VERSION};
/// A loaded graph snapshot (M15 S1): the snapshot file path plus what it
/// deserialized into — a full oaknode project, or only the copied-project
/// identity (the minimal `{"project_copy":N}` payload the
/// [`oak_render::worker::GraphSnapshotStore`] writes before the app wires
/// full graph uploads in S2).
/// [`oak_render::worker::GraphSnapshotStore`] wrote before M16 S1 shipped
/// full graph snapshots).
struct LoadedGraph {
/// Snapshot file path (S2: graph_update diffing is path-based).
#[allow(dead_code)]
path: String,
/// The deserialized project (S2: node-graph render path; today only
/// montage/footage/generate tickets use the loaded context).
#[allow(dead_code)]
/// The deserialized project (M16 S1: node-graph tickets render the
/// viewer's frame from here; montage/footage/generate tickets use the
/// loaded context too).
project: Option<Arc<Mutex<oak_node::project::Project>>>,
/// The loaded snapshot's owning project uuid; `None` for the
/// identity-only legacy payload. Graph-mode tickets (which carry their
/// owning project's uuid) only render from this graph when it matches —
/// a stale snapshot from a different project must not answer foreign
/// viewer identities (M16 S1 cross-process snapshot race).
project_uuid: Option<String>,
/// Source-identity -> loaded-id map (see
/// [`oak_node::serializer::load_with_id_map`]): tickets carry viewer
/// identities from the *saved* project, while the loaded graph's arena
/// slots differ (deleted slots shift every later node).
id_map: std::collections::HashMap<u64, oak_node::id::NodeId>,
project_copy: u64,
}
@@ -280,12 +296,22 @@ impl WorkerSession {
/// creation, anything else initializes the render backend through the
/// oakrender crate's direct Rust API (dynamic -> OpenGL fallback).
/// The M15 `"cpu"` backend is the headless render mode: no renderer,
/// CPU evaluation + decode via [`oak_render::eval`].
/// CPU evaluation + decode via [`oak_render::eval`]. M16 S1: a failed
/// renderer init for any other backend (e.g. "auto" on a GPU-less
/// host) is tolerated — the session continues headless and tickets
/// evaluate through the CPU path.
pub fn create(backend: &str) -> Result<WorkerSession, String> {
let renderer = if is_no_backend(backend) || is_cpu_backend(backend) {
None
} else {
Some(Renderer::create(backend)?)
let renderer = match backend {
b if is_no_backend(b) || is_cpu_backend(b) => None,
b => match Renderer::create(b) {
Ok(r) => Some(r),
Err(e) => {
log_error(&format!(
"session: {b} renderer init failed ({e}); continuing headless (CPU eval path)"
));
None
}
},
};
Ok(WorkerSession {
renderer,
@@ -542,11 +568,18 @@ impl WorkerSession {
))
}
};
match oak_node::serializer::load(&content) {
Ok(project) => {
match oak_node::serializer::load_with_id_map(&content) {
Ok((project, id_map)) => {
let project_uuid = project
.lock()
.unwrap_or_else(|e| e.into_inner())
.uuid
.clone();
self.graph = Some(LoadedGraph {
path: load.path.clone(),
project: Some(project),
project_uuid: Some(project_uuid),
id_map,
project_copy: 0,
});
log_error("LoadGraph: oaknode project deserialized");
@@ -560,6 +593,8 @@ impl WorkerSession {
self.graph = Some(LoadedGraph {
path: load.path.clone(),
project: None,
project_uuid: None,
id_map: std::collections::HashMap::new(),
project_copy: pc,
});
log_error(&format!(
@@ -1022,7 +1057,7 @@ impl WorkerSession {
if !bgra8 {
// F32 RGBA: render straight into the slot (no staging copy).
render_f32_into(spec, &params, time, (w, h), &mut dst[..dst_need])?;
render_f32_into(spec, &params, &self.graph, time, (w, h), &mut dst[..dst_need])?;
} else {
// BGRA8: render the F32 pipeline frame into the session
// scratch, then convert into the slot (the end-of-pipe format
@@ -1031,7 +1066,14 @@ impl WorkerSession {
if self.f32_scratch.len() < f32_need {
self.f32_scratch.resize(f32_need, 0);
}
render_f32_into(spec, &params, time, (w, h), &mut self.f32_scratch[..f32_need])?;
render_f32_into(
spec,
&params,
&self.graph,
time,
(w, h),
&mut self.f32_scratch[..f32_need],
)?;
convert_f32_rgba_to_bgra8(&self.f32_scratch[..f32_need], &mut dst[..dst_need]);
}
@@ -1074,11 +1116,15 @@ impl WorkerSession {
})
.collect();
VideoTicketParams {
viewer: self
.graph
.as_ref()
.map(|g| g.project_copy)
.unwrap_or(0),
viewer: if spec.viewer_node != 0 {
spec.viewer_node
} else {
self.graph
.as_ref()
.map(|g| g.project_copy)
.unwrap_or(0)
},
project: spec.project_key.clone(),
time,
force_size: Some((spec.width, spec.height)),
force_format: Some(PixelFormat::F32),
@@ -1093,17 +1139,73 @@ impl WorkerSession {
}
/// Render the F32 RGBA pipeline frame for `spec` into `dst`
/// (`(w*h*16)` bytes): generated transparent black, footage decode,
/// or montage composite — through [`oak_render::eval`].
/// (`(w*h*16)` bytes): graph-mode viewer frame, generated transparent
/// black, footage decode, or montage composite — through
/// [`oak_render::eval`].
fn render_f32_into(
spec: &BatchTicketSpec,
params: &VideoTicketParams,
graph: &Option<LoadedGraph>,
time: Rational,
size: (i32, i32),
dst: &mut [u8],
) -> Result<(), String> {
let (w, h) = size;
let stride = w * 16;
// M16 S1 graph mode: render the ticket's viewer node from the loaded
// snapshot — but only when the snapshot belongs to the ticket's own
// project. The ticket carries its owning project's uuid; a stale graph
// from a different project (fresh projects reuse small identity
// numbers, so a foreign viewer identity can resolve successfully and
// silently render the wrong picture) must never answer it — that ticket
// falls through to the montage path instead, and is NOT logged (the
// mismatch is the normal suite-order case, not an error). A missing
// graph / viewer is logged once and also falls back.
if spec.viewer_node != 0 {
let project_matches = graph
.as_ref()
.and_then(|g| g.project_uuid.as_deref())
.map(|uuid| uuid == spec.project_key.as_str())
.unwrap_or(false);
if project_matches {
if let Some(project) = graph.as_ref().and_then(|g| g.project.as_ref()) {
// The ticket's viewer is an identity in the *saved* project;
// translate through the load map first, then fall back to the
// raw packed id (identity-only snapshots, foreign files).
let viewer_id = graph
.as_ref()
.and_then(|g| g.id_map.get(&spec.viewer_node).copied())
.or_else(|| oak_node::id::NodeId::from_identity(spec.viewer_node));
match viewer_id {
Some(viewer_id) => {
let rendered = eval::render_graph_frame(project, viewer_id, time, (w, h), PixelFormat::F32);
match &rendered {
Ok(oak_render::texture::Texture::Cpu(frame)) => {
let src_stride = frame.linesize_bytes() as usize;
let row_bytes = (w as usize) * 16;
if frame.data.len() < src_stride * (h as usize)
|| dst.len() < row_bytes * (h as usize)
{
return Err("graph frame geometry mismatch".to_string());
}
for y in 0..h as usize {
dst[y * row_bytes..(y + 1) * row_bytes].copy_from_slice(
&frame.data[y * src_stride..y * src_stride + row_bytes],
);
}
return Ok(());
}
Ok(_) => return Err("graph render produced a GPU texture".to_string()),
Err(e) => warn_graph_fallback(spec.viewer_node, &e.to_string()),
}
}
None => warn_graph_fallback(spec.viewer_node, "viewer node not in graph"),
}
} else {
warn_graph_fallback(spec.viewer_node, "no loaded graph");
}
}
}
if !params.montage.is_empty() {
return eval::render_montage_frame_into(time, params, (w, h), dst, stride)
.map_err(|e| format!("montage render: {e}"));
@@ -1136,6 +1238,17 @@ fn render_f32_into(
Ok(())
}
/// Log (once per process) why a graph-mode ticket fell back to the
/// montage path — a missing snapshot, an absent viewer node, or a graph
/// render error. AtomicBool keeps a GPU-less or graph-less host from
/// spamming the worker log on every frame.
fn warn_graph_fallback(viewer: u64, why: &str) {
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
log_error(&format!("graph-mode ticket viewer {viewer}: {why}; falling back to montage"));
}
}
/// Convert F32 RGBA (`src`, 16 bytes/px) to 8-bit BGRA (`dst`, 4
/// bytes/px) with clamping — the worker-side end-of-pipe convert for
/// BGRA8 preview slots.
@@ -1171,8 +1284,9 @@ pub fn worker_main(backend: &str) -> i32 {
// 1. Session creation initializes the render backend through the
// oakrender crate's direct Rust API
// (oakengine_worker_session_create()). The M15 "cpu" backend is
// headless: no renderer, CPU evaluation + decode only.
let cpu_mode = is_cpu_backend(backend);
// headless: no renderer, CPU evaluation + decode only. M16 S1: a
// failed GPU init for any other backend (e.g. "auto" on a GPU-less
// host) is tolerated — the session continues headless.
let mut session = match WorkerSession::create(backend) {
Ok(s) => s,
Err(msg) => {
@@ -1180,9 +1294,10 @@ pub fn worker_main(backend: &str) -> i32 {
return 1;
}
};
if !session.has_renderer() && !cpu_mode {
// Mirrors oakengine_worker_main(): without a renderer the worker
// cannot do anything, so it exits 1. ("--backend none" lands here.)
if !session.has_renderer() && is_no_backend(backend) {
// Mirrors oakengine_worker_main(): an explicit no-renderer request
// (""/"none") leaves nothing to evaluate, so the worker exits 1.
// Any other backend failure already logged headless continuation.
log_error("no renderer initialized");
return 1;
}
@@ -31,6 +31,13 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use oak_core::{PixelFormat, Rational, TimeRange};
use oak_node::block::ClipBlockBehavior;
use oak_node::footage::FootageBehavior;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::project::Project;
use oak_node::sequence::SequenceBehavior;
use oak_node::track::{TrackBehavior, TrackListBehavior};
use oak_render::ipc::SLOT_FORMAT_BGRA8;
use oak_render::procpool::{
main_heap_frame_copies, reset_main_heap_frame_copies, DispatcherConfig, ProcessDispatcher,
@@ -69,6 +76,7 @@ fn config(workers: usize, slots: u32) -> DispatcherConfig {
fn params(time: Rational, footage: Option<(String, i32)>) -> Arc<VideoTicketParams> {
Arc::new(VideoTicketParams {
viewer: 1,
project: String::new(),
time,
force_size: Some((64, 64)),
// No forced format: the dispatcher's default slot format (BGRA8)
@@ -444,6 +452,7 @@ fn submit_audio(
time: start,
params: Arc::new(VideoTicketParams {
viewer,
project: String::new(),
time: start,
force_size: None,
force_format: None,
@@ -661,6 +670,7 @@ fn oversized_audio_ticket_is_refused_by_process_backend() {
time: Rational::new(0, 1),
params: Arc::new(VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(0, 1),
force_size: None,
force_format: None,
@@ -722,6 +732,7 @@ fn f32_ticket_gets_f32_slot_and_bgra8_stays_bgra8() {
time: Rational::new(0, 1),
params: Arc::new(VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(0, 1),
force_size: Some((64, 64)),
force_format: Some(PixelFormat::F32),
@@ -774,3 +785,237 @@ fn f32_ticket_gets_f32_slot_and_bgra8_stays_bgra8() {
dispatcher.shutdown();
}
/// One sequence with a single video track holding one clip over `clip`
/// (range [0, 1)); returns the project and the sequence node.
fn build_graph_project(clip: &std::path::Path) -> (Arc<Mutex<Project>>, NodeId) {
let project = Project::new();
let seq;
{
let mut p = project.lock().unwrap();
// Footage is created first so the sequence lands on slot 1
// (identity != 0): identity 0 is the "no viewer" sentinel in
// ticket specs, and the graph branch short-circuits on it.
let mut footage = FootageBehavior::new(clip.to_str().unwrap());
footage.probe().expect("probe the test clip");
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
let (score, sbehavior) = SequenceBehavior::create();
seq = p.graph.add_node(score, sbehavior);
let (tcore, tbehavior) = TrackListBehavior::create();
let tl = p.graph.add_node(tcore, tbehavior);
let (tcore, tbehavior) = TrackBehavior::create();
let track = p.graph.add_node(tcore, tbehavior);
let (ccore, cbehavior) = oak_node::block::clip_create();
let clip_node = p.graph.add_node(ccore, cbehavior);
p.graph
.connect(footage, clip_node, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
let clip_behavior = p
.graph
.get_mut(clip_node)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.expect("clip block");
clip_behavior.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1));
p.graph
.get_mut(track)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.expect("video track")
.append_block(clip_node);
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.expect("video track list")
.tracks
.push(track);
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.expect("sequence")
.track_lists
.push(tl);
}
(project, seq)
}
/// Post a single graph-mode ticket rendering the sequence `viewer` at
/// t=0 through the worker pool. `project_uuid` must be the owning project's
/// uuid (M16 S1: workers render graph tickets only when the snapshot's
/// project matches the ticket's).
fn post_graph_job(
dispatcher: &ProcessDispatcher,
results: &Arc<Mutex<Vec<TicketResult>>>,
viewer: u64,
project_uuid: &str,
) {
let results = results.clone();
let job = Job {
node_identity: viewer,
time: Rational::new(0, 1),
params: Arc::new(VideoTicketParams {
viewer,
project: project_uuid.to_string(),
time: Rational::new(0, 1),
force_size: Some((64, 64)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: Vec::new(),
}),
audio: None,
produce: Arc::new(|_, _| {
Err(oak_render::error::Error::Failed(
"process backend does not use the in-process producer".into(),
))
}),
done: Box::new(move |result| {
results.lock().unwrap_or_else(|e| e.into_inner()).push(result);
}),
schedule: JobSchedule::seek(),
};
assert!(dispatcher.post(job), "post accepted while alive");
}
/// Assert a delivered ticket is a 64x64 BGRA8 slot carrying opaque
/// non-black content — the signature of a real graph render (the
/// transparent-black montage fallback would be all zero), then release
/// the slot.
fn assert_graph_frame_opaque(dispatcher: &ProcessDispatcher, payload: TicketResult) {
let payload = payload.expect("graph-mode frame rendered");
let TicketPayload::ShmFrame(frame) = payload else {
panic!("graph-mode tickets must deliver ShmFrame payloads");
};
assert_eq!(frame.meta.width, 64);
assert_eq!(frame.meta.height, 64);
assert_eq!(frame.meta.format, SLOT_FORMAT_BGRA8);
assert_eq!(frame.meta.data_size, 64 * 64 * 4);
let pixels = &frame.shm.slot_bytes(frame.slot)[..frame.meta.data_size as usize];
let alpha_ok = pixels.chunks_exact(4).filter(|px| px[3] == 255).count();
assert!(
alpha_ok as f64 >= 0.99 * (64 * 64) as f64,
"graph-rendered frame must be opaque ({alpha_ok}/4096)"
);
assert!(
pixels.iter().any(|&b| b != 0),
"graph-rendered frame is not black"
);
dispatcher.release_frame(&frame);
}
/// M16 S1 end-to-end graph mode: the dispatcher starts with a real
/// project snapshot (oaknode XML), the worker loads it right after the
/// handshake, and a ticket naming the sequence node as its viewer
/// renders the sequence's clip through the graph path into the slot.
#[test]
fn graph_mode_renders_sequence_viewer_from_snapshot() {
let _guard = lock_test();
let clip = std::env::temp_dir().join(format!(
"oak-procpool-graph-clip-{}.mp4",
std::process::id()
));
let snapshot = std::env::temp_dir().join(format!(
"oak-procpool-graph-snapshot-{}.xml",
std::process::id()
));
let _ = std::fs::remove_file(&clip);
let _ = std::fs::remove_file(&snapshot);
oak_codec::testmedia::write_test_clip(&clip, 64, 64, 10, 10).expect("test clip generation");
let (project, seq) = build_graph_project(&clip);
let xml = {
let p = project.lock().unwrap_or_else(|e| e.into_inner());
oak_node::serializer::save(&p).expect("project serializes")
};
std::fs::write(&snapshot, xml).expect("snapshot written");
let viewer = seq.identity();
assert_ne!(viewer, 0, "viewer must not be the no-viewer sentinel 0");
let mut cfg = config(1, 4);
cfg.graph_snapshot = Some(snapshot.display().to_string());
let dispatcher = ProcessDispatcher::new(cfg).expect("dispatcher config");
dispatcher.start().expect("worker starts");
let results = Arc::new(Mutex::new(Vec::new()));
let project_uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone();
post_graph_job(&dispatcher, &results, viewer, &project_uuid);
pump_until(&dispatcher, &results, 1);
let result = results.lock().unwrap_or_else(|e| e.into_inner()).pop().unwrap();
assert_graph_frame_opaque(&dispatcher, result);
dispatcher.shutdown();
let _ = std::fs::remove_file(&clip);
let _ = std::fs::remove_file(&snapshot);
}
/// M16 S1: a snapshot pushed after startup reroutes subsequent viewer
/// tickets — `set_graph_snapshot` sends `load_graph` down the same FIFO
/// as the batches, so the worker loads the graph before it claims the
/// ticket (no restart needed).
#[test]
fn set_graph_snapshot_after_start_reroutes_tickets() {
let _guard = lock_test();
let clip = std::env::temp_dir().join(format!(
"oak-procpool-reroute-clip-{}.mp4",
std::process::id()
));
let snapshot = std::env::temp_dir().join(format!(
"oak-procpool-reroute-snapshot-{}.xml",
std::process::id()
));
let _ = std::fs::remove_file(&clip);
let _ = std::fs::remove_file(&snapshot);
oak_codec::testmedia::write_test_clip(&clip, 64, 64, 10, 10).expect("test clip generation");
let (project, seq) = build_graph_project(&clip);
let xml = {
let p = project.lock().unwrap_or_else(|e| e.into_inner());
oak_node::serializer::save(&p).expect("project serializes")
};
std::fs::write(&snapshot, xml).expect("snapshot written");
let viewer = seq.identity();
assert_ne!(viewer, 0, "viewer must not be the no-viewer sentinel 0");
let dispatcher = ProcessDispatcher::new(config(1, 4)).expect("dispatcher config");
dispatcher.start().expect("worker starts");
dispatcher.set_graph_snapshot(Some(snapshot.display().to_string()));
let results = Arc::new(Mutex::new(Vec::new()));
let project_uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone();
post_graph_job(&dispatcher, &results, viewer, &project_uuid);
pump_until(&dispatcher, &results, 1);
let result = results.lock().unwrap_or_else(|e| e.into_inner()).pop().unwrap();
assert_graph_frame_opaque(&dispatcher, result);
dispatcher.shutdown();
let _ = std::fs::remove_file(&clip);
let _ = std::fs::remove_file(&snapshot);
}
-1
View File
@@ -145,7 +145,6 @@ if [ -z "${MSYSTEM:-}" ]; then
enable_if_pkg openh264 libopenh264
enable_if_pkg snappy libsnappy
fi
enable_if_pkg wavpack libwavpack
enable_if_pkg webp libwebp
enable_if_pkg xvid libxvid
enable_if_pkg kvazaar libkvazaar