feat(oakplugin): wire OpenFX plugins into the node graph and renderer

- oaknode: dynamic node factory registration, PluginNode value model
  pushing PluginJobPayload, traverser texture passthrough for texture
  inputs, type-stamped RefBox::get_checked.
- oakrender: PluginExecutor dependency-inversion slot; eval resolves
  and executes plugin jobs, purple frame on failure.
- oakplugin: node_factory with full OFX param -> node input
  translation (15 types, color semantics heuristic, combo ordering,
  secret/ui_group/ui_page, clip inputs), plugin instance registry,
  render executor + duplicator installation, progress reporter and
  active-viewer provider injection points, U8/U16/F16 input
  conversion with NaN scrubbing, in-place output frame writeback fix.
- gl_bridge.rs documents the wgpu<->GL interop spike: Metal-first on
  macOS rules out wgpu-hal GL interop; offscreen GL context deferred.

End-to-end tests cover registration, param translation, CPU render
pixel assertions, identity passthrough and NaN fallback.
This commit is contained in:
2026-08-18 17:15:13 +08:00
parent 9daa266189
commit 2db1615453
21 changed files with 2595 additions and 189 deletions
+107 -3
View File
@@ -16,14 +16,29 @@
//! The node type registry (C++ `NodeFactory` / `node/factory`):
//! type id -> constructor, plus menu metadata.
//!
//! Two entry kinds: the static built-in table installed by
//! [`crate::nodes::register_all`] (compile-time [`NodeMeta`]), and
//! runtime entries appended after plugin discovery
//! ([`DynamicNodeMeta`] — the C++ `NodeFactory::register_plugin_nodes`
//! appends one library entry per discovered OFX plugin; those have no
//! compile-time ids, so their metadata is owned and their constructor
//! is a closure capturing the plugin identifier).
use std::sync::OnceLock;
use std::sync::{Mutex, OnceLock};
use crate::node::{Category, NodeBehavior, NodeCore};
/// Constructor for a node type: behavior + default core inputs.
pub type NodeConstructor = fn() -> (NodeCore, Box<dyn NodeBehavior>);
/// Constructor for a runtime-registered node type (captures per-plugin
/// state; each call builds a fresh node with a fresh backing instance,
/// matching the C++ `NodeFactory::create` `n->copy()` semantics for
/// plugin library entries).
pub type DynNodeConstructor =
std::sync::Arc<dyn Fn() -> (NodeCore, Box<dyn NodeBehavior>) + Send + Sync>;
/// Static metadata for the node menu (C++ factory listing).
#[derive(Clone)]
pub struct NodeMeta {
@@ -37,9 +52,30 @@ pub struct NodeMeta {
pub create: NodeConstructor,
}
/// The registry (built at crate init by `nodes::register_all`).
/// Runtime-registered node type metadata (the C++ factory's plugin
/// entries). Owned strings: plugin identifiers and labels are only
/// known at scan time.
#[derive(Clone)]
pub struct DynamicNodeMeta {
/// Type id (the OFX plugin identifier).
pub type_id: String,
/// Display name (the plugin descriptor's label).
pub name: String,
/// Categories.
pub categories: Vec<Category>,
/// Sub-category (the OFX context display name, e.g. "Filter").
pub sub_category: String,
/// Description (the plugin descriptor's description).
pub description: String,
/// Constructor.
pub create: DynNodeConstructor,
}
/// The registry (built at crate init by `nodes::register_all`; plugin
/// entries are appended at runtime via [`Factory::register_dynamic`]).
pub struct Factory {
entries: Vec<NodeMeta>,
dynamic: Mutex<Vec<DynamicNodeMeta>>,
}
impl Factory {
@@ -53,7 +89,10 @@ impl Factory {
.get()
.expect("nodes::register_all installs the entry table")
.clone();
Factory { entries }
Factory {
entries,
dynamic: Mutex::new(Vec::new()),
}
})
}
@@ -66,6 +105,71 @@ impl Factory {
pub fn entries(&self) -> &[NodeMeta] {
&self.entries
}
fn dynamic(&self) -> std::sync::MutexGuard<'_, Vec<DynamicNodeMeta>> {
self.dynamic.lock().unwrap_or_else(|e| e.into_inner())
}
/// Append a runtime-discovered node type (C++
/// `NodeFactory::register_plugin_nodes` library append). Returns
/// false when the type id is already registered (static or dynamic
/// — the C++ `existing_ids` skip).
pub fn register_dynamic(&self, meta: DynamicNodeMeta) -> bool {
if self.find(&meta.type_id).is_some() {
return false;
}
let mut dyns = self.dynamic();
if dyns.iter().any(|m| m.type_id == meta.type_id) {
return false;
}
dyns.push(meta);
true
}
/// The runtime entries (clone of the current list; registration
/// order).
pub fn dynamic_entries(&self) -> Vec<DynamicNodeMeta> {
self.dynamic().clone()
}
/// Look up a runtime entry by type id.
pub fn find_dynamic(&self, type_id: &str) -> Option<DynamicNodeMeta> {
self.dynamic().iter().find(|m| m.type_id == type_id).cloned()
}
/// Look up either entry kind and construct a node (static first,
/// then dynamic — C++ `NodeFactory::create` walks one combined
/// library).
pub fn create_any(&self, type_id: &str) -> Option<(NodeCore, Box<dyn NodeBehavior>)> {
if let Some(meta) = self.find(type_id) {
return Some((meta.create)());
}
self.find_dynamic(type_id).map(|m| (m.create)())
}
/// Combined entry count (static + dynamic).
pub fn total_count(&self) -> usize {
self.entries.len() + self.dynamic().len()
}
/// Type id at a combined index (static entries first, then dynamic
/// in registration order).
pub fn type_id_at(&self, index: usize) -> Option<String> {
if index < self.entries.len() {
return Some(self.entries[index].type_id.to_string());
}
self.dynamic()
.get(index - self.entries.len())
.map(|m| m.type_id.clone())
}
/// Display name for a type id (either entry kind).
pub fn name_of(&self, type_id: &str) -> Option<String> {
if let Some(meta) = self.find(type_id) {
return Some(meta.name.to_string());
}
self.find_dynamic(type_id).map(|m| m.name)
}
}
/// Entries installed by [`crate::nodes::register_all`].
+42
View File
@@ -37,13 +37,32 @@ use std::sync::atomic::{AtomicU32, Ordering};
pub const OAKNODE_ABI_VERSION: u32 = 1;
/// Heap box behind a handle's `ctx`.
///
/// `repr(C)`: the field order is the stable prefix layout
/// [`RefBoxHeader`] relies on for type discrimination across boxes of
/// different payloads sharing one handle channel (the render seam
/// downcasts plugin-job boxes vs texture boxes).
#[repr(C)]
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Type identity of the boxed value (stamped by [`make_owned`];
/// [`get_checked`] compares it before reading [`RefBox::value`]).
pub type_id: std::any::TypeId,
/// Boxed value.
pub value: T,
}
/// The fixed-size prefix of every [`RefBox`] (layout-stable across
/// payload types because [`RefBox`] is `repr(C)`).
#[repr(C)]
struct RefBoxHeader {
/// Mirror of [`RefBox::refs`] (present for the layout prefix;
/// never read here).
_refs: AtomicU32,
type_id: std::any::TypeId,
}
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
@@ -79,6 +98,7 @@ unsafe extern "C" fn refbox_release_owned<T: Any + Send>(ctx: *mut std::ffi::c_v
pub fn make_owned<T: Any + Send>(value: T) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
type_id: std::any::TypeId::of::<T>(),
value,
}));
CHandle {
@@ -98,6 +118,7 @@ pub fn make_owned_with<T: Any + Send>(
) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
type_id: std::any::TypeId::of::<T>(),
value,
}));
CHandle {
@@ -118,3 +139,24 @@ pub unsafe fn get<T: Any>(h: &CHandle) -> Option<&T> {
}
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
}
/// Typed view with type discrimination: `None` when the handle is
/// empty **or** boxes a different payload type (the render seam probes
/// texture-channel handles for plugin-job payloads this way without
/// knowing the producer).
///
/// # Safety
/// `h` must be either empty or a live handle created by
/// [`make_owned`]/[`make_owned_with`] for the duration of the call.
pub unsafe fn get_checked<T: Any>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: every live box starts with the repr(C) RefBox prefix;
// the caller guarantees the handle is alive.
let header = unsafe { &*(h.ctx as *const RefBoxHeader) };
if header.type_id != std::any::TypeId::of::<T>() {
return None;
}
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
}
+1 -1
View File
@@ -44,7 +44,7 @@ mod ociogradingtransformlog;
mod ociolut;
mod opacity;
mod pan;
mod plugin;
pub mod plugin;
mod polygon;
mod rippledistortnode;
mod shapenode;
+175 -100
View File
@@ -14,20 +14,25 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! OpenFX plugin node (C++ `src/node/src/plugins/plugin.{h,cpp}`,
//! OpenFX plugin node (C++ `engine/node/plugins/plugin.{h,cpp}`,
//! `olive::plugin::PluginNode`).
//!
//! DECLARATION ONLY. This node is a thin wrapper over an OFX plugin
//! instance that lives behind the `oakplugin` crate's C ABI bridge
//! (opaque oakrender handles); no OFX types (`OFX::Host::ImageEffect::Instance`,
//! `kOfxParam*`, ...) are declared here. The plugin instance is
//! represented as the opaque [`PluginInstanceHandle`] below — the real
//! definition belongs to the oakplugin bridge module and this draft
//! stands in for it.
//! Thin wrapper over an OFX plugin instance that lives in the
//! `oakplugin` crate; no OFX types (`OFX::Host::ImageEffect::Instance`,
//! `kOfxParam*`, ...) are declared here because oaknode sits below
//! oakplugin in the dependency graph. The instance is represented as
//! the opaque [`PluginInstanceHandle`] — an identity key into the
//! oakplugin crate's instance registry.
//!
//! All per-plugin data (inputs, defaults, properties, labels) is
//! discovered at runtime from the plugin descriptor through the
//! bridge, mirroring the C++ constructor.
//! discovered at runtime from the plugin descriptor by the oakplugin
//! discovery pass (`oakplugin::node_factory`), which builds the
//! [`NodeCore`] inputs, constructs [`PluginNode`]s and registers one
//! factory entry per discovered plugin (the C++
//! `factory.cpp::register_plugin_nodes` + `PluginNode::PluginNode`
//! split across the dependency seam).
use std::sync::{Arc, Mutex, OnceLock};
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
@@ -36,8 +41,9 @@ use oakcore_rs::{Rational, TimeRange};
/// Texture input id (C++ `plugin::k_texture_input`). Type: texture;
/// no default. Only synthesized when the plugin declares clip inputs
/// but none of them is the simple-source clip (see [`create`]); then
/// it becomes the node's effect input with display name "Texture".
/// but none of them is the simple-source clip (see the discovery
/// pass); then it becomes the node's effect input with display name
/// "Texture".
pub const TEXTURE_INPUT: &str = "tex_in";
/// OFX simple-source clip name (C++ `kOfxImageEffectSimpleSourceClipName`):
@@ -45,9 +51,8 @@ pub const TEXTURE_INPUT: &str = "tex_in";
pub const SOURCE_CLIP: &str = "Source";
/// Opaque handle to an OFX plugin instance owned by the `oakplugin`
/// crate C ABI bridge (C++ `OFX::Host::ImageEffect::Instance *`,
/// member `plugin_instance_`). Placeholder type for this draft — the
/// bridge's real handle type replaces it.
/// crate (identity key into its instance registry; C++
/// `OFX::Host::ImageEffect::Instance *`, member `plugin_instance_`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PluginInstanceHandle(pub u64);
@@ -64,6 +69,27 @@ impl PluginInstanceHandle {
}
}
/// Plugin job payload (C++ `plugin::PluginJob`): everything the render
/// seam needs to run the instance against the input texture. Boxed
/// into a texture-typed [`NodeValue`] by [`PluginNode::value`] and
/// resolved by the oakrender evaluation seam (C++
/// `RenderProcessor::process_plugin_job`), which executes it through
/// the oakplugin render driver.
#[derive(Clone, Debug)]
pub struct PluginJobPayload {
/// The instance identity (oakplugin registry key).
pub instance: PluginInstanceHandle,
/// The request time (C++ `globals.time().in()`).
pub time: Rational,
/// The effect input id the main source texture arrives on (C++
/// `node->get_effect_input_id()`).
pub effect_input_id: String,
/// Snapshot of the full input row (C++ `PluginJob` holds the
/// `NodeValueRow`: the tagged non-texture values become param
/// overrides, the texture values feed the clip inputs).
pub values: NodeValueRow,
}
/// OFX plugin node. Wraps one plugin instance; inputs mirror the
/// plugin's declared params and clips.
///
@@ -71,10 +97,10 @@ impl PluginInstanceHandle {
/// pointer, `sub_category_`. Because `name()`/`id()`/`description()`
/// read the plugin descriptor at call time in C++ and the Rust trait
/// returns `&str`, this port caches those strings on the struct
/// (populated from the bridge at construction).
/// (populated from the descriptor at construction).
pub struct PluginNode {
/// The wrapped plugin instance (C++ `plugin_instance_`; an
/// opaque bridge handle here).
/// oakplugin registry identity here).
instance: PluginInstanceHandle,
/// Sub-category derived from the OFX context (C++
/// `sub_category_`): "Filter", "Generator", "Transition", or
@@ -90,7 +116,51 @@ pub struct PluginNode {
description: String,
}
/// The plugin-node duplicator (installed by the oakplugin crate): old
/// instance identity -> fresh instance identity (the C++
/// `PluginNode::copy()` creates a fresh instance — filter context
/// preferred, else the plugin's first declared context). `None` when
/// instance creation fails.
type PluginDuplicator = dyn Fn(PluginInstanceHandle) -> Option<PluginInstanceHandle> + Send + Sync;
static DUPLICATOR: OnceLock<Mutex<Option<Arc<PluginDuplicator>>>> = OnceLock::new();
fn duplicator_slot() -> &'static Mutex<Option<Arc<PluginDuplicator>>> {
DUPLICATOR.get_or_init(|| Mutex::new(None))
}
/// Install the plugin-node duplicator (oakplugin discovery path;
/// `None` clears it). Without it [`PluginNode::duplicate`] returns
/// `None`.
pub fn set_plugin_duplicator(dup: Option<Arc<PluginDuplicator>>) {
*duplicator_slot().lock().unwrap_or_else(|e| e.into_inner()) = dup;
}
impl PluginNode {
/// Constructor for the oakplugin discovery path (the C++
/// `PluginNode::PluginNode(instance)`; the input walk happens in
/// the oakplugin translation pass, which owns the OFX types).
pub fn new(
instance: PluginInstanceHandle,
name: String,
type_id: String,
description: String,
sub_category: String,
) -> Self {
PluginNode {
instance,
sub_category,
name,
type_id,
description,
}
}
/// The wrapped instance identity (oakplugin registry key).
pub fn instance_handle(&self) -> PluginInstanceHandle {
self.instance
}
/// Forward a push-button param activation to the plugin (C++
/// `push_button_clicked()`; currently a no-op upstream).
pub fn push_button_clicked(&mut self, core: &mut NodeCore, name: String) {
@@ -133,12 +203,9 @@ impl NodeBehavior for PluginNode {
/// the value to the matching OFX param); then resolves the input
/// texture — simple-source clip first, then `tex_in`, then the
/// first texture-typed input — and, when both texture and plugin
/// instance exist, pushes the texture converted to a plugin job
/// bound to this node and the request time.
///
/// The Rust model has no plugin-job payload: the job case pushes a
/// null texture handle marking a renderer-deferred plugin job
/// (`// CPP-PARITY: plugin.cpp` `value()`).
/// instance exist, pushes a [`PluginJobPayload`] boxed into a
/// texture-typed value (the C++ `table->push(k_texture,
/// tex->to_job(job), this)`; the render seam executes the job).
fn value(
&self,
core: &NodeCore,
@@ -146,8 +213,6 @@ impl NodeBehavior for PluginNode {
time: Rational,
table: &mut NodeValueTable,
) {
let _ = (core, time);
// Re-push every non-texture, non-none input value, tagged with
// its input id.
for (id, v) in inputs.iter() {
@@ -170,11 +235,15 @@ impl NodeBehavior for PluginNode {
.or_else(|| inputs.values().find(|v| matches!(v, NodeValue::Texture(_))));
if tex.is_some() && !self.instance.is_null() {
// C++ `table->push(NodeValue::k_texture, tex->to_job(job),
// this)` — a deferred plugin job; no job payload here.
let payload = PluginJobPayload {
instance: self.instance,
time,
effect_input_id: core.effect_input.clone(),
values: inputs.clone(),
};
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
NodeValue::Texture(crate::handle::make_owned(payload)),
None,
);
}
@@ -255,16 +324,29 @@ impl NodeBehavior for PluginNode {
let _ = (core, frame, time);
}
/// Deep copy (C++ `copy()`): asks the bridge to create a fresh
/// plugin instance — filter context when supported, else the
/// plugin's first declared context — and wraps it in a new node;
/// `None` when there is no instance or instance creation fails.
///
/// This crate's bridge has no instance-creation call, so a plugin
/// node can never be duplicated here and `None` is always returned
/// (`// CPP-PARITY: plugin.cpp` `copy()`).
/// Deep copy (C++ `copy()`): asks the oakplugin side (through the
/// installed [`set_plugin_duplicator`] hook) for a fresh plugin
/// instance — filter context when supported, else the plugin's
/// first declared context — and wraps it in a new behavior; the
/// caller clones the core (inputs included), matching the C++
/// `Node::copy` split. `None` when there is no instance or no
/// duplicator installed.
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
None
if self.instance.is_null() {
return None;
}
let dup = duplicator_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()?;
let new_handle = dup(self.instance)?;
Some(Box::new(PluginNode {
instance: new_handle,
sub_category: self.sub_category.clone(),
name: self.name.clone(),
type_id: self.type_id.clone(),
description: self.description.clone(),
}))
}
/// Downcast to [`Self`] (instance/metadata access).
@@ -278,48 +360,12 @@ impl NodeBehavior for PluginNode {
}
}
/// Constructor (C++ `PluginNode::PluginNode(instance)`): stores the
/// instance handle and derives the sub-category from the OFX context
/// (filter/generator/transition, else "General"). Then walks the
/// plugin's params through the bridge:
///
/// - group params seed a group-label map; page params seed a
/// page-label map and a param->page-label map (skipping
/// skip-row/skip-column sentinels);
/// - each value param becomes an input: int/choice -> int/combo,
/// double -> float, boolean -> boolean, string -> text,
/// RGB/RGBA -> color, 2D/3D double/int -> vec2/vec3,
/// str-choice -> str-combo, bytes/custom -> binary, push-button ->
/// push-button; group/page and unknown types are skipped;
/// - defaults come from a per-plugin-id cache built from the OFX
/// `kOfxParamPropDefault` properties (normalised-coordinate doubles
/// are converted to canonical pixels against the project extent);
/// a non-null default is added as the input default and set as the
/// standard value (except for push-buttons);
/// - secret params get the hidden flag; the param label (or name)
/// becomes the input display name; parent groups set the `ui_group`
/// property, pages the `ui_page` property;
/// - color inputs get a `color_semantic` property ("color"/"scalar",
/// deduced from label/hint/display-range/default/group heuristics),
/// `min`/`max` from the display range, and a `tooltip` from the
/// hint;
/// - combo inputs get combo-box strings from the choice options
/// (ordered by the choice-order property when present), and
/// str-combos additionally a `combo_value_str` property.
///
/// Finally every non-output clip becomes a texture input named from
/// its label ("Source"/"From"/"To" for the well-known clips), and the
/// effect input is set: the simple-source clip if present, else
/// `tex_in` if present, else a synthesized `tex_in` texture input
/// (display name "Texture") when the plugin has any clip input at
/// all.
///
/// The plugin-instance bridge is not wired in this crate, so the
/// descriptor walk cannot run here: a placeholder node with a null
/// instance and empty cached metadata is returned. Real construction
/// belongs to the oakplugin bridge's discovery pass, which registers
/// one `PluginNode` per discovered plugin (`// CPP-PARITY: plugin.cpp`
/// `PluginNode::PluginNode`).
/// Constructor (C++ `PluginNode::PluginNode(instance)`): the real
/// construction (instance creation + the OFX param/clip -> input
/// translation) belongs to the oakplugin discovery pass
/// (`oakplugin::node_factory`), which registers one factory entry per
/// discovered plugin. This placeholder (null instance, empty cached
/// metadata) stays for the static-registration surface and tests.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let core = NodeCore::new();
let node = PluginNode {
@@ -333,12 +379,12 @@ pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
}
/// Register this node type. NOTE: unlike built-in nodes, plugin nodes
/// have no static type id — the C++ factory appends one `PluginNode`
/// per discovered OFX plugin at runtime
/// (`factory.cpp::add_plugins_to_library`), keyed by plugin
/// have no static type id — the oakplugin discovery pass appends one
/// factory entry per discovered OFX plugin at runtime (C++
/// `factory.cpp::register_plugin_nodes`) through
/// [`crate::factory::Factory::register_dynamic`], keyed by plugin
/// identifier, so there is no fixed `NodeMeta` literal to push. This
/// function is a no-op placeholder; real registration belongs to the
/// oakplugin bridge's discovery pass.
/// function is a no-op placeholder kept for the static table.
pub fn register(meta: &mut Vec<NodeMeta>) {
let _ = meta;
}
@@ -386,7 +432,8 @@ mod tests {
row.insert("mode".to_string(), NodeValue::Combo(2));
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
// Both values pushed, tagged with their input ids.
// Both values pushed, tagged with their input ids, plus the
// plugin job texture (no texture input in the row -> no job).
let tagged: Vec<(&str, &NodeValue)> = table
.rows()
.iter()
@@ -405,24 +452,38 @@ mod tests {
}
#[test]
fn value_skips_texture_and_none_inputs() {
fn value_pushes_job_payload_for_texture_input() {
let n = node();
let core = NodeCore::new();
let mut core = NodeCore::new();
core.effect_input = TEXTURE_INPUT.to_string();
let mut row = NodeValueRow::default();
row.insert(
"tex_in".to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
row.insert("gain".to_string(), NodeValue::Float(0.25));
row.insert("none_in".to_string(), NodeValue::None);
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
// The texture is consumed as the job source; nothing else is pushed
// (none values are skipped; the plugin job is the texture push).
assert_eq!(table.count(), 1);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
let time = Rational::new(3, 2);
n.value(&core, &row, time, &mut table);
// The texture is consumed as the job source: one tagged param
// value + the boxed plugin job payload.
assert_eq!(table.count(), 2);
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
panic!("expected the plugin job texture");
};
// SAFETY: the handle was created by value() boxing a
// PluginJobPayload.
let payload = unsafe { crate::handle::get::<PluginJobPayload>(h) }
.expect("texture handle must box a PluginJobPayload");
assert_eq!(payload.instance, PluginInstanceHandle(1));
assert_eq!(payload.time, time);
assert_eq!(payload.effect_input_id, TEXTURE_INPUT);
assert_eq!(payload.values.len(), 3);
assert_eq!(
payload.values.get("gain"),
Some(&NodeValue::Float(0.25))
);
}
#[test]
@@ -440,10 +501,7 @@ mod tests {
);
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_))));
}
#[test]
@@ -532,9 +590,26 @@ mod tests {
}
#[test]
fn duplicate_returns_none() {
fn duplicate_uses_installed_duplicator() {
// No duplicator installed -> None.
set_plugin_duplicator(None);
let n = node();
assert!(n.duplicate(&NodeCore::new()).is_none());
// Installed duplicator: fresh handle, metadata copied.
set_plugin_duplicator(Some(Arc::new(|h: PluginInstanceHandle| {
Some(PluginInstanceHandle(h.0 + 100))
})));
let dup = n.duplicate(&NodeCore::new()).expect("duplicator installed");
let dup = dup.as_any().unwrap().downcast_ref::<PluginNode>().unwrap();
assert_eq!(dup.instance_handle(), PluginInstanceHandle(101));
assert_eq!(dup.name(), "Test Plugin");
assert_eq!(dup.sub_category(), "Filter");
// Duplicator failure -> None.
set_plugin_duplicator(Some(Arc::new(|_: PluginInstanceHandle| None)));
assert!(n.duplicate(&NodeCore::new()).is_none());
set_plugin_duplicator(None);
}
#[test]
+30 -16
View File
@@ -126,34 +126,48 @@ impl Traverser {
return Err(Error::State);
}
let entry = graph.get(node).ok_or(Error::NotFound)?;
// Build this node's input row from its upstream outputs. The
// C++ picks the last value of the matching type per input;
// the Rust model keys rows by input id.
// the Rust model keys rows by input id. Inputs declared as
// texture take the upstream texture directly (the scalar
// chain below would otherwise hand a plugin node's tagged
// param passthrough to a downstream clip input).
let mut row: NodeValueRow = std::collections::BTreeMap::new();
for (from, input_id, element) in graph.input_connections(node) {
let _ = element;
if let Some(from_table) = tables.get(&from) {
let value = from_table
.get(ValueType::Float)
.or_else(|| from_table.get(ValueType::Int))
.or_else(|| from_table.get(ValueType::Color))
.or_else(|| from_table.get(ValueType::Vec2))
.or_else(|| from_table.get(ValueType::Vec3))
.or_else(|| from_table.get(ValueType::Vec4))
.or_else(|| from_table.get(ValueType::Boolean))
.or_else(|| from_table.get(ValueType::Rational))
.or_else(|| from_table.get(ValueType::Text))
.or_else(|| from_table.get(ValueType::Combo))
.or_else(|| from_table.get(ValueType::StrCombo))
.cloned()
.unwrap_or(NodeValue::None);
let value = if entry.core.input_data_type(&input_id)
== Some(ValueType::Texture)
{
from_table
.get(ValueType::Texture)
.cloned()
.unwrap_or(NodeValue::None)
} else {
from_table
.get(ValueType::Float)
.or_else(|| from_table.get(ValueType::Int))
.or_else(|| from_table.get(ValueType::Color))
.or_else(|| from_table.get(ValueType::Vec2))
.or_else(|| from_table.get(ValueType::Vec3))
.or_else(|| from_table.get(ValueType::Vec4))
.or_else(|| from_table.get(ValueType::Boolean))
.or_else(|| from_table.get(ValueType::Rational))
.or_else(|| from_table.get(ValueType::Text))
.or_else(|| from_table.get(ValueType::Combo))
.or_else(|| from_table.get(ValueType::StrCombo))
.or_else(|| from_table.get(ValueType::Texture))
.cloned()
.unwrap_or(NodeValue::None)
};
row.insert(input_id, value);
}
}
// Evaluate the node's behavior into its output table.
let mut table = NodeValueTable::default();
let entry = graph.get(node).ok_or(Error::NotFound)?;
// The behavior writes outputs; the default no-op leaves the
// table empty (C++ `Node::value` default).
entry
+6
View File
@@ -627,6 +627,12 @@ impl NodeValueTable {
pub fn rows(&self) -> &[(ValueType, NodeValue, Option<String>)] {
&self.rows
}
/// Mutable row access (the render seam resolves job payloads into
/// finished textures in place).
pub fn rows_mut(&mut self) -> &mut Vec<(ValueType, NodeValue, Option<String>)> {
&mut self.rows
}
}
/// Structural equality: `Texture` compares by handle address, `Samples`