// 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 侧只承载行为
//! ([`oak_node::nodes::plugin::PluginNode`])——这是依赖方向
//! (oakplugin → oakrender → oaknode)强加的拆分。
//!
//! 职责:
//! - **实例注册表**:节点持 [`oak_node::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 oak_node::factory::{DynNodeConstructor, DynamicNodeMeta};
use oak_node::input::{flags as input_flags, Input};
use oak_node::node::{Category, NodeBehavior, NodeCore};
use oak_node::nodes::plugin::{
PluginInstanceHandle, PluginNode, SOURCE_CLIP, TEXTURE_INPUT,
};
use oak_node::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()
}
/// 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> = 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())),
// parametric:值 = 默认曲线的确定性 JSON(节点输入无曲线模型,
// 经 Text 承载;撤销/工程序列化随标准值免费获得)。默认曲线
// 来自 def.default(describe 期插件经 parametric suite 改过
// 的默认),不是 P_DEFAULT 属性(parametric 无标量默认)。
ofx::TYPE_PARAMETRIC => match &def.default {
ParamValue::Parametric(curves) => {
Some(NodeValue::Text(crate::param_curve::curves_to_json(curves)))
}
// 防御:type_default 恒产出 Parametric,此处只兜底。
_ => None,
},
// PushButton/Group/Page/未知 → 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,
// parametric:值 = 默认曲线的 JSON 文本(见
// [`crate::param_curve::curves_to_json`])。
ofx::TYPE_PARAMETRIC => ValueType::Parametric,
_ => 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())));
}
// parametric 专属属性(消费方 = 检查器曲线编辑器;值从
// OfxParametricParameterSuite 的属性表搬运):
// - "parametric_dimension":int,维度数;
// - "parametric_range":Vec2,定义域 (lo, hi);
// - "parametric_ui_colour":每维一条(重复键,对齐 combo_option
// 惯例),Color([r,g,b,1]),取自 double×3N 属性,未配置不携带。
// 无参数动画(第 1 期)→ 不可键帧。
if matches!(value_type, ValueType::Parametric) {
input.flags |= input_flags::NOT_KEYFRAMABLE;
let dim = prop_int(&def.props, ofx::P_PARAMETRIC_DIMENSION, 0).max(1);
input.properties.push((
"parametric_dimension".to_string(),
NodeValue::Int(dim as i64),
));
input.properties.push((
"parametric_range".to_string(),
NodeValue::Vec2([
prop_double(&def.props, ofx::P_PARAMETRIC_RANGE, 0),
prop_double(&def.props, ofx::P_PARAMETRIC_RANGE, 1),
]),
));
let colour_dim = def.props.dimension(ofx::P_PARAMETRIC_UI_COLOUR);
for i in 0..dim as usize {
if i * 3 + 2 >= colour_dim {
break;
}
input.properties.push((
"parametric_ui_colour".to_string(),
NodeValue::Color([
prop_double(&def.props, ofx::P_PARAMETRIC_UI_COLOUR, i * 3),
prop_double(&def.props, ofx::P_PARAMETRIC_UI_COLOUR, i * 3 + 1),
prop_double(&def.props, ofx::P_PARAMETRIC_UI_COLOUR, i * 3 + 2),
1.0,
]),
));
}
}
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;建完即弃)。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(oak_node::nodes::plugin::create)
});
let meta = DynamicNodeMeta {
type_id: plugin.identifier.clone(),
name,
categories: vec![Category::OpenFx],
sub_category,
description,
create,
};
if oak_node::factory::Factory::global().register_dynamic(meta) {
registered.push(plugin.identifier.clone());
}
}
registered
}
// ---------------------------------------------------------------------------
// 渲染执行器 + duplicator(依赖反转的 oakplugin 侧半环)
// ---------------------------------------------------------------------------
/// 文本族参数注入(POD 无字符串表达;直接 set_ofx)。按参数的 OFX
/// 类型分发:String → String、StrChoice → StrChoice;Parametric →
/// 解析 JSON 曲线集([`crate::param_curve::curves_from_json`],宿主侧
/// [`crate::param_curve::curves_to_json`] 的格式)写回
/// `ParamValue::Parametric`——即"节点输入值变化 → 写回实例"的
/// parametric 路径(渲染期参数覆盖的分支,见
/// [`execute_plugin_job`])。JSON 解析失败 / 类型不符 → 忽略(与
/// 字符串族类型不匹配静默一致)。
fn set_text_param(inst: &Instance, key: &str, text: &str) {
let Some(p) = inst.params.find(key) else {
return;
};
match p.def.ofx_type.as_str() {
ofx::TYPE_STRING => {
let Ok(cs) = CString::new(text) else {
return;
};
p.set_ofx(ParamValue::String(cs));
}
ofx::TYPE_STRCHOICE => {
let Ok(cs) = CString::new(text) else {
return;
};
p.set_ofx(ParamValue::StrChoice(cs));
}
ofx::TYPE_PARAMETRIC => {
if let Some(curves) = crate::param_curve::curves_from_json(text) {
p.set_ofx(ParamValue::Parametric(curves));
}
}
_ => {}
}
}
/// executor 槽实现:JobSpec::Plugin → render_driver::render_frame。
fn execute_plugin_job(
req: &oak_render::eval::PluginJobRequest<'_>,
) -> oak_render::error::Result {
use oak_render::error::Error;
let oak_render::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 分支 + parametric 的 JSON 写回)。
let mut pod_values = Vec::new();
for (key, nv) in values {
match nv {
NodeValue::Text(s) | NodeValue::StrCombo(s) => {
set_text_param(&inst.value, key, 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 = oak_render::eval::generate_frame(
oak_core::Rational::from_double(*time),
req.src.size(),
oak_core::PixelFormat::F32,
)?;
let job = crate::render_driver::RenderJob {
time: *time,
dst: oak_render::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(|| {
oak_render::eval::set_plugin_executor(Some(Arc::new(execute_plugin_job)));
oak_render::eval::set_plugin_instance_factory(Some(Arc::new(shared_plugin_instance)));
oak_node::nodes::plugin::set_plugin_duplicator(Some(Arc::new(duplicate_instance)));
});
}
// ---------------------------------------------------------------------------
// montage 渲染路径的共享实例(渲染进程按插件标识惰性创建 + 进程级缓存)
// ---------------------------------------------------------------------------
/// montage 效果栈携带插件标识而非实例 id(montage 在主进程从时间线
/// 解析,实例活在渲染进程)。渲染进程经此工厂按需创建实例并缓存:
/// 参数每次渲染前由 montage 注入([`execute_plugin_job`] 的 values
/// 覆盖),worker 单线程顺序渲染,共享实例无跨帧状态冲突。
fn shared_plugin_instance(identifier: &str) -> Option {
static SHARED: OnceLock>> = OnceLock::new();
let cache = SHARED.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(&id) = cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(identifier)
{
return Some(id);
}
let host = Host::global();
let plugin = host.cache.find(identifier)?;
// 上下文选择与 [`register_plugin_nodes`] 一致(filter 优先,否则
// 首个支持上下文;factory.cpp:171-177)。
let context = if plugin.contexts.iter().any(|c| c == "OfxImageEffectContextFilter") {
"OfxImageEffectContextFilter".to_string()
} else {
plugin.contexts.first()?.clone()
};
let inst = host.create_instance(identifier, Some(&context)).ok()?;
let id = register_instance(inst);
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(identifier.to_string(), id);
Some(id)
}
// ---------------------------------------------------------------------------
// 测试
// ---------------------------------------------------------------------------
#[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> = 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("OfxParamTypeBogus"), None);
// parametric → Parametric(值 = 曲线 JSON 文本)。
assert_eq!(
input_type_for(ofx::TYPE_PARAMETRIC),
Some(ValueType::Parametric)
);
}
#[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);
}
#[test]
fn shared_instance_unknown_identifier_yields_none() {
// 缓存里查无此标识 → None(montage 路径据此按"无求值器"
// 告警并直通)。
assert!(shared_plugin_instance("com.example.definitely-missing").is_none());
}
/// 构造一个只含 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(),
unloaded: std::sync::atomic::AtomicBool::new(false),
});
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
/// (UserEdited,inArgs 带 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);
}
/// 构造一个带 parametric 参数(维度 2、自定义 range、双维 UI 颜色
/// 已配置)与一个 String 参数的实例(直接登记进注册表)。
fn instance_with_parametric() -> 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(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> i32 {
0
}
let mut def = ParamDef::new("curve", ofx::TYPE_PARAMETRIC);
def.props
.set_one(ofx::PROP_LABEL, PropValue::String(CString::new("Curve").unwrap()));
def.props
.set_one(ofx::P_PARAMETRIC_DIMENSION, PropValue::Int(2));
def.props.define(
ofx::P_PARAMETRIC_RANGE,
vec![PropValue::Double(0.0), PropValue::Double(1.0)],
);
def.props.define(
ofx::P_PARAMETRIC_UI_COLOUR,
vec![
PropValue::Double(1.0),
PropValue::Double(0.0),
PropValue::Double(0.0),
PropValue::Double(0.0),
PropValue::Double(1.0),
PropValue::Double(0.0),
],
);
let mut params = ParamSetInstance { params: Vec::new() };
params
.params
.push(Box::new(ParamInstance::from_def(def)));
params.params.push(Box::new(ParamInstance::from_def(
ParamDef::new("title", ofx::TYPE_STRING),
)));
let plugin = Arc::new(Plugin {
identifier: "test.parametric".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(),
unloaded: std::sync::atomic::AtomicBool::new(false),
});
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,
}))
}
/// 翻译 pass 产出 Parametric 输入:value_type、默认值(= 默认曲线
/// 的 JSON)、display_name(= label)、不可键帧 + 专属属性
/// (dimension / range / 每维一条 UI 颜色)。
#[test]
fn build_core_produces_parametric_input() {
let id = instance_with_parametric();
let inst = instance_from_id(id).expect("已登记");
let core = build_core(&inst.value);
let input = core.get_input("curve").expect("parametric 输入应存在");
assert_eq!(input.value_type, ValueType::Parametric);
assert_eq!(input.display_name, "Curve");
assert_ne!(input.flags & input_flags::NOT_KEYFRAMABLE, 0);
// 默认值 = def.default 曲线集的 JSON(type_default 产出 1 条
// 恒等曲线;dimension 属性只做索引门控,未配置曲线按恒等读,
// 不扩充默认值)。
let expected = crate::param_curve::curves_to_json(&[crate::param_curve::Curve::identity(
0.0, 1.0,
)]);
match &input.default {
NodeValue::Text(s) => assert_eq!(s, &expected, "默认值应为曲线 JSON"),
other => panic!("预期 Text(JSON),实际 {other:?}"),
}
// 标准值 = 默认(工程序列化从这里走)。
assert_eq!(core.standard_value("curve", -1), input.default);
// 专属属性。
let props = |name: &str| {
input
.properties
.iter()
.filter(|(k, _)| k == name)
.map(|(_, v)| v.clone())
.collect::>()
};
assert_eq!(props("parametric_dimension"), vec![NodeValue::Int(2)]);
assert_eq!(
props("parametric_range"),
vec![NodeValue::Vec2([0.0, 1.0])]
);
// 双维 → 两条颜色(重复键)。
assert_eq!(
props("parametric_ui_colour"),
vec![
NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
]
);
unregister_instance(id);
}
/// parametric 输入(Text JSON)→ 实例写回:有效 JSON 解析并
/// `set_ofx(Parametric)`;坏 JSON / 类型不符 → 静默忽略(保持现值)。
#[test]
fn parametric_input_writes_back_to_instance() {
let id = instance_with_parametric();
let inst = instance_from_id(id).expect("已登记");
// 有效 JSON(双维,第二维单点)→ 曲线写回,求值即实例曲线。
let json = r#"{"curves":[[{"key":0,"value":0,"slope":1},{"key":0.5,"value":0.7,"slope":1}],[{"key":0,"value":0,"slope":1}]]}"#;
set_text_param(&inst.value, "curve", json);
let curves = match inst.value.params.find("curve").unwrap().get() {
ParamValue::Parametric(c) => c,
other => panic!("应写回 Parametric,实际 {other:?}"),
};
assert_eq!(curves[0].evaluate(0.5), 0.7);
assert_eq!(curves[1].len(), 1);
// 坏 JSON → 不改值(与字符串族类型不匹配静默一致)。
let before = inst.value.params.find("curve").unwrap().get();
set_text_param(&inst.value, "curve", "{broken");
assert_eq!(inst.value.params.find("curve").unwrap().get(), before);
// 非 parametric 参数(String)遇任意文本 → 字符串值路径。
set_text_param(&inst.value, "title", "hello");
assert!(matches!(
inst.value.params.find("title").unwrap().get(),
ParamValue::String(_)
));
unregister_instance(id);
}
}