diff --git a/crates/oaknode/src/factory.rs b/crates/oaknode/src/factory.rs index 5d2b6fb53..eb3189d3d 100644 --- a/crates/oaknode/src/factory.rs +++ b/crates/oaknode/src/factory.rs @@ -16,14 +16,29 @@ //! The node type registry (C++ `NodeFactory` / `node/factory`): //! type id -> constructor, plus menu metadata. +//! +//! Two entry kinds: the static built-in table installed by +//! [`crate::nodes::register_all`] (compile-time [`NodeMeta`]), and +//! runtime entries appended after plugin discovery +//! ([`DynamicNodeMeta`] — the C++ `NodeFactory::register_plugin_nodes` +//! appends one library entry per discovered OFX plugin; those have no +//! compile-time ids, so their metadata is owned and their constructor +//! is a closure capturing the plugin identifier). -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock}; use crate::node::{Category, NodeBehavior, NodeCore}; /// Constructor for a node type: behavior + default core inputs. pub type NodeConstructor = fn() -> (NodeCore, Box); +/// 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 (NodeCore, Box) + Send + Sync>; + /// Static metadata for the node menu (C++ factory listing). #[derive(Clone)] pub struct NodeMeta { @@ -37,9 +52,30 @@ pub struct NodeMeta { pub create: NodeConstructor, } -/// The registry (built at crate init by `nodes::register_all`). +/// Runtime-registered node type metadata (the C++ factory's plugin +/// entries). Owned strings: plugin identifiers and labels are only +/// known at scan time. +#[derive(Clone)] +pub struct DynamicNodeMeta { + /// Type id (the OFX plugin identifier). + pub type_id: String, + /// Display name (the plugin descriptor's label). + pub name: String, + /// Categories. + pub categories: Vec, + /// Sub-category (the OFX context display name, e.g. "Filter"). + pub sub_category: String, + /// Description (the plugin descriptor's description). + pub description: String, + /// Constructor. + pub create: DynNodeConstructor, +} + +/// The registry (built at crate init by `nodes::register_all`; plugin +/// entries are appended at runtime via [`Factory::register_dynamic`]). pub struct Factory { entries: Vec, + dynamic: Mutex>, } impl Factory { @@ -53,7 +89,10 @@ impl Factory { .get() .expect("nodes::register_all installs the entry table") .clone(); - Factory { entries } + Factory { + entries, + dynamic: Mutex::new(Vec::new()), + } }) } @@ -66,6 +105,71 @@ impl Factory { pub fn entries(&self) -> &[NodeMeta] { &self.entries } + + fn dynamic(&self) -> std::sync::MutexGuard<'_, Vec> { + 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 { + self.dynamic().clone() + } + + /// Look up a runtime entry by type id. + pub fn find_dynamic(&self, type_id: &str) -> Option { + 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)> { + 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 { + 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 { + 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`]. diff --git a/crates/oaknode/src/handle.rs b/crates/oaknode/src/handle.rs index 0559eab20..bcab04732 100644 --- a/crates/oaknode/src/handle.rs +++ b/crates/oaknode/src/handle.rs @@ -37,13 +37,32 @@ use std::sync::atomic::{AtomicU32, Ordering}; pub const OAKNODE_ABI_VERSION: u32 = 1; /// Heap box behind a handle's `ctx`. +/// +/// `repr(C)`: the field order is the stable prefix layout +/// [`RefBoxHeader`] relies on for type discrimination across boxes of +/// different payloads sharing one handle channel (the render seam +/// downcasts plugin-job boxes vs texture boxes). +#[repr(C)] pub struct RefBox { /// Atomic reference count. pub refs: AtomicU32, + /// Type identity of the boxed value (stamped by [`make_owned`]; + /// [`get_checked`] compares it before reading [`RefBox::value`]). + pub type_id: std::any::TypeId, /// Boxed value. pub value: T, } +/// The fixed-size prefix of every [`RefBox`] (layout-stable across +/// payload types because [`RefBox`] is `repr(C)`). +#[repr(C)] +struct RefBoxHeader { + /// Mirror of [`RefBox::refs`] (present for the layout prefix; + /// never read here). + _refs: AtomicU32, + type_id: std::any::TypeId, +} + /// The shared ABI value-handle type (single-lib unification, see /// `docs/zh/plans/riir/single-lib.md`): one canonical /// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported @@ -79,6 +98,7 @@ unsafe extern "C" fn refbox_release_owned(ctx: *mut std::ffi::c_v pub fn make_owned(value: T) -> CHandle { let rb = Box::into_raw(Box::new(RefBox { refs: AtomicU32::new(1), + type_id: std::any::TypeId::of::(), value, })); CHandle { @@ -98,6 +118,7 @@ pub fn make_owned_with( ) -> CHandle { let rb = Box::into_raw(Box::new(RefBox { refs: AtomicU32::new(1), + type_id: std::any::TypeId::of::(), value, })); CHandle { @@ -118,3 +139,24 @@ pub unsafe fn get(h: &CHandle) -> Option<&T> { } unsafe { Some(&(*(h.ctx as *const RefBox)).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(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::() { + return None; + } + unsafe { Some(&(*(h.ctx as *const RefBox)).value) } +} diff --git a/crates/oaknode/src/nodes/mod.rs b/crates/oaknode/src/nodes/mod.rs index b08e376af..eada687a6 100644 --- a/crates/oaknode/src/nodes/mod.rs +++ b/crates/oaknode/src/nodes/mod.rs @@ -44,7 +44,7 @@ mod ociogradingtransformlog; mod ociolut; mod opacity; mod pan; -mod plugin; +pub mod plugin; mod polygon; mod rippledistortnode; mod shapenode; diff --git a/crates/oaknode/src/nodes/plugin.rs b/crates/oaknode/src/nodes/plugin.rs index 0b0b4c384..990b6cb38 100644 --- a/crates/oaknode/src/nodes/plugin.rs +++ b/crates/oaknode/src/nodes/plugin.rs @@ -14,20 +14,25 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! OpenFX plugin node (C++ `src/node/src/plugins/plugin.{h,cpp}`, +//! OpenFX plugin node (C++ `engine/node/plugins/plugin.{h,cpp}`, //! `olive::plugin::PluginNode`). //! -//! DECLARATION ONLY. This node is a thin wrapper over an OFX plugin -//! instance that lives behind the `oakplugin` crate's C ABI bridge -//! (opaque oakrender handles); no OFX types (`OFX::Host::ImageEffect::Instance`, -//! `kOfxParam*`, ...) are declared here. The plugin instance is -//! represented as the opaque [`PluginInstanceHandle`] below — the real -//! definition belongs to the oakplugin bridge module and this draft -//! stands in for it. +//! Thin wrapper over an OFX plugin instance that lives in the +//! `oakplugin` crate; no OFX types (`OFX::Host::ImageEffect::Instance`, +//! `kOfxParam*`, ...) are declared here because oaknode sits below +//! oakplugin in the dependency graph. The instance is represented as +//! the opaque [`PluginInstanceHandle`] — an identity key into the +//! oakplugin crate's instance registry. //! //! All per-plugin data (inputs, defaults, properties, labels) is -//! discovered at runtime from the plugin descriptor through the -//! bridge, mirroring the C++ constructor. +//! discovered at runtime from the plugin descriptor by the oakplugin +//! discovery pass (`oakplugin::node_factory`), which builds the +//! [`NodeCore`] inputs, constructs [`PluginNode`]s and registers one +//! factory entry per discovered plugin (the C++ +//! `factory.cpp::register_plugin_nodes` + `PluginNode::PluginNode` +//! split across the dependency seam). + +use std::sync::{Arc, Mutex, OnceLock}; use crate::factory::NodeMeta; use crate::node::{Category, NodeBehavior, NodeCore}; @@ -36,8 +41,9 @@ use oakcore_rs::{Rational, TimeRange}; /// Texture input id (C++ `plugin::k_texture_input`). Type: texture; /// no default. Only synthesized when the plugin declares clip inputs -/// but none of them is the simple-source clip (see [`create`]); then -/// it becomes the node's effect input with display name "Texture". +/// but none of them is the simple-source clip (see the discovery +/// pass); then it becomes the node's effect input with display name +/// "Texture". pub const TEXTURE_INPUT: &str = "tex_in"; /// OFX simple-source clip name (C++ `kOfxImageEffectSimpleSourceClipName`): @@ -45,9 +51,8 @@ pub const TEXTURE_INPUT: &str = "tex_in"; pub const SOURCE_CLIP: &str = "Source"; /// Opaque handle to an OFX plugin instance owned by the `oakplugin` -/// crate C ABI bridge (C++ `OFX::Host::ImageEffect::Instance *`, -/// member `plugin_instance_`). Placeholder type for this draft — the -/// bridge's real handle type replaces it. +/// crate (identity key into its instance registry; C++ +/// `OFX::Host::ImageEffect::Instance *`, member `plugin_instance_`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct PluginInstanceHandle(pub u64); @@ -64,6 +69,27 @@ impl PluginInstanceHandle { } } +/// Plugin job payload (C++ `plugin::PluginJob`): everything the render +/// seam needs to run the instance against the input texture. Boxed +/// into a texture-typed [`NodeValue`] by [`PluginNode::value`] and +/// resolved by the oakrender evaluation seam (C++ +/// `RenderProcessor::process_plugin_job`), which executes it through +/// the oakplugin render driver. +#[derive(Clone, Debug)] +pub struct PluginJobPayload { + /// The instance identity (oakplugin registry key). + pub instance: PluginInstanceHandle, + /// The request time (C++ `globals.time().in()`). + pub time: Rational, + /// The effect input id the main source texture arrives on (C++ + /// `node->get_effect_input_id()`). + pub effect_input_id: String, + /// Snapshot of the full input row (C++ `PluginJob` holds the + /// `NodeValueRow`: the tagged non-texture values become param + /// overrides, the texture values feed the clip inputs). + pub values: NodeValueRow, +} + /// OFX plugin node. Wraps one plugin instance; inputs mirror the /// plugin's declared params and clips. /// @@ -71,10 +97,10 @@ impl PluginInstanceHandle { /// pointer, `sub_category_`. Because `name()`/`id()`/`description()` /// read the plugin descriptor at call time in C++ and the Rust trait /// returns `&str`, this port caches those strings on the struct -/// (populated from the bridge at construction). +/// (populated from the descriptor at construction). pub struct PluginNode { /// The wrapped plugin instance (C++ `plugin_instance_`; an - /// opaque bridge handle here). + /// oakplugin registry identity here). instance: PluginInstanceHandle, /// Sub-category derived from the OFX context (C++ /// `sub_category_`): "Filter", "Generator", "Transition", or @@ -90,7 +116,51 @@ pub struct PluginNode { description: String, } +/// The plugin-node duplicator (installed by the oakplugin crate): old +/// instance identity -> fresh instance identity (the C++ +/// `PluginNode::copy()` creates a fresh instance — filter context +/// preferred, else the plugin's first declared context). `None` when +/// instance creation fails. +type PluginDuplicator = dyn Fn(PluginInstanceHandle) -> Option + Send + Sync; + +static DUPLICATOR: OnceLock>>> = OnceLock::new(); + +fn duplicator_slot() -> &'static Mutex>> { + 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>) { + *duplicator_slot().lock().unwrap_or_else(|e| e.into_inner()) = dup; +} + impl PluginNode { + /// Constructor for the oakplugin discovery path (the C++ + /// `PluginNode::PluginNode(instance)`; the input walk happens in + /// the oakplugin translation pass, which owns the OFX types). + pub fn new( + instance: PluginInstanceHandle, + name: String, + type_id: String, + description: String, + sub_category: String, + ) -> Self { + PluginNode { + instance, + sub_category, + name, + type_id, + description, + } + } + + /// The wrapped instance identity (oakplugin registry key). + pub fn instance_handle(&self) -> PluginInstanceHandle { + self.instance + } + /// Forward a push-button param activation to the plugin (C++ /// `push_button_clicked()`; currently a no-op upstream). pub fn push_button_clicked(&mut self, core: &mut NodeCore, name: String) { @@ -133,12 +203,9 @@ impl NodeBehavior for PluginNode { /// the value to the matching OFX param); then resolves the input /// texture — simple-source clip first, then `tex_in`, then the /// first texture-typed input — and, when both texture and plugin - /// instance exist, pushes the texture converted to a plugin job - /// bound to this node and the request time. - /// - /// The Rust model has no plugin-job payload: the job case pushes a - /// null texture handle marking a renderer-deferred plugin job - /// (`// CPP-PARITY: plugin.cpp` `value()`). + /// instance exist, pushes a [`PluginJobPayload`] boxed into a + /// texture-typed value (the C++ `table->push(k_texture, + /// tex->to_job(job), this)`; the render seam executes the job). fn value( &self, core: &NodeCore, @@ -146,8 +213,6 @@ impl NodeBehavior for PluginNode { time: Rational, table: &mut NodeValueTable, ) { - let _ = (core, time); - // Re-push every non-texture, non-none input value, tagged with // its input id. for (id, v) in inputs.iter() { @@ -170,11 +235,15 @@ impl NodeBehavior for PluginNode { .or_else(|| inputs.values().find(|v| matches!(v, NodeValue::Texture(_)))); if tex.is_some() && !self.instance.is_null() { - // C++ `table->push(NodeValue::k_texture, tex->to_job(job), - // this)` — a deferred plugin job; no job payload here. + let payload = PluginJobPayload { + instance: self.instance, + time, + effect_input_id: core.effect_input.clone(), + values: inputs.clone(), + }; table.push( crate::value::ValueType::Texture, - NodeValue::Texture(crate::handle::CHandle::null()), + NodeValue::Texture(crate::handle::make_owned(payload)), None, ); } @@ -255,16 +324,29 @@ impl NodeBehavior for PluginNode { let _ = (core, frame, time); } - /// Deep copy (C++ `copy()`): asks the bridge to create a fresh - /// plugin instance — filter context when supported, else the - /// plugin's first declared context — and wraps it in a new node; - /// `None` when there is no instance or instance creation fails. - /// - /// This crate's bridge has no instance-creation call, so a plugin - /// node can never be duplicated here and `None` is always returned - /// (`// CPP-PARITY: plugin.cpp` `copy()`). + /// Deep copy (C++ `copy()`): asks the oakplugin side (through the + /// installed [`set_plugin_duplicator`] hook) for a fresh plugin + /// instance — filter context when supported, else the plugin's + /// first declared context — and wraps it in a new behavior; the + /// caller clones the core (inputs included), matching the C++ + /// `Node::copy` split. `None` when there is no instance or no + /// duplicator installed. fn duplicate(&self, _core: &NodeCore) -> Option> { - None + if self.instance.is_null() { + return None; + } + let dup = duplicator_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()?; + let new_handle = dup(self.instance)?; + Some(Box::new(PluginNode { + instance: new_handle, + sub_category: self.sub_category.clone(), + name: self.name.clone(), + type_id: self.type_id.clone(), + description: self.description.clone(), + })) } /// Downcast to [`Self`] (instance/metadata access). @@ -278,48 +360,12 @@ impl NodeBehavior for PluginNode { } } -/// Constructor (C++ `PluginNode::PluginNode(instance)`): stores the -/// instance handle and derives the sub-category from the OFX context -/// (filter/generator/transition, else "General"). Then walks the -/// plugin's params through the bridge: -/// -/// - group params seed a group-label map; page params seed a -/// page-label map and a param->page-label map (skipping -/// skip-row/skip-column sentinels); -/// - each value param becomes an input: int/choice -> int/combo, -/// double -> float, boolean -> boolean, string -> text, -/// RGB/RGBA -> color, 2D/3D double/int -> vec2/vec3, -/// str-choice -> str-combo, bytes/custom -> binary, push-button -> -/// push-button; group/page and unknown types are skipped; -/// - defaults come from a per-plugin-id cache built from the OFX -/// `kOfxParamPropDefault` properties (normalised-coordinate doubles -/// are converted to canonical pixels against the project extent); -/// a non-null default is added as the input default and set as the -/// standard value (except for push-buttons); -/// - secret params get the hidden flag; the param label (or name) -/// becomes the input display name; parent groups set the `ui_group` -/// property, pages the `ui_page` property; -/// - color inputs get a `color_semantic` property ("color"/"scalar", -/// deduced from label/hint/display-range/default/group heuristics), -/// `min`/`max` from the display range, and a `tooltip` from the -/// hint; -/// - combo inputs get combo-box strings from the choice options -/// (ordered by the choice-order property when present), and -/// str-combos additionally a `combo_value_str` property. -/// -/// Finally every non-output clip becomes a texture input named from -/// its label ("Source"/"From"/"To" for the well-known clips), and the -/// effect input is set: the simple-source clip if present, else -/// `tex_in` if present, else a synthesized `tex_in` texture input -/// (display name "Texture") when the plugin has any clip input at -/// all. -/// -/// The plugin-instance bridge is not wired in this crate, so the -/// descriptor walk cannot run here: a placeholder node with a null -/// instance and empty cached metadata is returned. Real construction -/// belongs to the oakplugin bridge's discovery pass, which registers -/// one `PluginNode` per discovered plugin (`// CPP-PARITY: plugin.cpp` -/// `PluginNode::PluginNode`). +/// Constructor (C++ `PluginNode::PluginNode(instance)`): the real +/// construction (instance creation + the OFX param/clip -> input +/// translation) belongs to the oakplugin discovery pass +/// (`oakplugin::node_factory`), which registers one factory entry per +/// discovered plugin. This placeholder (null instance, empty cached +/// metadata) stays for the static-registration surface and tests. pub fn create() -> (NodeCore, Box) { let core = NodeCore::new(); let node = PluginNode { @@ -333,12 +379,12 @@ pub fn create() -> (NodeCore, Box) { } /// Register this node type. NOTE: unlike built-in nodes, plugin nodes -/// have no static type id — the C++ factory appends one `PluginNode` -/// per discovered OFX plugin at runtime -/// (`factory.cpp::add_plugins_to_library`), keyed by plugin +/// have no static type id — the oakplugin discovery pass appends one +/// factory entry per discovered OFX plugin at runtime (C++ +/// `factory.cpp::register_plugin_nodes`) through +/// [`crate::factory::Factory::register_dynamic`], keyed by plugin /// identifier, so there is no fixed `NodeMeta` literal to push. This -/// function is a no-op placeholder; real registration belongs to the -/// oakplugin bridge's discovery pass. +/// function is a no-op placeholder kept for the static table. pub fn register(meta: &mut Vec) { let _ = meta; } @@ -386,7 +432,8 @@ mod tests { row.insert("mode".to_string(), NodeValue::Combo(2)); let mut table = NodeValueTable::default(); n.value(&core, &row, Rational::new(0, 1), &mut table); - // Both values pushed, tagged with their input ids. + // Both values pushed, tagged with their input ids, plus the + // plugin job texture (no texture input in the row -> no job). let tagged: Vec<(&str, &NodeValue)> = table .rows() .iter() @@ -405,24 +452,38 @@ mod tests { } #[test] - fn value_skips_texture_and_none_inputs() { + fn value_pushes_job_payload_for_texture_input() { let n = node(); - let core = NodeCore::new(); + let mut core = NodeCore::new(); + core.effect_input = TEXTURE_INPUT.to_string(); let mut row = NodeValueRow::default(); row.insert( "tex_in".to_string(), NodeValue::Texture(crate::handle::CHandle::null()), ); + row.insert("gain".to_string(), NodeValue::Float(0.25)); row.insert("none_in".to_string(), NodeValue::None); let mut table = NodeValueTable::default(); - n.value(&core, &row, Rational::new(0, 1), &mut table); - // The texture is consumed as the job source; nothing else is pushed - // (none values are skipped; the plugin job is the texture push). - assert_eq!(table.count(), 1); - assert!(matches!( - table.get(ValueType::Texture), - Some(NodeValue::Texture(h)) if h.is_null() - )); + let time = Rational::new(3, 2); + n.value(&core, &row, time, &mut table); + // The texture is consumed as the job source: one tagged param + // value + the boxed plugin job payload. + assert_eq!(table.count(), 2); + let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else { + panic!("expected the plugin job texture"); + }; + // SAFETY: the handle was created by value() boxing a + // PluginJobPayload. + let payload = unsafe { crate::handle::get::(h) } + .expect("texture handle must box a PluginJobPayload"); + assert_eq!(payload.instance, PluginInstanceHandle(1)); + assert_eq!(payload.time, time); + assert_eq!(payload.effect_input_id, TEXTURE_INPUT); + assert_eq!(payload.values.len(), 3); + assert_eq!( + payload.values.get("gain"), + Some(&NodeValue::Float(0.25)) + ); } #[test] @@ -440,10 +501,7 @@ mod tests { ); let mut table = NodeValueTable::default(); n.value(&core, &row, Rational::new(0, 1), &mut table); - assert!(matches!( - table.get(ValueType::Texture), - Some(NodeValue::Texture(h)) if h.is_null() - )); + assert!(matches!(table.get(ValueType::Texture), Some(NodeValue::Texture(_)))); } #[test] @@ -532,9 +590,26 @@ mod tests { } #[test] - fn duplicate_returns_none() { + fn duplicate_uses_installed_duplicator() { + // No duplicator installed -> None. + set_plugin_duplicator(None); let n = node(); assert!(n.duplicate(&NodeCore::new()).is_none()); + + // Installed duplicator: fresh handle, metadata copied. + set_plugin_duplicator(Some(Arc::new(|h: PluginInstanceHandle| { + Some(PluginInstanceHandle(h.0 + 100)) + }))); + let dup = n.duplicate(&NodeCore::new()).expect("duplicator installed"); + let dup = dup.as_any().unwrap().downcast_ref::().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] diff --git a/crates/oaknode/src/traverser.rs b/crates/oaknode/src/traverser.rs index 0e1e0d07b..75ec4b73e 100644 --- a/crates/oaknode/src/traverser.rs +++ b/crates/oaknode/src/traverser.rs @@ -126,34 +126,48 @@ impl Traverser { return Err(Error::State); } + let entry = graph.get(node).ok_or(Error::NotFound)?; + // Build this node's input row from its upstream outputs. The // C++ picks the last value of the matching type per input; - // the Rust model keys rows by input id. + // the Rust model keys rows by input id. Inputs declared as + // texture take the upstream texture directly (the scalar + // chain below would otherwise hand a plugin node's tagged + // param passthrough to a downstream clip input). let mut row: NodeValueRow = std::collections::BTreeMap::new(); for (from, input_id, element) in graph.input_connections(node) { let _ = element; if let Some(from_table) = tables.get(&from) { - let value = from_table - .get(ValueType::Float) - .or_else(|| from_table.get(ValueType::Int)) - .or_else(|| from_table.get(ValueType::Color)) - .or_else(|| from_table.get(ValueType::Vec2)) - .or_else(|| from_table.get(ValueType::Vec3)) - .or_else(|| from_table.get(ValueType::Vec4)) - .or_else(|| from_table.get(ValueType::Boolean)) - .or_else(|| from_table.get(ValueType::Rational)) - .or_else(|| from_table.get(ValueType::Text)) - .or_else(|| from_table.get(ValueType::Combo)) - .or_else(|| from_table.get(ValueType::StrCombo)) - .cloned() - .unwrap_or(NodeValue::None); + let value = if entry.core.input_data_type(&input_id) + == Some(ValueType::Texture) + { + from_table + .get(ValueType::Texture) + .cloned() + .unwrap_or(NodeValue::None) + } else { + from_table + .get(ValueType::Float) + .or_else(|| from_table.get(ValueType::Int)) + .or_else(|| from_table.get(ValueType::Color)) + .or_else(|| from_table.get(ValueType::Vec2)) + .or_else(|| from_table.get(ValueType::Vec3)) + .or_else(|| from_table.get(ValueType::Vec4)) + .or_else(|| from_table.get(ValueType::Boolean)) + .or_else(|| from_table.get(ValueType::Rational)) + .or_else(|| from_table.get(ValueType::Text)) + .or_else(|| from_table.get(ValueType::Combo)) + .or_else(|| from_table.get(ValueType::StrCombo)) + .or_else(|| from_table.get(ValueType::Texture)) + .cloned() + .unwrap_or(NodeValue::None) + }; row.insert(input_id, value); } } // Evaluate the node's behavior into its output table. let mut table = NodeValueTable::default(); - let entry = graph.get(node).ok_or(Error::NotFound)?; // The behavior writes outputs; the default no-op leaves the // table empty (C++ `Node::value` default). entry diff --git a/crates/oaknode/src/value.rs b/crates/oaknode/src/value.rs index 289b63897..3f45d19af 100644 --- a/crates/oaknode/src/value.rs +++ b/crates/oaknode/src/value.rs @@ -627,6 +627,12 @@ impl NodeValueTable { pub fn rows(&self) -> &[(ValueType, NodeValue, Option)] { &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)> { + &mut self.rows + } } /// Structural equality: `Texture` compares by handle address, `Samples` diff --git a/crates/oakplugin/README.md b/crates/oakplugin/README.md index 0132117f4..2aa202192 100644 --- a/crates/oakplugin/README.md +++ b/crates/oakplugin/README.md @@ -221,6 +221,67 @@ src/ 能力。修复:`init_descriptor_props` 补齐预定义(默认 "false"/空数组/None)。 +## 阶段 6a:OpenFX 引擎接线(oaknode/oakrender/oakplugin 收编) + +把 OFX 插件接进 oaknode 节点工厂与 oakrender 评估环的接线层 +(此前插件侧只有宿主/suite/渲染驱动,未进节点图)。全部落在 +crates/,不动 src/(app)与 gpui/。 + +- **`node_factory.rs`(新增)**: + - 实例注册表:`register_instance/instance_from_id/unregister_instance` + (u64 键 ↔ `Arc>`;进程级存活,对齐 C++ 工厂 + 持有;节点经 [`oaknode::nodes::plugin::PluginInstanceHandle`] + 持键)。 + - `register_plugin_nodes()`:遍历宿主插件缓存,filter 上下文优先 + (否则首个),经 `Factory::register_dynamic` 注册动态节点条目 + (已存在 id 跳过,对齐 C++ existing_ids)。返回新注册 id 列表。 + - 参数翻译 `build_core`:15 类 OFX 参数 → oaknode 输入(类型表、 + 默认值缓存、颜色语义启发式、combo ChoiceOrder 排序、secret→ + hidden、ui_group/ui_page、min/max/tooltip、clip→纹理输入、 + effect_input 选择),逐条对齐 engine/node/plugins/plugin.cpp。 + - `install_render_executor()`:把 render_driver 装进 + `oakrender::eval::set_plugin_executor`(依赖反转),duplicator + 装进 `oaknode::nodes::plugin::set_plugin_duplicator`。 + - `set_project_extent(w,h)`:normalised 坐标默认值换算基准。 +- **`gl_bridge.rs`(新增,spike 文档)**:`texture_id` 桩的 GL 互 + 操作评估——方案 A(wgpu-hal GL 互操作)在 macOS 不可行(Metal + 后端无 GL 命名空间);方案 B(独立离屏 GL 上下文 + 回读 + + wgpu upload)技术可行但暂缓(无真实 GL 插件可验证 + 需新 GL 依 + 赖 + 每帧同步回读 stall)。`texture_id` 保持恒 0,GL 插件经 CPU + render action 正确出帧。 +- **`progress.rs`**:新增 `UiProgressReporter` trait + + `ReporterFactory` + `set_reporter_factory`(app 注入点)。render + 未装 C 回调时装静默报告器,progressStart 携 (label,message) 经 + 工厂现造 UI 报告器;update 返回 false 即取消。 +- **`suites/timeline.rs`**:新增 `ViewerTimeInfo` + + `ActiveViewerProvider` + `set_active_viewer_provider`(app 注入 + 点)。timeline suite 的 getTime/getTimeBounds 在渲染上下文缺失时 + 回退活动 viewer 时间源。 +- **`render_driver.rs`**:`apply_param_overrides` 增 Double 标量 + NaN/Inf 清洗(回退默认并告警)+ Min/Max 钳制(对齐 + pluginrenderer.cpp:155-177)。 +- **`clip.rs`**:`fetch_image` 支持 U8/U16/F16 输入帧归一化转 F32 + (对齐 oliveclip.cpp setInputTexture 的格式转换路径);转换中的 + NaN/Inf 清洗为 0(oliveclip.cpp copy_pixels 的 scrub);新增 + `f16_to_f32` 手写位转换。 +- **oakrender `eval.rs`**:`JobSpec::Plugin` 扩为携带 + instance/time/effect_input_id/inputs/values;新增 + `PluginExecutor` + `set_plugin_executor` 依赖反转槽; + `process_plugin_job` 经执行器出帧,失败回退紫帧 (1,0,1,1); + `RenderEvalHooks` 实现 `RenderHooks::resolve` 解 + `PluginJobPayload` 盒并执行。 +- **oaknode**:`factory.rs` 动态注册面(`register_dynamic`/ + `dynamic_entries`/`create_any` 等);`nodes/plugin.rs` 重写为 + PluginJobPayload 值模型 + duplicator 槽;`traverser.rs` 纹理输入 + 直通;`handle.rs` `get_checked` 按 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 测试桩): diff --git a/crates/oakplugin/src/clip.rs b/crates/oakplugin/src/clip.rs index 736cd0277..3d7926512 100644 --- a/crates/oakplugin/src/clip.rs +++ b/crates/oakplugin/src/clip.rs @@ -61,6 +61,35 @@ fn components_from_props(props: &PropertySet) -> Option 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 { /// 按描述符实例化(createInstance 路径调用;公开:宿主与测试 /// 都需要构造 clip 实例)。实例 props 是描述符 props 的深拷贝 @@ -155,8 +184,10 @@ impl ClipInstance { /// (像素格式按协商结果,全链路 F32)。 /// `// [P2]` GL 路径:clipLoadTexture 语义在此扩展。 /// - /// 第 1 期约束:帧必须是 f32 格式(全链路 F32);`region` 只支持 - /// None(整帧)——转换(u8→f32 等)与子区域随 renderer 桥落地。 + /// 输入帧支持 U8/U16/F16/F32:非 F32 归一化转换为 F32(对齐 + /// oliveclip.cpp setInputTexture 的格式转换路径);转换中的 + /// NaN/Inf 清洗为 0(oliveclip.cpp copy_pixels 的 scrub)。 + /// `region` 只支持 None(整帧)——子区域随 renderer 桥落地。 pub fn fetch_image( &self, time: f64, @@ -183,10 +214,14 @@ impl ClipInstance { // 纹理 → CPU 帧(GPU 纹理后端下载;帧随 drop 释放)。 let frame = crate::render::texture_get_frame(&texture)?; 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!( - "输入帧格式 {} 非 F32(第 1 期约束)", - params.format + "输入帧格式 {format} 不支持(仅 U8/U16/F16/F32)" ))); } let (w, h) = (params.width as f64, params.height as f64); @@ -208,19 +243,71 @@ impl ClipInstance { if src.is_null() { return Err(Error::Failed("帧无数据".into())); } - // 行优先拷贝(帧行跨度经 linesize 读取——真实 oakrender 帧可 - // 有行填充;目标 Image 恒紧凑。M11 §4 修复:phase 1 假设紧凑 - // 行,对真实 oakrender 的填充帧会写错列)。 + // 行优先 + 格式转换(帧行跨度经 linesize 读取——真实 + // oakrender 帧可有行填充;目标 Image 恒紧凑 F32)。 + // U8/U16/F16 输入归一化到 [0,1] 浮点(对齐 oliveclip.cpp + // setInputTexture 的 swscale 转换路径:插件侧永远见到协商位 + // 深);F32/转换结果中的 NaN/Inf 清洗为 0(oliveclip.cpp + // copy_pixels 的 scrub——CImg 对 NaN 未定义行为)。 let channels = components.channel_count(); - let 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 = 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 dst = image.pixels_mut(); + let mut scrubbed = false; for y in 0..h as usize { let s = y * row; - let d = y * tight; - dst[d..d + tight].copy_from_slice(&src_bytes[s..s + tight]); + for i in 0..samples_per_row { + 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) } @@ -303,3 +390,27 @@ impl ClipInstance { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn f16_to_f32_covers_special_values() { + // 常规值:1.0 = 0x3C00,-2.0 = 0xC000,0.5 = 0x3800。 + assert_eq!(f16_to_f32(0x3C00), 1.0); + assert_eq!(f16_to_f32(0xC000), -2.0); + assert_eq!(f16_to_f32(0x3800), 0.5); + // 零与负零。 + assert_eq!(f16_to_f32(0x0000), 0.0); + assert_eq!(f16_to_f32(0x8000).to_bits(), (0.0f32).to_bits() | (1 << 31)); + // 非规格数:最小正规格数 2^-14 ≈ 0.00006104;2^-24 是最小非 + // 规格数之一。 + assert!((f16_to_f32(0x0400) - 2f32.powi(-14)).abs() < 1e-12); + assert!((f16_to_f32(0x0001) - 2f32.powi(-24)).abs() < 1e-12); + // Inf/NaN。 + assert!(f16_to_f32(0x7C00).is_infinite() && f16_to_f32(0x7C00) > 0.0); + assert!(f16_to_f32(0xFC00).is_infinite() && f16_to_f32(0xFC00) < 0.0); + assert!(f16_to_f32(0x7E00).is_nan()); + } +} diff --git a/crates/oakplugin/src/gl_bridge.rs b/crates/oakplugin/src/gl_bridge.rs new file mode 100644 index 000000000..659c2d0bf --- /dev/null +++ b/crates/oakplugin/src/gl_bridge.rs @@ -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 . + +//! GL 互操作桥(阶段 6a spike 结论;实现未落地)。 +//! +//! ## 背景 +//! +//! OFX 的 `OpenGLRender` 扩展要求宿主把 clip 纹理以 **GL 纹理名** +//! (`kOfxImageEffectPropOpenGLTextureIndex`)递给插件,插件直接画 +//! 进输出纹理。oak 的渲染后端是 wgpu,[`crate::render::texture_id`] +//! 因此是恒 0 的桩:render_driver 的 `use_opengl` 决策据它恒回退 +//! CPU 路径(GL 插件经 CPU render action 仍可工作)。本模块记录把 +//! 桩替换为真实 GL 互操作的评估。 +//! +//! ## 方案 A:wgpu-hal GL 互操作 —— 不可行(macOS) +//! +//! wgpu-hal 的 GL 互操作面(`hal::api::Gles` 的 adapter/texture +//! 互转)只在实例本身就是 GLES 后端时存在。oak 在 macOS 上的 wgpu +//! 实例是 **Metal**(wgpu 支持矩阵中 macOS/iOS 仅 Metal 为一等后 +//! 端;OpenGL 需 ANGLE 转译层且仅 GLES 3.0,见 +//! 的 Supported Platforms 表与 +//! CHANGELOG #4185"GLES backend optional on macOS")。Metal 后端与 +//! GL 纹理名之间没有共享命名空间,wgpu 也不暴露跨后端纹理导入。 +//! 即便为插件强行把整条管线切到 wgpu GLES 后端,也是以全局渲染性 +//! 能换单一插件路径,方向错误。**结论:放弃。** +//! +//! ## 方案 B:独立离屏 GL 上下文 + 回读 + wgpu 上传 —— 技术可行, +//! 暂缓 +//! +//! 路径:宿主自建原生 GL 上下文(macOS 为 CGL;OpenGL 自 10.14 起 +//! 弃用但仍可用),与插件共享纹理命名空间(CGL share group)→ 插件 +//! render 画进 FBO 附着纹理 → `glReadPixels`/PBO 回读为 CPU 帧 → +//! 经 [`oakrender::backend::GpuContextLike::upload`] 上传成 wgpu +//! 纹理。接线点已就位: +//! +//! 1. [`crate::render::texture_id`] 返回真实 GL 名(当前恒 0); +//! 2. [`crate::render_driver::render_frame`] 的 `use_opengl` 分支 +//! (`plugin_supports_opengl && depth_ok && dst_id != 0`)随之 +//! 生效,走 [`crate::instance::Instance::render_gl`]; +//! 3. GL suite([`crate::suites::gl_render`])的纹理索引属性写出真 +//! 值。 +//! +//! 暂缓理由: +//! - 需引入新依赖(`glow` + CGL 绑定)与上下文生命周期/线程模型 +//! 管理(OFX 插件可在自起线程回调 suite); +//! - 每帧同步回读是一次 GPU stall,性能上只在"插件本来就是 GL 加 +//! 速"时划算,而当前无任何真实 GL OFX 插件可验证(测试 `.gl` +//! 变体只验证 suite 调用面); +//! - CPU 回退路径完整可用,GL 插件经 render action 正确出帧。 +//! +//! ## TODO(phase-GL) +//! +//! 实现方案 B:CGL 上下文工厂 + share group、`texture_id` 真值化、 +//! 回读→上传流水线,以及一个真实 GL 插件的端到端验证(golden 帧比 +//! 较)。在此之前,[`crate::render::texture_id`] 保持恒 0 桩。 diff --git a/crates/oakplugin/src/instance.rs b/crates/oakplugin/src/instance.rs index 58beec023..ef7b2d3b7 100644 --- a/crates/oakplugin/src/instance.rs +++ b/crates/oakplugin/src/instance.rs @@ -594,7 +594,8 @@ impl Instance { crate::suites::set_render_ctx(Some(crate::suites::RenderCtx { time, scale, range })); crate::suites::set_current_output(Some(output.clone())); - // 进度报告器(facade 回调 → Progress suite)。 + // 进度报告器(facade 回调优先;无回调而 app 注册了 UI 工厂 + // 时装静默报告器,progressStart 再经工厂现造 UI 报告器)。 if let Some((cb, userdata)) = self .progress_cb .lock() @@ -604,6 +605,10 @@ impl Instance { crate::suites::progress::set_current(Some(unsafe { 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( @@ -689,6 +694,10 @@ impl Instance { crate::suites::progress::set_current(Some(unsafe { 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( diff --git a/crates/oakplugin/src/lib.rs b/crates/oakplugin/src/lib.rs index c52677dd6..95e83752a 100644 --- a/crates/oakplugin/src/lib.rs +++ b/crates/oakplugin/src/lib.rs @@ -56,11 +56,13 @@ pub mod clip; pub mod descriptor; pub mod error; +pub mod gl_bridge; pub mod handle; pub mod host; pub mod image; pub mod instance; pub mod node; +pub mod node_factory; pub mod param; pub mod progress; pub mod property; diff --git a/crates/oakplugin/src/node_factory.rs b/crates/oakplugin/src/node_factory.rs new file mode 100644 index 000000000..38ad85924 --- /dev/null +++ b/crates/oakplugin/src/node_factory.rs @@ -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 . + +//! 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>`(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>>>> = OnceLock::new(); +static NEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +fn instances() -> &'static Mutex>>> { + INSTANCES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 登记一个实例,返回非 0 句柄键(C++ 的 `Instance*` 指针身份)。 +/// 实例进程级存活(对齐 C++ 工厂持有;Drop 才发 destroyInstance)。 +pub fn register_instance(inst: Arc>) -> 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>> { + 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> = OnceLock::new(); + +/// 注入项目幅面(宽、高;normalised 坐标默认值换算用)。 +pub fn set_project_extent(width: f64, height: f64) { + *project_extent_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = (width, height); +} + +fn project_extent_slot() -> &'static Mutex<(f64, f64)> { + PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0))) +} + +fn project_extent() -> (f64, f64) { + *project_extent_slot().lock().unwrap_or_else(|e| e.into_inner()) +} + +/// C++ to_canonical(plugin.cpp:52-55)。 +fn to_canonical(normalised: f64, extent: f64) -> f64 { + if extent > 0.0 { + normalised * extent + } else { + normalised + } +} + +// --------------------------------------------------------------------------- +// 属性读取助手 +// --------------------------------------------------------------------------- + +fn prop_str(props: &PropertySet, name: &str, index: usize) -> String { + match props.get(name, index) { + Some(PropValue::String(s)) => s.to_string_lossy().into_owned(), + _ => String::new(), + } +} + +fn prop_double(props: &PropertySet, name: &str, index: usize) -> f64 { + match props.get(name, index) { + Some(PropValue::Double(v)) => v, + Some(PropValue::Int(v)) => v as f64, + _ => 0.0, + } +} + +fn prop_int(props: &PropertySet, name: &str, index: usize) -> i32 { + match props.get(name, index) { + Some(PropValue::Int(v)) => v, + Some(PropValue::Double(v)) => v as i32, + _ => 0, + } +} + +fn is_normalised_coord_system(def: &ParamDef) -> bool { + prop_str(&def.props, ofx::P_DEFAULT_COORD_SYS, 0) == ofx::V_COORD_NORMALISED +} + +// --------------------------------------------------------------------------- +// 默认值(plugin.cpp default_value_for_param,:57-131) +// --------------------------------------------------------------------------- + +/// 单个参数的节点默认值(无值类返回 None;对齐 C++ 返回 invalid +/// QVariant 的分支)。 +fn default_value_for_param(def: &ParamDef) -> Option { + let props = &def.props; + match def.ofx_type.as_str() { + ofx::TYPE_INTEGER => Some(NodeValue::Int(prop_int(props, ofx::P_DEFAULT, 0) as i64)), + ofx::TYPE_CHOICE => Some(NodeValue::Combo(prop_int(props, ofx::P_DEFAULT, 0) as i64)), + ofx::TYPE_BOOLEAN => Some(NodeValue::Boolean(prop_int(props, ofx::P_DEFAULT, 0) != 0)), + ofx::TYPE_DOUBLE => { + let mut val = prop_double(props, ofx::P_DEFAULT, 0); + if is_normalised_coord_system(def) { + let (x_size, _) = project_extent(); + val = to_canonical(val, x_size); + } + Some(NodeValue::Float(val)) + } + ofx::TYPE_STRING | ofx::TYPE_STRCHOICE => { + Some(NodeValue::Text(prop_str(props, ofx::P_DEFAULT, 0))) + } + ofx::TYPE_CUSTOM => { + // C++ 亦按字符串读默认(plugin.cpp:83-87);节点输入是 + // binary,按字节保留。 + Some(NodeValue::Binary( + prop_str(props, ofx::P_DEFAULT, 0).into_bytes(), + )) + } + ofx::TYPE_RGB | ofx::TYPE_RGBA => { + let count = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 }; + let mut values = [0.0, 0.0, 0.0, 1.0]; + for i in 0..count { + values[i] = prop_double(props, ofx::P_DEFAULT, i); + } + let alpha = if count == 4 { values[3] } else { 1.0 }; + Some(NodeValue::Color([values[0], values[1], values[2], alpha])) + } + ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER2D | ofx::TYPE_INTEGER3D => { + let is_double = matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D); + let count = if matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D) { + 2 + } else { + 3 + }; + let mut values = [0.0f64; 3]; + if is_double { + for i in 0..count { + values[i] = prop_double(props, ofx::P_DEFAULT, i); + } + if is_normalised_coord_system(def) { + let (x_size, y_size) = project_extent(); + values[0] = to_canonical(values[0], x_size); + values[1] = to_canonical(values[1], y_size); + if count == 3 { + values[2] = to_canonical(values[2], x_size); + } + } + } else { + for i in 0..count { + values[i] = prop_int(props, ofx::P_DEFAULT, i) as f64; + } + } + if count == 2 { + Some(NodeValue::Vec2([values[0], values[1]])) + } else { + Some(NodeValue::Vec3([values[0], values[1], values[2]])) + } + } + ofx::TYPE_BYTES => Some(NodeValue::Binary(Vec::new())), + // PushButton/Group/Page/Parametric/未知 → invalid(C++ 末尾 + // return QVariant())。 + _ => None, + } +} + +/// 每插件的默认值缓存(C++ g_plugin_param_defaults,plugin.cpp:37)。 +static DEFAULTS: OnceLock>>> = OnceLock::new(); + +fn defaults_slot() -> &'static Mutex>> { + 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 { + { + let cache = defaults_slot().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(hit) = cache.get(plugin_id) { + return hit.clone(); + } + } + let mut defaults = HashMap::new(); + for p in ¶ms.params { + let ofx_type = p.def.ofx_type.as_str(); + if ofx_type == ofx::TYPE_GROUP || ofx_type == ofx::TYPE_PAGE || ofx_type == ofx::TYPE_PUSHBUTTON { + continue; + } + if p.def.name.is_empty() { + continue; + } + if let Some(v) = default_value_for_param(&p.def) { + defaults.insert(p.def.name.clone(), v); + } + } + defaults_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(plugin_id.to_string(), defaults.clone()); + defaults +} + +// --------------------------------------------------------------------------- +// 颜色语义启发式(plugin.cpp deduce_color_semantic,:133-222) +// --------------------------------------------------------------------------- + +/// RGB/RGBA 参数是取色器("color")还是逐通道标量组("scalar")。 +fn deduce_color_semantic(def: &ParamDef, group_labels: &HashMap) -> &'static str { + const COLOR_KEYWORDS: &[&str] = &["color", "colour", "fill", "tint", "key"]; + const SCALAR_KEYWORDS: &[&str] = &[ + "gamma", + "contrast", + "gain", + "offset", + "saturation", + "exposure", + "brightness", + "lift", + "multiply", + "scale", + "pivot", + ]; + + if def.ofx_type != ofx::TYPE_RGB && def.ofx_type != ofx::TYPE_RGBA { + return "color"; + } + + let label = prop_str(&def.props, ofx::PROP_LABEL, 0).to_lowercase(); + let hint = prop_str(&def.props, ofx::P_HINT, 0).to_lowercase(); + let name = def.name.to_lowercase(); + + // 规则 1:显式颜色关键词 → color。 + for kw in COLOR_KEYWORDS { + if label.contains(kw) || hint.contains(kw) || name.contains(kw) { + return "color"; + } + } + + // 规则 2:显式标量/调色关键词 → scalar。 + for kw in SCALAR_KEYWORDS { + if label.contains(kw) || hint.contains(kw) || name.contains(kw) { + return "scalar"; + } + } + + // 规则 3:display 范围显著越出 [0,1] → scalar。 + let dim = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 }; + for i in 0..dim { + let dmin = prop_double(&def.props, ofx::P_DISPLAY_MIN, i); + let dmax = prop_double(&def.props, ofx::P_DISPLAY_MAX, i); + if dmin < -0.01 || dmax > 1.01 { + return "scalar"; + } + } + + // 规则 4:默认值全相等 → scalar(lean)。 + let mut defs = [0.0f64; 4]; + for i in 0..dim { + defs[i] = prop_double(&def.props, ofx::P_DEFAULT, i); + } + let all_equal = (1..dim).all(|i| defs[i] == defs[0]); + if all_equal { + return "scalar"; + } + + // 规则 5:父 group 名含标量关键词 → scalar。 + let parent = prop_str(&def.props, ofx::P_PARENT, 0).to_lowercase(); + if !parent.is_empty() { + let group_label = group_labels + .get(&prop_str(&def.props, ofx::P_PARENT, 0)) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + for kw in SCALAR_KEYWORDS { + if parent.contains(kw) || group_label.contains(kw) { + return "scalar"; + } + } + } + + // 兜底。 + "color" +} + +// --------------------------------------------------------------------------- +// clip 显示名(plugin.cpp clip_label_for_name,:248-270) +// --------------------------------------------------------------------------- + +fn clip_label_for_name(name: &str, clip_props: Option<&PropertySet>) -> String { + // 过渡上下文 clip 名(ofxImageEffect.h:1435-1441)。 + if name == SOURCE_CLIP { + return "Source".to_string(); + } + if name == "SourceFrom" { + return "From".to_string(); + } + if name == "SourceTo" { + return "To".to_string(); + } + if let Some(props) = clip_props { + let label = prop_str(props, ofx::PROP_LABEL, 0); + if !label.is_empty() { + return label; + } + } + name.to_string() +} + +// --------------------------------------------------------------------------- +// 参数翻译:OFX 参数 → 节点输入(plugin.cpp:331-490) +// --------------------------------------------------------------------------- + +/// OFX 类型 → 节点输入值类型(plugin.cpp:340-378 的类型表; +/// Group/Page 与未知类型(k_none)均无输入 → None,跳过)。 +fn input_type_for(ofx_type: &str) -> Option { + 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 = HashMap::new(); + let mut page_for_param: HashMap = 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 = (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)> { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/oakplugin/src/param.rs b/crates/oakplugin/src/param.rs index 876f6b3b3..5821694d9 100644 --- a/crates/oakplugin/src/param.rs +++ b/crates/oakplugin/src/param.rs @@ -126,6 +126,8 @@ pub(crate) const V_DOUBLE_TYPE_PLAIN: &str = "OfxParamDoubleTypePlain"; pub(crate) const P_DEFAULT_COORD_SYS: &str = "OfxParamPropDefaultCoordinateSystem"; /// kOfxParamCoordinatesCanonical。 pub(crate) const V_COORD_CANONICAL: &str = "OfxParamCoordinatesCanonical"; +/// kOfxParamCoordinatesNormalised(ofxParam.h:514)。 +pub(crate) const V_COORD_NORMALISED: &str = "OfxParamCoordinatesNormalised"; /// kOfxParamPropShowTimeMarker。 pub(crate) const P_SHOW_TIME_MARKER: &str = "OfxParamPropShowTimeMarker"; /// kOfxParamPropDimensionLabel。 @@ -138,6 +140,14 @@ pub(crate) const V_STRING_SINGLE_LINE: &str = "OfxParamStringIsSingleLine"; pub(crate) const P_STRING_FILE_EXISTS: &str = "OfxParamPropStringFilePathExists"; /// kOfxParamPropChoiceOption。 pub(crate) const P_CHOICE_OPTION: &str = "OfxParamPropChoiceOption"; +/// kOfxParamPropChoiceOrder。 +pub(crate) const P_CHOICE_ORDER: &str = "OfxParamPropChoiceOrder"; +/// kOfxParamPropChoiceEnum。 +pub(crate) const P_CHOICE_ENUM: &str = "OfxParamPropChoiceEnum"; +/// kOfxParamPageSkipRow(page 子项哨兵,ofxParam.h:178)。 +pub(crate) const PAGE_SKIP_ROW: &str = "OfxParamPageSkipRow"; +/// kOfxParamPageSkipColumn(page 子项哨兵,ofxParam.h:186)。 +pub(crate) const PAGE_SKIP_COLUMN: &str = "OfxParamPageSkipColumn"; /// kOfxParamPropCustomInterpCallbackV1。 pub(crate) const P_CUSTOM_INTERP: &str = "OfxParamPropCustomCallbackV1"; /// kOfxParamPropPageChild。 diff --git a/crates/oakplugin/src/progress.rs b/crates/oakplugin/src/progress.rs index a2e2d54ac..b0c6b3036 100644 --- a/crates/oakplugin/src/progress.rs +++ b/crates/oakplugin/src/progress.rs @@ -17,13 +17,57 @@ //! 进度上报(Progress suite 的宿主侧)。 //! //! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的 -//! 回调出 crate(M9 的 facade 回调模式,不设全局状态)。 -//! 取消是粘滞的:一旦回调答 false,本报告器的 [`is_cancelled`] -//! (image effect suite 的 abort 查询)持续为真。 +//! 回调出 crate(M9 的 facade 回调模式)。取消是粘滞的:一旦回调 +//! 答 false,本报告器的 [`is_cancelled`](image effect suite 的 +//! 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::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 Box + Send + Sync>; + +static REPORTER_FACTORY: OnceLock>> = OnceLock::new(); + +fn factory_slot() -> &'static Mutex> { + REPORTER_FACTORY.get_or_init(|| Mutex::new(None)) +} + +/// 注册/清除进度 UI 工厂(app 接线点;覆盖式)。 +pub fn set_reporter_factory(factory: Option) { + *factory_slot().lock().unwrap_or_else(|e| e.into_inner()) = factory; +} + +pub(crate) fn reporter_factory() -> Option { + 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` 的 /// `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 { callback: Option, userdata: usize, + /// UI 报告器(progressStart 经 [`reporter_factory`] 现造; + /// C 回调优先,无回调时才用)。 + ui: Mutex>>, /// 取消标志(粘滞;update 返回 false 时置位)。 cancelled: AtomicBool, } impl ProgressReporter { - /// 无回调(渲染静默进行)。 + /// 无回调(渲染静默进行;progressStart 仍可能经工厂装上 UI + /// 报告器)。 pub fn silent() -> Self { Self { callback: None, userdata: 0, + ui: Mutex::new(None), cancelled: AtomicBool::new(false), } } @@ -59,10 +108,26 @@ impl ProgressReporter { callback: Some(callback), // usize 存(裸指针破坏 Send 推导;值语义不变)。 userdata: userdata as usize, + ui: Mutex::new(None), 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 表示应取消(映射 /// kOfxStatReplyNo/action 失败由调用点决定)。 pub fn update(&self, progress: f64) -> bool { @@ -75,7 +140,19 @@ impl ProgressReporter { } !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, + } + } } } diff --git a/crates/oakplugin/src/render.rs b/crates/oakplugin/src/render.rs index c4078f2eb..13ed1b35c 100644 --- a/crates/oakplugin/src/render.rs +++ b/crates/oakplugin/src/render.rs @@ -129,7 +129,8 @@ pub fn texture_create( /// Rust 等价物。恒 0(GL suite 的 `OpenGLTextureIndex` 属性与 /// render 驱动的 use_opengl 决策据此回退 CPU 路径;GPU 上传若落地 /// 走 `oakrender::backend::GpuContextLike::upload` 的 wgpu token, -/// 不暴露 GL id)。 +/// 不暴露 GL id)。真实化的评估与方案见 [`crate::gl_bridge`] +/// (阶段 6a spike:方案 A 不可行,方案 B 暂缓)。 pub fn texture_id(_texture: &Texture) -> i32 { 0 } diff --git a/crates/oakplugin/src/render_driver.rs b/crates/oakplugin/src/render_driver.rs index 562d11bbb..9d740d06b 100644 --- a/crates/oakplugin/src/render_driver.rs +++ b/crates/oakplugin/src/render_driver.rs @@ -133,13 +133,15 @@ pub fn end_sequence( /// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。 /// -/// 返回各输入 clip 的 RoI(clip 名 → 矩形;Phase 2 供测试断言, -/// 宿主不据此裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器 -/// 行为一致)。 +/// 返回装配完成的输出纹理与各输入 clip 的 RoI(clip 名 → 矩形; +/// 值型纹理下输出写入 `job.dst` 的本地副本并随返回值交付——CPU +/// 纹理的 `to_frame` 是拷贝,就地写不回只读的 `job.dst`)。宿主不 +/// 据 RoI 裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器行为 +/// 一致。 pub fn render_frame( inst: &Instance, job: &RenderJob, -) -> crate::error::Result> { +) -> crate::error::Result<(Texture, Vec<(String, OfxRectD)>)> { use crate::error::Error; // 1. 实例锁(pluginrenderer.cpp:1436-1444:OlivePluginInstance 非 @@ -167,8 +169,10 @@ pub fn render_frame( _ => false, }; - // 目标帧与参数(F32 校验;输出装配的依据)。 + // 目标帧与参数(F32 校验;输出装配的依据)。dst 取本地副本: + // 值型纹理下输出装配写回副本,随返回值交付(job.dst 只读)。 let (dst_params, w, h) = read_dst(&job.dst)?; + let mut dst = job.dst.clone(); let par = pixel_aspect(&dst_params); // 规范坐标的 RoI/RoD(pluginrenderer.cpp:1595-1603:x2 = 宽 × PAR)。 let region_of_interest = OfxRectD { @@ -209,7 +213,7 @@ pub fn render_frame( .find(|c| c.name == "Output") .ok_or_else(|| Error::Failed("实例无 Output clip".into()))?; output_clip.set_region_of_definition(region_of_interest, job.time); - output_clip.set_output_texture(Some(job.dst.clone()), job.time); + output_clip.set_output_texture(Some(dst.clone()), job.time); // 6. 输入 clip:RoD 与格式(pluginrenderer.cpp:1627-1665;Phase 2 // 全链路 F32 → 格式选择恒等,无转换路径)。 @@ -244,8 +248,8 @@ pub fn render_frame( // 9. isIdentity 短路(ofxRendering "Identity Effects"):插件声明 // 本帧等价于某输入 clip → 直接透传该 clip 在透传时间的帧。 if let Some((t, clip_name)) = inst.is_identity(job.time)? { - passthrough(inst, &clip_name, t, &job.dst)?; - return Ok(zip_rois(inst, &rois)); + passthrough(inst, &clip_name, t, &mut dst)?; + return Ok((dst, zip_rois(inst, &rois))); } // 10. 参数覆盖(pluginrenderer.cpp:1729-1731 + 132-290)。 @@ -270,7 +274,7 @@ pub fn render_frame( output.clone(), )?; // 输出装配(pluginrenderer.cpp:1762-1834 的 CPU 路径)。 - write_output_frame(&job.dst, &output)?; + write_output_frame(&mut dst, &output)?; } else { // GL 路径:插件直接画进已附着的输出纹理( // pluginrenderer.cpp:1784-1834 的 GL 分支);无 CPU 回读。 @@ -279,11 +283,11 @@ pub fn render_frame( RenderScale { x: 1.0, y: 1.0 }, render_window, job.renderer.clone().unwrap(), - job.dst.clone(), + dst.clone(), )?; } - Ok(zip_rois(inst, &rois)) + Ok((dst, zip_rois(inst, &rois))) } /// 把输入 clip 名与 RoI 列表配对(与 `clips` 顺序一致)。 @@ -373,7 +377,7 @@ fn passthrough( inst: &Instance, clip_name: &str, t: f64, - dst: &Texture, + dst: &mut Texture, ) -> crate::error::Result<()> { use crate::error::Error; 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 { 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; }; + // 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); } } +/// 读属性的 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 校验)。 /// Phase 2 输出装配的公共落点(CPU render 路径与 isIdentity 透传 /// 共用)。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; + // CPU 纹理就地写入;GPU 纹理经下载帧改写后 upload 回写。 let mut frame = render::texture_get_frame(dst)?; let params = frame.video_params(); 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; dst_bytes[d..d + tight].copy_from_slice(&pixels[s..s + tight]); } - // GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写 - // (CPU 纹理无需上传)。 - if let Texture::Gpu { token, ctx, .. } = dst { - ctx.upload(*token, &frame) - .map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?; + match dst { + // 就地写回(值型 CPU 纹理:to_frame 是拷贝,必须写回本体)。 + Texture::Cpu(f) => { + f.data = frame.data; + } + // GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写。 + Texture::Gpu { token, ctx, .. } => { + ctx.upload(*token, &frame) + .map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?; + } } Ok(()) } diff --git a/crates/oakplugin/src/suites/progress.rs b/crates/oakplugin/src/suites/progress.rs index 5c6207897..04cc2e6f3 100644 --- a/crates/oakplugin/src/suites/progress.rs +++ b/crates/oakplugin/src/suites/progress.rs @@ -84,10 +84,29 @@ pub struct ProgressSuiteV2 { pub end: unsafe extern "C" fn(*mut c_void) -> c_int, } -/// progressStart:括号起点,OK(label/message 留作未来 UI 展示, -/// 第 1 期不建模)。 -unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, _label: *const c_char) -> c_int { - caught(|| status::OK) +/// C 字符串解码(空指针/非法 UTF-8 → 空串)。 +unsafe fn decode<'a>(p: *const c_char) -> &'a str { + if p.is_null() { + return ""; + } + unsafe { std::ffi::CStr::from_ptr(p) } + .to_str() + .unwrap_or("") +} + +/// progressStart:括号起点;携 label(v2 另携 message)经 +/// [`crate::progress`] 工厂现造 UI 报告器(无工厂/已有报告器时 +/// no-op),状态恒 OK。 +unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, label: *const c_char) -> c_int { + caught(|| { + let label = unsafe { decode(label) }.to_string(); + CURRENT.with(|c| { + if let Some(r) = c.borrow().as_ref() { + r.install_ui(&label, ""); + } + }); + status::OK + }) } unsafe extern "C" fn progress_update_v1(_handle: *mut c_void, progress: c_double) -> c_int { @@ -100,10 +119,19 @@ unsafe extern "C" fn progress_end_v1(_handle: *mut c_void) -> c_int { unsafe extern "C" fn progress_start_v2( _handle: *mut c_void, - _label: *const c_char, - _message: *const c_char, + label: *const c_char, + message: *const c_char, ) -> 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 { diff --git a/crates/oakplugin/src/suites/timeline.rs b/crates/oakplugin/src/suites/timeline.rs index a102be781..c77325d24 100644 --- a/crates/oakplugin/src/suites/timeline.rs +++ b/crates/oakplugin/src/suites/timeline.rs @@ -18,14 +18,54 @@ //! ([`crate::suites::RenderCtx`],frame range 来自 clip 桥)。 //! 参照 HS: ofxhImageEffect.cpp gTimelineSuite。 //! -//! 无渲染上下文(渲染外调用)→ 时间 0 / 时间域 (0,0) 的 headless -//! 默认;gotoTime 第 1 期无时间线驱动 → OK no-op(渲染时间由驱动 -//! 固定,见 [`crate::suites::set_render_ctx`])。 +//! 无渲染上下文(渲染外调用)→ 回退 app 注入的活动 viewer 时间源 +//! ([`set_active_viewer_provider`],阶段 6a 注入点);也未注入则 +//! 时间 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::sync::{Arc, Mutex, OnceLock}; 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 Option + Send + Sync>; + +static ACTIVE_VIEWER: OnceLock>> = OnceLock::new(); + +fn viewer_slot() -> &'static Mutex> { + ACTIVE_VIEWER.get_or_init(|| Mutex::new(None)) +} + +/// 注册/清除活动 viewer 提供器(app 接线点;覆盖式)。 +pub fn set_active_viewer_provider(provider: Option) { + *viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = provider; +} + +fn active_viewer() -> Option { + let provider = viewer_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()?; + provider() +} + /// 函数表布局(OfxTimeLineSuiteV1)。 #[repr(C)] 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() { return status::ERR_VALUE; } - // 渲染上下文缺省 → 0(headless 默认)。 - *time = render_ctx().map_or(0.0, |c| c.time); + // 渲染上下文 → 活动 viewer → 0(headless 默认)。 + *time = render_ctx() + .map(|c| c.time) + .or_else(|| active_viewer().map(|v| v.time)) + .unwrap_or(0.0); status::OK }) } @@ -77,7 +120,11 @@ unsafe extern "C" fn timeline_get_time_bounds( if min.is_null() || max.is_null() { 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; *max = r.1; status::OK diff --git a/crates/oakplugin/tests/node_e2e_test.rs b/crates/oakplugin/tests/node_e2e_test.rs new file mode 100644 index 000000000..3e147e4f2 --- /dev/null +++ b/crates/oakplugin/tests/node_e2e_test.rs @@ -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 . + +//! 阶段 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> { + 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::(handle) } + .cloned() + .expect("纹理盒必须是渲染产物(PluginJobPayload 已 resolve)") +} + +fn first_pixel(texture: &Texture) -> [f32; 4] { + let Texture::Cpu(frame) = texture else { + panic!("期望 CPU 帧"); + }; + let mut out = [0f32; 4]; + for i in 0..4 { + out[i] = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap()); + } + out +} + +/// 注册 + 参数翻译:动态条目、输入类型/默认值/显示名/隐藏标记/ +/// combo 选项/effect_input(对齐 plugin.cpp 构造函数)。 +#[test] +fn plugin_nodes_register_with_translated_inputs() { + common::with_host(|| { + if !scan_and_register() { + return; + } + + let entries = Factory::global().dynamic_entries(); + assert!( + entries.iter().any(|m| m.type_id == PLUGIN_ID), + "CPU 变体应注册为动态节点" + ); + assert!( + entries.iter().any(|m| m.type_id == IDENTITY_ID), + "identity 变体应注册为动态节点" + ); + let meta = entries.iter().find(|m| m.type_id == PLUGIN_ID).unwrap(); + assert_eq!(meta.sub_category, "Filter"); + assert_eq!( + meta.categories, + vec![oaknode::node::Category::OpenFx] + ); + + let (core, behavior) = Factory::global() + .create_any(PLUGIN_ID) + .expect("create_any 应建出插件节点"); + + // gain:Double → Float,默认 0.0,显示名 Gain,带 display + // min/max 属性(-2/2)。 + let gain = core.get_input("gain").expect("gain 输入"); + assert_eq!(gain.value_type, ValueType::Float); + assert_eq!(gain.default, NodeValue::Float(0.0)); + assert_eq!(gain.display_name, "Gain"); + // min/max/tooltip 属性 C++ 只对颜色输入设置 + // (plugin.cpp:407-430 的 k_color 分支);Double 参数无。 + assert!(gain + .properties + .iter() + .all(|(k, _)| k != "min" && k != "max")); + + // mode:Choice → Combo,两个选项 Fast/High。 + let mode = core.get_input("mode").expect("mode 输入"); + assert_eq!(mode.value_type, ValueType::Combo); + let options: Vec = mode + .properties + .iter() + .filter(|(k, _)| k == "combo_option") + .map(|(_, v)| match v { + NodeValue::Text(s) => s.clone(), + _ => panic!("combo_option 应是 Text"), + }) + .collect(); + assert_eq!(options, vec!["Fast".to_string(), "High".to_string()]); + + // debug:secret → hidden。 + let debug = core.get_input("debug").expect("debug 输入"); + assert!( + debug.flags & oaknode::input::flags::HIDDEN != 0, + "secret 参数应隐藏" + ); + + // label:String → Text。 + let label = core.get_input("label").expect("label 输入"); + assert_eq!(label.value_type, ValueType::Text); + + // Source clip → 纹理输入;effect_input 选中 Source。 + let source = core.get_input("Source").expect("Source 输入"); + assert_eq!(source.value_type, ValueType::Texture); + assert_eq!(core.effect_input, "Source"); + + // 行为是持真实实例句柄的 PluginNode。 + let plugin = behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .expect("行为应是 PluginNode"); + assert!(!plugin.instance_handle().is_null()); + + Host::global().shutdown(); + }); +} + +/// CPU 端到端:常量源 → 插件节点(render 填常量 0.5/alpha 1)→ +/// 输出帧像素断言。 +#[test] +fn plugin_renders_constant_frame_end_to_end() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let (core, behavior) = Factory::global() + .create_any(PLUGIN_ID) + .expect("create_any"); + + let mut graph = Graph::new(); + let src_id = graph.add_node( + NodeCore::new(), + Box::new(ConstSource { + rgba: [0.2, 0.4, 0.6, 1.0], + size: (4, 4), + }), + ); + let plug_id = graph.add_node(core, behavior); + graph + .connect(src_id, plug_id, "Source", -1) + .expect("Source 连接"); + + let mut traverser = Traverser::new(); + let mut hooks = oakrender::eval::RenderEvalHooks::new(); + let table = traverser + .evaluate( + &graph, + &EvalRequest::new(plug_id, Rational::new(0, 1)), + &mut hooks, + ) + .expect("evaluate 应成功"); + + let texture = rendered_texture(&table); + assert_eq!(texture.size(), (4, 4)); + // 测试插件 render 无视输入,填常量 0.5(alpha=1)。 + assert_eq!(first_pixel(&texture), [0.5, 0.5, 0.5, 1.0]); + + Host::global().shutdown(); + }); +} + +/// isIdentity 透传:identity 变体声明恒透传 Source → 输出应等于 +/// 输入帧(render_driver 的 passthrough 短路)。 +#[test] +fn identity_variant_passes_source_through() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let (core, behavior) = Factory::global() + .create_any(IDENTITY_ID) + .expect("create_any(identity)"); + + let mut graph = Graph::new(); + let src_id = graph.add_node( + NodeCore::new(), + Box::new(ConstSource { + rgba: [0.25, 0.75, 0.5, 1.0], + size: (2, 2), + }), + ); + let plug_id = graph.add_node(core, behavior); + graph + .connect(src_id, plug_id, "Source", -1) + .expect("Source 连接"); + + let mut traverser = Traverser::new(); + let mut hooks = oakrender::eval::RenderEvalHooks::new(); + let table = traverser + .evaluate( + &graph, + &EvalRequest::new(plug_id, Rational::new(0, 1)), + &mut hooks, + ) + .expect("evaluate 应成功"); + + let texture = rendered_texture(&table); + assert_eq!(first_pixel(&texture), [0.25, 0.75, 0.5, 1.0]); + + Host::global().shutdown(); + }); +} + +/// 参数覆盖路径:Text/StrCombo 走 set_ofx,数值走 POD——经 set 后 +/// 实例参数值可读回(翻译注入的回归保护)。 +#[test] +fn param_overrides_reach_instance() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let inst = Host::global() + .create_instance(PLUGIN_ID, None) + .expect("实例"); + let id = oakplugin::node_factory::register_instance(inst.clone()); + + // 数值覆盖(gain = 1.25)。 + let job_values = vec![( + "gain".to_string(), + oaknode::value::NodeValue::Float(1.25), + )]; + let pod: Vec<(String, oakplugin::node::Value)> = job_values + .iter() + .filter_map(|(k, v)| { + oakplugin::node::Value::from_node_value(v).map(|p| (k.clone(), p)) + }) + .collect(); + let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32) + .unwrap(); + let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32) + .unwrap(); + let job = oakplugin::render_driver::RenderJob { + time: 0.0, + dst: Texture::wrap_frame(dst), + src: Some(Texture::wrap_frame(src)), + effect_input_id: Some("Source".into()), + inputs: Vec::new(), + values: pod, + renderer: None, + clear_destination: false, + interactive: false, + }; + oakplugin::render_driver::render_frame(&inst.value, &job) + .expect("render_frame 应成功"); + let gain = inst.value.params.find("gain").unwrap().get(); + assert_eq!( + gain, + oakplugin::param::ParamValue::Double([1.25, 0.0, 0.0], 1) + ); + + // NaN 覆盖回退默认(gain 默认 0.0)。 + let pod_nan = vec![( + "gain".to_string(), + oakplugin::node::Value::float(f64::NAN), + )]; + let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32) + .unwrap(); + let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32) + .unwrap(); + let job = oakplugin::render_driver::RenderJob { + time: 0.0, + dst: Texture::wrap_frame(dst), + src: Some(Texture::wrap_frame(src)), + effect_input_id: Some("Source".into()), + inputs: Vec::new(), + values: pod_nan, + renderer: None, + clear_destination: false, + interactive: false, + }; + oakplugin::render_driver::render_frame(&inst.value, &job) + .expect("NaN 覆盖不应失败"); + assert_eq!( + inst.value.params.find("gain").unwrap().get(), + oakplugin::param::ParamValue::Double([0.0, 0.0, 0.0], 1) + ); + + oakplugin::node_factory::unregister_instance(id); + Host::global().shutdown(); + }); +} diff --git a/crates/oakrender/src/eval.rs b/crates/oakrender/src/eval.rs index e15e62301..30c96c3e3 100644 --- a/crates/oakrender/src/eval.rs +++ b/crates/oakrender/src/eval.rs @@ -20,10 +20,12 @@ //! is one hook method. //! //! This pass implements the CPU-side, graph-free parts of the hooks: -//! frame generation and color transforms run fully; footage decode, -//! shader execution, plugin jobs and the disk frame-cache payload I/O -//! depend on the oakcodec / oaknode / oakplugin C ABIs and fail with -//! explainable errors (their success-path tests are `#[ignore]`d). +//! frame generation and color transforms run fully; plugin jobs +//! dispatch through the executor slot oakplugin installs +//! ([`set_plugin_executor`]); footage decode, shader execution and the +//! 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; @@ -33,6 +35,7 @@ use oakcodec::decoder::{ }; use oakcodec::ffmpeg::FFmpegDecoder; use oakcore_rs::{PixelFormat, Rational, TimeRange}; +use oaknode::value::{NodeValue, NodeValueRow, NodeValueTable}; use crate::error::{Error, Result}; use crate::frame::VideoParamsPod; @@ -73,11 +76,22 @@ pub enum JobSpec { }, /// Sample generation (C++ SampleJob). Sample, - /// OFX plugin job — forwarded to the oakplugin crate C ABI - /// (render never sees OFX types). + /// OFX plugin job — executed through the registered plugin executor + /// ([`set_plugin_executor`]; the oakplugin crate installs its + /// render driver there, so oakrender never sees OFX types). Plugin { - /// OakPluginInstance identity. + /// Plugin instance identity (oakplugin instance registry key). 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, + /// 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, } +// --------------------------------------------------------------------------- +// 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 + Send + Sync; + +static PLUGIN_EXECUTOR: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +fn executor_slot() -> &'static std::sync::Mutex>> { + 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>) { + *executor_slot().lock().unwrap_or_else(|e| e.into_inner()) = executor; +} + +/// The installed plugin executor, if any. +pub fn plugin_executor() -> Option> { + 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)] impl RenderEvalHooks { pub fn new() -> Self { @@ -184,16 +261,33 @@ impl RenderEvalHooks { Ok(()) } - /// C++ process_plugin_job (forwarded to oakplugin C ABI). - fn process_plugin_job(&mut self, texture: Texture, spec: &JobSpec) -> Result { - let JobSpec::Plugin { instance } = spec else { + /// C++ process_plugin_job: dispatch through the installed plugin + /// executor (the oakplugin render driver; dependency inversion). A + /// 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 { + let JobSpec::Plugin { + instance, + time, + effect_input_id, + inputs, + values, + } = spec + else { return Err(Error::Invalid); }; - let _ = instance; - let _ = texture; - Err(Error::Failed( - "plugin jobs deferred: forwarded to the oakplugin crate C ABI".into(), - )) + let size = src.size(); + let Some(executor) = plugin_executor() else { + return Ok(purple_frame(Rational::from_double(*time), size)); + }; + 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. @@ -206,6 +300,102 @@ impl RenderEvalHooks { "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::(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::(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 { @@ -617,9 +807,6 @@ mod tests { .process_video_cache_job(&JobSpec::Cache { path: "p".into() }) .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 .process_color_transform(&mut dest, &JobSpec::ColorTransform { processor: 1 }) .is_err()); @@ -661,6 +848,152 @@ mod tests { .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::(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 /// real GPU work). struct UnusedCtx; diff --git a/crates/oakundo/src/global.rs b/crates/oakundo/src/global.rs index c495e8d0e..055591b76 100644 --- a/crates/oakundo/src/global.rs +++ b/crates/oakundo/src/global.rs @@ -375,6 +375,18 @@ pub fn command_is_done(row: i64, out_value: *mut c_int) -> Result<()> { 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 { + 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 { + with_stack(|s| s.command_is_done(row)) +} + #[cfg(test)] mod tests { use super::*; @@ -497,4 +509,32 @@ mod tests { 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()); + } }