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

- process_shader_job: bind all texture params by name, recurse into nested
  shader payloads (depth cap 8), fall back to frame size without inputs
- run_effect: take iterative_input so dropshadow previous_iteration_in works
- merge: actually composite inputs; keyer mask, opacity modulation, math
  texture ops and mrg generator layers now bind their textures
- transform distort: real fragment-side inverse-matrix sampling
- time offset / time remap: wire NodeBehavior time adjustment hooks
- plugin: fix first-node identity colliding with unbound sentinel
This commit is contained in:
2026-09-09 16:33:29 +08:00
parent cde4dddf32
commit 5ab12b937f
28 changed files with 1157 additions and 434 deletions
+38 -25
View File
@@ -3668,25 +3668,25 @@ struct AppArgs {
}
impl AppArgs {
/// Parses `std::env::args` plus the `OAK_ENGINE` override:
/// `oakapp [project.ove] [--mock]`.
fn from_env() -> Self {
/// The pure parse step: `oakapp [project.ove] [--mock]`. The first
/// positional wins; later positionals are ignored. `oak_engine` is
/// the `OAK_ENGINE` override ("mock", case-insensitive). `--help` is
/// skipped here — `from_env` handles it (print + exit).
fn parse_args(
argv: impl IntoIterator<Item = std::ffi::OsString>,
oak_engine: Option<&str>,
) -> Self {
let mut args = AppArgs::default();
for arg in std::env::args_os().skip(1) {
for arg in argv {
let text = arg.to_string_lossy();
match text.as_ref() {
"--mock" => args.mock = true,
"--help" | "-h" => {
println!("oakapp — Oak Video Editor");
println!("usage: oakapp [project.ove] [--mock]");
println!(" --mock use the mock engine (or set OAK_ENGINE=mock)");
std::process::exit(0);
}
"--help" | "-h" => {}
_ if args.project.is_none() => args.project = Some(arg.into()),
other => println!("[app] ignoring unknown argument {other:?}"),
}
}
if std::env::var("OAK_ENGINE")
if oak_engine
.map(|v| v.eq_ignore_ascii_case("mock"))
.unwrap_or(false)
{
@@ -3694,6 +3694,25 @@ impl AppArgs {
}
args
}
/// Parses `std::env::args` plus the `OAK_ENGINE` override:
/// `oakapp [project.ove] [--mock]`.
fn from_env() -> Self {
// --help prints and exits before the pure parse.
if std::env::args_os()
.skip(1)
.any(|a| a == "--help" || a == "-h")
{
println!("oakapp — Oak Video Editor");
println!("usage: oakapp [project.ove] [--mock]");
println!(" --mock use the mock engine (or set OAK_ENGINE=mock)");
std::process::exit(0);
}
Self::parse_args(
std::env::args_os().skip(1),
std::env::var("OAK_ENGINE").ok().as_deref(),
)
}
}
/// Builds the app root entity for the chosen backend.
@@ -5434,20 +5453,10 @@ mod tests {
/// flag, and the `OAK_ENGINE` env var forces the mock.
#[test]
fn app_args_parse_path_and_mock_flag() {
// Simulate argv without touching the real environment: parse a slice
// directly.
let parse = |argv: &[&str], env: Option<&str>| -> AppArgs {
let mut args = AppArgs::default();
for text in argv {
match *text {
"--mock" => args.mock = true,
other => args.project = Some(PathBuf::from(other)),
}
}
if env.map(|v| v.eq_ignore_ascii_case("mock")).unwrap_or(false) {
args.mock = true;
}
args
// Drive the real parser with synthetic argv (the env override is
// a parameter, no process-global env is touched).
let parse = |argv: &[&str], env: Option<&str>| {
AppArgs::parse_args(argv.iter().map(std::ffi::OsString::from), env)
};
let a = parse(&["/tmp/a.ove"], None);
assert_eq!(a.project, Some(PathBuf::from("/tmp/a.ove")));
@@ -5462,6 +5471,10 @@ mod tests {
let d = parse(&[], None);
assert!(d.project.is_none());
assert!(!d.mock);
// The first positional wins; later positionals are ignored.
let e = parse(&["/tmp/a.ove", "/tmp/b.ove"], None);
assert_eq!(e.project, Some(PathBuf::from("/tmp/a.ove")));
}
// -------------------------------------------------------------------
-117
View File
@@ -1,117 +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/>.
//! C ABI contract tests (ffi).
//!
//! These integration tests are deliberately no-op: they compile against a
//! build of the crate **without** `#[cfg(test)]`, so the in-memory
//! oakcommon/oakrender test stubs (`bridge::test_stubs`) are not linked and
//! any export that touches them would fail at link time. The exhaustive
//! matrix is driven from the existing C++ gtest suite
//! (`src/codec/tests`, unchanged) running against this crate, and the
//! Rust-side behavior is covered by the unit tests in `src/ffi/*.rs` plus
//! the crate-internal module tests.
/// Every exported handle-returning function returns `ctx == NULL` on
/// failure and a valid refcounted handle on success (`abi_version`
/// stamped). Covers frame/decoder/encoder/conform/proxy `init`
/// families.
///
/// Covered in the (deleted) `src/ffi/*.rs` unit tests; the C-ABI entry
/// points now live in the oakengine facade, whose own tests assert the
/// same contract.
#[test]
fn handle_contract_all_exports() {
// No-op — see the module doc.
}
/// `free(NULL)` / `free(empty)` are no-ops across every free export
/// (frame/decoder/encoder/conform/proxy).
///
/// Covered in `src/ffi/frame.rs` (`free_null_and_empty_are_noops`) and
/// the other ffi module unit tests.
#[test]
fn free_null_noop_all_exports() {
// No-op — see the module doc.
}
/// Two-stage string functions: size query, short-buffer truncation rule,
/// and exact-fit write — for every string getter (decoder_name,
/// transform_image_sequence_file_name, last_error, proxy_state_to_string,
/// proxy filenames, export_format_get_extension).
///
/// Covered in the `src/ffi/*.rs` unit tests through the shared
/// `ffi::string_out` helper.
#[test]
fn two_stage_string_contract() {
// No-op — see the module doc.
}
/// `oakcodec_debug_alive_count` returns 0 after a full create/destroy
/// cycle and does not leak across repeated init/free pairs.
///
/// Covered in `src/ffi/frame.rs` (`frame_lifecycle_golden`) and the
/// other ffi module unit tests.
#[test]
fn alive_count_zero_after_cycle() {
// No-op — see the module doc.
}
/// Frame lifecycle parity: `init_with_params` → `get_params` round-trips
/// the width/height/time-base; `set_params` + `allocate` makes
/// `is_allocated` true and `data` non-NULL with the expected
/// `allocated_size`/`linesize_bytes`.
///
/// Covered in `src/ffi/frame.rs` (`frame_lifecycle_golden`).
#[test]
fn frame_lifecycle_golden() {
// No-op — see the module doc.
}
/// Decoder probe parity against a known reference file: stream counts,
/// per-stream POD fields (`oakcodec_video_stream_info` / audio), and the
/// image-sequence filename transforms (`get_image_sequence_digit_count` /
/// `get_image_sequence_index` / `transform_image_sequence_file_name`).
///
/// Covered in `src/ffi/decoder.rs` (`probe_golden_video`,
/// `probe_golden_audio`, `image_sequence_exports`).
#[test]
fn decoder_probe_golden() {
// No-op — see the module doc.
}
/// `oakcodec_encoding_generate_matrix` parity with the C++ helper for a
/// fixed (width, height, rate, format, codec) input; `format`/`codec` and
/// the resulting `oakcodec_encoding_params` fields are compared against
/// golden C++ output.
///
/// Covered in `src/ffi/encoder.rs`
/// (`export_format_extension_and_generate_matrix`).
#[test]
fn encoding_generate_matrix_golden() {
// No-op — see the module doc.
}
/// Conform/proxy state machines: fresh conform instance reports
/// generating/unavailable per the `OAKCODEC_CONFORM_*` contract, and
/// `oakcodec_proxy_params_default` fills the `oakcodec_proxy_params`
/// defaults byte-for-byte.
///
/// Covered in `src/ffi/conform.rs` and `src/ffi/proxy.rs` unit tests.
#[test]
fn conform_proxy_state_parity() {
// No-op — see the module doc.
}
-7
View File
@@ -1,7 +0,0 @@
#[test]
fn dbg_print() {
let mut sp = crate::subtitleparams::SubtitleParams::new();
sp.add_subtitle(1, 1, 2, 1, "a < b & \"c\" > d").unwrap();
let xml = sp.save_xml().unwrap();
println!("XML_OUTPUT_START>>>{}<<<XML_OUTPUT_END", xml);
}
+16 -4
View File
@@ -254,10 +254,12 @@ mod tests {
#[test]
fn default_level_is_info() {
// Other tests may shift the process-global filter; this test
// asserts only that getting/setting round-trips.
let before = log_get_level();
let _ = before;
// The documented default is Info; (re)applying it must drive the
// process-global filter to the Info floor.
assert_eq!(DEFAULT_LOG_LEVEL, Level::Info);
log_set_level(DEFAULT_LOG_LEVEL);
assert_eq!(log_get_level(), Level::Info);
assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL.to_filter());
}
#[test]
@@ -301,6 +303,16 @@ mod tests {
Level::Fatal,
] {
log_set_level(threshold);
// The observable filter state: the facade's max level tracks
// the threshold, and below-threshold traffic is disabled.
assert_eq!(log::max_level(), threshold.to_filter());
if threshold != Level::Debug {
assert!(
!log::log_enabled!(log::Level::Debug),
"debug traffic must be filtered at {threshold:?}"
);
}
// Emitting below the threshold is still an Ok no-op.
assert!(log(Level::Debug, "x").is_ok());
assert!(log(Level::Fatal, "y").is_ok());
}
+8 -2
View File
@@ -337,6 +337,7 @@ impl NodeBehavior for ClipBlockBehavior {
/// in-point. Other inputs pass through unchanged.
fn input_time_adjustment(
&self,
_core: &NodeCore,
input: &str,
_element: i32,
time: TimeRange,
@@ -666,7 +667,9 @@ mod tests {
#[test]
fn clip_input_time_adjustment_maps_timeline_to_media() {
let time = TimeRange::new(Rational::new(12, 1), Rational::new(13, 1));
let map = |c: &ClipBlockBehavior| c.input_time_adjustment(clip_input::TEXTURE_INPUT, -1, time, false);
let map = |c: &ClipBlockBehavior| {
c.input_time_adjustment(&NodeCore::new(), clip_input::TEXTURE_INPUT, -1, time, false)
};
// Speed 1: media = (12 - 10) + 5 = 7.
assert_eq!(
@@ -695,7 +698,10 @@ mod tests {
TimeRange::new(Rational::new(11, 1), Rational::new(12, 1))
);
// Non-`tex_in` inputs pass through untouched.
assert_eq!(clip_with(2.0, true).input_time_adjustment("other_in", -1, time, false), time);
assert_eq!(
clip_with(2.0, true).input_time_adjustment(&NodeCore::new(), "other_in", -1, time, false),
time
);
}
/// The clip copies the connected `tex_in` texture to its output;
+17 -3
View File
@@ -27,12 +27,16 @@ use crate::nodes::plugin::PluginJobPayload;
use crate::value::NodeValueRow;
/// Job types
///
/// The payloads travel boxed inside `Texture` values during graph
/// evaluation (the renderer probes the box's payload type at resolve
/// time); the graph-shaped job model lands in v0.6.
pub enum Job{
FootageJob(FootageJobPayload),
ShaderJob(ShaderJobPayload),
PluginJob(PluginJobPayload),
ColorTransformJob
ColorTransformJob(ColorTransformJobPayload)
}
/// C++ `FootageJob` payload: the decode request a footage node emits at
@@ -77,9 +81,19 @@ pub struct ShaderJobPayload {
pub iterative_input: String,
}
/// C++ `ColorTransformJob` payload: the OCIO processor application a
/// color node emits at its output. The processor is shared by `Arc` —
/// the node keeps its cached processor while in-flight jobs reference
/// the same immutable instance. The input texture value rides along
/// (C++ `t->to_job(job)` wraps the texture the job applies to).
#[derive(Clone)]
pub struct ColorTransformJobPayload{
pub color_processor: ColorProcessor,
/// The OCIO processor to apply (C++ `ColorTransformJob::processor`).
pub color_processor: std::sync::Arc<ColorProcessor>,
/// The input texture value (C++ the texture `to_job` was called on).
pub input: crate::value::NodeValue,
/// Request time in media seconds.
pub time: Rational,
}
impl Default for FootageJobPayload {
+7 -3
View File
@@ -553,27 +553,31 @@ pub trait NodeBehavior: Send {
/// Time adjustment through this node (C++
/// `input_time_adjustment()`/`output_time_adjustment()`; clips
/// override for speed/reverse).
/// override for speed/reverse, the time nodes for offset/remap).
/// `core` is the node's own data — the C++ implementations read
/// member inputs (e.g. the offset/remap value) at call time.
fn input_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
let _ = (core, input, element, traverse);
time
}
/// Output-side time adjustment.
fn output_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
let _ = (core, input, element, traverse);
time
}
+17 -5
View File
@@ -673,13 +673,25 @@ mod tests {
}
#[test]
fn value_gaussian_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(10.0));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
fn value_gaussian_pushes_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(RADIUS_INPUT.to_string(), NodeValue::Float(10.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.blur");
assert_eq!(
payload.params.get(RADIUS_INPUT),
Some(&NodeValue::Float(10.0)),
"the blur radius rides in the job params"
);
}
#[test]
@@ -617,7 +617,16 @@ mod tests {
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// A corner off its default (y-only is enough — C++ `is_null()`
// requires BOTH components zero) pushes the job; a pass-through
// would push the input's own (null) handle instead.
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
assert!(
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }.is_some(),
"a moved corner must push the cornerpin shader job"
);
}
#[test]
+12 -4
View File
@@ -368,17 +368,25 @@ mod tests {
}
#[test]
fn value_pushes_deferred_job_with_processor() {
fn value_pushes_color_transform_job_with_processor() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let n = node();
n.base.set_processor(Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
)));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
panic!("job row expected");
};
assert!(unsafe {
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
}
.is_some());
}
#[test]
+29
View File
@@ -258,6 +258,35 @@ fn matrix_rotate_z(m: [f64; 16], degrees: f64) -> [f64; 16] {
matrix_mul(m, r)
}
/// Invert a 2D affine transform (C++ `Matrix4x4::inverted` on the 2D
/// path). The matrices this crate generates are affine —
/// `[a b 0 tx; c d 0 ty; 0 0 sz 0; 0 0 0 1]` in the row-major
/// `m[r*4+c]` layout — so the inverse is analytic. `None` when the
/// affine part is singular (a zero scale collapses the plane; the C++
/// callers fall back to identity there).
pub fn matrix_invert_2d(m: [f64; 16]) -> Option<[f64; 16]> {
let (a, b, tx) = (m[0], m[1], m[3]);
let (c, d, ty) = (m[4], m[5], m[7]);
let det = a * d - b * c;
if det.abs() < 1e-12 {
return None;
}
let inv = 1.0 / det;
let (ia, ib, ic, id) = (d * inv, -b * inv, -c * inv, a * inv);
let mut out = super::mathbase::identity_matrix();
out[0] = ia;
out[1] = ib;
out[3] = -(ia * tx + ib * ty);
out[4] = ic;
out[5] = id;
out[7] = -(ic * tx + id * ty);
let sz = m[10];
if sz.abs() > 1e-12 {
out[10] = 1.0 / sz;
}
Some(out)
}
/// Resolve a vec2 input from the render row or the keyframed/standard
/// value (C++ `value.at(id).to_vec2()`; missing values read as
/// `(0, 0)`).
+75 -56
View File
@@ -49,60 +49,60 @@ pub const TEXTURE_INPUT: &str = "tex_in";
/// generation helpers reach the manager through
/// `crate::colormanager`/oakrender at call time.
pub struct OcioBase {
/// Owned color processor handle (C++ `processor_`, an
/// `OakColorProcessor`); `None`/empty while no valid processor has
/// been generated. Released with the node (C++ destructor calls
/// `oakrender_color_processor_free`).
processor: Option<crate::handle::CHandle>,
/// Owned color processor (C++ `processor_`, an `OakColorProcessor`),
/// shared by `Arc` because the [`crate::jobs::ColorTransformJobPayload`]
/// emitted at evaluation time carries the same immutable processor.
/// `None` while no valid processor has been generated. Released with
/// the node (C++ destructor calls `oakrender_color_processor_free`).
///
/// Behind a mutex because the C++ regenerates the processor from
/// `value()`-time paths (`ensure_processor()`'s mutable-in-const-
/// method pattern), so interior mutability is required.
processor: std::sync::Mutex<Option<std::sync::Arc<oak_core::color::ColorProcessor>>>,
}
// The processor handle wraps a refcounted C object that is only
// dereferenced from the render path (the C++ base likewise passes its
// `OakColorProcessor` across threads by value); moving the struct
// between threads does not introduce sharing the C++ side does not
// already have.
unsafe impl Send for OcioBase {}
impl OcioBase {
/// Construct the shared base state (C++ `OCIOBaseNode::OCIOBaseNode()`):
/// the processor starts empty; the constructor side that adds
/// [`TEXTURE_INPUT`], marks it the effect input and sets the
/// video-effect flag happens in each node's `create()`.
pub fn new() -> Self {
OcioBase { processor: None }
OcioBase {
processor: std::sync::Mutex::new(None),
}
}
/// Borrowed view of the owned processor handle (C++
/// Shared reference to the owned processor (C++
/// `OCIOBaseNode::processor()`; callers must NOT free it).
pub fn processor(&self) -> Option<&crate::handle::CHandle> {
self.processor.as_ref()
pub fn processor(&self) -> Option<std::sync::Arc<oak_core::color::ColorProcessor>> {
self.processor
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Take ownership of a new processor handle, releasing the old one
/// Take ownership of a new processor, releasing the old one
/// (C++ `OCIOBaseNode::set_processor()`, which frees the previous
/// `OakColorProcessor` before storing the new one).
pub fn set_processor(
&mut self,
processor: Option<crate::handle::CHandle>,
&self,
processor: Option<std::sync::Arc<oak_core::color::ColorProcessor>>,
) {
// The C++ frees the previous processor via
// `oakrender_color_processor_free`; the Rust handle is a
// refcounted `CHandle` released on drop, so replacing the field
// drops the old one automatically.
self.processor = processor;
// `oakrender_color_processor_free`; the Rust processor is an
// `Arc` released on drop, so replacing the field drops the old
// one automatically once any in-flight jobs release it.
*self.processor.lock().unwrap_or_else(|e| e.into_inner()) = processor;
}
/// Shared output evaluation (C++ `OCIOBaseNode::value()`): no texture
/// on [`TEXTURE_INPUT`] -> push nothing; texture present and
/// processor ready -> push a `ColorTransformJob` built from the
/// processor and the input texture; texture present but processor not
/// ready (e.g. still being generated asynchronously) -> pass the
/// input texture through unchanged.
///
/// The Rust model has no color-transform job payload: the ready case
/// pushes a null texture handle marking a renderer-deferred job
/// (the C++ `t->to_job(ColorTransformJob)` resolved by the renderer
/// via the processor); the not-ready case pushes the input texture.
/// processor ready -> push a boxed
/// [`crate::jobs::ColorTransformJobPayload`] built from the processor
/// and the input texture (the C++ `t->to_job(ColorTransformJob)`,
/// resolved by the render seam via the processor); texture present
/// but processor not ready (e.g. still being generated
/// asynchronously) -> pass the input texture through unchanged.
/// `// CPP-PARITY: ociobase.cpp` `value()`.
pub fn value(
&self,
@@ -111,20 +111,23 @@ impl OcioBase {
time: oak_core::Rational,
table: &mut NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(tex @ NodeValue::Texture(_)) => {
if self.processor.is_some() {
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
}
}
_ => {}
let _ = core;
let Some(tex @ NodeValue::Texture(_)) = inputs.get(TEXTURE_INPUT) else {
return;
};
match self.processor() {
Some(processor) => table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::make_owned(
crate::jobs::ColorTransformJobPayload {
color_processor: processor,
input: tex.clone(),
time,
},
)),
None,
),
None => table.push(crate::value::ValueType::Texture, tex.clone(), None),
}
}
@@ -156,9 +159,11 @@ mod tests {
#[test]
fn processor_state_transitions() {
let mut base = OcioBase::new();
let base = OcioBase::new();
assert!(base.processor().is_none());
base.set_processor(Some(crate::handle::CHandle::null()));
base.set_processor(Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
)));
assert!(base.processor().is_some());
base.set_processor(None);
assert!(base.processor().is_none());
@@ -194,21 +199,35 @@ mod tests {
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let mut base = OcioBase::new();
base.set_processor(Some(crate::handle::CHandle::null()));
fn value_pushes_color_transform_job_with_processor() {
let base = OcioBase::new();
base.set_processor(Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
)));
let mut table = NodeValueTable::default();
let inputs = NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let tex = NodeValue::Texture(crate::handle::make_owned(1u8));
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
base.value(
&NodeCore::new(),
&inputs,
oak_core::Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
// The ready case pushes the boxed ColorTransformJobPayload (the
// C++ `t->to_job(ColorTransformJob)`).
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
panic!("job row expected");
};
let payload = unsafe {
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
}
.expect("a boxed ColorTransformJobPayload");
assert!(std::sync::Arc::ptr_eq(
&payload.color_processor,
&base.processor().unwrap()
));
assert_eq!(payload.input, tex);
assert_eq!(payload.time, oak_core::Rational::new(0, 1));
}
#[test]
+101 -43
View File
@@ -56,8 +56,8 @@ struct ProcessorState {
/// `last_direction_`, starts `-1`).
last_direction: i64,
/// Cached processor for change detection (C++ `last_processor_`);
/// released with the node.
last_processor: Option<crate::handle::CHandle>,
/// shared with the base's active processor slot.
last_processor: Option<std::sync::Arc<oak_core::color::ColorProcessor>>,
/// Human-readable reason no LUT processor is active (C++
/// `last_error_`); empty when a valid processor is in use or no LUT
/// file has been selected yet.
@@ -77,10 +77,6 @@ impl Default for ProcessorState {
}
}
// The cached processor handle wraps a refcounted C object that is only
// dereferenced from the render path; see `OcioBase` for the rationale.
unsafe impl Send for ProcessorState {}
/// OCIO LUT node. Applies a LUT file through OpenColorIO.
pub struct OCIOLutNode {
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
@@ -175,7 +171,7 @@ impl OCIOLutNode {
{
let state = self.state.lock().unwrap();
if !state.dirty
&& state.last_processor.as_ref().is_some_and(|p| !p.is_null())
&& state.last_processor.is_some()
&& Self::file_path(core) == state.last_path
&& Self::read_direction_input(core) == state.last_direction
{
@@ -186,34 +182,63 @@ impl OCIOLutNode {
}
/// Rebuild the processor from the LUT path and direction (C++
/// `create_processor_from_inputs()`): no manager, empty path,
/// non-regular file, or unsupported extension -> clear both
/// processors, reset the cache markers, record the error, and return
/// false; unchanged path+direction with a live cached processor ->
/// clear the dirty flag and return false (reuse); otherwise create
/// the LUT processor via `oakrender_color_processor_create_lut`
/// (direction 0 = forward), update the cache markers and both
/// processor slots, and return true.
/// `create_processor_from_inputs()`): empty path, non-regular file,
/// or a failed OCIO FileTransform -> clear both processor slots,
/// reset the cache markers, record the error, and return false;
/// otherwise create the LUT processor via
/// [`oak_core::color::ColorProcessor::create_lut`] on the process-wide
/// default config (direction 0 = forward), update the cache markers
/// and both processor slots, and return true. A missing default
/// config yields `None` from `create_lut`, landing in the error
/// branch (the node then passes its input through unchanged).
/// `// CPP-PARITY: ociolut.cpp` `create_processor_from_inputs()`.
fn create_processor_from_inputs(&self, core: &NodeCore) -> bool {
let _ = core;
let mut state = self.state.lock().unwrap();
let path = Self::file_path(core);
let direction = Self::read_direction_input(core);
// C++ branch 1: no color manager. The Rust model reaches the
// manager through the oakrender bridge (absent here), so this
// branch is always taken: reset the cache markers and report
// false. The C++ additionally clears the standard processor and
// frees `last_processor_` — the Rust base processor is only
// reachable through `&mut self` and can never hold a processor
// without the render bridge, so those clears are no-ops here.
// The empty-path/non-regular-file/unsupported-extension error
// branches are unreachable without a manager and are not
// representable. `// CPP-PARITY: ociolut.cpp`
// create_processor_from_inputs.
state.last_processor = None;
state.last_path.clear();
state.last_direction = -1;
state.dirty = false;
false
// Reset both processor slots and the cache markers, recording
// `error` (empty = no error: no LUT selected yet).
let clear = |node: &Self, error: String| {
let mut state = node.state.lock().unwrap();
state.last_processor = None;
state.last_path.clear();
state.last_direction = -1;
state.dirty = false;
state.last_error = error;
node.base.set_processor(None);
false
};
if path.is_empty() {
return clear(self, String::new());
}
if !std::path::Path::new(&path).is_file() {
return clear(self, format!("LUT file not found: {path}"));
}
let dir = if direction == 1 {
oak_core::color::Direction::Inverse
} else {
oak_core::color::Direction::Normal
};
match oak_core::color::ColorProcessor::create_lut(&path, dir) {
Some(processor) => {
let processor = std::sync::Arc::new(processor);
{
let mut state = self.state.lock().unwrap();
state.last_processor = Some(processor.clone());
state.last_path = path;
state.last_direction = direction;
state.dirty = false;
state.last_error.clear();
}
self.base.set_processor(Some(processor));
true
}
None => clear(
self,
format!("Failed to create a LUT processor from {path}"),
),
}
}
/// OCIO config change hook (C++ `config_changed()` override):
@@ -506,7 +531,7 @@ mod tests {
}
#[test]
fn create_processor_from_inputs_resets_markers_without_manager() {
fn create_processor_from_inputs_resets_markers_without_lut() {
let n = node();
let core = NodeCore::new();
{
@@ -514,9 +539,13 @@ mod tests {
state.dirty = true;
state.last_path = "/tmp/foo.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(crate::handle::CHandle::null());
state.last_processor = Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
));
state.last_error = "stale error".to_string();
}
// No FILE_INPUT on the core -> empty path branch: markers reset
// and the stale error clears (no LUT selected is not an error).
let created = n.create_processor_from_inputs(&core);
assert!(!created);
let state = n.state.lock().unwrap();
@@ -524,8 +553,7 @@ mod tests {
assert_eq!(state.last_path, "");
assert_eq!(state.last_direction, -1);
assert!(state.last_processor.is_none());
// The no-manager branch does not touch the recorded error (C++).
assert_eq!(state.last_error, "stale error");
assert_eq!(state.last_error, "");
}
#[test]
@@ -600,7 +628,9 @@ mod tests {
state.dirty = false;
state.last_path = "/tmp/x.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(crate::handle::make_owned::<u8>(1));
state.last_processor = Some(std::sync::Arc::new(
oak_core::color::ColorProcessor::pass_through(),
));
}
// Unchanged path+direction with a live processor: early return,
// markers are preserved (create_processor_from_inputs would have
@@ -660,17 +690,45 @@ mod tests {
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
fn value_pushes_color_transform_job_with_processor() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
FILE_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text(String::new()),
));
core.add_input(crate::input::Input::new(
DIRECTION_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
core.set_standard_value(FILE_INPUT, -1, NodeValue::Text("/tmp/x.cube".into()));
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Combo(1));
let n = node();
let processor = std::sync::Arc::new(oak_core::color::ColorProcessor::pass_through());
{
// Prime the cache so value()'s ensure_processor early-returns
// (unchanged path/direction, live cached processor).
let mut state = n.state.lock().unwrap();
state.dirty = false;
state.last_path = "/tmp/x.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(processor.clone());
}
n.base.set_processor(Some(processor));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
panic!("job row expected");
};
assert!(unsafe {
crate::handle::get_checked::<crate::jobs::ColorTransformJobPayload>(handle)
}
.is_some());
}
#[test]
+31 -6
View File
@@ -308,7 +308,7 @@ mod tests {
}
#[test]
fn value_opacity_scaled_pushes_job_placeholder() {
fn value_opacity_scaled_pushes_shader_job() {
let (mut core, behavior) = create();
core.set_standard_value(VALUE_INPUT, -1, NodeValue::Float(0.5));
let inputs = crate::value::NodeValueRow::from([(
@@ -317,11 +317,20 @@ mod tests {
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// The scaled-opacity path pushes the default-variant shader job
// (a pass-through would push the input's null handle).
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.opacity");
assert_eq!(payload.shader_id, "");
assert_eq!(payload.effect_input, TEXTURE_INPUT);
}
#[test]
fn value_opacity_in_row_scaled_pushes_job_placeholder() {
fn value_opacity_in_row_scaled_pushes_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
@@ -332,7 +341,17 @@ mod tests {
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.opacity");
assert_eq!(
payload.params.get(VALUE_INPUT),
Some(&NodeValue::Float(0.5)),
"the opacity value rides in the job params"
);
}
#[test]
@@ -349,7 +368,7 @@ mod tests {
}
#[test]
fn value_texture_opacity_pushes_rgbmult_placeholder() {
fn value_texture_opacity_pushes_rgbmult_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
@@ -363,7 +382,13 @@ mod tests {
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// A texture opacity input selects the rgbmult shader variant.
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let payload = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("shader job pushed");
assert_eq!(payload.shader_id, "rgbmult");
}
#[test]
+16 -6
View File
@@ -487,21 +487,31 @@ mod tests {
}
#[test]
fn value_resolves_source_clip_first() {
fn value_pushes_job_for_source_clip_only_input() {
// Gate: a plugin with only the simple-source clip connected still
// emits its job (the C++ resolution tries `Source` before
// `tex_in`). With no texture at all, nothing is pushed.
let n = node();
let core = NodeCore::new();
let mut row = NodeValueRow::default();
row.insert(
SOURCE_CLIP.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
row.insert(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_))));
assert!(
matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(h)) if !h.is_null()),
"a source-clip-only row must still produce the plugin job"
);
let mut table = NodeValueTable::default();
n.value(&core, &NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(
table.get(ValueType::Texture).is_none(),
"no texture anywhere -> no job"
);
}
#[test]
+20 -2
View File
@@ -459,7 +459,7 @@ mod tests {
}
#[test]
fn value_with_base_merges() {
fn value_with_base_pushes_merge_job_with_nested_blend() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
super::super::generatorwithmerge::BASE_INPUT.to_string(),
@@ -467,7 +467,25 @@ mod tests {
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// The base-merge path pushes the "mrg" alpha-over job whose
// blend_in is the generator's own "rgb" job nested as a texture
// value (same shape as shapenode's merge test).
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("texture expected");
};
let mrg = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(h) }
.expect("merge job pushed");
assert_eq!(mrg.shader_id, "mrg");
assert_eq!(mrg.effect_input, super::super::generatorwithmerge::BASE_INPUT);
assert!(mrg.params.contains_key(super::super::generatorwithmerge::BASE_INPUT));
let blend = match mrg.params.get(crate::nodes::merge::BLEND_INPUT) {
Some(NodeValue::Texture(b)) => *b,
other => panic!("blend_in must carry the nested job: {other:?}"),
};
let blend_job = unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(&blend) }
.expect("nested generator job");
assert_eq!(blend_job.shader_id, "rgb");
assert_eq!(blend_job.type_id, "org.olivevideoeditor.Olive.polygon");
}
#[test]
+10 -17
View File
@@ -66,9 +66,9 @@ impl TimeOffsetNode {
/// `time_in` value evaluated at that endpoint; all other inputs fall
/// through to the base-class identity behavior.
///
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
/// `NodeCore`, so this value-resolving variant is what render-time call
/// sites (and the tests) use; the trait method documents that gap.
/// The [`NodeBehavior::input_time_adjustment`] trait method receives the
/// core and delegates here; kept as an associated fn so the tests can
/// drive the mapping without a behavior instance.
pub fn input_time_adjustment_with(
core: &NodeCore,
input: &str,
@@ -146,42 +146,35 @@ impl NodeBehavior for TimeOffsetNode {
/// `input_in`, both ends of the range are shifted forward by the current
/// `time_in` value (C++ `get_remapped_time()`: `input + time_in`);
/// all other inputs fall through to the base-class identity behavior.
///
/// The C++ evaluation reads the keyframable `time_in` input, which
/// requires the node's data ([`NodeCore`]) — not carried by this trait
/// signature. The exact remap is ported in
/// [`Self::input_time_adjustment_with`] (and tested there); until the
/// adjustment API gains core access, the identity range is returned
/// `core` carries the node's data (the keyframable `time_in` input),
/// matching the C++ member read
/// (`// CPP-PARITY: timeoffsetnode.cpp` `input_time_adjustment`).
fn input_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
Self::input_time_adjustment_with(core, input, element, time, traverse)
}
/// Output-side time remap (C++ `output_time_adjustment()`): the exact
/// inverse of the input adjustment — for `input_in`, both ends of the
/// range are shifted back by subtracting the `time_in` value (C++
/// `get_remapped_output_time()`: `input - time_in`); all other inputs
/// fall through to the base-class identity behavior.
///
/// As with the input side, the value read needs the node's data; the
/// exact remap is ported in [`Self::output_time_adjustment_with`]
/// fall through to the base-class identity behavior
/// (`// CPP-PARITY: timeoffsetnode.cpp` `output_time_adjustment`).
fn output_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
Self::output_time_adjustment_with(core, input, element, time, traverse)
}
/// Evaluate outputs (C++ `value()`): pushes the value arriving at
+17 -15
View File
@@ -56,9 +56,9 @@ impl TimeRemapNode {
/// original time); all other inputs fall through to the base-class
/// identity behavior.
///
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
/// `NodeCore`, so this value-resolving variant is what render-time call
/// sites (and the tests) use; the trait method documents that gap.
/// The [`NodeBehavior::input_time_adjustment`] trait method receives the
/// core and delegates here; kept as an associated fn so the tests can
/// drive the mapping without a behavior instance.
pub fn input_time_adjustment_with(
core: &NodeCore,
input: &str,
@@ -115,23 +115,18 @@ impl NodeBehavior for TimeRemapNode {
/// `input_in`, both ends of the range are replaced by the `time_in`
/// value at that time (C++ `get_remapped_time()`: `time_in` evaluated at
/// `input`, discarding the original time); all other inputs fall through
/// to the base-class identity behavior.
///
/// The C++ evaluation reads the keyframable `time_in` input, which
/// requires the node's data ([`NodeCore`]) — not carried by this trait
/// signature. The exact remap is ported in
/// [`Self::input_time_adjustment_with`] (and tested there); until the
/// adjustment API gains core access, the identity range is returned
/// to the base-class identity behavior. `core` carries the node's data
/// (the keyframable `time_in` input), matching the C++ member read
/// (`// CPP-PARITY: timeremap.cpp` `input_time_adjustment`).
fn input_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
Self::input_time_adjustment_with(core, input, element, time, traverse)
}
/// Output-side time remap (C++ `output_time_adjustment()`): the C++
@@ -140,12 +135,13 @@ impl NodeBehavior for TimeRemapNode {
/// base-class identity behavior; declared here for parity.
fn output_time_adjustment(
&self,
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
let _ = (core, input, element, traverse);
time
}
@@ -328,8 +324,14 @@ mod tests {
fn output_time_adjustment_is_identity() {
let n = TimeRemapNode;
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
assert_eq!(n.output_time_adjustment(INPUT_INPUT, -1, t, true), t);
assert_eq!(n.output_time_adjustment("other_in", -1, t, true), t);
assert_eq!(
n.output_time_adjustment(&NodeCore::new(), INPUT_INPUT, -1, t, true),
t
);
assert_eq!(
n.output_time_adjustment(&NodeCore::new(), "other_in", -1, t, true),
t
);
}
#[test]
@@ -21,7 +21,29 @@
//! `pos_in`/`rot_in`/`scale_in`/`uniform_scale_in`/`anchor_in` inputs
//! and the `generate_matrix` helper.
/// The transform fragment shader: samples the input through the inverse
/// of the node's pixel-space transform (`transform_in` carries the
/// CPU-inverted matrix; `resolution_in` is auto-filled by the runner).
/// The C++ path transforms vertices (`ove_mvpmat` in transform.vert);
/// an affine transform is equivalently applied fragment-side by
/// inverse-mapping the sample position — the fixed fullscreen vertex
/// stage stays unchanged.
const TRANSFORM_FRAG: &str = r#"uniform sampler2D tex_in;
uniform mat4 transform_in;
uniform vec2 resolution_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec2 px = ove_texcoord * resolution_in;
vec2 src = (transform_in * vec4(px, 0.0, 1.0)).xy;
frag_color = texture(tex_in, src / resolution_in);
}
"#;
use crate::factory::NodeMeta;
use crate::jobs::ShaderJobPayload;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
@@ -366,21 +388,37 @@ impl NodeBehavior for TransformDistortNode {
);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
// The shader-job box (C++ `Texture::Job(globals.vparams(),
// job)`): the behavior's type id selects the fragment
// source, and the effect input key locates the main
// texture inside the params row. C++ also inserts
// `ove_mvpmat` (the auto-scaled real matrix) and sets the
// `ove_maintex` interpolation, but the matrix needs the
// texture's params and the sequence resolution — neither
// available here — so it is left absent and the runner
// fills an identity `ove_mvpmat`; the C++ identity check
// (pass-through when the real matrix is identity) is not
// representable either, so the job is always queued with a
// texture (`// CPP-PARITY: transformdistortnode.cpp`
// value(); TODO: inject the real matrix from the renderer
// seam, where the resolution data is available).
Some(tex @ crate::value::NodeValue::Texture(_)) => {
// The identity transform passes the texture through (C++
// skips the job when the real matrix is identity).
let identity = super::mathbase::identity_matrix();
let is_identity = generated_matrix
.iter()
.zip(identity)
.all(|(a, b)| (*a - b).abs() <= 1e-9);
if is_identity {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
return;
}
// The job carries the inverse transform in pixel space
// (C++ inserts `ove_mvpmat` — the forward matrix for the
// vertex stage; the fragment-side implementation samples
// through the inverse, see TRANSFORM_FRAG). A singular
// matrix (zero scale) passes the input through, matching
// the C++ inverted()-fails fallback. The auto-scaled
// variant stays unrepresentable: it needs the texture's
// params and the sequence resolution at job-build time
// (`// CPP-PARITY: transformdistortnode.cpp` value()).
let Some(inverse) = super::matrix::matrix_invert_2d(generated_matrix) else {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
return;
};
let mut params = inputs.clone();
params.insert(
"transform_in".to_string(),
crate::value::NodeValue::Matrix(inverse),
);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
@@ -390,7 +428,7 @@ impl NodeBehavior for TransformDistortNode {
type_id: self.type_id().to_string(),
shader_id: String::new(),
effect_input: core.effect_input.clone(),
params: inputs.clone(),
params,
iterative_input: TEXTURE_INPUT.to_string(),
})),
None,
@@ -404,12 +442,12 @@ impl NodeBehavior for TransformDistortNode {
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and returns a default (empty) `ShaderCode` — the
/// node relies on the renderer's default vertex/fragment shaders,
/// so this maps to `None`.
/// request id and returns the transform fragment shader — the
/// inverse-sampling equivalent of the C++ default vertex-shader
/// transform (see [`TRANSFORM_FRAG`]).
fn shader_code(&self, request: &str) -> Option<String> {
let _ = request;
None
Some(TRANSFORM_FRAG.to_string())
}
/// Gizmo transform/positions (C++ `update_gizmo_positions()` and
@@ -715,11 +753,31 @@ mod tests {
}
#[test]
fn value_pushes_matrix_and_job_with_texture() {
fn value_identity_transform_passes_texture_through() {
// Default inputs (position 0, rotation 0, scale 1) generate the
// identity matrix: the texture passes through without a job (C++
// skips the job when the real matrix is identity).
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Matrix).is_some());
assert_eq!(table.get(ValueType::Texture), Some(&tex()));
}
#[test]
fn value_pushes_matrix_and_job_with_texture() {
let (mut core, behavior) = create();
// A real transform (position +50 in x) generates a non-identity
// matrix, so the shader job is queued.
core.set_standard_value(
super::super::matrix::POSITION_INPUT,
-1,
NodeValue::Vec2([50.0, 0.0]),
);
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
// Matrix output always pushed; texture job queued for the seam.
assert!(table.get(ValueType::Matrix).is_some());
let handle = match table.get(ValueType::Texture).unwrap() {
@@ -734,6 +792,12 @@ mod tests {
assert_eq!(payload.effect_input, TEXTURE_INPUT);
assert_eq!(payload.time, Rational::new(0, 1));
assert!(payload.params.contains_key(TEXTURE_INPUT));
// The job carries the inverse of the +50px translation
// (fragment-side inverse sampling).
match payload.params.get("transform_in") {
Some(NodeValue::Matrix(m)) => assert_eq!(m[3], -50.0),
other => panic!("transform_in must carry the inverse matrix: {other:?}"),
}
}
#[test]
@@ -771,9 +835,11 @@ mod tests {
}
#[test]
fn shader_code_returns_none() {
fn shader_code_returns_inverse_sampling_frag() {
let n = empty_node();
assert!(n.shader_code("anything").is_none());
let frag = n.shader_code("anything").expect("real transform shader");
assert!(frag.contains("transform_in"));
assert!(frag.contains("resolution_in"));
}
#[test]
+1 -1
View File
@@ -221,7 +221,7 @@ fn adjusted_time(
) -> Rational {
entry
.behavior
.input_time_adjustment(input, element, TimeRange::new(time, time), true)
.input_time_adjustment(&entry.core, input, element, TimeRange::new(time, time), true)
.in_()
}
+10 -5
View File
@@ -261,15 +261,20 @@ fn registry() -> &'static Mutex<HashMap<u64, RegistryEntry>> {
}
/// 登记 oaknode 节点(facade 装配期调用;对应 M9 C++ 版
/// `oaknode_node_identity()` 注册表的登记侧)。返回打包身份
/// [`oak_node::id::NodeId::identity`],写入
/// [`crate::instance::Instance::bind_node`]。同一身份重复登记
/// 覆盖旧条目(重绑定)。
/// `oaknode_node_identity()` 注册表的登记侧)。返回的注册表键是打包
/// 身份 +1[`oak_node::id::NodeId::identity`]:身份 0 是合法的首
/// 节点,而 `Instance::node_identity` 以 0 为未绑定哨兵,移位后二者
/// 永不冲突。键写入 [`crate::instance::Instance::bind_node`],摘除与
/// 查找([`node_from_identity`])用同一键。同一身份重复登记覆盖旧
/// 条目(重绑定)。
pub fn register_node(
project: Arc<Mutex<oak_node::project::Project>>,
id: oak_node::id::NodeId,
) -> u64 {
let identity = id.identity();
let identity = id
.identity()
.checked_add(1)
.expect("register_node: the invalid node id has no registry key");
let entry = RegistryEntry {
project: Arc::downgrade(&project),
id,
+2 -3
View File
@@ -258,12 +258,11 @@ mod tests {
assert_eq!(p.format, PIXEL_FORMAT_F32);
}
/// 渲染器 kind 查询与 GL 判断texture_id 桩恒 0(无 GL 命名空间)。
/// 渲染器 kind 查询与 GL 判断(wgpu 后端按设计无 GL 纹理名,
/// `texture_id` 恒 0 的桩不在这里断言)。
#[test]
fn renderer_kind_and_gl_id_stub() {
let r: Renderer = std::sync::Arc::new(FakeGpu);
assert!(!renderer_is_open_gl(&r));
let dummy = Texture::dummy();
assert_eq!(texture_id(&dummy), 0, "wgpu 无 GL 纹理名:桩恒 0");
}
}
+2 -2
View File
@@ -302,9 +302,9 @@ mod tests {
fn shim_v2_table_entry_resolves() {
let _g = TEST_LOCK.lock().unwrap();
let s = suite_v2();
assert!(!std::ptr::eq(
assert!(std::ptr::eq(
s.message as *const (),
ofx_message_shim_v1 as *const ()
ofx_message_shim_v2 as *const ()
));
}
+59
View File
@@ -1028,4 +1028,63 @@ mod tests {
);
}
}
/// paramSetValue → instanceChanged 回写(C++ paramChangedByPlugin):
/// 实例绑定节点后,插件侧改值经 PARAM_OWNER 定位实例、
/// notify_instance_changed 生成 undo 命令并立即 redo——节点输入
/// 即新值。
#[test]
fn set_value_writes_back_to_bound_node() {
let (inst, ih) = make_instance();
let s = suite_v1();
let mut gain: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.param_get_handle)(ih, cs("gain").as_ptr(), &mut gain, std::ptr::null_mut()),
0
);
}
// 宿主侧登记:param → 实例(生产由 createInstance 路径做),
// 实例 → oaknode 节点(facade 装配期做)。
let project = oak_node::project::Project::new();
let node_id = {
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
let id = guard.graph.add_node(
oak_node::node::NodeCore::empty(),
Box::new(oak_node::nodes::EmptyBehavior),
);
guard.graph.get_mut(id).unwrap().core.add_input(
oak_node::input::Input::new(
"gain",
oak_node::value::ValueType::Int,
oak_node::value::NodeValue::Int(0),
),
);
id
};
let identity = crate::node::register_node(project.clone(), node_id);
inst.bind_node(identity as usize);
let param_addr = tag::strip(gain) as usize;
register_param_owner(param_addr, &inst.props as *const PropertySet as usize);
unsafe {
assert_eq!((s.param_set_value)(gain, 5), 0);
}
let stored = project
.lock()
.unwrap_or_else(|e| e.into_inner())
.graph
.get(node_id)
.unwrap()
.core
.standard_value("gain", -1);
assert_eq!(
stored,
oak_node::value::NodeValue::Int(5),
"插件改值必须经 instanceChanged 写回节点输入"
);
unregister_params_of(&inst.props as *const PropertySet as usize);
crate::node::unregister_node(identity);
}
}
+2 -10
View File
@@ -416,15 +416,6 @@ mod tests {
stop: AtomicU32,
}
impl AutoCacheEvents for Probe {
fn progress(&mut self, value: f64) {
assert_eq!(value, 0.5);
self.progress.fetch_add(1, Ordering::Relaxed);
}
fn stop_proxy_tasks(&mut self) {
self.stop.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn events_deliver_progress() {
@@ -447,8 +438,9 @@ mod tests {
}
impl AutoCacheEvents for ProbeEvents {
fn progress(&mut self, value: f64) {
// The reported fraction must arrive intact.
assert_eq!(value, 0.5);
self.probe.progress.fetch_add(1, Ordering::Relaxed);
let _ = value;
}
fn stop_proxy_tasks(&mut self) {
self.probe.stop.fetch_add(1, Ordering::Relaxed);
+524 -66
View File
@@ -24,8 +24,10 @@
//! ([`set_plugin_executor`]); footage jobs decode through the oakcodec
//! decoder bridge; shader jobs execute on the shared GPU context
//! ([`oak_core::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.
//! through when no adapter is available); color transform jobs apply
//! their OCIO processor (CPU frames convert for real; the GPU
//! color-managed blit is deferred at the backend and passes through);
//! the disk frame-cache payload I/O remains deferred.
use std::sync::{Arc, Mutex};
@@ -40,7 +42,7 @@ use oak_core::color::ColorProcessor;
use oak_core::frame::VideoParamsPod;
use oak_core::texture::{Frame, Texture};
use oak_core::{PixelFormat, Rational, TimeRange};
use oak_node::jobs::{FootageJobPayload, ShaderJobPayload};
use oak_node::jobs::{ColorTransformJobPayload, FootageJobPayload, ShaderJobPayload};
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable};
/// Static mapping of OCIO-based node shaders to the OCIO function they
@@ -241,6 +243,14 @@ pub fn plugin_instance_factory() -> Option<Arc<PluginInstanceFactory>> {
.clone()
}
/// Box a resolved texture into the table's texture channel (the render
/// seam's output is the one place a texture legitimately travels as a
/// refcounted handle — [`oak_node::handle::get_checked`] probes against
/// `Texture` stay type-checked).
fn texture_value(texture: Texture) -> NodeValue {
NodeValue::Texture(oak_node::handle::make_owned(texture))
}
/// The failure marker frame: solid magenta (1, 0, 1, 1) F32 RGBA —
/// the C++ plugin renderer paints failed plugin output purple so a
/// broken plugin is visible instead of silently black.
@@ -267,30 +277,51 @@ impl RenderEvalHooks {
}
}
/// C++ process_color_transform.
/// C++ process_color_transform: apply the job's OCIO processor to the
/// input texture. A CPU frame converts in place (the real OCIO
/// `convert_frame`); a GPU input passes through with a one-time log —
/// the color-managed GPU blit is deferred at the backend
/// (`GpuContext::blit` rejects a processor). An invalid processor
/// passes the input through unchanged (C++ creates processors
/// non-fatally); a non-texture input is `Error::Invalid`.
fn process_color_transform_job(
&mut self,
src: &mut Texture,
spec: &JobSpec,
processor: &ColorProcessor
) -> Result<()> {
// The processor is looked up by identity in the process-wide
// processor cache; this pass resolves the identity through the
// default config (the processor cache lands with the manager).
let ctx = oak_core::backend::GpuContext::shared();
if let Some(ctx) = ctx {
let Ok(dst) = ctx.create_texture(src.size().0, src.size().1) else {
return Err(Error::Failed("Unable to create destination texture".to_string()));
};
match src {
Texture::Gpu { token: s, ctx, .. }
=> { return ctx.blit(*s, dst, Some(processor));},
_ => return Err(Error::Failed(
"CPU or mixed CPU/GPU texture blit unsupported".into(),
)),
}
payload: &ColorTransformJobPayload,
) -> Result<Texture> {
let NodeValue::Texture(handle) = &payload.input else {
return Err(Error::Invalid);
};
if handle.ctx.is_null() {
return Err(Error::Invalid);
}
Err(Error::Invalid)
let tex = (unsafe { oak_node::handle::get_checked::<Texture>(handle) })
.cloned()
.ok_or(Error::Invalid)?;
if !payload.color_processor.is_valid() {
// No valid processor (no default config, LUT load failure):
// pass the input through, mirroring the C++ non-fatal
// processor creation.
return Ok(tex);
}
let mut tex = tex;
if let Texture::Cpu(frame) = &mut tex {
// The CPU leg converts in place (real OCIO `convert_frame`).
payload.color_processor.convert_frame(frame)?;
return Ok(tex);
}
// GPU leg: deferred at the backend — pass through, logged once
// per processor.
let key = format!(
"colortransform:gpu:{}",
payload.color_processor.cache_id()
);
if unsupported_warned().insert(key) {
eprintln!(
"color transform on a GPU texture passes through unchanged: \
color-managed GPU blit deferred at the backend"
);
}
Ok(tex)
}
/// C++ process_frame_generation: fill the destination with a generated
@@ -504,14 +535,71 @@ impl RenderEvalHooks {
}
}
/// Resolve the color-transform payloads an OCIO node pushed into its
/// output table (C++ ColorTransformJob processing in jobmanager.cpp):
/// apply each boxed [`ColorTransformJobPayload`]'s processor to its
/// input texture and replace the box with the result. Failures fall
/// back to the job's input texture (a pass-through — the C++ renderer
/// leaves the failed transform's output as its input); genuine
/// textures pass through.
fn resolve_color_transform_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, ColorTransformJobPayload)> = 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::<ColorTransformJobPayload>(handle)
}
.cloned();
payload.map(|p| (i, p))
})
.collect();
for (i, payload) in jobs {
let resolved = match self.process_color_transform_job(&payload) {
Ok(texture) => NodeValue::Texture(oak_node::handle::make_owned(texture)),
Err(err) => {
eprintln!("color transform job failed: {err:#}");
payload.input.clone()
}
};
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.
/// shared GPU context and run the requested iterations. **Every**
/// texture-typed param is bound by its input id (C++ binds all
/// sampler inputs — merge's base/blend, the keyers' garbage/core
/// mattes, opacity's texture modulation); a param boxing a nested
/// shader payload resolves recursively first (the C++
/// AcceleratedJob chain — generator-over-base `mrg` jobs). CPU
/// frames upload into scratch textures; the pass size comes from
/// the effect input's texture (else the first bound texture, else
/// the hook's frame size, else 1x1). `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> {
self.process_shader_job_depth(payload, 0)
}
/// [`Self::process_shader_job`] with a recursion guard for nested
/// payloads (generator-over-base chains nest at most 2 deep).
fn process_shader_job_depth(&self, payload: &ShaderJobPayload, depth: u32) -> Option<Texture> {
/// Nested-payload recursion ceiling (defensive; real graphs nest
/// a generator job inside a merge job and stop there).
const MAX_JOB_DEPTH: u32 = 8;
// 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);
@@ -523,16 +611,6 @@ impl RenderEvalHooks {
}
};
// 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) = oak_core::backend::GpuContext::shared() else {
warn("no GPU context");
return None;
@@ -619,22 +697,52 @@ impl RenderEvalHooks {
}
};
// 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)) => {
// Bind every texture-typed param by name: genuine texture boxes
// bind directly (CPU frames upload into scratch first); nested
// shader payloads (the generator layer of an `mrg` job) resolve
// recursively. `scratch` holds the upload tokens created here;
// `keepalive` holds the cloned `Texture`s — a `Texture::Gpu`
// clone destroys its token on drop, so the clones must outlive
// the pass. Both are released when the job finishes (input-token
// destruction at job end matches the historical semantics).
let mut inputs: Vec<(String, u64)> = Vec::new();
let mut scratch: Vec<u64> = Vec::new();
let mut keepalive: Vec<Texture> = Vec::new();
let mut size: Option<(i32, i32)> = None;
let bind = |key: &str,
value: &NodeValue,
inputs: &mut Vec<(String, u64)>,
scratch: &mut Vec<u64>,
keepalive: &mut Vec<Texture>,
size: &mut Option<(i32, i32)>|
-> Option<()> {
let NodeValue::Texture(handle) = value else {
return None;
};
if handle.ctx.is_null() {
return None;
}
let tex = (unsafe { oak_node::handle::get_checked::<Texture>(handle) })
.cloned()
.or_else(|| {
if depth >= MAX_JOB_DEPTH {
return None;
}
let nested = (unsafe {
oak_node::handle::get_checked::<ShaderJobPayload>(handle)
})
.cloned()?;
Some(self.process_shader_job_depth(&nested, depth + 1)?)
});
let tex = tex?;
let (token, tex_size) = match &tex {
Texture::Gpu {
token,
width,
height,
..
} => (*token, (*width, *height)),
Texture::Cpu(frame) => {
let token = match ctx.create_texture(frame.width, frame.height) {
Ok(t) => t,
Err(err) => {
@@ -647,20 +755,50 @@ impl RenderEvalHooks {
warn(&format!("input upload failed: {err:#}"));
return None;
}
(
vec![(payload.effect_input.clone(), token)],
(frame.width, frame.height),
Some(token),
)
scratch.push(token);
(token, (frame.width, frame.height))
}
None => (Vec::new(), (1, 1), None),
};
// The pass size follows the effect input's texture (C++ the
// job's video params = the main input size); any other bound
// texture sets it only when no effect input was seen.
if size.is_none() || key == payload.effect_input {
*size = Some(tex_size);
}
inputs.push((key.to_string(), token));
keepalive.push(tex);
Some(())
};
// The effect input binds first: `run_effect` falls back to
// `inputs.first()` for the shader's first declared sampler.
let effect_value = payload.params.get(&payload.effect_input).cloned();
if let Some(value) = &effect_value {
bind(
&payload.effect_input,
value,
&mut inputs,
&mut scratch,
&mut keepalive,
&mut size,
);
}
for (key, value) in &payload.params {
if key == &payload.effect_input {
continue;
}
bind(key, value, &mut inputs, &mut scratch, &mut keepalive, &mut size);
}
// Generators bind no texture: render at the requested frame size
// (the graph driver sets it to the sequence size); 1x1 only when
// nobody knows better.
let size = size.or(self.frame_size).unwrap_or((1, 1));
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);
for t in &scratch {
ctx.destroy_texture(*t);
}
warn(&format!("output texture: {err:#}"));
return None;
@@ -675,9 +813,14 @@ impl RenderEvalHooks {
dst,
size,
payload.iterations.max(1) as u32,
if payload.iterative_input.is_empty() {
None
} else {
Some(payload.iterative_input.as_str())
},
);
if let Some(t) = uploaded {
ctx.destroy_texture(t);
for t in &scratch {
ctx.destroy_texture(*t);
}
match result {
Ok(()) => Some(Texture::Gpu {
@@ -717,6 +860,7 @@ impl oak_node::traverser::RenderHooks for RenderEvalHooks {
self.resolve_plugin_jobs(table);
self.resolve_footage_jobs(table);
self.resolve_shader_jobs(table);
self.resolve_color_transform_jobs(table);
}
}
@@ -2304,7 +2448,111 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
/// The composite seam matches the C++ alpha-over math: bottom (last)
/// The resolve seam applies a ColorTransformJob's OCIO processor for
/// real (C++ ColorTransformJob processing): a CPU frame converts
/// through the LUT in place.
#[test]
fn resolve_color_transform_job_applies_lut_on_cpu() {
if oak_core::color::set_up_default_config().is_err() {
eprintln!("bundled OCIO missing; skipping");
return;
}
// 1D LUT doubling the red channel (linear ramp 0→0, 1→2).
let path = std::env::temp_dir()
.join(format!("oakrender_lut_double_{}.cube", std::process::id()));
std::fs::write(&path, "LUT_1D_SIZE 2\n0.0 0.0 0.0\n2.0 1.0 1.0\n").unwrap();
let Some(processor) = oak_core::color::ColorProcessor::create_lut(
path.to_str().unwrap(),
oak_core::color::Direction::Normal,
)
.filter(|p| p.is_valid()) else {
eprintln!("LUT processor unavailable; skipping");
let _ = std::fs::remove_file(&path);
return;
};
// Input: a 0.25-grey CPU frame.
let mut frame = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap();
for px in frame.data.chunks_exact_mut(16) {
for (c, v) in px.chunks_exact_mut(4).zip([0.25f32, 0.25, 0.25, 1.0]) {
c.copy_from_slice(&v.to_le_bytes());
}
}
let payload = ColorTransformJobPayload {
color_processor: std::sync::Arc::new(processor),
input: NodeValue::Texture(oak_node::handle::make_owned(Texture::wrap_frame(frame))),
time: Rational::new(0, 1),
};
let mut table = NodeValueTable::default();
table.push(
oak_node::value::ValueType::Texture,
NodeValue::Texture(oak_node::handle::make_owned(payload)),
None,
);
use oak_node::traverser::RenderHooks;
let mut hooks = RenderEvalHooks::new();
hooks.resolve(
oak_node::id::NodeId::INVALID,
&NodeValueRow::new(),
&mut table,
);
let rows = table.rows();
let NodeValue::Texture(handle) = &rows[0].1 else {
unreachable!()
};
let out = unsafe { oak_node::handle::get_checked::<Texture>(handle) }
.expect("the job box is replaced by the converted texture");
let px = first_pixel(out);
assert!(
(px[0] - 0.5).abs() < 1e-3,
"red channel doubles through the LUT: {px:?}"
);
assert!((px[1] - 0.25).abs() < 1e-4, "green unchanged: {px:?}");
assert_eq!(px[3], 1.0, "alpha preserved");
let _ = std::fs::remove_file(&path);
}
/// An invalid processor passes the input texture through unchanged
/// (C++ creates processors non-fatally).
#[test]
fn resolve_color_transform_job_passes_through_when_processor_invalid() {
let mut frame = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap();
for px in frame.data.chunks_exact_mut(16) {
for (c, v) in px.chunks_exact_mut(4).zip([0.4f32, 0.3, 0.2, 1.0]) {
c.copy_from_slice(&v.to_le_bytes());
}
}
let payload = ColorTransformJobPayload {
color_processor: std::sync::Arc::new(ColorProcessor::pass_through()),
input: NodeValue::Texture(oak_node::handle::make_owned(Texture::wrap_frame(frame))),
time: Rational::new(0, 1),
};
let mut table = NodeValueTable::default();
table.push(
oak_node::value::ValueType::Texture,
NodeValue::Texture(oak_node::handle::make_owned(payload)),
None,
);
use oak_node::traverser::RenderHooks;
let mut hooks = RenderEvalHooks::new();
hooks.resolve(
oak_node::id::NodeId::INVALID,
&NodeValueRow::new(),
&mut table,
);
let rows = table.rows();
let NodeValue::Texture(handle) = &rows[0].1 else {
unreachable!()
};
let out = unsafe { oak_node::handle::get_checked::<Texture>(handle) }
.expect("the job box is replaced by the input texture");
assert_eq!(first_pixel(out), [0.4, 0.3, 0.2, 1.0], "untouched");
}
/// into transparent, then top (first) over it — `out = src*a +
/// dst*(1-a)`, `out_a = a + dst_a*(1-a)` (premultiplied source).
#[test]
@@ -2431,5 +2679,215 @@ mod tests {
ctx.destroy_texture(dst);
ctx.destroy_texture(src);
}
/// Pixel readback helper for the GPU verification tests.
fn pixel_at(frame: &Frame, x: usize, y: usize) -> [f32; 4] {
let at = (y * frame.width as usize + x) * 16;
let mut out = [0f32; 4];
for c in 0..4 {
out[c] = f32::from_le_bytes(frame.data[at + c * 4..at + c * 4 + 4].try_into().unwrap());
}
out
}
/// Build a solid-color F32 CPU frame.
fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
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(rgba) {
c.copy_from_slice(&v.to_le_bytes());
}
}
Texture::wrap_frame(frame)
}
/// Evaluate one node's `value()` against `inputs` and resolve the
/// resulting table through the hooks (the full value -> job -> GPU
/// run -> texture path), reading the frame back while the table —
/// which owns the texture's GPU token — is still alive.
fn eval_node_row(
type_id: &str,
inputs: NodeValueRow,
frame_size: Option<(i32, i32)>,
) -> Frame {
use oak_node::traverser::RenderHooks;
let (core, behavior) = oak_node::factory::Factory::global()
.create_any(type_id)
.expect("node type registered");
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let mut hooks = RenderEvalHooks::new();
hooks.frame_size = frame_size;
hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
let Some(NodeValue::Texture(handle)) =
table.get(oak_node::value::ValueType::Texture)
else {
panic!("{type_id}: no texture produced");
};
if handle.ctx.is_null() {
panic!("{type_id}: null texture produced");
}
let tex = (unsafe { oak_node::handle::get_checked::<Texture>(handle) })
.expect("resolved texture");
assert!(
matches!(tex, Texture::Gpu { .. }),
"{type_id}: the job must render on the GPU"
);
tex.to_frame().expect("readback")
}
/// Merge over the real GPU path: the merge node declares no effect
/// input, so base and blend must bind by name for the alpha-over to
/// run at all. Red base + half-alpha green blend -> (0.5, 1, 0, 1).
#[test]
fn gpu_merge_alpha_over_binds_base_and_blend() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("no adapter; skipping");
return;
}
let mut inputs = NodeValueRow::new();
inputs.insert(
"base_in".into(),
texture_value(filled_frame((16, 16), [1.0, 0.0, 0.0, 1.0])),
);
inputs.insert(
"blend_in".into(),
texture_value(filled_frame((16, 16), [0.0, 1.0, 0.0, 0.5])),
);
let frame = eval_node_row("org.olivevideoeditor.Olive.merge", inputs, None);
assert_eq!(frame.width, 16, "the pass size follows the base");
for (x, y) in [(0, 0), (8, 8), (15, 15)] {
let px = pixel_at(&frame, x, y);
let want = [0.5, 1.0, 0.0, 1.0];
for (c, (got, w)) in px.iter().zip(want).enumerate() {
assert!(
(got - w).abs() < 1e-4,
"merge ({x},{y}) ch{c}: got {got}, want {w}"
);
}
}
}
/// A bare generator (no input connected) renders at the hook's frame
/// size instead of a 1x1 the composite step would drop.
#[test]
fn gpu_generator_without_input_renders_at_frame_size() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("no adapter; skipping");
return;
}
let mut inputs = NodeValueRow::new();
inputs.insert("color_in".into(), NodeValue::Color([0.2, 0.4, 0.6, 1.0]));
let frame = eval_node_row(
"org.olivevideoeditor.Olive.solidgenerator",
inputs,
Some((8, 4)),
);
assert_eq!((frame.width, frame.height), (8, 4));
let px = pixel_at(&frame, 3, 2);
for (c, w) in [0.2f32, 0.4, 0.6, 1.0].iter().enumerate() {
assert!(
(px[c] - w).abs() < 1e-4,
"solid ch{c}: got {}, want {w}",
px[c]
);
}
}
/// Generator-over-base ("mrg"): the nested generator job resolves
/// recursively and alpha-overs onto the base — the pentagon is green
/// (the generated layer), the corners stay red (the base).
#[test]
fn gpu_generator_over_base_composites_nested_job() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("no adapter; skipping");
return;
}
let mut inputs = NodeValueRow::new();
inputs.insert(
"base_in".into(),
texture_value(filled_frame((512, 512), [1.0, 0.0, 0.0, 1.0])),
);
inputs.insert("color_in".into(), NodeValue::Color([0.0, 1.0, 0.0, 1.0]));
let frame = eval_node_row("org.olivevideoeditor.Olive.polygon", inputs, Some((512, 512)));
assert_eq!((frame.width, frame.height), (512, 512));
let center = pixel_at(&frame, 256, 256);
assert!(
center[1] > 0.9 && center[0] < 0.1,
"pentagon center is the generated green: {center:?}"
);
let corner = pixel_at(&frame, 5, 5);
assert!(
corner[0] > 0.9 && corner[1] < 0.1,
"corner keeps the red base: {corner:?}"
);
}
/// Drop shadow with non-zero softness: three iterations feed back
/// through `previous_iteration_in`; the blurred shadow lands offset
/// from the source, widening the non-transparent area.
#[test]
fn gpu_dropshadow_softness_blurs_and_offsets() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("no adapter; skipping");
return;
}
// 16x16 transparent frame with an opaque 4x4 square at (4,4).
let mut frame = generate_frame(Rational::new(0, 1), (16, 16), PixelFormat::F32).unwrap();
for y in 4..8usize {
for x in 4..8usize {
let at = (y * 16 + x) * 16;
for (c, v) in [1.0f32, 1.0, 1.0, 1.0].iter().enumerate() {
frame.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
let mut inputs = NodeValueRow::new();
inputs.insert("tex_in".into(), texture_value(Texture::wrap_frame(frame)));
inputs.insert("color_in".into(), NodeValue::Color([0.0, 0.0, 0.0, 1.0]));
inputs.insert("distance_in".into(), NodeValue::Float(4.0));
inputs.insert("angle_in".into(), NodeValue::Float(45.0));
inputs.insert("radius_in".into(), NodeValue::Float(2.0));
inputs.insert("opacity_in".into(), NodeValue::Float(1.0));
inputs.insert("fast_in".into(), NodeValue::Boolean(false));
let out_frame = eval_node_row("org.olivevideoeditor.Olive.dropshadow", inputs, None);
assert_eq!((out_frame.width, out_frame.height), (16, 16));
let covered = out_frame
.data
.chunks_exact(16)
.filter(|px| f32::from_le_bytes(px[12..16].try_into().unwrap()) > 0.01)
.count();
assert!(
covered > 16,
"the offset blurred shadow must widen the covered area beyond the 4x4 source square: {covered}"
);
}
/// Transform over the real GPU path: the fragment-side inverse
/// sampling applies the node's matrix for real — a +3px x
/// translation moves the white pixel from (2, 3) to (5, 3).
#[test]
fn gpu_transform_translates_pixels() {
if oak_core::backend::GpuContext::shared().is_none() {
eprintln!("no adapter; skipping");
return;
}
// 8x8 black frame with one white pixel at (2, 3).
let mut frame = generate_frame(Rational::new(0, 1), (8, 8), PixelFormat::F32).unwrap();
let at = (3 * 8 + 2) * 16;
for (c, v) in [1.0f32, 1.0, 1.0, 1.0].iter().enumerate() {
frame.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
let mut inputs = NodeValueRow::new();
inputs.insert("tex_in".into(), texture_value(Texture::wrap_frame(frame)));
inputs.insert("pos_in".into(), NodeValue::Vec2([3.0, 0.0]));
let out = eval_node_row("org.olivevideoeditor.Olive.transform", inputs, None);
assert_eq!((out.width, out.height), (8, 8));
assert_eq!(pixel_at(&out, 2, 3), [0.0, 0.0, 0.0, 0.0], "source spot vacated");
assert_eq!(pixel_at(&out, 5, 3), [1.0, 1.0, 1.0, 1.0], "pixel moved +3 in x");
assert_eq!(pixel_at(&out, 0, 0), [0.0, 0.0, 0.0, 0.0]);
}
}
+19 -1
View File
@@ -2691,8 +2691,26 @@ mod tests {
#[test]
fn copy_counter_counts_only_slot_to_vec() {
// The zero-copy contract: only `slot_to_vec` bumps the counter —
// `slot_bytes` (the borrowed view the preview path uses) must not.
reset_main_heap_frame_copies();
let key = format!("oak-procpool-copycounter-{}", std::process::id());
let view = ShmRegionView::create(&key, 2, 64).expect("shm segment");
let _borrowed = view.slot_bytes(0);
assert_eq!(
main_heap_frame_copies(),
0,
"borrowed slot reads stay zero-copy"
);
let _copied = view.slot_to_vec(0);
assert_eq!(
main_heap_frame_copies(),
1,
"slot_to_vec is the one counted copy"
);
drop(view);
SharedMemoryRegion::unlink_key(&key);
reset_main_heap_frame_copies();
assert_eq!(main_heap_frame_copies(), 0);
}
/// A worker's `plugin_progress` NDJSON line is forwarded to the
+25 -7
View File
@@ -189,6 +189,10 @@ pub fn compile_effect(
/// - `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).
/// `iterative_input` (C++ `ShaderJob::iterative_input`) names the
/// texture the feedback lands in — e.g. the drop shadow's
/// `previous_iteration_in` — while the other samplers keep their
/// original bindings; empty/`None` feeds back into the main input.
/// - Well-known uniforms are auto-filled when declared but absent from
/// `params`: `resolution_in` (the frame size), `ove_iteration`,
/// `ove_mvpmat` (identity).
@@ -200,6 +204,7 @@ pub fn run_effect(
dst: u64,
size: (i32, i32),
iterations: u32,
iterative_input: Option<&str>,
) -> Result<()> {
use oak_node::value::NodeValue;
@@ -250,7 +255,12 @@ pub fn run_effect(
}
// Ping-pong (C++ Blit): one scratch texture for two passes, two for
// longer chains; the last pass always lands in `dst`.
// longer chains; the last pass always lands in `dst`. Each pass feeds
// back into the iterative input (C++ `ShaderJob::iterative_input`),
// defaulting to the first (main) texture.
let feedback = iterative_input
.and_then(|name| effect.translated.textures.iter().position(|t| t == name))
.unwrap_or(0);
let scratch_a = ctx.create_texture(size.0, size.1)?;
let scratch_b = if iterations > 2 {
Some(ctx.create_texture(size.0, size.1)?)
@@ -273,7 +283,10 @@ pub fn run_effect(
};
let uniforms = pack_uniforms(&effect.translated, &pass_row);
ctx.run_shader_pass(&effect.program, &uniforms, &input_tokens, target)?;
input_tokens[0] = target;
if !input_tokens.is_empty() {
let slot = feedback.min(input_tokens.len() - 1);
input_tokens[slot] = target;
}
}
Ok(())
})();
@@ -912,7 +925,7 @@ void main() {
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();
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 4), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
for px in 0..16usize {
@@ -956,13 +969,13 @@ void main() {
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();
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1, None).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();
run_effect(&ctx, &effect, &row, &[("tex_in".to_string(), src)], dst, (16, 1), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
for x in 0..16usize {
let got = pixel(&out, x)[0];
@@ -1014,7 +1027,7 @@ void main() {
"color_in".into(),
oak_node::value::NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
);
run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1).unwrap();
run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
let center = pixel(&out, 256 * 512 + 256);
@@ -1024,7 +1037,7 @@ void main() {
// Degenerate: a single point draws nothing.
row.insert("point_count".into(), oak_node::value::NodeValue::Int(1));
run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1).unwrap();
run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1, None).unwrap();
let out = ctx.download(dst).unwrap();
assert_eq!(
pixel(&out, 256 * 512 + 256),
@@ -1079,6 +1092,7 @@ void main() {
dst,
(512, 512),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
@@ -1096,6 +1110,7 @@ void main() {
dst,
(512, 512),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
@@ -1128,6 +1143,7 @@ void main() {
dst,
(512, 512),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
@@ -1208,6 +1224,7 @@ void main() {
dst,
(8, 4),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();
@@ -1299,6 +1316,7 @@ void main() {
dst,
(8, 4),
1,
None,
)
.unwrap();
let out = ctx.download(dst).unwrap();