From 5ab12b937f32b1f8de78e4dc5a4731ccbd7c00b5 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 9 Sep 2026 16:33:29 +0800 Subject: [PATCH] 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 --- crates/oak-app/src/app.rs | 63 +- crates/oak-codec/tests/ffi_contract_test.rs | 117 ---- crates/oak-core/src/dbg_print_test.rs | 7 - crates/oak-core/src/debug.rs | 20 +- crates/oak-node/src/block.rs | 10 +- crates/oak-node/src/jobs.rs | 20 +- crates/oak-node/src/node.rs | 10 +- crates/oak-node/src/nodes/blur.rs | 22 +- .../src/nodes/cornerpindistortnode.rs | 11 +- crates/oak-node/src/nodes/displaytransform.rs | 16 +- crates/oak-node/src/nodes/matrix.rs | 29 + crates/oak-node/src/nodes/ociobase.rs | 131 ++-- crates/oak-node/src/nodes/ociolut.rs | 144 +++-- crates/oak-node/src/nodes/opacity.rs | 37 +- crates/oak-node/src/nodes/plugin.rs | 22 +- crates/oak-node/src/nodes/polygon.rs | 22 +- crates/oak-node/src/nodes/timeoffsetnode.rs | 27 +- crates/oak-node/src/nodes/timeremap.rs | 32 +- .../src/nodes/transformdistortnode.rs | 112 +++- crates/oak-node/src/traverser.rs | 2 +- crates/oak-plugin/src/node.rs | 15 +- crates/oak-plugin/src/render.rs | 5 +- crates/oak-plugin/src/suites/message.rs | 4 +- crates/oak-plugin/src/suites/param.rs | 59 ++ crates/oak-render/src/autocacher.rs | 12 +- crates/oak-render/src/eval.rs | 590 ++++++++++++++++-- crates/oak-render/src/procpool.rs | 20 +- crates/oak-render/src/shaderfx.rs | 32 +- 28 files changed, 1157 insertions(+), 434 deletions(-) delete mode 100644 crates/oak-codec/tests/ffi_contract_test.rs delete mode 100644 crates/oak-core/src/dbg_print_test.rs diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index f46091c10..0906453a7 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -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, + 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"))); } // ------------------------------------------------------------------- diff --git a/crates/oak-codec/tests/ffi_contract_test.rs b/crates/oak-codec/tests/ffi_contract_test.rs deleted file mode 100644 index adf5aa5fd..000000000 --- a/crates/oak-codec/tests/ffi_contract_test.rs +++ /dev/null @@ -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 . - -//! 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. -} diff --git a/crates/oak-core/src/dbg_print_test.rs b/crates/oak-core/src/dbg_print_test.rs deleted file mode 100644 index 0e9a73e92..000000000 --- a/crates/oak-core/src/dbg_print_test.rs +++ /dev/null @@ -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>>>{}<<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, + /// 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 { diff --git a/crates/oak-node/src/node.rs b/crates/oak-node/src/node.rs index 11df35524..453191a1a 100644 --- a/crates/oak-node/src/node.rs +++ b/crates/oak-node/src/node.rs @@ -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 } diff --git a/crates/oak-node/src/nodes/blur.rs b/crates/oak-node/src/nodes/blur.rs index 896dcbe5d..2e87abb22 100644 --- a/crates/oak-node/src/nodes/blur.rs +++ b/crates/oak-node/src/nodes/blur.rs @@ -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::(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] diff --git a/crates/oak-node/src/nodes/cornerpindistortnode.rs b/crates/oak-node/src/nodes/cornerpindistortnode.rs index 9706aec20..67e6c93d2 100644 --- a/crates/oak-node/src/nodes/cornerpindistortnode.rs +++ b/crates/oak-node/src/nodes/cornerpindistortnode.rs @@ -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::(h) }.is_some(), + "a moved corner must push the cornerpin shader job" + ); } #[test] diff --git a/crates/oak-node/src/nodes/displaytransform.rs b/crates/oak-node/src/nodes/displaytransform.rs index ba6e4a926..c1758ef69 100644 --- a/crates/oak-node/src/nodes/displaytransform.rs +++ b/crates/oak-node/src/nodes/displaytransform.rs @@ -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::(handle) + } + .is_some()); } #[test] diff --git a/crates/oak-node/src/nodes/matrix.rs b/crates/oak-node/src/nodes/matrix.rs index a13569776..ef2da7486 100644 --- a/crates/oak-node/src/nodes/matrix.rs +++ b/crates/oak-node/src/nodes/matrix.rs @@ -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)`). diff --git a/crates/oak-node/src/nodes/ociobase.rs b/crates/oak-node/src/nodes/ociobase.rs index 8a937374e..21c0bda85 100644 --- a/crates/oak-node/src/nodes/ociobase.rs +++ b/crates/oak-node/src/nodes/ociobase.rs @@ -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, + /// 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>>, } -// 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> { + 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, + &self, + processor: Option>, ) { // 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::(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] diff --git a/crates/oak-node/src/nodes/ociolut.rs b/crates/oak-node/src/nodes/ociolut.rs index 6569e5f53..73915d396 100644 --- a/crates/oak-node/src/nodes/ociolut.rs +++ b/crates/oak-node/src/nodes/ociolut.rs @@ -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, + /// shared with the base's active processor slot. + last_processor: Option>, /// 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::(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::(handle) + } + .is_some()); } #[test] diff --git a/crates/oak-node/src/nodes/opacity.rs b/crates/oak-node/src/nodes/opacity.rs index 013e1373d..9382accbc 100644 --- a/crates/oak-node/src/nodes/opacity.rs +++ b/crates/oak-node/src/nodes/opacity.rs @@ -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::(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::(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::(h) } + .expect("shader job pushed"); + assert_eq!(payload.shader_id, "rgbmult"); } #[test] diff --git a/crates/oak-node/src/nodes/plugin.rs b/crates/oak-node/src/nodes/plugin.rs index caa894c66..94527f792 100644 --- a/crates/oak-node/src/nodes/plugin.rs +++ b/crates/oak-node/src/nodes/plugin.rs @@ -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] diff --git a/crates/oak-node/src/nodes/polygon.rs b/crates/oak-node/src/nodes/polygon.rs index 24fa0632c..d7eb2fe47 100644 --- a/crates/oak-node/src/nodes/polygon.rs +++ b/crates/oak-node/src/nodes/polygon.rs @@ -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::(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::(&blend) } + .expect("nested generator job"); + assert_eq!(blend_job.shader_id, "rgb"); + assert_eq!(blend_job.type_id, "org.olivevideoeditor.Olive.polygon"); } #[test] diff --git a/crates/oak-node/src/nodes/timeoffsetnode.rs b/crates/oak-node/src/nodes/timeoffsetnode.rs index 45f59507b..641829569 100644 --- a/crates/oak-node/src/nodes/timeoffsetnode.rs +++ b/crates/oak-node/src/nodes/timeoffsetnode.rs @@ -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 diff --git a/crates/oak-node/src/nodes/timeremap.rs b/crates/oak-node/src/nodes/timeremap.rs index ed284b7e1..fa32ec9d4 100644 --- a/crates/oak-node/src/nodes/timeremap.rs +++ b/crates/oak-node/src/nodes/timeremap.rs @@ -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] diff --git a/crates/oak-node/src/nodes/transformdistortnode.rs b/crates/oak-node/src/nodes/transformdistortnode.rs index 9cd64dbd4..e903f5944 100644 --- a/crates/oak-node/src/nodes/transformdistortnode.rs +++ b/crates/oak-node/src/nodes/transformdistortnode.rs @@ -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 { 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] diff --git a/crates/oak-node/src/traverser.rs b/crates/oak-node/src/traverser.rs index 10f3784f1..c19d9f5ee 100644 --- a/crates/oak-node/src/traverser.rs +++ b/crates/oak-node/src/traverser.rs @@ -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_() } diff --git a/crates/oak-plugin/src/node.rs b/crates/oak-plugin/src/node.rs index 013e9d946..f4b61a937 100644 --- a/crates/oak-plugin/src/node.rs +++ b/crates/oak-plugin/src/node.rs @@ -261,15 +261,20 @@ fn registry() -> &'static Mutex> { } /// 登记 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>, 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, diff --git a/crates/oak-plugin/src/render.rs b/crates/oak-plugin/src/render.rs index 6279cf1bb..461f57c7a 100644 --- a/crates/oak-plugin/src/render.rs +++ b/crates/oak-plugin/src/render.rs @@ -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"); } } diff --git a/crates/oak-plugin/src/suites/message.rs b/crates/oak-plugin/src/suites/message.rs index a5de702fe..72230b3e0 100644 --- a/crates/oak-plugin/src/suites/message.rs +++ b/crates/oak-plugin/src/suites/message.rs @@ -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 () )); } diff --git a/crates/oak-plugin/src/suites/param.rs b/crates/oak-plugin/src/suites/param.rs index 2e9e991ab..d124e1184 100644 --- a/crates/oak-plugin/src/suites/param.rs +++ b/crates/oak-plugin/src/suites/param.rs @@ -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); + } } diff --git a/crates/oak-render/src/autocacher.rs b/crates/oak-render/src/autocacher.rs index 854dbc383..8e9c4c6dc 100644 --- a/crates/oak-render/src/autocacher.rs +++ b/crates/oak-render/src/autocacher.rs @@ -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); diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index 66652d127..17a9e07de 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -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> { .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 { + 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::(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::(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 { + 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 { + /// 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::(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) = - 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 = Vec::new(); + let mut keepalive: Vec = Vec::new(); + let mut size: Option<(i32, i32)> = None; + let bind = |key: &str, + value: &NodeValue, + inputs: &mut Vec<(String, u64)>, + scratch: &mut Vec, + keepalive: &mut Vec, + 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::(handle) }) + .cloned() + .or_else(|| { + if depth >= MAX_JOB_DEPTH { + return None; + } + let nested = (unsafe { + oak_node::handle::get_checked::(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::(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::(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::(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]); + } + } diff --git a/crates/oak-render/src/procpool.rs b/crates/oak-render/src/procpool.rs index 0d8fb6469..94c56adcf 100644 --- a/crates/oak-render/src/procpool.rs +++ b/crates/oak-render/src/procpool.rs @@ -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 diff --git a/crates/oak-render/src/shaderfx.rs b/crates/oak-render/src/shaderfx.rs index ffa938d18..cf4058ed3 100644 --- a/crates/oak-render/src/shaderfx.rs +++ b/crates/oak-render/src/shaderfx.rs @@ -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();