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:
@@ -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`].
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user