render: translate node GLSL shaders to WGSL and run them as wgpu passes
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
# Oak Video Editor - Non-Linear Video Editor
|
||||
# Copyright (C) 2026 Oak Team
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# The project FFmpeg (built by tooling/ffmpeg/build-ffmpeg.sh into
|
||||
# .cache/ffmpeg) is the only supported FFmpeg: ffmpeg-sys-next reads
|
||||
# FFMPEG_DIR at build-script time, which cannot come from a .env file —
|
||||
# a relative [env] entry here is the only machine-agnostic way to set
|
||||
# it. Run tooling/ffmpeg/build-ffmpeg.sh once before the first build.
|
||||
|
||||
[env]
|
||||
FFMPEG_DIR = { value = ".cache/ffmpeg", relative = true }
|
||||
@@ -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
|
||||
|
||||
@@ -121,3 +121,5 @@ tarpaulin-out/
|
||||
.env
|
||||
# CD packaging artifacts
|
||||
/*.dmg
|
||||
.cargo/config.toml
|
||||
perf.data
|
||||
|
||||
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,8 +297,13 @@ pub fn multicam_angle_frame_params(
|
||||
) -> Result<VideoTicketParams, String> {
|
||||
validate_geometry(width, height, tb)?;
|
||||
let time = Rational::new(frame_ts * tb.0, tb.1);
|
||||
// Bind the uuid before the literal: the struct expression is the tail of
|
||||
// the block, so an inline `lock(p)` temporary would outlive the field
|
||||
// initializers and deadlock the reentrant lock in `single_track_video_montage`.
|
||||
let project = lock(p).uuid.clone();
|
||||
Ok(VideoTicketParams {
|
||||
viewer: seq.identity(),
|
||||
project,
|
||||
time,
|
||||
force_size: Some((width, height)),
|
||||
force_format: None,
|
||||
@@ -551,8 +556,13 @@ pub fn sequence_frame_params(
|
||||
) -> Result<VideoTicketParams, String> {
|
||||
validate_geometry(width, height, tb)?;
|
||||
let time = Rational::new(frame_ts * tb.0, tb.1);
|
||||
// Bind the uuid before the literal: an inline `lock(p)` temporary in the
|
||||
// block-tail struct expression would still be alive when `video_montage`
|
||||
// re-locks the project, deadlocking the same thread.
|
||||
let project = lock(p).uuid.clone();
|
||||
Ok(VideoTicketParams {
|
||||
viewer: seq.identity(),
|
||||
project,
|
||||
time,
|
||||
force_size: Some((width, height)),
|
||||
force_format: None,
|
||||
@@ -598,8 +608,12 @@ pub fn footage_frame_params(
|
||||
.ok_or_else(|| "the node is not footage".to_string())?
|
||||
};
|
||||
let time = Rational::new(frame_ts * tb.0, tb.1);
|
||||
// Bind the uuid before the literal (same tail-expression temporary rule
|
||||
// as the montage paths; harmless here but keeps the pattern uniform).
|
||||
let project = lock(p).uuid.clone();
|
||||
Ok(VideoTicketParams {
|
||||
viewer: footage.identity(),
|
||||
project,
|
||||
time,
|
||||
force_size: Some((width, height)),
|
||||
force_format: None,
|
||||
@@ -989,6 +1003,7 @@ mod tests {
|
||||
let render = |montage: Vec<MontageClip>| {
|
||||
let params = VideoTicketParams {
|
||||
viewer: 0,
|
||||
project: String::new(),
|
||||
time,
|
||||
force_size: Some((64, 64)),
|
||||
force_format: Some(oak_core::PixelFormat::F32),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@ use oak_core::{Rational, TimeRange};
|
||||
use crate::id::NodeId;
|
||||
use crate::input::Input;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
|
||||
/// Block core data (C++ `Block` members): timeline span + media range.
|
||||
#[derive(Clone)]
|
||||
@@ -330,6 +330,57 @@ impl NodeBehavior for ClipBlockBehavior {
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Timeline -> media time mapping on the texture input (C++
|
||||
/// `ClipBlock::InputTimeAdjustment`): `media = (time - in) * speed`
|
||||
/// (reversed flips inside the block span), offset by the media
|
||||
/// in-point. Other inputs pass through unchanged.
|
||||
fn input_time_adjustment(
|
||||
&self,
|
||||
input: &str,
|
||||
_element: i32,
|
||||
time: TimeRange,
|
||||
_traverse: bool,
|
||||
) -> TimeRange {
|
||||
if input != clip_input::TEXTURE_INPUT {
|
||||
return time;
|
||||
}
|
||||
let mut media = time.in_() - self.core.in_();
|
||||
if (self.core.speed - 1.0).abs() > 1e-9 {
|
||||
if self.core.speed.abs() < 1e-12 {
|
||||
media = Rational::new(0, 1);
|
||||
} else {
|
||||
media = Rational::from_double(media.to_f64() * self.core.speed);
|
||||
}
|
||||
}
|
||||
if self.core.reversed {
|
||||
media = self.core.length() - media;
|
||||
}
|
||||
media = media + self.core.media_in;
|
||||
TimeRange::new(media, media + (time.out() - time.in_()))
|
||||
}
|
||||
|
||||
/// Pass the connected texture through (C++ `ClipBlock::ProcessFrame`
|
||||
/// copies `tex_in` to the output). An unconnected `tex_in` yields
|
||||
/// nothing, so a bare clip is inert.
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
inputs: &NodeValueRow,
|
||||
_time: Rational,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
if !self.core.enabled {
|
||||
return;
|
||||
}
|
||||
let Some(value) = inputs.get(clip_input::TEXTURE_INPUT) else {
|
||||
return;
|
||||
};
|
||||
// `NodeValue::clone` addrefs the texture handle so the table row
|
||||
// owns its own reference (released on drop); a plain handle copy
|
||||
// would double-release the input's reference.
|
||||
table.push(ValueType::Texture, value.clone(), None);
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for GapBlockBehavior {
|
||||
@@ -375,6 +426,16 @@ impl NodeBehavior for GapBlockBehavior {
|
||||
load_block_core(reader, &mut self.core, &mut |_, _| false);
|
||||
true
|
||||
}
|
||||
|
||||
/// No video output (the compositor skips uncovered spans).
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
_inputs: &NodeValueRow,
|
||||
_time: Rational,
|
||||
_table: &mut NodeValueTable,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for TransitionBlockBehavior {
|
||||
@@ -435,6 +496,17 @@ impl NodeBehavior for TransitionBlockBehavior {
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// No video output yet (C++ transition crossfades are not ported; a
|
||||
/// transition renders as a hole for now).
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
_inputs: &NodeValueRow,
|
||||
_time: Rational,
|
||||
_table: &mut NodeValueTable,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor for a clip block (C++ `ClipBlock::ClipBlock()`): adds the
|
||||
@@ -448,9 +520,9 @@ pub fn clip_create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
// The texture input (C++ `ClipBlock` prepends it ahead of the static
|
||||
// inputs): this is where the effect chain attaches, so it sits right
|
||||
// after the inherited `enabled_in` and stays connectable. An unconnected
|
||||
// `tex_in` is inert — the traverser only feeds rows from actual edges
|
||||
// and `ClipBlockBehavior` never reads inputs, so a bare clip (no
|
||||
// effects) evaluates exactly as before.
|
||||
// `tex_in` is inert — [`ClipBlockBehavior::value`] passes only a
|
||||
// connected texture through (the traverser feeds rows from actual
|
||||
// edges), so a bare clip (no effects) emits no output.
|
||||
let mut tex = Input::new(
|
||||
clip_input::TEXTURE_INPUT,
|
||||
ValueType::Texture,
|
||||
@@ -575,6 +647,97 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn clip_with(speed: f64, reversed: bool) -> ClipBlockBehavior {
|
||||
ClipBlockBehavior {
|
||||
core: BlockCore {
|
||||
range: TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
|
||||
media_in: Rational::new(5, 1),
|
||||
speed,
|
||||
reversed,
|
||||
..BlockCore::default()
|
||||
},
|
||||
footage: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeline -> media time mapping on `tex_in` (C++
|
||||
/// `ClipBlock::InputTimeAdjustment`): speed first, then reverse, then
|
||||
/// the media in-point offset. Other inputs pass through unchanged.
|
||||
#[test]
|
||||
fn clip_input_time_adjustment_maps_timeline_to_media() {
|
||||
let time = TimeRange::new(Rational::new(12, 1), Rational::new(13, 1));
|
||||
let map = |c: &ClipBlockBehavior| c.input_time_adjustment(clip_input::TEXTURE_INPUT, -1, time, false);
|
||||
|
||||
// Speed 1: media = (12 - 10) + 5 = 7.
|
||||
assert_eq!(
|
||||
map(&clip_with(1.0, false)),
|
||||
TimeRange::new(Rational::new(7, 1), Rational::new(8, 1))
|
||||
);
|
||||
// Speed 2: media = (12 - 10) * 2 + 5 = 9.
|
||||
assert_eq!(
|
||||
map(&clip_with(2.0, false)),
|
||||
TimeRange::new(Rational::new(9, 1), Rational::new(10, 1))
|
||||
);
|
||||
// Speed 0 clamps to the media in-point.
|
||||
assert_eq!(
|
||||
map(&clip_with(0.0, false)),
|
||||
TimeRange::new(Rational::new(5, 1), Rational::new(6, 1))
|
||||
);
|
||||
// Reversed flips inside the block span before the media offset:
|
||||
// (10 - (12 - 10)) + 5 = 13.
|
||||
assert_eq!(
|
||||
map(&clip_with(1.0, true)),
|
||||
TimeRange::new(Rational::new(13, 1), Rational::new(14, 1))
|
||||
);
|
||||
// Reversed + speed 2: (10 - (12 - 10) * 2) + 5 = 11.
|
||||
assert_eq!(
|
||||
map(&clip_with(2.0, true)),
|
||||
TimeRange::new(Rational::new(11, 1), Rational::new(12, 1))
|
||||
);
|
||||
// Non-`tex_in` inputs pass through untouched.
|
||||
assert_eq!(clip_with(2.0, true).input_time_adjustment("other_in", -1, time, false), time);
|
||||
}
|
||||
|
||||
/// The clip copies the connected `tex_in` texture to its output;
|
||||
/// disabled clips, unconnected clips and non-clip blocks emit nothing
|
||||
/// (a bare clip is inert, matching C++ `ClipBlock::ProcessFrame`).
|
||||
#[test]
|
||||
fn clip_value_passes_connected_texture_only() {
|
||||
let mut inputs = NodeValueRow::new();
|
||||
let handle = crate::handle::make_owned(42i32);
|
||||
// The value owns the `make_owned` reference; the inserted row is a
|
||||
// proper `NodeValue` clone (addref'd), never a bare handle copy.
|
||||
let tex_value = NodeValue::Texture(handle);
|
||||
inputs.insert(clip_input::TEXTURE_INPUT.to_string(), tex_value.clone());
|
||||
|
||||
let mut table = NodeValueTable::default();
|
||||
clip_with(1.0, false).value(&NodeCore::empty(), &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.count(), 1);
|
||||
let NodeValue::Texture(out) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(out.ctx, handle.ctx, "the same texture box passes through");
|
||||
|
||||
let mut disabled = clip_with(1.0, false);
|
||||
disabled.core.enabled = false;
|
||||
let mut table = NodeValueTable::default();
|
||||
disabled.value(&NodeCore::empty(), &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.count(), 0, "disabled clip emits nothing");
|
||||
|
||||
let mut table = NodeValueTable::default();
|
||||
clip_with(1.0, false).value(&NodeCore::empty(), &NodeValueRow::new(), Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.count(), 0, "unconnected clip emits nothing");
|
||||
|
||||
for behavior in [
|
||||
Box::new(GapBlockBehavior::new()) as Box<dyn NodeBehavior>,
|
||||
Box::new(TransitionBlockBehavior::new()) as Box<dyn NodeBehavior>,
|
||||
] {
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&NodeCore::empty(), &NodeValueRow::new(), Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.count(), 0, "gap/transition emit nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect node can be chained onto the clip through `tex_in`: the
|
||||
/// connection succeeds and resolves back to the effect.
|
||||
#[test]
|
||||
|
||||
@@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::input::Input;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::value::{AudioParams, NodeValue, ValueType, VideoParams};
|
||||
use crate::value::{AudioParams, NodeValue, NodeValueRow, NodeValueTable, ValueType, VideoParams};
|
||||
|
||||
/// One media stream inside a footage file.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -347,6 +347,35 @@ impl NodeBehavior for FootageBehavior {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Emit the decode request (C++ `Footage::ProcessFootageRequest`): a
|
||||
/// boxed [`crate::nodes::jobs::FootageJobPayload`] the render hooks
|
||||
/// resolve to the decoded frame. A footage with no probed video stream
|
||||
/// (or no filename) outputs nothing.
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
_inputs: &NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
if self.filename.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(stream) = self.streams.iter().find(|s| s.is_video) else {
|
||||
return;
|
||||
};
|
||||
let payload = crate::nodes::jobs::FootageJobPayload {
|
||||
filename: self.filename.clone(),
|
||||
stream_index: stream.index,
|
||||
time,
|
||||
};
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(payload)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Custom project save (C++ `Footage::SaveCustom`): the file name,
|
||||
/// media timestamp, proxy state and the probed streams. `<filename>`
|
||||
/// and `<streams>` are Rust additions (C++ reads the name from the
|
||||
@@ -790,4 +819,64 @@ mod tests {
|
||||
assert!(f.streams.is_empty());
|
||||
assert_eq!(f.timestamp, 0);
|
||||
}
|
||||
|
||||
/// A footage with a probed video stream emits a boxed footage job
|
||||
/// payload carrying the decode request (C++ `Footage::ProcessFootageRequest`);
|
||||
/// a footage with no filename or no video stream outputs nothing.
|
||||
#[test]
|
||||
fn footage_value_emits_footage_job_payload() {
|
||||
let mut f = FootageBehavior::new("clip.mov");
|
||||
f.streams.push(StreamInfo {
|
||||
index: 1,
|
||||
is_video: true,
|
||||
video: None,
|
||||
audio: None,
|
||||
duration: oak_core::Rational::new(1, 1),
|
||||
});
|
||||
|
||||
let mut table = NodeValueTable::default();
|
||||
f.value(
|
||||
&NodeCore::empty(),
|
||||
&NodeValueRow::new(),
|
||||
oak_core::Rational::new(3, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert_eq!(table.count(), 1);
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::FootageJobPayload>(handle) }
|
||||
.expect("footage output boxes a FootageJobPayload");
|
||||
assert_eq!(payload.filename, "clip.mov");
|
||||
assert_eq!(payload.stream_index, 1);
|
||||
assert_eq!(payload.time, oak_core::Rational::new(3, 1));
|
||||
|
||||
// No filename: nothing.
|
||||
let mut table = NodeValueTable::default();
|
||||
FootageBehavior::new("").value(
|
||||
&NodeCore::empty(),
|
||||
&NodeValueRow::new(),
|
||||
oak_core::Rational::new(1, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert_eq!(table.count(), 0);
|
||||
|
||||
// Only an audio stream: nothing.
|
||||
let mut audio_only = FootageBehavior::new("clip.mov");
|
||||
audio_only.streams.push(StreamInfo {
|
||||
index: 0,
|
||||
is_video: false,
|
||||
video: None,
|
||||
audio: None,
|
||||
duration: oak_core::Rational::new(1, 1),
|
||||
});
|
||||
let mut table = NodeValueTable::default();
|
||||
audio_only.value(
|
||||
&NodeCore::empty(),
|
||||
&NodeValueRow::new(),
|
||||
oak_core::Rational::new(1, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert_eq!(table.count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -75,18 +75,24 @@ pub struct ChromaKeyNode {
|
||||
|
||||
/// Fragment shader (C++ loads the `:/shaders/chromakey.frag` resource
|
||||
/// in `get_shader_code`). Text copied verbatim from
|
||||
/// `engine/shaders/chromakey.frag`. The `%1` marker is replaced with
|
||||
/// `engine/shaders/chromakey.frag`, except the tolerance uniforms,
|
||||
/// which are spelled `upper_tolerance_in`/`lower_tolerance_in` to
|
||||
/// match the input ids: the renderer binds uniforms by matching the
|
||||
/// shader-declared name against the job's value keys, so the C++
|
||||
/// misspelling (`upper_tolerence_in`) never receives the renamed
|
||||
/// input's value and the tolerances stay 0. `// CPP-PARITY: the C++
|
||||
/// frag still declares the misspelled names (the input rename commit
|
||||
/// bec52b46b did not update it), which makes chromakey broken there;
|
||||
/// the fix is applied here only.` The `%1` marker is replaced with
|
||||
/// the OCIO-generated shader stub (`request.stub`) at request time;
|
||||
/// the shader calls `SceneLinearToCIEXYZ_d65`, which the stub must
|
||||
/// define. Note the shader still uses the legacy misspelled uniform
|
||||
/// names `upper_tolerence_in`/`lower_tolerence_in`, matching the old
|
||||
/// input ids remapped by `map_legacy_input_id`.
|
||||
/// define.
|
||||
const SHADER_FRAG: &str = r#"// Main texture input
|
||||
uniform sampler2D tex_in;
|
||||
uniform vec4 color_key;
|
||||
uniform bool mask_only_in;
|
||||
uniform float upper_tolerence_in;
|
||||
uniform float lower_tolerence_in;
|
||||
uniform float upper_tolerance_in;
|
||||
uniform float lower_tolerance_in;
|
||||
|
||||
uniform sampler2D garbage_in;
|
||||
uniform sampler2D core_in;
|
||||
@@ -154,7 +160,7 @@ void main() {
|
||||
vec4 cie_xyz_key = SceneLinearToCIEXYZ_d65(color_key);
|
||||
vec4 lab_key = CIExyz_to_Lab(cie_xyz_key);
|
||||
|
||||
float mask = colorclose(lab, lab_key, lower_tolerence_in, upper_tolerence_in);
|
||||
float mask = colorclose(lab, lab_key, lower_tolerance_in, upper_tolerance_in);
|
||||
|
||||
mask = clamp(mask, 0.0, 1.0);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -277,16 +278,24 @@ impl NodeBehavior for CornerPinDistortNode {
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
|
||||
/// all four corner sliders at their `(0, 0)` default -> pass-through
|
||||
/// push of the input texture unchanged; otherwise build a shader job
|
||||
/// with `resolution_in` inserted and custom vertex coordinates: each
|
||||
/// corner offset is converted to pixels via `value_to_pixel` and then
|
||||
/// to clip space (`/ half_resolution - 1.0`) and pushed as two
|
||||
/// triangles (TL, TR, BR / TL, BL, BR).
|
||||
/// push of the input texture unchanged; otherwise push a shader job.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` value and the adjusted vertex coordinates) is
|
||||
/// deferred to the renderer seam (`// CPP-PARITY:
|
||||
/// cornerpindistortnode.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and uniforms, keyed by the effect input,
|
||||
/// and `resolution_in` is filled by the runner from the input
|
||||
/// texture's size, matching the C++ insert of the texture's virtual
|
||||
/// resolution (`// CPP-PARITY: cornerpindistortnode.cpp` value()).
|
||||
///
|
||||
/// TODO(vertex-shader): C++ additionally overrides the vertex
|
||||
/// coordinates — each corner offset converted to pixels via
|
||||
/// `value_to_pixel` and then to clip space (`/ half_resolution - 1.0`),
|
||||
/// pushed as two triangles (TL, TR, BR / TL, BL, BR) via
|
||||
/// `job.SetVertexCoordinates(...)` — and the quad warp needs the custom
|
||||
/// vertex shader `cornerpin.vert` (`ove_mvpmat`), which the
|
||||
/// [`ShaderJobPayload`] (no vertex field) and the single-fragment
|
||||
/// `shader_code()` seam cannot carry. The fragment path (perspective
|
||||
/// interpolation) is testable without it.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -317,9 +326,21 @@ impl NodeBehavior for CornerPinDistortNode {
|
||||
&& corner_is_null(BOTTOM_RIGHT_INPUT)
|
||||
&& corner_is_null(BOTTOM_LEFT_INPUT))
|
||||
{
|
||||
// The shader-job box (C++ ShaderJob): the behavior's type id
|
||||
// selects the fragment source; the effect input key locates the
|
||||
// main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -570,17 +591,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_moved_corner_pushes_deferred_job() {
|
||||
fn value_moved_corner_pushes_job_payload() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(TOP_RIGHT_INPUT, -1, NodeValue::Vec2([10.0, 5.0]));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("cornerpin output boxes a ShaderJobPayload");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.cornerpin");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_corner_moved_on_y_only_pushes_deferred_job() {
|
||||
fn value_corner_moved_on_y_only_pushes_job_payload() {
|
||||
// C++ `is_null()` requires both components zero: a corner at
|
||||
// (0, 5) is not at its default.
|
||||
let (mut core, behavior) = create();
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -174,9 +175,12 @@ impl NodeBehavior for CropDistortNode {
|
||||
/// shader job; all zero -> pass-through push of the input texture
|
||||
/// unchanged.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` value) is deferred to the renderer seam
|
||||
/// (`// CPP-PARITY: cropdistortnode.cpp` value()).
|
||||
/// The job case boxes a [`ShaderJobPayload`] that the renderer's
|
||||
/// resolve hook executes and replaces with the result texture; the
|
||||
/// params row carries the input texture and uniforms, keyed by the
|
||||
/// effect input, and `resolution_in` is filled by the runner from the
|
||||
/// input texture's size, matching the C++ `texture->params()`
|
||||
/// insertion (`// CPP-PARITY: cropdistortnode.cpp` `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -207,9 +211,21 @@ impl NodeBehavior for CropDistortNode {
|
||||
};
|
||||
|
||||
if left != 0.0 || right != 0.0 || top != 0.0 || bottom != 0.0 {
|
||||
// The shader-job box (C++ `texture->toJob(job)`): the behavior's
|
||||
// type id selects the fragment source, and the effect input key
|
||||
// locates the main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: TEXTURE_INPUT.to_string(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -477,13 +493,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_any_crop_pushes_deferred_job() {
|
||||
fn value_any_crop_pushes_shader_job_payload() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(LEFT_INPUT, -1, NodeValue::Float(0.25));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.crop");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.time, Rational::new(0, 1));
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -38,6 +39,13 @@ pub const METHOD_INPUT: &str = "method_in";
|
||||
/// `_in`). Type: boolean; default `false`.
|
||||
pub const PRESERVE_LUMINANCE_INPUT: &str = "preserve_luminance_input";
|
||||
|
||||
/// Luma coefficients uniform id (C++ uses the `luma_coeffs` literal in
|
||||
/// `value()`). Not a declared node input — it is the shader uniform name
|
||||
/// fed per frame in `value()` with the project color manager's default
|
||||
/// luma coefficients (Rec. 709 `{0.2126, 0.7152, 0.0722}` fallback).
|
||||
/// Type: vec3.
|
||||
pub const LUMA_COEFFS_INPUT: &str = "luma_coeffs";
|
||||
|
||||
/// Despill node: removes green/blue screen spill from the keyed
|
||||
/// foreground using one of several channel-averaging methods. The C++
|
||||
/// class has no own members.
|
||||
@@ -183,7 +191,6 @@ impl NodeBehavior for DespillNode {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let _ = (core, time);
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(crate::value::NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
@@ -194,12 +201,27 @@ impl NodeBehavior for DespillNode {
|
||||
// color manager's default luma coefficients when one is attached —
|
||||
// the Rust model has no project/manager access, so the fallback
|
||||
// always applies) into a ShaderJob over the whole input row and
|
||||
// pushes `tex->to_job(job)`. The Rust model has no shader-job
|
||||
// payload: the renderer seam resolves the deferred job from this
|
||||
// null handle.
|
||||
// pushes `tex->to_job(job)`. The job is boxed here as a
|
||||
// [`ShaderJobPayload`] that the renderer's resolve hook executes
|
||||
// and replaces with the result texture; the params row carries the
|
||||
// luma coefficients under the shader uniform name.
|
||||
let mut params = inputs.clone();
|
||||
params.insert(
|
||||
LUMA_COEFFS_INPUT.to_string(),
|
||||
crate::value::NodeValue::Vec3([0.2126, 0.7152, 0.0722]),
|
||||
);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -324,7 +346,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_texture_pushes_deferred_shader_job() {
|
||||
fn value_with_texture_pushes_shader_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
@@ -332,7 +354,22 @@ mod tests {
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("payload boxed behind the handle");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.despill");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
// The luma coefficients are injected under the shader uniform name
|
||||
// (C++ `job.Insert("luma_coeffs", ...)`).
|
||||
assert_eq!(
|
||||
payload.params.get(LUMA_COEFFS_INPUT),
|
||||
Some(&NodeValue::Vec3([0.2126, 0.7152, 0.0722]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -49,6 +50,12 @@ pub const OPACITY_INPUT: &str = "opacity_in";
|
||||
/// default `false`.
|
||||
pub const FAST_INPUT: &str = "fast_in";
|
||||
|
||||
/// Iterative-input texture id (C++ ShaderJob param
|
||||
/// `previous_iteration_in`). Not a real node input: the C++ `value()`
|
||||
/// inserts it into the job params directly, so it only exists here as
|
||||
/// the feedback slot between the two blur passes and the merge step.
|
||||
pub const ITERATIVE_INPUT: &str = "previous_iteration_in";
|
||||
|
||||
/// Drop shadow filter node. Adds a colored, blurred, offset copy of the
|
||||
/// input's alpha behind the image. The C++ class declares no own member
|
||||
/// fields.
|
||||
@@ -222,10 +229,14 @@ impl NodeBehavior for DropShadowFilter {
|
||||
/// the input texture; when softness is non-zero the job runs 3
|
||||
/// iterations feeding back through `previous_iteration_in`.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` / `previous_iteration_in` bindings and the 3-iteration
|
||||
/// feedback when softness != 0) is deferred to the renderer seam
|
||||
/// (`// CPP-PARITY: dropshadowfilter.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; `resolution_in`
|
||||
/// is filled by the runner from the input texture's size. The
|
||||
/// feedback is carried both as metadata (`iterations` = 3 and
|
||||
/// `iterative_input` = `previous_iteration_in` when softness != 0,
|
||||
/// else 1 / empty) and as an unconditional `previous_iteration_in`
|
||||
/// entry in `params`, mirroring the C++ unconditional `SetParam`
|
||||
/// (`// CPP-PARITY: dropshadowfilter.cpp` `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -233,16 +244,45 @@ impl NodeBehavior for DropShadowFilter {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
if !matches!(
|
||||
inputs.get(TEXTURE_INPUT),
|
||||
Some(crate::value::NodeValue::Texture(_))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let _ = (core, time, inputs);
|
||||
let tex = match inputs.get(TEXTURE_INPUT) {
|
||||
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
|
||||
_ => return,
|
||||
};
|
||||
let softness = match inputs.get(SOFTNESS_INPUT) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(SOFTNESS_INPUT, -1, time).to_double(),
|
||||
};
|
||||
|
||||
// C++ unconditionally binds the input texture to
|
||||
// `previous_iteration_in` (SetParam), so the params row always
|
||||
// carries the entry even when the job runs a single iteration.
|
||||
let mut params = inputs.clone();
|
||||
params.insert(ITERATIVE_INPUT.to_string(), tex);
|
||||
|
||||
// C++ `if (!qIsNull(softness)) job.SetIterations(3)`: the merge
|
||||
// step (ove_iteration == 2) needs two blur passes first, so a
|
||||
// non-zero softness runs 3 iterations feeding back through
|
||||
// `previous_iteration_in`.
|
||||
let softness_nonzero = softness != 0.0;
|
||||
let iterations = if softness_nonzero { 3 } else { 1 };
|
||||
let iterative_input = if softness_nonzero {
|
||||
ITERATIVE_INPUT.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input,
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -384,12 +424,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_texture_pushes_deferred_job() {
|
||||
fn value_with_softness_pushes_three_iteration_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(SOFTNESS_INPUT.to_string(), NodeValue::Float(10.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => {
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.dropshadow");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 3);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterative_input, ITERATIVE_INPUT);
|
||||
assert!(payload.params.contains_key(ITERATIVE_INPUT));
|
||||
}
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_zero_softness_pushes_single_iteration_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(SOFTNESS_INPUT.to_string(), NodeValue::Float(0.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => {
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
// The params row still carries the unconditional
|
||||
// `previous_iteration_in` binding (C++ SetParam).
|
||||
assert!(payload.params.contains_key(ITERATIVE_INPUT));
|
||||
}
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -105,6 +106,11 @@ impl NodeBehavior for FlipDistortNode {
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
|
||||
/// either flip flag set -> shader job over the whole value row;
|
||||
/// neither set -> pass-through push of the input texture unchanged.
|
||||
///
|
||||
/// The job case boxes a [`ShaderJobPayload`] that the renderer's
|
||||
/// resolve hook executes and replaces with the result texture; the
|
||||
/// params row carries the input texture and uniforms, keyed by the
|
||||
/// effect input (`// CPP-PARITY: flipdistortnode.cpp` `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -127,13 +133,21 @@ impl NodeBehavior for FlipDistortNode {
|
||||
};
|
||||
|
||||
if horiz || vert {
|
||||
// C++ pushes `tex->to_job(ShaderJob(value))` (the whole row as
|
||||
// job values); the Rust model defers the job to the renderer
|
||||
// seam, so a null handle marks "renderer must produce this
|
||||
// texture" (`// CPP-PARITY: flipdistortnode.cpp` value()).
|
||||
// The shader-job box (C++ `tex->toJob(ShaderJob(value))`): the
|
||||
// behavior's type id selects the fragment source, and the effect
|
||||
// input key locates the main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: TEXTURE_INPUT.to_string(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -247,13 +261,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_flip_pushes_deferred_job() {
|
||||
fn value_flip_pushes_shader_job_payload() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(VERTICAL_INPUT, -1, NodeValue::Boolean(true));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.flip");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.time, Rational::new(0, 1));
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -265,7 +290,14 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.flip");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! base texture. Not instantiable, so this is a helper module, not a
|
||||
//! [`NodeBehavior`] implementation.
|
||||
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
use crate::value::NodeValue;
|
||||
|
||||
/// Base texture input id (C++ `k_base_input`). Type: texture; flags:
|
||||
@@ -87,12 +88,15 @@ impl GeneratorWithMerge {
|
||||
/// `MergeNode::k_blend_in`, pushing `base->to_job(merge)`; without
|
||||
/// a base, pushes the generated job unchanged.
|
||||
///
|
||||
/// The Rust model has no shader-job payload (see
|
||||
/// [`crate::nodes::mathbase`]): the merged case pushes a null
|
||||
/// texture handle marking a renderer-deferred `"mrg"` shader job,
|
||||
/// and the un-merged case pushes `job` itself. `job` is an opaque
|
||||
/// oakrender texture handle (cross-module payload; null in the
|
||||
/// deferred-job model) — see [`crate::value::NodeValue::Texture`].
|
||||
/// `job` boxes the generated job's [`ShaderJobPayload`]. Without a
|
||||
/// base the box is pushed through as-is (addref'd, so the table's
|
||||
/// reference outlives the caller's handle). With a base, a new
|
||||
/// `"mrg"` payload is boxed whose params row carries the base
|
||||
/// texture under [`BASE_INPUT`] and the generated job (the blend
|
||||
/// layer) under [`crate::nodes::merge::BLEND_INPUT`], with
|
||||
/// `type_id`/`time` taken from the generated job — mirroring the
|
||||
/// C++ `merge` shader job. A null or foreign `job` handle (the
|
||||
/// legacy deferred model) keeps the old placeholder behavior.
|
||||
pub fn push_mergable_job(
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
job: crate::handle::CHandle,
|
||||
@@ -101,19 +105,54 @@ impl GeneratorWithMerge {
|
||||
match inputs.get(BASE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {
|
||||
// A base is connected: the C++ pushes
|
||||
// `base->to_job(ShaderJob("mrg"))` — a deferred alpha-over
|
||||
// merge of the generated texture over the base.
|
||||
// `base->to_job(ShaderJob("mrg"))` — an alpha-over merge
|
||||
// of the generated texture over the base.
|
||||
// `// CPP-PARITY: generatorwithmerge.cpp` push_mergable_job.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
None,
|
||||
);
|
||||
match unsafe { crate::handle::get_checked::<ShaderJobPayload>(&job) } {
|
||||
Some(gen_job) => {
|
||||
let mut params = crate::value::NodeValueRow::new();
|
||||
params.insert(
|
||||
BASE_INPUT.to_string(),
|
||||
inputs
|
||||
.get(BASE_INPUT)
|
||||
.cloned()
|
||||
.expect("base input matched above"),
|
||||
);
|
||||
params.insert(
|
||||
crate::nodes::merge::BLEND_INPUT.to_string(),
|
||||
NodeValue::Texture(unsafe { job.addref() }),
|
||||
);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time: gen_job.time,
|
||||
iterations: 1,
|
||||
type_id: gen_job.type_id.clone(),
|
||||
shader_id: "mrg".to_string(),
|
||||
effect_input: BASE_INPUT.to_string(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// Legacy null-handle deferred model: keep the
|
||||
// placeholder — TODO: drop once every generator
|
||||
// boxes a job payload.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(job),
|
||||
NodeValue::Texture(unsafe { job.addref() }),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -125,9 +164,37 @@ impl GeneratorWithMerge {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
#[test]
|
||||
fn push_job_without_base_pushes_job() {
|
||||
fn push_job_without_base_pushes_job_unchanged() {
|
||||
let job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload {
|
||||
type_id: "org.olivevideoeditor.Olive.solidgenerator".to_string(),
|
||||
time: Rational::new(2, 1),
|
||||
shader_id: "1".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
let mut table = NodeValueTable::default();
|
||||
GeneratorWithMerge::push_mergable_job(
|
||||
&crate::value::NodeValueRow::default(),
|
||||
job,
|
||||
&mut table,
|
||||
);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle)
|
||||
}
|
||||
.expect("job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.solidgenerator");
|
||||
assert_eq!(payload.shader_id, "1");
|
||||
assert_eq!(payload.time, Rational::new(2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_job_without_base_null_job_pushes_null() {
|
||||
let job = crate::handle::CHandle::null();
|
||||
let mut table = NodeValueTable::default();
|
||||
GeneratorWithMerge::push_mergable_job(
|
||||
@@ -142,7 +209,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_job_with_base_pushes_deferred_merge() {
|
||||
fn push_job_with_base_boxes_merge_payload() {
|
||||
let gen_job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload {
|
||||
type_id: "org.olivevideoeditor.Olive.solidgenerator".to_string(),
|
||||
time: Rational::new(2, 1),
|
||||
shader_id: "1".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
let base = NodeValue::Texture(crate::handle::make_owned::<u8>(7));
|
||||
let inputs = crate::value::NodeValueRow::from([(BASE_INPUT.to_string(), base.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
GeneratorWithMerge::push_mergable_job(&inputs, gen_job, &mut table);
|
||||
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let merge = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle)
|
||||
}
|
||||
.expect("merge job payload boxed");
|
||||
assert_eq!(merge.shader_id, "mrg");
|
||||
assert_eq!(merge.type_id, "org.olivevideoeditor.Olive.solidgenerator");
|
||||
assert_eq!(merge.time, Rational::new(2, 1));
|
||||
assert_eq!(merge.iterations, 1);
|
||||
assert_eq!(merge.effect_input, BASE_INPUT);
|
||||
assert_eq!(merge.params.get(BASE_INPUT), Some(&base));
|
||||
let blend = match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let blend_job = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&blend)
|
||||
}
|
||||
.expect("blend job payload boxed");
|
||||
assert_eq!(blend_job.type_id, "org.olivevideoeditor.Olive.solidgenerator");
|
||||
assert_eq!(blend_job.shader_id, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_job_with_base_null_job_keeps_placeholder() {
|
||||
let job = crate::handle::CHandle::null();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
BASE_INPUT.to_string(),
|
||||
@@ -150,9 +256,9 @@ mod tests {
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
GeneratorWithMerge::push_mergable_job(&inputs, job, &mut table);
|
||||
assert!(
|
||||
table.get(ValueType::Texture).is_some(),
|
||||
"merge job placeholder pushed"
|
||||
);
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => assert!(h.is_null()),
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Render-job payloads (C++ `app/render/job/footagejob.h`,
|
||||
//! `app/render/job/shaderjob.h`): boxed inside `Texture` values during
|
||||
//! graph evaluation, then resolved to real textures by the render hooks
|
||||
//! ([`crate::traverser::RenderHooks::resolve`]).
|
||||
//! `// CPP-PARITY: app/render/job/footagejob.h, shaderjob.h`.
|
||||
|
||||
use oak_core::Rational;
|
||||
|
||||
use crate::id::NodeId;
|
||||
use crate::value::NodeValueRow;
|
||||
|
||||
/// C++ `FootageJob` payload: the decode request a footage node emits at
|
||||
/// its output instead of a texture. The render hooks decode it at the
|
||||
/// request time and replace it with the resulting frame.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FootageJobPayload {
|
||||
/// Footage file path.
|
||||
pub filename: String,
|
||||
/// Container stream index.
|
||||
pub stream_index: i32,
|
||||
/// Request time in media seconds.
|
||||
pub time: Rational,
|
||||
}
|
||||
|
||||
/// C++ `ShaderJob` payload: the GPU shader pass a node emits at its
|
||||
/// output. The fragment shader is looked up by `type_id`/`shader_id` in
|
||||
/// the node behavior; the param row carries the uniforms (including the
|
||||
/// effect input texture, keyed by `effect_input`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShaderJobPayload {
|
||||
/// Emitting node identity (for diagnostics).
|
||||
pub node_id: NodeId,
|
||||
/// Request time in media seconds.
|
||||
pub time: Rational,
|
||||
/// Pass iterations (C++ `ShaderJob::iterations`).
|
||||
pub iterations: i32,
|
||||
/// Node behavior type id — the pipeline cache key and the lookup key
|
||||
/// for the emitting node (C++ `job.node`).
|
||||
pub type_id: String,
|
||||
/// Shader variant id passed to the behavior's `shader_code()` (C++
|
||||
/// `ShaderJob::shader_id`); empty for the default variant.
|
||||
pub shader_id: String,
|
||||
/// Effect input id: the param row key carrying the main input texture
|
||||
/// (C++ `node->GetEffectInput()`).
|
||||
pub effect_input: String,
|
||||
/// The param row at evaluation time (C++ `ShaderJob::params`): uniform
|
||||
/// values keyed by input id, the effect input texture among them.
|
||||
pub params: NodeValueRow,
|
||||
/// The texture the iterative passes feed back into (C++ `ShaderJob::
|
||||
/// iterative_input`; empty = the effect input).
|
||||
pub iterative_input: String,
|
||||
}
|
||||
|
||||
impl Default for FootageJobPayload {
|
||||
fn default() -> Self {
|
||||
FootageJobPayload {
|
||||
filename: String::new(),
|
||||
stream_index: 0,
|
||||
time: Rational::new(0, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShaderJobPayload {
|
||||
fn default() -> Self {
|
||||
ShaderJobPayload {
|
||||
node_id: NodeId::INVALID,
|
||||
time: Rational::new(0, 1),
|
||||
iterations: 1,
|
||||
type_id: String::new(),
|
||||
shader_id: String::new(),
|
||||
effect_input: String::new(),
|
||||
params: NodeValueRow::new(),
|
||||
iterative_input: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -145,6 +145,8 @@ impl NodeBehavior for MathNode {
|
||||
&calc.most_likely_value_a,
|
||||
PARAM_B_INPUT,
|
||||
&calc.most_likely_value_b,
|
||||
time,
|
||||
self.type_id(),
|
||||
core,
|
||||
inputs,
|
||||
table,
|
||||
@@ -398,4 +400,61 @@ mod tests {
|
||||
assert_eq!(out.sample_value(0, 0), 3.0);
|
||||
assert_eq!(out.sample_value(0, 1), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_texture_multiplied_pushes_job_payload() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2)); // Multiply
|
||||
let tex = NodeValue::Texture(crate::handle::make_owned::<u8>(7));
|
||||
let inputs = row(&[
|
||||
(PARAM_A_INPUT, tex.clone()),
|
||||
(PARAM_B_INPUT, NodeValue::Float(2.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(7, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle)
|
||||
}
|
||||
.expect("shader job payload boxed in the pushed texture");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.math");
|
||||
// op=2 (multiply), pairing=8 (texture_number), a=10 (texture),
|
||||
// b=2 (float).
|
||||
assert_eq!(payload.shader_id, "2.8.10.2");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, "");
|
||||
assert_eq!(payload.time, Rational::new(7, 1));
|
||||
assert_eq!(payload.params.get(PARAM_A_INPUT), Some(&tex));
|
||||
assert_eq!(
|
||||
payload.params.get(PARAM_B_INPUT),
|
||||
Some(&NodeValue::Float(2.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_null_texture_pushes_no_payload() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2)); // Multiply
|
||||
let inputs = row(&[
|
||||
(
|
||||
PARAM_A_INPUT,
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(PARAM_B_INPUT, NodeValue::Float(2.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
// Null texture operand -> no-op push-through, not a job payload.
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle)
|
||||
};
|
||||
assert!(payload.is_none(), "no shader job for a null texture");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! by `MathNode` (and conceptually other binary math nodes).
|
||||
|
||||
use crate::node::NodeCore;
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
|
||||
/// Binary operation (C++ `MathNodeBase::Operation`).
|
||||
@@ -380,14 +381,18 @@ impl MathNodeBase {
|
||||
/// vec/vec (zero-padding divide guard), matrix*vec, vec/number,
|
||||
/// matrix/matrix, color+/-color, color*number, sample buffers
|
||||
/// (elementwise, longer tail memcpy'd), texture pairings (shader job
|
||||
/// with `"op.pairing.ta.tb"` id; no-op push-through when the texture
|
||||
/// is null, the number is identity, or the matrix is identity), and
|
||||
/// sample*number (static: in-place SIMD loop; dynamic: sample job).
|
||||
/// payload boxed in the texture value with `"op.pairing.ta.tb"` id;
|
||||
/// no-op push-through when the texture is null, the number is
|
||||
/// identity, or the matrix is identity), and sample*number (static:
|
||||
/// in-place SIMD loop; dynamic: sample job).
|
||||
///
|
||||
/// `core`/`inputs` carry the node's input state so the sample*number
|
||||
/// branch can tell a static number (in-place transform) from a
|
||||
/// dynamic one (deferred sample job), mirroring the C++ `this`
|
||||
/// member access in `is_input_static(number_param)`.
|
||||
/// member access in `is_input_static(number_param)`. `time` is the
|
||||
/// job's request timestamp and `type_id` the emitting behavior's
|
||||
/// type id — the caller (e.g. `MathNode::value`) forwards its `time`
|
||||
/// argument and `self.type_id()`.
|
||||
pub fn value_internal(
|
||||
operation: Operation,
|
||||
pairing: Pairing,
|
||||
@@ -395,6 +400,8 @@ impl MathNodeBase {
|
||||
val_a: &NodeValue,
|
||||
param_b_in: &str,
|
||||
val_b: &NodeValue,
|
||||
time: oak_core::Rational,
|
||||
type_id: &str,
|
||||
core: &NodeCore,
|
||||
inputs: &NodeValueRow,
|
||||
output: &mut NodeValueTable,
|
||||
@@ -607,19 +614,37 @@ impl MathNodeBase {
|
||||
// Just push texture as-is.
|
||||
output.push(ValueType::Texture, texture_val.clone(), None);
|
||||
} else {
|
||||
// Push a texture-typed value representing the deferred
|
||||
// shader job. The C++ pushes `Texture::job(...)`
|
||||
// carrying the `ShaderJob` (with the
|
||||
// `"op.pairing.type_a.type_b"` id), which the renderer
|
||||
// resolves via `get_shader_code`; the Rust model defers
|
||||
// the job to the renderer seam
|
||||
// (`traverser::RenderHooks::resolve`), so the job
|
||||
// payload is not representable and a null handle marks
|
||||
// "renderer must produce this texture".
|
||||
// Push a texture value boxing the deferred shader job
|
||||
// (C++ `Texture::job(ShaderJob)`), which the renderer
|
||||
// seam (`traverser::RenderHooks::resolve`) downcasts
|
||||
// and resolves via `get_shader_code`. The id encodes
|
||||
// the operation, the pairing, and the actual operand
|
||||
// types; the params row carries the two operands under
|
||||
// their input ids, mirroring C++ `job.Insert(param_a_in,
|
||||
// val_a); job.Insert(param_b_in, val_b)`.
|
||||
// `// CPP-PARITY: mathbase.cpp` texture pairings.
|
||||
let shader_id = format!(
|
||||
"{}.{}.{}.{}",
|
||||
operation as i32,
|
||||
pairing as i32,
|
||||
val_a.value_type().to_cpp_discriminant(),
|
||||
val_b.value_type().to_cpp_discriminant()
|
||||
);
|
||||
output.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: type_id.to_string(),
|
||||
shader_id,
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: NodeValueRow::from([
|
||||
(param_a_in.to_string(), val_a.clone()),
|
||||
(param_b_in.to_string(), val_b.clone()),
|
||||
]),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -1050,6 +1075,8 @@ mod tests {
|
||||
&NodeValue::Float(2.0),
|
||||
"b",
|
||||
&NodeValue::Float(3.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1067,6 +1094,8 @@ mod tests {
|
||||
&NodeValue::Rational(Rational::new(1, 2)),
|
||||
"b",
|
||||
&NodeValue::Rational(Rational::new(1, 3)),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1084,6 +1113,8 @@ mod tests {
|
||||
&NodeValue::Rational(Rational::new(2, 1)),
|
||||
"b",
|
||||
&NodeValue::Rational(Rational::new(3, 1)),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1101,6 +1132,8 @@ mod tests {
|
||||
&NodeValue::Vec2([1.0, 4.0]),
|
||||
"b",
|
||||
&NodeValue::Vec2([2.0, 2.0]),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1118,6 +1151,8 @@ mod tests {
|
||||
&NodeValue::Vec3([1.0, 2.0, 3.0]),
|
||||
"b",
|
||||
&NodeValue::Float(2.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1147,6 +1182,8 @@ mod tests {
|
||||
&NodeValue::Matrix(m),
|
||||
"b",
|
||||
&NodeValue::Vec2([10.0, 20.0]),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1168,6 +1205,8 @@ mod tests {
|
||||
&NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
|
||||
"b",
|
||||
&NodeValue::Color([0.5, 0.5, 0.0, 0.0]),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1185,6 +1224,8 @@ mod tests {
|
||||
&NodeValue::Float(0.5),
|
||||
"b",
|
||||
&NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1223,6 +1264,8 @@ mod tests {
|
||||
&a,
|
||||
"b",
|
||||
&b,
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1252,6 +1295,8 @@ mod tests {
|
||||
&samples,
|
||||
"number_in",
|
||||
&NodeValue::Float(2.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&core,
|
||||
&inputs,
|
||||
&mut out,
|
||||
@@ -1280,6 +1325,8 @@ mod tests {
|
||||
&samples,
|
||||
"number_in",
|
||||
&NodeValue::Float(1.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1379,6 +1426,8 @@ mod tests {
|
||||
&tex,
|
||||
"num_in",
|
||||
&NodeValue::Float(1.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1403,6 +1452,8 @@ mod tests {
|
||||
&tex,
|
||||
"num_in",
|
||||
&NodeValue::Float(1.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1415,7 +1466,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_texture_number_job_placeholder() {
|
||||
fn value_texture_number_pushes_job_payload() {
|
||||
let tex = crate::value::NodeValue::Texture(crate::handle::make_owned::<u8>(7));
|
||||
let mut out = NodeValueTable::default();
|
||||
MathNodeBase::value_internal(
|
||||
@@ -1425,14 +1476,28 @@ mod tests {
|
||||
&tex,
|
||||
"num_in",
|
||||
&NodeValue::Float(2.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
);
|
||||
match out.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => assert!(h.is_null(), "deferred job placeholder"),
|
||||
_ => panic!("texture"),
|
||||
let handle = match out.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle)
|
||||
}
|
||||
.expect("shader job payload boxed in the pushed texture");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.math");
|
||||
// op=2 (multiply), pairing=8 (texture_number), a=10 (texture),
|
||||
// b=2 (float).
|
||||
assert_eq!(payload.shader_id, "2.8.10.2");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, "");
|
||||
assert_eq!(payload.params.get("tex_in"), Some(&tex));
|
||||
assert_eq!(payload.params.get("num_in"), Some(&NodeValue::Float(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1446,6 +1511,8 @@ mod tests {
|
||||
&tex,
|
||||
"mat_in",
|
||||
&NodeValue::Matrix(identity_matrix()),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1483,6 +1550,8 @@ mod tests {
|
||||
&samples,
|
||||
"num_in",
|
||||
&NodeValue::Float(2.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&core,
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1504,6 +1573,8 @@ mod tests {
|
||||
&NodeValue::Vec2([1.0, 2.0]),
|
||||
"b",
|
||||
&NodeValue::Vec3([1.0, 1.0, 1.0]),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1524,6 +1595,8 @@ mod tests {
|
||||
&NodeValue::Vec2([4.0, 8.0]),
|
||||
"b",
|
||||
&NodeValue::Float(2.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1545,6 +1618,8 @@ mod tests {
|
||||
&NodeValue::Matrix(a),
|
||||
"b",
|
||||
&NodeValue::Matrix(b),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1592,6 +1667,8 @@ mod tests {
|
||||
&NodeValue::Rational(a),
|
||||
"b",
|
||||
&NodeValue::Rational(b),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1719,6 +1796,8 @@ mod tests {
|
||||
&NodeValue::Color([1.0, 2.0, 3.0, 4.0]),
|
||||
"b",
|
||||
&NodeValue::Color([0.5, 0.5, 0.5, 0.5]),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
@@ -1739,6 +1818,8 @@ mod tests {
|
||||
&NodeValue::Float(1.0),
|
||||
"b",
|
||||
&NodeValue::Float(2.0),
|
||||
Rational::new(0, 1),
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
&NodeCore::new(),
|
||||
&NodeValueRow::default(),
|
||||
&mut out,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Base (background) texture input id (C++ `k_base_in`). Type:
|
||||
/// texture; flags: not-keyframable.
|
||||
@@ -117,13 +118,17 @@ impl NodeBehavior for MergeNode {
|
||||
/// present, push a shader job over the base texture with the whole
|
||||
/// input row as job values; if neither, push nothing.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the both-present case
|
||||
/// pushes a null texture handle marking a renderer-deferred
|
||||
/// alpha-over job resolved via [`Self::shader_code`]
|
||||
/// (`// CPP-PARITY: merge.cpp` `value()`). The "blend has fewer
|
||||
/// than 4 channels" check needs the texture's channel count, which
|
||||
/// the Rust texture handle does not carry, so the alpha-less blend
|
||||
/// case is only distinguishable by presence here.
|
||||
/// The both-present case boxes a [`ShaderJobPayload`] that the
|
||||
/// renderer's resolve hook executes and replaces with the result
|
||||
/// texture; the params row carries both input textures, keyed by
|
||||
/// their input ids. The C++ `MergeNode` constructor never sets an
|
||||
/// effect input, so `effect_input` is empty and the runner has no
|
||||
/// main texture to bind — binding `base_in`/`blend_in` explicitly is
|
||||
/// a renderer TODO. The "blend has fewer than 4 channels" check
|
||||
/// needs the texture's channel count, which the Rust texture handle
|
||||
/// does not carry, so the alpha-less blend case is only
|
||||
/// distinguishable by presence here (`// CPP-PARITY: merge.cpp`
|
||||
/// `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -131,7 +136,6 @@ impl NodeBehavior for MergeNode {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let _ = (core, time);
|
||||
let base = inputs.get(BASE_INPUT);
|
||||
let blend = inputs.get(BLEND_INPUT);
|
||||
|
||||
@@ -140,15 +144,25 @@ impl NodeBehavior for MergeNode {
|
||||
Some(b @ crate::value::NodeValue::Texture(_)),
|
||||
Some(bl @ crate::value::NodeValue::Texture(_)),
|
||||
) => {
|
||||
// Both present: alpha-over shader job. The C++ checks
|
||||
// the blend channel count here (RGBA required for an
|
||||
// alpha to over with) and pushes the blend as-is when it
|
||||
// has no alpha channel — not representable without the
|
||||
// texture params (`// CPP-PARITY: merge.cpp`).
|
||||
// Both present: alpha-over shader job (C++
|
||||
// `base_tex->toJob(ShaderJob(value))`). The C++ checks the
|
||||
// blend channel count here (RGBA required for an alpha to over
|
||||
// with) and pushes the blend as-is when it has no alpha
|
||||
// channel — not representable without the texture params
|
||||
// (`// CPP-PARITY: merge.cpp`).
|
||||
let _ = (b, bl);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -262,7 +276,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_both_pushes_deferred_job() {
|
||||
fn value_both_pushes_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(BASE_INPUT.to_string(), tex()),
|
||||
@@ -270,7 +284,19 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => {
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.merge");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert!(payload.params.contains_key(BASE_INPUT));
|
||||
assert!(payload.params.contains_key(BLEND_INPUT));
|
||||
}
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -111,15 +112,13 @@ impl NodeBehavior for MosaicFilterNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing; if
|
||||
/// the block counts already equal the texture's pixel dimensions ->
|
||||
/// pass-through push of the input texture; otherwise push a shader
|
||||
/// job with bilinear interpolation forced on `tex_in` (mipmapping
|
||||
/// makes block colors look wrong).
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
|
||||
/// otherwise queue the input texture as a shader job.
|
||||
///
|
||||
/// The "block counts equal the pixel dimensions" check compares the
|
||||
/// input values against the texture's width/height, which the Rust
|
||||
/// texture handle does not carry — so the pass-through optimization
|
||||
/// The C++ pass-through optimization — when the block counts already
|
||||
/// equal the texture's pixel dimensions, push the input texture
|
||||
/// unchanged — compares the input values against the texture's
|
||||
/// width/height, which the Rust texture handle does not carry, so it
|
||||
/// is not representable and a shader job is always queued when a
|
||||
/// texture is present (`// CPP-PARITY: mosaicfilternode.cpp` value()).
|
||||
fn value(
|
||||
@@ -135,10 +134,33 @@ impl NodeBehavior for MosaicFilterNode {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let _ = (core, time, inputs);
|
||||
|
||||
// `// CPP-PARITY: mosaicfilternode.cpp` `value()` — the C++ pushes
|
||||
// `tex->to_job(job)` when the block counts differ from the texture's
|
||||
// pixel dimensions and passes the texture through when they match;
|
||||
// the Rust texture handle carries no width/height, so the
|
||||
// pass-through branch is not representable and a shader job is
|
||||
// always queued here. The C++ also forces bilinear interpolation on
|
||||
// `tex_in` (`job.SetInterpolation(tex_in, kLinear)`) so mipmapping
|
||||
// does not smear block colors; [`ShaderJobPayload`] has no
|
||||
// interpolation field and the renderer does not support it yet
|
||||
// (TODO: carry interpolation on the payload and apply it in the
|
||||
// renderer). The job is boxed here as a [`ShaderJobPayload`] that
|
||||
// the renderer's resolve hook executes and replaces with the result
|
||||
// texture; the params row is the whole input row.
|
||||
let params = inputs.clone();
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -244,12 +266,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_texture_pushes_deferred_job() {
|
||||
fn value_with_texture_pushes_shader_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(HORIZ_INPUT.to_string(), NodeValue::Float(32.0)),
|
||||
(VERT_INPUT.to_string(), NodeValue::Float(18.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("payload boxed behind the handle");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.mosaicfilter");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
// The whole input row travels as the params; horiz/vert stay put
|
||||
// under their input ids.
|
||||
assert_eq!(
|
||||
payload.params.get(HORIZ_INPUT),
|
||||
Some(&NodeValue::Float(32.0))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(VERT_INPUT),
|
||||
Some(&NodeValue::Float(18.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Base texture input id (C++ `k_base_in`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -127,15 +128,16 @@ impl NodeBehavior for NoiseGeneratorNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): builds a shader job from the
|
||||
/// input row, additionally inserting `time_in` (current time in
|
||||
/// seconds as a float), then pushes a texture job using the base
|
||||
/// texture's params when connected, else the sequence video params.
|
||||
/// Evaluate outputs (C++ `value()`): always pushes a shader job —
|
||||
/// with a base texture connected it runs at the base's params,
|
||||
/// otherwise at the sequence params. The params row is the whole
|
||||
/// input row plus `time_in` (the request time in seconds as a
|
||||
/// float, C++ `globals.time().in().toDouble()`).
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `time_in` value) is deferred to the renderer seam, so a null
|
||||
/// texture handle marks "renderer must produce this texture"
|
||||
/// (`// CPP-PARITY: noise.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the runner
|
||||
/// fills the shader's `base_in_enabled` flag from the presence of
|
||||
/// the `base_in` texture (`// CPP-PARITY: noise.cpp` `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -143,12 +145,23 @@ impl NodeBehavior for NoiseGeneratorNode {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
// C++ always pushes a job — with a base texture connected it runs
|
||||
// at the base's params, otherwise at the sequence params.
|
||||
let _ = (core, inputs, time);
|
||||
let mut params = inputs.clone();
|
||||
params.insert(
|
||||
"time_in".to_string(),
|
||||
crate::value::NodeValue::Float(time.to_f64()),
|
||||
);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -238,25 +251,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_always_pushes_deferred_job() {
|
||||
fn value_pushes_shader_job() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&crate::value::NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
Rational::new(3, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let job =
|
||||
unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("noise output boxes a ShaderJobPayload");
|
||||
assert_eq!(job.type_id, "org.olivevideoeditor.Olive.noise");
|
||||
assert_eq!(job.shader_id, "");
|
||||
assert_eq!(job.iterations, 1);
|
||||
assert_eq!(job.effect_input, BASE_INPUT);
|
||||
// The params row carries the request time as the `time_in` float
|
||||
// (C++ `globals.time().in().toDouble()`).
|
||||
assert_eq!(job.params.get("time_in").map(|v| v.to_double()), Some(3.0));
|
||||
}
|
||||
|
||||
// With a base texture connected the job is still pushed.
|
||||
#[test]
|
||||
fn value_with_base_texture_pushes_shader_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
BASE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let job =
|
||||
unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("noise output boxes a ShaderJobPayload");
|
||||
assert_eq!(job.type_id, "org.olivevideoeditor.Olive.noise");
|
||||
assert!(job.params.contains_key(BASE_INPUT));
|
||||
assert!(job.params.contains_key("time_in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -142,14 +143,16 @@ impl NodeBehavior for RippleDistortNode {
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
|
||||
/// intensity != 0.0 -> shader job over the whole value row with
|
||||
/// `resolution_in` inserted from the texture's virtual resolution;
|
||||
/// intensity != 0.0 -> shader job over the whole value row;
|
||||
/// intensity == 0.0 -> pass-through push of the input texture
|
||||
/// unchanged.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` value) is deferred to the renderer seam
|
||||
/// (`// CPP-PARITY: rippledistortnode.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and uniforms, keyed by the effect input,
|
||||
/// and `resolution_in` is filled by the runner from the input
|
||||
/// texture's size, matching the C++ insert of the texture's virtual
|
||||
/// resolution (`// CPP-PARITY: rippledistortnode.cpp` value()).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -168,9 +171,21 @@ impl NodeBehavior for RippleDistortNode {
|
||||
};
|
||||
|
||||
if intensity != 0.0 {
|
||||
// The shader-job box (C++ ShaderJob): the behavior's type id
|
||||
// selects the fragment source; the effect input key locates the
|
||||
// main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -351,7 +366,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_nonzero_intensity_pushes_deferred_job() {
|
||||
fn value_nonzero_intensity_pushes_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
@@ -359,7 +374,15 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("ripple output boxes a ShaderJobPayload");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.ripple");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Shape type input id (C++ `k_type_input`). Type: combo; prepended
|
||||
/// ahead of the base inputs; combo strings (matching the C++ `Type`
|
||||
@@ -192,11 +193,15 @@ impl NodeBehavior for ShapeNode {
|
||||
/// the sequence video params), and pushes it through
|
||||
/// `push_mergable_job` (merged over `base_in` when connected).
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the deferred job
|
||||
/// (including the `resolution_in` value and the `"shape"` shader id)
|
||||
/// is resolved by the renderer seam, so a null texture handle marks
|
||||
/// "renderer must produce this texture" (`// CPP-PARITY: shapenode.cpp`
|
||||
/// `value()`).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; `resolution_in`
|
||||
/// is filled by the runner from the frame size, so it is not part of
|
||||
/// the params here. With a `base_in` texture connected, the C++
|
||||
/// `push_mergable_job` instead pushes a `"mrg"` alpha-over job whose
|
||||
/// `blend_in` is the shape job nested as a texture value — mirrored
|
||||
/// here as a nested payload, which the renderer cannot yet
|
||||
/// recursively resolve (TODO; `// CPP-PARITY: shapenode.cpp`
|
||||
/// `value()`, `generatorwithmerge.cpp` `push_mergable_job`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -204,12 +209,59 @@ impl NodeBehavior for ShapeNode {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let _ = (core, time);
|
||||
super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
|
||||
inputs,
|
||||
crate::handle::CHandle::null(),
|
||||
table,
|
||||
);
|
||||
// The `"shape"` shader job (C++ `value()`: `ShaderJob job(value);`
|
||||
// `Insert("resolution_in")`; `SetShaderID("shape")`).
|
||||
let shape_job = crate::value::NodeValue::Texture(crate::handle::make_owned(
|
||||
ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: "shape".to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
},
|
||||
));
|
||||
|
||||
match inputs.get(super::generatorwithmerge::BASE_INPUT) {
|
||||
Some(base @ crate::value::NodeValue::Texture(_)) => {
|
||||
// A base is connected: the C++ `push_mergable_job` pushes
|
||||
// `base->toJob(ShaderJob("mrg"))` with `base_in` = the base
|
||||
// texture and `blend_in` = the shape job nested as a texture
|
||||
// value; the merge's params are a fresh row holding exactly
|
||||
// those two keys, and the default `ShaderJob` has
|
||||
// `iterations = 1` and no iterative input. Recursively
|
||||
// resolving the nested payload is a renderer TODO
|
||||
// (`// CPP-PARITY: generatorwithmerge.cpp`
|
||||
// `push_mergable_job`).
|
||||
let mut params = crate::value::NodeValueRow::new();
|
||||
params.insert(
|
||||
super::generatorwithmerge::BASE_INPUT.to_string(),
|
||||
base.clone(),
|
||||
);
|
||||
params.insert(crate::nodes::merge::BLEND_INPUT.to_string(), shape_job);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(
|
||||
ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: "mrg".to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
},
|
||||
)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
table.push(crate::value::ValueType::Texture, shape_job, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shader code request (C++ `get_shader_code()`): `"shape"` returns
|
||||
@@ -378,7 +430,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_deferred_job() {
|
||||
fn value_pushes_shape_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
@@ -387,7 +439,54 @@ mod tests {
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => {
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.shape");
|
||||
assert_eq!(payload.shader_id, "shape");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(
|
||||
payload.effect_input,
|
||||
super::super::generatorwithmerge::BASE_INPUT
|
||||
);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
}
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_base_merges_nested_shape_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
super::super::generatorwithmerge::BASE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => {
|
||||
let merge = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("merge job payload boxed");
|
||||
assert_eq!(merge.shader_id, "mrg");
|
||||
assert_eq!(merge.iterations, 1);
|
||||
assert!(merge
|
||||
.params
|
||||
.contains_key(super::super::generatorwithmerge::BASE_INPUT));
|
||||
// The shape job is nested as the merge's blend texture.
|
||||
match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
|
||||
Some(NodeValue::Texture(blend)) => {
|
||||
let shape =
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(blend) }
|
||||
.expect("nested shape job payload boxed");
|
||||
assert_eq!(shape.shader_id, "shape");
|
||||
}
|
||||
_ => panic!("nested blend job expected"),
|
||||
}
|
||||
}
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Color input id (C++ `k_color_input`). Type: color; default
|
||||
/// `(1.0, 0.0, 0.0, 1.0)` (red — "a color that isn't black").
|
||||
@@ -77,12 +78,14 @@ impl NodeBehavior for SolidGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): pushes a texture job built
|
||||
/// from the whole input row at the sequence video params.
|
||||
/// Evaluate outputs (C++ `value()`): always pushes a shader job
|
||||
/// built from the whole input row, run at the sequence video params
|
||||
/// (the generator has no texture input to source params from).
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job is deferred to
|
||||
/// the renderer seam, so a null texture handle marks "renderer must
|
||||
/// produce this texture" (`// CPP-PARITY: solid.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params
|
||||
/// row carries the color uniform keyed by `color_in`
|
||||
/// (`// CPP-PARITY: solid.cpp` `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -90,10 +93,18 @@ impl NodeBehavior for SolidGenerator {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let _ = (core, inputs, time);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -156,16 +167,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_deferred_job() {
|
||||
fn value_pushes_shader_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
COLOR_INPUT.to_string(),
|
||||
NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&crate::value::NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
behavior.value(&core, &inputs, Rational::new(3, 1), &mut table);
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let job = unsafe {
|
||||
crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle)
|
||||
}
|
||||
.expect("solid output boxes a ShaderJobPayload");
|
||||
assert_eq!(
|
||||
job.type_id,
|
||||
"org.olivevideoeditor.Olive.solidgenerator"
|
||||
);
|
||||
assert_eq!(job.shader_id, "");
|
||||
assert_eq!(job.iterations, 1);
|
||||
assert_eq!(job.effect_input, "");
|
||||
assert_eq!(
|
||||
job.params.get(COLOR_INPUT).map(|v| v.to_double()),
|
||||
Some(0.0)
|
||||
);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -177,9 +178,10 @@ impl NodeBehavior for StrokeFilterNode {
|
||||
/// texture; otherwise push a shader job with `resolution_in` set to
|
||||
/// the texture's virtual resolution.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` value) is deferred to the renderer seam
|
||||
/// (`// CPP-PARITY: stroke.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; `resolution_in`
|
||||
/// is filled by the runner from the frame size, matching the C++
|
||||
/// `tex->virtual_resolution()` (`// CPP-PARITY: stroke.cpp` `value()`).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -202,9 +204,21 @@ impl NodeBehavior for StrokeFilterNode {
|
||||
};
|
||||
|
||||
if radius > 0.0 && opacity > 0.0 {
|
||||
// The shader-job box (C++ ShaderJob): the behavior's type id
|
||||
// selects the fragment source; the effect input key locates the
|
||||
// main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -360,7 +374,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_positive_radius_and_opacity_pushes_deferred_job() {
|
||||
fn value_positive_radius_and_opacity_pushes_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
@@ -369,7 +383,19 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => {
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.stroke");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert!(payload.params.contains_key(RADIUS_INPUT));
|
||||
}
|
||||
_ => panic!("texture expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -123,13 +124,14 @@ impl NodeBehavior for SwirlDistortNode {
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
|
||||
/// angle != 0.0 AND radius != 0.0 -> shader job over the whole value
|
||||
/// row with `resolution_in` inserted from the texture's virtual
|
||||
/// resolution; otherwise pass-through push of the input texture
|
||||
/// unchanged.
|
||||
/// row; otherwise pass-through push of the input texture unchanged.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` value) is deferred to the renderer seam
|
||||
/// (`// CPP-PARITY: swirldistortnode.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and uniforms, keyed by the effect input,
|
||||
/// and `resolution_in` is filled by the runner from the input
|
||||
/// texture's size, matching the C++ insert of the texture's virtual
|
||||
/// resolution (`// CPP-PARITY: swirldistortnode.cpp` value()).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -152,9 +154,21 @@ impl NodeBehavior for SwirlDistortNode {
|
||||
};
|
||||
|
||||
if angle != 0.0 && radius != 0.0 {
|
||||
// The shader-job box (C++ ShaderJob): the behavior's type id
|
||||
// selects the fragment source; the effect input key locates the
|
||||
// main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -336,7 +350,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_angle_and_radius_pushes_deferred_job() {
|
||||
fn value_angle_and_radius_pushes_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
@@ -345,7 +359,15 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("swirl output boxes a ShaderJobPayload");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.swirl");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -167,7 +168,6 @@ impl NodeBehavior for ThreeWayColorNode {
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let _ = (core, time);
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(crate::value::NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
@@ -178,12 +178,27 @@ impl NodeBehavior for ThreeWayColorNode {
|
||||
// project color manager's default luma coefficients when one is
|
||||
// attached — the Rust model has no project/manager access, so the
|
||||
// fallback always applies) into a ShaderJob over the whole input
|
||||
// row and pushes `tex->to_job(job)`. The Rust model has no
|
||||
// shader-job payload: the renderer seam resolves the deferred job
|
||||
// from this null handle.
|
||||
// row and pushes `tex->to_job(job)`. The job is boxed here as a
|
||||
// [`ShaderJobPayload`] that the renderer's resolve hook executes
|
||||
// and replaces with the result texture; the params row carries the
|
||||
// luma coefficients under the shader uniform name.
|
||||
let mut params = inputs.clone();
|
||||
params.insert(
|
||||
LUMA_COEFFICIENTS_INPUT.to_string(),
|
||||
crate::value::NodeValue::Vec3([0.2126, 0.7152, 0.0722]),
|
||||
);
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -348,7 +363,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_texture_pushes_deferred_shader_job() {
|
||||
fn value_with_texture_pushes_shader_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
@@ -356,7 +371,22 @@ mod tests {
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("payload boxed behind the handle");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.threewaycolor");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
// The luma coefficients are injected under the shader uniform name
|
||||
// (C++ `job.Insert("luma_coefficients_in", ...)`).
|
||||
assert_eq!(
|
||||
payload.params.get(LUMA_COEFFICIENTS_INPUT),
|
||||
Some(&NodeValue::Vec3([0.2126, 0.7152, 0.0722]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -218,13 +219,15 @@ impl NodeBehavior for TileDistortNode {
|
||||
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
|
||||
/// scale differs from 1.0 (an approximate-equality epsilon test:
|
||||
/// `abs(scale-1)*1e12 > min(abs(scale), 1)`) -> shader job over the
|
||||
/// whole value row with `resolution_in` inserted from the texture's
|
||||
/// virtual resolution; scale ~== 1.0 -> pass-through push of the
|
||||
/// input texture unchanged.
|
||||
/// whole value row; scale ~== 1.0 -> pass-through push of the input
|
||||
/// texture unchanged.
|
||||
///
|
||||
/// The Rust model has no shader-job payload: the job (including the
|
||||
/// `resolution_in` value) is deferred to the renderer seam
|
||||
/// (`// CPP-PARITY: tiledistortnode.cpp` value()).
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and uniforms, keyed by the effect input,
|
||||
/// and `resolution_in` is filled by the runner from the input
|
||||
/// texture's size, matching the C++ insert of the texture's virtual
|
||||
/// resolution (`// CPP-PARITY: tiledistortnode.cpp` value()).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -245,9 +248,21 @@ impl NodeBehavior for TileDistortNode {
|
||||
// `!qFuzzyCompare(scale, 1.0)` (double overload) — job when the
|
||||
// scale is not approximately 1.0.
|
||||
if (scale_value - 1.0).abs() * 1e12 > scale_value.abs().min(1.0) {
|
||||
// The shader-job box (C++ ShaderJob): the behavior's type id
|
||||
// selects the fragment source; the effect input key locates the
|
||||
// main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -432,13 +447,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_non_unit_scale_pushes_deferred_job() {
|
||||
fn value_non_unit_scale_pushes_job_payload() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(SCALE_INPUT, -1, NodeValue::Float(0.5));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("tile output boxes a ShaderJobPayload");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.tile");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Parent matrix input id (C++ `k_parent_input`). Type: matrix; no
|
||||
/// default (identity when unconnected).
|
||||
@@ -330,12 +331,15 @@ impl NodeBehavior for TransformDistortNode {
|
||||
/// identity matrix (or no texture) -> pass-through push of the
|
||||
/// input texture value.
|
||||
///
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and uniforms, keyed by the effect input.
|
||||
/// The real matrix needs the texture's params and the sequence
|
||||
/// resolution (the Rust texture handle carries no params and the
|
||||
/// value() signature no globals), so the identity check — and thus
|
||||
/// the pass-through-vs-job decision — is not representable here: with
|
||||
/// a texture the job is always queued for the renderer seam
|
||||
/// (`// CPP-PARITY: transformdistortnode.cpp` value()).
|
||||
/// a texture the job is always queued (`// CPP-PARITY:
|
||||
/// transformdistortnode.cpp` value()).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -362,16 +366,33 @@ impl NodeBehavior for TransformDistortNode {
|
||||
);
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(tex @ crate::value::NodeValue::Texture(_)) => {
|
||||
// C++ builds the auto-scaled real matrix and pushes a job
|
||||
// at the global video params binding `ove_maintex` /
|
||||
// `ove_mvpmat`; the deferred job is resolved by the
|
||||
// renderer seam (`// CPP-PARITY: transformdistortnode.cpp`
|
||||
// value()).
|
||||
let _ = tex;
|
||||
Some(crate::value::NodeValue::Texture(_)) => {
|
||||
// The shader-job box (C++ `Texture::Job(globals.vparams(),
|
||||
// job)`): the behavior's type id selects the fragment
|
||||
// source, and the effect input key locates the main
|
||||
// texture inside the params row. C++ also inserts
|
||||
// `ove_mvpmat` (the auto-scaled real matrix) and sets the
|
||||
// `ove_maintex` interpolation, but the matrix needs the
|
||||
// texture's params and the sequence resolution — neither
|
||||
// available here — so it is left absent and the runner
|
||||
// fills an identity `ove_mvpmat`; the C++ identity check
|
||||
// (pass-through when the real matrix is identity) is not
|
||||
// representable either, so the job is always queued with a
|
||||
// texture (`// CPP-PARITY: transformdistortnode.cpp`
|
||||
// value(); TODO: inject the real matrix from the renderer
|
||||
// seam, where the resolution data is available).
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: TEXTURE_INPUT.to_string(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -701,7 +722,18 @@ mod tests {
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
// Matrix output always pushed; texture job queued for the seam.
|
||||
assert!(table.get(ValueType::Matrix).is_some());
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let handle = match table.get(ValueType::Texture).unwrap() {
|
||||
NodeValue::Texture(h) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.transform");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.time, Rational::new(0, 1));
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -136,6 +137,13 @@ impl NodeBehavior for WaveDistortNode {
|
||||
/// intensity != 0.0 -> shader job over the whole value row rendered
|
||||
/// at the texture's own params; intensity == 0.0 -> pass-through
|
||||
/// push of the input texture unchanged.
|
||||
///
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and uniforms, keyed by the effect input.
|
||||
/// C++ renders the job at the texture's own params and inserts no
|
||||
/// `resolution_in` (the wave shader declares none), so the runner has
|
||||
/// nothing extra to fill (`// CPP-PARITY: wavedistortnode.cpp` value()).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
@@ -154,12 +162,21 @@ impl NodeBehavior for WaveDistortNode {
|
||||
};
|
||||
|
||||
if intensity != 0.0 {
|
||||
// C++ pushes `Texture::job(texture->params(), ShaderJob(value))`;
|
||||
// the deferred job is resolved by the renderer seam
|
||||
// (`// CPP-PARITY: wavedistortnode.cpp` value()).
|
||||
// The shader-job box (C++ ShaderJob): the behavior's type id
|
||||
// selects the fragment source; the effect input key locates the
|
||||
// main texture inside the params row.
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
@@ -287,7 +304,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_nonzero_intensity_pushes_deferred_job() {
|
||||
fn value_nonzero_intensity_pushes_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
@@ -295,7 +312,15 @@ mod tests {
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("wave output boxes a ShaderJobPayload");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.wave");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::nodes::jobs::ShaderJobPayload;
|
||||
|
||||
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
|
||||
/// not-keyframable; this is the node's effect input.
|
||||
@@ -183,17 +184,27 @@ impl NodeBehavior for WhiteBalanceNode {
|
||||
None => core.value_at_time(TINT_INPUT, -1, time).to_double(),
|
||||
};
|
||||
let gain = Self::gain_for_temperature(temperature, tint);
|
||||
let _ = gain;
|
||||
|
||||
// `// CPP-PARITY: whitebalance.cpp` `value()` — the C++ builds a
|
||||
// ShaderJob from the whole input row, inserts `wb_gain_in` as the
|
||||
// per-frame vec3 gain, and pushes `tex->to_job(job)`. The Rust
|
||||
// model has no shader-job payload: the renderer seam resolves the
|
||||
// deferred job from this null handle, recomputing the gain from
|
||||
// the same inputs.
|
||||
// per-frame vec3 gain, and pushes `tex->to_job(job)`. The job is
|
||||
// boxed here as a [`ShaderJobPayload`] that the renderer's resolve
|
||||
// hook executes and replaces with the result texture; the params
|
||||
// row carries the computed gain under the shader uniform name.
|
||||
let mut params = inputs.clone();
|
||||
params.insert(GAIN_INPUT.to_string(), crate::value::NodeValue::Vec3(gain));
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
@@ -374,7 +385,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_texture_pushes_deferred_shader_job() {
|
||||
fn value_with_texture_pushes_shader_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
@@ -382,7 +393,23 @@ mod tests {
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.get(ValueType::Texture).is_some());
|
||||
let NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::nodes::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("payload boxed behind the handle");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.whitebalance");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
// The computed per-frame gain is injected under the shader uniform
|
||||
// name, overriding any row value (C++ `job.Insert(k_gain_input, ...)`).
|
||||
let gain = payload.params.get(GAIN_INPUT).unwrap();
|
||||
let NodeValue::Vec3(g) = gain else {
|
||||
panic!("expected a vec3 gain");
|
||||
};
|
||||
assert_eq!(g, &WhiteBalanceNode::gain_for_temperature(6500.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -493,6 +493,21 @@ fn writer_text_chars(writer: &mut dyn XmlWrite, text: &str) {
|
||||
/// Load a project from XML text. Applies version upgrades in order;
|
||||
/// rejects versions newer than the build (C++ `k_project_too_new`).
|
||||
pub fn load(xml: &str) -> crate::error::Result<Arc<Mutex<Project>>> {
|
||||
Ok(load_with_id_map(xml)?.0)
|
||||
}
|
||||
|
||||
/// Deserialize a project and also return the source-identity -> loaded-id
|
||||
/// translation map built while loading (XML `ptr` -> [`NodeId`]).
|
||||
///
|
||||
/// `load` rebuilds the graph in file order and assigns fresh arena slots,
|
||||
/// so a node's identity in the saved project does not generally match its
|
||||
/// identity after loading (a gap left by any deleted slot shifts every
|
||||
/// later node). Callers that hold identities from the *saved* project —
|
||||
/// e.g. a render worker resolving a ticket's viewer node against the
|
||||
/// snapshot it loaded — must translate through this map.
|
||||
pub fn load_with_id_map(
|
||||
xml: &str,
|
||||
) -> crate::error::Result<(Arc<Mutex<Project>>, std::collections::HashMap<u64, NodeId>)> {
|
||||
use crate::error::Error;
|
||||
let mut reader = XmlReaderBridge::new(xml).ok_or(Error::Failed(
|
||||
"oakcommon XML reader unavailable".to_string(),
|
||||
@@ -534,11 +549,11 @@ pub fn load(xml: &str) -> crate::error::Result<Arc<Mutex<Project>>> {
|
||||
}
|
||||
|
||||
let project = Project::new();
|
||||
{
|
||||
let id_map = {
|
||||
let mut guard = lock(&project);
|
||||
load_project_body(&mut reader, &mut guard)?;
|
||||
}
|
||||
Ok(project)
|
||||
load_project_body(&mut reader, &mut guard)?
|
||||
};
|
||||
Ok((project, id_map))
|
||||
}
|
||||
|
||||
/// Parse the `<project>` body: uuid, nodes, settings. C++ full saves
|
||||
@@ -546,7 +561,10 @@ pub fn load(xml: &str) -> crate::error::Result<Arc<Mutex<Project>>> {
|
||||
/// ...</project><layout>...</layout></project>` — `// CPP-PARITY:
|
||||
/// serializer230220.cpp`); a nested `<project>` element is descended
|
||||
/// into transparently instead of skipped.
|
||||
fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::error::Result<()> {
|
||||
fn load_project_body(
|
||||
reader: &mut dyn XmlRead,
|
||||
project: &mut Project,
|
||||
) -> crate::error::Result<std::collections::HashMap<u64, NodeId>> {
|
||||
use crate::error::Error;
|
||||
// Identity -> NodeId map for connection resolution.
|
||||
let mut id_map: std::collections::HashMap<u64, NodeId> = std::collections::HashMap::new();
|
||||
@@ -644,7 +662,7 @@ fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::
|
||||
// reattach each child to its bin folder.
|
||||
resolve_folder_children(&mut project.graph, &id_map);
|
||||
|
||||
Ok(())
|
||||
Ok(id_map)
|
||||
}
|
||||
|
||||
/// Parse one `<node>` into the graph; returns its id.
|
||||
|
||||
@@ -19,9 +19,22 @@
|
||||
//! Key change from C++: no inheritance. C++ `RenderProcessor :
|
||||
//! NodeTraverser` overrode virtuals to plug rendering in; here the
|
||||
//! traverser is a free engine and oakrender supplies [`RenderHooks`].
|
||||
//! The graph is walked iteratively in topological order with an
|
||||
//! explicit value stack (the C++ recursive path could blow the stack
|
||||
//! on deep graphs — same order, no recursion).
|
||||
//!
|
||||
//! Evaluation is **time-aware and memoized per (node, time)**: a node's
|
||||
//! inputs may pull upstream values at adjusted times (the consuming
|
||||
//! node's `input_time_adjustment` — clips map sequence time to media
|
||||
//! time, tracks clamp to the covering block, C++
|
||||
//! `traverser.cpp` `ProcessInput`), so one evaluation pass can evaluate
|
||||
//! the same node at several times (keyed like the C++ `value_cache_`,
|
||||
//! which is per (node, range)). The walk is an explicit-stack DFS —
|
||||
//! 10k-deep chains must not blow the call stack (the earlier
|
||||
//! topological-order pass was recursion-free for the same reason).
|
||||
//!
|
||||
//! Input rows carry the C++ `GenerateRowValue` semantics: connected
|
||||
//! inputs take the upstream output (evaluated at the adjusted time);
|
||||
//! unconnected inputs take `NodeCore::value_at_time` — keyframe
|
||||
//! interpolation when the track is non-empty, else the standard value
|
||||
//! (C++ `ProcessInputElement` → `GetValueAtTime`).
|
||||
//! `// CPP-PARITY: src/node/src/traverser.cpp`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -75,18 +88,22 @@ impl EvalRequest {
|
||||
|
||||
/// The traversal engine.
|
||||
pub struct Traverser {
|
||||
/// Value stack / per-node row cache for this pass.
|
||||
stack: Vec<(NodeId, NodeValueTable)>,
|
||||
/// Nodes touched by the last [`Traverser::invalidate_downstream`]
|
||||
/// walk (observable for tests; the C++ fan-out has no return value).
|
||||
last_invalidation: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// DFS stack frame: `Enter` queues the upstream nodes, `Exit` builds the
|
||||
/// row and evaluates.
|
||||
enum Frame {
|
||||
Enter(NodeId, Rational),
|
||||
Exit(NodeId, Rational),
|
||||
}
|
||||
|
||||
impl Traverser {
|
||||
/// New empty engine (reusable across evaluations).
|
||||
pub fn new() -> Self {
|
||||
Traverser {
|
||||
stack: Vec::new(),
|
||||
last_invalidation: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -99,9 +116,9 @@ impl Traverser {
|
||||
/// Evaluate `request` against `graph`, calling `hooks` at the
|
||||
/// backend seams. Returns the root's output table.
|
||||
///
|
||||
/// Errors: `State` on cancellation, `Failed` on node evaluation
|
||||
/// errors (C++ returned empty tables; we surface the error —
|
||||
/// `// CPP-PARITY: traverser.cpp` behavior notes inline).
|
||||
/// Errors: `State` on cancellation, `NotFound` on an invalid root.
|
||||
/// Only nodes upstream of the root are evaluated (lazy — the C++
|
||||
/// recursion shares this property).
|
||||
pub fn evaluate(
|
||||
&mut self,
|
||||
graph: &Graph,
|
||||
@@ -113,72 +130,58 @@ impl Traverser {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
self.stack.clear();
|
||||
let order = graph.topological_order();
|
||||
// Per-pass memo: (node, time) -> evaluated output table. A shared
|
||||
// upstream evaluates once per requested time (C++ value_cache_).
|
||||
let mut cache: HashMap<(NodeId, Rational), NodeValueTable> = HashMap::new();
|
||||
let mut queued: HashSet<(NodeId, Rational)> = HashSet::new();
|
||||
let mut stack: Vec<Frame> = vec![Frame::Enter(request.root, request.time)];
|
||||
queued.insert((request.root, request.time));
|
||||
|
||||
// Per-node output tables for this pass (memoization: a shared
|
||||
// upstream evaluates once — `// CPP-PARITY: traverser.cpp`
|
||||
// process_node_children).
|
||||
let mut tables: HashMap<NodeId, NodeValueTable> = HashMap::new();
|
||||
|
||||
for node in order {
|
||||
while let Some(frame) = stack.pop() {
|
||||
if hooks.is_cancelled() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
|
||||
let entry = graph.get(node).ok_or(Error::NotFound)?;
|
||||
|
||||
// Build this node's input row from its upstream outputs. The
|
||||
// C++ picks the last value of the matching type per input;
|
||||
// the Rust model keys rows by input id. Inputs declared as
|
||||
// texture take the upstream texture directly (the scalar
|
||||
// chain below would otherwise hand a plugin node's tagged
|
||||
// param passthrough to a downstream clip input).
|
||||
let mut row: NodeValueRow = std::collections::BTreeMap::new();
|
||||
for (from, input_id, element) in graph.input_connections(node) {
|
||||
let _ = element;
|
||||
if let Some(from_table) = tables.get(&from) {
|
||||
let value = if entry.core.input_data_type(&input_id)
|
||||
== Some(ValueType::Texture)
|
||||
{
|
||||
from_table
|
||||
.get(ValueType::Texture)
|
||||
.cloned()
|
||||
.unwrap_or(NodeValue::None)
|
||||
} else {
|
||||
from_table
|
||||
.get(ValueType::Float)
|
||||
.or_else(|| from_table.get(ValueType::Int))
|
||||
.or_else(|| from_table.get(ValueType::Color))
|
||||
.or_else(|| from_table.get(ValueType::Vec2))
|
||||
.or_else(|| from_table.get(ValueType::Vec3))
|
||||
.or_else(|| from_table.get(ValueType::Vec4))
|
||||
.or_else(|| from_table.get(ValueType::Boolean))
|
||||
.or_else(|| from_table.get(ValueType::Rational))
|
||||
.or_else(|| from_table.get(ValueType::Text))
|
||||
.or_else(|| from_table.get(ValueType::Combo))
|
||||
.or_else(|| from_table.get(ValueType::StrCombo))
|
||||
.or_else(|| from_table.get(ValueType::Texture))
|
||||
.cloned()
|
||||
.unwrap_or(NodeValue::None)
|
||||
match frame {
|
||||
Frame::Enter(node, time) => {
|
||||
if cache.contains_key(&(node, time)) {
|
||||
continue;
|
||||
}
|
||||
let Some(entry) = graph.get(node) else {
|
||||
continue;
|
||||
};
|
||||
row.insert(input_id, value);
|
||||
stack.push(Frame::Exit(node, time));
|
||||
// Queue every connected upstream at its adjusted time.
|
||||
for (from, input, element) in graph.input_connections(node) {
|
||||
let from = entry
|
||||
.behavior
|
||||
.connected_render_output(&entry.core, &input, element)
|
||||
.unwrap_or(from);
|
||||
let adjusted = adjusted_time(entry, &input, element, time);
|
||||
let key = (from, adjusted);
|
||||
if !cache.contains_key(&key) && queued.insert(key) {
|
||||
stack.push(Frame::Enter(from, adjusted));
|
||||
}
|
||||
}
|
||||
}
|
||||
Frame::Exit(node, time) => {
|
||||
if cache.contains_key(&(node, time)) {
|
||||
continue;
|
||||
}
|
||||
let Some(entry) = graph.get(node) else {
|
||||
continue;
|
||||
};
|
||||
let row = build_row(graph, &cache, entry, node, time);
|
||||
let mut table = NodeValueTable::default();
|
||||
entry.behavior.value(&entry.core, &row, time, &mut table);
|
||||
hooks.resolve(node, &row, &mut table);
|
||||
cache.insert((node, time), table);
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate the node's behavior into its output table.
|
||||
let mut table = NodeValueTable::default();
|
||||
// The behavior writes outputs; the default no-op leaves the
|
||||
// table empty (C++ `Node::value` default).
|
||||
entry
|
||||
.behavior
|
||||
.value(&entry.core, &row, request.time, &mut table);
|
||||
hooks.resolve(node, &row, &mut table);
|
||||
|
||||
tables.insert(node, table);
|
||||
}
|
||||
|
||||
Ok(tables.remove(&request.root).unwrap_or_default())
|
||||
Ok(cache
|
||||
.remove(&(request.root, request.time))
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Invalidate walk: mark downstream caches dirty after an input
|
||||
@@ -201,10 +204,109 @@ impl Traverser {
|
||||
|
||||
impl Default for Traverser {
|
||||
fn default() -> Self {
|
||||
Traverser::new()
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The consuming node's time adjustment for `input` (C++
|
||||
/// `Node::InputTimeAdjustment` with `traverse = true`): clips map
|
||||
/// sequence time to media time, tracks clamp to the covering block. The
|
||||
/// trait speaks ranges; a video frame evaluates at a point, so the
|
||||
/// adjusted range's `in` is the upstream time.
|
||||
fn adjusted_time(
|
||||
entry: &crate::graph::NodeEntry,
|
||||
input: &str,
|
||||
element: i32,
|
||||
time: Rational,
|
||||
) -> Rational {
|
||||
entry
|
||||
.behavior
|
||||
.input_time_adjustment(input, element, TimeRange::new(time, time), true)
|
||||
.in_()
|
||||
}
|
||||
|
||||
/// Build the input row of `node` at `time` from the memoized upstream
|
||||
/// tables plus the standard/keyframed values of unconnected inputs
|
||||
/// (C++ `GenerateRowValue` + `ProcessInputElement`).
|
||||
fn build_row(
|
||||
graph: &Graph,
|
||||
cache: &HashMap<(NodeId, Rational), NodeValueTable>,
|
||||
entry: &crate::graph::NodeEntry,
|
||||
node: NodeId,
|
||||
time: Rational,
|
||||
) -> NodeValueRow {
|
||||
let mut row: NodeValueRow = std::collections::BTreeMap::new();
|
||||
let connections = graph.input_connections(node);
|
||||
for input in &entry.core.inputs {
|
||||
let id = input.id.as_str();
|
||||
let mut conns: Vec<(NodeId, i32)> = connections
|
||||
.iter()
|
||||
.filter(|(_, i, _)| i == id)
|
||||
.map(|(from, _, element)| (*from, *element))
|
||||
.collect();
|
||||
if conns.is_empty() {
|
||||
// Unconnected: keyframe interpolation when the track is
|
||||
// non-empty, else the standard value (C++ GetValueAtTime).
|
||||
row.insert(id.to_string(), entry.core.value_at_time(id, -1, time));
|
||||
continue;
|
||||
}
|
||||
// Array inputs (element >= 0): the consuming node may restrict
|
||||
// which elements are live at this time (C++
|
||||
// `GetActiveElementsAtTime` — a track pulls only the blocks
|
||||
// covering the frame). An empty answer means "no restriction".
|
||||
if conns.iter().any(|(_, e)| *e >= 0) {
|
||||
let active = entry.behavior.active_elements_at_time(id, time);
|
||||
if !active.is_empty() {
|
||||
conns.retain(|(_, e)| active.contains(e));
|
||||
}
|
||||
conns.sort_by_key(|(_, e)| *e);
|
||||
}
|
||||
for (from, element) in conns {
|
||||
let from = entry
|
||||
.behavior
|
||||
.connected_render_output(&entry.core, id, element)
|
||||
.unwrap_or(from);
|
||||
let upstream_time = adjusted_time(entry, id, element, time);
|
||||
let value = cache
|
||||
.get(&(from, upstream_time))
|
||||
.map(|t| pick_value(t, entry.core.input_data_type(id)))
|
||||
.unwrap_or(NodeValue::None);
|
||||
row.insert(id.to_string(), value);
|
||||
}
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// Pick the row value for an input of `data_type` from an upstream
|
||||
/// output table. Texture inputs take the upstream texture directly (the
|
||||
/// scalar chain would otherwise hand a plugin node's tagged param
|
||||
/// passthrough to a downstream clip input); everything else takes the
|
||||
/// last value of the first matching scalar type (C++ value-hint
|
||||
/// resolution's common case).
|
||||
fn pick_value(table: &NodeValueTable, data_type: Option<ValueType>) -> NodeValue {
|
||||
if data_type == Some(ValueType::Texture) {
|
||||
return table
|
||||
.get(ValueType::Texture)
|
||||
.cloned()
|
||||
.unwrap_or(NodeValue::None);
|
||||
}
|
||||
table
|
||||
.get(ValueType::Float)
|
||||
.or_else(|| table.get(ValueType::Int))
|
||||
.or_else(|| table.get(ValueType::Color))
|
||||
.or_else(|| table.get(ValueType::Vec2))
|
||||
.or_else(|| table.get(ValueType::Vec3))
|
||||
.or_else(|| table.get(ValueType::Vec4))
|
||||
.or_else(|| table.get(ValueType::Boolean))
|
||||
.or_else(|| table.get(ValueType::Rational))
|
||||
.or_else(|| table.get(ValueType::Text))
|
||||
.or_else(|| table.get(ValueType::Combo))
|
||||
.or_else(|| table.get(ValueType::StrCombo))
|
||||
.or_else(|| table.get(ValueType::Texture))
|
||||
.cloned()
|
||||
.unwrap_or(NodeValue::None)
|
||||
}
|
||||
|
||||
/// A value database: per-node input rows over a time range (C++
|
||||
/// `NodeValueDatabase`), exposed by the traverser ffi family.
|
||||
pub struct ValueDatabase {
|
||||
|
||||
@@ -919,6 +919,99 @@ fn multicam_node_round_trip_preserves_current_in() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A runtime-registered (dynamic) node type — the OFX plugin seam — is
|
||||
/// rebuilt from a snapshot by the serializer: a type id that lives only
|
||||
/// in the factory's dynamic table (the C++ `register_plugin_nodes`
|
||||
/// library entries) is rejected before registration and round-trips
|
||||
/// once registered. The worker process runs the identical plugin scan
|
||||
/// at startup (worker.rs `register_plugin_nodes`), so a snapshot
|
||||
/// carrying plugin nodes deserializes there the same way.
|
||||
#[test]
|
||||
fn dynamic_plugin_node_round_trips_across_load() {
|
||||
use oak_node::factory::{DynamicNodeMeta, Factory};
|
||||
use oak_node::input::Input;
|
||||
use oak_node::node::{Category, NodeBehavior, NodeCore};
|
||||
use oak_node::project::Project;
|
||||
use oak_node::value::{NodeValue, ValueType};
|
||||
|
||||
const TYPE_ID: &str = "org.test.dynamic-rebuild-probe";
|
||||
const INPUT_ID: &str = "probe_in";
|
||||
|
||||
// A pure-Rust stand-in for a discovered OFX plugin node: its type id
|
||||
// is unknown to the static menu table, so only the dynamic path of
|
||||
// `create_any` can construct it (mirroring the plugin closure that
|
||||
// captures the identifier).
|
||||
struct Probe;
|
||||
impl NodeBehavior for Probe {
|
||||
fn name(&self) -> &str {
|
||||
"Dynamic Probe"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
TYPE_ID
|
||||
}
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Probe))
|
||||
}
|
||||
}
|
||||
|
||||
// Build a project with one probe node carrying a standard value.
|
||||
let project = Project::new();
|
||||
// The dynamic constructor must rebuild the same declared inputs as
|
||||
// the original (a real plugin's `create_plugin_node` builds the core
|
||||
// from its OFX params).
|
||||
let probe_core = || {
|
||||
let mut core = NodeCore::new();
|
||||
core.inputs
|
||||
.push(Input::new(INPUT_ID, ValueType::Float, NodeValue::Float(0.0)));
|
||||
core
|
||||
};
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let id = p.graph.add_node(probe_core(), Box::new(Probe));
|
||||
p.graph
|
||||
.get_mut(id)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value(INPUT_ID, -1, NodeValue::Float(2.5));
|
||||
}
|
||||
let xml = {
|
||||
let p = project.lock().unwrap();
|
||||
oak_node::serializer::save(&p).unwrap()
|
||||
};
|
||||
assert!(xml.contains(TYPE_ID), "the dynamic type id is persisted");
|
||||
|
||||
// Before the plugin scan registers the entry the snapshot is
|
||||
// unreadable (the plugin is not installed in this process).
|
||||
assert!(oak_node::serializer::load(&xml).is_err());
|
||||
|
||||
// The scan registers the dynamic entry...
|
||||
let registered = Factory::global().register_dynamic(DynamicNodeMeta {
|
||||
type_id: TYPE_ID.to_string(),
|
||||
name: "Dynamic Probe".to_string(),
|
||||
categories: vec![Category::OpenFx],
|
||||
sub_category: "Filter".to_string(),
|
||||
description: "test probe".to_string(),
|
||||
create: std::sync::Arc::new(move || (probe_core(), Box::new(Probe))),
|
||||
});
|
||||
assert!(registered, "the probe type id was not registered before");
|
||||
|
||||
// ...and the same snapshot now rebuilds the node in-process, with
|
||||
// its type id and standard value intact.
|
||||
let loaded = oak_node::serializer::load(&xml).unwrap();
|
||||
let l = loaded.lock().unwrap();
|
||||
let probe = l
|
||||
.graph
|
||||
.node_ids()
|
||||
.into_iter()
|
||||
.find(|id| l.graph.get(*id).map(|e| e.behavior.type_id()) == Some(TYPE_ID))
|
||||
.expect("loaded project has the dynamic node");
|
||||
assert_eq!(
|
||||
l.graph.get(probe).unwrap().core.standard_value(INPUT_ID, -1),
|
||||
NodeValue::Float(2.5),
|
||||
"the standard value survives the rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
/// A clip with multicam enabled round-trips: the `current_in` value, the
|
||||
/// `sequence_in` edge, and the `sequence_type_in` selector all survive.
|
||||
#[test]
|
||||
|
||||
@@ -232,3 +232,124 @@ fn invalidation_fanout() {
|
||||
assert!(walked.contains(&a) && walked.contains(&d));
|
||||
let _ = NodeId::INVALID;
|
||||
}
|
||||
|
||||
/// A behavior that echoes its `val_in` row value into the table (probes
|
||||
/// what the traverser fed it).
|
||||
struct Echo;
|
||||
impl NodeBehavior for Echo {
|
||||
fn name(&self) -> &str {
|
||||
"Echo"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"test.echo"
|
||||
}
|
||||
fn duplicate(&self, _c: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Echo))
|
||||
}
|
||||
fn value(&self, _c: &NodeCore, inputs: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
|
||||
if let Some(v) = inputs.get("val_in") {
|
||||
table.push(ValueType::Float, v.clone(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconnected inputs are filled with the keyframe-interpolated value at
|
||||
/// the evaluation time (C++ GetValueAtTime): a linear 0→10 keyframe
|
||||
/// track read at its midpoint feeds 5.
|
||||
#[test]
|
||||
fn unconnected_input_evaluates_keyframes_at_time() {
|
||||
use oak_node::keyframe::{Keyframe, KeyframeTrack};
|
||||
let mut g = Graph::new();
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new("val_in", ValueType::Float, NodeValue::Float(0.0)));
|
||||
core.keyframe_track_mut("val_in", -1).set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(0.0),
|
||||
interpolation: oak_node::keyframe::Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
core.keyframe_track_mut("val_in", -1).set_key(Keyframe {
|
||||
time: Rational::new(10, 1),
|
||||
value: NodeValue::Float(10.0),
|
||||
interpolation: oak_node::keyframe::Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
let id = g.add_node(core, Box::new(Echo));
|
||||
|
||||
let mut t = Traverser::new();
|
||||
let mut hooks = Noop;
|
||||
let table = t
|
||||
.evaluate(&g, &EvalRequest::new(id, Rational::new(5, 1)), &mut hooks)
|
||||
.unwrap();
|
||||
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(5.0)));
|
||||
}
|
||||
|
||||
/// A connected input is evaluated at the consumer's adjusted time (C++
|
||||
/// InputTimeAdjustment with traverse=true): the consumer doubles the
|
||||
/// time, the upstream time-echo reports what it was evaluated at.
|
||||
#[test]
|
||||
fn connected_input_uses_adjusted_time() {
|
||||
struct TimeEcho;
|
||||
impl NodeBehavior for TimeEcho {
|
||||
fn name(&self) -> &str {
|
||||
"TimeEcho"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"test.timeecho"
|
||||
}
|
||||
fn duplicate(&self, _c: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(TimeEcho))
|
||||
}
|
||||
fn value(&self, _c: &NodeCore, _i: &NodeValueRow, t: Rational, table: &mut NodeValueTable) {
|
||||
table.push(ValueType::Rational, NodeValue::Rational(t), None);
|
||||
}
|
||||
}
|
||||
struct Doubler;
|
||||
impl NodeBehavior for Doubler {
|
||||
fn name(&self) -> &str {
|
||||
"Doubler"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"test.doubler"
|
||||
}
|
||||
fn duplicate(&self, _c: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Doubler))
|
||||
}
|
||||
fn input_time_adjustment(
|
||||
&self,
|
||||
input: &str,
|
||||
_element: i32,
|
||||
time: TimeRange,
|
||||
traverse: bool,
|
||||
) -> TimeRange {
|
||||
if input == "val_in" && traverse {
|
||||
TimeRange::new(time.in_() * Rational::new(2, 1), time.out() * Rational::new(2, 1))
|
||||
} else {
|
||||
time
|
||||
}
|
||||
}
|
||||
fn value(&self, _c: &NodeCore, inputs: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
|
||||
if let Some(v) = inputs.get("val_in") {
|
||||
table.push(ValueType::Rational, v.clone(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut g = Graph::new();
|
||||
let src = node_with_input(&mut g, Box::new(TimeEcho));
|
||||
let consumer = node_with_input(&mut g, Box::new(Doubler));
|
||||
g.connect(src, consumer, "val_in", -1).unwrap();
|
||||
|
||||
let mut t = Traverser::new();
|
||||
let mut hooks = Noop;
|
||||
let table = t
|
||||
.evaluate(&g, &EvalRequest::new(consumer, Rational::new(3, 1)), &mut hooks)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table.get(ValueType::Rational),
|
||||
Some(&NodeValue::Rational(Rational::new(6, 1))),
|
||||
"the upstream was evaluated at the doubled time"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ oak-node = { path = "../oak-node" }
|
||||
# direct replacement for the C++ liboakgl2/liboakvulkan backend plugins.
|
||||
# Version 25 (2025 stable line); the only GPU dependency.
|
||||
wgpu = "25"
|
||||
# naga: GLSL → WGSL translation for the node shaders (the embedded GLSL
|
||||
# stays the single source of truth, matching the C++ Vulkan backend's
|
||||
# mechanical-conversion approach). Pinned to wgpu 25's naga generation;
|
||||
# only the GLSL frontend and WGSL writer are compiled.
|
||||
naga = { version = "25", default-features = false, features = ["glsl-in", "wgsl-out"] }
|
||||
# ocio-rs: safe Rust bindings for OpenColorIO v2.5.2 — the ColorProcessor
|
||||
# implementation; OCIO is never rewritten. The `bundled` feature compiles
|
||||
# the vendored OpenColorIO C++ sources (cmake/ninja required); without it
|
||||
|
||||
@@ -106,6 +106,7 @@ fn main() {
|
||||
time: Rational::new(frame, 25),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer: 1,
|
||||
project: String::new(),
|
||||
time: Rational::new(frame, 25),
|
||||
force_size: Some((width, height)),
|
||||
force_format: None,
|
||||
|
||||
@@ -108,6 +108,7 @@ fn main() {
|
||||
time: Rational::new(frame, 25),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer: 1,
|
||||
project: String::new(),
|
||||
time: Rational::new(frame, 25),
|
||||
force_size: Some((width, height)),
|
||||
force_format: None,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -188,6 +188,12 @@ pub struct GpuContext {
|
||||
textures: Mutex<HashMap<u64, GpuTexture>>,
|
||||
next_token: AtomicU64,
|
||||
blit: Mutex<Option<wgpu::RenderPipeline>>,
|
||||
/// FLOAT32_FILTERABLE was available (linear sampling on F32 textures).
|
||||
filterable: bool,
|
||||
/// Compiled effect pipelines, keyed by the shaderfx cache key.
|
||||
programs: Mutex<HashMap<String, Arc<ShaderProgram>>>,
|
||||
/// The lazily created 1×1 placeholder texture (unconnected inputs).
|
||||
placeholder: Mutex<Option<u64>>,
|
||||
}
|
||||
|
||||
// SAFETY check: wgpu Device/Queue/Instance are Send+Sync; the rest is
|
||||
@@ -215,10 +221,19 @@ impl GpuContext {
|
||||
Err(_) => continue, // try the next backend in the fallback order
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
// Linear sampling on Rgba32Float needs FLOAT32_FILTERABLE
|
||||
// (widely available on desktop GPUs); without it effect
|
||||
// shaders sample nearest — a quality degradation, not a
|
||||
// failure (logged once by the shaderfx runner).
|
||||
let filterable = adapter.features().contains(wgpu::Features::FLOAT32_FILTERABLE);
|
||||
let mut required_features = wgpu::Features::empty();
|
||||
if filterable {
|
||||
required_features |= wgpu::Features::FLOAT32_FILTERABLE;
|
||||
}
|
||||
let (device, queue) =
|
||||
match pollster_block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("oakrender"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_features,
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::default(),
|
||||
trace: wgpu::Trace::Off,
|
||||
@@ -239,6 +254,9 @@ impl GpuContext {
|
||||
textures: Mutex::new(HashMap::new()),
|
||||
next_token: AtomicU64::new(1),
|
||||
blit: Mutex::new(None),
|
||||
filterable,
|
||||
programs: Mutex::new(HashMap::new()),
|
||||
placeholder: Mutex::new(None),
|
||||
}));
|
||||
}
|
||||
None
|
||||
@@ -556,8 +574,332 @@ impl GpuContext {
|
||||
});
|
||||
Ok(pipeline)
|
||||
}
|
||||
|
||||
/// The process-wide shared context (lazy; `None` when no adapter is
|
||||
/// available — callers then take the CPU fallback). The effect/montage
|
||||
/// evaluation path renders through this context; the backend choice
|
||||
/// follows the user's `GraphicsBackend` config (`OAK_RENDER_BACKEND`
|
||||
/// overrides). `DisplayRenderer::init` adopts it too, so a process
|
||||
/// owns exactly one wgpu device.
|
||||
pub fn shared() -> Option<Arc<GpuContext>> {
|
||||
static SHARED: std::sync::OnceLock<Option<Arc<GpuContext>>> = std::sync::OnceLock::new();
|
||||
SHARED
|
||||
.get_or_init(|| Self::create(BackendKind::from_user_config()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// True when the device can linear-sample Rgba32Float textures
|
||||
/// (FLOAT32_FILTERABLE). Effect shaders use a filtering sampler when
|
||||
/// true, nearest otherwise.
|
||||
pub fn is_filterable(&self) -> bool {
|
||||
self.filterable
|
||||
}
|
||||
|
||||
/// The context's shared 1×1 transparent placeholder texture, created
|
||||
/// on first use: unconnected effect inputs bind it (C++ binds texture
|
||||
/// id 0 — an empty texture — the same way).
|
||||
pub fn placeholder_texture(&self) -> Result<u64> {
|
||||
let mut slot = lock(&self.placeholder);
|
||||
if let Some(token) = *slot {
|
||||
return Ok(token);
|
||||
}
|
||||
let token = self.create_texture(1, 1)?;
|
||||
let mut pod = VideoParamsPod::default();
|
||||
pod.width = 1;
|
||||
pod.height = 1;
|
||||
pod.format = PixelFormat::F32 as i32;
|
||||
let mut frame = Frame::new();
|
||||
frame.set_video_params(pod);
|
||||
frame.allocate();
|
||||
self.upload(token, &frame)?;
|
||||
*slot = Some(token);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Compile (or fetch from the cache) an effect pass: the translated
|
||||
/// fragment WGSL (`shaderfx::translate` output, entry point `main`)
|
||||
/// paired with the fixed fullscreen-triangle vertex stage. `key`
|
||||
/// identifies the shader program in the context cache (include the
|
||||
/// filtering mode when it varies for the same shader). Pipeline
|
||||
/// creation runs under a validation error scope so a bad shader is a
|
||||
/// fallible result, not a device loss.
|
||||
pub fn compile_shader_pass(
|
||||
&self,
|
||||
key: &str,
|
||||
wgsl: &str,
|
||||
texture_count: u32,
|
||||
has_uniforms: bool,
|
||||
filtering: bool,
|
||||
) -> Result<Arc<ShaderProgram>> {
|
||||
if let Some(p) = lock(&self.programs).get(key) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
|
||||
// Bind group layout, mirroring shaderfx's binding assignment:
|
||||
// binding 0 = the uniform block (when present), then each input
|
||||
// texture as a (texture, sampler) pair.
|
||||
let mut entries = Vec::new();
|
||||
if has_uniforms {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let sample_type = wgpu::TextureSampleType::Float {
|
||||
filterable: self.filterable && filtering,
|
||||
};
|
||||
for i in 0..texture_count {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 1 + 2 * i,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 2 + 2 * i,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(if self.filterable && filtering {
|
||||
wgpu::SamplerBindingType::Filtering
|
||||
} else {
|
||||
wgpu::SamplerBindingType::NonFiltering
|
||||
}),
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let layout = self
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("oakrender-fx-layout"),
|
||||
entries: &entries,
|
||||
});
|
||||
|
||||
let vs_module = self
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("oakrender-fx-vs"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(EFFECT_VS_WGSL)),
|
||||
});
|
||||
let fs_module = self
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("oakrender-fx-fs"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Owned(wgsl.to_string())),
|
||||
});
|
||||
let pipeline_layout = self
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("oakrender-fx-pipeline-layout"),
|
||||
bind_group_layouts: &[&layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
self.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let pipeline = self
|
||||
.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("oakrender-fx"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs_module,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs_module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
if let Some(err) = pollster_block_on(self.device.pop_error_scope()) {
|
||||
return Err(Error::Failed(format!("effect pipeline validation failed: {err}")));
|
||||
}
|
||||
|
||||
let program = Arc::new(ShaderProgram {
|
||||
pipeline,
|
||||
layout,
|
||||
texture_count,
|
||||
has_uniforms,
|
||||
filtering,
|
||||
});
|
||||
lock(&self.programs).insert(key.to_string(), program.clone());
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
/// Run one effect pass: fragment-shade `dst` from `textures[0]` (the
|
||||
/// main input) plus any extra input textures, with `uniforms` as the
|
||||
/// packed std140 block (see [`crate::shaderfx::pack_uniforms`]).
|
||||
pub fn run_shader_pass(
|
||||
&self,
|
||||
program: &ShaderProgram,
|
||||
uniforms: &[u8],
|
||||
textures: &[u64],
|
||||
dst: u64,
|
||||
) -> Result<()> {
|
||||
if textures.len() != program.texture_count as usize {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let dst_tex = lock(&self.textures)
|
||||
.get(&dst)
|
||||
.cloned()
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
let uniform_buffer = if program.has_uniforms {
|
||||
let size = uniforms.len().max(16) as u64;
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("oakrender-fx-uniforms"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
if !uniforms.is_empty() {
|
||||
self.queue.write_buffer(&buffer, 0, uniforms);
|
||||
}
|
||||
Some(buffer)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("oakrender-fx-sampler"),
|
||||
mag_filter: if self.filterable && program.filtering {
|
||||
wgpu::FilterMode::Linear
|
||||
} else {
|
||||
wgpu::FilterMode::Nearest
|
||||
},
|
||||
min_filter: if self.filterable && program.filtering {
|
||||
wgpu::FilterMode::Linear
|
||||
} else {
|
||||
wgpu::FilterMode::Nearest
|
||||
},
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut bg_entries: Vec<wgpu::BindGroupEntry> = Vec::new();
|
||||
if let Some(buffer) = &uniform_buffer {
|
||||
bg_entries.push(wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
});
|
||||
}
|
||||
// Texture views must outlive the bind group creation.
|
||||
let views: Vec<wgpu::TextureView> = textures
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let reg = lock(&self.textures);
|
||||
let tex = reg.get(t).ok_or(Error::NotFound)?;
|
||||
Ok(tex
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
for (i, view) in views.iter().enumerate() {
|
||||
bg_entries.push(wgpu::BindGroupEntry {
|
||||
binding: 1 + 2 * i as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
});
|
||||
bg_entries.push(wgpu::BindGroupEntry {
|
||||
binding: 2 + 2 * i as u32,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
});
|
||||
}
|
||||
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("oakrender-fx-bg"),
|
||||
layout: &program.layout,
|
||||
entries: &bg_entries,
|
||||
});
|
||||
|
||||
let dst_view = dst_tex
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("oakrender-fx"),
|
||||
});
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("oakrender-fx-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &dst_view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
pass.set_pipeline(&program.pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
self.queue.submit(Some(encoder.finish()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A compiled effect pass: the translated fragment WGSL paired with the
|
||||
/// fixed fullscreen-triangle vertex stage, plus its bind group layout.
|
||||
pub struct ShaderProgram {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Input texture count (each binds a (texture, sampler) pair).
|
||||
pub texture_count: u32,
|
||||
/// Whether the shader declares the uniform block (binding 0).
|
||||
pub has_uniforms: bool,
|
||||
/// Whether the input samplers filter (subject to FLOAT32_FILTERABLE).
|
||||
pub filtering: bool,
|
||||
}
|
||||
|
||||
/// The fixed vertex stage for effect passes: a fullscreen triangle
|
||||
/// emitting `ove_texcoord`-convention UVs at location 0. UV v=0 is the
|
||||
/// first texture data row (the upload/download row order), so effect
|
||||
/// passes are pixel-identity with the CPU pipeline — no vertical flip
|
||||
/// anywhere in the chain.
|
||||
const EFFECT_VS_WGSL: &str = r#"
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
|
||||
var pos = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>(3.0, -1.0),
|
||||
vec2<f32>(-1.0, 3.0),
|
||||
);
|
||||
let p = pos[vi];
|
||||
return VsOut(vec4<f32>(p, 0.0, 1.0), vec2<f32>(0.5 + 0.5 * p.x, 0.5 - 0.5 * p.y));
|
||||
}
|
||||
"#;
|
||||
|
||||
|
||||
impl GpuContextLike for GpuContext {
|
||||
fn kind(&self) -> BackendKind {
|
||||
self.kind()
|
||||
@@ -675,11 +1017,21 @@ impl DisplayRenderer {
|
||||
/// Initialize: create the GPU context for the configured backend.
|
||||
/// `gl_context` must be null — a foreign OpenGL context cannot be
|
||||
/// adopted by wgpu (documented limitation).
|
||||
///
|
||||
/// When the requested backend matches the user's configured choice
|
||||
/// (the common case), the process-wide shared context
|
||||
/// ([`GpuContext::shared`]) is adopted so the process owns exactly
|
||||
/// one wgpu device; an explicit different backend gets its own
|
||||
/// context.
|
||||
pub fn init(&mut self, gl_context: *mut std::ffi::c_void) -> Result<()> {
|
||||
if !gl_context.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
self.ctx = GpuContext::create(self.backend);
|
||||
self.ctx = if self.backend == BackendKind::from_user_config() {
|
||||
GpuContext::shared()
|
||||
} else {
|
||||
GpuContext::create(self.backend)
|
||||
};
|
||||
if self.ctx.is_none() {
|
||||
return Err(Error::Failed("no GPU adapter available".into()));
|
||||
}
|
||||
@@ -1025,6 +1377,78 @@ mod tests {
|
||||
ctx.destroy_texture(dst);
|
||||
}
|
||||
|
||||
/// End-to-end effect pass: a translated node shader (gain multiply)
|
||||
/// runs through `compile_shader_pass`/`run_shader_pass` and the
|
||||
/// readback matches the expected pixels exactly.
|
||||
#[test]
|
||||
fn gpu_effect_pass_runs_translated_shader() {
|
||||
let Some(ctx) = any_gpu() else {
|
||||
eprintln!("no adapter; skipping effect pass");
|
||||
return;
|
||||
};
|
||||
let glsl = r#"
|
||||
uniform sampler2D tex_in;
|
||||
uniform float gain_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
frag_color = texture(tex_in, ove_texcoord) * gain_in;
|
||||
}
|
||||
"#;
|
||||
let translated = crate::shaderfx::translate(glsl).unwrap();
|
||||
let program = ctx
|
||||
.compile_shader_pass(
|
||||
"test-gain",
|
||||
&translated.wgsl,
|
||||
translated.textures.len() as u32,
|
||||
!translated.uniforms.is_empty(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut row = oak_node::value::NodeValueRow::new();
|
||||
row.insert("gain_in".into(), oak_node::value::NodeValue::Float(0.5));
|
||||
let uniforms = crate::shaderfx::pack_uniforms(&translated, &row);
|
||||
|
||||
let w = 4;
|
||||
let h = 2;
|
||||
let src = ctx.create_texture(w, h).unwrap();
|
||||
let dst = ctx.create_texture(w, h).unwrap();
|
||||
let mut frame = Frame::new();
|
||||
let mut pod = VideoParamsPod::default();
|
||||
pod.width = w;
|
||||
pod.height = h;
|
||||
frame.set_video_params(pod);
|
||||
frame.allocate();
|
||||
// Distinct values per pixel (F32 RGBA): 0.2/0.4/0.6/1.0 shifted
|
||||
// per pixel, so a UV mixup would be visible.
|
||||
for px in 0..(w * h) as usize {
|
||||
for c in 0..4 {
|
||||
let v = 0.2 + 0.1 * (px + c) as f32;
|
||||
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
|
||||
.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
ctx.upload(src, &frame).unwrap();
|
||||
ctx.run_shader_pass(&program, &uniforms, &[src], dst).unwrap();
|
||||
let out = ctx.download(dst).unwrap();
|
||||
for px in 0..(w * h) as usize {
|
||||
for c in 0..4 {
|
||||
let at = (px * 4 + c) * 4;
|
||||
let got = f32::from_le_bytes(out.data[at..at + 4].try_into().unwrap());
|
||||
let want = (0.2 + 0.1 * (px + c) as f32) * 0.5;
|
||||
assert!(
|
||||
(got - want).abs() < 1e-6,
|
||||
"px {px} ch {c}: got {got}, want {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.destroy_texture(src);
|
||||
ctx.destroy_texture(dst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_missing_texture_errors() {
|
||||
let Some(ctx) = any_gpu() else {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! OpenColorIO v2.5.2, bundled real-OCIO build). OCIO is never rewritten —
|
||||
//! this module maps the C++ call surface onto ocio-rs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use oak_core::PixelFormat;
|
||||
@@ -524,6 +525,78 @@ pub fn config_path() -> Option<String> {
|
||||
))
|
||||
}
|
||||
|
||||
// ---- OCIO GPU function shaders (C++ colormanagement.cpp GetColorContext) --
|
||||
|
||||
/// Cache of generated GLSL stubs, keyed by function name + color spaces +
|
||||
/// config cache id (so a swapped test config re-generates). `None` entries
|
||||
/// are cached too — the lookup result is deterministic per key.
|
||||
static OCIO_STUB_CACHE: LazyLock<Mutex<HashMap<String, Option<String>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// The GLSL ES 3.0 shader text implementing the OCIO function `fn_name`
|
||||
/// between `from_space` and `to_space` (both resolved as color-space names
|
||||
/// or roles, exactly like the C++ `getProcessor(src, dst)` calls).
|
||||
///
|
||||
/// This mirrors the C++ OCIO-node shader path: the node's
|
||||
/// `GenerateProcessor` builds a `ColorTransform` between two spaces and
|
||||
/// `GetShaderCode` receives the auto-generated stub from
|
||||
/// `Renderer::GetColorContext` (colormanagement.cpp), which the node then
|
||||
/// splices into its fragment shader at the `%1` marker.
|
||||
///
|
||||
/// `None` when no default config exists or the processor is LUT-based:
|
||||
/// `extractGpuShaderInfo` reports lookup textures that the C++ renderer
|
||||
/// uploads per LUT (the loops right after `GetColorContext`), but the Rust
|
||||
/// renderer has no such upload path, so those transforms are skipped and
|
||||
/// the node passes through.
|
||||
pub fn ocio_function_shader(fn_name: &str, from_space: &str, to_space: &str) -> Option<String> {
|
||||
let config = default_config()?;
|
||||
let cache_key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
fn_name,
|
||||
from_space,
|
||||
to_space,
|
||||
config.cache_id().unwrap_or_default()
|
||||
);
|
||||
if let Some(hit) = OCIO_STUB_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&cache_key)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
let stub = build_ocio_function_shader(&config, fn_name, from_space, to_space);
|
||||
OCIO_STUB_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(cache_key, stub.clone());
|
||||
stub
|
||||
}
|
||||
|
||||
/// Build (but do not cache) the GLSL stub for `fn_name` between the two
|
||||
/// spaces (C++ `GpuShaderDesc::CreateShaderDesc` + `setLanguage` +
|
||||
/// `extractGpuShaderInfo`, colormanagement.cpp `GetColorContext`).
|
||||
fn build_ocio_function_shader(
|
||||
config: &SafeConfig,
|
||||
fn_name: &str,
|
||||
from_space: &str,
|
||||
to_space: &str,
|
||||
) -> Option<String> {
|
||||
let processor = config.processor(from_space, to_space).ok()?;
|
||||
let gpu = processor.default_gpu_processor().ok()?;
|
||||
let mut desc = ocio_rs::GpuShaderDesc::create().ok()?;
|
||||
desc.set_language(ocio_rs::GpuLanguage::GlslEs3_0).ok()?;
|
||||
desc.set_function_name(fn_name).ok()?;
|
||||
desc.set_resource_prefix("ocio_").ok()?;
|
||||
gpu.try_extract_shader_info(&mut desc).ok()?;
|
||||
// LUT-based processors need their 1D/3D textures uploaded (the C++
|
||||
// `GetColorContext` caller loops over `getNum3DTextures`/`getNumTextures`
|
||||
// right after extraction); without a LUT upload path these cannot render.
|
||||
if desc.num_textures() > 0 || desc.num_3d_textures() > 0 {
|
||||
return None;
|
||||
}
|
||||
desc.shader_text()
|
||||
}
|
||||
|
||||
// ---- LUT library (C++ LUTLibrary) ------------------------------------------
|
||||
|
||||
/// Supported LUT extensions (C++ `LUTLibrary::supported_extensions()`).
|
||||
@@ -627,8 +700,7 @@ mod tests {
|
||||
let _lock = config_lock();
|
||||
if set_up_default_config().is_err() {
|
||||
return;
|
||||
}
|
||||
// Create via the built-in config; a valid processor must exist for
|
||||
} // Create via the built-in config; a valid processor must exist for
|
||||
// the ACES scene→display-encoded pairing and must preserve alpha.
|
||||
let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal);
|
||||
let p = p.expect("processor handle always returned");
|
||||
@@ -891,5 +963,51 @@ mod tests {
|
||||
let p = ColorProcessor::create_lut("/nonexistent/never.cube", Direction::Normal).unwrap();
|
||||
assert!(!p.is_valid(), "unreadable LUT → pass-through processor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_function_shader_generates_glsl_for_chromakey() {
|
||||
let _lock = config_lock();
|
||||
if set_up_default_config().is_err() {
|
||||
return; // Bundled OCIO missing (e.g. stub build): skip.
|
||||
}
|
||||
let stub = ocio_function_shader(
|
||||
"SceneLinearToCIEXYZ_d65",
|
||||
"scene_linear",
|
||||
"cie_xyz_d65_interchange",
|
||||
)
|
||||
.expect("default config generates an analytic shader");
|
||||
assert!(stub.contains("SceneLinearToCIEXYZ_d65"), "function name present");
|
||||
assert!(!stub.contains("sampler"), "no LUT upload expected in the default config");
|
||||
// Cache hit: a repeated call returns the same text.
|
||||
let again = ocio_function_shader(
|
||||
"SceneLinearToCIEXYZ_d65",
|
||||
"scene_linear",
|
||||
"cie_xyz_d65_interchange",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stub, again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_function_shader_rejects_lut_processors() {
|
||||
let _lock = config_lock();
|
||||
// studio-config's Rec.709 display is a CLF LUT chain (unlike the
|
||||
// analytic sRGB one); without a LUT upload path it must be refused.
|
||||
let Ok(cfg) = ocio_rs::Config::create_from_builtin_config(
|
||||
"studio-config-v2.1.0_aces-v1.3_ocio-v2.3",
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let cfg = SafeConfig(cfg);
|
||||
assert!(
|
||||
build_ocio_function_shader(&cfg, "probe", "ACEScg", "Rec.709 - Display").is_none(),
|
||||
"LUT-based processor must be refused"
|
||||
);
|
||||
// A pure-matrix pairing on the same config still generates.
|
||||
assert!(
|
||||
build_ocio_function_shader(&cfg, "probe", "ACEScg", "ACES2065-1").is_some(),
|
||||
"analytic processor still generates"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+712
-13
@@ -19,15 +19,15 @@
|
||||
//! implementing oaknode's `RenderHooks`. Each C++ `process_*` virtual
|
||||
//! is one hook method.
|
||||
//!
|
||||
//! This pass implements the CPU-side, graph-free parts of the hooks:
|
||||
//! frame generation and color transforms run fully; plugin jobs
|
||||
//! dispatch through the executor slot oakplugin installs
|
||||
//! ([`set_plugin_executor`]); footage decode, shader execution and the
|
||||
//! disk frame-cache payload I/O depend on the oakcodec / oakplugin C
|
||||
//! ABIs and fail with explainable errors (their success-path tests are
|
||||
//! `#[ignore]`d).
|
||||
//! This pass implements the graph hooks: frame generation runs fully;
|
||||
//! plugin jobs dispatch through the executor slot oakplugin installs
|
||||
//! ([`set_plugin_executor`]); footage jobs decode through the oakcodec
|
||||
//! decoder bridge; shader jobs execute on the shared GPU context
|
||||
//! ([`crate::backend::GpuContext`], falling back to an input pass-
|
||||
//! through when no adapter is available); color transforms by identity
|
||||
//! and the disk frame-cache payload I/O remain deferred.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_codec::decoder::{
|
||||
CodecStream, Decoder as _, RenderMode, RetrieveAudioStatus, RetrieveVideoParams,
|
||||
@@ -35,12 +35,42 @@ use oak_codec::decoder::{
|
||||
};
|
||||
use oak_codec::ffmpeg::FFmpegDecoder;
|
||||
use oak_core::{PixelFormat, Rational, TimeRange};
|
||||
use oak_node::nodes::jobs::{FootageJobPayload, ShaderJobPayload};
|
||||
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::frame::VideoParamsPod;
|
||||
use crate::shaderfx::{compile_effect, run_effect};
|
||||
use crate::texture::{Frame, Texture};
|
||||
|
||||
/// Static mapping of OCIO-based node shaders to the OCIO function they
|
||||
/// splice at their `%1` marker: (node type id, OCIO function name, source
|
||||
/// space/role, destination space/role) — the C++ `GenerateProcessor`
|
||||
/// `ColorTransform` pairs, resolved by the OCIO config at runtime.
|
||||
///
|
||||
/// Only the node's *function* (the GPU stub) is requested here; the
|
||||
/// processor is built against the manager's reference color space
|
||||
/// (C++ chromakey.cpp `GenerateProcessor` uses `GetReferenceColorSpace`,
|
||||
/// which `ColorManager` sets to `OCIO::ROLE_SCENE_LINEAR`,
|
||||
/// colormanager.cpp).
|
||||
pub const OCIO_SHADER_STUBS: &[(&str, &str, &str, &str)] = &[(
|
||||
"org.olivevideoeditor.Olive.chromakey",
|
||||
"SceneLinearToCIEXYZ_d65",
|
||||
"scene_linear",
|
||||
"cie_xyz_d65_interchange",
|
||||
)];
|
||||
|
||||
/// The OCIO GPU function shader for `type_id` (the `%1` stub), or `None`
|
||||
/// when the node is not OCIO-based or the processor cannot be generated
|
||||
/// (no default config, or a LUT processor the renderer cannot upload).
|
||||
pub fn ocio_stub_for(type_id: &str) -> Option<String> {
|
||||
let (_, fn_name, from, to) = OCIO_SHADER_STUBS
|
||||
.iter()
|
||||
.find(|(id, ..)| *id == type_id)
|
||||
.copied()?;
|
||||
crate::color::ocio_function_shader(fn_name, from, to)
|
||||
}
|
||||
|
||||
/// Job specification: the closed set of C++ `*Job` payloads
|
||||
/// (AcceleratedJob family) as internal evaluation records — jobs no
|
||||
/// longer travel inside values across module boundaries.
|
||||
@@ -101,6 +131,11 @@ pub struct RenderEvalHooks {
|
||||
pub use_cache: bool,
|
||||
/// Active ticket identity (for cancellation polling).
|
||||
pub ticket: Option<crate::ticket::TicketId>,
|
||||
/// Forced output size for resolved footage jobs; `None` decodes at
|
||||
/// the media's native size (C++ `RenderProcessor` requests the
|
||||
/// texture at the output resolution). The graph-sequence driver sets
|
||||
/// this to the sequence frame size.
|
||||
pub frame_size: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -202,6 +237,7 @@ impl RenderEvalHooks {
|
||||
Self {
|
||||
use_cache: false,
|
||||
ticket: None,
|
||||
frame_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +441,260 @@ impl RenderEvalHooks {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the footage payloads a footage node pushed into its output
|
||||
/// table (C++ FootageJob processing in jobmanager.cpp): decodes each
|
||||
/// boxed [`FootageJobPayload`] at its request time and replaces the
|
||||
/// box with the resulting texture. Genuine textures pass through.
|
||||
fn resolve_footage_jobs(&mut self, table: &mut NodeValueTable) {
|
||||
let size = self.frame_size.unwrap_or((0, 0));
|
||||
for (_, value, _) in table.rows_mut() {
|
||||
let NodeValue::Texture(handle) = value else {
|
||||
continue;
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
continue;
|
||||
}
|
||||
let payload = unsafe {
|
||||
oak_node::handle::get_checked::<FootageJobPayload>(handle)
|
||||
}
|
||||
.cloned();
|
||||
let Some(payload) = payload else {
|
||||
continue;
|
||||
};
|
||||
match render_footage_frame(
|
||||
&payload.filename,
|
||||
payload.stream_index,
|
||||
payload.time,
|
||||
size,
|
||||
PixelFormat::F32,
|
||||
) {
|
||||
Ok(texture) => {
|
||||
*value = NodeValue::Texture(oak_node::handle::make_owned(texture));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("footage job decode failed: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the shader payloads an effect node pushed into its output
|
||||
/// table (C++ ShaderJob processing in jobmanager.cpp): execute each
|
||||
/// boxed [`ShaderJobPayload`] on the shared GPU context and replace
|
||||
/// the box with the result texture. Failed or un-runnable jobs fall
|
||||
/// back to the effect input texture from the params row (a pass-
|
||||
/// through — C++ leaves the failed shader's output as its input);
|
||||
/// a missing input resolves to `NodeValue::None`.
|
||||
fn resolve_shader_jobs(&mut self, table: &mut NodeValueTable) {
|
||||
// Collect the boxes up front: replacing a row while iterating
|
||||
// `rows_mut` would alias the table.
|
||||
let jobs: Vec<(usize, ShaderJobPayload)> = table
|
||||
.rows_mut()
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter_map(|(i, (_, value, _))| {
|
||||
let NodeValue::Texture(handle) = value else {
|
||||
return None;
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
return None;
|
||||
}
|
||||
let payload = unsafe {
|
||||
oak_node::handle::get_checked::<ShaderJobPayload>(handle)
|
||||
}
|
||||
.cloned();
|
||||
payload.map(|p| (i, p))
|
||||
})
|
||||
.collect();
|
||||
for (i, payload) in jobs {
|
||||
let resolved = match self.process_shader_job(&payload) {
|
||||
Some(texture) => NodeValue::Texture(oak_node::handle::make_owned(texture)),
|
||||
None => payload
|
||||
.params
|
||||
.get(&payload.effect_input)
|
||||
.cloned()
|
||||
.unwrap_or(NodeValue::None),
|
||||
};
|
||||
table.rows_mut()[i].1 = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one shader payload (C++ process_shader run by the render
|
||||
/// worker): compile the emitting behavior's fragment shader on the
|
||||
/// shared GPU context, upload a CPU input frame when needed, run the
|
||||
/// requested iterations and return the result texture. `None` when the
|
||||
/// job cannot run (no GPU context, unknown node type, missing shader,
|
||||
/// or a compile/upload/run failure) — the caller then falls back to
|
||||
/// the effect input texture.
|
||||
fn process_shader_job(&self, payload: &ShaderJobPayload) -> Option<Texture> {
|
||||
// One log line per shader per process instead of one per frame.
|
||||
let warn = |reason: &str| {
|
||||
let key = format!("shader:{}:{}", payload.type_id, payload.shader_id);
|
||||
if unsupported_warned().insert(key) {
|
||||
eprintln!(
|
||||
"shader job \"{}\" (shader \"{}\") failed: {reason}",
|
||||
payload.type_id, payload.shader_id
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// The main input texture: the param row entry under the effect
|
||||
// input id (usually "tex_in"), already resolved by the traverser
|
||||
// (footage decode runs before the shader pass in `resolve`).
|
||||
let input = match payload.params.get(&payload.effect_input) {
|
||||
Some(NodeValue::Texture(handle)) if !handle.ctx.is_null() => {
|
||||
(unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some(ctx) = crate::backend::GpuContext::shared() else {
|
||||
warn("no GPU context");
|
||||
return None;
|
||||
};
|
||||
|
||||
// The emitting node behavior: the type id selects the fragment
|
||||
// source (C++ `node->get_shader_code(shader_id)`).
|
||||
let Some((_, behavior)) =
|
||||
oak_node::factory::Factory::global().create_any(&payload.type_id)
|
||||
else {
|
||||
warn("unknown node type");
|
||||
return None;
|
||||
};
|
||||
|
||||
// OCIO-based nodes splice the auto-generated OCIO function into
|
||||
// their `%1` marker (C++ `GetShaderCode({shader_id, stub})`, the
|
||||
// stub built in colormanagement.cpp `GetColorContext`). A stub
|
||||
// that cannot be generated — no default config, or a LUT
|
||||
// processor with no upload path — falls back to the effect input
|
||||
// pass-through.
|
||||
let ocio_entry = OCIO_SHADER_STUBS
|
||||
.iter()
|
||||
.find(|(id, ..)| *id == payload.type_id)
|
||||
.copied();
|
||||
let glsl = match ocio_entry {
|
||||
Some((_, fn_name, from, to)) => {
|
||||
let Some(stub) = crate::color::ocio_function_shader(fn_name, from, to) else {
|
||||
return None;
|
||||
};
|
||||
match behavior.shader_code(&stub) {
|
||||
Some(glsl) => glsl,
|
||||
None => {
|
||||
warn("shader not found");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => match behavior.shader_code(&payload.shader_id) {
|
||||
Some(glsl) => glsl,
|
||||
None => {
|
||||
warn("shader not found");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Pipeline cache key: the type id plus the shader-variant id (the
|
||||
// OCIO stub text folds in too, so a config change recompiles
|
||||
// instead of reusing a stale variant).
|
||||
let key = match ocio_entry {
|
||||
Some(_) => {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
std::hash::Hash::hash(&glsl, &mut h);
|
||||
format!(
|
||||
"{}:{}:ocio:{}",
|
||||
payload.type_id,
|
||||
payload.shader_id,
|
||||
std::hash::Hasher::finish(&h)
|
||||
)
|
||||
}
|
||||
None => format!("{}:{}", payload.type_id, payload.shader_id),
|
||||
};
|
||||
let compiled = match compile_effect(&ctx, &key, &glsl, ctx.is_filterable()) {
|
||||
Ok(effect) => effect,
|
||||
Err(err) => {
|
||||
warn(&format!("compile failed: {err:#}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Inputs + pass size: GPU input textures bind directly; CPU frames
|
||||
// upload into a scratch texture first (`uploaded` is freed on every
|
||||
// exit path).
|
||||
let (inputs, size, uploaded): (Vec<(String, u64)>, (i32, i32), Option<u64>) =
|
||||
match &input {
|
||||
Some(Texture::Gpu {
|
||||
token,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
}) => (
|
||||
vec![(payload.effect_input.clone(), *token)],
|
||||
(*width, *height),
|
||||
None,
|
||||
),
|
||||
Some(Texture::Cpu(frame)) => {
|
||||
let token = match ctx.create_texture(frame.width, frame.height) {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn(&format!("input texture: {err:#}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Err(err) = ctx.upload(token, frame) {
|
||||
ctx.destroy_texture(token);
|
||||
warn(&format!("input upload failed: {err:#}"));
|
||||
return None;
|
||||
}
|
||||
(
|
||||
vec![(payload.effect_input.clone(), token)],
|
||||
(frame.width, frame.height),
|
||||
Some(token),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), (1, 1), None),
|
||||
};
|
||||
|
||||
let dst = match ctx.create_texture(size.0.max(1), size.1.max(1)) {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
if let Some(t) = uploaded {
|
||||
ctx.destroy_texture(t);
|
||||
}
|
||||
warn(&format!("output texture: {err:#}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let result = run_effect(
|
||||
&ctx,
|
||||
&compiled,
|
||||
&payload.params,
|
||||
&inputs,
|
||||
dst,
|
||||
size,
|
||||
payload.iterations.max(1) as u32,
|
||||
);
|
||||
if let Some(t) = uploaded {
|
||||
ctx.destroy_texture(t);
|
||||
}
|
||||
match result {
|
||||
Ok(()) => Some(Texture::Gpu {
|
||||
token: dst,
|
||||
backend: ctx.kind(),
|
||||
width: size.0.max(1),
|
||||
height: size.1.max(1),
|
||||
format: PixelFormat::F32,
|
||||
ctx: ctx.clone(),
|
||||
}),
|
||||
Err(err) => {
|
||||
ctx.destroy_texture(dst);
|
||||
warn(&format!("run failed: {err:#}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl oak_node::traverser::RenderHooks for RenderEvalHooks {
|
||||
@@ -420,11 +710,14 @@ impl oak_node::traverser::RenderHooks for RenderEvalHooks {
|
||||
|
||||
fn resolve(
|
||||
&mut self,
|
||||
_node: oak_node::id::NodeId,
|
||||
node: oak_node::id::NodeId,
|
||||
_row: &NodeValueRow,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
let _ = node;
|
||||
self.resolve_plugin_jobs(table);
|
||||
self.resolve_footage_jobs(table);
|
||||
self.resolve_shader_jobs(table);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,14 +882,18 @@ pub fn render_footage_frame(
|
||||
return Err(Error::Failed("footage decode: bad decoded frame".into()));
|
||||
}
|
||||
|
||||
let mut dst = generate_frame(time, (w, h), format)?;
|
||||
// `(0, 0)` means "native size": decode without scaling and produce a
|
||||
// frame matching the decoded dimensions (M12: the graph sequence
|
||||
// path requests native frames and scales at composite time).
|
||||
let (dw, dh) = if w > 0 && h > 0 { (w, h) } else { (src_w, src_h) };
|
||||
let mut dst = generate_frame(time, (dw, dh), format)?;
|
||||
let dst_linesize = dst.linesize_bytes() as i32;
|
||||
let src_data = match decoded.data() {
|
||||
Some(d) => d,
|
||||
None => return Err(Error::Failed("footage decode: no frame data".into())),
|
||||
};
|
||||
|
||||
if src_w == w && src_h == h && src_linesize == dst_linesize {
|
||||
if src_w == dw && src_h == dh && src_linesize == dst_linesize {
|
||||
let bytes = (src_h as usize)
|
||||
.checked_mul(src_linesize as usize)
|
||||
.ok_or(Error::NoMem)?;
|
||||
@@ -609,13 +906,215 @@ pub fn render_footage_frame(
|
||||
src_h,
|
||||
&mut dst.data,
|
||||
dst_linesize,
|
||||
w,
|
||||
h,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
}
|
||||
Ok(Texture::wrap_frame(dst))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph-driven sequence rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// WGSL fragment for the graph compositor's alpha-over pass (the C++
|
||||
/// viewer shader is `:/shaders/alphaover.frag`; same premultiplied-over
|
||||
/// math on raw texture loads). Bindings: 1 = destination (accumulator),
|
||||
/// 3 = source (the clip frame) — the layout [`GpuContext::compile_shader_pass`]
|
||||
/// assigns to texture pairs.
|
||||
const COMP_WGSL: &str = r#"
|
||||
@group(0) @binding(1) var dst_tex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var src_tex: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let dims = textureDimensions(dst_tex);
|
||||
let coord = clamp(vec2<u32>(u32(i32(frag.x)), u32(i32(frag.y))), vec2<u32>(0u, 0u), dims - vec2<u32>(1u, 1u));
|
||||
let s = textureLoad(src_tex, coord, 0);
|
||||
let d = textureLoad(dst_tex, coord, 0);
|
||||
let a = clamp(s.a, 0.0, 1.0);
|
||||
return clamp(vec4<f32>(s.rgb * a + d.rgb * (1.0 - a), a + d.a * (1.0 - a)), vec4<f32>(0.0), vec4<f32>(1.0));
|
||||
}
|
||||
"#;
|
||||
|
||||
/// GPU composite of `frames` into one `(w, h)` frame: bottom (last) to
|
||||
/// top (first), alpha-over into a ping-pong accumulator pair. Frames that
|
||||
/// do not match `(w, h)` are skipped (the caller scales at decode time;
|
||||
/// mismatches are defensive).
|
||||
fn composite_tracks_gpu(
|
||||
ctx: &crate::backend::GpuContext,
|
||||
frames: &[Frame],
|
||||
size: (i32, i32),
|
||||
) -> Result<Frame> {
|
||||
let (w, h) = size;
|
||||
if w <= 0 || h <= 0 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let program = ctx.compile_shader_pass("oak/builtin/alpha-over", COMP_WGSL, 2, false, false)?;
|
||||
let mut acc = ctx.create_texture(w, h)?;
|
||||
let mut out = ctx.create_texture(w, h)?;
|
||||
let src = ctx.create_texture(w, h)?;
|
||||
|
||||
let result = (|| {
|
||||
// The accumulator starts fully transparent.
|
||||
let clear = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32)?;
|
||||
ctx.upload(acc, &clear)?;
|
||||
for frame in frames.iter().rev().filter(|f| f.width == w && f.height == h) {
|
||||
ctx.upload(src, frame)?;
|
||||
ctx.run_shader_pass(&program, &[], &[acc, src], out)?;
|
||||
std::mem::swap(&mut acc, &mut out);
|
||||
}
|
||||
ctx.download(acc)
|
||||
})();
|
||||
|
||||
ctx.destroy_texture(acc);
|
||||
ctx.destroy_texture(out);
|
||||
ctx.destroy_texture(src);
|
||||
result
|
||||
}
|
||||
|
||||
/// CPU composite of `frames` into one `size` frame — the fallback when no
|
||||
/// GPU device is available (or the pass fails): bottom (last) to top
|
||||
/// (first) via [`composite_over`].
|
||||
fn composite_tracks(frames: Vec<Frame>, size: (i32, i32)) -> Frame {
|
||||
let (w, h) = size;
|
||||
if w <= 0 || h <= 0 {
|
||||
return Frame::dummy();
|
||||
}
|
||||
if let Some(ctx) = crate::backend::GpuContext::shared() {
|
||||
match composite_tracks_gpu(&ctx, &frames, (w, h)) {
|
||||
Ok(frame) => return frame,
|
||||
Err(err) => eprintln!("GPU track composite failed, using CPU: {err:#}"),
|
||||
}
|
||||
}
|
||||
let Ok(mut acc) = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32) else {
|
||||
return Frame::dummy();
|
||||
};
|
||||
let acc_stride = acc.linesize_bytes() as i32;
|
||||
for frame in frames.iter().rev().filter(|f| f.width == w && f.height == h) {
|
||||
composite_over(
|
||||
&mut acc.data,
|
||||
acc_stride,
|
||||
w,
|
||||
h,
|
||||
&frame.data,
|
||||
frame.linesize_bytes() as i32,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Render one frame of `viewer` (a sequence) at `time`: evaluate every
|
||||
/// enabled clip overlapping `time` through the node graph (one traverser
|
||||
/// pass per clip; the hooks' decoder cache is shared across clips) and
|
||||
/// composite the resulting frames topmost-first — in the video track
|
||||
/// list, track 0 is the topmost stack element (C++ `TrackList` order).
|
||||
///
|
||||
/// `size` is the decode target for every clip, so all frames composite
|
||||
/// without per-frame scaling. Errors: `Invalid` for a non-F32 format or a
|
||||
/// non-positive size, `NotFound` for a missing viewer or non-sequence.
|
||||
pub fn render_graph_frame(
|
||||
project: &Mutex<oak_node::project::Project>,
|
||||
viewer: oak_node::id::NodeId,
|
||||
time: Rational,
|
||||
size: (i32, i32),
|
||||
format: PixelFormat,
|
||||
) -> Result<Texture> {
|
||||
if format != PixelFormat::F32 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let (w, h) = size;
|
||||
if w <= 0 || h <= 0 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let graph = &project.lock().unwrap().graph;
|
||||
let entry = graph.get(viewer).ok_or(Error::NotFound)?;
|
||||
let sequence = entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::sequence::SequenceBehavior>())
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
// Collect the clips covering `time`: the sequence's track lists (video
|
||||
// then audio — C++ `Sequence` keeps them in the `k_track_input_format`
|
||||
// array order), the video list's tracks in stack order, then each
|
||||
// track's blocks.
|
||||
let mut clips: Vec<oak_node::id::NodeId> = Vec::new();
|
||||
for tl_id in &sequence.track_lists {
|
||||
let Some(tl) = graph.get(*tl_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(tl) = tl
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::track::TrackListBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if tl.kind != oak_node::track::TrackType::Video {
|
||||
continue;
|
||||
}
|
||||
for track_id in &tl.tracks {
|
||||
let Some(track) = graph.get(*track_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(track) = track
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::track::TrackBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for block_id in &track.blocks {
|
||||
let Some(block) = graph.get(*block_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(clip) = block
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::ClipBlockBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if clip.core.enabled && time >= clip.core.in_() && time < clip.core.out() {
|
||||
clips.push(*block_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut traverser = oak_node::traverser::Traverser::new();
|
||||
let mut hooks = RenderEvalHooks::new();
|
||||
hooks.frame_size = Some(size);
|
||||
let mut frames: Vec<Frame> = Vec::new();
|
||||
for clip in clips {
|
||||
let request = oak_node::traverser::EvalRequest::new(clip, time);
|
||||
let table = traverser.evaluate(graph, &request, &mut hooks).map_err(|e| {
|
||||
Error::Failed(format!("graph evaluation of clip {clip:?} failed: {e:?}"))
|
||||
})?;
|
||||
let Some(NodeValue::Texture(handle)) = table.get(oak_node::value::ValueType::Texture)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
continue;
|
||||
}
|
||||
let Some(texture) = (unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match texture.to_frame() {
|
||||
Ok(frame) => frames.push(frame),
|
||||
Err(err) => eprintln!("graph sequence: texture read-back failed: {err:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut frame = composite_tracks(frames, size);
|
||||
frame.timestamp = time;
|
||||
Ok(Texture::wrap_frame(frame))
|
||||
}
|
||||
|
||||
/// Render the audio montage over `params.range` (M12 P1): every clip
|
||||
/// overlapping the range is decoded (interleaved f32 at the output rate
|
||||
/// and layout) and mixed with its gain; uncovered parts stay silent.
|
||||
@@ -1056,6 +1555,7 @@ mod tests {
|
||||
fn produced_frame_honors_ticket_params() {
|
||||
let params = crate::ticket::VideoTicketParams {
|
||||
viewer: 1,
|
||||
project: String::new(),
|
||||
time: Rational::new(2, 1),
|
||||
force_size: Some((16, 9)),
|
||||
force_format: Some(PixelFormat::F32),
|
||||
@@ -1485,4 +1985,203 @@ mod tests {
|
||||
let out = apply_clip_effects(solid_texture(0.8, 0.4, 0.2, 1.0), &clip, Rational::new(0, 1));
|
||||
assert_eq!(first_pixel(&out), [0.8, 0.4, 0.2, 1.0]);
|
||||
}
|
||||
|
||||
// ---- Graph-driven sequence (M12 phase 2) ----------------------------
|
||||
|
||||
/// Native-size decode: a `(0, 0)` request produces the footage's
|
||||
/// intrinsic dimensions (the graph sequence path decodes native and
|
||||
/// scales at composite time).
|
||||
#[test]
|
||||
fn footage_native_size_decode() {
|
||||
let path = std::env::temp_dir().join(format!("oakrender_graph_native_{}.mp4", std::process::id()));
|
||||
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
|
||||
let tex = render_footage_frame(&path.to_string_lossy(), 0, Rational::new(0, 1), (0, 0), PixelFormat::F32)
|
||||
.expect("native-size decode");
|
||||
assert_eq!(tex.size(), (64, 64));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The resolve seam decodes each boxed [`FootageJobPayload`] into a
|
||||
/// texture and leaves genuine texture boxes untouched (in-place row
|
||||
/// replacement, C++ FootageJob processing).
|
||||
#[test]
|
||||
fn resolve_footage_jobs_decodes_payload_box() {
|
||||
let path = std::env::temp_dir().join(format!("oakrender_graph_resolve_{}.mp4", std::process::id()));
|
||||
oak_codec::testmedia::write_test_clip(&path, 32, 32, 10, 10).expect("test clip generation");
|
||||
|
||||
let mut table = NodeValueTable::default();
|
||||
let payload = FootageJobPayload {
|
||||
filename: path.to_string_lossy().into_owned(),
|
||||
stream_index: 0,
|
||||
time: Rational::new(0, 1),
|
||||
};
|
||||
table.push(
|
||||
oak_node::value::ValueType::Texture,
|
||||
NodeValue::Texture(oak_node::handle::make_owned(payload)),
|
||||
None,
|
||||
);
|
||||
let genuine = Texture::wrap_frame(generate_frame(Rational::new(0, 1), (4, 4), PixelFormat::F32).unwrap());
|
||||
table.push(
|
||||
oak_node::value::ValueType::Texture,
|
||||
NodeValue::Texture(oak_node::handle::make_owned(genuine)),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut hooks = RenderEvalHooks::new();
|
||||
hooks.frame_size = Some((32, 32));
|
||||
hooks.resolve_footage_jobs(&mut table);
|
||||
assert_eq!(table.count(), 2, "both rows stay, only the payload is replaced");
|
||||
|
||||
let rows = table.rows();
|
||||
let NodeValue::Texture(decoded_handle) = &rows[0].1 else {
|
||||
unreachable!()
|
||||
};
|
||||
let decoded = unsafe { oak_node::handle::get_checked::<Texture>(decoded_handle) }
|
||||
.expect("payload box replaced by the decoded texture");
|
||||
let Texture::Cpu(frame) = decoded else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!((frame.width, frame.height), (32, 32));
|
||||
assert!(
|
||||
frame.data.iter().any(|&b| b != 0),
|
||||
"decoded frame must contain non-black pixels"
|
||||
);
|
||||
|
||||
let NodeValue::Texture(genuine_handle) = &rows[1].1 else {
|
||||
unreachable!()
|
||||
};
|
||||
let genuine = unsafe { oak_node::handle::get_checked::<Texture>(genuine_handle) }
|
||||
.expect("genuine texture box stays untouched");
|
||||
assert_eq!(genuine.size(), (4, 4));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The composite seam matches the C++ alpha-over math: bottom (last)
|
||||
/// into transparent, then top (first) over it — `out = src*a +
|
||||
/// dst*(1-a)`, `out_a = a + dst_a*(1-a)` (premultiplied source).
|
||||
#[test]
|
||||
fn composite_tracks_matches_alpha_over_math() {
|
||||
let Texture::Cpu(top) = &solid_texture(0.5, 0.25, 0.125, 0.5) else {
|
||||
unreachable!()
|
||||
};
|
||||
let Texture::Cpu(bottom) = &solid_texture(1.0, 1.0, 1.0, 0.75) else {
|
||||
unreachable!()
|
||||
};
|
||||
let frames = vec![top.clone(), bottom.clone()];
|
||||
let expected = [0.625f32, 0.5, 0.4375, 0.875];
|
||||
|
||||
// bottom over transparent: (0.75, 0.75, 0.75, 0.75), then top over:
|
||||
// r = 0.5*0.5 + 0.75*0.5, g = 0.25*0.5 + 0.75*0.5,
|
||||
// b = 0.125*0.5 + 0.75*0.5, a = 0.5 + 0.75*0.5.
|
||||
let out = composite_tracks(frames.clone(), (2, 1));
|
||||
let pixel = first_pixel(&Texture::wrap_frame(out));
|
||||
for (got, want) in pixel.iter().zip(expected) {
|
||||
assert!((got - want).abs() < 1e-4, "CPU composite: expected {want}, got {got}");
|
||||
}
|
||||
|
||||
// Same math through the GPU pass when a device is available.
|
||||
if let Some(ctx) = crate::backend::GpuContext::shared() {
|
||||
let gpu_out = composite_tracks_gpu(&ctx, &frames, (2, 1)).expect("GPU composite");
|
||||
let pixel = first_pixel(&Texture::wrap_frame(gpu_out));
|
||||
for (got, want) in pixel.iter().zip(expected) {
|
||||
assert!((got - want).abs() < 1e-3, "GPU composite: expected {want}, got {got}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end chromakey through the real GPU path: a solid opaque
|
||||
/// green frame keyed on the node's default green key leaves every
|
||||
/// pixel at zero (the C++ `ColorTransformJob` + `chromakey.frag`
|
||||
/// math: `colorclose` returns 0 at the key color, so the
|
||||
/// shadows/highlights transform yields `mask = 0` and `col *= mask`
|
||||
/// blanks the frame). Exercises the OCIO splice in
|
||||
/// [`super::process_shader_job`]: the shader's `%1` marker is filled
|
||||
/// with the real `SceneLinearToCIEXYZ_d65` GLSL generated from the
|
||||
/// default OCIO config, and the tolerance uniforms reach the shader
|
||||
/// under their (fixed) input-id spelling.
|
||||
#[test]
|
||||
fn gpu_chromakey_keys_green_with_ocio_stub() {
|
||||
let Some(ctx) = crate::backend::GpuContext::shared() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Install the process-wide default config (the C++
|
||||
// `ColorManager::SetUpDefaultConfig` startup step; color.rs tests
|
||||
// do the same). Without it the OCIO stub cannot be generated and
|
||||
// the job falls back to the input pass-through.
|
||||
if crate::color::set_up_default_config().is_err() {
|
||||
eprintln!("bundled OCIO missing; skipping");
|
||||
return;
|
||||
}
|
||||
if crate::color::ocio_function_shader(
|
||||
"SceneLinearToCIEXYZ_d65",
|
||||
"scene_linear",
|
||||
"cie_xyz_d65_interchange",
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
eprintln!("no OCIO config; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let size = (16, 16);
|
||||
let mut frame = generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
|
||||
for px in frame.data.chunks_exact_mut(16) {
|
||||
for (c, v) in px.chunks_exact_mut(4).zip([0.0f32, 1.0, 0.0, 1.0]) {
|
||||
c.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
let src = ctx.create_texture(size.0, size.1).unwrap();
|
||||
ctx.upload(src, &frame).unwrap();
|
||||
let input = Texture::Gpu {
|
||||
token: src,
|
||||
backend: ctx.kind(),
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
format: PixelFormat::F32,
|
||||
ctx: ctx.clone(),
|
||||
};
|
||||
|
||||
let mut params = NodeValueRow::new();
|
||||
params.insert("tex_in".into(), NodeValue::Texture(oak_node::handle::make_owned(input)));
|
||||
params.insert("color_key".into(), NodeValue::Color([0.0, 1.0, 0.0, 1.0]));
|
||||
params.insert("lower_tolerance_in".into(), NodeValue::Float(5.0));
|
||||
params.insert("upper_tolerance_in".into(), NodeValue::Float(25.0));
|
||||
params.insert("mask_only_in".into(), NodeValue::Boolean(false));
|
||||
params.insert("invert_in".into(), NodeValue::Boolean(false));
|
||||
params.insert("shadows_in".into(), NodeValue::Float(100.0));
|
||||
params.insert("highlights_in".into(), NodeValue::Float(100.0));
|
||||
|
||||
let payload = ShaderJobPayload {
|
||||
node_id: oak_node::id::NodeId::from_identity(1).unwrap(),
|
||||
time: Rational::new(0, 1),
|
||||
iterations: 1,
|
||||
type_id: "org.olivevideoeditor.Olive.chromakey".into(),
|
||||
shader_id: String::new(),
|
||||
effect_input: "tex_in".into(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
};
|
||||
let rendered = RenderEvalHooks::new()
|
||||
.process_shader_job(&payload)
|
||||
.expect("chromakey renders on the GPU");
|
||||
|
||||
let Texture::Gpu { token: dst, .. } = rendered else {
|
||||
panic!("expected a GPU texture");
|
||||
};
|
||||
let out = ctx.download(dst).unwrap();
|
||||
for px in out.data.chunks_exact(16) {
|
||||
for (c, v) in px.chunks_exact(4).enumerate() {
|
||||
let got = f32::from_le_bytes(v.try_into().unwrap());
|
||||
assert!(
|
||||
got.abs() < 1e-4,
|
||||
"channel {c}: keyed green must be fully transparent (got {got})"
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.destroy_texture(dst);
|
||||
ctx.destroy_texture(src);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +480,13 @@ pub struct BatchTicketSpec {
|
||||
pub footage_stream: i32,
|
||||
/// Sequence montage (ordered topmost-last; empty = none).
|
||||
pub montage: Vec<WireMontageClip>,
|
||||
/// Sequence viewer node identity (0 = montage mode; nonzero = render
|
||||
/// the viewer's graph frame from the worker's loaded snapshot).
|
||||
pub viewer_node: u64,
|
||||
/// The owning project's uuid (M16 S1): the worker renders the viewer's
|
||||
/// graph frame only when this matches the loaded snapshot's project
|
||||
/// ("" = no graph mode).
|
||||
pub project_key: String,
|
||||
}
|
||||
|
||||
/// `render_batch` (main->worker) — a batch of frame tickets with
|
||||
@@ -1224,6 +1231,22 @@ pub struct SharedMemoryRegion {
|
||||
shm_name: String,
|
||||
}
|
||||
|
||||
/// Owned segment keys that are still mapped when the process exits. Test
|
||||
/// binaries finish via `std::process::exit` (libtest), which skips Rust
|
||||
/// static destructors — the process-wide render-manager singleton never
|
||||
/// runs `Drop`, its `shm_unlink` never fires, and every test run leaks one
|
||||
/// ~66 MiB segment per worker until `/dev/shm` fills up (the next create
|
||||
/// then `memset`s a mapping backed by a full tmpfs and faults with SIGBUS).
|
||||
/// `libc::atexit` handlers DO run under `process::exit`, so each `Create`
|
||||
/// registers its key here and [`SharedMemoryRegion::atexit_cleanup_owned_shm`]
|
||||
/// unlinks them all at exit. Unlinking while a peer still maps the segment
|
||||
/// is safe — POSIX only removes the name; the mapping lives until the last
|
||||
/// `munmap` (the workers attach without owning, so they never register).
|
||||
#[cfg(unix)]
|
||||
static OWNED_SHM_KEYS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
|
||||
#[cfg(unix)]
|
||||
static ATEXIT_REGISTERED: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
impl SharedMemoryRegion {
|
||||
/// An empty (invalid) region.
|
||||
pub fn new() -> SharedMemoryRegion {
|
||||
@@ -1269,6 +1292,36 @@ impl SharedMemoryRegion {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember `key` so it is unlinked at process exit (see
|
||||
/// [`OWNED_SHM_KEYS`]). Safe to call from any thread; duplicate keys
|
||||
/// are harmless (the unlink is idempotent).
|
||||
#[cfg(unix)]
|
||||
fn track_owned_key(key: &str) {
|
||||
ATEXIT_REGISTERED.call_once(|| {
|
||||
// SAFETY: `atexit_cleanup_owned_shm` is a plain extern "C" fn
|
||||
// that is valid for the whole process lifetime, which is what
|
||||
// libc::atexit requires.
|
||||
unsafe {
|
||||
libc::atexit(Self::atexit_cleanup_owned_shm);
|
||||
}
|
||||
});
|
||||
if let Ok(mut keys) = OWNED_SHM_KEYS.lock() {
|
||||
keys.get_or_insert_with(Vec::new).push(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlink every segment this process created, at process exit — runs
|
||||
/// even when the exit path is `std::process::exit` (test binaries).
|
||||
#[cfg(unix)]
|
||||
extern "C" fn atexit_cleanup_owned_shm() {
|
||||
let keys = OWNED_SHM_KEYS.lock().ok().and_then(|mut keys| keys.take());
|
||||
if let Some(keys) = keys {
|
||||
for key in keys {
|
||||
Self::unlink_key(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the segment identified by `key` with the given `size` in bytes.
|
||||
///
|
||||
/// `key` is a short identifier (no leading slash needed; the platform
|
||||
@@ -1358,6 +1411,7 @@ impl SharedMemoryRegion {
|
||||
self.error.clear();
|
||||
|
||||
if mode == ShmMode::Create {
|
||||
Self::track_owned_key(&self.key);
|
||||
unsafe { ptr::write_bytes(self.data, 0, size) };
|
||||
}
|
||||
true
|
||||
@@ -1845,6 +1899,8 @@ mod tests {
|
||||
footage_file: "a.mp4".into(),
|
||||
footage_stream: 0,
|
||||
montage: vec![],
|
||||
viewer_node: 0,
|
||||
project_key: String::new(),
|
||||
},
|
||||
BatchTicketSpec {
|
||||
ticket: 42,
|
||||
@@ -1877,6 +1933,8 @@ mod tests {
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
viewer_node: 0,
|
||||
project_key: String::new(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::error::{Error, Result};
|
||||
use crate::eval;
|
||||
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef};
|
||||
use crate::ticket::{TicketArena, TicketId};
|
||||
use crate::worker::{InlineDispatcher, JobDispatch};
|
||||
use crate::worker::{GraphSnapshotStore, InlineDispatcher, JobDispatch};
|
||||
|
||||
static MANAGER: Mutex<Option<Arc<RenderManager>>> = Mutex::new(None);
|
||||
|
||||
@@ -73,6 +73,20 @@ pub struct RenderManager {
|
||||
pub autocacher: Mutex<Option<PreviewAutoCacher>>,
|
||||
/// Aggressive decoder GC toggle.
|
||||
aggressive_gc: AtomicBool,
|
||||
/// Graph snapshot files shared with worker processes (M16 S1).
|
||||
snapshots: GraphSnapshotStore,
|
||||
/// The snapshot path currently shipped to the worker pool (None until
|
||||
/// the app pushes one).
|
||||
current_snapshot: Mutex<Option<String>>,
|
||||
/// The (project uuid, undo revision) the current snapshot was written
|
||||
/// for (M16 S1: dedup key — revisions alone collide across projects,
|
||||
/// since every fresh project shares small revision numbers).
|
||||
current_key: Mutex<Option<(String, u64)>>,
|
||||
/// Teardown in progress (M16 S1): set first thing in
|
||||
/// [`RenderManager::shutdown`]; `set_graph_snapshot` /
|
||||
/// `clear_graph_snapshot` become no-ops afterwards so a stale push
|
||||
/// from a dying test/app cannot re-arm the worker pool mid-shutdown.
|
||||
stopping: AtomicBool,
|
||||
}
|
||||
|
||||
impl RenderManager {
|
||||
@@ -133,6 +147,10 @@ impl RenderManager {
|
||||
requested_backend: backend,
|
||||
autocacher: Mutex::new(None),
|
||||
aggressive_gc: AtomicBool::new(false),
|
||||
snapshots: GraphSnapshotStore::new(),
|
||||
current_snapshot: Mutex::new(None),
|
||||
current_key: Mutex::new(None),
|
||||
stopping: AtomicBool::new(false),
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
@@ -155,15 +173,72 @@ impl RenderManager {
|
||||
pub fn shutdown() {
|
||||
let manager = lock(&MANAGER).take();
|
||||
if let Some(manager) = manager {
|
||||
// Mark stopping FIRST: from here on `set_graph_snapshot`,
|
||||
// `clear_graph_snapshot` and `poll` become no-ops, so a
|
||||
// concurrent app thread racing the teardown cannot re-arm the
|
||||
// dispatcher after it is drained.
|
||||
manager.stopping.store(true, Ordering::Release);
|
||||
manager.tickets.cancel_all();
|
||||
// Drain after the cancels so queued completions fire. Both
|
||||
// dispatches are idempotent.
|
||||
manager.dispatch.shutdown();
|
||||
manager.audio_dispatch.shutdown();
|
||||
// Release the graph snapshot (the file is retained: a worker
|
||||
// may still hold the path for a late load_graph).
|
||||
if let Some(path) = lock(&manager.current_snapshot).take() {
|
||||
manager.snapshots.release(&path);
|
||||
}
|
||||
// The store directory is cleared here and only here — no worker
|
||||
// can reference a snapshot file once the dispatchers are down.
|
||||
manager.snapshots.cleanup();
|
||||
drop(manager);
|
||||
}
|
||||
}
|
||||
|
||||
/// M16 S1 graph mode: snapshot the project to the worker pool. The
|
||||
/// snapshot is serialized once per (project, revision) — the undo-stack
|
||||
/// position; the key includes the project's uuid because fresh projects
|
||||
/// reuse small identity numbers and two projects at the same revision
|
||||
/// would otherwise collide on one file (the cross-project snapshot race
|
||||
/// that shipped before M16 S1). A new key rewrites the file and
|
||||
/// re-sends `load_graph` to every live worker, releasing the previous
|
||||
/// snapshot (file retained at zero refs).
|
||||
pub fn set_graph_snapshot(
|
||||
&self,
|
||||
project: &std::sync::Mutex<oak_node::project::Project>,
|
||||
revision: u64,
|
||||
) -> Result<()> {
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return Ok(()); // teardown: no re-arm after the drain
|
||||
}
|
||||
let uuid = lock(project).uuid.clone();
|
||||
if *lock(&self.current_key) == Some((uuid.clone(), revision)) {
|
||||
return Ok(()); // unchanged state: no rewrite, no re-send
|
||||
}
|
||||
let path = self.snapshots.acquire(project, revision)?;
|
||||
if let Some(old) = lock(&self.current_snapshot).replace(path.clone()) {
|
||||
self.snapshots.release(&old);
|
||||
}
|
||||
self.dispatch.set_graph_snapshot(Some(path));
|
||||
*lock(&self.current_key) = Some((uuid, revision));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// M16 S1 graph mode: drop the current snapshot (project closed). The
|
||||
/// protocol has no clear message, so alive workers keep their loaded
|
||||
/// graph; new/restarted workers no longer load it and the snapshot file
|
||||
/// is retained (removed wholesale at manager shutdown).
|
||||
pub fn clear_graph_snapshot(&self) {
|
||||
if self.stopping.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Some(old) = lock(&self.current_snapshot).take() {
|
||||
self.snapshots.release(&old);
|
||||
}
|
||||
*lock(&self.current_key) = None;
|
||||
self.dispatch.set_graph_snapshot(None);
|
||||
}
|
||||
|
||||
/// Pump the video backend's control plane (M15 S2): the process
|
||||
/// dispatcher delivers ticket completions from its poll loop, so the
|
||||
/// UI tick and any blocking wait must call this regularly. No-op on
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
//! worker dead: its claimed frames are re-queued to the scheduler
|
||||
//! (any healthy worker may claim them), the child is reaped, the
|
||||
//! segment recreated and the process respawned (bounded restarts).
|
||||
//! - **Where rendering runs.** Each `oak-worker` child is a
|
||||
//! single-threaded NDJSON loop (no render thread inside the worker):
|
||||
//! a `render_batch` message renders every ticket synchronously on the
|
||||
//! child's loop thread (`WorkerSession::handle_render_batch_stream`
|
||||
//! in `crates/oak-worker/src/worker.rs`). Parallelism comes from
|
||||
//! the pool of worker processes spawned here (`spawn_worker`),
|
||||
//! never from threads inside a worker — see "Where the rendering
|
||||
//! happens" in `crates/oak-worker/README.md`.
|
||||
//! - **S2 model.** The in-process [`crate::worker::WorkerPool`] is
|
||||
//! gone (M15 S2 mandate); [`crate::manager::RenderManager`] defaults
|
||||
//! to this backend. The ticket arena also routes **playback-window**
|
||||
@@ -950,10 +958,15 @@ impl ProcessDispatcher {
|
||||
/// Pump the control plane: drain worker events, restart the dead,
|
||||
/// claim + dispatch batches. Non-blocking; call from the UI tick (or
|
||||
/// after any submit/release). Completions fire after the lock drops.
|
||||
/// A no-op once shutting down (M16 S1: a stale poll must not restart
|
||||
/// dead workers during/after teardown).
|
||||
pub fn poll(&self) {
|
||||
let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
|
||||
{
|
||||
let mut inner = lock(&self.inner);
|
||||
if inner.shutting_down {
|
||||
return;
|
||||
}
|
||||
self.pump(&mut inner, &mut fired);
|
||||
}
|
||||
for (done, result) in fired {
|
||||
@@ -1035,6 +1048,32 @@ impl ProcessDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) the graph snapshot path shipped to every worker via
|
||||
/// `load_graph` (M16 S1). A new path is sent to every alive worker —
|
||||
/// reloading a snapshot is idempotent, and the manager only re-sends
|
||||
/// when the snapshot revision actually changes. Clearing only updates
|
||||
/// the config: the protocol has no clear message, so alive workers
|
||||
/// keep their loaded graph and only new/restarted workers skip it.
|
||||
pub fn set_graph_snapshot(&self, path: Option<String>) {
|
||||
let mut inner = lock(&self.inner);
|
||||
if inner.shutting_down {
|
||||
return;
|
||||
}
|
||||
inner.config.graph_snapshot = path.clone();
|
||||
let Some(path) = path else { return };
|
||||
for handle in inner.workers.iter_mut() {
|
||||
if matches!(handle.state, WorkerState::Alive | WorkerState::Starting) {
|
||||
handle.graph_sent = true;
|
||||
if self
|
||||
.send_json(handle, &json!({ "type": "load_graph", "path": path }))
|
||||
.is_err()
|
||||
{
|
||||
handle.state = WorkerState::Dead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internals ------------------------------------------------------
|
||||
|
||||
fn pump(&self, inner: &mut Inner, fired: &mut Vec<(Completion, TicketResult)>) {
|
||||
@@ -1476,7 +1515,10 @@ impl ProcessDispatcher {
|
||||
let shm = ShmRegionView::create(&key, inner.slots, inner.slot_bytes)?;
|
||||
|
||||
let mut child = Command::new(&inner.bin)
|
||||
.args(["--backend", "cpu"])
|
||||
// Auto backend: prefer the GPU, fall back to the CPU renderer
|
||||
// (M16 S1 — the worker tolerates a GPU init failure and keeps
|
||||
// evaluating headless).
|
||||
.args(["--backend", "auto"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
@@ -1810,6 +1852,12 @@ impl JobDispatch for ProcessDispatcher {
|
||||
});
|
||||
}
|
||||
|
||||
/// Ship a graph snapshot to the worker pool (M16 S1; delegates to the
|
||||
/// inherent [`ProcessDispatcher::set_graph_snapshot`]).
|
||||
fn set_graph_snapshot(&self, path: Option<String>) {
|
||||
self.set_graph_snapshot(path);
|
||||
}
|
||||
|
||||
/// Release a consumed frame's slot (delegates to the inherent
|
||||
/// release — see [`ProcessDispatcher::release_frame`]).
|
||||
fn release_frame(&self, frame: &ShmFrameRef) {
|
||||
@@ -1964,6 +2012,12 @@ fn build_ticket_spec(
|
||||
footage_file,
|
||||
footage_stream,
|
||||
montage,
|
||||
// M16 S1 graph mode: the worker renders the viewer's graph frame
|
||||
// when nonzero (else the montage path above) — and only when the
|
||||
// ticket's project matches the worker's loaded snapshot (the
|
||||
// `project_key` uuid guard).
|
||||
viewer_node: params.viewer,
|
||||
project_key: params.project.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Effect shader translation: the nodes' embedded GLSL fragment shaders
|
||||
//! (the C++ `:/shaders/*.frag` corpus, kept verbatim in oak-node) are
|
||||
//! converted to WGSL at runtime through naga and run as wgpu fullscreen
|
||||
//! passes.
|
||||
//!
|
||||
//! The conversion mirrors the C++ Vulkan backend's mechanical rewrite
|
||||
//! (`vulkanrenderer.cpp` `ConvertGlslToVulkan` + `ExtractUniforms`):
|
||||
//!
|
||||
//! - a `#version 450 core` prelude is prepended;
|
||||
//! - legacy `texture2D(`/`texture3D(` calls are renamed to `texture(`;
|
||||
//! - the pipeline I/O globals get explicit locations
|
||||
//! (`layout(location = 0) in vec2 ove_texcoord;`,
|
||||
//! `layout(location = 0) out vec4 frag_color;`);
|
||||
//! - loose `uniform <type> <name>;` declarations are extracted: samplers
|
||||
//! get explicit `set`/`binding` qualifiers, and value uniforms are
|
||||
//! collected into one anonymous `std140` uniform block (GLSL 450
|
||||
//! anonymous block members stay accessible by their bare names, so the
|
||||
//! shader body needs no rewriting).
|
||||
//!
|
||||
//! Uniform values are packed by the caller following std140 rules
|
||||
//! (float/int/bool 4/4, vec2 8/8, vec3 12/16, vec4 16/16, mat4 64/16 —
|
||||
//! the same table the C++ `GetStd140Size/Alignment` used).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A value uniform's GLSL type (std140 packing + `NodeValue` mapping).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UniformType {
|
||||
/// `float`.
|
||||
Float,
|
||||
/// `int`.
|
||||
Int,
|
||||
/// `bool` (stored as `int` in the block; WGSL has no shareable bool).
|
||||
Bool,
|
||||
/// `vec2`.
|
||||
Vec2,
|
||||
/// `vec3`.
|
||||
Vec3,
|
||||
/// `vec4`.
|
||||
Vec4,
|
||||
/// `mat4`.
|
||||
Mat4,
|
||||
}
|
||||
|
||||
impl UniformType {
|
||||
/// The GLSL type keyword, or `None` when it is not a value uniform
|
||||
/// (samplers, arrays and unknown types are not packable).
|
||||
fn from_keyword(kw: &str) -> Option<UniformType> {
|
||||
Some(match kw {
|
||||
"float" => UniformType::Float,
|
||||
"int" => UniformType::Int,
|
||||
"bool" => UniformType::Bool,
|
||||
"vec2" => UniformType::Vec2,
|
||||
"vec3" => UniformType::Vec3,
|
||||
"vec4" => UniformType::Vec4,
|
||||
"mat4" => UniformType::Mat4,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The GLSL keyword back (block re-emission).
|
||||
fn keyword(self) -> &'static str {
|
||||
match self {
|
||||
UniformType::Float => "float",
|
||||
UniformType::Int => "int",
|
||||
UniformType::Bool => "bool",
|
||||
UniformType::Vec2 => "vec2",
|
||||
UniformType::Vec3 => "vec3",
|
||||
UniformType::Vec4 => "vec4",
|
||||
UniformType::Mat4 => "mat4",
|
||||
}
|
||||
}
|
||||
|
||||
/// std140 base alignment in bytes (C++ `GetStd140Alignment`).
|
||||
pub fn align(self) -> usize {
|
||||
match self {
|
||||
UniformType::Float | UniformType::Int | UniformType::Bool => 4,
|
||||
UniformType::Vec2 => 8,
|
||||
UniformType::Vec3 | UniformType::Vec4 | UniformType::Mat4 => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// std140 storage size in bytes (C++ `GetStd140Size`).
|
||||
pub fn size(self) -> usize {
|
||||
match self {
|
||||
UniformType::Float | UniformType::Int | UniformType::Bool => 4,
|
||||
UniformType::Vec2 => 8,
|
||||
UniformType::Vec3 => 12,
|
||||
UniformType::Vec4 => 16,
|
||||
UniformType::Mat4 => 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value uniform (std140-packed into the uniform block).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UniformDecl {
|
||||
/// Uniform name (= the node input id, the Olive convention).
|
||||
pub name: String,
|
||||
/// Its type.
|
||||
pub ty: UniformType,
|
||||
/// Byte offset in the packed block (assigned by [`translate`]).
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// The result of translating one effect fragment shader.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TranslatedShader {
|
||||
/// The WGSL fragment module (entry point `main`).
|
||||
pub wgsl: String,
|
||||
/// Value uniforms in block order (offsets assigned, block tail-padded
|
||||
/// to 16).
|
||||
pub uniforms: Vec<UniformDecl>,
|
||||
/// The packed uniform block size in bytes (0 = no value uniforms).
|
||||
pub uniform_block_bytes: usize,
|
||||
/// Texture input names in binding order (combined `sampler2D` etc.;
|
||||
/// the first one is the effect's main input by Olive convention).
|
||||
pub textures: Vec<String>,
|
||||
/// Fragment input varyings in location order (`ove_texcoord` first,
|
||||
/// then any effect-specific varyings like cornerpin's perspective
|
||||
/// helpers). The runner's vertex stage must produce all of them.
|
||||
pub varyings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The effect runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A compiled effect: the translated shader plus its cached pipeline.
|
||||
pub struct CompiledEffect {
|
||||
/// The translation result (uniform layout + texture bindings).
|
||||
pub translated: TranslatedShader,
|
||||
/// The compiled pipeline in the context cache.
|
||||
pub program: std::sync::Arc<crate::backend::ShaderProgram>,
|
||||
}
|
||||
|
||||
/// Translate `glsl` and compile the pipeline on `ctx`. `key` is the
|
||||
/// pipeline cache key (the effect type id plus any shader-variant id).
|
||||
pub fn compile_effect(
|
||||
ctx: &crate::backend::GpuContext,
|
||||
key: &str,
|
||||
glsl: &str,
|
||||
filtering: bool,
|
||||
) -> Result<CompiledEffect> {
|
||||
let translated = translate(glsl)?;
|
||||
let program = ctx.compile_shader_pass(
|
||||
key,
|
||||
&translated.wgsl,
|
||||
translated.textures.len() as u32,
|
||||
!translated.uniforms.is_empty(),
|
||||
filtering,
|
||||
)?;
|
||||
Ok(CompiledEffect { translated, program })
|
||||
}
|
||||
|
||||
/// Run an effect: shade `dst` from the input textures with `params` as
|
||||
/// the uniform values.
|
||||
///
|
||||
/// - `inputs` maps the shader's texture names to context texture tokens;
|
||||
/// declared textures without an input bind the context's 1×1
|
||||
/// placeholder and get `<name>_enabled = 0` (C++ Blit's texture
|
||||
/// binding + enable-flag convention). The first declared texture is
|
||||
/// the main input — and the iterative one when `iterations` > 1
|
||||
/// (C++ `ShaderJob::SetIterations` with `tex_in`).
|
||||
/// - `iterations` runs the shader that many times, feeding each pass's
|
||||
/// output back as the main input (C++ `OpenGLRenderer::Blit`'s
|
||||
/// ping-pong; the `ove_iteration` uniform tracks the pass index).
|
||||
/// - Well-known uniforms are auto-filled when declared but absent from
|
||||
/// `params`: `resolution_in` (the frame size), `ove_iteration`,
|
||||
/// `ove_mvpmat` (identity).
|
||||
pub fn run_effect(
|
||||
ctx: &crate::backend::GpuContext,
|
||||
effect: &CompiledEffect,
|
||||
params: &oak_node::value::NodeValueRow,
|
||||
inputs: &[(String, u64)],
|
||||
dst: u64,
|
||||
size: (i32, i32),
|
||||
iterations: u32,
|
||||
) -> Result<()> {
|
||||
use oak_node::value::NodeValue;
|
||||
|
||||
let declares = |name: &str| effect.translated.uniforms.iter().any(|u| u.name == name);
|
||||
|
||||
// Resolve every declared texture to a token (placeholder when the
|
||||
// effect's input is unconnected).
|
||||
let mut tokens: Vec<u64> = Vec::with_capacity(effect.translated.textures.len());
|
||||
let mut row = params.clone();
|
||||
for (i, name) in effect.translated.textures.iter().enumerate() {
|
||||
let token = inputs
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, t)| *t)
|
||||
.or_else(|| if i == 0 { inputs.first().map(|(_, t)| *t) } else { None });
|
||||
match token {
|
||||
Some(t) => {
|
||||
tokens.push(t);
|
||||
let flag = format!("{name}_enabled");
|
||||
if declares(&flag) && !row.contains_key(&flag) {
|
||||
row.insert(flag, NodeValue::Boolean(true));
|
||||
}
|
||||
}
|
||||
None => tokens.push(ctx.placeholder_texture()?),
|
||||
}
|
||||
}
|
||||
|
||||
// Well-known uniforms (C++ inserts resolution_in at job-build time;
|
||||
// ove_mvpmat defaults to identity in Blit).
|
||||
if declares("resolution_in") && !row.contains_key("resolution_in") {
|
||||
row.insert(
|
||||
"resolution_in".to_string(),
|
||||
NodeValue::Vec2([size.0 as f64, size.1 as f64]),
|
||||
);
|
||||
}
|
||||
if declares("ove_mvpmat") && !row.contains_key("ove_mvpmat") {
|
||||
let mut m = [0.0f64; 16];
|
||||
for i in 0..4 {
|
||||
m[i * 4 + i] = 1.0;
|
||||
}
|
||||
row.insert("ove_mvpmat".to_string(), NodeValue::Matrix(m));
|
||||
}
|
||||
|
||||
let iterations = iterations.max(1);
|
||||
if iterations == 1 {
|
||||
let uniforms = pack_uniforms(&effect.translated, &row);
|
||||
return ctx.run_shader_pass(&effect.program, &uniforms, &tokens, dst);
|
||||
}
|
||||
|
||||
// Ping-pong (C++ Blit): one scratch texture for two passes, two for
|
||||
// longer chains; the last pass always lands in `dst`.
|
||||
let scratch_a = ctx.create_texture(size.0, size.1)?;
|
||||
let scratch_b = if iterations > 2 {
|
||||
Some(ctx.create_texture(size.0, size.1)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = (|| -> Result<()> {
|
||||
let mut input_tokens = tokens.clone();
|
||||
for i in 0..iterations {
|
||||
let mut pass_row = row.clone();
|
||||
if declares("ove_iteration") {
|
||||
pass_row.insert("ove_iteration".to_string(), NodeValue::Int(i as i64));
|
||||
}
|
||||
let target = if i == iterations - 1 {
|
||||
dst
|
||||
} else if i % 2 == 0 {
|
||||
scratch_a
|
||||
} else {
|
||||
scratch_b.unwrap()
|
||||
};
|
||||
let uniforms = pack_uniforms(&effect.translated, &pass_row);
|
||||
ctx.run_shader_pass(&effect.program, &uniforms, &input_tokens, target)?;
|
||||
input_tokens[0] = target;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
ctx.destroy_texture(scratch_a);
|
||||
if let Some(b) = scratch_b {
|
||||
ctx.destroy_texture(b);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
/// Replace whole-word occurrences of `name` in `s` with `replacement`
|
||||
/// (identifier boundaries: alphanumerics and `_`). The node-shader
|
||||
/// corpus uses plain identifiers, so this simple scan suffices — no
|
||||
/// regex dependency.
|
||||
fn replace_ident(s: &str, name: &str, replacement: &str) -> String {
|
||||
fn is_ident_char(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_'
|
||||
}
|
||||
let bytes = s.as_bytes();
|
||||
let name = name.as_bytes();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i..].starts_with(name)
|
||||
&& (i == 0 || !is_ident_char(bytes[i - 1]))
|
||||
&& !bytes
|
||||
.get(i + name.len())
|
||||
.is_some_and(|&c| is_ident_char(c))
|
||||
{
|
||||
out.push_str(replacement);
|
||||
i += name.len();
|
||||
} else {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a standalone `in`/`out` varying declaration line body (after
|
||||
/// the direction keyword), e.g. `vec2 ove_texcoord;` → `(name, type)`.
|
||||
/// Function parameter lists and anything else are rejected.
|
||||
fn parse_plain_global(rest: &str) -> Option<(String, String)> {
|
||||
let rest = rest.trim().strip_suffix(';')?.trim();
|
||||
let (ty, name) = rest.split_once(char::is_whitespace)?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
}
|
||||
Some((name.to_string(), ty.to_string()))
|
||||
}
|
||||
|
||||
/// Binding 0 is the uniform block; textures/samplers follow.
|
||||
const UNIFORM_BLOCK_BINDING: u32 = 0;
|
||||
|
||||
/// Pack the uniform block for `shader` from `params` (the effect's
|
||||
/// parameter row; uniform names are the node input ids by Olive
|
||||
/// convention). Packing follows the declared uniform types, converting
|
||||
/// from whatever `NodeValue` shape arrived (the C++ Blit dispatched on
|
||||
/// the value type with GL's implicit conversions; here the declared
|
||||
/// type wins). Undeclared params are skipped; missing values stay zero.
|
||||
pub fn pack_uniforms(
|
||||
shader: &TranslatedShader,
|
||||
params: &oak_node::value::NodeValueRow,
|
||||
) -> Vec<u8> {
|
||||
use oak_node::value::NodeValue;
|
||||
let mut buf = vec![0u8; shader.uniform_block_bytes];
|
||||
for decl in &shader.uniforms {
|
||||
let Some(value) = params.get(&decl.name) else {
|
||||
continue;
|
||||
};
|
||||
let f32s: Vec<f32> = match (decl.ty, value) {
|
||||
(UniformType::Float, NodeValue::Float(v)) => vec![*v as f32],
|
||||
(UniformType::Float, NodeValue::Int(v) | NodeValue::Combo(v)) => vec![*v as f32],
|
||||
(UniformType::Float, NodeValue::Boolean(v)) => vec![f32::from(u8::from(*v))],
|
||||
(UniformType::Int, NodeValue::Int(v) | NodeValue::Combo(v)) => {
|
||||
write_i32(&mut buf, decl.offset, *v as i32);
|
||||
continue;
|
||||
}
|
||||
(UniformType::Int, NodeValue::Float(v)) => {
|
||||
write_i32(&mut buf, decl.offset, *v as i32);
|
||||
continue;
|
||||
}
|
||||
(UniformType::Bool, NodeValue::Boolean(v)) => {
|
||||
write_i32(&mut buf, decl.offset, i32::from(*v));
|
||||
continue;
|
||||
}
|
||||
(UniformType::Bool, NodeValue::Int(v) | NodeValue::Combo(v)) => {
|
||||
write_i32(&mut buf, decl.offset, i32::from(*v != 0));
|
||||
continue;
|
||||
}
|
||||
(UniformType::Vec2, NodeValue::Vec2(v)) => v.iter().map(|x| *x as f32).collect(),
|
||||
(UniformType::Vec3, NodeValue::Vec3(v)) => v.iter().map(|x| *x as f32).collect(),
|
||||
// A color packs into a vec3 slot as its RGB (C++ Color →
|
||||
// glUniform4f only for vec4; a vec3 target takes rgb).
|
||||
(UniformType::Vec3, NodeValue::Color(v)) => {
|
||||
v[..3].iter().map(|x| *x as f32).collect()
|
||||
}
|
||||
(UniformType::Vec4, NodeValue::Vec4(v) | NodeValue::Color(v)) => {
|
||||
v.iter().map(|x| *x as f32).collect()
|
||||
}
|
||||
// Matrices: GLSL mat4 is column-major; the NodeValue comment
|
||||
// marks the layout row-major, so transpose on the way in.
|
||||
(UniformType::Mat4, NodeValue::Matrix(m)) => {
|
||||
let mut cols = Vec::with_capacity(16);
|
||||
for c in 0..4 {
|
||||
for r in 0..4 {
|
||||
cols.push(m[r * 4 + c] as f32);
|
||||
}
|
||||
}
|
||||
cols
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
for (i, v) in f32s.iter().enumerate() {
|
||||
let at = decl.offset + i * 4;
|
||||
if at + 4 <= buf.len() {
|
||||
buf[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn write_i32(buf: &mut [u8], offset: usize, v: i32) {
|
||||
if offset + 4 <= buf.len() {
|
||||
buf[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a GLSL type keyword is a combined sampler (C++
|
||||
/// `IsSamplerType`: sampler\*D / samplerCube / sampler2DArray).
|
||||
fn is_sampler_type(kw: &str) -> bool {
|
||||
kw.starts_with("sampler")
|
||||
}
|
||||
|
||||
/// Parse a `uniform <type> <name>;` declaration line (the constrained
|
||||
/// style of the node shader corpus: one declaration per line, no layout
|
||||
/// qualifiers, no initializers, no arrays — arrays are reported as
|
||||
/// unsupported, matching the C++ Blit). Returns `(type, name)`.
|
||||
fn parse_uniform_line(line: &str) -> Option<(&str, &str)> {
|
||||
let t = line.trim_start();
|
||||
let rest = t.strip_prefix("uniform")?;
|
||||
if !rest.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.trim_start();
|
||||
let (ty, rest) = rest.split_once(char::is_whitespace)?;
|
||||
let name = rest.trim().strip_suffix(';')?.trim();
|
||||
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
}
|
||||
Some((ty, name))
|
||||
}
|
||||
|
||||
/// Translate one GLSL fragment shader to WGSL (naga glsl-in → wgsl-out).
|
||||
/// The source keeps the Olive node-shader conventions; see the module
|
||||
/// docs for the rewrite steps.
|
||||
pub fn translate(glsl: &str) -> Result<TranslatedShader> {
|
||||
let mut body_lines: Vec<String> = Vec::new();
|
||||
let mut uniforms: Vec<(UniformType, String)> = Vec::new();
|
||||
let mut textures: Vec<String> = Vec::new();
|
||||
|
||||
for line in glsl.lines() {
|
||||
if let Some((ty, name)) = parse_uniform_line(line) {
|
||||
if is_sampler_type(ty) {
|
||||
textures.push(name.to_string());
|
||||
continue;
|
||||
}
|
||||
match UniformType::from_keyword(ty) {
|
||||
Some(t) => {
|
||||
uniforms.push((t, name.to_string()));
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
return Err(Error::Failed(format!(
|
||||
"unsupported uniform type in shader: {ty} {name}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
body_lines.push(line.to_string());
|
||||
}
|
||||
|
||||
let mut src = String::from("#version 450 core\n");
|
||||
let mut in_loc = 0u32;
|
||||
let mut out_loc = 0u32;
|
||||
let mut varyings: Vec<String> = Vec::new();
|
||||
|
||||
// Re-emit the extracted uniforms BEFORE the body (GLSL requires
|
||||
// declarations to precede use): the value block first (binding 0),
|
||||
// then the samplers. The block is anonymous — GLSL 450 anonymous
|
||||
// block members stay accessible by their bare names, so the shader
|
||||
// body needs no rewriting.
|
||||
if !uniforms.is_empty() {
|
||||
src.push_str("layout(std140, set = 0, binding = ");
|
||||
src.push_str(&UNIFORM_BLOCK_BINDING.to_string());
|
||||
src.push_str(") uniform OakParams {\n");
|
||||
for (ty, name) in &uniforms {
|
||||
// bools are declared as int (WGSL has no host-shareable
|
||||
// bool); the body's uses were rewritten to `bool(x)`.
|
||||
let kw = if *ty == UniformType::Bool {
|
||||
"int"
|
||||
} else {
|
||||
ty.keyword()
|
||||
};
|
||||
src.push_str(&format!(" {kw} {name};\n"));
|
||||
}
|
||||
src.push_str("};\n");
|
||||
}
|
||||
for (i, name) in textures.iter().enumerate() {
|
||||
// Split the combined sampler2D: texture at an odd binding, its
|
||||
// sampler right after (the binding map is reported through
|
||||
// [`TranslatedShader::textures`] in declaration order).
|
||||
src.push_str(&format!(
|
||||
"layout(set = 0, binding = {}) uniform texture2D {};\n\
|
||||
layout(set = 0, binding = {}) uniform sampler {}_s;\n",
|
||||
1 + 2 * i,
|
||||
name,
|
||||
2 + 2 * i,
|
||||
name
|
||||
));
|
||||
}
|
||||
|
||||
for line in &body_lines {
|
||||
let mut l = line.clone();
|
||||
// Strip a pre-existing #version (the prelude pins 450 core).
|
||||
if l.trim_start().starts_with("#version") {
|
||||
continue;
|
||||
}
|
||||
// Legacy sampling entry points.
|
||||
l = l.replace("texture2D(", "texture(");
|
||||
l = l.replace("texture3D(", "texture(");
|
||||
l = l.replace("textureCube(", "texture(");
|
||||
// naga's GLSL frontend has no combined sampler2D uniforms: split
|
||||
// each into (texture2D, sampler) and combine at the call site
|
||||
// (`texture(sampler2D(tex, tex_s), uv)` — the same style naga's
|
||||
// own GLSL tests use).
|
||||
for name in &textures {
|
||||
l = l.replace(
|
||||
&format!("texture({name},"),
|
||||
&format!("texture(sampler2D({name}, {name}_s),"),
|
||||
);
|
||||
}
|
||||
// WGSL has no host-shareable bool: bool uniforms live in the
|
||||
// block as `int`, so their uses become `bool(x)` (nonzero test).
|
||||
for (ty, name) in &uniforms {
|
||||
if *ty == UniformType::Bool {
|
||||
l = replace_ident(&l, name, &format!("bool({name})"));
|
||||
}
|
||||
}
|
||||
// Explicit interface locations (C++ ConvertGlslToVulkan did this
|
||||
// for the two known globals; shaders with extra varyings — e.g.
|
||||
// cornerpin's perspective helpers — get sequential locations so
|
||||
// nothing collides at location 0).
|
||||
let trimmed = l.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix("in ") {
|
||||
if let Some((name, _ty)) = parse_plain_global(rest) {
|
||||
let indent = &l[..l.len() - trimmed.len()];
|
||||
l = format!("{indent}layout(location = {in_loc}) in {}", rest.trim());
|
||||
varyings.push(name);
|
||||
in_loc += 1;
|
||||
}
|
||||
} else if let Some(rest) = trimmed.strip_prefix("out ") {
|
||||
if parse_plain_global(rest).is_some() {
|
||||
let indent = &l[..l.len() - trimmed.len()];
|
||||
l = format!("{indent}layout(location = {out_loc}) out {}", rest.trim());
|
||||
out_loc += 1;
|
||||
}
|
||||
}
|
||||
src.push_str(&l);
|
||||
src.push('\n');
|
||||
}
|
||||
|
||||
let module = naga::front::glsl::Frontend::default()
|
||||
.parse(&naga::front::glsl::Options::from(naga::ShaderStage::Fragment), &src)
|
||||
.map_err(|e| Error::Failed(format!("GLSL parse failed: {e:?}")))?;
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map_err(|e| Error::Failed(format!("translated shader failed validation: {e:?}")))?;
|
||||
let wgsl = naga::back::wgsl::write_string(&module, &info, naga::back::wgsl::WriterFlags::empty())
|
||||
.map_err(|e| Error::Failed(format!("WGSL emission failed: {e:?}")))?;
|
||||
|
||||
// std140 offsets (declaration order; the block tail pads to 16).
|
||||
let mut offset = 0usize;
|
||||
let mut decls = Vec::with_capacity(uniforms.len());
|
||||
for (ty, name) in uniforms {
|
||||
let align = ty.align();
|
||||
offset = offset.next_multiple_of(align);
|
||||
decls.push(UniformDecl { name, ty, offset });
|
||||
offset += ty.size();
|
||||
}
|
||||
let uniform_block_bytes = if decls.is_empty() {
|
||||
0
|
||||
} else {
|
||||
offset.next_multiple_of(16)
|
||||
};
|
||||
|
||||
Ok(TranslatedShader {
|
||||
wgsl,
|
||||
uniforms: decls,
|
||||
uniform_block_bytes,
|
||||
textures,
|
||||
varyings,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The minimal node-shader shape: texture input + one float uniform.
|
||||
#[test]
|
||||
fn translates_minimal_effect_shader() {
|
||||
let glsl = r#"
|
||||
uniform sampler2D tex_in;
|
||||
uniform float gain_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
frag_color = texture(tex_in, ove_texcoord) * gain_in;
|
||||
}
|
||||
"#;
|
||||
let out = translate(glsl).expect("translate");
|
||||
assert_eq!(out.textures, vec!["tex_in"]);
|
||||
assert_eq!(out.uniforms.len(), 1);
|
||||
assert_eq!(out.uniforms[0].name, "gain_in");
|
||||
assert_eq!(out.uniforms[0].ty, UniformType::Float);
|
||||
assert_eq!(out.uniforms[0].offset, 0);
|
||||
assert_eq!(out.uniform_block_bytes, 16);
|
||||
assert!(out.wgsl.contains("fn main"), "WGSL entry point: {}", out.wgsl);
|
||||
}
|
||||
|
||||
/// bool/int/vec/color-shaped uniforms pack with std140 offsets.
|
||||
#[test]
|
||||
fn std140_offsets_match_the_cpp_table() {
|
||||
let glsl = r#"
|
||||
uniform sampler2D tex_in;
|
||||
uniform bool flag_in;
|
||||
uniform vec2 center_in;
|
||||
uniform float radius_in;
|
||||
uniform vec4 color_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
frag_color = flag_in ? color_in * radius_in : vec4(c.xy + center_in, c.zw);
|
||||
}
|
||||
"#;
|
||||
let out = translate(glsl).expect("translate");
|
||||
let offsets: Vec<(&str, usize)> = out
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| (u.name.as_str(), u.offset))
|
||||
.collect();
|
||||
// bool 4/4 @0; vec2 align 8 @8; float 4 @16; vec4 align 16 @32.
|
||||
assert_eq!(
|
||||
offsets,
|
||||
vec![
|
||||
("flag_in", 0),
|
||||
("center_in", 8),
|
||||
("radius_in", 16),
|
||||
("color_in", 32)
|
||||
]
|
||||
);
|
||||
assert_eq!(out.uniform_block_bytes, 48);
|
||||
}
|
||||
|
||||
/// Array uniforms are unsupported (C++ Blit skipped them too).
|
||||
#[test]
|
||||
fn array_uniforms_are_rejected() {
|
||||
let glsl = "uniform float taps_in[8];\nvoid main() {}\n";
|
||||
// The array syntax is not a parseable `uniform <type> <name>;`
|
||||
// line for our parser, so the declaration is left in the body and
|
||||
// naga sees it — either way the translation must not panic.
|
||||
let _ = translate(glsl);
|
||||
}
|
||||
|
||||
/// Uniform packing follows the declared types and std140 offsets.
|
||||
#[test]
|
||||
fn pack_uniforms_maps_node_values() {
|
||||
use oak_node::value::{NodeValue, NodeValueRow};
|
||||
let glsl = r#"
|
||||
uniform sampler2D tex_in;
|
||||
uniform float gain_in;
|
||||
uniform bool flag_in;
|
||||
uniform vec4 color_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() { frag_color = texture(tex_in, ove_texcoord); }
|
||||
"#;
|
||||
let out = translate(glsl).unwrap();
|
||||
let mut row = NodeValueRow::new();
|
||||
row.insert("gain_in".into(), NodeValue::Float(0.5));
|
||||
row.insert("flag_in".into(), NodeValue::Boolean(true));
|
||||
row.insert("color_in".into(), NodeValue::Color([0.1, 0.2, 0.3, 0.4]));
|
||||
let buf = pack_uniforms(&out, &row);
|
||||
assert_eq!(buf.len(), out.uniform_block_bytes);
|
||||
let gain = out.uniforms.iter().find(|u| u.name == "gain_in").unwrap();
|
||||
assert_eq!(
|
||||
f32::from_le_bytes(buf[gain.offset..gain.offset + 4].try_into().unwrap()),
|
||||
0.5
|
||||
);
|
||||
let flag = out.uniforms.iter().find(|u| u.name == "flag_in").unwrap();
|
||||
assert_eq!(
|
||||
i32::from_le_bytes(buf[flag.offset..flag.offset + 4].try_into().unwrap()),
|
||||
1
|
||||
);
|
||||
let color = out.uniforms.iter().find(|u| u.name == "color_in").unwrap();
|
||||
for (i, want) in [0.1f32, 0.2, 0.3, 0.4].iter().enumerate() {
|
||||
let at = color.offset + i * 4;
|
||||
assert_eq!(f32::from_le_bytes(buf[at..at + 4].try_into().unwrap()), *want);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- GPU runner tests (skipped without an adapter) -------------------
|
||||
|
||||
fn gpu() -> Option<std::sync::Arc<crate::backend::GpuContext>> {
|
||||
crate::backend::GpuContext::create(crate::backend::BackendKind::Auto)
|
||||
}
|
||||
|
||||
fn f32_frame(w: i32, h: i32, fill: impl Fn(usize) -> [f32; 4]) -> crate::texture::Frame {
|
||||
use crate::texture::Frame;
|
||||
let mut frame = Frame::new();
|
||||
let mut pod = crate::frame::VideoParamsPod::default();
|
||||
pod.width = w;
|
||||
pod.height = h;
|
||||
pod.format = oak_core::PixelFormat::F32 as i32;
|
||||
frame.set_video_params(pod);
|
||||
frame.allocate();
|
||||
for px in 0..(w * h) as usize {
|
||||
let rgba = fill(px);
|
||||
for (c, v) in rgba.iter().enumerate() {
|
||||
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
|
||||
.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
fn pixel(out: &crate::texture::Frame, x: usize) -> [f32; 4] {
|
||||
let mut rgba = [0.0f32; 4];
|
||||
for (c, v) in rgba.iter_mut().enumerate() {
|
||||
*v = f32::from_le_bytes(out.data[(x * 4 + c) * 4..(x * 4 + c) * 4 + 4].try_into().unwrap());
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
/// The real opacity shader through the full runner: pixels are
|
||||
/// multiplied by the factor (and the UV convention is identity —
|
||||
/// a flip would move the non-uniform pixels around).
|
||||
#[test]
|
||||
fn gpu_opacity_effect_scales_pixels() {
|
||||
let Some(ctx) = gpu() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let (_core, behavior) = oak_node::factory::Factory::global()
|
||||
.create_any("org.olivevideoeditor.Olive.opacity")
|
||||
.unwrap();
|
||||
let glsl = behavior.shader_code("").unwrap();
|
||||
let effect = compile_effect(&ctx, "test/opacity", &glsl, false).unwrap();
|
||||
|
||||
let frame = f32_frame(16, 4, |px| {
|
||||
[0.2 + 0.01 * px as f32, 0.4, 0.6, 1.0]
|
||||
});
|
||||
let src = ctx.create_texture(16, 4).unwrap();
|
||||
ctx.upload(src, &frame).unwrap();
|
||||
let dst = ctx.create_texture(16, 4).unwrap();
|
||||
|
||||
let mut row = oak_node::value::NodeValueRow::new();
|
||||
row.insert("opacity_in".into(), oak_node::value::NodeValue::Float(0.5));
|
||||
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 4), 1).unwrap();
|
||||
|
||||
let out = ctx.download(dst).unwrap();
|
||||
for px in 0..16usize {
|
||||
let want = (0.2 + 0.01 * px as f32) * 0.5;
|
||||
let got = pixel(&out, px)[0];
|
||||
assert!((got - want).abs() < 1e-4, "px {px}: got {got}, want {want}");
|
||||
}
|
||||
ctx.destroy_texture(src);
|
||||
ctx.destroy_texture(dst);
|
||||
}
|
||||
|
||||
/// The real blur shader: radius 0 is an exact passthrough (the
|
||||
/// shader's MODE_NONE branch), and a 2px horizontal box blur on a
|
||||
/// step edge lands exactly half-and-half at the boundary pixels.
|
||||
#[test]
|
||||
fn gpu_blur_effect_passthrough_and_step() {
|
||||
let Some(ctx) = gpu() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let (_core, behavior) = oak_node::factory::Factory::global()
|
||||
.create_any("org.olivevideoeditor.Olive.blur")
|
||||
.unwrap();
|
||||
let glsl = behavior.shader_code("").unwrap();
|
||||
let effect = compile_effect(&ctx, "test/blur", &glsl, false).unwrap();
|
||||
|
||||
let src = ctx.create_texture(16, 1).unwrap();
|
||||
let dst = ctx.create_texture(16, 1).unwrap();
|
||||
let step = f32_frame(16, 1, |px| {
|
||||
if px < 8 {
|
||||
[0.0, 0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
}
|
||||
});
|
||||
ctx.upload(src, &step).unwrap();
|
||||
|
||||
// radius 0: passthrough.
|
||||
let mut row = oak_node::value::NodeValueRow::new();
|
||||
row.insert("method_in".into(), oak_node::value::NodeValue::Combo(0));
|
||||
row.insert("radius_in".into(), oak_node::value::NodeValue::Float(0.0));
|
||||
row.insert("horiz_in".into(), oak_node::value::NodeValue::Boolean(true));
|
||||
row.insert("vert_in".into(), oak_node::value::NodeValue::Boolean(false));
|
||||
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1).unwrap();
|
||||
let out = ctx.download(dst).unwrap();
|
||||
assert_eq!(out.data, step.data, "radius 0 is a passthrough");
|
||||
|
||||
// radius 2 horizontal box: out(x) = 0.5 * (in[x-1] + in[x+1]).
|
||||
row.insert("radius_in".into(), oak_node::value::NodeValue::Float(2.0));
|
||||
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1).unwrap();
|
||||
let out = ctx.download(dst).unwrap();
|
||||
for x in 0..16usize {
|
||||
let got = pixel(&out, x)[0];
|
||||
let want = match x {
|
||||
0 => 0.0, // first tap out of bounds (repeat_edge off)
|
||||
7 | 8 => 0.5,
|
||||
15 => 0.5, // second tap out of bounds
|
||||
_ if x < 7 => 0.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"px {x}: got {got}, want {want}"
|
||||
);
|
||||
}
|
||||
ctx.destroy_texture(src);
|
||||
ctx.destroy_texture(dst);
|
||||
}
|
||||
|
||||
/// Every registered node type that ships a shader must translate (the
|
||||
/// all-shaders sweep). OCIO-stubbed shaders (`%1` markers needing the
|
||||
/// OCIO-generated function text) retry with the real OCIO stub first;
|
||||
/// only nodes whose stub is unavailable *and* unknown fail outright.
|
||||
#[test]
|
||||
fn all_registered_shaders_translate() {
|
||||
let mut ok = Vec::new();
|
||||
let mut ocio_stubbed = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
for meta in oak_node::factory::Factory::global().entries() {
|
||||
let (_core, behavior) = (meta.create)();
|
||||
let Some(glsl) = behavior.shader_code("") else {
|
||||
continue;
|
||||
};
|
||||
match translate(&glsl) {
|
||||
Ok(_) => ok.push(meta.type_id),
|
||||
Err(e) => {
|
||||
let msg = format!("{e:?}");
|
||||
// The unresolved OCIO stub is the one sanctioned
|
||||
// failure mode (chromakey & co. call into
|
||||
// OCIO-generated functions). Retry with the real OCIO
|
||||
// stub: the wiring must make these translate. When no
|
||||
// OCIO config is available (stub build), fall back to
|
||||
// a pass-through function so the sweep still covers
|
||||
// the node's own shader body.
|
||||
let retried = crate::eval::ocio_stub_for(meta.type_id)
|
||||
.or_else(|| {
|
||||
crate::eval::OCIO_SHADER_STUBS
|
||||
.iter()
|
||||
.find(|(id, ..)| *id == meta.type_id)
|
||||
.map(|(_, fn_name, ..)| {
|
||||
format!("vec4 {fn_name}(vec4 c) {{ return c; }}")
|
||||
})
|
||||
});
|
||||
match retried {
|
||||
Some(stub) => match translate(&behavior.shader_code(&stub).unwrap()) {
|
||||
Ok(_) => ok.push(meta.type_id),
|
||||
Err(e) => {
|
||||
failed.push((meta.type_id, format!("with OCIO stub: {e:?}")))
|
||||
}
|
||||
},
|
||||
None if msg.contains("SceneLinear") || msg.contains("UnknownFunction") => {
|
||||
// Not in the stub table but still OCIO-shaped:
|
||||
// report separately, not as a regression.
|
||||
ocio_stubbed.push(meta.type_id);
|
||||
}
|
||||
None => failed.push((meta.type_id, msg)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("shader sweep: {} ok, {} ocio-stubbed", ok.len(), ocio_stubbed.len());
|
||||
for id in &ocio_stubbed {
|
||||
eprintln!(" ocio-stubbed: {id}");
|
||||
}
|
||||
for (id, msg) in &failed {
|
||||
eprintln!(" FAILED: {id}: {}", &msg[..msg.len().min(200)]);
|
||||
}
|
||||
assert!(failed.is_empty(), "{} shaders failed to translate", failed.len());
|
||||
assert!(!ok.is_empty(), "no shaders translated at all");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -160,6 +160,11 @@ pub trait JobDispatch: Send + Sync {
|
||||
/// flight; the completion fires `Error::State`). Default no-op: only
|
||||
/// the process backend schedules.
|
||||
fn cancel_preview_frame(&self, _sequence: u64, _frame: i64, _version: u64) {}
|
||||
|
||||
/// Set (or clear) the graph snapshot path sent to workers via
|
||||
/// `load_graph` (M16 S1). Default no-op: only the process backend
|
||||
/// ships snapshots.
|
||||
fn set_graph_snapshot(&self, _path: Option<String>) {}
|
||||
}
|
||||
|
||||
/// Thread-free job dispatcher (M15 S2). Executes jobs on the calling
|
||||
@@ -267,7 +272,10 @@ impl JobDispatch for InlineDispatcher {
|
||||
|
||||
/// Graph snapshot files shared with worker processes (C++
|
||||
/// write_graph_snapshot + path refcounting): a snapshot is written once
|
||||
/// and reference-counted; the file is unlinked at zero.
|
||||
/// per project (uuid) and undo revision, reference-counted, and NEVER
|
||||
/// unlinked at zero refs (M16 S1: a worker may still hold the path for
|
||||
/// a late `load_graph`). The whole store directory is cleared by
|
||||
/// [`GraphSnapshotStore::cleanup`] at manager shutdown.
|
||||
pub struct GraphSnapshotStore {
|
||||
entries: Mutex<HashMap<String, SnapshotEntry>>,
|
||||
dir: std::path::PathBuf,
|
||||
@@ -294,21 +302,46 @@ impl GraphSnapshotStore {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Write (or reuse) the snapshot for a project copy; returns the path
|
||||
/// token with the reference count incremented.
|
||||
pub fn acquire(&mut self, project_copy: u64) -> Result<String> {
|
||||
let path = self.dir.join(format!("{project_copy}.json"));
|
||||
/// Write (or reuse) the snapshot for `revision` of `project`; returns
|
||||
/// the path token with the reference count incremented. The project is
|
||||
/// serialized to the store's XML snapshot format; the same (uuid,
|
||||
/// revision) pair is never rewritten. The write is atomic: the XML is
|
||||
/// staged to a temp file and renamed over the final path, so a worker
|
||||
/// reading the file sees a complete snapshot — never a torn write.
|
||||
pub fn acquire(
|
||||
&self,
|
||||
project: &std::sync::Mutex<oak_node::project::Project>,
|
||||
revision: u64,
|
||||
) -> Result<String> {
|
||||
let (uuid, xml) = {
|
||||
let g = lock(project);
|
||||
let xml = oak_node::serializer::save(&g)
|
||||
.map_err(|e| Error::Failed(format!("save graph snapshot: {e}")))?;
|
||||
(g.uuid.clone(), xml)
|
||||
};
|
||||
// Per-project filename: two projects never share a snapshot path,
|
||||
// so a stale load of another project's graph can never be mistaken
|
||||
// for this project's (identity collisions are otherwise the norm —
|
||||
// fresh projects reuse small identity numbers).
|
||||
let path = self.dir.join(format!("graph-{uuid}-{revision}.xml"));
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut entries = lock(&self.entries);
|
||||
if let Some(entry) = entries.get_mut(&path_str) {
|
||||
entry.refs += 1;
|
||||
return Ok(path_str);
|
||||
}
|
||||
// Minimal snapshot payload: the copied-project identity. The real
|
||||
// graph serialization is owned by oaknode.
|
||||
let payload = format!("{{\"project_copy\":{project_copy}}}\n");
|
||||
std::fs::write(&path, payload)
|
||||
.map_err(|e| Error::Failed(format!("write snapshot: {e}")))?;
|
||||
// Atomic staging: temp file + rename. The rename is a single
|
||||
// directory entry swap, so a concurrent worker load observes
|
||||
// either the old file or the complete new one.
|
||||
let tmp = self
|
||||
.dir
|
||||
.join(format!("graph-{uuid}-{revision}.{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &xml)
|
||||
.map_err(|e| Error::Failed(format!("write snapshot temp: {e}")))?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(Error::Failed(format!("rename snapshot: {e}")));
|
||||
}
|
||||
entries.insert(
|
||||
path_str.clone(),
|
||||
SnapshotEntry {
|
||||
@@ -319,24 +352,31 @@ impl GraphSnapshotStore {
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
/// Drop one reference; unlinks the file at zero.
|
||||
pub fn release(&mut self, path: &str) {
|
||||
/// Drop one reference. The FILE IS NOT UNLINKED: a worker may still
|
||||
/// hold the path for a late `load_graph` (M16 S1), and per-project
|
||||
/// filenames mean a stale snapshot is never confused with a live one.
|
||||
/// Entries leave the table at zero refs; the files themselves are
|
||||
/// removed wholesale by [`GraphSnapshotStore::cleanup`].
|
||||
pub fn release(&self, path: &str) {
|
||||
let mut entries = lock(&self.entries);
|
||||
let remove = if let Some(entry) = entries.get_mut(path) {
|
||||
if let Some(entry) = entries.get_mut(path) {
|
||||
entry.refs = entry.refs.saturating_sub(1);
|
||||
entry.refs == 0
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if remove {
|
||||
entries.remove(path);
|
||||
let _ = std::fs::remove_file(path);
|
||||
if entry.refs == 0 {
|
||||
entries.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the store's whole directory — called from manager shutdown
|
||||
/// only, when no worker can still reference a snapshot file.
|
||||
pub fn cleanup(&self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
lock(&self.entries).clear();
|
||||
}
|
||||
|
||||
/// Mark a snapshot as already uploaded to all live children
|
||||
/// (C++ set_graph_path_cached).
|
||||
pub fn mark_cached(&mut self, path: &str, cached: bool) {
|
||||
pub fn mark_cached(&self, path: &str, cached: bool) {
|
||||
if let Some(entry) = lock(&self.entries).get_mut(path) {
|
||||
entry.cached = cached;
|
||||
}
|
||||
@@ -384,6 +424,7 @@ mod tests {
|
||||
time: Rational::new(tag as i64, 1),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer: 0,
|
||||
project: String::new(),
|
||||
time: Rational::new(0, 1),
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
@@ -454,6 +495,7 @@ mod tests {
|
||||
time: Rational::new(0, 1),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer: 0,
|
||||
project: String::new(),
|
||||
time: Rational::new(0, 1),
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
@@ -491,6 +533,7 @@ mod tests {
|
||||
let ok: Producer = Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
|
||||
let params = Arc::new(VideoTicketParams {
|
||||
viewer: 0,
|
||||
project: String::new(),
|
||||
time: Rational::new(0, 1),
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
@@ -534,10 +577,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_store_refcount_and_unlink() {
|
||||
let mut store = GraphSnapshotStore::new();
|
||||
let p1 = store.acquire(42).unwrap();
|
||||
let p2 = store.acquire(42).unwrap();
|
||||
fn snapshot_store_refcount_and_cleanup() {
|
||||
let store = GraphSnapshotStore::new();
|
||||
let project = oak_node::project::Project::new();
|
||||
let p1 = store.acquire(&project, 1).unwrap();
|
||||
let p2 = store.acquire(&project, 1).unwrap();
|
||||
assert_eq!(p1, p2, "second acquire reuses the file");
|
||||
assert!(std::path::Path::new(&p1).exists());
|
||||
store.mark_cached(&p1, true);
|
||||
@@ -550,7 +594,18 @@ mod tests {
|
||||
"refcount 1: still alive"
|
||||
);
|
||||
store.release(&p1);
|
||||
assert!(!std::path::Path::new(&p1).exists(), "refcount 0: unlinked");
|
||||
assert_eq!(store.refs(&p1), 0);
|
||||
assert_eq!(store.refs(&p1), 0, "entry removed at zero refs");
|
||||
// M16 S1: release no longer unlinks the file (a worker may still
|
||||
// hold the path for a late load_graph); the store directory is
|
||||
// cleared wholesale at shutdown instead.
|
||||
assert!(
|
||||
std::path::Path::new(&p1).exists(),
|
||||
"refcount 0: file retained for late loads"
|
||||
);
|
||||
store.cleanup();
|
||||
assert!(
|
||||
!std::path::Path::new(&p1).exists(),
|
||||
"cleanup removes the snapshot directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! M12 phase 2: the graph-driven sequence renderer.
|
||||
//!
|
||||
//! Builds a real node graph (sequence -> video track list -> video tracks
|
||||
//! -> clip blocks -> footage), evaluates the clip overlapping the request
|
||||
//! time through the traverser and composites the decoded frames — the same
|
||||
//! path the engine's viewer uses. Track 0 is the topmost stack element.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_core::{PixelFormat, Rational, TimeRange};
|
||||
use oak_node::block::ClipBlockBehavior;
|
||||
use oak_node::footage::FootageBehavior;
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::node::NodeCore;
|
||||
use oak_node::project::Project;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior};
|
||||
|
||||
use oak_render::texture::Texture;
|
||||
|
||||
mod common;
|
||||
|
||||
/// Unique temp path per test (the process id disambiguates parallel test
|
||||
/// binaries; the tag separates tests inside one binary).
|
||||
fn clip_path(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("oakrender_graph_{tag}_{}.mp4", std::process::id()))
|
||||
}
|
||||
|
||||
/// One sequence + one video track list with one track per clip
|
||||
/// `(filename, [in, out))`. Track 0 (the first entry) composites on top.
|
||||
fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc<Mutex<Project>>, NodeId) {
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let (score, sbehavior) = SequenceBehavior::create();
|
||||
seq = p.graph.add_node(score, sbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackListBehavior::create();
|
||||
let tl = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
for &(path, in_, out) in clips {
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let track = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let mut footage = FootageBehavior::new(path);
|
||||
footage.probe().expect("probe the generated clip");
|
||||
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
||||
|
||||
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||
let clip = p.graph.add_node(ccore, cbehavior);
|
||||
p.graph
|
||||
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to clip");
|
||||
|
||||
let clip_behavior = p
|
||||
.graph
|
||||
.get_mut(clip)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ClipBlockBehavior>()
|
||||
.expect("clip block");
|
||||
clip_behavior.core.range = TimeRange::new(in_, out);
|
||||
|
||||
p.graph
|
||||
.get_mut(track)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track")
|
||||
.append_block(clip);
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(track);
|
||||
}
|
||||
|
||||
p.graph
|
||||
.get_mut(seq)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.expect("sequence")
|
||||
.track_lists
|
||||
.push(tl);
|
||||
}
|
||||
(project, seq)
|
||||
}
|
||||
|
||||
/// The raw CPU frame bytes of a rendered texture.
|
||||
fn frame_data(texture: &Texture) -> &[u8] {
|
||||
let Texture::Cpu(frame) = texture else {
|
||||
panic!("graph render produced a non-CPU texture");
|
||||
};
|
||||
&frame.data
|
||||
}
|
||||
|
||||
/// Two clips on two tracks, non-overlapping in time: at each request time
|
||||
/// exactly one clip covers, and its output must match the single-track
|
||||
/// render byte for byte (same decode + same composite path).
|
||||
#[test]
|
||||
fn graph_sequence_renders_two_tracks() {
|
||||
let path_a = clip_path("two_tracks_a");
|
||||
let path_b = clip_path("two_tracks_b");
|
||||
oak_codec::testmedia::write_test_clip(&path_a, 64, 64, 10, 10).expect("clip A generation");
|
||||
oak_codec::testmedia::write_test_clip(&path_b, 32, 32, 10, 10).expect("clip B generation");
|
||||
|
||||
let (project, seq) = build_project(&[
|
||||
(&path_a.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
|
||||
(&path_b.to_string_lossy(), Rational::new(1, 1), Rational::new(2, 1)),
|
||||
]);
|
||||
|
||||
let t05 = oak_render::eval::render_graph_frame(
|
||||
&project,
|
||||
seq,
|
||||
Rational::new(1, 2),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("render t=0.5");
|
||||
let t15 = oak_render::eval::render_graph_frame(
|
||||
&project,
|
||||
seq,
|
||||
Rational::new(3, 2),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("render t=1.5");
|
||||
assert_eq!(t05.size(), (64, 64));
|
||||
assert_eq!(t15.size(), (64, 64));
|
||||
|
||||
// Solo renders of each clip for byte comparison.
|
||||
let (solo_a, seq_a) = build_project(&[(&path_a.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1))]);
|
||||
let solo_a_tex = oak_render::eval::render_graph_frame(
|
||||
&solo_a,
|
||||
seq_a,
|
||||
Rational::new(1, 2),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("solo A render");
|
||||
let (solo_b, seq_b) = build_project(&[(&path_b.to_string_lossy(), Rational::new(1, 1), Rational::new(2, 1))]);
|
||||
let solo_b_tex = oak_render::eval::render_graph_frame(
|
||||
&solo_b,
|
||||
seq_b,
|
||||
Rational::new(3, 2),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("solo B render");
|
||||
|
||||
// Each time picks exactly the clip covering it, unchanged by the other
|
||||
// track (B is 32x32 and must scale up to the 64x64 target).
|
||||
assert_eq!(frame_data(&t05), frame_data(&solo_a_tex), "t=0.5 renders clip A");
|
||||
assert_eq!(frame_data(&t15), frame_data(&solo_b_tex), "t=1.5 renders clip B");
|
||||
assert_ne!(frame_data(&t05), frame_data(&t15), "the two clips differ");
|
||||
|
||||
// Both frames carry real content.
|
||||
assert!(frame_data(&t05).iter().any(|&b| b != 0), "t=0.5 is not black");
|
||||
assert!(frame_data(&t15).iter().any(|&b| b != 0), "t=1.5 is not black");
|
||||
|
||||
let _ = std::fs::remove_file(&path_a);
|
||||
let _ = std::fs::remove_file(&path_b);
|
||||
}
|
||||
|
||||
/// The driver rejects bad arguments explainably: non-F32 format, a
|
||||
/// non-positive size, and a missing viewer.
|
||||
#[test]
|
||||
fn graph_render_rejects_bad_inputs() {
|
||||
let (project, seq) = build_project(&[]);
|
||||
|
||||
let err = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (64, 64), PixelFormat::U8)
|
||||
.err()
|
||||
.expect("non-F32 format rejected");
|
||||
assert_eq!(err.code(), oak_render::error::Error::Invalid.code());
|
||||
|
||||
let err = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (0, 64), PixelFormat::F32)
|
||||
.err()
|
||||
.expect("non-positive size rejected");
|
||||
assert_eq!(err.code(), oak_render::error::Error::Invalid.code());
|
||||
|
||||
let err = oak_render::eval::render_graph_frame(&project, NodeId::INVALID, Rational::new(0, 1), (64, 64), PixelFormat::F32)
|
||||
.err()
|
||||
.expect("missing viewer rejected");
|
||||
assert_eq!(err.code(), oak_render::error::Error::NotFound.code());
|
||||
}
|
||||
|
||||
/// One sequence + one track with a single clip, with an effect node
|
||||
/// inserted between the footage and the clip block: `insert_effect`
|
||||
/// receives the project lock plus the footage and clip node ids, rewires
|
||||
/// the graph, and returns the effect node id. The clip keeps the
|
||||
/// `(in, out)` range from `clip`.
|
||||
fn build_effect_project(
|
||||
clip: (&str, Rational, Rational),
|
||||
insert_effect: impl FnOnce(&mut Project, NodeId, NodeId) -> NodeId,
|
||||
) -> (Arc<Mutex<Project>>, NodeId) {
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let (score, sbehavior) = SequenceBehavior::create();
|
||||
seq = p.graph.add_node(score, sbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackListBehavior::create();
|
||||
let tl = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let track = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let mut footage = FootageBehavior::new(clip.0);
|
||||
footage.probe().expect("probe the generated clip");
|
||||
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
||||
|
||||
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||
let clip_node = p.graph.add_node(ccore, cbehavior);
|
||||
p.graph
|
||||
.connect(footage, clip_node, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to clip");
|
||||
|
||||
let clip_behavior = p
|
||||
.graph
|
||||
.get_mut(clip_node)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ClipBlockBehavior>()
|
||||
.expect("clip block");
|
||||
clip_behavior.core.range = TimeRange::new(clip.1, clip.2);
|
||||
|
||||
let _effect = insert_effect(&mut p, footage, clip_node);
|
||||
|
||||
p.graph
|
||||
.get_mut(track)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track")
|
||||
.append_block(clip_node);
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(track);
|
||||
p.graph
|
||||
.get_mut(seq)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.expect("sequence")
|
||||
.track_lists
|
||||
.push(tl);
|
||||
}
|
||||
(project, seq)
|
||||
}
|
||||
|
||||
/// The F32 RGBA channel of a 64x64 frame at `(x, y)`.
|
||||
fn channel(data: &[u8], x: usize, y: usize, c: usize) -> f32 {
|
||||
let off = (y * 64 + x) * 16 + c * 4;
|
||||
f32::from_le_bytes(data[off..off + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// M12 phase 3a: an opacity shader job (scalar 0.5) pushed by the effect
|
||||
/// node is resolved on the shared GPU context and composited — each color
|
||||
/// channel ends up as the plain render halved twice (the shader scales the
|
||||
/// straight-alpha vec4 by 0.5, then the alpha-over composite applies the
|
||||
/// halved alpha again), i.e. a 0.25 channel ratio. Skipped (with a note)
|
||||
/// when no GPU adapter exists.
|
||||
#[test]
|
||||
fn shader_job_opacity_halves_pixels() {
|
||||
if oak_render::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("skipping shader_job_opacity_halves_pixels: no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let path = clip_path("opacity_job");
|
||||
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("clip generation");
|
||||
|
||||
let (plain_project, plain_seq) = build_project(&[(
|
||||
&path.to_string_lossy(),
|
||||
Rational::new(0, 1),
|
||||
Rational::new(1, 1),
|
||||
)]);
|
||||
let plain_tex = oak_render::eval::render_graph_frame(
|
||||
&plain_project,
|
||||
plain_seq,
|
||||
Rational::new(0, 1),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("plain render");
|
||||
let plain = frame_data(&plain_tex).to_vec();
|
||||
|
||||
let (effect_project, effect_seq) = build_effect_project(
|
||||
(&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
|
||||
|p, footage, clip| {
|
||||
let (ecore, ebehavior) = oak_node::nodes::opacity::create();
|
||||
let effect = p.graph.add_node(ecore, ebehavior);
|
||||
p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1);
|
||||
p.graph
|
||||
.connect(footage, effect, oak_node::nodes::opacity::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to effect");
|
||||
p.graph
|
||||
.connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect effect to clip");
|
||||
p.graph
|
||||
.get_mut(effect)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value(
|
||||
oak_node::nodes::opacity::VALUE_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Float(0.5),
|
||||
);
|
||||
effect
|
||||
},
|
||||
);
|
||||
let effect_tex = oak_render::eval::render_graph_frame(
|
||||
&effect_project,
|
||||
effect_seq,
|
||||
Rational::new(0, 1),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("opacity render");
|
||||
let blurred = frame_data(&effect_tex).to_vec();
|
||||
|
||||
// Sample away from the x=32 half boundary (MPEG-2 chroma bleed and
|
||||
// luma ringing stay within a few pixels of it).
|
||||
let mut ratios: Vec<f32> = Vec::new();
|
||||
for y in 4..60 {
|
||||
for x in (4..24).chain(40..60) {
|
||||
for c in 0..3 {
|
||||
let a = channel(&plain, x, y, c);
|
||||
if a > 0.02 {
|
||||
ratios.push(channel(&blurred, x, y, c) / a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(ratios.len() >= 512, "too few comparable samples: {}", ratios.len());
|
||||
let mean = ratios.iter().sum::<f32>() / ratios.len() as f32;
|
||||
assert!(
|
||||
(mean - 0.25).abs() < 0.02,
|
||||
"opacity channel ratio {mean} is not 0.25"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// M12 phase 3a: a box-blur shader job (radius 2, both axes) is resolved
|
||||
/// on the shared GPU context — the output differs from the plain render
|
||||
/// byte-wise, the hard left/right half boundary softens (the per-pixel
|
||||
/// step at the boundary shrinks), and left-half content bleeds into the
|
||||
/// boundary pixel on the right half. Skipped when no GPU adapter exists.
|
||||
#[test]
|
||||
fn shader_job_blur_smooths_edge() {
|
||||
if oak_render::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("skipping shader_job_blur_smooths_edge: no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let path = clip_path("blur_job");
|
||||
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("clip generation");
|
||||
|
||||
let (plain_project, plain_seq) = build_project(&[(
|
||||
&path.to_string_lossy(),
|
||||
Rational::new(0, 1),
|
||||
Rational::new(1, 1),
|
||||
)]);
|
||||
let plain_tex = oak_render::eval::render_graph_frame(
|
||||
&plain_project,
|
||||
plain_seq,
|
||||
Rational::new(0, 1),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("plain render");
|
||||
let plain = frame_data(&plain_tex).to_vec();
|
||||
|
||||
let (effect_project, effect_seq) = build_effect_project(
|
||||
(&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)),
|
||||
|p, footage, clip| {
|
||||
let (ecore, ebehavior) = oak_node::nodes::blur::create();
|
||||
let effect = p.graph.add_node(ecore, ebehavior);
|
||||
p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1);
|
||||
p.graph
|
||||
.connect(footage, effect, oak_node::nodes::blur::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to effect");
|
||||
p.graph
|
||||
.connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect effect to clip");
|
||||
let core = &mut p.graph.get_mut(effect).unwrap().core;
|
||||
core.set_standard_value(
|
||||
oak_node::nodes::blur::METHOD_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Combo(0),
|
||||
);
|
||||
core.set_standard_value(
|
||||
oak_node::nodes::blur::RADIUS_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Float(2.0),
|
||||
);
|
||||
core.set_standard_value(
|
||||
oak_node::nodes::blur::HORIZ_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Boolean(true),
|
||||
);
|
||||
core.set_standard_value(
|
||||
oak_node::nodes::blur::VERT_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Boolean(true),
|
||||
);
|
||||
effect
|
||||
},
|
||||
);
|
||||
let effect_tex = oak_render::eval::render_graph_frame(
|
||||
&effect_project,
|
||||
effect_seq,
|
||||
Rational::new(0, 1),
|
||||
(64, 64),
|
||||
PixelFormat::F32,
|
||||
)
|
||||
.expect("blur render");
|
||||
let blurred = frame_data(&effect_tex).to_vec();
|
||||
|
||||
// The blur must actually change pixels (a silently dropped job would
|
||||
// fall back to the pass-through input and byte-match the plain frame).
|
||||
assert_ne!(plain, blurred, "the blur job must actually change pixels");
|
||||
|
||||
// Row y=32 (vertically uniform): the boundary step x=31 -> x=32 must
|
||||
// shrink, and the right-side boundary pixel picks up left-half content.
|
||||
let r = |data: &[u8], x: usize| channel(data, x, 32, 0);
|
||||
let plain_step = (r(&plain, 32) - r(&plain, 31)).abs();
|
||||
let blurred_step = (r(&blurred, 32) - r(&blurred, 31)).abs();
|
||||
assert!(
|
||||
blurred_step < plain_step,
|
||||
"boundary step {blurred_step} not below the plain {plain_step}"
|
||||
);
|
||||
assert!(
|
||||
r(&blurred, 32) > r(&plain, 32),
|
||||
"blurred boundary pixel {} not above the plain {}",
|
||||
r(&blurred, 32),
|
||||
r(&plain, 32)
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -492,6 +492,15 @@ impl RenderTask {
|
||||
/// `start_video_ticket` param marshalling).
|
||||
fn build_video_ticket(&self, time: Rational) -> Result<VideoTicketParams> {
|
||||
let (project, viewer_id) = &self.viewer;
|
||||
// The owning project's uuid (M16 S1: graph-mode tickets carry it so
|
||||
// workers render only from their own project's snapshot). Read
|
||||
// without holding the lock across the montage build below
|
||||
// (`std::sync::Mutex` is not reentrant).
|
||||
let project_uuid = project
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.uuid
|
||||
.clone();
|
||||
// Inspect the viewer node WITHOUT holding the project lock across
|
||||
// the montage build below: `video_montage` locks the same project,
|
||||
// and `std::sync::Mutex` is not reentrant — holding it here
|
||||
@@ -522,6 +531,7 @@ impl RenderTask {
|
||||
match footage {
|
||||
Some(filename) => Ok(VideoTicketParams {
|
||||
viewer: viewer_id.identity(),
|
||||
project: project_uuid.clone(),
|
||||
time,
|
||||
force_size: self.force_size(),
|
||||
force_format: self.force_format(),
|
||||
@@ -537,6 +547,7 @@ impl RenderTask {
|
||||
let montage = Self::video_montage(project, *viewer_id, time);
|
||||
Ok(VideoTicketParams {
|
||||
viewer: viewer_id.identity(),
|
||||
project: project_uuid.clone(),
|
||||
time,
|
||||
force_size: self.force_size(),
|
||||
force_format: self.force_format(),
|
||||
|
||||
@@ -51,3 +51,10 @@ oak-plugin = { path = "../oak-plugin" }
|
||||
# re-exporting oakrender::ipc). No liboakengine dylib, no C ABI, no
|
||||
# build.rs link step.
|
||||
oak-render = { path = "../oak-render" }
|
||||
|
||||
[dev-dependencies]
|
||||
# oakcodec's test-media generator (`testmedia::write_test_clip`) for the
|
||||
# graph-mode procpool integration tests — self-contained generated clips
|
||||
# instead of repo fixtures. oakcodec already links in transitively via
|
||||
# oaknode; this re-entry makes it nameable from the test binary.
|
||||
oak-codec = { path = "../oak-codec" }
|
||||
|
||||
+65
-11
@@ -47,14 +47,16 @@ CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release
|
||||
|
||||
Same flow as the C++ main, in the same order:
|
||||
|
||||
1. **parse `--backend <name>`** (default `opengl`; `none` skips
|
||||
renderer creation and the process exits 1, like the C++ main;
|
||||
`cpu` is the M15 headless render mode — no renderer, but the session
|
||||
stays fully operational and renders through the CPU evaluation path).
|
||||
1. **parse `--backend <name>`** (the pool spawns workers with `auto`;
|
||||
`none` skips renderer creation and the process exits 1, like the C++
|
||||
main; `cpu` is the M15 headless render mode — no renderer, but the
|
||||
session stays fully operational and renders through the CPU
|
||||
evaluation path).
|
||||
2. **initialize the render backend** (inside `src/worker.rs`): the
|
||||
oakrender `DisplayRenderer` direct Rust API, falling back to the direct
|
||||
OpenGL renderer exactly like the C++ `create_renderer()` fallback
|
||||
chain. Then the runtime services load (color-manager default config,
|
||||
oakrender `DisplayRenderer` direct Rust API. Under `auto` a failed
|
||||
initialization degrades to a cpu-mode session (logged) instead of
|
||||
exiting — GPU-less machines keep rendering through the CPU fallback.
|
||||
Then the runtime services load (color-manager default config,
|
||||
the oakplugin render executor).
|
||||
3. **write the startup handshake** (protocol version 1, empty shared-memory
|
||||
geometry — same as the C++ worker's startup handshake; the parent
|
||||
@@ -70,6 +72,56 @@ Same flow as the C++ main, in the same order:
|
||||
are dispatched by the session. Responses are one compact JSON line
|
||||
per message.
|
||||
|
||||
## Where the rendering happens (no render thread)
|
||||
|
||||
A frequent reading trap: **oak-worker spawns no render thread.** The
|
||||
binary is a deliberately single-threaded NDJSON loop — `main.rs` →
|
||||
`worker::worker_main` (`src/worker.rs`) reads one control line from
|
||||
stdin, handles it, writes the response, repeat. Rendering happens
|
||||
**synchronously on that loop thread** the moment a batch arrives:
|
||||
|
||||
```
|
||||
stdin "render_batch"
|
||||
→ WorkerSession::handle_render_batch_stream (src/worker.rs)
|
||||
→ render_ticket_to_slot (acquire shm slot)
|
||||
→ render_spec_pixels (the actual render)
|
||||
→ stdout "frame_ready" / "frame_failed" (one line per ticket)
|
||||
```
|
||||
|
||||
`render_spec_pixels` picks between two render paths per ticket:
|
||||
|
||||
- **Graph path** (the default when a project snapshot is loaded):
|
||||
tickets carry `viewer_node` (the sequence's node identity), the worker
|
||||
evaluates the deserialized project through `oak_node::traverser`
|
||||
(time-aware, keyframe-resolving), and every node `value()` pushes a
|
||||
job payload that `oakrender::eval`'s resolve hooks execute — footage
|
||||
decode, **shader effects as wgpu fullscreen passes**
|
||||
(`oakrender::shaderfx`: the nodes' embedded GLSL is translated to WGSL
|
||||
through naga, uniforms packed std140), OFX plugins through the
|
||||
oakplugin render driver. Textures stay GPU-resident across the effect
|
||||
chain; the frame is read back once, at the end, into the shm slot.
|
||||
- **Montage path** (fallback: no snapshot loaded / legacy tickets): the
|
||||
flat wire-spec CPU pipeline (`render_montage_frame_into`).
|
||||
|
||||
The parallelism is **across processes, not threads**: the main process
|
||||
(app side) spawns the pool of `oak-worker` children in
|
||||
`oakrender::procpool::ProcessDispatcher` (`spawn_worker` in
|
||||
`crates/oak-render/src/procpool.rs`), one shared-memory segment and one
|
||||
reader thread per child, and shards ticket batches across them. So
|
||||
"no worker ⇒ no rendering" does not imply a hidden render thread — the
|
||||
dispatcher has no in-process rendering path at all (M15 S2 deleted it;
|
||||
only the test-only inline backend renders in-process). Audio follows
|
||||
the same shape via `render_audio_batch` → `render_audio_ticket_to_slot`
|
||||
→ `oakrender::eval::render_audio_samples_into`.
|
||||
|
||||
Consequences worth knowing before editing this file:
|
||||
|
||||
- A frame render blocks the control loop: `cancel` is observed only
|
||||
between batches (batch granularity), which is why the main process
|
||||
also stops the render loop on its side.
|
||||
- A crash mid-render kills the whole loop — that is the point (OFX
|
||||
crash isolation); the dispatcher reaps, re-queues and respawns.
|
||||
|
||||
## Implemented vs stubbed (nothing is faked)
|
||||
|
||||
**Real:** argument parsing, render backend initialization (real wgpu
|
||||
@@ -86,10 +138,12 @@ slots** (`render_frame` v1 + `render_batch` v2: generated frames,
|
||||
footage decode, montage compositing, end-of-pipe F32→BGRA8 conversion),
|
||||
unknown-type/malformed-message errors, shutdown/EOF termination.
|
||||
|
||||
**Deferred to M15 S2/S3 (documented in `src/worker.rs`):** the loaded
|
||||
project's node-graph render path (plugin-node evaluation per graph
|
||||
snapshot update) — today tickets render from their wire spec
|
||||
(montage/footage/generate), which covers the preview pipeline.
|
||||
**Deferred / known limits:** the graph path is live (tickets with
|
||||
`viewer_node` evaluate the loaded project through the node graph). Still
|
||||
open: transition blocks render as plain cuts, polygon/mask's CPU
|
||||
rasterization stage (the matte generators pass through with a TODO), and
|
||||
audio still renders from the montage wire spec (graph audio evaluation
|
||||
is later work).
|
||||
|
||||
**Deviation from the C++:** the startup handshake omits `gl_major`/
|
||||
`gl_minor` — the oakrender module exposes no GL context version (the C++
|
||||
|
||||
+142
-27
@@ -33,7 +33,12 @@
|
||||
//! - **The main loop.** [`worker_main`] creates the session, loads the
|
||||
//! runtime config (including the oakplugin render executor), writes
|
||||
//! the startup handshake, and serves the stdin/stdout NDJSON loop
|
||||
//! until a `shutdown` message or EOF.
|
||||
//! until a `shutdown` message or EOF. The worker is single-threaded
|
||||
//! by design — there is no render thread: a `render_batch` renders
|
||||
//! its tickets synchronously on this loop thread, and parallelism
|
||||
//! comes from the main process's worker pool
|
||||
//! (`oak_render::procpool`), not from threads here (see "Where the
|
||||
//! rendering happens" in this crate's README).
|
||||
//!
|
||||
//! Real rendering landed in M15 S1: `load_graph` deserializes the graph
|
||||
//! snapshot file (oaknode project XML, with the minimal
|
||||
@@ -66,16 +71,27 @@ use crate::{log_error, PROTOCOL_VERSION};
|
||||
/// A loaded graph snapshot (M15 S1): the snapshot file path plus what it
|
||||
/// deserialized into — a full oaknode project, or only the copied-project
|
||||
/// identity (the minimal `{"project_copy":N}` payload the
|
||||
/// [`oak_render::worker::GraphSnapshotStore`] writes before the app wires
|
||||
/// full graph uploads in S2).
|
||||
/// [`oak_render::worker::GraphSnapshotStore`] wrote before M16 S1 shipped
|
||||
/// full graph snapshots).
|
||||
struct LoadedGraph {
|
||||
/// Snapshot file path (S2: graph_update diffing is path-based).
|
||||
#[allow(dead_code)]
|
||||
path: String,
|
||||
/// The deserialized project (S2: node-graph render path; today only
|
||||
/// montage/footage/generate tickets use the loaded context).
|
||||
#[allow(dead_code)]
|
||||
/// The deserialized project (M16 S1: node-graph tickets render the
|
||||
/// viewer's frame from here; montage/footage/generate tickets use the
|
||||
/// loaded context too).
|
||||
project: Option<Arc<Mutex<oak_node::project::Project>>>,
|
||||
/// The loaded snapshot's owning project uuid; `None` for the
|
||||
/// identity-only legacy payload. Graph-mode tickets (which carry their
|
||||
/// owning project's uuid) only render from this graph when it matches —
|
||||
/// a stale snapshot from a different project must not answer foreign
|
||||
/// viewer identities (M16 S1 cross-process snapshot race).
|
||||
project_uuid: Option<String>,
|
||||
/// Source-identity -> loaded-id map (see
|
||||
/// [`oak_node::serializer::load_with_id_map`]): tickets carry viewer
|
||||
/// identities from the *saved* project, while the loaded graph's arena
|
||||
/// slots differ (deleted slots shift every later node).
|
||||
id_map: std::collections::HashMap<u64, oak_node::id::NodeId>,
|
||||
project_copy: u64,
|
||||
}
|
||||
|
||||
@@ -280,12 +296,22 @@ impl WorkerSession {
|
||||
/// creation, anything else initializes the render backend through the
|
||||
/// oakrender crate's direct Rust API (dynamic -> OpenGL fallback).
|
||||
/// The M15 `"cpu"` backend is the headless render mode: no renderer,
|
||||
/// CPU evaluation + decode via [`oak_render::eval`].
|
||||
/// CPU evaluation + decode via [`oak_render::eval`]. M16 S1: a failed
|
||||
/// renderer init for any other backend (e.g. "auto" on a GPU-less
|
||||
/// host) is tolerated — the session continues headless and tickets
|
||||
/// evaluate through the CPU path.
|
||||
pub fn create(backend: &str) -> Result<WorkerSession, String> {
|
||||
let renderer = if is_no_backend(backend) || is_cpu_backend(backend) {
|
||||
None
|
||||
} else {
|
||||
Some(Renderer::create(backend)?)
|
||||
let renderer = match backend {
|
||||
b if is_no_backend(b) || is_cpu_backend(b) => None,
|
||||
b => match Renderer::create(b) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
log_error(&format!(
|
||||
"session: {b} renderer init failed ({e}); continuing headless (CPU eval path)"
|
||||
));
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(WorkerSession {
|
||||
renderer,
|
||||
@@ -542,11 +568,18 @@ impl WorkerSession {
|
||||
))
|
||||
}
|
||||
};
|
||||
match oak_node::serializer::load(&content) {
|
||||
Ok(project) => {
|
||||
match oak_node::serializer::load_with_id_map(&content) {
|
||||
Ok((project, id_map)) => {
|
||||
let project_uuid = project
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.uuid
|
||||
.clone();
|
||||
self.graph = Some(LoadedGraph {
|
||||
path: load.path.clone(),
|
||||
project: Some(project),
|
||||
project_uuid: Some(project_uuid),
|
||||
id_map,
|
||||
project_copy: 0,
|
||||
});
|
||||
log_error("LoadGraph: oaknode project deserialized");
|
||||
@@ -560,6 +593,8 @@ impl WorkerSession {
|
||||
self.graph = Some(LoadedGraph {
|
||||
path: load.path.clone(),
|
||||
project: None,
|
||||
project_uuid: None,
|
||||
id_map: std::collections::HashMap::new(),
|
||||
project_copy: pc,
|
||||
});
|
||||
log_error(&format!(
|
||||
@@ -1022,7 +1057,7 @@ impl WorkerSession {
|
||||
|
||||
if !bgra8 {
|
||||
// F32 RGBA: render straight into the slot (no staging copy).
|
||||
render_f32_into(spec, ¶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<LoadedGraph>,
|
||||
time: Rational,
|
||||
size: (i32, i32),
|
||||
dst: &mut [u8],
|
||||
) -> Result<(), String> {
|
||||
let (w, h) = size;
|
||||
let stride = w * 16;
|
||||
// M16 S1 graph mode: render the ticket's viewer node from the loaded
|
||||
// snapshot — but only when the snapshot belongs to the ticket's own
|
||||
// project. The ticket carries its owning project's uuid; a stale graph
|
||||
// from a different project (fresh projects reuse small identity
|
||||
// numbers, so a foreign viewer identity can resolve successfully and
|
||||
// silently render the wrong picture) must never answer it — that ticket
|
||||
// falls through to the montage path instead, and is NOT logged (the
|
||||
// mismatch is the normal suite-order case, not an error). A missing
|
||||
// graph / viewer is logged once and also falls back.
|
||||
if spec.viewer_node != 0 {
|
||||
let project_matches = graph
|
||||
.as_ref()
|
||||
.and_then(|g| g.project_uuid.as_deref())
|
||||
.map(|uuid| uuid == spec.project_key.as_str())
|
||||
.unwrap_or(false);
|
||||
if project_matches {
|
||||
if let Some(project) = graph.as_ref().and_then(|g| g.project.as_ref()) {
|
||||
// The ticket's viewer is an identity in the *saved* project;
|
||||
// translate through the load map first, then fall back to the
|
||||
// raw packed id (identity-only snapshots, foreign files).
|
||||
let viewer_id = graph
|
||||
.as_ref()
|
||||
.and_then(|g| g.id_map.get(&spec.viewer_node).copied())
|
||||
.or_else(|| oak_node::id::NodeId::from_identity(spec.viewer_node));
|
||||
match viewer_id {
|
||||
Some(viewer_id) => {
|
||||
let rendered = eval::render_graph_frame(project, viewer_id, time, (w, h), PixelFormat::F32);
|
||||
match &rendered {
|
||||
Ok(oak_render::texture::Texture::Cpu(frame)) => {
|
||||
let src_stride = frame.linesize_bytes() as usize;
|
||||
let row_bytes = (w as usize) * 16;
|
||||
if frame.data.len() < src_stride * (h as usize)
|
||||
|| dst.len() < row_bytes * (h as usize)
|
||||
{
|
||||
return Err("graph frame geometry mismatch".to_string());
|
||||
}
|
||||
for y in 0..h as usize {
|
||||
dst[y * row_bytes..(y + 1) * row_bytes].copy_from_slice(
|
||||
&frame.data[y * src_stride..y * src_stride + row_bytes],
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => return Err("graph render produced a GPU texture".to_string()),
|
||||
Err(e) => warn_graph_fallback(spec.viewer_node, &e.to_string()),
|
||||
}
|
||||
}
|
||||
None => warn_graph_fallback(spec.viewer_node, "viewer node not in graph"),
|
||||
}
|
||||
} else {
|
||||
warn_graph_fallback(spec.viewer_node, "no loaded graph");
|
||||
}
|
||||
}
|
||||
}
|
||||
if !params.montage.is_empty() {
|
||||
return eval::render_montage_frame_into(time, params, (w, h), dst, stride)
|
||||
.map_err(|e| format!("montage render: {e}"));
|
||||
@@ -1136,6 +1238,17 @@ fn render_f32_into(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log (once per process) why a graph-mode ticket fell back to the
|
||||
/// montage path — a missing snapshot, an absent viewer node, or a graph
|
||||
/// render error. AtomicBool keeps a GPU-less or graph-less host from
|
||||
/// spamming the worker log on every frame.
|
||||
fn warn_graph_fallback(viewer: u64, why: &str) {
|
||||
static WARNED: AtomicBool = AtomicBool::new(false);
|
||||
if !WARNED.swap(true, Ordering::Relaxed) {
|
||||
log_error(&format!("graph-mode ticket viewer {viewer}: {why}; falling back to montage"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert F32 RGBA (`src`, 16 bytes/px) to 8-bit BGRA (`dst`, 4
|
||||
/// bytes/px) with clamping — the worker-side end-of-pipe convert for
|
||||
/// BGRA8 preview slots.
|
||||
@@ -1171,8 +1284,9 @@ pub fn worker_main(backend: &str) -> i32 {
|
||||
// 1. Session creation initializes the render backend through the
|
||||
// oakrender crate's direct Rust API
|
||||
// (oakengine_worker_session_create()). The M15 "cpu" backend is
|
||||
// headless: no renderer, CPU evaluation + decode only.
|
||||
let cpu_mode = is_cpu_backend(backend);
|
||||
// headless: no renderer, CPU evaluation + decode only. M16 S1: a
|
||||
// failed GPU init for any other backend (e.g. "auto" on a GPU-less
|
||||
// host) is tolerated — the session continues headless.
|
||||
let mut session = match WorkerSession::create(backend) {
|
||||
Ok(s) => s,
|
||||
Err(msg) => {
|
||||
@@ -1180,9 +1294,10 @@ pub fn worker_main(backend: &str) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
if !session.has_renderer() && !cpu_mode {
|
||||
// Mirrors oakengine_worker_main(): without a renderer the worker
|
||||
// cannot do anything, so it exits 1. ("--backend none" lands here.)
|
||||
if !session.has_renderer() && is_no_backend(backend) {
|
||||
// Mirrors oakengine_worker_main(): an explicit no-renderer request
|
||||
// (""/"none") leaves nothing to evaluate, so the worker exits 1.
|
||||
// Any other backend failure already logged headless continuation.
|
||||
log_error("no renderer initialized");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use oak_core::{PixelFormat, Rational, TimeRange};
|
||||
use oak_node::block::ClipBlockBehavior;
|
||||
use oak_node::footage::FootageBehavior;
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::node::NodeCore;
|
||||
use oak_node::project::Project;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior};
|
||||
use oak_render::ipc::SLOT_FORMAT_BGRA8;
|
||||
use oak_render::procpool::{
|
||||
main_heap_frame_copies, reset_main_heap_frame_copies, DispatcherConfig, ProcessDispatcher,
|
||||
@@ -69,6 +76,7 @@ fn config(workers: usize, slots: u32) -> DispatcherConfig {
|
||||
fn params(time: Rational, footage: Option<(String, i32)>) -> Arc<VideoTicketParams> {
|
||||
Arc::new(VideoTicketParams {
|
||||
viewer: 1,
|
||||
project: String::new(),
|
||||
time,
|
||||
force_size: Some((64, 64)),
|
||||
// No forced format: the dispatcher's default slot format (BGRA8)
|
||||
@@ -444,6 +452,7 @@ fn submit_audio(
|
||||
time: start,
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer,
|
||||
project: String::new(),
|
||||
time: start,
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
@@ -661,6 +670,7 @@ fn oversized_audio_ticket_is_refused_by_process_backend() {
|
||||
time: Rational::new(0, 1),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer: 1,
|
||||
project: String::new(),
|
||||
time: Rational::new(0, 1),
|
||||
force_size: None,
|
||||
force_format: None,
|
||||
@@ -722,6 +732,7 @@ fn f32_ticket_gets_f32_slot_and_bgra8_stays_bgra8() {
|
||||
time: Rational::new(0, 1),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer: 1,
|
||||
project: String::new(),
|
||||
time: Rational::new(0, 1),
|
||||
force_size: Some((64, 64)),
|
||||
force_format: Some(PixelFormat::F32),
|
||||
@@ -774,3 +785,237 @@ fn f32_ticket_gets_f32_slot_and_bgra8_stays_bgra8() {
|
||||
|
||||
dispatcher.shutdown();
|
||||
}
|
||||
|
||||
/// One sequence with a single video track holding one clip over `clip`
|
||||
/// (range [0, 1)); returns the project and the sequence node.
|
||||
fn build_graph_project(clip: &std::path::Path) -> (Arc<Mutex<Project>>, NodeId) {
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
|
||||
// Footage is created first so the sequence lands on slot 1
|
||||
// (identity != 0): identity 0 is the "no viewer" sentinel in
|
||||
// ticket specs, and the graph branch short-circuits on it.
|
||||
let mut footage = FootageBehavior::new(clip.to_str().unwrap());
|
||||
footage.probe().expect("probe the test clip");
|
||||
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
||||
|
||||
let (score, sbehavior) = SequenceBehavior::create();
|
||||
seq = p.graph.add_node(score, sbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackListBehavior::create();
|
||||
let tl = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let track = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||
let clip_node = p.graph.add_node(ccore, cbehavior);
|
||||
p.graph
|
||||
.connect(footage, clip_node, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to clip");
|
||||
|
||||
let clip_behavior = p
|
||||
.graph
|
||||
.get_mut(clip_node)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ClipBlockBehavior>()
|
||||
.expect("clip block");
|
||||
clip_behavior.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1));
|
||||
|
||||
p.graph
|
||||
.get_mut(track)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track")
|
||||
.append_block(clip_node);
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(track);
|
||||
p.graph
|
||||
.get_mut(seq)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.expect("sequence")
|
||||
.track_lists
|
||||
.push(tl);
|
||||
}
|
||||
(project, seq)
|
||||
}
|
||||
|
||||
/// Post a single graph-mode ticket rendering the sequence `viewer` at
|
||||
/// t=0 through the worker pool. `project_uuid` must be the owning project's
|
||||
/// uuid (M16 S1: workers render graph tickets only when the snapshot's
|
||||
/// project matches the ticket's).
|
||||
fn post_graph_job(
|
||||
dispatcher: &ProcessDispatcher,
|
||||
results: &Arc<Mutex<Vec<TicketResult>>>,
|
||||
viewer: u64,
|
||||
project_uuid: &str,
|
||||
) {
|
||||
let results = results.clone();
|
||||
let job = Job {
|
||||
node_identity: viewer,
|
||||
time: Rational::new(0, 1),
|
||||
params: Arc::new(VideoTicketParams {
|
||||
viewer,
|
||||
project: project_uuid.to_string(),
|
||||
time: Rational::new(0, 1),
|
||||
force_size: Some((64, 64)),
|
||||
force_format: None,
|
||||
cache: None,
|
||||
cache_dir: None,
|
||||
cache_id: None,
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce: Arc::new(|_, _| {
|
||||
Err(oak_render::error::Error::Failed(
|
||||
"process backend does not use the in-process producer".into(),
|
||||
))
|
||||
}),
|
||||
done: Box::new(move |result| {
|
||||
results.lock().unwrap_or_else(|e| e.into_inner()).push(result);
|
||||
}),
|
||||
schedule: JobSchedule::seek(),
|
||||
};
|
||||
assert!(dispatcher.post(job), "post accepted while alive");
|
||||
}
|
||||
|
||||
/// Assert a delivered ticket is a 64x64 BGRA8 slot carrying opaque
|
||||
/// non-black content — the signature of a real graph render (the
|
||||
/// transparent-black montage fallback would be all zero), then release
|
||||
/// the slot.
|
||||
fn assert_graph_frame_opaque(dispatcher: &ProcessDispatcher, payload: TicketResult) {
|
||||
let payload = payload.expect("graph-mode frame rendered");
|
||||
let TicketPayload::ShmFrame(frame) = payload else {
|
||||
panic!("graph-mode tickets must deliver ShmFrame payloads");
|
||||
};
|
||||
assert_eq!(frame.meta.width, 64);
|
||||
assert_eq!(frame.meta.height, 64);
|
||||
assert_eq!(frame.meta.format, SLOT_FORMAT_BGRA8);
|
||||
assert_eq!(frame.meta.data_size, 64 * 64 * 4);
|
||||
let pixels = &frame.shm.slot_bytes(frame.slot)[..frame.meta.data_size as usize];
|
||||
let alpha_ok = pixels.chunks_exact(4).filter(|px| px[3] == 255).count();
|
||||
assert!(
|
||||
alpha_ok as f64 >= 0.99 * (64 * 64) as f64,
|
||||
"graph-rendered frame must be opaque ({alpha_ok}/4096)"
|
||||
);
|
||||
assert!(
|
||||
pixels.iter().any(|&b| b != 0),
|
||||
"graph-rendered frame is not black"
|
||||
);
|
||||
dispatcher.release_frame(&frame);
|
||||
}
|
||||
|
||||
/// M16 S1 end-to-end graph mode: the dispatcher starts with a real
|
||||
/// project snapshot (oaknode XML), the worker loads it right after the
|
||||
/// handshake, and a ticket naming the sequence node as its viewer
|
||||
/// renders the sequence's clip through the graph path into the slot.
|
||||
#[test]
|
||||
fn graph_mode_renders_sequence_viewer_from_snapshot() {
|
||||
let _guard = lock_test();
|
||||
|
||||
let clip = std::env::temp_dir().join(format!(
|
||||
"oak-procpool-graph-clip-{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
let snapshot = std::env::temp_dir().join(format!(
|
||||
"oak-procpool-graph-snapshot-{}.xml",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&clip);
|
||||
let _ = std::fs::remove_file(&snapshot);
|
||||
oak_codec::testmedia::write_test_clip(&clip, 64, 64, 10, 10).expect("test clip generation");
|
||||
|
||||
let (project, seq) = build_graph_project(&clip);
|
||||
let xml = {
|
||||
let p = project.lock().unwrap_or_else(|e| e.into_inner());
|
||||
oak_node::serializer::save(&p).expect("project serializes")
|
||||
};
|
||||
std::fs::write(&snapshot, xml).expect("snapshot written");
|
||||
let viewer = seq.identity();
|
||||
assert_ne!(viewer, 0, "viewer must not be the no-viewer sentinel 0");
|
||||
|
||||
let mut cfg = config(1, 4);
|
||||
cfg.graph_snapshot = Some(snapshot.display().to_string());
|
||||
let dispatcher = ProcessDispatcher::new(cfg).expect("dispatcher config");
|
||||
dispatcher.start().expect("worker starts");
|
||||
|
||||
let results = Arc::new(Mutex::new(Vec::new()));
|
||||
let project_uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone();
|
||||
post_graph_job(&dispatcher, &results, viewer, &project_uuid);
|
||||
pump_until(&dispatcher, &results, 1);
|
||||
|
||||
let result = results.lock().unwrap_or_else(|e| e.into_inner()).pop().unwrap();
|
||||
assert_graph_frame_opaque(&dispatcher, result);
|
||||
|
||||
dispatcher.shutdown();
|
||||
let _ = std::fs::remove_file(&clip);
|
||||
let _ = std::fs::remove_file(&snapshot);
|
||||
}
|
||||
|
||||
/// M16 S1: a snapshot pushed after startup reroutes subsequent viewer
|
||||
/// tickets — `set_graph_snapshot` sends `load_graph` down the same FIFO
|
||||
/// as the batches, so the worker loads the graph before it claims the
|
||||
/// ticket (no restart needed).
|
||||
#[test]
|
||||
fn set_graph_snapshot_after_start_reroutes_tickets() {
|
||||
let _guard = lock_test();
|
||||
|
||||
let clip = std::env::temp_dir().join(format!(
|
||||
"oak-procpool-reroute-clip-{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
let snapshot = std::env::temp_dir().join(format!(
|
||||
"oak-procpool-reroute-snapshot-{}.xml",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&clip);
|
||||
let _ = std::fs::remove_file(&snapshot);
|
||||
oak_codec::testmedia::write_test_clip(&clip, 64, 64, 10, 10).expect("test clip generation");
|
||||
|
||||
let (project, seq) = build_graph_project(&clip);
|
||||
let xml = {
|
||||
let p = project.lock().unwrap_or_else(|e| e.into_inner());
|
||||
oak_node::serializer::save(&p).expect("project serializes")
|
||||
};
|
||||
std::fs::write(&snapshot, xml).expect("snapshot written");
|
||||
let viewer = seq.identity();
|
||||
assert_ne!(viewer, 0, "viewer must not be the no-viewer sentinel 0");
|
||||
|
||||
let dispatcher = ProcessDispatcher::new(config(1, 4)).expect("dispatcher config");
|
||||
dispatcher.start().expect("worker starts");
|
||||
dispatcher.set_graph_snapshot(Some(snapshot.display().to_string()));
|
||||
|
||||
let results = Arc::new(Mutex::new(Vec::new()));
|
||||
let project_uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone();
|
||||
post_graph_job(&dispatcher, &results, viewer, &project_uuid);
|
||||
pump_until(&dispatcher, &results, 1);
|
||||
|
||||
let result = results.lock().unwrap_or_else(|e| e.into_inner()).pop().unwrap();
|
||||
assert_graph_frame_opaque(&dispatcher, result);
|
||||
|
||||
dispatcher.shutdown();
|
||||
let _ = std::fs::remove_file(&clip);
|
||||
let _ = std::fs::remove_file(&snapshot);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user