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`):
|
//! The node type registry (C++ `NodeFactory` / `node/factory`):
|
||||||
//! type id -> constructor, plus menu metadata.
|
//! 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};
|
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||||
|
|
||||||
/// Constructor for a node type: behavior + default core inputs.
|
/// Constructor for a node type: behavior + default core inputs.
|
||||||
pub type NodeConstructor = fn() -> (NodeCore, Box<dyn NodeBehavior>);
|
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).
|
/// Static metadata for the node menu (C++ factory listing).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct NodeMeta {
|
pub struct NodeMeta {
|
||||||
@@ -37,9 +52,30 @@ pub struct NodeMeta {
|
|||||||
pub create: NodeConstructor,
|
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 {
|
pub struct Factory {
|
||||||
entries: Vec<NodeMeta>,
|
entries: Vec<NodeMeta>,
|
||||||
|
dynamic: Mutex<Vec<DynamicNodeMeta>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Factory {
|
impl Factory {
|
||||||
@@ -53,7 +89,10 @@ impl Factory {
|
|||||||
.get()
|
.get()
|
||||||
.expect("nodes::register_all installs the entry table")
|
.expect("nodes::register_all installs the entry table")
|
||||||
.clone();
|
.clone();
|
||||||
Factory { entries }
|
Factory {
|
||||||
|
entries,
|
||||||
|
dynamic: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +105,71 @@ impl Factory {
|
|||||||
pub fn entries(&self) -> &[NodeMeta] {
|
pub fn entries(&self) -> &[NodeMeta] {
|
||||||
&self.entries
|
&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`].
|
/// Entries installed by [`crate::nodes::register_all`].
|
||||||
|
|||||||
@@ -37,13 +37,32 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
|||||||
pub const OAKNODE_ABI_VERSION: u32 = 1;
|
pub const OAKNODE_ABI_VERSION: u32 = 1;
|
||||||
|
|
||||||
/// Heap box behind a handle's `ctx`.
|
/// 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> {
|
pub struct RefBox<T: ?Sized> {
|
||||||
/// Atomic reference count.
|
/// Atomic reference count.
|
||||||
pub refs: AtomicU32,
|
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.
|
/// Boxed value.
|
||||||
pub value: T,
|
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
|
/// The shared ABI value-handle type (single-lib unification, see
|
||||||
/// `docs/zh/plans/riir/single-lib.md`): one canonical
|
/// `docs/zh/plans/riir/single-lib.md`): one canonical
|
||||||
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
|
/// `{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 {
|
pub fn make_owned<T: Any + Send>(value: T) -> CHandle {
|
||||||
let rb = Box::into_raw(Box::new(RefBox {
|
let rb = Box::into_raw(Box::new(RefBox {
|
||||||
refs: AtomicU32::new(1),
|
refs: AtomicU32::new(1),
|
||||||
|
type_id: std::any::TypeId::of::<T>(),
|
||||||
value,
|
value,
|
||||||
}));
|
}));
|
||||||
CHandle {
|
CHandle {
|
||||||
@@ -98,6 +118,7 @@ pub fn make_owned_with<T: Any + Send>(
|
|||||||
) -> CHandle {
|
) -> CHandle {
|
||||||
let rb = Box::into_raw(Box::new(RefBox {
|
let rb = Box::into_raw(Box::new(RefBox {
|
||||||
refs: AtomicU32::new(1),
|
refs: AtomicU32::new(1),
|
||||||
|
type_id: std::any::TypeId::of::<T>(),
|
||||||
value,
|
value,
|
||||||
}));
|
}));
|
||||||
CHandle {
|
CHandle {
|
||||||
@@ -118,3 +139,24 @@ pub unsafe fn get<T: Any>(h: &CHandle) -> Option<&T> {
|
|||||||
}
|
}
|
||||||
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
|
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 ociolut;
|
||||||
mod opacity;
|
mod opacity;
|
||||||
mod pan;
|
mod pan;
|
||||||
mod plugin;
|
pub mod plugin;
|
||||||
mod polygon;
|
mod polygon;
|
||||||
mod rippledistortnode;
|
mod rippledistortnode;
|
||||||
mod shapenode;
|
mod shapenode;
|
||||||
|
|||||||
+175
-100
@@ -14,20 +14,25 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// 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`).
|
//! `olive::plugin::PluginNode`).
|
||||||
//!
|
//!
|
||||||
//! DECLARATION ONLY. This node is a thin wrapper over an OFX plugin
|
//! Thin wrapper over an OFX plugin instance that lives in the
|
||||||
//! instance that lives behind the `oakplugin` crate's C ABI bridge
|
//! `oakplugin` crate; no OFX types (`OFX::Host::ImageEffect::Instance`,
|
||||||
//! (opaque oakrender handles); no OFX types (`OFX::Host::ImageEffect::Instance`,
|
//! `kOfxParam*`, ...) are declared here because oaknode sits below
|
||||||
//! `kOfxParam*`, ...) are declared here. The plugin instance is
|
//! oakplugin in the dependency graph. The instance is represented as
|
||||||
//! represented as the opaque [`PluginInstanceHandle`] below — the real
|
//! the opaque [`PluginInstanceHandle`] — an identity key into the
|
||||||
//! definition belongs to the oakplugin bridge module and this draft
|
//! oakplugin crate's instance registry.
|
||||||
//! stands in for it.
|
|
||||||
//!
|
//!
|
||||||
//! All per-plugin data (inputs, defaults, properties, labels) is
|
//! All per-plugin data (inputs, defaults, properties, labels) is
|
||||||
//! discovered at runtime from the plugin descriptor through the
|
//! discovered at runtime from the plugin descriptor by the oakplugin
|
||||||
//! bridge, mirroring the C++ constructor.
|
//! 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::factory::NodeMeta;
|
||||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
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;
|
/// Texture input id (C++ `plugin::k_texture_input`). Type: texture;
|
||||||
/// no default. Only synthesized when the plugin declares clip inputs
|
/// no default. Only synthesized when the plugin declares clip inputs
|
||||||
/// but none of them is the simple-source clip (see [`create`]); then
|
/// but none of them is the simple-source clip (see the discovery
|
||||||
/// it becomes the node's effect input with display name "Texture".
|
/// pass); then it becomes the node's effect input with display name
|
||||||
|
/// "Texture".
|
||||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||||
|
|
||||||
/// OFX simple-source clip name (C++ `kOfxImageEffectSimpleSourceClipName`):
|
/// OFX simple-source clip name (C++ `kOfxImageEffectSimpleSourceClipName`):
|
||||||
@@ -45,9 +51,8 @@ pub const TEXTURE_INPUT: &str = "tex_in";
|
|||||||
pub const SOURCE_CLIP: &str = "Source";
|
pub const SOURCE_CLIP: &str = "Source";
|
||||||
|
|
||||||
/// Opaque handle to an OFX plugin instance owned by the `oakplugin`
|
/// Opaque handle to an OFX plugin instance owned by the `oakplugin`
|
||||||
/// crate C ABI bridge (C++ `OFX::Host::ImageEffect::Instance *`,
|
/// crate (identity key into its instance registry; C++
|
||||||
/// member `plugin_instance_`). Placeholder type for this draft — the
|
/// `OFX::Host::ImageEffect::Instance *`, member `plugin_instance_`).
|
||||||
/// bridge's real handle type replaces it.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct PluginInstanceHandle(pub u64);
|
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
|
/// OFX plugin node. Wraps one plugin instance; inputs mirror the
|
||||||
/// plugin's declared params and clips.
|
/// plugin's declared params and clips.
|
||||||
///
|
///
|
||||||
@@ -71,10 +97,10 @@ impl PluginInstanceHandle {
|
|||||||
/// pointer, `sub_category_`. Because `name()`/`id()`/`description()`
|
/// pointer, `sub_category_`. Because `name()`/`id()`/`description()`
|
||||||
/// read the plugin descriptor at call time in C++ and the Rust trait
|
/// read the plugin descriptor at call time in C++ and the Rust trait
|
||||||
/// returns `&str`, this port caches those strings on the struct
|
/// 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 {
|
pub struct PluginNode {
|
||||||
/// The wrapped plugin instance (C++ `plugin_instance_`; an
|
/// The wrapped plugin instance (C++ `plugin_instance_`; an
|
||||||
/// opaque bridge handle here).
|
/// oakplugin registry identity here).
|
||||||
instance: PluginInstanceHandle,
|
instance: PluginInstanceHandle,
|
||||||
/// Sub-category derived from the OFX context (C++
|
/// Sub-category derived from the OFX context (C++
|
||||||
/// `sub_category_`): "Filter", "Generator", "Transition", or
|
/// `sub_category_`): "Filter", "Generator", "Transition", or
|
||||||
@@ -90,7 +116,51 @@ pub struct PluginNode {
|
|||||||
description: String,
|
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 {
|
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++
|
/// Forward a push-button param activation to the plugin (C++
|
||||||
/// `push_button_clicked()`; currently a no-op upstream).
|
/// `push_button_clicked()`; currently a no-op upstream).
|
||||||
pub fn push_button_clicked(&mut self, core: &mut NodeCore, name: String) {
|
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
|
/// the value to the matching OFX param); then resolves the input
|
||||||
/// texture — simple-source clip first, then `tex_in`, then the
|
/// texture — simple-source clip first, then `tex_in`, then the
|
||||||
/// first texture-typed input — and, when both texture and plugin
|
/// first texture-typed input — and, when both texture and plugin
|
||||||
/// instance exist, pushes the texture converted to a plugin job
|
/// instance exist, pushes a [`PluginJobPayload`] boxed into a
|
||||||
/// bound to this node and the request time.
|
/// texture-typed value (the C++ `table->push(k_texture,
|
||||||
///
|
/// tex->to_job(job), this)`; the render seam executes the job).
|
||||||
/// 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()`).
|
|
||||||
fn value(
|
fn value(
|
||||||
&self,
|
&self,
|
||||||
core: &NodeCore,
|
core: &NodeCore,
|
||||||
@@ -146,8 +213,6 @@ impl NodeBehavior for PluginNode {
|
|||||||
time: Rational,
|
time: Rational,
|
||||||
table: &mut NodeValueTable,
|
table: &mut NodeValueTable,
|
||||||
) {
|
) {
|
||||||
let _ = (core, time);
|
|
||||||
|
|
||||||
// Re-push every non-texture, non-none input value, tagged with
|
// Re-push every non-texture, non-none input value, tagged with
|
||||||
// its input id.
|
// its input id.
|
||||||
for (id, v) in inputs.iter() {
|
for (id, v) in inputs.iter() {
|
||||||
@@ -170,11 +235,15 @@ impl NodeBehavior for PluginNode {
|
|||||||
.or_else(|| inputs.values().find(|v| matches!(v, NodeValue::Texture(_))));
|
.or_else(|| inputs.values().find(|v| matches!(v, NodeValue::Texture(_))));
|
||||||
|
|
||||||
if tex.is_some() && !self.instance.is_null() {
|
if tex.is_some() && !self.instance.is_null() {
|
||||||
// C++ `table->push(NodeValue::k_texture, tex->to_job(job),
|
let payload = PluginJobPayload {
|
||||||
// this)` — a deferred plugin job; no job payload here.
|
instance: self.instance,
|
||||||
|
time,
|
||||||
|
effect_input_id: core.effect_input.clone(),
|
||||||
|
values: inputs.clone(),
|
||||||
|
};
|
||||||
table.push(
|
table.push(
|
||||||
crate::value::ValueType::Texture,
|
crate::value::ValueType::Texture,
|
||||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
NodeValue::Texture(crate::handle::make_owned(payload)),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,16 +324,29 @@ impl NodeBehavior for PluginNode {
|
|||||||
let _ = (core, frame, time);
|
let _ = (core, frame, time);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deep copy (C++ `copy()`): asks the bridge to create a fresh
|
/// Deep copy (C++ `copy()`): asks the oakplugin side (through the
|
||||||
/// plugin instance — filter context when supported, else the
|
/// installed [`set_plugin_duplicator`] hook) for a fresh plugin
|
||||||
/// plugin's first declared context — and wraps it in a new node;
|
/// instance — filter context when supported, else the plugin's
|
||||||
/// `None` when there is no instance or instance creation fails.
|
/// first declared context — and wraps it in a new behavior; the
|
||||||
///
|
/// caller clones the core (inputs included), matching the C++
|
||||||
/// This crate's bridge has no instance-creation call, so a plugin
|
/// `Node::copy` split. `None` when there is no instance or no
|
||||||
/// node can never be duplicated here and `None` is always returned
|
/// duplicator installed.
|
||||||
/// (`// CPP-PARITY: plugin.cpp` `copy()`).
|
|
||||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
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).
|
/// Downcast to [`Self`] (instance/metadata access).
|
||||||
@@ -278,48 +360,12 @@ impl NodeBehavior for PluginNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constructor (C++ `PluginNode::PluginNode(instance)`): stores the
|
/// Constructor (C++ `PluginNode::PluginNode(instance)`): the real
|
||||||
/// instance handle and derives the sub-category from the OFX context
|
/// construction (instance creation + the OFX param/clip -> input
|
||||||
/// (filter/generator/transition, else "General"). Then walks the
|
/// translation) belongs to the oakplugin discovery pass
|
||||||
/// plugin's params through the bridge:
|
/// (`oakplugin::node_factory`), which registers one factory entry per
|
||||||
///
|
/// discovered plugin. This placeholder (null instance, empty cached
|
||||||
/// - group params seed a group-label map; page params seed a
|
/// metadata) stays for the static-registration surface and tests.
|
||||||
/// 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`).
|
|
||||||
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||||
let core = NodeCore::new();
|
let core = NodeCore::new();
|
||||||
let node = PluginNode {
|
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
|
/// Register this node type. NOTE: unlike built-in nodes, plugin nodes
|
||||||
/// have no static type id — the C++ factory appends one `PluginNode`
|
/// have no static type id — the oakplugin discovery pass appends one
|
||||||
/// per discovered OFX plugin at runtime
|
/// factory entry per discovered OFX plugin at runtime (C++
|
||||||
/// (`factory.cpp::add_plugins_to_library`), keyed by plugin
|
/// `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
|
/// identifier, so there is no fixed `NodeMeta` literal to push. This
|
||||||
/// function is a no-op placeholder; real registration belongs to the
|
/// function is a no-op placeholder kept for the static table.
|
||||||
/// oakplugin bridge's discovery pass.
|
|
||||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||||
let _ = meta;
|
let _ = meta;
|
||||||
}
|
}
|
||||||
@@ -386,7 +432,8 @@ mod tests {
|
|||||||
row.insert("mode".to_string(), NodeValue::Combo(2));
|
row.insert("mode".to_string(), NodeValue::Combo(2));
|
||||||
let mut table = NodeValueTable::default();
|
let mut table = NodeValueTable::default();
|
||||||
n.value(&core, &row, Rational::new(0, 1), &mut table);
|
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
|
let tagged: Vec<(&str, &NodeValue)> = table
|
||||||
.rows()
|
.rows()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -405,24 +452,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn value_skips_texture_and_none_inputs() {
|
fn value_pushes_job_payload_for_texture_input() {
|
||||||
let n = node();
|
let n = node();
|
||||||
let core = NodeCore::new();
|
let mut core = NodeCore::new();
|
||||||
|
core.effect_input = TEXTURE_INPUT.to_string();
|
||||||
let mut row = NodeValueRow::default();
|
let mut row = NodeValueRow::default();
|
||||||
row.insert(
|
row.insert(
|
||||||
"tex_in".to_string(),
|
"tex_in".to_string(),
|
||||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||||
);
|
);
|
||||||
|
row.insert("gain".to_string(), NodeValue::Float(0.25));
|
||||||
row.insert("none_in".to_string(), NodeValue::None);
|
row.insert("none_in".to_string(), NodeValue::None);
|
||||||
let mut table = NodeValueTable::default();
|
let mut table = NodeValueTable::default();
|
||||||
n.value(&core, &row, Rational::new(0, 1), &mut table);
|
let time = Rational::new(3, 2);
|
||||||
// The texture is consumed as the job source; nothing else is pushed
|
n.value(&core, &row, time, &mut table);
|
||||||
// (none values are skipped; the plugin job is the texture push).
|
// The texture is consumed as the job source: one tagged param
|
||||||
assert_eq!(table.count(), 1);
|
// value + the boxed plugin job payload.
|
||||||
assert!(matches!(
|
assert_eq!(table.count(), 2);
|
||||||
table.get(ValueType::Texture),
|
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||||
Some(NodeValue::Texture(h)) if h.is_null()
|
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]
|
#[test]
|
||||||
@@ -440,10 +501,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let mut table = NodeValueTable::default();
|
let mut table = NodeValueTable::default();
|
||||||
n.value(&core, &row, Rational::new(0, 1), &mut table);
|
n.value(&core, &row, Rational::new(0, 1), &mut table);
|
||||||
assert!(matches!(
|
assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_))));
|
||||||
table.get(ValueType::Texture),
|
|
||||||
Some(NodeValue::Texture(h)) if h.is_null()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -532,9 +590,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_returns_none() {
|
fn duplicate_uses_installed_duplicator() {
|
||||||
|
// No duplicator installed -> None.
|
||||||
|
set_plugin_duplicator(None);
|
||||||
let n = node();
|
let n = node();
|
||||||
assert!(n.duplicate(&NodeCore::new()).is_none());
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -126,14 +126,27 @@ impl Traverser {
|
|||||||
return Err(Error::State);
|
return Err(Error::State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let entry = graph.get(node).ok_or(Error::NotFound)?;
|
||||||
|
|
||||||
// Build this node's input row from its upstream outputs. The
|
// Build this node's input row from its upstream outputs. The
|
||||||
// C++ picks the last value of the matching type per input;
|
// 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();
|
let mut row: NodeValueRow = std::collections::BTreeMap::new();
|
||||||
for (from, input_id, element) in graph.input_connections(node) {
|
for (from, input_id, element) in graph.input_connections(node) {
|
||||||
let _ = element;
|
let _ = element;
|
||||||
if let Some(from_table) = tables.get(&from) {
|
if let Some(from_table) = tables.get(&from) {
|
||||||
let value = from_table
|
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)
|
.get(ValueType::Float)
|
||||||
.or_else(|| from_table.get(ValueType::Int))
|
.or_else(|| from_table.get(ValueType::Int))
|
||||||
.or_else(|| from_table.get(ValueType::Color))
|
.or_else(|| from_table.get(ValueType::Color))
|
||||||
@@ -145,15 +158,16 @@ impl Traverser {
|
|||||||
.or_else(|| from_table.get(ValueType::Text))
|
.or_else(|| from_table.get(ValueType::Text))
|
||||||
.or_else(|| from_table.get(ValueType::Combo))
|
.or_else(|| from_table.get(ValueType::Combo))
|
||||||
.or_else(|| from_table.get(ValueType::StrCombo))
|
.or_else(|| from_table.get(ValueType::StrCombo))
|
||||||
|
.or_else(|| from_table.get(ValueType::Texture))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(NodeValue::None);
|
.unwrap_or(NodeValue::None)
|
||||||
|
};
|
||||||
row.insert(input_id, value);
|
row.insert(input_id, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate the node's behavior into its output table.
|
// Evaluate the node's behavior into its output table.
|
||||||
let mut table = NodeValueTable::default();
|
let mut table = NodeValueTable::default();
|
||||||
let entry = graph.get(node).ok_or(Error::NotFound)?;
|
|
||||||
// The behavior writes outputs; the default no-op leaves the
|
// The behavior writes outputs; the default no-op leaves the
|
||||||
// table empty (C++ `Node::value` default).
|
// table empty (C++ `Node::value` default).
|
||||||
entry
|
entry
|
||||||
|
|||||||
@@ -627,6 +627,12 @@ impl NodeValueTable {
|
|||||||
pub fn rows(&self) -> &[(ValueType, NodeValue, Option<String>)] {
|
pub fn rows(&self) -> &[(ValueType, NodeValue, Option<String>)] {
|
||||||
&self.rows
|
&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`
|
/// Structural equality: `Texture` compares by handle address, `Samples`
|
||||||
|
|||||||
@@ -221,6 +221,67 @@ src/
|
|||||||
能力。修复:`init_descriptor_props` 补齐预定义(默认
|
能力。修复:`init_descriptor_props` 补齐预定义(默认
|
||||||
"false"/空数组/None)。
|
"false"/空数组/None)。
|
||||||
|
|
||||||
|
## 阶段 6a:OpenFX 引擎接线(oaknode/oakrender/oakplugin 收编)
|
||||||
|
|
||||||
|
把 OFX 插件接进 oaknode 节点工厂与 oakrender 评估环的接线层
|
||||||
|
(此前插件侧只有宿主/suite/渲染驱动,未进节点图)。全部落在
|
||||||
|
crates/,不动 src/(app)与 gpui/。
|
||||||
|
|
||||||
|
- **`node_factory.rs`(新增)**:
|
||||||
|
- 实例注册表:`register_instance/instance_from_id/unregister_instance`
|
||||||
|
(u64 键 ↔ `Arc<RefBox<Instance>>`;进程级存活,对齐 C++ 工厂
|
||||||
|
持有;节点经 [`oaknode::nodes::plugin::PluginInstanceHandle`]
|
||||||
|
持键)。
|
||||||
|
- `register_plugin_nodes()`:遍历宿主插件缓存,filter 上下文优先
|
||||||
|
(否则首个),经 `Factory::register_dynamic` 注册动态节点条目
|
||||||
|
(已存在 id 跳过,对齐 C++ existing_ids)。返回新注册 id 列表。
|
||||||
|
- 参数翻译 `build_core`:15 类 OFX 参数 → oaknode 输入(类型表、
|
||||||
|
默认值缓存、颜色语义启发式、combo ChoiceOrder 排序、secret→
|
||||||
|
hidden、ui_group/ui_page、min/max/tooltip、clip→纹理输入、
|
||||||
|
effect_input 选择),逐条对齐 engine/node/plugins/plugin.cpp。
|
||||||
|
- `install_render_executor()`:把 render_driver 装进
|
||||||
|
`oakrender::eval::set_plugin_executor`(依赖反转),duplicator
|
||||||
|
装进 `oaknode::nodes::plugin::set_plugin_duplicator`。
|
||||||
|
- `set_project_extent(w,h)`:normalised 坐标默认值换算基准。
|
||||||
|
- **`gl_bridge.rs`(新增,spike 文档)**:`texture_id` 桩的 GL 互
|
||||||
|
操作评估——方案 A(wgpu-hal GL 互操作)在 macOS 不可行(Metal
|
||||||
|
后端无 GL 命名空间);方案 B(独立离屏 GL 上下文 + 回读 +
|
||||||
|
wgpu upload)技术可行但暂缓(无真实 GL 插件可验证 + 需新 GL 依
|
||||||
|
赖 + 每帧同步回读 stall)。`texture_id` 保持恒 0,GL 插件经 CPU
|
||||||
|
render action 正确出帧。
|
||||||
|
- **`progress.rs`**:新增 `UiProgressReporter` trait +
|
||||||
|
`ReporterFactory` + `set_reporter_factory`(app 注入点)。render
|
||||||
|
未装 C 回调时装静默报告器,progressStart 携 (label,message) 经
|
||||||
|
工厂现造 UI 报告器;update 返回 false 即取消。
|
||||||
|
- **`suites/timeline.rs`**:新增 `ViewerTimeInfo` +
|
||||||
|
`ActiveViewerProvider` + `set_active_viewer_provider`(app 注入
|
||||||
|
点)。timeline suite 的 getTime/getTimeBounds 在渲染上下文缺失时
|
||||||
|
回退活动 viewer 时间源。
|
||||||
|
- **`render_driver.rs`**:`apply_param_overrides` 增 Double 标量
|
||||||
|
NaN/Inf 清洗(回退默认并告警)+ Min/Max 钳制(对齐
|
||||||
|
pluginrenderer.cpp:155-177)。
|
||||||
|
- **`clip.rs`**:`fetch_image` 支持 U8/U16/F16 输入帧归一化转 F32
|
||||||
|
(对齐 oliveclip.cpp setInputTexture 的格式转换路径);转换中的
|
||||||
|
NaN/Inf 清洗为 0(oliveclip.cpp copy_pixels 的 scrub);新增
|
||||||
|
`f16_to_f32` 手写位转换。
|
||||||
|
- **oakrender `eval.rs`**:`JobSpec::Plugin` 扩为携带
|
||||||
|
instance/time/effect_input_id/inputs/values;新增
|
||||||
|
`PluginExecutor` + `set_plugin_executor` 依赖反转槽;
|
||||||
|
`process_plugin_job` 经执行器出帧,失败回退紫帧 (1,0,1,1);
|
||||||
|
`RenderEvalHooks` 实现 `RenderHooks::resolve` 解
|
||||||
|
`PluginJobPayload` 盒并执行。
|
||||||
|
- **oaknode**:`factory.rs` 动态注册面(`register_dynamic`/
|
||||||
|
`dynamic_entries`/`create_any` 等);`nodes/plugin.rs` 重写为
|
||||||
|
PluginJobPayload 值模型 + duplicator 槽;`traverser.rs` 纹理输入
|
||||||
|
直通;`handle.rs` `get_checked<T>` 按 TypeId 判别盒类型。
|
||||||
|
|
||||||
|
app 接线(阶段 6b,不在本 crate 范围)经这些公共入口接入:
|
||||||
|
`node_factory::register_plugin_nodes`、
|
||||||
|
`progress::set_reporter_factory`、
|
||||||
|
`suites::timeline::set_active_viewer_provider`、
|
||||||
|
`node_factory::set_project_extent`、
|
||||||
|
`oaknode::factory::Factory::global().dynamic_entries/create_any`。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
运行(全量,含渲染像素路径的 oakrender 测试桩):
|
运行(全量,含渲染像素路径的 oakrender 测试桩):
|
||||||
|
|||||||
+123
-12
@@ -61,6 +61,35 @@ fn components_from_props(props: &PropertySet) -> Option<crate::image::Components
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IEEE 754 半精度 → 单精度(无 half 依赖,手写位转换;非规格数/
|
||||||
|
/// Inf/NaN 均按标准展开)。
|
||||||
|
fn f16_to_f32(bits: u16) -> f32 {
|
||||||
|
let sign = ((bits >> 15) & 1) as u32;
|
||||||
|
let exp = ((bits >> 10) & 0x1f) as u32;
|
||||||
|
let mant = (bits & 0x3ff) as u32;
|
||||||
|
let f32_bits = if exp == 0 {
|
||||||
|
if mant == 0 {
|
||||||
|
sign << 31
|
||||||
|
} else {
|
||||||
|
// 非规格数:规格化到 f32 指数域。
|
||||||
|
let mut m = mant;
|
||||||
|
let mut e: i32 = 127 - 15;
|
||||||
|
while m & 0x400 == 0 {
|
||||||
|
m <<= 1;
|
||||||
|
e -= 1;
|
||||||
|
}
|
||||||
|
let m = (m & 0x3ff) << 13;
|
||||||
|
(sign << 31) | (((e + 1) as u32) << 23) | m
|
||||||
|
}
|
||||||
|
} else if exp == 0x1f {
|
||||||
|
// Inf/NaN。
|
||||||
|
(sign << 31) | (0xff << 23) | (mant << 13)
|
||||||
|
} else {
|
||||||
|
(sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13)
|
||||||
|
};
|
||||||
|
f32::from_bits(f32_bits)
|
||||||
|
}
|
||||||
|
|
||||||
impl ClipInstance {
|
impl ClipInstance {
|
||||||
/// 按描述符实例化(createInstance 路径调用;公开:宿主与测试
|
/// 按描述符实例化(createInstance 路径调用;公开:宿主与测试
|
||||||
/// 都需要构造 clip 实例)。实例 props 是描述符 props 的深拷贝
|
/// 都需要构造 clip 实例)。实例 props 是描述符 props 的深拷贝
|
||||||
@@ -155,8 +184,10 @@ impl ClipInstance {
|
|||||||
/// (像素格式按协商结果,全链路 F32)。
|
/// (像素格式按协商结果,全链路 F32)。
|
||||||
/// `// [P2]` GL 路径:clipLoadTexture 语义在此扩展。
|
/// `// [P2]` GL 路径:clipLoadTexture 语义在此扩展。
|
||||||
///
|
///
|
||||||
/// 第 1 期约束:帧必须是 f32 格式(全链路 F32);`region` 只支持
|
/// 输入帧支持 U8/U16/F16/F32:非 F32 归一化转换为 F32(对齐
|
||||||
/// None(整帧)——转换(u8→f32 等)与子区域随 renderer 桥落地。
|
/// oliveclip.cpp setInputTexture 的格式转换路径);转换中的
|
||||||
|
/// NaN/Inf 清洗为 0(oliveclip.cpp copy_pixels 的 scrub)。
|
||||||
|
/// `region` 只支持 None(整帧)——子区域随 renderer 桥落地。
|
||||||
pub fn fetch_image(
|
pub fn fetch_image(
|
||||||
&self,
|
&self,
|
||||||
time: f64,
|
time: f64,
|
||||||
@@ -183,10 +214,14 @@ impl ClipInstance {
|
|||||||
// 纹理 → CPU 帧(GPU 纹理后端下载;帧随 drop 释放)。
|
// 纹理 → CPU 帧(GPU 纹理后端下载;帧随 drop 释放)。
|
||||||
let frame = crate::render::texture_get_frame(&texture)?;
|
let frame = crate::render::texture_get_frame(&texture)?;
|
||||||
let params = frame.video_params();
|
let params = frame.video_params();
|
||||||
if params.format != PIXEL_FORMAT_F32 {
|
let format = params.format;
|
||||||
|
if format != PIXEL_FORMAT_F32
|
||||||
|
&& format != crate::render::PIXEL_FORMAT_U8
|
||||||
|
&& format != oakcore_rs::PixelFormat::U16 as i32
|
||||||
|
&& format != oakcore_rs::PixelFormat::F16 as i32
|
||||||
|
{
|
||||||
return Err(Error::Failed(format!(
|
return Err(Error::Failed(format!(
|
||||||
"输入帧格式 {} 非 F32(第 1 期约束)",
|
"输入帧格式 {format} 不支持(仅 U8/U16/F16/F32)"
|
||||||
params.format
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let (w, h) = (params.width as f64, params.height as f64);
|
let (w, h) = (params.width as f64, params.height as f64);
|
||||||
@@ -208,19 +243,71 @@ impl ClipInstance {
|
|||||||
if src.is_null() {
|
if src.is_null() {
|
||||||
return Err(Error::Failed("帧无数据".into()));
|
return Err(Error::Failed("帧无数据".into()));
|
||||||
}
|
}
|
||||||
// 行优先拷贝(帧行跨度经 linesize 读取——真实 oakrender 帧可
|
// 行优先 + 格式转换(帧行跨度经 linesize 读取——真实
|
||||||
// 有行填充;目标 Image 恒紧凑。M11 §4 修复:phase 1 假设紧凑
|
// oakrender 帧可有行填充;目标 Image 恒紧凑 F32)。
|
||||||
// 行,对真实 oakrender 的填充帧会写错列)。
|
// U8/U16/F16 输入归一化到 [0,1] 浮点(对齐 oliveclip.cpp
|
||||||
|
// setInputTexture 的 swscale 转换路径:插件侧永远见到协商位
|
||||||
|
// 深);F32/转换结果中的 NaN/Inf 清洗为 0(oliveclip.cpp
|
||||||
|
// copy_pixels 的 scrub——CImg 对 NaN 未定义行为)。
|
||||||
let channels = components.channel_count();
|
let channels = components.channel_count();
|
||||||
let tight = (w as usize) * channels * 4;
|
let samples_per_row = (w as usize) * channels;
|
||||||
|
let src_bpc = match format {
|
||||||
|
f if f == crate::render::PIXEL_FORMAT_U8 => 1,
|
||||||
|
f if f == oakcore_rs::PixelFormat::U16 as i32 => 2,
|
||||||
|
f if f == oakcore_rs::PixelFormat::F16 as i32 => 2,
|
||||||
|
_ => 4,
|
||||||
|
};
|
||||||
|
let tight_src = samples_per_row * src_bpc;
|
||||||
let row = frame.linesize_bytes();
|
let row = frame.linesize_bytes();
|
||||||
let row = if row > 0 { row } else { tight };
|
let row = if row > 0 { row } else { tight_src };
|
||||||
let src_bytes = unsafe { std::slice::from_raw_parts(src, row * h as usize) };
|
let src_bytes = unsafe { std::slice::from_raw_parts(src, row * h as usize) };
|
||||||
let dst = image.pixels_mut();
|
let dst = image.pixels_mut();
|
||||||
|
let mut scrubbed = false;
|
||||||
for y in 0..h as usize {
|
for y in 0..h as usize {
|
||||||
let s = y * row;
|
let s = y * row;
|
||||||
let d = y * tight;
|
for i in 0..samples_per_row {
|
||||||
dst[d..d + tight].copy_from_slice(&src_bytes[s..s + tight]);
|
let v = match format {
|
||||||
|
f if f == crate::render::PIXEL_FORMAT_U8 => {
|
||||||
|
src_bytes[s + i] as f32 / 255.0
|
||||||
|
}
|
||||||
|
f if f == oakcore_rs::PixelFormat::U16 as i32 => {
|
||||||
|
let off = s + i * 2;
|
||||||
|
let bits = u16::from_le_bytes([src_bytes[off], src_bytes[off + 1]]);
|
||||||
|
bits as f32 / 65535.0
|
||||||
|
}
|
||||||
|
f if f == oakcore_rs::PixelFormat::F16 as i32 => {
|
||||||
|
let off = s + i * 2;
|
||||||
|
let bits = u16::from_le_bytes([src_bytes[off], src_bytes[off + 1]]);
|
||||||
|
let v = f16_to_f32(bits);
|
||||||
|
if v.is_nan() || v.is_infinite() {
|
||||||
|
scrubbed = true;
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let off = s + i * 4;
|
||||||
|
let v = f32::from_le_bytes([
|
||||||
|
src_bytes[off],
|
||||||
|
src_bytes[off + 1],
|
||||||
|
src_bytes[off + 2],
|
||||||
|
src_bytes[off + 3],
|
||||||
|
]);
|
||||||
|
if v.is_nan() || v.is_infinite() {
|
||||||
|
scrubbed = true;
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let d = (y * samples_per_row + i) * 4;
|
||||||
|
dst[d..d + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scrubbed {
|
||||||
|
eprintln!("[PLUGIN] NaN/Inf scrubbed from input frame data during fetch");
|
||||||
}
|
}
|
||||||
Ok(image)
|
Ok(image)
|
||||||
}
|
}
|
||||||
@@ -303,3 +390,27 @@ impl ClipInstance {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f16_to_f32_covers_special_values() {
|
||||||
|
// 常规值:1.0 = 0x3C00,-2.0 = 0xC000,0.5 = 0x3800。
|
||||||
|
assert_eq!(f16_to_f32(0x3C00), 1.0);
|
||||||
|
assert_eq!(f16_to_f32(0xC000), -2.0);
|
||||||
|
assert_eq!(f16_to_f32(0x3800), 0.5);
|
||||||
|
// 零与负零。
|
||||||
|
assert_eq!(f16_to_f32(0x0000), 0.0);
|
||||||
|
assert_eq!(f16_to_f32(0x8000).to_bits(), (0.0f32).to_bits() | (1 << 31));
|
||||||
|
// 非规格数:最小正规格数 2^-14 ≈ 0.00006104;2^-24 是最小非
|
||||||
|
// 规格数之一。
|
||||||
|
assert!((f16_to_f32(0x0400) - 2f32.powi(-14)).abs() < 1e-12);
|
||||||
|
assert!((f16_to_f32(0x0001) - 2f32.powi(-24)).abs() < 1e-12);
|
||||||
|
// Inf/NaN。
|
||||||
|
assert!(f16_to_f32(0x7C00).is_infinite() && f16_to_f32(0x7C00) > 0.0);
|
||||||
|
assert!(f16_to_f32(0xFC00).is_infinite() && f16_to_f32(0xFC00) < 0.0);
|
||||||
|
assert!(f16_to_f32(0x7E00).is_nan());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
//! GL 互操作桥(阶段 6a spike 结论;实现未落地)。
|
||||||
|
//!
|
||||||
|
//! ## 背景
|
||||||
|
//!
|
||||||
|
//! OFX 的 `OpenGLRender` 扩展要求宿主把 clip 纹理以 **GL 纹理名**
|
||||||
|
//! (`kOfxImageEffectPropOpenGLTextureIndex`)递给插件,插件直接画
|
||||||
|
//! 进输出纹理。oak 的渲染后端是 wgpu,[`crate::render::texture_id`]
|
||||||
|
//! 因此是恒 0 的桩:render_driver 的 `use_opengl` 决策据它恒回退
|
||||||
|
//! CPU 路径(GL 插件经 CPU render action 仍可工作)。本模块记录把
|
||||||
|
//! 桩替换为真实 GL 互操作的评估。
|
||||||
|
//!
|
||||||
|
//! ## 方案 A:wgpu-hal GL 互操作 —— 不可行(macOS)
|
||||||
|
//!
|
||||||
|
//! wgpu-hal 的 GL 互操作面(`hal::api::Gles` 的 adapter/texture
|
||||||
|
//! 互转)只在实例本身就是 GLES 后端时存在。oak 在 macOS 上的 wgpu
|
||||||
|
//! 实例是 **Metal**(wgpu 支持矩阵中 macOS/iOS 仅 Metal 为一等后
|
||||||
|
//! 端;OpenGL 需 ANGLE 转译层且仅 GLES 3.0,见
|
||||||
|
//! <https://github.com/gfx-rs/wgpu> 的 Supported Platforms 表与
|
||||||
|
//! CHANGELOG #4185"GLES backend optional on macOS")。Metal 后端与
|
||||||
|
//! GL 纹理名之间没有共享命名空间,wgpu 也不暴露跨后端纹理导入。
|
||||||
|
//! 即便为插件强行把整条管线切到 wgpu GLES 后端,也是以全局渲染性
|
||||||
|
//! 能换单一插件路径,方向错误。**结论:放弃。**
|
||||||
|
//!
|
||||||
|
//! ## 方案 B:独立离屏 GL 上下文 + 回读 + wgpu 上传 —— 技术可行,
|
||||||
|
//! 暂缓
|
||||||
|
//!
|
||||||
|
//! 路径:宿主自建原生 GL 上下文(macOS 为 CGL;OpenGL 自 10.14 起
|
||||||
|
//! 弃用但仍可用),与插件共享纹理命名空间(CGL share group)→ 插件
|
||||||
|
//! render 画进 FBO 附着纹理 → `glReadPixels`/PBO 回读为 CPU 帧 →
|
||||||
|
//! 经 [`oakrender::backend::GpuContextLike::upload`] 上传成 wgpu
|
||||||
|
//! 纹理。接线点已就位:
|
||||||
|
//!
|
||||||
|
//! 1. [`crate::render::texture_id`] 返回真实 GL 名(当前恒 0);
|
||||||
|
//! 2. [`crate::render_driver::render_frame`] 的 `use_opengl` 分支
|
||||||
|
//! (`plugin_supports_opengl && depth_ok && dst_id != 0`)随之
|
||||||
|
//! 生效,走 [`crate::instance::Instance::render_gl`];
|
||||||
|
//! 3. GL suite([`crate::suites::gl_render`])的纹理索引属性写出真
|
||||||
|
//! 值。
|
||||||
|
//!
|
||||||
|
//! 暂缓理由:
|
||||||
|
//! - 需引入新依赖(`glow` + CGL 绑定)与上下文生命周期/线程模型
|
||||||
|
//! 管理(OFX 插件可在自起线程回调 suite);
|
||||||
|
//! - 每帧同步回读是一次 GPU stall,性能上只在"插件本来就是 GL 加
|
||||||
|
//! 速"时划算,而当前无任何真实 GL OFX 插件可验证(测试 `.gl`
|
||||||
|
//! 变体只验证 suite 调用面);
|
||||||
|
//! - CPU 回退路径完整可用,GL 插件经 render action 正确出帧。
|
||||||
|
//!
|
||||||
|
//! ## TODO(phase-GL)
|
||||||
|
//!
|
||||||
|
//! 实现方案 B:CGL 上下文工厂 + share group、`texture_id` 真值化、
|
||||||
|
//! 回读→上传流水线,以及一个真实 GL 插件的端到端验证(golden 帧比
|
||||||
|
//! 较)。在此之前,[`crate::render::texture_id`] 保持恒 0 桩。
|
||||||
@@ -594,7 +594,8 @@ impl Instance {
|
|||||||
crate::suites::set_render_ctx(Some(crate::suites::RenderCtx { time, scale, range }));
|
crate::suites::set_render_ctx(Some(crate::suites::RenderCtx { time, scale, range }));
|
||||||
crate::suites::set_current_output(Some(output.clone()));
|
crate::suites::set_current_output(Some(output.clone()));
|
||||||
|
|
||||||
// 进度报告器(facade 回调 → Progress suite)。
|
// 进度报告器(facade 回调优先;无回调而 app 注册了 UI 工厂
|
||||||
|
// 时装静默报告器,progressStart 再经工厂现造 UI 报告器)。
|
||||||
if let Some((cb, userdata)) = self
|
if let Some((cb, userdata)) = self
|
||||||
.progress_cb
|
.progress_cb
|
||||||
.lock()
|
.lock()
|
||||||
@@ -604,6 +605,10 @@ impl Instance {
|
|||||||
crate::suites::progress::set_current(Some(unsafe {
|
crate::suites::progress::set_current(Some(unsafe {
|
||||||
crate::progress::ProgressReporter::new(cb, userdata as *mut std::ffi::c_void)
|
crate::progress::ProgressReporter::new(cb, userdata as *mut std::ffi::c_void)
|
||||||
}));
|
}));
|
||||||
|
} else if crate::progress::has_reporter_factory() {
|
||||||
|
crate::suites::progress::set_current(Some(
|
||||||
|
crate::progress::ProgressReporter::silent(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let inst_handle = crate::suites::tag::make(
|
let inst_handle = crate::suites::tag::make(
|
||||||
@@ -689,6 +694,10 @@ impl Instance {
|
|||||||
crate::suites::progress::set_current(Some(unsafe {
|
crate::suites::progress::set_current(Some(unsafe {
|
||||||
crate::progress::ProgressReporter::new(cb, userdata as *mut std::ffi::c_void)
|
crate::progress::ProgressReporter::new(cb, userdata as *mut std::ffi::c_void)
|
||||||
}));
|
}));
|
||||||
|
} else if crate::progress::has_reporter_factory() {
|
||||||
|
crate::suites::progress::set_current(Some(
|
||||||
|
crate::progress::ProgressReporter::silent(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let inst_handle = crate::suites::tag::make(
|
let inst_handle = crate::suites::tag::make(
|
||||||
|
|||||||
@@ -56,11 +56,13 @@
|
|||||||
pub mod clip;
|
pub mod clip;
|
||||||
pub mod descriptor;
|
pub mod descriptor;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod gl_bridge;
|
||||||
pub mod handle;
|
pub mod handle;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod instance;
|
pub mod instance;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
|
pub mod node_factory;
|
||||||
pub mod param;
|
pub mod param;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod property;
|
pub mod property;
|
||||||
|
|||||||
@@ -0,0 +1,964 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
//! OFX 插件 → 节点工厂接线(阶段 6a)。
|
||||||
|
//!
|
||||||
|
//! 对应 C++ `NodeFactory::register_plugin_nodes`
|
||||||
|
//! (factory.cpp:148-186)+ `PluginNode::PluginNode(Instance*)` 的
|
||||||
|
//! 参数翻译构造函数(engine/node/plugins/plugin.cpp:274-514)。
|
||||||
|
//! OFX 类型只存在于本 crate,故翻译放这里;oaknode 侧只承载行为
|
||||||
|
//! ([`oaknode::nodes::plugin::PluginNode`])——这是依赖方向
|
||||||
|
//! (oakplugin → oakrender → oaknode)强加的拆分。
|
||||||
|
//!
|
||||||
|
//! 职责:
|
||||||
|
//! - **实例注册表**:节点持 [`oaknode::nodes::plugin::PluginInstanceHandle`]
|
||||||
|
//! (u64 键),这里持 `Arc<RefBox<Instance>>`(C++ 的工厂实例在库
|
||||||
|
//! 条目内存活;Rust 侧等价于注册表进程级存活)。
|
||||||
|
//! - **参数翻译**:15 类 OFX 参数 → oaknode 输入(类型表逐字对齐
|
||||||
|
//! plugin.cpp:340-378),默认值缓存、颜色语义启发式、combo 排序、
|
||||||
|
//! secret→hidden、ui_group/ui_page、clip→纹理输入、effect_input
|
||||||
|
//! 选择(plugin.cpp:494-514)。
|
||||||
|
//! - **渲染执行器**:[`install_render_executor`] 把 render_driver 装
|
||||||
|
//! 进 oakrender 的 executor 槽(依赖反转;oakrender 看不见本 crate)
|
||||||
|
//! 与 oaknode 的 duplicator 槽。
|
||||||
|
//!
|
||||||
|
//! app 注入点另见 [`crate::progress::set_reporter_factory`](进度
|
||||||
|
//! UI)与 [`crate::suites::timeline::set_active_viewer_provider`]
|
||||||
|
//! (timeline suite 回退时间源)。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use oaknode::factory::{DynNodeConstructor, DynamicNodeMeta};
|
||||||
|
use oaknode::input::{flags as input_flags, Input};
|
||||||
|
use oaknode::node::{Category, NodeBehavior, NodeCore};
|
||||||
|
use oaknode::nodes::plugin::{
|
||||||
|
PluginInstanceHandle, PluginNode, SOURCE_CLIP, TEXTURE_INPUT,
|
||||||
|
};
|
||||||
|
use oaknode::value::{NodeValue, ValueType};
|
||||||
|
|
||||||
|
use crate::handle::RefBox;
|
||||||
|
use crate::host::Host;
|
||||||
|
use crate::instance::Instance;
|
||||||
|
use crate::param::{self as ofx, ParamDef, ParamValue};
|
||||||
|
use crate::property::{PropertySet, Value as PropValue};
|
||||||
|
|
||||||
|
/// kOfxPropPluginDescription(描述符根属性)。
|
||||||
|
const PROP_PLUGIN_DESCRIPTION: &str = "OfxPropPluginDescription";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 实例注册表
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static INSTANCES: OnceLock<Mutex<HashMap<u64, Arc<RefBox<Instance>>>>> = OnceLock::new();
|
||||||
|
static NEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
fn instances() -> &'static Mutex<HashMap<u64, Arc<RefBox<Instance>>>> {
|
||||||
|
INSTANCES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一个实例,返回非 0 句柄键(C++ 的 `Instance*` 指针身份)。
|
||||||
|
/// 实例进程级存活(对齐 C++ 工厂持有;Drop 才发 destroyInstance)。
|
||||||
|
pub fn register_instance(inst: Arc<RefBox<Instance>>) -> u64 {
|
||||||
|
let id = NEXT_INSTANCE_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
instances()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(id, inst);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 句柄键 → 实例(查无返回 None)。
|
||||||
|
pub fn instance_from_id(id: u64) -> Option<Arc<RefBox<Instance>>> {
|
||||||
|
instances()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘除登记(节点销毁路径;未登记的键 no-op)。
|
||||||
|
pub fn unregister_instance(id: u64) {
|
||||||
|
instances()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前登记数(测试/诊断)。
|
||||||
|
pub fn registered_instance_count() -> usize {
|
||||||
|
instances().lock().unwrap_or_else(|e| e.into_inner()).len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 项目幅面(normalised 坐标默认值 → canonical 的换算基准)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// C++ get_project_extent 读 Current::current_video_params
|
||||||
|
// (plugin.cpp:45-50);crate 侧无项目上下文,app 经
|
||||||
|
// set_project_extent 注入,未注入用 HD 缺省。
|
||||||
|
|
||||||
|
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// 注入项目幅面(宽、高;normalised 坐标默认值换算用)。
|
||||||
|
pub fn set_project_extent(width: f64, height: f64) {
|
||||||
|
*project_extent_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner()) = (width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_extent_slot() -> &'static Mutex<(f64, f64)> {
|
||||||
|
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_extent() -> (f64, f64) {
|
||||||
|
*project_extent_slot().lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C++ to_canonical(plugin.cpp:52-55)。
|
||||||
|
fn to_canonical(normalised: f64, extent: f64) -> f64 {
|
||||||
|
if extent > 0.0 {
|
||||||
|
normalised * extent
|
||||||
|
} else {
|
||||||
|
normalised
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 属性读取助手
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn prop_str(props: &PropertySet, name: &str, index: usize) -> String {
|
||||||
|
match props.get(name, index) {
|
||||||
|
Some(PropValue::String(s)) => s.to_string_lossy().into_owned(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prop_double(props: &PropertySet, name: &str, index: usize) -> f64 {
|
||||||
|
match props.get(name, index) {
|
||||||
|
Some(PropValue::Double(v)) => v,
|
||||||
|
Some(PropValue::Int(v)) => v as f64,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prop_int(props: &PropertySet, name: &str, index: usize) -> i32 {
|
||||||
|
match props.get(name, index) {
|
||||||
|
Some(PropValue::Int(v)) => v,
|
||||||
|
Some(PropValue::Double(v)) => v as i32,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_normalised_coord_system(def: &ParamDef) -> bool {
|
||||||
|
prop_str(&def.props, ofx::P_DEFAULT_COORD_SYS, 0) == ofx::V_COORD_NORMALISED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 默认值(plugin.cpp default_value_for_param,:57-131)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 单个参数的节点默认值(无值类返回 None;对齐 C++ 返回 invalid
|
||||||
|
/// QVariant 的分支)。
|
||||||
|
fn default_value_for_param(def: &ParamDef) -> Option<NodeValue> {
|
||||||
|
let props = &def.props;
|
||||||
|
match def.ofx_type.as_str() {
|
||||||
|
ofx::TYPE_INTEGER => Some(NodeValue::Int(prop_int(props, ofx::P_DEFAULT, 0) as i64)),
|
||||||
|
ofx::TYPE_CHOICE => Some(NodeValue::Combo(prop_int(props, ofx::P_DEFAULT, 0) as i64)),
|
||||||
|
ofx::TYPE_BOOLEAN => Some(NodeValue::Boolean(prop_int(props, ofx::P_DEFAULT, 0) != 0)),
|
||||||
|
ofx::TYPE_DOUBLE => {
|
||||||
|
let mut val = prop_double(props, ofx::P_DEFAULT, 0);
|
||||||
|
if is_normalised_coord_system(def) {
|
||||||
|
let (x_size, _) = project_extent();
|
||||||
|
val = to_canonical(val, x_size);
|
||||||
|
}
|
||||||
|
Some(NodeValue::Float(val))
|
||||||
|
}
|
||||||
|
ofx::TYPE_STRING | ofx::TYPE_STRCHOICE => {
|
||||||
|
Some(NodeValue::Text(prop_str(props, ofx::P_DEFAULT, 0)))
|
||||||
|
}
|
||||||
|
ofx::TYPE_CUSTOM => {
|
||||||
|
// C++ 亦按字符串读默认(plugin.cpp:83-87);节点输入是
|
||||||
|
// binary,按字节保留。
|
||||||
|
Some(NodeValue::Binary(
|
||||||
|
prop_str(props, ofx::P_DEFAULT, 0).into_bytes(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ofx::TYPE_RGB | ofx::TYPE_RGBA => {
|
||||||
|
let count = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 };
|
||||||
|
let mut values = [0.0, 0.0, 0.0, 1.0];
|
||||||
|
for i in 0..count {
|
||||||
|
values[i] = prop_double(props, ofx::P_DEFAULT, i);
|
||||||
|
}
|
||||||
|
let alpha = if count == 4 { values[3] } else { 1.0 };
|
||||||
|
Some(NodeValue::Color([values[0], values[1], values[2], alpha]))
|
||||||
|
}
|
||||||
|
ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER2D | ofx::TYPE_INTEGER3D => {
|
||||||
|
let is_double = matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D);
|
||||||
|
let count = if matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D) {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
3
|
||||||
|
};
|
||||||
|
let mut values = [0.0f64; 3];
|
||||||
|
if is_double {
|
||||||
|
for i in 0..count {
|
||||||
|
values[i] = prop_double(props, ofx::P_DEFAULT, i);
|
||||||
|
}
|
||||||
|
if is_normalised_coord_system(def) {
|
||||||
|
let (x_size, y_size) = project_extent();
|
||||||
|
values[0] = to_canonical(values[0], x_size);
|
||||||
|
values[1] = to_canonical(values[1], y_size);
|
||||||
|
if count == 3 {
|
||||||
|
values[2] = to_canonical(values[2], x_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i in 0..count {
|
||||||
|
values[i] = prop_int(props, ofx::P_DEFAULT, i) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 2 {
|
||||||
|
Some(NodeValue::Vec2([values[0], values[1]]))
|
||||||
|
} else {
|
||||||
|
Some(NodeValue::Vec3([values[0], values[1], values[2]]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ofx::TYPE_BYTES => Some(NodeValue::Binary(Vec::new())),
|
||||||
|
// PushButton/Group/Page/Parametric/未知 → invalid(C++ 末尾
|
||||||
|
// return QVariant())。
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每插件的默认值缓存(C++ g_plugin_param_defaults,plugin.cpp:37)。
|
||||||
|
static DEFAULTS: OnceLock<Mutex<HashMap<String, HashMap<String, NodeValue>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn defaults_slot() -> &'static Mutex<HashMap<String, HashMap<String, NodeValue>>> {
|
||||||
|
DEFAULTS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取(或首访构建)某插件的参数默认值表(build_default_values,
|
||||||
|
/// plugin.cpp:224-246)。
|
||||||
|
fn cached_defaults(
|
||||||
|
plugin_id: &str,
|
||||||
|
params: &crate::param::ParamSetInstance,
|
||||||
|
) -> HashMap<String, NodeValue> {
|
||||||
|
{
|
||||||
|
let cache = defaults_slot().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(hit) = cache.get(plugin_id) {
|
||||||
|
return hit.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut defaults = HashMap::new();
|
||||||
|
for p in ¶ms.params {
|
||||||
|
let ofx_type = p.def.ofx_type.as_str();
|
||||||
|
if ofx_type == ofx::TYPE_GROUP || ofx_type == ofx::TYPE_PAGE || ofx_type == ofx::TYPE_PUSHBUTTON {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if p.def.name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(v) = default_value_for_param(&p.def) {
|
||||||
|
defaults.insert(p.def.name.clone(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaults_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(plugin_id.to_string(), defaults.clone());
|
||||||
|
defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 颜色语义启发式(plugin.cpp deduce_color_semantic,:133-222)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// RGB/RGBA 参数是取色器("color")还是逐通道标量组("scalar")。
|
||||||
|
fn deduce_color_semantic(def: &ParamDef, group_labels: &HashMap<String, String>) -> &'static str {
|
||||||
|
const COLOR_KEYWORDS: &[&str] = &["color", "colour", "fill", "tint", "key"];
|
||||||
|
const SCALAR_KEYWORDS: &[&str] = &[
|
||||||
|
"gamma",
|
||||||
|
"contrast",
|
||||||
|
"gain",
|
||||||
|
"offset",
|
||||||
|
"saturation",
|
||||||
|
"exposure",
|
||||||
|
"brightness",
|
||||||
|
"lift",
|
||||||
|
"multiply",
|
||||||
|
"scale",
|
||||||
|
"pivot",
|
||||||
|
];
|
||||||
|
|
||||||
|
if def.ofx_type != ofx::TYPE_RGB && def.ofx_type != ofx::TYPE_RGBA {
|
||||||
|
return "color";
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = prop_str(&def.props, ofx::PROP_LABEL, 0).to_lowercase();
|
||||||
|
let hint = prop_str(&def.props, ofx::P_HINT, 0).to_lowercase();
|
||||||
|
let name = def.name.to_lowercase();
|
||||||
|
|
||||||
|
// 规则 1:显式颜色关键词 → color。
|
||||||
|
for kw in COLOR_KEYWORDS {
|
||||||
|
if label.contains(kw) || hint.contains(kw) || name.contains(kw) {
|
||||||
|
return "color";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则 2:显式标量/调色关键词 → scalar。
|
||||||
|
for kw in SCALAR_KEYWORDS {
|
||||||
|
if label.contains(kw) || hint.contains(kw) || name.contains(kw) {
|
||||||
|
return "scalar";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则 3:display 范围显著越出 [0,1] → scalar。
|
||||||
|
let dim = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 };
|
||||||
|
for i in 0..dim {
|
||||||
|
let dmin = prop_double(&def.props, ofx::P_DISPLAY_MIN, i);
|
||||||
|
let dmax = prop_double(&def.props, ofx::P_DISPLAY_MAX, i);
|
||||||
|
if dmin < -0.01 || dmax > 1.01 {
|
||||||
|
return "scalar";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则 4:默认值全相等 → scalar(lean)。
|
||||||
|
let mut defs = [0.0f64; 4];
|
||||||
|
for i in 0..dim {
|
||||||
|
defs[i] = prop_double(&def.props, ofx::P_DEFAULT, i);
|
||||||
|
}
|
||||||
|
let all_equal = (1..dim).all(|i| defs[i] == defs[0]);
|
||||||
|
if all_equal {
|
||||||
|
return "scalar";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则 5:父 group 名含标量关键词 → scalar。
|
||||||
|
let parent = prop_str(&def.props, ofx::P_PARENT, 0).to_lowercase();
|
||||||
|
if !parent.is_empty() {
|
||||||
|
let group_label = group_labels
|
||||||
|
.get(&prop_str(&def.props, ofx::P_PARENT, 0))
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for kw in SCALAR_KEYWORDS {
|
||||||
|
if parent.contains(kw) || group_label.contains(kw) {
|
||||||
|
return "scalar";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底。
|
||||||
|
"color"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// clip 显示名(plugin.cpp clip_label_for_name,:248-270)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn clip_label_for_name(name: &str, clip_props: Option<&PropertySet>) -> String {
|
||||||
|
// 过渡上下文 clip 名(ofxImageEffect.h:1435-1441)。
|
||||||
|
if name == SOURCE_CLIP {
|
||||||
|
return "Source".to_string();
|
||||||
|
}
|
||||||
|
if name == "SourceFrom" {
|
||||||
|
return "From".to_string();
|
||||||
|
}
|
||||||
|
if name == "SourceTo" {
|
||||||
|
return "To".to_string();
|
||||||
|
}
|
||||||
|
if let Some(props) = clip_props {
|
||||||
|
let label = prop_str(props, ofx::PROP_LABEL, 0);
|
||||||
|
if !label.is_empty() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 参数翻译:OFX 参数 → 节点输入(plugin.cpp:331-490)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// OFX 类型 → 节点输入值类型(plugin.cpp:340-378 的类型表;
|
||||||
|
/// Group/Page 与未知类型(k_none)均无输入 → None,跳过)。
|
||||||
|
fn input_type_for(ofx_type: &str) -> Option<ValueType> {
|
||||||
|
Some(match ofx_type {
|
||||||
|
ofx::TYPE_INTEGER => ValueType::Int,
|
||||||
|
ofx::TYPE_DOUBLE => ValueType::Float,
|
||||||
|
ofx::TYPE_BOOLEAN => ValueType::Boolean,
|
||||||
|
ofx::TYPE_STRING => ValueType::Text,
|
||||||
|
ofx::TYPE_RGB | ofx::TYPE_RGBA => ValueType::Color,
|
||||||
|
ofx::TYPE_CHOICE => ValueType::Combo,
|
||||||
|
ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D => ValueType::Vec2,
|
||||||
|
ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER3D => ValueType::Vec3,
|
||||||
|
ofx::TYPE_STRCHOICE => ValueType::StrCombo,
|
||||||
|
ofx::TYPE_BYTES | ofx::TYPE_CUSTOM => ValueType::Binary,
|
||||||
|
ofx::TYPE_PUSHBUTTON => ValueType::PushButton,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从描述符实例构建节点输入表(PluginNode 构造函数的参数/clip 循环)。
|
||||||
|
fn build_core(inst: &Instance) -> NodeCore {
|
||||||
|
let mut core = NodeCore::new();
|
||||||
|
|
||||||
|
let defaults = cached_defaults(&inst.plugin.identifier, &inst.params);
|
||||||
|
|
||||||
|
// 第 1 遍:group/page 标签与 param→page 映射(plugin.cpp:300-330)。
|
||||||
|
let mut group_labels: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut page_for_param: HashMap<String, String> = HashMap::new();
|
||||||
|
for p in &inst.params.params {
|
||||||
|
let def = &p.def;
|
||||||
|
match def.ofx_type.as_str() {
|
||||||
|
ofx::TYPE_GROUP => {
|
||||||
|
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
|
||||||
|
group_labels.insert(
|
||||||
|
def.name.clone(),
|
||||||
|
if label.is_empty() { def.name.clone() } else { label },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ofx::TYPE_PAGE => {
|
||||||
|
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
|
||||||
|
let page_label = if label.is_empty() { def.name.clone() } else { label };
|
||||||
|
let count = def.props.dimension(ofx::P_PAGE_CHILD);
|
||||||
|
for i in 0..count {
|
||||||
|
let child = prop_str(&def.props, ofx::P_PAGE_CHILD, i);
|
||||||
|
if child == ofx::PAGE_SKIP_ROW || child == ofx::PAGE_SKIP_COLUMN {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
page_for_param.insert(child, page_label.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 遍:值参数 → 输入(plugin.cpp:331-490)。
|
||||||
|
for p in &inst.params.params {
|
||||||
|
let def = &p.def;
|
||||||
|
let Some(value_type) = input_type_for(&def.ofx_type) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let input_id = def.name.clone();
|
||||||
|
if input_id.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_secret = prop_int(&def.props, ofx::P_SECRET, 0) != 0;
|
||||||
|
|
||||||
|
// 默认值(缓存表;C++ defaults.value(input_id))。
|
||||||
|
let mut input = match defaults.get(&input_id) {
|
||||||
|
Some(default_value) => {
|
||||||
|
let input = Input::new(&input_id, value_type, default_value.clone());
|
||||||
|
if !matches!(value_type, ValueType::PushButton) {
|
||||||
|
core.set_standard_value(&input_id, 0, default_value.clone());
|
||||||
|
}
|
||||||
|
input
|
||||||
|
}
|
||||||
|
None => Input::new(&input_id, value_type, NodeValue::None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_secret {
|
||||||
|
input.flags |= input_flags::HIDDEN;
|
||||||
|
}
|
||||||
|
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
|
||||||
|
input.display_name = if label.is_empty() { input_id.clone() } else { label };
|
||||||
|
|
||||||
|
let parent = prop_str(&def.props, ofx::P_PARENT, 0);
|
||||||
|
if !parent.is_empty() {
|
||||||
|
let group = group_labels.get(&parent).cloned().unwrap_or(parent.clone());
|
||||||
|
input
|
||||||
|
.properties
|
||||||
|
.push(("ui_group".to_string(), NodeValue::Text(group)));
|
||||||
|
}
|
||||||
|
if let Some(page) = page_for_param.get(&input_id) {
|
||||||
|
input
|
||||||
|
.properties
|
||||||
|
.push(("ui_page".to_string(), NodeValue::Text(page.clone())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(value_type, ValueType::Color) {
|
||||||
|
let semantic = deduce_color_semantic(def, &group_labels);
|
||||||
|
input.properties.push((
|
||||||
|
"color_semantic".to_string(),
|
||||||
|
NodeValue::Text(semantic.to_string()),
|
||||||
|
));
|
||||||
|
// display min/max 取第 0 维(plugin.cpp:418-424)。
|
||||||
|
input.properties.push((
|
||||||
|
"min".to_string(),
|
||||||
|
NodeValue::Float(prop_double(&def.props, ofx::P_DISPLAY_MIN, 0)),
|
||||||
|
));
|
||||||
|
input.properties.push((
|
||||||
|
"max".to_string(),
|
||||||
|
NodeValue::Float(prop_double(&def.props, ofx::P_DISPLAY_MAX, 0)),
|
||||||
|
));
|
||||||
|
let hint = prop_str(&def.props, ofx::P_HINT, 0);
|
||||||
|
if !hint.is_empty() {
|
||||||
|
input
|
||||||
|
.properties
|
||||||
|
.push(("tooltip".to_string(), NodeValue::Text(hint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(value_type, ValueType::Combo | ValueType::StrCombo) {
|
||||||
|
let mut option_labels = Vec::new();
|
||||||
|
let mut option_values = Vec::new();
|
||||||
|
let label_count = def.props.dimension(ofx::P_CHOICE_OPTION);
|
||||||
|
let value_count = def.props.dimension(ofx::P_CHOICE_ENUM);
|
||||||
|
for i in 0..label_count {
|
||||||
|
option_labels.push(prop_str(&def.props, ofx::P_CHOICE_OPTION, i));
|
||||||
|
}
|
||||||
|
for i in 0..value_count {
|
||||||
|
option_values.push(prop_str(&def.props, ofx::P_CHOICE_ENUM, i));
|
||||||
|
}
|
||||||
|
if option_labels.is_empty() && !option_values.is_empty() {
|
||||||
|
option_labels = option_values.clone();
|
||||||
|
}
|
||||||
|
if option_values.is_empty() && !option_labels.is_empty() {
|
||||||
|
option_values = option_labels.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChoiceOrder 稳定排序(plugin.cpp:449-472)。
|
||||||
|
let order_count = def.props.dimension(ofx::P_CHOICE_ORDER);
|
||||||
|
if order_count == option_labels.len() && option_labels.len() == option_values.len() {
|
||||||
|
let mut indices: Vec<usize> = (0..option_labels.len()).collect();
|
||||||
|
indices.sort_by_key(|&i| prop_int(&def.props, ofx::P_CHOICE_ORDER, i));
|
||||||
|
option_labels = indices.iter().map(|&i| option_labels[i].clone()).collect();
|
||||||
|
option_values = indices.iter().map(|&i| option_values[i].clone()).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// combo 选项经重复键属性携带(NodeValue 无字符串表变体;
|
||||||
|
// 消费方按 ("combo_option", _) 全量收集,str_combo 的
|
||||||
|
// 值表为 ("combo_value", _)——C++ set_combo_box_strings /
|
||||||
|
// "combo_value_str" 的等价物)。
|
||||||
|
for label in &option_labels {
|
||||||
|
input.properties.push((
|
||||||
|
"combo_option".to_string(),
|
||||||
|
NodeValue::Text(label.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if matches!(value_type, ValueType::StrCombo) {
|
||||||
|
for value in &option_values {
|
||||||
|
input.properties.push((
|
||||||
|
"combo_value".to_string(),
|
||||||
|
NodeValue::Text(value.clone()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core.add_input(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip → 纹理输入(plugin.cpp:492-501)。
|
||||||
|
let mut has_texture_input = false;
|
||||||
|
for clip in &inst.clips {
|
||||||
|
if clip.name == "Output" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut input = Input::new(&clip.name, ValueType::Texture, NodeValue::None);
|
||||||
|
input.display_name = clip_label_for_name(&clip.name, Some(&clip.props));
|
||||||
|
core.add_input(input);
|
||||||
|
has_texture_input = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// effect_input 选择(plugin.cpp:503-514)。
|
||||||
|
if core.has_input(SOURCE_CLIP) {
|
||||||
|
core.effect_input = SOURCE_CLIP.to_string();
|
||||||
|
} else if core.has_input(TEXTURE_INPUT) {
|
||||||
|
core.effect_input = TEXTURE_INPUT.to_string();
|
||||||
|
} else if has_texture_input {
|
||||||
|
let mut input = Input::new(TEXTURE_INPUT, ValueType::Texture, NodeValue::None);
|
||||||
|
input.display_name = "Texture".to_string();
|
||||||
|
core.add_input(input);
|
||||||
|
core.effect_input = TEXTURE_INPUT.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
core
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上下文的显示子分类(plugin.cpp:281-290)。
|
||||||
|
fn sub_category_for(context: &str) -> &'static str {
|
||||||
|
match context {
|
||||||
|
"OfxImageEffectContextFilter" => "Filter",
|
||||||
|
"OfxImageEffectContextGenerator" => "Generator",
|
||||||
|
"OfxImageEffectContextTransition" => "Transition",
|
||||||
|
_ => "General",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 插件描述符的显示名(plugin.cpp PluginNode::name,:517-524)。
|
||||||
|
fn plugin_display_name(inst: &Instance) -> String {
|
||||||
|
let label = prop_str(&inst.plugin.descriptor.props, ofx::PROP_LABEL, 0);
|
||||||
|
if label.is_empty() {
|
||||||
|
inst.plugin.identifier.clone()
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 插件描述符的描述文本(plugin.cpp PluginNode::description,
|
||||||
|
/// :531-538)。
|
||||||
|
fn plugin_description(inst: &Instance) -> String {
|
||||||
|
prop_str(&inst.plugin.descriptor.props, PROP_PLUGIN_DESCRIPTION, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 节点构造(每次建图都新建实例——C++ 库条目共享一个实例是 Qt 父子
|
||||||
|
// 所有权模型;Rust 侧每节点独占实例才能支持 duplicate 与并发渲染)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 为 (identifier, context) 建一个插件节点(新实例 + 注册表登记)。
|
||||||
|
fn create_plugin_node(
|
||||||
|
identifier: &str,
|
||||||
|
context: &str,
|
||||||
|
) -> Option<(NodeCore, Box<dyn NodeBehavior>)> {
|
||||||
|
let inst = Host::global()
|
||||||
|
.create_instance(identifier, Some(context))
|
||||||
|
.ok()?;
|
||||||
|
let core = build_core(&inst.value);
|
||||||
|
let name = plugin_display_name(&inst.value);
|
||||||
|
let description = plugin_description(&inst.value);
|
||||||
|
let sub_category = sub_category_for(context).to_string();
|
||||||
|
let id = register_instance(inst);
|
||||||
|
let node = PluginNode::new(
|
||||||
|
PluginInstanceHandle(id),
|
||||||
|
name,
|
||||||
|
identifier.to_string(),
|
||||||
|
description,
|
||||||
|
sub_category,
|
||||||
|
);
|
||||||
|
Some((core, Box::new(node)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描宿主插件缓存并向节点工厂注册动态条目(C++
|
||||||
|
/// `NodeFactory::register_plugin_nodes`,factory.cpp:148-186)。
|
||||||
|
/// 返回新注册的 type id 列表;已存在的 id 跳过(register_dynamic
|
||||||
|
/// 去重,对齐 C++ existing_ids 检查)。
|
||||||
|
pub fn register_plugin_nodes() -> Vec<String> {
|
||||||
|
install_render_executor();
|
||||||
|
|
||||||
|
let host = Host::global();
|
||||||
|
let mut registered = Vec::new();
|
||||||
|
for i in 0..host.cache.count() {
|
||||||
|
let Some(plugin) = host.cache.at(i) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上下文选择:filter 优先,否则第一个(factory.cpp:171-177)。
|
||||||
|
let context = if plugin.contexts.iter().any(|c| c == "OfxImageEffectContextFilter") {
|
||||||
|
"OfxImageEffectContextFilter".to_string()
|
||||||
|
} else {
|
||||||
|
match plugin.contexts.first() {
|
||||||
|
Some(c) => c.clone(),
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping OFX plugin with no contexts: {}",
|
||||||
|
plugin.identifier
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 元数据实例(name/description;建完即弃)。
|
||||||
|
let Ok(inst) = host.create_instance(&plugin.identifier, Some(&context)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = plugin_display_name(&inst.value);
|
||||||
|
let description = plugin_description(&inst.value);
|
||||||
|
let sub_category = sub_category_for(&context).to_string();
|
||||||
|
drop(inst);
|
||||||
|
|
||||||
|
let identifier = plugin.identifier.clone();
|
||||||
|
let create: DynNodeConstructor = Arc::new(move || {
|
||||||
|
create_plugin_node(&identifier, &context)
|
||||||
|
.unwrap_or_else(oaknode::nodes::plugin::create)
|
||||||
|
});
|
||||||
|
|
||||||
|
let meta = DynamicNodeMeta {
|
||||||
|
type_id: plugin.identifier.clone(),
|
||||||
|
name,
|
||||||
|
categories: vec![Category::OpenFx],
|
||||||
|
sub_category,
|
||||||
|
description,
|
||||||
|
create,
|
||||||
|
};
|
||||||
|
if oaknode::factory::Factory::global().register_dynamic(meta) {
|
||||||
|
registered.push(plugin.identifier.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registered
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 渲染执行器 + duplicator(依赖反转的 oakplugin 侧半环)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 字符串族参数注入(POD 无字符串表达;直接 set_ofx)。
|
||||||
|
fn set_string_param(inst: &Instance, key: &str, expected_type: &str, value: &str) {
|
||||||
|
let Some(p) = inst.params.find(key) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if p.def.ofx_type != expected_type {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(cs) = CString::new(value) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let pv = if expected_type == ofx::TYPE_STRING {
|
||||||
|
ParamValue::String(cs)
|
||||||
|
} else {
|
||||||
|
ParamValue::StrChoice(cs)
|
||||||
|
};
|
||||||
|
p.set_ofx(pv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// executor 槽实现:JobSpec::Plugin → render_driver::render_frame。
|
||||||
|
fn execute_plugin_job(
|
||||||
|
req: &oakrender::eval::PluginJobRequest<'_>,
|
||||||
|
) -> oakrender::error::Result<oakrender::texture::Texture> {
|
||||||
|
use oakrender::error::Error;
|
||||||
|
|
||||||
|
let oakrender::eval::JobSpec::Plugin {
|
||||||
|
instance,
|
||||||
|
time,
|
||||||
|
effect_input_id,
|
||||||
|
inputs,
|
||||||
|
values,
|
||||||
|
} = req.spec
|
||||||
|
else {
|
||||||
|
return Err(Error::Invalid);
|
||||||
|
};
|
||||||
|
|
||||||
|
let inst = instance_from_id(*instance).ok_or_else(|| {
|
||||||
|
Error::Failed(format!("插件实例 {instance} 未登记(实例已释放?)"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if req.src.is_dummy() {
|
||||||
|
return Err(Error::Failed("plugin job 无可用输入纹理".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数注入:数值族走 render_driver 的 POD 覆盖;字符串族 POD 无
|
||||||
|
// 表达,这里直接 set_ofx(对齐 pluginrenderer.cpp 的
|
||||||
|
// StringInstance::set 分支)。
|
||||||
|
let mut pod_values = Vec::new();
|
||||||
|
for (key, nv) in values {
|
||||||
|
match nv {
|
||||||
|
NodeValue::Text(s) => set_string_param(&inst.value, key, ofx::TYPE_STRING, s),
|
||||||
|
NodeValue::StrCombo(s) => set_string_param(&inst.value, key, ofx::TYPE_STRCHOICE, s),
|
||||||
|
NodeValue::PushButton | NodeValue::None => {}
|
||||||
|
other => {
|
||||||
|
if let Some(v) = crate::node::Value::from_node_value(other) {
|
||||||
|
pod_values.push((key.clone(), v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出纹理:与输入同尺寸的 F32 帧(render_driver 校验 F32)。
|
||||||
|
let dst_frame = oakrender::eval::generate_frame(
|
||||||
|
oakcore_rs::Rational::from_double(*time),
|
||||||
|
req.src.size(),
|
||||||
|
oakcore_rs::PixelFormat::F32,
|
||||||
|
)?;
|
||||||
|
let job = crate::render_driver::RenderJob {
|
||||||
|
time: *time,
|
||||||
|
dst: oakrender::texture::Texture::wrap_frame(dst_frame),
|
||||||
|
src: Some(req.src.clone()),
|
||||||
|
effect_input_id: effect_input_id.clone(),
|
||||||
|
inputs: inputs.clone(),
|
||||||
|
values: pod_values,
|
||||||
|
renderer: None,
|
||||||
|
clear_destination: false,
|
||||||
|
interactive: false,
|
||||||
|
};
|
||||||
|
let (out, _rois) = crate::render_driver::render_frame(&inst.value, &job)
|
||||||
|
.map_err(|e| Error::Failed(format!("插件渲染失败:{e:?}")))?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// duplicator 槽实现:duplicate() 经注册表换新实例。
|
||||||
|
fn duplicate_instance(old: PluginInstanceHandle) -> Option<PluginInstanceHandle> {
|
||||||
|
let inst = instance_from_id(old.0)?;
|
||||||
|
let identifier = inst.value.plugin.identifier.clone();
|
||||||
|
let context = inst.value.context.clone();
|
||||||
|
let new_inst = Host::global()
|
||||||
|
.create_instance(&identifier, Some(&context))
|
||||||
|
.ok()?;
|
||||||
|
Some(PluginInstanceHandle(register_instance(new_inst)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static EXECUTOR_INSTALLED: OnceLock<()> = OnceLock::new();
|
||||||
|
|
||||||
|
/// 把渲染执行器装进 oakrender 的 executor 槽、duplicator 装进
|
||||||
|
/// oaknode(幂等)。[`register_plugin_nodes`] 已内含;app 侧单独
|
||||||
|
/// 初始化渲染管线时也可直接调。
|
||||||
|
pub fn install_render_executor() {
|
||||||
|
EXECUTOR_INSTALLED.get_or_init(|| {
|
||||||
|
oakrender::eval::set_plugin_executor(Some(Arc::new(execute_plugin_job)));
|
||||||
|
oaknode::nodes::plugin::set_plugin_duplicator(Some(Arc::new(duplicate_instance)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 测试
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_type_table_covers_all_ofx_kinds() {
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_INTEGER),
|
||||||
|
Some(ValueType::Int)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_DOUBLE),
|
||||||
|
Some(ValueType::Float)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_BOOLEAN),
|
||||||
|
Some(ValueType::Boolean)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_STRING),
|
||||||
|
Some(ValueType::Text)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_RGB),
|
||||||
|
Some(ValueType::Color)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_RGBA),
|
||||||
|
Some(ValueType::Color)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_CHOICE),
|
||||||
|
Some(ValueType::Combo)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_DOUBLE2D),
|
||||||
|
Some(ValueType::Vec2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_INTEGER2D),
|
||||||
|
Some(ValueType::Vec2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_DOUBLE3D),
|
||||||
|
Some(ValueType::Vec3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_INTEGER3D),
|
||||||
|
Some(ValueType::Vec3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_STRCHOICE),
|
||||||
|
Some(ValueType::StrCombo)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_BYTES),
|
||||||
|
Some(ValueType::Binary)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_CUSTOM),
|
||||||
|
Some(ValueType::Binary)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
input_type_for(ofx::TYPE_PUSHBUTTON),
|
||||||
|
Some(ValueType::PushButton)
|
||||||
|
);
|
||||||
|
// 容器与未知类型:跳过。
|
||||||
|
assert_eq!(input_type_for(ofx::TYPE_GROUP), None);
|
||||||
|
assert_eq!(input_type_for(ofx::TYPE_PAGE), None);
|
||||||
|
assert_eq!(input_type_for("OfxParamTypeParametric"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_semantic_rules() {
|
||||||
|
fn def_with(label: &str, ofx_type: &str) -> ParamDef {
|
||||||
|
let def = ParamDef {
|
||||||
|
props: PropertySet::new(),
|
||||||
|
name: "p".into(),
|
||||||
|
ofx_type: ofx_type.into(),
|
||||||
|
default: ParamValue::Container,
|
||||||
|
};
|
||||||
|
def.props.set_one(
|
||||||
|
ofx::PROP_LABEL,
|
||||||
|
PropValue::String(CString::new(label).unwrap()),
|
||||||
|
);
|
||||||
|
def
|
||||||
|
}
|
||||||
|
let groups = HashMap::new();
|
||||||
|
// 非颜色类型恒 "color"。
|
||||||
|
assert_eq!(
|
||||||
|
deduce_color_semantic(&def_with("Anything", ofx::TYPE_DOUBLE), &groups),
|
||||||
|
"color"
|
||||||
|
);
|
||||||
|
// 规则 1:颜色关键词。
|
||||||
|
assert_eq!(
|
||||||
|
deduce_color_semantic(&def_with("Tint Color", ofx::TYPE_RGB), &groups),
|
||||||
|
"color"
|
||||||
|
);
|
||||||
|
// 规则 2:标量关键词。
|
||||||
|
assert_eq!(
|
||||||
|
deduce_color_semantic(&def_with("Gamma Adjust", ofx::TYPE_RGBA), &groups),
|
||||||
|
"scalar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_semantic_display_range_rule() {
|
||||||
|
let def = ParamDef {
|
||||||
|
props: PropertySet::new(),
|
||||||
|
name: "rgb".into(),
|
||||||
|
ofx_type: ofx::TYPE_RGB.into(),
|
||||||
|
default: ParamValue::Container,
|
||||||
|
};
|
||||||
|
// 默认 (0,0,0) 全相等前先被规则 3 拦截:display max 越界。
|
||||||
|
def.props
|
||||||
|
.set_one(ofx::P_DISPLAY_MAX, PropValue::Double(2.0));
|
||||||
|
let groups = HashMap::new();
|
||||||
|
assert_eq!(deduce_color_semantic(&def, &groups), "scalar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_labels_special_case_names() {
|
||||||
|
assert_eq!(clip_label_for_name("Source", None), "Source");
|
||||||
|
assert_eq!(clip_label_for_name("SourceFrom", None), "From");
|
||||||
|
assert_eq!(clip_label_for_name("SourceTo", None), "To");
|
||||||
|
assert_eq!(clip_label_for_name("Overlay", None), "Overlay");
|
||||||
|
let props = PropertySet::new();
|
||||||
|
props.set_one(
|
||||||
|
ofx::PROP_LABEL,
|
||||||
|
PropValue::String(CString::new("Matte").unwrap()),
|
||||||
|
);
|
||||||
|
assert_eq!(clip_label_for_name("Overlay", Some(&props)), "Matte");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_registry_roundtrip() {
|
||||||
|
// 注册表对不存在的键返回 None;摘除未登记键 no-op。
|
||||||
|
assert!(instance_from_id(u64::MAX).is_none());
|
||||||
|
unregister_instance(u64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,8 @@ pub(crate) const V_DOUBLE_TYPE_PLAIN: &str = "OfxParamDoubleTypePlain";
|
|||||||
pub(crate) const P_DEFAULT_COORD_SYS: &str = "OfxParamPropDefaultCoordinateSystem";
|
pub(crate) const P_DEFAULT_COORD_SYS: &str = "OfxParamPropDefaultCoordinateSystem";
|
||||||
/// kOfxParamCoordinatesCanonical。
|
/// kOfxParamCoordinatesCanonical。
|
||||||
pub(crate) const V_COORD_CANONICAL: &str = "OfxParamCoordinatesCanonical";
|
pub(crate) const V_COORD_CANONICAL: &str = "OfxParamCoordinatesCanonical";
|
||||||
|
/// kOfxParamCoordinatesNormalised(ofxParam.h:514)。
|
||||||
|
pub(crate) const V_COORD_NORMALISED: &str = "OfxParamCoordinatesNormalised";
|
||||||
/// kOfxParamPropShowTimeMarker。
|
/// kOfxParamPropShowTimeMarker。
|
||||||
pub(crate) const P_SHOW_TIME_MARKER: &str = "OfxParamPropShowTimeMarker";
|
pub(crate) const P_SHOW_TIME_MARKER: &str = "OfxParamPropShowTimeMarker";
|
||||||
/// kOfxParamPropDimensionLabel。
|
/// kOfxParamPropDimensionLabel。
|
||||||
@@ -138,6 +140,14 @@ pub(crate) const V_STRING_SINGLE_LINE: &str = "OfxParamStringIsSingleLine";
|
|||||||
pub(crate) const P_STRING_FILE_EXISTS: &str = "OfxParamPropStringFilePathExists";
|
pub(crate) const P_STRING_FILE_EXISTS: &str = "OfxParamPropStringFilePathExists";
|
||||||
/// kOfxParamPropChoiceOption。
|
/// kOfxParamPropChoiceOption。
|
||||||
pub(crate) const P_CHOICE_OPTION: &str = "OfxParamPropChoiceOption";
|
pub(crate) const P_CHOICE_OPTION: &str = "OfxParamPropChoiceOption";
|
||||||
|
/// kOfxParamPropChoiceOrder。
|
||||||
|
pub(crate) const P_CHOICE_ORDER: &str = "OfxParamPropChoiceOrder";
|
||||||
|
/// kOfxParamPropChoiceEnum。
|
||||||
|
pub(crate) const P_CHOICE_ENUM: &str = "OfxParamPropChoiceEnum";
|
||||||
|
/// kOfxParamPageSkipRow(page 子项哨兵,ofxParam.h:178)。
|
||||||
|
pub(crate) const PAGE_SKIP_ROW: &str = "OfxParamPageSkipRow";
|
||||||
|
/// kOfxParamPageSkipColumn(page 子项哨兵,ofxParam.h:186)。
|
||||||
|
pub(crate) const PAGE_SKIP_COLUMN: &str = "OfxParamPageSkipColumn";
|
||||||
/// kOfxParamPropCustomInterpCallbackV1。
|
/// kOfxParamPropCustomInterpCallbackV1。
|
||||||
pub(crate) const P_CUSTOM_INTERP: &str = "OfxParamPropCustomCallbackV1";
|
pub(crate) const P_CUSTOM_INTERP: &str = "OfxParamPropCustomCallbackV1";
|
||||||
/// kOfxParamPropPageChild。
|
/// kOfxParamPropPageChild。
|
||||||
|
|||||||
@@ -17,13 +17,57 @@
|
|||||||
//! 进度上报(Progress suite 的宿主侧)。
|
//! 进度上报(Progress suite 的宿主侧)。
|
||||||
//!
|
//!
|
||||||
//! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的
|
//! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的
|
||||||
//! 回调出 crate(M9 的 facade 回调模式,不设全局状态)。
|
//! 回调出 crate(M9 的 facade 回调模式)。取消是粘滞的:一旦回调
|
||||||
//! 取消是粘滞的:一旦回调答 false,本报告器的 [`is_cancelled`]
|
//! 答 false,本报告器的 [`is_cancelled`](image effect suite 的
|
||||||
//! (image effect suite 的 abort 查询)持续为真。
|
//! abort 查询)持续为真。
|
||||||
|
//!
|
||||||
|
//! ## app 注入点(阶段 6a)
|
||||||
|
//!
|
||||||
|
//! facade C 回调之外,app 可经 [`set_reporter_factory`] 注册一个
|
||||||
|
//! 工厂:渲染未装 C 回调时,Progress suite 的 progressStart 携
|
||||||
|
//! (label, message) 从工厂现造一个 [`UiProgressReporter`](如进度
|
||||||
|
//! 对话框),progressUpdate 转发给它,返回 false 即取消。对应 C++
|
||||||
|
//! `PluginProgressDialogReporter` 的创建路径。
|
||||||
|
|
||||||
use std::ffi::c_int;
|
use std::ffi::c_int;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
/// 进度 UI 报告器(app 实现:对话框、状态栏等)。`update` 返回
|
||||||
|
/// false 表示用户请求取消(映射 kOfxStatReplyNo)。
|
||||||
|
pub trait UiProgressReporter: Send {
|
||||||
|
/// 上报进度(0.0..=1.0);false = 取消。
|
||||||
|
fn update(&mut self, progress: f64) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 报告器工厂:progressStart 携 (label, message) 调用,现造一个
|
||||||
|
/// [`UiProgressReporter`]。
|
||||||
|
pub type ReporterFactory =
|
||||||
|
Arc<dyn Fn(&str, &str) -> Box<dyn UiProgressReporter> + Send + Sync>;
|
||||||
|
|
||||||
|
static REPORTER_FACTORY: OnceLock<Mutex<Option<ReporterFactory>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn factory_slot() -> &'static Mutex<Option<ReporterFactory>> {
|
||||||
|
REPORTER_FACTORY.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册/清除进度 UI 工厂(app 接线点;覆盖式)。
|
||||||
|
pub fn set_reporter_factory(factory: Option<ReporterFactory>) {
|
||||||
|
*factory_slot().lock().unwrap_or_else(|e| e.into_inner()) = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reporter_factory() -> Option<ReporterFactory> {
|
||||||
|
factory_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否已注册 UI 工厂(render 路径据此决定是否装静默报告器)。
|
||||||
|
pub fn has_reporter_factory() -> bool {
|
||||||
|
reporter_factory().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// 进度回调:签名与 `include/plugin/instance.h` 的
|
/// 进度回调:签名与 `include/plugin/instance.h` 的
|
||||||
/// `oakplugin_progress_fn` 逐字一致——`(progress, userdata)`,
|
/// `oakplugin_progress_fn` 逐字一致——`(progress, userdata)`,
|
||||||
@@ -36,16 +80,21 @@ pub type ProgressFn = unsafe extern "C" fn(progress: f64, userdata: *mut std::ff
|
|||||||
pub struct ProgressReporter {
|
pub struct ProgressReporter {
|
||||||
callback: Option<ProgressFn>,
|
callback: Option<ProgressFn>,
|
||||||
userdata: usize,
|
userdata: usize,
|
||||||
|
/// UI 报告器(progressStart 经 [`reporter_factory`] 现造;
|
||||||
|
/// C 回调优先,无回调时才用)。
|
||||||
|
ui: Mutex<Option<Box<dyn UiProgressReporter>>>,
|
||||||
/// 取消标志(粘滞;update 返回 false 时置位)。
|
/// 取消标志(粘滞;update 返回 false 时置位)。
|
||||||
cancelled: AtomicBool,
|
cancelled: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProgressReporter {
|
impl ProgressReporter {
|
||||||
/// 无回调(渲染静默进行)。
|
/// 无回调(渲染静默进行;progressStart 仍可能经工厂装上 UI
|
||||||
|
/// 报告器)。
|
||||||
pub fn silent() -> Self {
|
pub fn silent() -> Self {
|
||||||
Self {
|
Self {
|
||||||
callback: None,
|
callback: None,
|
||||||
userdata: 0,
|
userdata: 0,
|
||||||
|
ui: Mutex::new(None),
|
||||||
cancelled: AtomicBool::new(false),
|
cancelled: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +108,26 @@ impl ProgressReporter {
|
|||||||
callback: Some(callback),
|
callback: Some(callback),
|
||||||
// usize 存(裸指针破坏 Send 推导;值语义不变)。
|
// usize 存(裸指针破坏 Send 推导;值语义不变)。
|
||||||
userdata: userdata as usize,
|
userdata: userdata as usize,
|
||||||
|
ui: Mutex::new(None),
|
||||||
cancelled: AtomicBool::new(false),
|
cancelled: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// progressStart 钩子:无 C 回调且工厂已注册时,现造 UI 报告器
|
||||||
|
/// (已装过则不重复造——progressStart 可嵌套括号)。
|
||||||
|
pub(crate) fn install_ui(&self, label: &str, message: &str) {
|
||||||
|
if self.callback.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut slot = self.ui.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if slot.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(factory) = reporter_factory() {
|
||||||
|
*slot = Some(factory(label, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 报告进度;返回 false 表示应取消(映射
|
/// 报告进度;返回 false 表示应取消(映射
|
||||||
/// kOfxStatReplyNo/action 失败由调用点决定)。
|
/// kOfxStatReplyNo/action 失败由调用点决定)。
|
||||||
pub fn update(&self, progress: f64) -> bool {
|
pub fn update(&self, progress: f64) -> bool {
|
||||||
@@ -75,9 +140,21 @@ impl ProgressReporter {
|
|||||||
}
|
}
|
||||||
!abort
|
!abort
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
let mut slot = self.ui.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match slot.as_mut() {
|
||||||
|
Some(ui) => {
|
||||||
|
let keep_going = ui.update(progress);
|
||||||
|
if !keep_going {
|
||||||
|
self.cancelled.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
keep_going
|
||||||
|
}
|
||||||
None => true,
|
None => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 本次渲染是否已被取消(image effect suite 的 abort 透传)。
|
/// 本次渲染是否已被取消(image effect suite 的 abort 透传)。
|
||||||
pub(crate) fn is_cancelled(&self) -> bool {
|
pub(crate) fn is_cancelled(&self) -> bool {
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ pub fn texture_create(
|
|||||||
/// Rust 等价物。恒 0(GL suite 的 `OpenGLTextureIndex` 属性与
|
/// Rust 等价物。恒 0(GL suite 的 `OpenGLTextureIndex` 属性与
|
||||||
/// render 驱动的 use_opengl 决策据此回退 CPU 路径;GPU 上传若落地
|
/// render 驱动的 use_opengl 决策据此回退 CPU 路径;GPU 上传若落地
|
||||||
/// 走 `oakrender::backend::GpuContextLike::upload` 的 wgpu token,
|
/// 走 `oakrender::backend::GpuContextLike::upload` 的 wgpu token,
|
||||||
/// 不暴露 GL id)。
|
/// 不暴露 GL id)。真实化的评估与方案见 [`crate::gl_bridge`]
|
||||||
|
/// (阶段 6a spike:方案 A 不可行,方案 B 暂缓)。
|
||||||
pub fn texture_id(_texture: &Texture) -> i32 {
|
pub fn texture_id(_texture: &Texture) -> i32 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,13 +133,15 @@ pub fn end_sequence(
|
|||||||
|
|
||||||
/// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。
|
/// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。
|
||||||
///
|
///
|
||||||
/// 返回各输入 clip 的 RoI(clip 名 → 矩形;Phase 2 供测试断言,
|
/// 返回装配完成的输出纹理与各输入 clip 的 RoI(clip 名 → 矩形;
|
||||||
/// 宿主不据此裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器
|
/// 值型纹理下输出写入 `job.dst` 的本地副本并随返回值交付——CPU
|
||||||
/// 行为一致)。
|
/// 纹理的 `to_frame` 是拷贝,就地写不回只读的 `job.dst`)。宿主不
|
||||||
|
/// 据 RoI 裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器行为
|
||||||
|
/// 一致。
|
||||||
pub fn render_frame(
|
pub fn render_frame(
|
||||||
inst: &Instance,
|
inst: &Instance,
|
||||||
job: &RenderJob,
|
job: &RenderJob,
|
||||||
) -> crate::error::Result<Vec<(String, OfxRectD)>> {
|
) -> crate::error::Result<(Texture, Vec<(String, OfxRectD)>)> {
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
// 1. 实例锁(pluginrenderer.cpp:1436-1444:OlivePluginInstance 非
|
// 1. 实例锁(pluginrenderer.cpp:1436-1444:OlivePluginInstance 非
|
||||||
@@ -167,8 +169,10 @@ pub fn render_frame(
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 目标帧与参数(F32 校验;输出装配的依据)。
|
// 目标帧与参数(F32 校验;输出装配的依据)。dst 取本地副本:
|
||||||
|
// 值型纹理下输出装配写回副本,随返回值交付(job.dst 只读)。
|
||||||
let (dst_params, w, h) = read_dst(&job.dst)?;
|
let (dst_params, w, h) = read_dst(&job.dst)?;
|
||||||
|
let mut dst = job.dst.clone();
|
||||||
let par = pixel_aspect(&dst_params);
|
let par = pixel_aspect(&dst_params);
|
||||||
// 规范坐标的 RoI/RoD(pluginrenderer.cpp:1595-1603:x2 = 宽 × PAR)。
|
// 规范坐标的 RoI/RoD(pluginrenderer.cpp:1595-1603:x2 = 宽 × PAR)。
|
||||||
let region_of_interest = OfxRectD {
|
let region_of_interest = OfxRectD {
|
||||||
@@ -209,7 +213,7 @@ pub fn render_frame(
|
|||||||
.find(|c| c.name == "Output")
|
.find(|c| c.name == "Output")
|
||||||
.ok_or_else(|| Error::Failed("实例无 Output clip".into()))?;
|
.ok_or_else(|| Error::Failed("实例无 Output clip".into()))?;
|
||||||
output_clip.set_region_of_definition(region_of_interest, job.time);
|
output_clip.set_region_of_definition(region_of_interest, job.time);
|
||||||
output_clip.set_output_texture(Some(job.dst.clone()), job.time);
|
output_clip.set_output_texture(Some(dst.clone()), job.time);
|
||||||
|
|
||||||
// 6. 输入 clip:RoD 与格式(pluginrenderer.cpp:1627-1665;Phase 2
|
// 6. 输入 clip:RoD 与格式(pluginrenderer.cpp:1627-1665;Phase 2
|
||||||
// 全链路 F32 → 格式选择恒等,无转换路径)。
|
// 全链路 F32 → 格式选择恒等,无转换路径)。
|
||||||
@@ -244,8 +248,8 @@ pub fn render_frame(
|
|||||||
// 9. isIdentity 短路(ofxRendering "Identity Effects"):插件声明
|
// 9. isIdentity 短路(ofxRendering "Identity Effects"):插件声明
|
||||||
// 本帧等价于某输入 clip → 直接透传该 clip 在透传时间的帧。
|
// 本帧等价于某输入 clip → 直接透传该 clip 在透传时间的帧。
|
||||||
if let Some((t, clip_name)) = inst.is_identity(job.time)? {
|
if let Some((t, clip_name)) = inst.is_identity(job.time)? {
|
||||||
passthrough(inst, &clip_name, t, &job.dst)?;
|
passthrough(inst, &clip_name, t, &mut dst)?;
|
||||||
return Ok(zip_rois(inst, &rois));
|
return Ok((dst, zip_rois(inst, &rois)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 参数覆盖(pluginrenderer.cpp:1729-1731 + 132-290)。
|
// 10. 参数覆盖(pluginrenderer.cpp:1729-1731 + 132-290)。
|
||||||
@@ -270,7 +274,7 @@ pub fn render_frame(
|
|||||||
output.clone(),
|
output.clone(),
|
||||||
)?;
|
)?;
|
||||||
// 输出装配(pluginrenderer.cpp:1762-1834 的 CPU 路径)。
|
// 输出装配(pluginrenderer.cpp:1762-1834 的 CPU 路径)。
|
||||||
write_output_frame(&job.dst, &output)?;
|
write_output_frame(&mut dst, &output)?;
|
||||||
} else {
|
} else {
|
||||||
// GL 路径:插件直接画进已附着的输出纹理(
|
// GL 路径:插件直接画进已附着的输出纹理(
|
||||||
// pluginrenderer.cpp:1784-1834 的 GL 分支);无 CPU 回读。
|
// pluginrenderer.cpp:1784-1834 的 GL 分支);无 CPU 回读。
|
||||||
@@ -279,11 +283,11 @@ pub fn render_frame(
|
|||||||
RenderScale { x: 1.0, y: 1.0 },
|
RenderScale { x: 1.0, y: 1.0 },
|
||||||
render_window,
|
render_window,
|
||||||
job.renderer.clone().unwrap(),
|
job.renderer.clone().unwrap(),
|
||||||
job.dst.clone(),
|
dst.clone(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(zip_rois(inst, &rois))
|
Ok((dst, zip_rois(inst, &rois)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把输入 clip 名与 RoI 列表配对(与 `clips` 顺序一致)。
|
/// 把输入 clip 名与 RoI 列表配对(与 `clips` 顺序一致)。
|
||||||
@@ -373,7 +377,7 @@ fn passthrough(
|
|||||||
inst: &Instance,
|
inst: &Instance,
|
||||||
clip_name: &str,
|
clip_name: &str,
|
||||||
t: f64,
|
t: f64,
|
||||||
dst: &Texture,
|
dst: &mut Texture,
|
||||||
) -> crate::error::Result<()> {
|
) -> crate::error::Result<()> {
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
let clip = inst
|
let clip = inst
|
||||||
@@ -395,18 +399,51 @@ fn apply_param_overrides(inst: &Instance, values: &[(String, crate::node::Value)
|
|||||||
let Some(p) = inst.params.find(key) else {
|
let Some(p) = inst.params.find(key) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(pv) = crate::param::param_value_from_node(v, &p.def.ofx_type) else {
|
let Some(mut pv) = crate::param::param_value_from_node(v, &p.def.ofx_type) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// Double 标量的 NaN/Inf 清洗 + Min/Max 钳制
|
||||||
|
// (pluginrenderer.cpp:155-177:坏值回退默认并告警,再按
|
||||||
|
// kOfxParamPropMin/Max 钳制;多维 Double 族 C++ 无此检查)。
|
||||||
|
if p.def.ofx_type == crate::param::TYPE_DOUBLE {
|
||||||
|
if let crate::param::ParamValue::Double(d, 1) = &mut pv {
|
||||||
|
if d[0].is_nan() || d[0].is_infinite() {
|
||||||
|
eprintln!(
|
||||||
|
"[PLUGIN] NaN/Inf in double param {key} replacing with default"
|
||||||
|
);
|
||||||
|
d[0] = prop_double(&p.def.props, crate::param::P_DEFAULT, 0);
|
||||||
|
}
|
||||||
|
if let Some(Value::Double(min)) = p.def.props.get(crate::param::P_MIN, 0) {
|
||||||
|
if d[0] < min {
|
||||||
|
d[0] = min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::Double(max)) = p.def.props.get(crate::param::P_MAX, 0) {
|
||||||
|
if d[0] > max {
|
||||||
|
d[0] = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
p.set_ofx(pv);
|
p.set_ofx(pv);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读属性的 Double 值(缺失 0.0;Int 提升)。
|
||||||
|
fn prop_double(props: &crate::property::PropertySet, name: &str, index: usize) -> f64 {
|
||||||
|
match props.get(name, index) {
|
||||||
|
Some(Value::Double(v)) => v,
|
||||||
|
Some(Value::Int(v)) => v as f64,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 把 CPU 图像写入目标纹理(行优先、行跨度感知;F32 校验)。
|
/// 把 CPU 图像写入目标纹理(行优先、行跨度感知;F32 校验)。
|
||||||
/// Phase 2 输出装配的公共落点(CPU render 路径与 isIdentity 透传
|
/// Phase 2 输出装配的公共落点(CPU render 路径与 isIdentity 透传
|
||||||
/// 共用)。GPU 目标纹理经后端 upload 回写(`Texture::Gpu` 分支)。
|
/// 共用)。GPU 目标纹理经后端 upload 回写(`Texture::Gpu` 分支)。
|
||||||
pub(crate) fn write_output_frame(dst: &Texture, image: &Image) -> crate::error::Result<()> {
|
pub(crate) fn write_output_frame(dst: &mut Texture, image: &Image) -> crate::error::Result<()> {
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
// CPU 纹理就地写入;GPU 纹理经下载帧改写后 upload 回写。
|
||||||
let mut frame = render::texture_get_frame(dst)?;
|
let mut frame = render::texture_get_frame(dst)?;
|
||||||
let params = frame.video_params();
|
let params = frame.video_params();
|
||||||
if params.format != render::PIXEL_FORMAT_F32 {
|
if params.format != render::PIXEL_FORMAT_F32 {
|
||||||
@@ -430,11 +467,16 @@ pub(crate) fn write_output_frame(dst: &Texture, image: &Image) -> crate::error::
|
|||||||
let s = y * tight;
|
let s = y * tight;
|
||||||
dst_bytes[d..d + tight].copy_from_slice(&pixels[s..s + tight]);
|
dst_bytes[d..d + tight].copy_from_slice(&pixels[s..s + tight]);
|
||||||
}
|
}
|
||||||
// GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写
|
match dst {
|
||||||
// (CPU 纹理无需上传)。
|
// 就地写回(值型 CPU 纹理:to_frame 是拷贝,必须写回本体)。
|
||||||
if let Texture::Gpu { token, ctx, .. } = dst {
|
Texture::Cpu(f) => {
|
||||||
|
f.data = frame.data;
|
||||||
|
}
|
||||||
|
// GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写。
|
||||||
|
Texture::Gpu { token, ctx, .. } => {
|
||||||
ctx.upload(*token, &frame)
|
ctx.upload(*token, &frame)
|
||||||
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
|
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,10 +84,29 @@ pub struct ProgressSuiteV2 {
|
|||||||
pub end: unsafe extern "C" fn(*mut c_void) -> c_int,
|
pub end: unsafe extern "C" fn(*mut c_void) -> c_int,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// progressStart:括号起点,OK(label/message 留作未来 UI 展示,
|
/// C 字符串解码(空指针/非法 UTF-8 → 空串)。
|
||||||
/// 第 1 期不建模)。
|
unsafe fn decode<'a>(p: *const c_char) -> &'a str {
|
||||||
unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, _label: *const c_char) -> c_int {
|
if p.is_null() {
|
||||||
caught(|| status::OK)
|
return "";
|
||||||
|
}
|
||||||
|
unsafe { std::ffi::CStr::from_ptr(p) }
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// progressStart:括号起点;携 label(v2 另携 message)经
|
||||||
|
/// [`crate::progress`] 工厂现造 UI 报告器(无工厂/已有报告器时
|
||||||
|
/// no-op),状态恒 OK。
|
||||||
|
unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, label: *const c_char) -> c_int {
|
||||||
|
caught(|| {
|
||||||
|
let label = unsafe { decode(label) }.to_string();
|
||||||
|
CURRENT.with(|c| {
|
||||||
|
if let Some(r) = c.borrow().as_ref() {
|
||||||
|
r.install_ui(&label, "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
status::OK
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe extern "C" fn progress_update_v1(_handle: *mut c_void, progress: c_double) -> c_int {
|
unsafe extern "C" fn progress_update_v1(_handle: *mut c_void, progress: c_double) -> c_int {
|
||||||
@@ -100,10 +119,19 @@ unsafe extern "C" fn progress_end_v1(_handle: *mut c_void) -> c_int {
|
|||||||
|
|
||||||
unsafe extern "C" fn progress_start_v2(
|
unsafe extern "C" fn progress_start_v2(
|
||||||
_handle: *mut c_void,
|
_handle: *mut c_void,
|
||||||
_label: *const c_char,
|
label: *const c_char,
|
||||||
_message: *const c_char,
|
message: *const c_char,
|
||||||
) -> c_int {
|
) -> c_int {
|
||||||
caught(|| status::OK)
|
caught(|| {
|
||||||
|
let label = unsafe { decode(label) }.to_string();
|
||||||
|
let message = unsafe { decode(message) }.to_string();
|
||||||
|
CURRENT.with(|c| {
|
||||||
|
if let Some(r) = c.borrow().as_ref() {
|
||||||
|
r.install_ui(&label, &message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
status::OK
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe extern "C" fn progress_update_v2(_handle: *mut c_void, progress: c_double) -> c_int {
|
unsafe extern "C" fn progress_update_v2(_handle: *mut c_void, progress: c_double) -> c_int {
|
||||||
|
|||||||
@@ -18,14 +18,54 @@
|
|||||||
//! ([`crate::suites::RenderCtx`],frame range 来自 clip 桥)。
|
//! ([`crate::suites::RenderCtx`],frame range 来自 clip 桥)。
|
||||||
//! 参照 HS: ofxhImageEffect.cpp gTimelineSuite。
|
//! 参照 HS: ofxhImageEffect.cpp gTimelineSuite。
|
||||||
//!
|
//!
|
||||||
//! 无渲染上下文(渲染外调用)→ 时间 0 / 时间域 (0,0) 的 headless
|
//! 无渲染上下文(渲染外调用)→ 回退 app 注入的活动 viewer 时间源
|
||||||
//! 默认;gotoTime 第 1 期无时间线驱动 → OK no-op(渲染时间由驱动
|
//! ([`set_active_viewer_provider`],阶段 6a 注入点);也未注入则
|
||||||
//! 固定,见 [`crate::suites::set_render_ctx`])。
|
//! 时间 0 / 时间域 (0,0) 的 headless 默认。gotoTime 第 1 期无时间线
|
||||||
|
//! 驱动 → OK no-op(渲染时间由驱动固定,见
|
||||||
|
//! [`crate::suites::set_render_ctx`])。
|
||||||
|
|
||||||
use std::ffi::{c_double, c_int, c_void};
|
use std::ffi::{c_double, c_int, c_void};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use crate::suites::{render_ctx, status};
|
use crate::suites::{render_ctx, status};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// app 注入点:活动 viewer 时间源
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 活动 viewer 的时间信息(timeline suite 渲染外回退源的最小集)。
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ViewerTimeInfo {
|
||||||
|
/// viewer 当前时间(秒)。
|
||||||
|
pub time: f64,
|
||||||
|
/// 时间域下界(秒)。
|
||||||
|
pub range_min: f64,
|
||||||
|
/// 时间域上界(秒)。
|
||||||
|
pub range_max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 活动 viewer 提供器:返回 None 表示当前无活动 viewer。
|
||||||
|
pub type ActiveViewerProvider = Arc<dyn Fn() -> Option<ViewerTimeInfo> + Send + Sync>;
|
||||||
|
|
||||||
|
static ACTIVE_VIEWER: OnceLock<Mutex<Option<ActiveViewerProvider>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn viewer_slot() -> &'static Mutex<Option<ActiveViewerProvider>> {
|
||||||
|
ACTIVE_VIEWER.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册/清除活动 viewer 提供器(app 接线点;覆盖式)。
|
||||||
|
pub fn set_active_viewer_provider(provider: Option<ActiveViewerProvider>) {
|
||||||
|
*viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_viewer() -> Option<ViewerTimeInfo> {
|
||||||
|
let provider = viewer_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone()?;
|
||||||
|
provider()
|
||||||
|
}
|
||||||
|
|
||||||
/// 函数表布局(OfxTimeLineSuiteV1)。
|
/// 函数表布局(OfxTimeLineSuiteV1)。
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
pub struct TimeLineSuiteV1 {
|
pub struct TimeLineSuiteV1 {
|
||||||
@@ -54,8 +94,11 @@ unsafe extern "C" fn timeline_get_time(handle: *mut c_void, time: *mut c_double)
|
|||||||
if time.is_null() {
|
if time.is_null() {
|
||||||
return status::ERR_VALUE;
|
return status::ERR_VALUE;
|
||||||
}
|
}
|
||||||
// 渲染上下文缺省 → 0(headless 默认)。
|
// 渲染上下文 → 活动 viewer → 0(headless 默认)。
|
||||||
*time = render_ctx().map_or(0.0, |c| c.time);
|
*time = render_ctx()
|
||||||
|
.map(|c| c.time)
|
||||||
|
.or_else(|| active_viewer().map(|v| v.time))
|
||||||
|
.unwrap_or(0.0);
|
||||||
status::OK
|
status::OK
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -77,7 +120,11 @@ unsafe extern "C" fn timeline_get_time_bounds(
|
|||||||
if min.is_null() || max.is_null() {
|
if min.is_null() || max.is_null() {
|
||||||
return status::ERR_VALUE;
|
return status::ERR_VALUE;
|
||||||
}
|
}
|
||||||
let r = render_ctx().map_or((0.0, 0.0), |c| (c.range.min, c.range.max));
|
// 渲染上下文 → 活动 viewer → (0,0)(headless 默认)。
|
||||||
|
let r = render_ctx()
|
||||||
|
.map(|c| (c.range.min, c.range.max))
|
||||||
|
.or_else(|| active_viewer().map(|v| (v.range_min, v.range_max)))
|
||||||
|
.unwrap_or((0.0, 0.0));
|
||||||
*min = r.0;
|
*min = r.0;
|
||||||
*max = r.1;
|
*max = r.1;
|
||||||
status::OK
|
status::OK
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
//! 阶段 6a 端到端:OFX 插件 → 节点工厂 → 节点图 → 渲染出帧。
|
||||||
|
//!
|
||||||
|
//! 链路:`scan_path`(最小测试插件,cbits/oak_test_plugin.c)→
|
||||||
|
//! `node_factory::register_plugin_nodes`(动态注册)→
|
||||||
|
//! `Factory::create_any`(参数翻译的输入表)→ Graph 连接常量纹理
|
||||||
|
//! 源 → `Traverser::evaluate` + `RenderEvalHooks`(解 PluginJobPayload
|
||||||
|
//! 经 render_driver 出帧)→ 像素断言。
|
||||||
|
//!
|
||||||
|
//! 测试插件未构建时全部 skip(common 约定)。宿主单例经
|
||||||
|
//! `common::with_host` 串行化。
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use oakcore_rs::{PixelFormat, Rational};
|
||||||
|
use oaknode::factory::Factory;
|
||||||
|
use oaknode::graph::Graph;
|
||||||
|
use oaknode::node::{NodeBehavior, NodeCore};
|
||||||
|
use oaknode::traverser::{EvalRequest, Traverser};
|
||||||
|
use oaknode::value::{NodeValue, ValueType};
|
||||||
|
use oakplugin::host::Host;
|
||||||
|
use oakrender::texture::Texture;
|
||||||
|
|
||||||
|
const PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||||
|
const IDENTITY_ID: &str = "org.oak.test-plugin.identity";
|
||||||
|
|
||||||
|
/// 常量纹理源节点:推一张填充实色的 F32 帧(测试专用行为)。
|
||||||
|
struct ConstSource {
|
||||||
|
rgba: [f32; 4],
|
||||||
|
size: (i32, i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeBehavior for ConstSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"ConstSource"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_id(&self) -> &str {
|
||||||
|
"test.const-source"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||||
|
Some(Box::new(ConstSource {
|
||||||
|
rgba: self.rgba,
|
||||||
|
size: self.size,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(
|
||||||
|
&self,
|
||||||
|
_core: &NodeCore,
|
||||||
|
_inputs: &oaknode::value::NodeValueRow,
|
||||||
|
time: Rational,
|
||||||
|
table: &mut oaknode::value::NodeValueTable,
|
||||||
|
) {
|
||||||
|
let mut frame =
|
||||||
|
oakrender::eval::generate_frame(time, self.size, PixelFormat::F32).unwrap();
|
||||||
|
for pixel in frame.data.chunks_exact_mut(16) {
|
||||||
|
for (i, v) in self.rgba.iter().enumerate() {
|
||||||
|
pixel[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.push(
|
||||||
|
ValueType::Texture,
|
||||||
|
NodeValue::Texture(oaknode::handle::make_owned(Texture::wrap_frame(frame))),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描 + 注册(幂等;宿主不可用返回 false = skip)。
|
||||||
|
fn scan_and_register() -> bool {
|
||||||
|
let Some(dir) = common::test_plugin_scan_dir() else {
|
||||||
|
common::skip("最小测试插件未构建");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if Host::global().cache.scan_path(&dir).is_err() {
|
||||||
|
common::skip("测试插件扫描失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
oakplugin::node_factory::register_plugin_nodes();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取输出表的渲染纹理(resolve 后的真纹理盒)。
|
||||||
|
fn rendered_texture(
|
||||||
|
table: &oaknode::value::NodeValueTable,
|
||||||
|
) -> Texture {
|
||||||
|
let NodeValue::Texture(handle) = table
|
||||||
|
.get(ValueType::Texture)
|
||||||
|
.expect("根输出应有纹理")
|
||||||
|
else {
|
||||||
|
panic!("纹理槽不是 Texture 值");
|
||||||
|
};
|
||||||
|
unsafe { oaknode::handle::get_checked::<Texture>(handle) }
|
||||||
|
.cloned()
|
||||||
|
.expect("纹理盒必须是渲染产物(PluginJobPayload 已 resolve)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_pixel(texture: &Texture) -> [f32; 4] {
|
||||||
|
let Texture::Cpu(frame) = texture else {
|
||||||
|
panic!("期望 CPU 帧");
|
||||||
|
};
|
||||||
|
let mut out = [0f32; 4];
|
||||||
|
for i in 0..4 {
|
||||||
|
out[i] = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册 + 参数翻译:动态条目、输入类型/默认值/显示名/隐藏标记/
|
||||||
|
/// combo 选项/effect_input(对齐 plugin.cpp 构造函数)。
|
||||||
|
#[test]
|
||||||
|
fn plugin_nodes_register_with_translated_inputs() {
|
||||||
|
common::with_host(|| {
|
||||||
|
if !scan_and_register() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = Factory::global().dynamic_entries();
|
||||||
|
assert!(
|
||||||
|
entries.iter().any(|m| m.type_id == PLUGIN_ID),
|
||||||
|
"CPU 变体应注册为动态节点"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entries.iter().any(|m| m.type_id == IDENTITY_ID),
|
||||||
|
"identity 变体应注册为动态节点"
|
||||||
|
);
|
||||||
|
let meta = entries.iter().find(|m| m.type_id == PLUGIN_ID).unwrap();
|
||||||
|
assert_eq!(meta.sub_category, "Filter");
|
||||||
|
assert_eq!(
|
||||||
|
meta.categories,
|
||||||
|
vec![oaknode::node::Category::OpenFx]
|
||||||
|
);
|
||||||
|
|
||||||
|
let (core, behavior) = Factory::global()
|
||||||
|
.create_any(PLUGIN_ID)
|
||||||
|
.expect("create_any 应建出插件节点");
|
||||||
|
|
||||||
|
// gain:Double → Float,默认 0.0,显示名 Gain,带 display
|
||||||
|
// min/max 属性(-2/2)。
|
||||||
|
let gain = core.get_input("gain").expect("gain 输入");
|
||||||
|
assert_eq!(gain.value_type, ValueType::Float);
|
||||||
|
assert_eq!(gain.default, NodeValue::Float(0.0));
|
||||||
|
assert_eq!(gain.display_name, "Gain");
|
||||||
|
// min/max/tooltip 属性 C++ 只对颜色输入设置
|
||||||
|
// (plugin.cpp:407-430 的 k_color 分支);Double 参数无。
|
||||||
|
assert!(gain
|
||||||
|
.properties
|
||||||
|
.iter()
|
||||||
|
.all(|(k, _)| k != "min" && k != "max"));
|
||||||
|
|
||||||
|
// mode:Choice → Combo,两个选项 Fast/High。
|
||||||
|
let mode = core.get_input("mode").expect("mode 输入");
|
||||||
|
assert_eq!(mode.value_type, ValueType::Combo);
|
||||||
|
let options: Vec<String> = mode
|
||||||
|
.properties
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| k == "combo_option")
|
||||||
|
.map(|(_, v)| match v {
|
||||||
|
NodeValue::Text(s) => s.clone(),
|
||||||
|
_ => panic!("combo_option 应是 Text"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(options, vec!["Fast".to_string(), "High".to_string()]);
|
||||||
|
|
||||||
|
// debug:secret → hidden。
|
||||||
|
let debug = core.get_input("debug").expect("debug 输入");
|
||||||
|
assert!(
|
||||||
|
debug.flags & oaknode::input::flags::HIDDEN != 0,
|
||||||
|
"secret 参数应隐藏"
|
||||||
|
);
|
||||||
|
|
||||||
|
// label:String → Text。
|
||||||
|
let label = core.get_input("label").expect("label 输入");
|
||||||
|
assert_eq!(label.value_type, ValueType::Text);
|
||||||
|
|
||||||
|
// Source clip → 纹理输入;effect_input 选中 Source。
|
||||||
|
let source = core.get_input("Source").expect("Source 输入");
|
||||||
|
assert_eq!(source.value_type, ValueType::Texture);
|
||||||
|
assert_eq!(core.effect_input, "Source");
|
||||||
|
|
||||||
|
// 行为是持真实实例句柄的 PluginNode。
|
||||||
|
let plugin = behavior
|
||||||
|
.as_any()
|
||||||
|
.and_then(|a| a.downcast_ref::<oaknode::nodes::plugin::PluginNode>())
|
||||||
|
.expect("行为应是 PluginNode");
|
||||||
|
assert!(!plugin.instance_handle().is_null());
|
||||||
|
|
||||||
|
Host::global().shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU 端到端:常量源 → 插件节点(render 填常量 0.5/alpha 1)→
|
||||||
|
/// 输出帧像素断言。
|
||||||
|
#[test]
|
||||||
|
fn plugin_renders_constant_frame_end_to_end() {
|
||||||
|
common::with_host(|| {
|
||||||
|
if !scan_and_register() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (core, behavior) = Factory::global()
|
||||||
|
.create_any(PLUGIN_ID)
|
||||||
|
.expect("create_any");
|
||||||
|
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let src_id = graph.add_node(
|
||||||
|
NodeCore::new(),
|
||||||
|
Box::new(ConstSource {
|
||||||
|
rgba: [0.2, 0.4, 0.6, 1.0],
|
||||||
|
size: (4, 4),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let plug_id = graph.add_node(core, behavior);
|
||||||
|
graph
|
||||||
|
.connect(src_id, plug_id, "Source", -1)
|
||||||
|
.expect("Source 连接");
|
||||||
|
|
||||||
|
let mut traverser = Traverser::new();
|
||||||
|
let mut hooks = oakrender::eval::RenderEvalHooks::new();
|
||||||
|
let table = traverser
|
||||||
|
.evaluate(
|
||||||
|
&graph,
|
||||||
|
&EvalRequest::new(plug_id, Rational::new(0, 1)),
|
||||||
|
&mut hooks,
|
||||||
|
)
|
||||||
|
.expect("evaluate 应成功");
|
||||||
|
|
||||||
|
let texture = rendered_texture(&table);
|
||||||
|
assert_eq!(texture.size(), (4, 4));
|
||||||
|
// 测试插件 render 无视输入,填常量 0.5(alpha=1)。
|
||||||
|
assert_eq!(first_pixel(&texture), [0.5, 0.5, 0.5, 1.0]);
|
||||||
|
|
||||||
|
Host::global().shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// isIdentity 透传:identity 变体声明恒透传 Source → 输出应等于
|
||||||
|
/// 输入帧(render_driver 的 passthrough 短路)。
|
||||||
|
#[test]
|
||||||
|
fn identity_variant_passes_source_through() {
|
||||||
|
common::with_host(|| {
|
||||||
|
if !scan_and_register() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (core, behavior) = Factory::global()
|
||||||
|
.create_any(IDENTITY_ID)
|
||||||
|
.expect("create_any(identity)");
|
||||||
|
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let src_id = graph.add_node(
|
||||||
|
NodeCore::new(),
|
||||||
|
Box::new(ConstSource {
|
||||||
|
rgba: [0.25, 0.75, 0.5, 1.0],
|
||||||
|
size: (2, 2),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let plug_id = graph.add_node(core, behavior);
|
||||||
|
graph
|
||||||
|
.connect(src_id, plug_id, "Source", -1)
|
||||||
|
.expect("Source 连接");
|
||||||
|
|
||||||
|
let mut traverser = Traverser::new();
|
||||||
|
let mut hooks = oakrender::eval::RenderEvalHooks::new();
|
||||||
|
let table = traverser
|
||||||
|
.evaluate(
|
||||||
|
&graph,
|
||||||
|
&EvalRequest::new(plug_id, Rational::new(0, 1)),
|
||||||
|
&mut hooks,
|
||||||
|
)
|
||||||
|
.expect("evaluate 应成功");
|
||||||
|
|
||||||
|
let texture = rendered_texture(&table);
|
||||||
|
assert_eq!(first_pixel(&texture), [0.25, 0.75, 0.5, 1.0]);
|
||||||
|
|
||||||
|
Host::global().shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 参数覆盖路径:Text/StrCombo 走 set_ofx,数值走 POD——经 set 后
|
||||||
|
/// 实例参数值可读回(翻译注入的回归保护)。
|
||||||
|
#[test]
|
||||||
|
fn param_overrides_reach_instance() {
|
||||||
|
common::with_host(|| {
|
||||||
|
if !scan_and_register() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let inst = Host::global()
|
||||||
|
.create_instance(PLUGIN_ID, None)
|
||||||
|
.expect("实例");
|
||||||
|
let id = oakplugin::node_factory::register_instance(inst.clone());
|
||||||
|
|
||||||
|
// 数值覆盖(gain = 1.25)。
|
||||||
|
let job_values = vec![(
|
||||||
|
"gain".to_string(),
|
||||||
|
oaknode::value::NodeValue::Float(1.25),
|
||||||
|
)];
|
||||||
|
let pod: Vec<(String, oakplugin::node::Value)> = job_values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| {
|
||||||
|
oakplugin::node::Value::from_node_value(v).map(|p| (k.clone(), p))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||||
|
.unwrap();
|
||||||
|
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||||
|
.unwrap();
|
||||||
|
let job = oakplugin::render_driver::RenderJob {
|
||||||
|
time: 0.0,
|
||||||
|
dst: Texture::wrap_frame(dst),
|
||||||
|
src: Some(Texture::wrap_frame(src)),
|
||||||
|
effect_input_id: Some("Source".into()),
|
||||||
|
inputs: Vec::new(),
|
||||||
|
values: pod,
|
||||||
|
renderer: None,
|
||||||
|
clear_destination: false,
|
||||||
|
interactive: false,
|
||||||
|
};
|
||||||
|
oakplugin::render_driver::render_frame(&inst.value, &job)
|
||||||
|
.expect("render_frame 应成功");
|
||||||
|
let gain = inst.value.params.find("gain").unwrap().get();
|
||||||
|
assert_eq!(
|
||||||
|
gain,
|
||||||
|
oakplugin::param::ParamValue::Double([1.25, 0.0, 0.0], 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// NaN 覆盖回退默认(gain 默认 0.0)。
|
||||||
|
let pod_nan = vec![(
|
||||||
|
"gain".to_string(),
|
||||||
|
oakplugin::node::Value::float(f64::NAN),
|
||||||
|
)];
|
||||||
|
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||||
|
.unwrap();
|
||||||
|
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||||
|
.unwrap();
|
||||||
|
let job = oakplugin::render_driver::RenderJob {
|
||||||
|
time: 0.0,
|
||||||
|
dst: Texture::wrap_frame(dst),
|
||||||
|
src: Some(Texture::wrap_frame(src)),
|
||||||
|
effect_input_id: Some("Source".into()),
|
||||||
|
inputs: Vec::new(),
|
||||||
|
values: pod_nan,
|
||||||
|
renderer: None,
|
||||||
|
clear_destination: false,
|
||||||
|
interactive: false,
|
||||||
|
};
|
||||||
|
oakplugin::render_driver::render_frame(&inst.value, &job)
|
||||||
|
.expect("NaN 覆盖不应失败");
|
||||||
|
assert_eq!(
|
||||||
|
inst.value.params.find("gain").unwrap().get(),
|
||||||
|
oakplugin::param::ParamValue::Double([0.0, 0.0, 0.0], 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
oakplugin::node_factory::unregister_instance(id);
|
||||||
|
Host::global().shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
+351
-18
@@ -20,10 +20,12 @@
|
|||||||
//! is one hook method.
|
//! is one hook method.
|
||||||
//!
|
//!
|
||||||
//! This pass implements the CPU-side, graph-free parts of the hooks:
|
//! This pass implements the CPU-side, graph-free parts of the hooks:
|
||||||
//! frame generation and color transforms run fully; footage decode,
|
//! frame generation and color transforms run fully; plugin jobs
|
||||||
//! shader execution, plugin jobs and the disk frame-cache payload I/O
|
//! dispatch through the executor slot oakplugin installs
|
||||||
//! depend on the oakcodec / oaknode / oakplugin C ABIs and fail with
|
//! ([`set_plugin_executor`]); footage decode, shader execution and the
|
||||||
//! explainable errors (their success-path tests are `#[ignore]`d).
|
//! disk frame-cache payload I/O depend on the oakcodec / oakplugin C
|
||||||
|
//! ABIs and fail with explainable errors (their success-path tests are
|
||||||
|
//! `#[ignore]`d).
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ use oakcodec::decoder::{
|
|||||||
};
|
};
|
||||||
use oakcodec::ffmpeg::FFmpegDecoder;
|
use oakcodec::ffmpeg::FFmpegDecoder;
|
||||||
use oakcore_rs::{PixelFormat, Rational, TimeRange};
|
use oakcore_rs::{PixelFormat, Rational, TimeRange};
|
||||||
|
use oaknode::value::{NodeValue, NodeValueRow, NodeValueTable};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::frame::VideoParamsPod;
|
use crate::frame::VideoParamsPod;
|
||||||
@@ -73,11 +76,22 @@ pub enum JobSpec {
|
|||||||
},
|
},
|
||||||
/// Sample generation (C++ SampleJob).
|
/// Sample generation (C++ SampleJob).
|
||||||
Sample,
|
Sample,
|
||||||
/// OFX plugin job — forwarded to the oakplugin crate C ABI
|
/// OFX plugin job — executed through the registered plugin executor
|
||||||
/// (render never sees OFX types).
|
/// ([`set_plugin_executor`]; the oakplugin crate installs its
|
||||||
|
/// render driver there, so oakrender never sees OFX types).
|
||||||
Plugin {
|
Plugin {
|
||||||
/// OakPluginInstance identity.
|
/// Plugin instance identity (oakplugin instance registry key).
|
||||||
instance: u64,
|
instance: u64,
|
||||||
|
/// Request time in seconds (C++ `PluginJob` time).
|
||||||
|
time: f64,
|
||||||
|
/// Clip name the main source texture arrives on (C++
|
||||||
|
/// `node->get_effect_input_id()`).
|
||||||
|
effect_input_id: Option<String>,
|
||||||
|
/// Clip input textures by clip name (multi-input plugins).
|
||||||
|
inputs: Vec<(String, Texture)>,
|
||||||
|
/// Param overrides: input id -> node value (the tagged values
|
||||||
|
/// captured at evaluation time).
|
||||||
|
values: Vec<(String, NodeValue)>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +103,69 @@ pub struct RenderEvalHooks {
|
|||||||
pub ticket: Option<crate::ticket::TicketId>,
|
pub ticket: Option<crate::ticket::TicketId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin job executor (dependency inversion seam)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// oakrender sits BELOW oakplugin in the dependency graph (oakplugin
|
||||||
|
// depends on oakrender for the texture value types), so the plugin job
|
||||||
|
// execution cannot be a direct call. The oakplugin crate installs its
|
||||||
|
// render driver here at init; `process_plugin_job` dispatches through
|
||||||
|
// the slot. Without an executor, plugin jobs fail explainably (the
|
||||||
|
// pre-wiring behavior).
|
||||||
|
|
||||||
|
/// Plugin job request handed to the registered executor (the C++
|
||||||
|
/// `process_plugin_job(texture, destination, node)` inputs flattened).
|
||||||
|
pub struct PluginJobRequest<'a> {
|
||||||
|
/// The job spec ([`JobSpec::Plugin`] guaranteed by the caller).
|
||||||
|
pub spec: &'a JobSpec,
|
||||||
|
/// The input texture the job runs against.
|
||||||
|
pub src: Texture,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin executor: runs one plugin job and returns the output
|
||||||
|
/// texture. Implemented by the oakplugin crate on top of its render
|
||||||
|
/// driver.
|
||||||
|
pub type PluginExecutor = dyn Fn(&PluginJobRequest<'_>) -> Result<Texture> + Send + Sync;
|
||||||
|
|
||||||
|
static PLUGIN_EXECUTOR: std::sync::OnceLock<std::sync::Mutex<Option<Arc<PluginExecutor>>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
fn executor_slot() -> &'static std::sync::Mutex<Option<Arc<PluginExecutor>>> {
|
||||||
|
PLUGIN_EXECUTOR.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the plugin job executor (oakplugin registration point;
|
||||||
|
/// `None` clears it).
|
||||||
|
pub fn set_plugin_executor(executor: Option<Arc<PluginExecutor>>) {
|
||||||
|
*executor_slot().lock().unwrap_or_else(|e| e.into_inner()) = executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The installed plugin executor, if any.
|
||||||
|
pub fn plugin_executor() -> Option<Arc<PluginExecutor>> {
|
||||||
|
executor_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn purple_frame(time: Rational, size: (i32, i32)) -> Texture {
|
||||||
|
let (w, h) = (size.0.max(1), size.1.max(1));
|
||||||
|
let mut frame = match generate_frame(time, (w, h), PixelFormat::F32) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return Texture::dummy(),
|
||||||
|
};
|
||||||
|
for pixel in frame.data.chunks_exact_mut(16) {
|
||||||
|
for (i, v) in [1.0f32, 0.0, 1.0, 1.0].iter().enumerate() {
|
||||||
|
pixel[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Texture::wrap_frame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
impl RenderEvalHooks {
|
impl RenderEvalHooks {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -184,16 +261,33 @@ impl RenderEvalHooks {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// C++ process_plugin_job (forwarded to oakplugin C ABI).
|
/// C++ process_plugin_job: dispatch through the installed plugin
|
||||||
fn process_plugin_job(&mut self, texture: Texture, spec: &JobSpec) -> Result<Texture> {
|
/// executor (the oakplugin render driver; dependency inversion). A
|
||||||
let JobSpec::Plugin { instance } = spec else {
|
/// missing executor or a failed render yields a purple failure frame
|
||||||
|
/// instead of aborting the graph, matching pluginjob.cpp's fallback.
|
||||||
|
fn process_plugin_job(&mut self, src: Texture, spec: &JobSpec) -> Result<Texture> {
|
||||||
|
let JobSpec::Plugin {
|
||||||
|
instance,
|
||||||
|
time,
|
||||||
|
effect_input_id,
|
||||||
|
inputs,
|
||||||
|
values,
|
||||||
|
} = spec
|
||||||
|
else {
|
||||||
return Err(Error::Invalid);
|
return Err(Error::Invalid);
|
||||||
};
|
};
|
||||||
let _ = instance;
|
let size = src.size();
|
||||||
let _ = texture;
|
let Some(executor) = plugin_executor() else {
|
||||||
Err(Error::Failed(
|
return Ok(purple_frame(Rational::from_double(*time), size));
|
||||||
"plugin jobs deferred: forwarded to the oakplugin crate C ABI".into(),
|
};
|
||||||
))
|
let _ = (instance, effect_input_id, inputs, values);
|
||||||
|
match executor(&PluginJobRequest { spec, src }) {
|
||||||
|
Ok(texture) => Ok(texture),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("plugin instance {instance} render at t={time}s failed: {err:#}");
|
||||||
|
Ok(purple_frame(Rational::from_double(*time), size))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// C++ process_video_cache_job.
|
/// C++ process_video_cache_job.
|
||||||
@@ -206,6 +300,102 @@ impl RenderEvalHooks {
|
|||||||
"disk frame-cache load deferred: oakcodec EXR/JPEG decode pending".into(),
|
"disk frame-cache load deferred: oakcodec EXR/JPEG decode pending".into(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executes the deferred plugin payloads a [`oaknode::nodes::plugin::PluginNode`]
|
||||||
|
/// pushed into its output table (C++ JobEnginePlugin processing in
|
||||||
|
/// jobmanager.cpp): unwraps each [`oaknode::nodes::plugin::PluginJobPayload`]
|
||||||
|
/// box, splits it into input textures and tagged param values, and
|
||||||
|
/// replaces the box with the rendered texture.
|
||||||
|
fn resolve_plugin_jobs(&mut self, table: &mut NodeValueTable) {
|
||||||
|
for (_, value, _) in table.rows_mut() {
|
||||||
|
let NodeValue::Texture(handle) = value else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if handle.ctx.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let payload = unsafe {
|
||||||
|
oaknode::handle::get_checked::<oaknode::nodes::plugin::PluginJobPayload>(handle)
|
||||||
|
}
|
||||||
|
.cloned();
|
||||||
|
let Some(payload) = payload else {
|
||||||
|
// A genuine texture box (e.g. a source node's frame):
|
||||||
|
// not a plugin job, leave it alone.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut inputs: Vec<(String, Texture)> = Vec::new();
|
||||||
|
let mut values: Vec<(String, NodeValue)> = Vec::new();
|
||||||
|
for (key, v) in payload.values.iter() {
|
||||||
|
match v {
|
||||||
|
NodeValue::Texture(h) if !h.ctx.is_null() => {
|
||||||
|
match unsafe { oaknode::handle::get_checked::<Texture>(h) }.cloned() {
|
||||||
|
Some(texture) => inputs.push((key.clone(), texture)),
|
||||||
|
None => eprintln!("plugin job input '{key}' is not a texture box"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeValue::Texture(_) | NodeValue::None => {}
|
||||||
|
other => values.push((key.clone(), other.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback order mirrors pluginrenderer.cpp's effect input
|
||||||
|
// resolution: the declared effect input, else the first
|
||||||
|
// available clip texture.
|
||||||
|
let effect_src = if payload.effect_input_id.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
inputs
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| key == &payload.effect_input_id)
|
||||||
|
.map(|(_, t)| t.clone())
|
||||||
|
};
|
||||||
|
let src = effect_src
|
||||||
|
.or_else(|| inputs.first().map(|(_, t)| t.clone()))
|
||||||
|
.unwrap_or_else(Texture::dummy);
|
||||||
|
|
||||||
|
let spec = JobSpec::Plugin {
|
||||||
|
instance: payload.instance.0,
|
||||||
|
time: payload.time.to_f64(),
|
||||||
|
effect_input_id: if payload.effect_input_id.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(payload.effect_input_id.clone())
|
||||||
|
},
|
||||||
|
inputs,
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
match self.process_plugin_job(src, &spec) {
|
||||||
|
Ok(texture) => {
|
||||||
|
*value = NodeValue::Texture(oaknode::handle::make_owned(texture));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("plugin job resolve failed: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl oaknode::traverser::RenderHooks for RenderEvalHooks {
|
||||||
|
fn use_cache(&self) -> bool {
|
||||||
|
self.use_cache
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cancelled(&self) -> bool {
|
||||||
|
// TODO(phase-6b): poll the ticket's cancellation flag here so
|
||||||
|
// long plugin renders can be interrupted.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(
|
||||||
|
&mut self,
|
||||||
|
_node: oaknode::id::NodeId,
|
||||||
|
_row: &NodeValueRow,
|
||||||
|
table: &mut NodeValueTable,
|
||||||
|
) {
|
||||||
|
self.resolve_plugin_jobs(table);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RenderEvalHooks {
|
impl Default for RenderEvalHooks {
|
||||||
@@ -617,9 +807,6 @@ mod tests {
|
|||||||
.process_video_cache_job(&JobSpec::Cache { path: "p".into() })
|
.process_video_cache_job(&JobSpec::Cache { path: "p".into() })
|
||||||
.is_err());
|
.is_err());
|
||||||
assert!(hooks.process_audio_footage(&JobSpec::Sample).is_err());
|
assert!(hooks.process_audio_footage(&JobSpec::Sample).is_err());
|
||||||
assert!(hooks
|
|
||||||
.process_plugin_job(Texture::dummy(), &JobSpec::Plugin { instance: 1 })
|
|
||||||
.is_err());
|
|
||||||
assert!(hooks
|
assert!(hooks
|
||||||
.process_color_transform(&mut dest, &JobSpec::ColorTransform { processor: 1 })
|
.process_color_transform(&mut dest, &JobSpec::ColorTransform { processor: 1 })
|
||||||
.is_err());
|
.is_err());
|
||||||
@@ -661,6 +848,152 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The plugin executor lives in a process-wide slot; the tests below
|
||||||
|
// mutate it and therefore serialize against each other.
|
||||||
|
static PLUGIN_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
fn plugin_spec() -> JobSpec {
|
||||||
|
JobSpec::Plugin {
|
||||||
|
instance: 7,
|
||||||
|
time: 0.5,
|
||||||
|
effect_input_id: Some("Source".into()),
|
||||||
|
inputs: Vec::new(),
|
||||||
|
values: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_pixel(texture: &Texture) -> [f32; 4] {
|
||||||
|
let Texture::Cpu(frame) = texture else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let mut out = [0f32; 4];
|
||||||
|
for i in 0..4 {
|
||||||
|
out[i] = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_job_without_executor_yields_purple_frame() {
|
||||||
|
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
|
||||||
|
set_plugin_executor(None);
|
||||||
|
let mut hooks = RenderEvalHooks::new();
|
||||||
|
let src = Texture::wrap_frame(
|
||||||
|
generate_frame(Rational::new(0, 1), (4, 2), PixelFormat::F32).unwrap(),
|
||||||
|
);
|
||||||
|
let out = hooks.process_plugin_job(src, &plugin_spec()).unwrap();
|
||||||
|
assert_eq!(out.size(), (4, 2));
|
||||||
|
assert_eq!(first_pixel(&out), [1.0, 0.0, 1.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_job_executor_error_falls_back_to_purple() {
|
||||||
|
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
|
||||||
|
set_plugin_executor(Some(Arc::new(|_req: &PluginJobRequest<'_>| {
|
||||||
|
Err(Error::Failed("boom".into()))
|
||||||
|
})));
|
||||||
|
let mut hooks = RenderEvalHooks::new();
|
||||||
|
let src = Texture::wrap_frame(
|
||||||
|
generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(),
|
||||||
|
);
|
||||||
|
let out = hooks.process_plugin_job(src, &plugin_spec()).unwrap();
|
||||||
|
assert_eq!(first_pixel(&out), [1.0, 0.0, 1.0, 1.0]);
|
||||||
|
set_plugin_executor(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_job_dispatches_through_installed_executor() {
|
||||||
|
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
|
||||||
|
set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
|
||||||
|
// Echo: paint the source size with the instance id.
|
||||||
|
let JobSpec::Plugin { instance, .. } = req.spec else {
|
||||||
|
return Err(Error::Invalid);
|
||||||
|
};
|
||||||
|
let v = (*instance as f32) / 10.0;
|
||||||
|
let mut frame = generate_frame(Rational::new(0, 1), req.src.size(), PixelFormat::F32)?;
|
||||||
|
for pixel in frame.data.chunks_exact_mut(16) {
|
||||||
|
for c in 0..4 {
|
||||||
|
pixel[c * 4..c * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Texture::wrap_frame(frame))
|
||||||
|
})));
|
||||||
|
let mut hooks = RenderEvalHooks::new();
|
||||||
|
let src = Texture::wrap_frame(
|
||||||
|
generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(),
|
||||||
|
);
|
||||||
|
let out = hooks.process_plugin_job(src, &plugin_spec()).unwrap();
|
||||||
|
assert_eq!(first_pixel(&out), [0.7, 0.7, 0.7, 0.7]);
|
||||||
|
set_plugin_executor(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_executes_payload_box_and_keeps_plain_textures() {
|
||||||
|
use oaknode::nodes::plugin::{PluginInstanceHandle, PluginJobPayload};
|
||||||
|
|
||||||
|
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
|
||||||
|
set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
|
||||||
|
let JobSpec::Plugin {
|
||||||
|
instance,
|
||||||
|
values,
|
||||||
|
inputs,
|
||||||
|
effect_input_id,
|
||||||
|
..
|
||||||
|
} = req.spec
|
||||||
|
else {
|
||||||
|
return Err(Error::Invalid);
|
||||||
|
};
|
||||||
|
// The resolve seam must deliver the tagged param values and
|
||||||
|
// the clip texture to the executor.
|
||||||
|
assert_eq!(*instance, 7);
|
||||||
|
assert_eq!(effect_input_id.as_deref(), Some("Source"));
|
||||||
|
assert_eq!(inputs.len(), 1);
|
||||||
|
assert_eq!(inputs[0].0, "Source");
|
||||||
|
assert!(values.iter().any(|(k, v)| {
|
||||||
|
k == "gain" && matches!(v, NodeValue::Float(f) if (*f - 0.25).abs() < 1e-6)
|
||||||
|
}));
|
||||||
|
let mut frame = generate_frame(Rational::new(0, 1), req.src.size(), PixelFormat::F32)?;
|
||||||
|
for pixel in frame.data.chunks_exact_mut(16) {
|
||||||
|
for (i, v) in [0.25f32, 0.5, 0.75, 1.0].iter().enumerate() {
|
||||||
|
pixel[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Texture::wrap_frame(frame))
|
||||||
|
})));
|
||||||
|
|
||||||
|
// A real source texture box plus a payload box referencing it.
|
||||||
|
let src_frame = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap();
|
||||||
|
let src_box = oaknode::handle::make_owned(Texture::wrap_frame(src_frame));
|
||||||
|
let mut values = NodeValueRow::new();
|
||||||
|
values.insert("Source".into(), NodeValue::Texture(src_box));
|
||||||
|
values.insert("gain".into(), NodeValue::Float(0.25));
|
||||||
|
let payload = PluginJobPayload {
|
||||||
|
instance: PluginInstanceHandle(7),
|
||||||
|
time: Rational::new(1, 2),
|
||||||
|
effect_input_id: "Source".into(),
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut table = NodeValueTable::default();
|
||||||
|
table.push(
|
||||||
|
oaknode::value::ValueType::Texture,
|
||||||
|
NodeValue::Texture(oaknode::handle::make_owned(payload)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hooks = RenderEvalHooks::new();
|
||||||
|
hooks.resolve_plugin_jobs(&mut table);
|
||||||
|
|
||||||
|
let NodeValue::Texture(handle) = table.get(oaknode::value::ValueType::Texture).unwrap()
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let rendered = unsafe { oaknode::handle::get_checked::<Texture>(handle) }
|
||||||
|
.expect("payload box must be replaced by the rendered texture");
|
||||||
|
assert_eq!(first_pixel(rendered), [0.25, 0.5, 0.75, 1.0]);
|
||||||
|
set_plugin_executor(None);
|
||||||
|
}
|
||||||
|
|
||||||
/// Stand-in context for the "GPU destination" test (never used for
|
/// Stand-in context for the "GPU destination" test (never used for
|
||||||
/// real GPU work).
|
/// real GPU work).
|
||||||
struct UnusedCtx;
|
struct UnusedCtx;
|
||||||
|
|||||||
@@ -375,6 +375,18 @@ pub fn command_is_done(row: i64, out_value: *mut c_int) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user-visible label of the row at `row` (the safe twin of the
|
||||||
|
/// two-stage [`command_text`]; the history panel's row query).
|
||||||
|
pub fn command_name(row: i64) -> Result<String> {
|
||||||
|
with_stack(|s| s.command_name(row).map(|n| n.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the row at `row` is done (the safe twin of
|
||||||
|
/// [`command_is_done`]; the history panel's gray-row query).
|
||||||
|
pub fn command_done(row: i64) -> Result<bool> {
|
||||||
|
with_stack(|s| s.command_is_done(row))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -497,4 +509,32 @@ mod tests {
|
|||||||
|
|
||||||
assert!(clear().is_ok());
|
assert!(clear().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The safe row getters answer like the C-ABI twins: labels survive an
|
||||||
|
/// undo (undone rows stay labeled) and `command_done` flips with the
|
||||||
|
/// stack pointer.
|
||||||
|
#[test]
|
||||||
|
fn value_command_name_and_done() {
|
||||||
|
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
assert!(clear().is_ok());
|
||||||
|
|
||||||
|
let cmd = UndoCommand::from_closures(|| {}, || {});
|
||||||
|
push(cmd, "alpha").unwrap();
|
||||||
|
assert_eq!(count().unwrap(), 2);
|
||||||
|
assert_eq!(command_name(1).unwrap(), "alpha");
|
||||||
|
assert!(command_done(1).unwrap());
|
||||||
|
assert_eq!(index().unwrap(), 2);
|
||||||
|
|
||||||
|
// Undo keeps the row labeled but marks it undone.
|
||||||
|
undo().unwrap();
|
||||||
|
assert_eq!(command_name(1).unwrap(), "alpha");
|
||||||
|
assert!(!command_done(1).unwrap());
|
||||||
|
assert_eq!(index().unwrap(), 1);
|
||||||
|
|
||||||
|
// Out-of-range rows error, they never panic.
|
||||||
|
assert!(command_name(2).is_err());
|
||||||
|
assert!(command_done(-1).is_err());
|
||||||
|
|
||||||
|
assert!(clear().is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user