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

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

End-to-end tests cover registration, param translation, CPU render
pixel assertions, identity passthrough and NaN fallback.
This commit is contained in:
2026-08-18 17:15:13 +08:00
parent 9daa266189
commit 2db1615453
21 changed files with 2595 additions and 189 deletions
+107 -3
View File
@@ -16,14 +16,29 @@
//! The node type registry (C++ `NodeFactory` / `node/factory`): //! 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`].
+42
View File
@@ -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) }
}
+1 -1
View File
@@ -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
View File
@@ -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]
+30 -16
View File
@@ -126,34 +126,48 @@ 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)
.get(ValueType::Float) == Some(ValueType::Texture)
.or_else(|| from_table.get(ValueType::Int)) {
.or_else(|| from_table.get(ValueType::Color)) from_table
.or_else(|| from_table.get(ValueType::Vec2)) .get(ValueType::Texture)
.or_else(|| from_table.get(ValueType::Vec3)) .cloned()
.or_else(|| from_table.get(ValueType::Vec4)) .unwrap_or(NodeValue::None)
.or_else(|| from_table.get(ValueType::Boolean)) } else {
.or_else(|| from_table.get(ValueType::Rational)) from_table
.or_else(|| from_table.get(ValueType::Text)) .get(ValueType::Float)
.or_else(|| from_table.get(ValueType::Combo)) .or_else(|| from_table.get(ValueType::Int))
.or_else(|| from_table.get(ValueType::StrCombo)) .or_else(|| from_table.get(ValueType::Color))
.cloned() .or_else(|| from_table.get(ValueType::Vec2))
.unwrap_or(NodeValue::None); .or_else(|| from_table.get(ValueType::Vec3))
.or_else(|| from_table.get(ValueType::Vec4))
.or_else(|| from_table.get(ValueType::Boolean))
.or_else(|| from_table.get(ValueType::Rational))
.or_else(|| from_table.get(ValueType::Text))
.or_else(|| from_table.get(ValueType::Combo))
.or_else(|| from_table.get(ValueType::StrCombo))
.or_else(|| from_table.get(ValueType::Texture))
.cloned()
.unwrap_or(NodeValue::None)
};
row.insert(input_id, value); 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
+6
View File
@@ -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`
+61
View File
@@ -221,6 +221,67 @@ src/
能力。修复:`init_descriptor_props` 补齐预定义(默认 能力。修复:`init_descriptor_props` 补齐预定义(默认
"false"/空数组/None)。 "false"/空数组/None)。
## 阶段 6aOpenFX 引擎接线(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` 保持恒 0GL 插件经 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 清洗为 0oliveclip.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
View File
@@ -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 清洗为 0oliveclip.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 清洗为 0oliveclip.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 = 0xC0000.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.000061042^-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());
}
}
+68
View File
@@ -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 互操作的评估。
//!
//! ## 方案 Awgpu-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 为 CGLOpenGL 自 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)
//!
//! 实现方案 BCGL 上下文工厂 + share group、`texture_id` 真值化、
//! 回读→上传流水线,以及一个真实 GL 插件的端到端验证(golden 帧比
//! 较)。在此之前,[`crate::render::texture_id`] 保持恒 0 桩。
+10 -1
View File
@@ -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(
+2
View File
@@ -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;
+964
View File
@@ -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_canonicalplugin.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/未知 → invalidC++ 末尾
// return QVariant())。
_ => None,
}
}
/// 每插件的默认值缓存(C++ g_plugin_param_defaultsplugin.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 &params.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";
}
}
// 规则 3display 范围显著越出 [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);
}
}
+10
View File
@@ -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";
/// kOfxParamCoordinatesNormalisedofxParam.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";
/// kOfxParamPageSkipRowpage 子项哨兵,ofxParam.h:178)。
pub(crate) const PAGE_SKIP_ROW: &str = "OfxParamPageSkipRow";
/// kOfxParamPageSkipColumnpage 子项哨兵,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。
+82 -5
View File
@@ -17,13 +17,57 @@
//! 进度上报(Progress suite 的宿主侧)。 //! 进度上报(Progress suite 的宿主侧)。
//! //!
//! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的 //! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的
//! 回调出 crateM9 的 facade 回调模式,不设全局状态)。 //! 回调出 crateM9 的 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,7 +140,19 @@ impl ProgressReporter {
} }
!abort !abort
} }
None => true, 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,
}
}
} }
} }
+2 -1
View File
@@ -129,7 +129,8 @@ pub fn texture_create(
/// Rust 等价物。恒 0GL suite 的 `OpenGLTextureIndex` 属性与 /// Rust 等价物。恒 0GL 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
} }
+61 -19
View File
@@ -133,13 +133,15 @@ pub fn end_sequence(
/// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。 /// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。
/// ///
/// 返回各输入 clip 的 RoIclip 名 → 矩形;Phase 2 供测试断言, /// 返回装配完成的输出纹理与各输入 clip 的 RoIclip 名 → 矩形;
/// 宿主不据此裁剪输入——输入由 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-1444OlivePluginInstance 非 // 1. 实例锁(pluginrenderer.cpp:1436-1444OlivePluginInstance 非
@@ -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/RoDpluginrenderer.cpp:1595-1603x2 = 宽 × PAR)。 // 规范坐标的 RoI/RoDpluginrenderer.cpp:1595-1603x2 = 宽 × 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. 输入 clipRoD 与格式(pluginrenderer.cpp:1627-1665Phase 2 // 6. 输入 clipRoD 与格式(pluginrenderer.cpp:1627-1665Phase 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) => {
ctx.upload(*token, &frame) f.data = frame.data;
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?; }
// GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写。
Texture::Gpu { token, ctx, .. } => {
ctx.upload(*token, &frame)
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
}
} }
Ok(()) Ok(())
} }
+35 -7
View File
@@ -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:括号起点,OKlabel/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:括号起点;携 labelv2 另携 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 {
+53 -6
View File
@@ -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;
} }
// 渲染上下文缺省 → 0headless 默认)。 // 渲染上下文 → 活动 viewer → 0headless 默认)。
*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
+372
View File
@@ -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 应建出插件节点");
// gainDouble → 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"));
// modeChoice → 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()]);
// debugsecret → hidden。
let debug = core.get_input("debug").expect("debug 输入");
assert!(
debug.flags & oaknode::input::flags::HIDDEN != 0,
"secret 参数应隐藏"
);
// labelString → 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.5alpha=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
View File
@@ -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;
+40
View File
@@ -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());
}
} }