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:
+38
-25
@@ -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")));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)`).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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_()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user