Files
oak-editor/crates/oakplugin/src/node_factory.rs
T
Mike-Solar 5498504398 fix(plugin): full OFX plugin discovery — host conformance fixes
Real openfx-misc/CImg/Shadertoy bundles (148 plugins at
/Library/OFX/Plugins) all failed to load before; every failure was
silent. Root causes found one by one with a probe example + lldb:

- property suite rejected propSet on undefined properties and
  propGetDimension on empty ones, and disallowed the index==size
  append — OFX semantics are create-on-set and appendable dimensions
  (this alone failed every plugin's describe)
- host property set missed the mandatory OfxPropType/OfxPropAPIVersion
  and the capability props ofxs' fetchHostDescription reads with
  throwOnFailure=true (IsBackground, TemporalClipAccess, MaxPages,
  PageRowColumnCount, host SupportedContexts, ...) — one missing prop
  aborted the read chain and left a half-initialised host description,
  which made every temporal plugin refuse to load
- MultiThreadSuiteV1 lacked the five mutex functions (the plugin reads
  past the short table — UB); implemented as a real counting-semaphore
  registry
- the OfxHost struct was a stack local; ofxs keeps the POINTER past
  setHost, so describe/render-time fetchSuite calls dereferenced a
  dangling stack address (bus error once plugins actually loaded) —
  the struct is now a leaked process global
- General is a standard OFX context and is no longer filtered out
  (Roto/AppendClip/STMap declare only it)
- every scan/load/describe early-out now logs its reason; suite entry
  points report non-OK statuses with caller location under
  OAK_OFX_TRACE
- examples/scan_probe.rs: scans the real plugin dirs and prints
  discovered/registered counts (also usable from CI)

Result: 148/148 plugins discovered, 134 registered as node types (the
remaining 14 need vendor suites — Vegas stereoscopic etc. — and are
logged, not silent)
2026-08-20 22:28:23 +08:00

1111 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! OFX 插件 → 节点工厂接线(阶段 6a)。
//!
//! 对应 C++ `NodeFactory::register_plugin_nodes`
//! factory.cpp:148-186+ `PluginNode::PluginNode(Instance*)` 的
//! 参数翻译构造函数(engine/node/plugins/plugin.cpp:274-514)。
//! OFX 类型只存在于本 crate,故翻译放这里;oaknode 侧只承载行为
//! [`oaknode::nodes::plugin::PluginNode`])——这是依赖方向
//! oakplugin → oakrender → oaknode)强加的拆分。
//!
//! 职责:
//! - **实例注册表**:节点持 [`oaknode::nodes::plugin::PluginInstanceHandle`]
//! u64 键),这里持 `Arc<RefBox<Instance>>`C++ 的工厂实例在库
//! 条目内存活;Rust 侧等价于注册表进程级存活)。
//! - **参数翻译**15 类 OFX 参数 → oaknode 输入(类型表逐字对齐
//! plugin.cpp:340-378),默认值缓存、颜色语义启发式、combo 排序、
//! secret→hidden、ui_group/ui_page、clip→纹理输入、effect_input
//! 选择(plugin.cpp:494-514)。
//! - **渲染执行器**[`install_render_executor`] 把 render_driver 装
//! 进 oakrender 的 executor 槽(依赖反转;oakrender 看不见本 crate
//! 与 oaknode 的 duplicator 槽。
//!
//! app 注入点另见 [`crate::progress::set_reporter_factory`](进度
//! UI)与 [`crate::suites::timeline::set_active_viewer_provider`]
//! timeline suite 回退时间源)。
use std::collections::HashMap;
use std::ffi::CString;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use oaknode::factory::{DynNodeConstructor, DynamicNodeMeta};
use oaknode::input::{flags as input_flags, Input};
use oaknode::node::{Category, NodeBehavior, NodeCore};
use oaknode::nodes::plugin::{
PluginInstanceHandle, PluginNode, SOURCE_CLIP, TEXTURE_INPUT,
};
use oaknode::value::{NodeValue, ValueType};
use crate::handle::RefBox;
use crate::host::Host;
use crate::instance::Instance;
use crate::param::{self as ofx, ParamDef, ParamValue};
use crate::property::{PropertySet, Value as PropValue};
/// kOfxPropPluginDescription(描述符根属性)。
const PROP_PLUGIN_DESCRIPTION: &str = "OfxPropPluginDescription";
// ---------------------------------------------------------------------------
// 实例注册表
// ---------------------------------------------------------------------------
static INSTANCES: OnceLock<Mutex<HashMap<u64, Arc<RefBox<Instance>>>>> = OnceLock::new();
static NEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1);
fn instances() -> &'static Mutex<HashMap<u64, Arc<RefBox<Instance>>>> {
INSTANCES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 登记一个实例,返回非 0 句柄键(C++ 的 `Instance*` 指针身份)。
/// 实例进程级存活(对齐 C++ 工厂持有;Drop 才发 destroyInstance)。
pub fn register_instance(inst: Arc<RefBox<Instance>>) -> u64 {
let id = NEXT_INSTANCE_ID.fetch_add(1, Ordering::Relaxed);
instances()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id, inst);
id
}
/// 句柄键 → 实例(查无返回 None)。
pub fn instance_from_id(id: u64) -> Option<Arc<RefBox<Instance>>> {
instances()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&id)
.cloned()
}
/// 摘除登记(节点销毁路径;未登记的键 no-op)。
pub fn unregister_instance(id: u64) {
instances()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
}
/// 当前登记数(测试/诊断)。
pub fn registered_instance_count() -> usize {
instances().lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Triggers a push-button parameter (the Rust counterpart of the C++
/// `oakengine_plugin_node_push_button_clicked`; called by the
/// inspector's button widget).
///
/// OFX push buttons carry no value: the host's press signal is a single
/// set on the parameter, then the press is routed to the plugin as
/// `kOfxActionInstanceChanged` (UserEdited) so the plugin reacts in its
/// own instanceChanged action (the OFX contract, ofxCore.h:405-435;
/// real plugins such as CImg depend on that action). Locates the
/// instance and parameter, type-checks, marks the value, then dispatches
/// the action. Returns false when the instance/parameter is unknown or
/// the parameter is not a push button; an instanceChanged failure is
/// logged and still reported as a successful press (the value was set).
pub fn push_button_clicked(instance: u64, param_name: &str) -> bool {
let Some(inst) = instance_from_id(instance) else {
return false;
};
let Some(p) = inst.value.params.find(param_name) else {
return false;
};
if p.def.ofx_type != ofx::TYPE_PUSHBUTTON {
return false;
}
p.set_ofx(ParamValue::PushButton);
// The current render context supplies the time / render scale when the
// press happens during a render; outside one, time 0 and scale 1:1.
let (time, scale) = crate::suites::render_ctx()
.map(|ctx| (ctx.time, ctx.scale))
.unwrap_or((0.0, crate::instance::RenderScale { x: 1.0, y: 1.0 }));
if let Err(e) = inst.value.instance_changed(
param_name,
crate::param::ChangeReason::UserEdited,
time,
scale,
) {
eprintln!("push_button_clicked: instanceChanged failed for \"{param_name}\": {e}");
}
true
}
// ---------------------------------------------------------------------------
// 项目幅面(normalised 坐标默认值 → canonical 的换算基准)
// ---------------------------------------------------------------------------
//
// C++ get_project_extent 读 Current::current_video_params
// plugin.cpp:45-50);crate 侧无项目上下文,app 经
// set_project_extent 注入,未注入用 HD 缺省。
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
/// 注入项目幅面(宽、高;normalised 坐标默认值换算用)。
pub fn set_project_extent(width: f64, height: f64) {
*project_extent_slot()
.lock()
.unwrap_or_else(|e| e.into_inner()) = (width, height);
}
fn project_extent_slot() -> &'static Mutex<(f64, f64)> {
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
}
fn project_extent() -> (f64, f64) {
*project_extent_slot().lock().unwrap_or_else(|e| e.into_inner())
}
/// C++ to_canonicalplugin.cpp:52-55)。
fn to_canonical(normalised: f64, extent: f64) -> f64 {
if extent > 0.0 {
normalised * extent
} else {
normalised
}
}
// ---------------------------------------------------------------------------
// 属性读取助手
// ---------------------------------------------------------------------------
fn prop_str(props: &PropertySet, name: &str, index: usize) -> String {
match props.get(name, index) {
Some(PropValue::String(s)) => s.to_string_lossy().into_owned(),
_ => String::new(),
}
}
fn prop_double(props: &PropertySet, name: &str, index: usize) -> f64 {
match props.get(name, index) {
Some(PropValue::Double(v)) => v,
Some(PropValue::Int(v)) => v as f64,
_ => 0.0,
}
}
fn prop_int(props: &PropertySet, name: &str, index: usize) -> i32 {
match props.get(name, index) {
Some(PropValue::Int(v)) => v,
Some(PropValue::Double(v)) => v as i32,
_ => 0,
}
}
fn is_normalised_coord_system(def: &ParamDef) -> bool {
prop_str(&def.props, ofx::P_DEFAULT_COORD_SYS, 0) == ofx::V_COORD_NORMALISED
}
// ---------------------------------------------------------------------------
// 默认值(plugin.cpp default_value_for_param:57-131
// ---------------------------------------------------------------------------
/// 单个参数的节点默认值(无值类返回 None;对齐 C++ 返回 invalid
/// QVariant 的分支)。
fn default_value_for_param(def: &ParamDef) -> Option<NodeValue> {
let props = &def.props;
match def.ofx_type.as_str() {
ofx::TYPE_INTEGER => Some(NodeValue::Int(prop_int(props, ofx::P_DEFAULT, 0) as i64)),
ofx::TYPE_CHOICE => Some(NodeValue::Combo(prop_int(props, ofx::P_DEFAULT, 0) as i64)),
ofx::TYPE_BOOLEAN => Some(NodeValue::Boolean(prop_int(props, ofx::P_DEFAULT, 0) != 0)),
ofx::TYPE_DOUBLE => {
let mut val = prop_double(props, ofx::P_DEFAULT, 0);
if is_normalised_coord_system(def) {
let (x_size, _) = project_extent();
val = to_canonical(val, x_size);
}
Some(NodeValue::Float(val))
}
ofx::TYPE_STRING | ofx::TYPE_STRCHOICE => {
Some(NodeValue::Text(prop_str(props, ofx::P_DEFAULT, 0)))
}
ofx::TYPE_CUSTOM => {
// C++ 亦按字符串读默认(plugin.cpp:83-87);节点输入是
// binary,按字节保留。
Some(NodeValue::Binary(
prop_str(props, ofx::P_DEFAULT, 0).into_bytes(),
))
}
ofx::TYPE_RGB | ofx::TYPE_RGBA => {
let count = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 };
let mut values = [0.0, 0.0, 0.0, 1.0];
for i in 0..count {
values[i] = prop_double(props, ofx::P_DEFAULT, i);
}
let alpha = if count == 4 { values[3] } else { 1.0 };
Some(NodeValue::Color([values[0], values[1], values[2], alpha]))
}
ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER2D | ofx::TYPE_INTEGER3D => {
let is_double = matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D);
let count = if matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D) {
2
} else {
3
};
let mut values = [0.0f64; 3];
if is_double {
for i in 0..count {
values[i] = prop_double(props, ofx::P_DEFAULT, i);
}
if is_normalised_coord_system(def) {
let (x_size, y_size) = project_extent();
values[0] = to_canonical(values[0], x_size);
values[1] = to_canonical(values[1], y_size);
if count == 3 {
values[2] = to_canonical(values[2], x_size);
}
}
} else {
for i in 0..count {
values[i] = prop_int(props, ofx::P_DEFAULT, i) as f64;
}
}
if count == 2 {
Some(NodeValue::Vec2([values[0], values[1]]))
} else {
Some(NodeValue::Vec3([values[0], values[1], values[2]]))
}
}
ofx::TYPE_BYTES => Some(NodeValue::Binary(Vec::new())),
// PushButton/Group/Page/Parametric/未知 → invalidC++ 末尾
// return QVariant())。
_ => None,
}
}
/// 每插件的默认值缓存(C++ g_plugin_param_defaultsplugin.cpp:37)。
static DEFAULTS: OnceLock<Mutex<HashMap<String, HashMap<String, NodeValue>>>> = OnceLock::new();
fn defaults_slot() -> &'static Mutex<HashMap<String, HashMap<String, NodeValue>>> {
DEFAULTS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 取(或首访构建)某插件的参数默认值表(build_default_values
/// plugin.cpp:224-246)。
fn cached_defaults(
plugin_id: &str,
params: &crate::param::ParamSetInstance,
) -> HashMap<String, NodeValue> {
{
let cache = defaults_slot().lock().unwrap_or_else(|e| e.into_inner());
if let Some(hit) = cache.get(plugin_id) {
return hit.clone();
}
}
let mut defaults = HashMap::new();
for p in &params.params {
let ofx_type = p.def.ofx_type.as_str();
if ofx_type == ofx::TYPE_GROUP || ofx_type == ofx::TYPE_PAGE || ofx_type == ofx::TYPE_PUSHBUTTON {
continue;
}
if p.def.name.is_empty() {
continue;
}
if let Some(v) = default_value_for_param(&p.def) {
defaults.insert(p.def.name.clone(), v);
}
}
defaults_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(plugin_id.to_string(), defaults.clone());
defaults
}
// ---------------------------------------------------------------------------
// 颜色语义启发式(plugin.cpp deduce_color_semantic:133-222
// ---------------------------------------------------------------------------
/// RGB/RGBA 参数是取色器("color")还是逐通道标量组("scalar")。
fn deduce_color_semantic(def: &ParamDef, group_labels: &HashMap<String, String>) -> &'static str {
const COLOR_KEYWORDS: &[&str] = &["color", "colour", "fill", "tint", "key"];
const SCALAR_KEYWORDS: &[&str] = &[
"gamma",
"contrast",
"gain",
"offset",
"saturation",
"exposure",
"brightness",
"lift",
"multiply",
"scale",
"pivot",
];
if def.ofx_type != ofx::TYPE_RGB && def.ofx_type != ofx::TYPE_RGBA {
return "color";
}
let label = prop_str(&def.props, ofx::PROP_LABEL, 0).to_lowercase();
let hint = prop_str(&def.props, ofx::P_HINT, 0).to_lowercase();
let name = def.name.to_lowercase();
// 规则 1:显式颜色关键词 → color。
for kw in COLOR_KEYWORDS {
if label.contains(kw) || hint.contains(kw) || name.contains(kw) {
return "color";
}
}
// 规则 2:显式标量/调色关键词 → scalar。
for kw in SCALAR_KEYWORDS {
if label.contains(kw) || hint.contains(kw) || name.contains(kw) {
return "scalar";
}
}
// 规则 3display 范围显著越出 [0,1] → scalar。
let dim = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 };
for i in 0..dim {
let dmin = prop_double(&def.props, ofx::P_DISPLAY_MIN, i);
let dmax = prop_double(&def.props, ofx::P_DISPLAY_MAX, i);
if dmin < -0.01 || dmax > 1.01 {
return "scalar";
}
}
// 规则 4:默认值全相等 → scalar(lean)。
let mut defs = [0.0f64; 4];
for i in 0..dim {
defs[i] = prop_double(&def.props, ofx::P_DEFAULT, i);
}
let all_equal = (1..dim).all(|i| defs[i] == defs[0]);
if all_equal {
return "scalar";
}
// 规则 5:父 group 名含标量关键词 → scalar。
let parent = prop_str(&def.props, ofx::P_PARENT, 0).to_lowercase();
if !parent.is_empty() {
let group_label = group_labels
.get(&prop_str(&def.props, ofx::P_PARENT, 0))
.map(|s| s.to_lowercase())
.unwrap_or_default();
for kw in SCALAR_KEYWORDS {
if parent.contains(kw) || group_label.contains(kw) {
return "scalar";
}
}
}
// 兜底。
"color"
}
// ---------------------------------------------------------------------------
// clip 显示名(plugin.cpp clip_label_for_name:248-270
// ---------------------------------------------------------------------------
fn clip_label_for_name(name: &str, clip_props: Option<&PropertySet>) -> String {
// 过渡上下文 clip 名(ofxImageEffect.h:1435-1441)。
if name == SOURCE_CLIP {
return "Source".to_string();
}
if name == "SourceFrom" {
return "From".to_string();
}
if name == "SourceTo" {
return "To".to_string();
}
if let Some(props) = clip_props {
let label = prop_str(props, ofx::PROP_LABEL, 0);
if !label.is_empty() {
return label;
}
}
name.to_string()
}
// ---------------------------------------------------------------------------
// 参数翻译:OFX 参数 → 节点输入(plugin.cpp:331-490
// ---------------------------------------------------------------------------
/// OFX 类型 → 节点输入值类型(plugin.cpp:340-378 的类型表;
/// Group/Page 与未知类型(k_none)均无输入 → None,跳过)。
fn input_type_for(ofx_type: &str) -> Option<ValueType> {
Some(match ofx_type {
ofx::TYPE_INTEGER => ValueType::Int,
ofx::TYPE_DOUBLE => ValueType::Float,
ofx::TYPE_BOOLEAN => ValueType::Boolean,
ofx::TYPE_STRING => ValueType::Text,
ofx::TYPE_RGB | ofx::TYPE_RGBA => ValueType::Color,
ofx::TYPE_CHOICE => ValueType::Combo,
ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D => ValueType::Vec2,
ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER3D => ValueType::Vec3,
ofx::TYPE_STRCHOICE => ValueType::StrCombo,
ofx::TYPE_BYTES | ofx::TYPE_CUSTOM => ValueType::Binary,
ofx::TYPE_PUSHBUTTON => ValueType::PushButton,
_ => return None,
})
}
/// 从描述符实例构建节点输入表(PluginNode 构造函数的参数/clip 循环)。
fn build_core(inst: &Instance) -> NodeCore {
let mut core = NodeCore::new();
let defaults = cached_defaults(&inst.plugin.identifier, &inst.params);
// 第 1 遍:group/page 标签与 param→page 映射(plugin.cpp:300-330)。
let mut group_labels: HashMap<String, String> = HashMap::new();
let mut page_for_param: HashMap<String, String> = HashMap::new();
for p in &inst.params.params {
let def = &p.def;
match def.ofx_type.as_str() {
ofx::TYPE_GROUP => {
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
group_labels.insert(
def.name.clone(),
if label.is_empty() { def.name.clone() } else { label },
);
}
ofx::TYPE_PAGE => {
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
let page_label = if label.is_empty() { def.name.clone() } else { label };
let count = def.props.dimension(ofx::P_PAGE_CHILD);
for i in 0..count {
let child = prop_str(&def.props, ofx::P_PAGE_CHILD, i);
if child == ofx::PAGE_SKIP_ROW || child == ofx::PAGE_SKIP_COLUMN {
continue;
}
page_for_param.insert(child, page_label.clone());
}
}
_ => {}
}
}
// 第 2 遍:值参数 → 输入(plugin.cpp:331-490)。
for p in &inst.params.params {
let def = &p.def;
let Some(value_type) = input_type_for(&def.ofx_type) else {
continue;
};
let input_id = def.name.clone();
if input_id.is_empty() {
continue;
}
let is_secret = prop_int(&def.props, ofx::P_SECRET, 0) != 0;
// 默认值(缓存表;C++ defaults.value(input_id))。
let mut input = match defaults.get(&input_id) {
Some(default_value) => {
let input = Input::new(&input_id, value_type, default_value.clone());
if !matches!(value_type, ValueType::PushButton) {
core.set_standard_value(&input_id, 0, default_value.clone());
}
input
}
None => Input::new(&input_id, value_type, NodeValue::None),
};
if is_secret {
input.flags |= input_flags::HIDDEN;
}
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
input.display_name = if label.is_empty() { input_id.clone() } else { label };
let parent = prop_str(&def.props, ofx::P_PARENT, 0);
if !parent.is_empty() {
let group = group_labels.get(&parent).cloned().unwrap_or(parent.clone());
input
.properties
.push(("ui_group".to_string(), NodeValue::Text(group)));
}
if let Some(page) = page_for_param.get(&input_id) {
input
.properties
.push(("ui_page".to_string(), NodeValue::Text(page.clone())));
}
if matches!(value_type, ValueType::Color) {
let semantic = deduce_color_semantic(def, &group_labels);
input.properties.push((
"color_semantic".to_string(),
NodeValue::Text(semantic.to_string()),
));
// display min/max 取第 0 维(plugin.cpp:418-424)。
input.properties.push((
"min".to_string(),
NodeValue::Float(prop_double(&def.props, ofx::P_DISPLAY_MIN, 0)),
));
input.properties.push((
"max".to_string(),
NodeValue::Float(prop_double(&def.props, ofx::P_DISPLAY_MAX, 0)),
));
let hint = prop_str(&def.props, ofx::P_HINT, 0);
if !hint.is_empty() {
input
.properties
.push(("tooltip".to_string(), NodeValue::Text(hint)));
}
}
if matches!(value_type, ValueType::Combo | ValueType::StrCombo) {
let mut option_labels = Vec::new();
let mut option_values = Vec::new();
let label_count = def.props.dimension(ofx::P_CHOICE_OPTION);
let value_count = def.props.dimension(ofx::P_CHOICE_ENUM);
for i in 0..label_count {
option_labels.push(prop_str(&def.props, ofx::P_CHOICE_OPTION, i));
}
for i in 0..value_count {
option_values.push(prop_str(&def.props, ofx::P_CHOICE_ENUM, i));
}
if option_labels.is_empty() && !option_values.is_empty() {
option_labels = option_values.clone();
}
if option_values.is_empty() && !option_labels.is_empty() {
option_values = option_labels.clone();
}
// ChoiceOrder 稳定排序(plugin.cpp:449-472)。
let order_count = def.props.dimension(ofx::P_CHOICE_ORDER);
if order_count == option_labels.len() && option_labels.len() == option_values.len() {
let mut indices: Vec<usize> = (0..option_labels.len()).collect();
indices.sort_by_key(|&i| prop_int(&def.props, ofx::P_CHOICE_ORDER, i));
option_labels = indices.iter().map(|&i| option_labels[i].clone()).collect();
option_values = indices.iter().map(|&i| option_values[i].clone()).collect();
}
// combo 选项经重复键属性携带(NodeValue 无字符串表变体;
// 消费方按 ("combo_option", _) 全量收集,str_combo 的
// 值表为 ("combo_value", _)——C++ set_combo_box_strings /
// "combo_value_str" 的等价物)。
for label in &option_labels {
input.properties.push((
"combo_option".to_string(),
NodeValue::Text(label.clone()),
));
}
if matches!(value_type, ValueType::StrCombo) {
for value in &option_values {
input.properties.push((
"combo_value".to_string(),
NodeValue::Text(value.clone()),
));
}
}
}
core.add_input(input);
}
// clip → 纹理输入(plugin.cpp:492-501)。
let mut has_texture_input = false;
for clip in &inst.clips {
if clip.name == "Output" {
continue;
}
let mut input = Input::new(&clip.name, ValueType::Texture, NodeValue::None);
input.display_name = clip_label_for_name(&clip.name, Some(&clip.props));
core.add_input(input);
has_texture_input = true;
}
// effect_input 选择(plugin.cpp:503-514)。
if core.has_input(SOURCE_CLIP) {
core.effect_input = SOURCE_CLIP.to_string();
} else if core.has_input(TEXTURE_INPUT) {
core.effect_input = TEXTURE_INPUT.to_string();
} else if has_texture_input {
let mut input = Input::new(TEXTURE_INPUT, ValueType::Texture, NodeValue::None);
input.display_name = "Texture".to_string();
core.add_input(input);
core.effect_input = TEXTURE_INPUT.to_string();
}
core
}
/// 上下文的显示子分类(plugin.cpp:281-290)。
fn sub_category_for(context: &str) -> &'static str {
match context {
"OfxImageEffectContextFilter" => "Filter",
"OfxImageEffectContextGenerator" => "Generator",
"OfxImageEffectContextTransition" => "Transition",
_ => "General",
}
}
/// 插件描述符的显示名(plugin.cpp PluginNode::name:517-524)。
fn plugin_display_name(inst: &Instance) -> String {
let label = prop_str(&inst.plugin.descriptor.props, ofx::PROP_LABEL, 0);
if label.is_empty() {
inst.plugin.identifier.clone()
} else {
label
}
}
/// 插件描述符的描述文本(plugin.cpp PluginNode::description
/// :531-538)。
fn plugin_description(inst: &Instance) -> String {
prop_str(&inst.plugin.descriptor.props, PROP_PLUGIN_DESCRIPTION, 0)
}
// ---------------------------------------------------------------------------
// 节点构造(每次建图都新建实例——C++ 库条目共享一个实例是 Qt 父子
// 所有权模型;Rust 侧每节点独占实例才能支持 duplicate 与并发渲染)
// ---------------------------------------------------------------------------
/// 为 (identifier, context) 建一个插件节点(新实例 + 注册表登记)。
fn create_plugin_node(
identifier: &str,
context: &str,
) -> Option<(NodeCore, Box<dyn NodeBehavior>)> {
let inst = Host::global()
.create_instance(identifier, Some(context))
.ok()?;
let core = build_core(&inst.value);
let name = plugin_display_name(&inst.value);
let description = plugin_description(&inst.value);
let sub_category = sub_category_for(context).to_string();
let id = register_instance(inst);
let node = PluginNode::new(
PluginInstanceHandle(id),
name,
identifier.to_string(),
description,
sub_category,
);
Some((core, Box::new(node)))
}
/// 扫描宿主插件缓存并向节点工厂注册动态条目(C++
/// `NodeFactory::register_plugin_nodes`factory.cpp:148-186)。
/// 返回新注册的 type id 列表;已存在的 id 跳过(register_dynamic
/// 去重,对齐 C++ existing_ids 检查)。
pub fn register_plugin_nodes() -> Vec<String> {
install_render_executor();
let host = Host::global();
let mut registered = Vec::new();
for i in 0..host.cache.count() {
let Some(plugin) = host.cache.at(i) else {
continue;
};
// 上下文选择:filter 优先,否则第一个(factory.cpp:171-177)。
let context = if plugin.contexts.iter().any(|c| c == "OfxImageEffectContextFilter") {
"OfxImageEffectContextFilter".to_string()
} else {
match plugin.contexts.first() {
Some(c) => c.clone(),
None => {
eprintln!(
"Skipping OFX plugin with no contexts: {}",
plugin.identifier
);
continue;
}
}
};
// 元数据实例(name/description;建完即弃)。describeInContext
// 失败的插件无法实例化(常见于只支持 Vegas 立体声等厂商套件
// 的插件)——记录原因,不静默跳过。
let inst = match host.create_instance(&plugin.identifier, Some(&context)) {
Ok(inst) => inst,
Err(e) => {
eprintln!("[ofx] {}: instance creation failed: {e}", plugin.identifier);
continue;
}
};
let name = plugin_display_name(&inst.value);
let description = plugin_description(&inst.value);
let sub_category = sub_category_for(&context).to_string();
drop(inst);
let identifier = plugin.identifier.clone();
let create: DynNodeConstructor = Arc::new(move || {
create_plugin_node(&identifier, &context)
.unwrap_or_else(oaknode::nodes::plugin::create)
});
let meta = DynamicNodeMeta {
type_id: plugin.identifier.clone(),
name,
categories: vec![Category::OpenFx],
sub_category,
description,
create,
};
if oaknode::factory::Factory::global().register_dynamic(meta) {
registered.push(plugin.identifier.clone());
}
}
registered
}
// ---------------------------------------------------------------------------
// 渲染执行器 + duplicator(依赖反转的 oakplugin 侧半环)
// ---------------------------------------------------------------------------
/// 字符串族参数注入(POD 无字符串表达;直接 set_ofx)。
fn set_string_param(inst: &Instance, key: &str, expected_type: &str, value: &str) {
let Some(p) = inst.params.find(key) else {
return;
};
if p.def.ofx_type != expected_type {
return;
}
let Ok(cs) = CString::new(value) else {
return;
};
let pv = if expected_type == ofx::TYPE_STRING {
ParamValue::String(cs)
} else {
ParamValue::StrChoice(cs)
};
p.set_ofx(pv);
}
/// executor 槽实现:JobSpec::Plugin → render_driver::render_frame。
fn execute_plugin_job(
req: &oakrender::eval::PluginJobRequest<'_>,
) -> oakrender::error::Result<oakrender::texture::Texture> {
use oakrender::error::Error;
let oakrender::eval::JobSpec::Plugin {
instance,
time,
effect_input_id,
inputs,
values,
} = req.spec
else {
return Err(Error::Invalid);
};
let inst = instance_from_id(*instance).ok_or_else(|| {
Error::Failed(format!("插件实例 {instance} 未登记(实例已释放?)"))
})?;
if req.src.is_dummy() {
return Err(Error::Failed("plugin job 无可用输入纹理".into()));
}
// 参数注入:数值族走 render_driver 的 POD 覆盖;字符串族 POD 无
// 表达,这里直接 set_ofx(对齐 pluginrenderer.cpp 的
// StringInstance::set 分支)。
let mut pod_values = Vec::new();
for (key, nv) in values {
match nv {
NodeValue::Text(s) => set_string_param(&inst.value, key, ofx::TYPE_STRING, s),
NodeValue::StrCombo(s) => set_string_param(&inst.value, key, ofx::TYPE_STRCHOICE, s),
NodeValue::PushButton | NodeValue::None => {}
other => {
if let Some(v) = crate::node::Value::from_node_value(other) {
pod_values.push((key.clone(), v));
}
}
}
}
// 输出纹理:与输入同尺寸的 F32 帧(render_driver 校验 F32)。
let dst_frame = oakrender::eval::generate_frame(
oakcore_rs::Rational::from_double(*time),
req.src.size(),
oakcore_rs::PixelFormat::F32,
)?;
let job = crate::render_driver::RenderJob {
time: *time,
dst: oakrender::texture::Texture::wrap_frame(dst_frame),
src: Some(req.src.clone()),
effect_input_id: effect_input_id.clone(),
inputs: inputs.clone(),
values: pod_values,
renderer: None,
clear_destination: false,
interactive: false,
};
let (out, _rois) = crate::render_driver::render_frame(&inst.value, &job)
.map_err(|e| Error::Failed(format!("插件渲染失败:{e:?}")))?;
Ok(out)
}
/// duplicator 槽实现:duplicate() 经注册表换新实例。
fn duplicate_instance(old: PluginInstanceHandle) -> Option<PluginInstanceHandle> {
let inst = instance_from_id(old.0)?;
let identifier = inst.value.plugin.identifier.clone();
let context = inst.value.context.clone();
let new_inst = Host::global()
.create_instance(&identifier, Some(&context))
.ok()?;
Some(PluginInstanceHandle(register_instance(new_inst)))
}
static EXECUTOR_INSTALLED: OnceLock<()> = OnceLock::new();
/// 把渲染执行器装进 oakrender 的 executor 槽、duplicator 装进
/// oaknode(幂等)。[`register_plugin_nodes`] 已内含;app 侧单独
/// 初始化渲染管线时也可直接调。
pub fn install_render_executor() {
EXECUTOR_INSTALLED.get_or_init(|| {
oakrender::eval::set_plugin_executor(Some(Arc::new(execute_plugin_job)));
oaknode::nodes::plugin::set_plugin_duplicator(Some(Arc::new(duplicate_instance)));
});
}
// ---------------------------------------------------------------------------
// 测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Records what the mock plugin entry saw, so the push-button test can
/// assert the kOfxActionInstanceChanged routing and its inArgs.
static PUSH_ENTRY_CALLS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
#[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);
}
/// 构造一个只含 push-button 参数的最小实例(直接登记进注册表)。
fn instance_with_push_button() -> u64 {
use std::ffi::{c_char, c_void};
use std::sync::atomic::AtomicU32;
use crate::descriptor::EffectDescriptor;
use crate::handle::RefBox;
use crate::host::Plugin;
use crate::param::{ParamDef, ParamInstance, ParamSetInstance};
unsafe extern "C" fn dummy_entry(
action: *const c_char,
_: *const c_void,
in_args: *mut c_void,
_: *mut c_void,
) -> i32 {
if !action.is_null() {
let action = unsafe {
std::ffi::CStr::from_ptr(action).to_string_lossy().into_owned()
};
if action == crate::host::ACTION_INSTANCE_CHANGED {
let mut calls = PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner());
calls.push(action);
// The instanceChanged inArgs contract (ofxCore.h:405-435):
// kOfxPropChangeReason = kOfxChangeUserEdited.
let props = unsafe { &*(in_args as *const crate::property::PropertySet) };
let reason = props.get(crate::host::PROP_CHANGE_REASON, 0);
let reason = match reason {
Some(crate::property::Value::String(s)) => {
s.to_string_lossy().into_owned()
}
_ => String::new(),
};
calls.push(reason);
}
}
0
}
let plugin = Arc::new(Plugin {
identifier: "test.plugin".into(),
version: (1, 0),
bundle_path: std::path::PathBuf::new(),
contexts: vec![],
descriptor: EffectDescriptor::new(),
lib: std::ptr::null_mut(),
entry: dummy_entry,
ofx_plugin: std::ptr::null_mut(),
});
let mut params = ParamSetInstance { params: Vec::new() };
params.params.push(Box::new(ParamInstance::from_def(ParamDef::new(
"button",
ofx::TYPE_PUSHBUTTON,
))));
params.params.push(Box::new(ParamInstance::from_def(ParamDef::new(
"gain",
ofx::TYPE_DOUBLE,
))));
let inst = crate::instance::Instance {
props: crate::property::PropertySet::new(),
plugin,
context: "OfxImageEffectContextFilter".into(),
params,
clips: Vec::new(),
node_identity: std::sync::atomic::AtomicUsize::new(0),
destroyed: std::sync::atomic::AtomicBool::new(false),
sequence_range: std::sync::Mutex::new(None),
progress_cb: std::sync::Mutex::new(None),
cancel: std::sync::atomic::AtomicBool::new(false),
edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()),
render_lock: std::sync::Mutex::new(()),
interact: std::sync::Mutex::new(None),
};
register_instance(Arc::new(RefBox {
refs: AtomicU32::new(1),
value: inst,
}))
}
/// push_button_clicked:实例/参数查无与类型不匹配 → false;命中 →
/// true、置 PushButton 值并把按下路由为 kOfxActionInstanceChanged
/// UserEditedinArgs 带 change reason / name / type / time /
/// render scale)。
#[test]
fn push_button_clicked_routes_instance_changed() {
*PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new();
let id = instance_with_push_button();
assert!(push_button_clicked(id, "button"));
// 类型不匹配 / 查无参数 / 查无实例:不触发 entry。
assert!(!push_button_clicked(id, "gain"));
assert!(!push_button_clicked(id, "nope"));
assert!(!push_button_clicked(u64::MAX, "button"));
// 只 "button" 那次按下进了插件 entry,且 reason 是 UserEdited。
let calls = PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(calls, vec!["OfxActionInstanceChanged", "OfxChangeUserEdited"]);
unregister_instance(id);
}
}