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