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

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

End-to-end tests cover registration, param translation, CPU render
pixel assertions, identity passthrough and NaN fallback.
This commit is contained in:
2026-08-18 17:15:13 +08:00
parent 9daa266189
commit 2db1615453
21 changed files with 2595 additions and 189 deletions
+61
View File
@@ -221,6 +221,67 @@ src/
能力。修复:`init_descriptor_props` 补齐预定义(默认
"false"/空数组/None)。
## 阶段 6aOpenFX 引擎接线(oaknode/oakrender/oakplugin 收编)
把 OFX 插件接进 oaknode 节点工厂与 oakrender 评估环的接线层
(此前插件侧只有宿主/suite/渲染驱动,未进节点图)。全部落在
crates/,不动 src/app)与 gpui/。
- **`node_factory.rs`(新增)**
- 实例注册表:`register_instance/instance_from_id/unregister_instance`
u64 键 ↔ `Arc<RefBox<Instance>>`;进程级存活,对齐 C++ 工厂
持有;节点经 [`oaknode::nodes::plugin::PluginInstanceHandle`]
持键)。
- `register_plugin_nodes()`:遍历宿主插件缓存,filter 上下文优先
(否则首个),经 `Factory::register_dynamic` 注册动态节点条目
(已存在 id 跳过,对齐 C++ existing_ids)。返回新注册 id 列表。
- 参数翻译 `build_core`15 类 OFX 参数 → oaknode 输入(类型表、
默认值缓存、颜色语义启发式、combo ChoiceOrder 排序、secret→
hidden、ui_group/ui_page、min/max/tooltip、clip→纹理输入、
effect_input 选择),逐条对齐 engine/node/plugins/plugin.cpp。
- `install_render_executor()`:把 render_driver 装进
`oakrender::eval::set_plugin_executor`(依赖反转),duplicator
装进 `oaknode::nodes::plugin::set_plugin_duplicator`。
- `set_project_extent(w,h)`normalised 坐标默认值换算基准。
- **`gl_bridge.rs`(新增,spike 文档)**`texture_id` 桩的 GL 互
操作评估——方案 A(wgpu-hal GL 互操作)在 macOS 不可行(Metal
后端无 GL 命名空间);方案 B(独立离屏 GL 上下文 + 回读 +
wgpu upload)技术可行但暂缓(无真实 GL 插件可验证 + 需新 GL 依
赖 + 每帧同步回读 stall)。`texture_id` 保持恒 0GL 插件经 CPU
render action 正确出帧。
- **`progress.rs`**:新增 `UiProgressReporter` trait +
`ReporterFactory` + `set_reporter_factory`app 注入点)。render
未装 C 回调时装静默报告器,progressStart 携 (label,message) 经
工厂现造 UI 报告器;update 返回 false 即取消。
- **`suites/timeline.rs`**:新增 `ViewerTimeInfo` +
`ActiveViewerProvider` + `set_active_viewer_provider`app 注入
点)。timeline suite 的 getTime/getTimeBounds 在渲染上下文缺失时
回退活动 viewer 时间源。
- **`render_driver.rs`**`apply_param_overrides` 增 Double 标量
NaN/Inf 清洗(回退默认并告警)+ Min/Max 钳制(对齐
pluginrenderer.cpp:155-177)。
- **`clip.rs`**`fetch_image` 支持 U8/U16/F16 输入帧归一化转 F32
(对齐 oliveclip.cpp setInputTexture 的格式转换路径);转换中的
NaN/Inf 清洗为 0oliveclip.cpp copy_pixels 的 scrub);新增
`f16_to_f32` 手写位转换。
- **oakrender `eval.rs`**`JobSpec::Plugin` 扩为携带
instance/time/effect_input_id/inputs/values;新增
`PluginExecutor` + `set_plugin_executor` 依赖反转槽;
`process_plugin_job` 经执行器出帧,失败回退紫帧 (1,0,1,1);
`RenderEvalHooks` 实现 `RenderHooks::resolve` 解
`PluginJobPayload` 盒并执行。
- **oaknode**`factory.rs` 动态注册面(`register_dynamic`/
`dynamic_entries`/`create_any` 等);`nodes/plugin.rs` 重写为
PluginJobPayload 值模型 + duplicator 槽;`traverser.rs` 纹理输入
直通;`handle.rs` `get_checked<T>` 按 TypeId 判别盒类型。
app 接线(阶段 6b,不在本 crate 范围)经这些公共入口接入:
`node_factory::register_plugin_nodes`、
`progress::set_reporter_factory`、
`suites::timeline::set_active_viewer_provider`、
`node_factory::set_project_extent`、
`oaknode::factory::Factory::global().dynamic_entries/create_any`。
## 测试
运行(全量,含渲染像素路径的 oakrender 测试桩):
+123 -12
View File
@@ -61,6 +61,35 @@ fn components_from_props(props: &PropertySet) -> Option<crate::image::Components
}
}
/// IEEE 754 半精度 → 单精度(无 half 依赖,手写位转换;非规格数/
/// Inf/NaN 均按标准展开)。
fn f16_to_f32(bits: u16) -> f32 {
let sign = ((bits >> 15) & 1) as u32;
let exp = ((bits >> 10) & 0x1f) as u32;
let mant = (bits & 0x3ff) as u32;
let f32_bits = if exp == 0 {
if mant == 0 {
sign << 31
} else {
// 非规格数:规格化到 f32 指数域。
let mut m = mant;
let mut e: i32 = 127 - 15;
while m & 0x400 == 0 {
m <<= 1;
e -= 1;
}
let m = (m & 0x3ff) << 13;
(sign << 31) | (((e + 1) as u32) << 23) | m
}
} else if exp == 0x1f {
// Inf/NaN。
(sign << 31) | (0xff << 23) | (mant << 13)
} else {
(sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13)
};
f32::from_bits(f32_bits)
}
impl ClipInstance {
/// 按描述符实例化(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 清洗为 0oliveclip.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 清洗为 0oliveclip.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 = 0xC0000.5 = 0x3800。
assert_eq!(f16_to_f32(0x3C00), 1.0);
assert_eq!(f16_to_f32(0xC000), -2.0);
assert_eq!(f16_to_f32(0x3800), 0.5);
// 零与负零。
assert_eq!(f16_to_f32(0x0000), 0.0);
assert_eq!(f16_to_f32(0x8000).to_bits(), (0.0f32).to_bits() | (1 << 31));
// 非规格数:最小正规格数 2^-14 ≈ 0.000061042^-24 是最小非
// 规格数之一。
assert!((f16_to_f32(0x0400) - 2f32.powi(-14)).abs() < 1e-12);
assert!((f16_to_f32(0x0001) - 2f32.powi(-24)).abs() < 1e-12);
// Inf/NaN。
assert!(f16_to_f32(0x7C00).is_infinite() && f16_to_f32(0x7C00) > 0.0);
assert!(f16_to_f32(0xFC00).is_infinite() && f16_to_f32(0xFC00) < 0.0);
assert!(f16_to_f32(0x7E00).is_nan());
}
}
+68
View File
@@ -0,0 +1,68 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! GL 互操作桥(阶段 6a spike 结论;实现未落地)。
//!
//! ## 背景
//!
//! OFX 的 `OpenGLRender` 扩展要求宿主把 clip 纹理以 **GL 纹理名**
//! `kOfxImageEffectPropOpenGLTextureIndex`)递给插件,插件直接画
//! 进输出纹理。oak 的渲染后端是 wgpu[`crate::render::texture_id`]
//! 因此是恒 0 的桩:render_driver 的 `use_opengl` 决策据它恒回退
//! CPU 路径(GL 插件经 CPU render action 仍可工作)。本模块记录把
//! 桩替换为真实 GL 互操作的评估。
//!
//! ## 方案 Awgpu-hal GL 互操作 —— 不可行(macOS)
//!
//! wgpu-hal 的 GL 互操作面(`hal::api::Gles` 的 adapter/texture
//! 互转)只在实例本身就是 GLES 后端时存在。oak 在 macOS 上的 wgpu
//! 实例是 **Metal**wgpu 支持矩阵中 macOS/iOS 仅 Metal 为一等后
//! 端;OpenGL 需 ANGLE 转译层且仅 GLES 3.0,见
//! <https://github.com/gfx-rs/wgpu> 的 Supported Platforms 表与
//! CHANGELOG #4185"GLES backend optional on macOS")。Metal 后端与
//! GL 纹理名之间没有共享命名空间,wgpu 也不暴露跨后端纹理导入。
//! 即便为插件强行把整条管线切到 wgpu GLES 后端,也是以全局渲染性
//! 能换单一插件路径,方向错误。**结论:放弃。**
//!
//! ## 方案 B:独立离屏 GL 上下文 + 回读 + wgpu 上传 —— 技术可行,
//! 暂缓
//!
//! 路径:宿主自建原生 GL 上下文(macOS 为 CGLOpenGL 自 10.14 起
//! 弃用但仍可用),与插件共享纹理命名空间(CGL share group)→ 插件
//! render 画进 FBO 附着纹理 → `glReadPixels`/PBO 回读为 CPU 帧 →
//! 经 [`oakrender::backend::GpuContextLike::upload`] 上传成 wgpu
//! 纹理。接线点已就位:
//!
//! 1. [`crate::render::texture_id`] 返回真实 GL 名(当前恒 0);
//! 2. [`crate::render_driver::render_frame`] 的 `use_opengl` 分支
//! `plugin_supports_opengl && depth_ok && dst_id != 0`)随之
//! 生效,走 [`crate::instance::Instance::render_gl`]
//! 3. GL suite[`crate::suites::gl_render`])的纹理索引属性写出真
//! 值。
//!
//! 暂缓理由:
//! - 需引入新依赖(`glow` + CGL 绑定)与上下文生命周期/线程模型
//! 管理(OFX 插件可在自起线程回调 suite);
//! - 每帧同步回读是一次 GPU stall,性能上只在"插件本来就是 GL 加
//! 速"时划算,而当前无任何真实 GL OFX 插件可验证(测试 `.gl`
//! 变体只验证 suite 调用面);
//! - CPU 回退路径完整可用,GL 插件经 render action 正确出帧。
//!
//! ## TODO(phase-GL)
//!
//! 实现方案 BCGL 上下文工厂 + share group、`texture_id` 真值化、
//! 回读→上传流水线,以及一个真实 GL 插件的端到端验证(golden 帧比
//! 较)。在此之前,[`crate::render::texture_id`] 保持恒 0 桩。
+10 -1
View File
@@ -594,7 +594,8 @@ impl Instance {
crate::suites::set_render_ctx(Some(crate::suites::RenderCtx { time, scale, range }));
crate::suites::set_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(
+2
View File
@@ -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;
+964
View File
@@ -0,0 +1,964 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! OFX 插件 → 节点工厂接线(阶段 6a)。
//!
//! 对应 C++ `NodeFactory::register_plugin_nodes`
//! factory.cpp:148-186+ `PluginNode::PluginNode(Instance*)` 的
//! 参数翻译构造函数(engine/node/plugins/plugin.cpp:274-514)。
//! OFX 类型只存在于本 crate,故翻译放这里;oaknode 侧只承载行为
//! [`oaknode::nodes::plugin::PluginNode`])——这是依赖方向
//! oakplugin → oakrender → oaknode)强加的拆分。
//!
//! 职责:
//! - **实例注册表**:节点持 [`oaknode::nodes::plugin::PluginInstanceHandle`]
//! u64 键),这里持 `Arc<RefBox<Instance>>`C++ 的工厂实例在库
//! 条目内存活;Rust 侧等价于注册表进程级存活)。
//! - **参数翻译**15 类 OFX 参数 → oaknode 输入(类型表逐字对齐
//! plugin.cpp:340-378),默认值缓存、颜色语义启发式、combo 排序、
//! secret→hidden、ui_group/ui_page、clip→纹理输入、effect_input
//! 选择(plugin.cpp:494-514)。
//! - **渲染执行器**[`install_render_executor`] 把 render_driver 装
//! 进 oakrender 的 executor 槽(依赖反转;oakrender 看不见本 crate
//! 与 oaknode 的 duplicator 槽。
//!
//! app 注入点另见 [`crate::progress::set_reporter_factory`](进度
//! UI)与 [`crate::suites::timeline::set_active_viewer_provider`]
//! timeline suite 回退时间源)。
use std::collections::HashMap;
use std::ffi::CString;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use oaknode::factory::{DynNodeConstructor, DynamicNodeMeta};
use oaknode::input::{flags as input_flags, Input};
use oaknode::node::{Category, NodeBehavior, NodeCore};
use oaknode::nodes::plugin::{
PluginInstanceHandle, PluginNode, SOURCE_CLIP, TEXTURE_INPUT,
};
use oaknode::value::{NodeValue, ValueType};
use crate::handle::RefBox;
use crate::host::Host;
use crate::instance::Instance;
use crate::param::{self as ofx, ParamDef, ParamValue};
use crate::property::{PropertySet, Value as PropValue};
/// kOfxPropPluginDescription(描述符根属性)。
const PROP_PLUGIN_DESCRIPTION: &str = "OfxPropPluginDescription";
// ---------------------------------------------------------------------------
// 实例注册表
// ---------------------------------------------------------------------------
static INSTANCES: OnceLock<Mutex<HashMap<u64, Arc<RefBox<Instance>>>>> = OnceLock::new();
static NEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1);
fn instances() -> &'static Mutex<HashMap<u64, Arc<RefBox<Instance>>>> {
INSTANCES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 登记一个实例,返回非 0 句柄键(C++ 的 `Instance*` 指针身份)。
/// 实例进程级存活(对齐 C++ 工厂持有;Drop 才发 destroyInstance)。
pub fn register_instance(inst: Arc<RefBox<Instance>>) -> u64 {
let id = NEXT_INSTANCE_ID.fetch_add(1, Ordering::Relaxed);
instances()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id, inst);
id
}
/// 句柄键 → 实例(查无返回 None)。
pub fn instance_from_id(id: u64) -> Option<Arc<RefBox<Instance>>> {
instances()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&id)
.cloned()
}
/// 摘除登记(节点销毁路径;未登记的键 no-op)。
pub fn unregister_instance(id: u64) {
instances()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
}
/// 当前登记数(测试/诊断)。
pub fn registered_instance_count() -> usize {
instances().lock().unwrap_or_else(|e| e.into_inner()).len()
}
// ---------------------------------------------------------------------------
// 项目幅面(normalised 坐标默认值 → canonical 的换算基准)
// ---------------------------------------------------------------------------
//
// C++ get_project_extent 读 Current::current_video_params
// plugin.cpp:45-50);crate 侧无项目上下文,app 经
// set_project_extent 注入,未注入用 HD 缺省。
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
/// 注入项目幅面(宽、高;normalised 坐标默认值换算用)。
pub fn set_project_extent(width: f64, height: f64) {
*project_extent_slot()
.lock()
.unwrap_or_else(|e| e.into_inner()) = (width, height);
}
fn project_extent_slot() -> &'static Mutex<(f64, f64)> {
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
}
fn project_extent() -> (f64, f64) {
*project_extent_slot().lock().unwrap_or_else(|e| e.into_inner())
}
/// C++ to_canonicalplugin.cpp:52-55)。
fn to_canonical(normalised: f64, extent: f64) -> f64 {
if extent > 0.0 {
normalised * extent
} else {
normalised
}
}
// ---------------------------------------------------------------------------
// 属性读取助手
// ---------------------------------------------------------------------------
fn prop_str(props: &PropertySet, name: &str, index: usize) -> String {
match props.get(name, index) {
Some(PropValue::String(s)) => s.to_string_lossy().into_owned(),
_ => String::new(),
}
}
fn prop_double(props: &PropertySet, name: &str, index: usize) -> f64 {
match props.get(name, index) {
Some(PropValue::Double(v)) => v,
Some(PropValue::Int(v)) => v as f64,
_ => 0.0,
}
}
fn prop_int(props: &PropertySet, name: &str, index: usize) -> i32 {
match props.get(name, index) {
Some(PropValue::Int(v)) => v,
Some(PropValue::Double(v)) => v as i32,
_ => 0,
}
}
fn is_normalised_coord_system(def: &ParamDef) -> bool {
prop_str(&def.props, ofx::P_DEFAULT_COORD_SYS, 0) == ofx::V_COORD_NORMALISED
}
// ---------------------------------------------------------------------------
// 默认值(plugin.cpp default_value_for_param:57-131
// ---------------------------------------------------------------------------
/// 单个参数的节点默认值(无值类返回 None;对齐 C++ 返回 invalid
/// QVariant 的分支)。
fn default_value_for_param(def: &ParamDef) -> Option<NodeValue> {
let props = &def.props;
match def.ofx_type.as_str() {
ofx::TYPE_INTEGER => Some(NodeValue::Int(prop_int(props, ofx::P_DEFAULT, 0) as i64)),
ofx::TYPE_CHOICE => Some(NodeValue::Combo(prop_int(props, ofx::P_DEFAULT, 0) as i64)),
ofx::TYPE_BOOLEAN => Some(NodeValue::Boolean(prop_int(props, ofx::P_DEFAULT, 0) != 0)),
ofx::TYPE_DOUBLE => {
let mut val = prop_double(props, ofx::P_DEFAULT, 0);
if is_normalised_coord_system(def) {
let (x_size, _) = project_extent();
val = to_canonical(val, x_size);
}
Some(NodeValue::Float(val))
}
ofx::TYPE_STRING | ofx::TYPE_STRCHOICE => {
Some(NodeValue::Text(prop_str(props, ofx::P_DEFAULT, 0)))
}
ofx::TYPE_CUSTOM => {
// C++ 亦按字符串读默认(plugin.cpp:83-87);节点输入是
// binary,按字节保留。
Some(NodeValue::Binary(
prop_str(props, ofx::P_DEFAULT, 0).into_bytes(),
))
}
ofx::TYPE_RGB | ofx::TYPE_RGBA => {
let count = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 };
let mut values = [0.0, 0.0, 0.0, 1.0];
for i in 0..count {
values[i] = prop_double(props, ofx::P_DEFAULT, i);
}
let alpha = if count == 4 { values[3] } else { 1.0 };
Some(NodeValue::Color([values[0], values[1], values[2], alpha]))
}
ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER2D | ofx::TYPE_INTEGER3D => {
let is_double = matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_DOUBLE3D);
let count = if matches!(def.ofx_type.as_str(), ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D) {
2
} else {
3
};
let mut values = [0.0f64; 3];
if is_double {
for i in 0..count {
values[i] = prop_double(props, ofx::P_DEFAULT, i);
}
if is_normalised_coord_system(def) {
let (x_size, y_size) = project_extent();
values[0] = to_canonical(values[0], x_size);
values[1] = to_canonical(values[1], y_size);
if count == 3 {
values[2] = to_canonical(values[2], x_size);
}
}
} else {
for i in 0..count {
values[i] = prop_int(props, ofx::P_DEFAULT, i) as f64;
}
}
if count == 2 {
Some(NodeValue::Vec2([values[0], values[1]]))
} else {
Some(NodeValue::Vec3([values[0], values[1], values[2]]))
}
}
ofx::TYPE_BYTES => Some(NodeValue::Binary(Vec::new())),
// PushButton/Group/Page/Parametric/未知 → invalidC++ 末尾
// return QVariant())。
_ => None,
}
}
/// 每插件的默认值缓存(C++ g_plugin_param_defaultsplugin.cpp:37)。
static DEFAULTS: OnceLock<Mutex<HashMap<String, HashMap<String, NodeValue>>>> = OnceLock::new();
fn defaults_slot() -> &'static Mutex<HashMap<String, HashMap<String, NodeValue>>> {
DEFAULTS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 取(或首访构建)某插件的参数默认值表(build_default_values
/// plugin.cpp:224-246)。
fn cached_defaults(
plugin_id: &str,
params: &crate::param::ParamSetInstance,
) -> HashMap<String, NodeValue> {
{
let cache = defaults_slot().lock().unwrap_or_else(|e| e.into_inner());
if let Some(hit) = cache.get(plugin_id) {
return hit.clone();
}
}
let mut defaults = HashMap::new();
for p in &params.params {
let ofx_type = p.def.ofx_type.as_str();
if ofx_type == ofx::TYPE_GROUP || ofx_type == ofx::TYPE_PAGE || ofx_type == ofx::TYPE_PUSHBUTTON {
continue;
}
if p.def.name.is_empty() {
continue;
}
if let Some(v) = default_value_for_param(&p.def) {
defaults.insert(p.def.name.clone(), v);
}
}
defaults_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(plugin_id.to_string(), defaults.clone());
defaults
}
// ---------------------------------------------------------------------------
// 颜色语义启发式(plugin.cpp deduce_color_semantic:133-222
// ---------------------------------------------------------------------------
/// RGB/RGBA 参数是取色器("color")还是逐通道标量组("scalar")。
fn deduce_color_semantic(def: &ParamDef, group_labels: &HashMap<String, String>) -> &'static str {
const COLOR_KEYWORDS: &[&str] = &["color", "colour", "fill", "tint", "key"];
const SCALAR_KEYWORDS: &[&str] = &[
"gamma",
"contrast",
"gain",
"offset",
"saturation",
"exposure",
"brightness",
"lift",
"multiply",
"scale",
"pivot",
];
if def.ofx_type != ofx::TYPE_RGB && def.ofx_type != ofx::TYPE_RGBA {
return "color";
}
let label = prop_str(&def.props, ofx::PROP_LABEL, 0).to_lowercase();
let hint = prop_str(&def.props, ofx::P_HINT, 0).to_lowercase();
let name = def.name.to_lowercase();
// 规则 1:显式颜色关键词 → color。
for kw in COLOR_KEYWORDS {
if label.contains(kw) || hint.contains(kw) || name.contains(kw) {
return "color";
}
}
// 规则 2:显式标量/调色关键词 → scalar。
for kw in SCALAR_KEYWORDS {
if label.contains(kw) || hint.contains(kw) || name.contains(kw) {
return "scalar";
}
}
// 规则 3display 范围显著越出 [0,1] → scalar。
let dim = if def.ofx_type == ofx::TYPE_RGBA { 4 } else { 3 };
for i in 0..dim {
let dmin = prop_double(&def.props, ofx::P_DISPLAY_MIN, i);
let dmax = prop_double(&def.props, ofx::P_DISPLAY_MAX, i);
if dmin < -0.01 || dmax > 1.01 {
return "scalar";
}
}
// 规则 4:默认值全相等 → scalar(lean)。
let mut defs = [0.0f64; 4];
for i in 0..dim {
defs[i] = prop_double(&def.props, ofx::P_DEFAULT, i);
}
let all_equal = (1..dim).all(|i| defs[i] == defs[0]);
if all_equal {
return "scalar";
}
// 规则 5:父 group 名含标量关键词 → scalar。
let parent = prop_str(&def.props, ofx::P_PARENT, 0).to_lowercase();
if !parent.is_empty() {
let group_label = group_labels
.get(&prop_str(&def.props, ofx::P_PARENT, 0))
.map(|s| s.to_lowercase())
.unwrap_or_default();
for kw in SCALAR_KEYWORDS {
if parent.contains(kw) || group_label.contains(kw) {
return "scalar";
}
}
}
// 兜底。
"color"
}
// ---------------------------------------------------------------------------
// clip 显示名(plugin.cpp clip_label_for_name:248-270
// ---------------------------------------------------------------------------
fn clip_label_for_name(name: &str, clip_props: Option<&PropertySet>) -> String {
// 过渡上下文 clip 名(ofxImageEffect.h:1435-1441)。
if name == SOURCE_CLIP {
return "Source".to_string();
}
if name == "SourceFrom" {
return "From".to_string();
}
if name == "SourceTo" {
return "To".to_string();
}
if let Some(props) = clip_props {
let label = prop_str(props, ofx::PROP_LABEL, 0);
if !label.is_empty() {
return label;
}
}
name.to_string()
}
// ---------------------------------------------------------------------------
// 参数翻译:OFX 参数 → 节点输入(plugin.cpp:331-490
// ---------------------------------------------------------------------------
/// OFX 类型 → 节点输入值类型(plugin.cpp:340-378 的类型表;
/// Group/Page 与未知类型(k_none)均无输入 → None,跳过)。
fn input_type_for(ofx_type: &str) -> Option<ValueType> {
Some(match ofx_type {
ofx::TYPE_INTEGER => ValueType::Int,
ofx::TYPE_DOUBLE => ValueType::Float,
ofx::TYPE_BOOLEAN => ValueType::Boolean,
ofx::TYPE_STRING => ValueType::Text,
ofx::TYPE_RGB | ofx::TYPE_RGBA => ValueType::Color,
ofx::TYPE_CHOICE => ValueType::Combo,
ofx::TYPE_DOUBLE2D | ofx::TYPE_INTEGER2D => ValueType::Vec2,
ofx::TYPE_DOUBLE3D | ofx::TYPE_INTEGER3D => ValueType::Vec3,
ofx::TYPE_STRCHOICE => ValueType::StrCombo,
ofx::TYPE_BYTES | ofx::TYPE_CUSTOM => ValueType::Binary,
ofx::TYPE_PUSHBUTTON => ValueType::PushButton,
_ => return None,
})
}
/// 从描述符实例构建节点输入表(PluginNode 构造函数的参数/clip 循环)。
fn build_core(inst: &Instance) -> NodeCore {
let mut core = NodeCore::new();
let defaults = cached_defaults(&inst.plugin.identifier, &inst.params);
// 第 1 遍:group/page 标签与 param→page 映射(plugin.cpp:300-330)。
let mut group_labels: HashMap<String, String> = HashMap::new();
let mut page_for_param: HashMap<String, String> = HashMap::new();
for p in &inst.params.params {
let def = &p.def;
match def.ofx_type.as_str() {
ofx::TYPE_GROUP => {
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
group_labels.insert(
def.name.clone(),
if label.is_empty() { def.name.clone() } else { label },
);
}
ofx::TYPE_PAGE => {
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
let page_label = if label.is_empty() { def.name.clone() } else { label };
let count = def.props.dimension(ofx::P_PAGE_CHILD);
for i in 0..count {
let child = prop_str(&def.props, ofx::P_PAGE_CHILD, i);
if child == ofx::PAGE_SKIP_ROW || child == ofx::PAGE_SKIP_COLUMN {
continue;
}
page_for_param.insert(child, page_label.clone());
}
}
_ => {}
}
}
// 第 2 遍:值参数 → 输入(plugin.cpp:331-490)。
for p in &inst.params.params {
let def = &p.def;
let Some(value_type) = input_type_for(&def.ofx_type) else {
continue;
};
let input_id = def.name.clone();
if input_id.is_empty() {
continue;
}
let is_secret = prop_int(&def.props, ofx::P_SECRET, 0) != 0;
// 默认值(缓存表;C++ defaults.value(input_id))。
let mut input = match defaults.get(&input_id) {
Some(default_value) => {
let input = Input::new(&input_id, value_type, default_value.clone());
if !matches!(value_type, ValueType::PushButton) {
core.set_standard_value(&input_id, 0, default_value.clone());
}
input
}
None => Input::new(&input_id, value_type, NodeValue::None),
};
if is_secret {
input.flags |= input_flags::HIDDEN;
}
let label = prop_str(&def.props, ofx::PROP_LABEL, 0);
input.display_name = if label.is_empty() { input_id.clone() } else { label };
let parent = prop_str(&def.props, ofx::P_PARENT, 0);
if !parent.is_empty() {
let group = group_labels.get(&parent).cloned().unwrap_or(parent.clone());
input
.properties
.push(("ui_group".to_string(), NodeValue::Text(group)));
}
if let Some(page) = page_for_param.get(&input_id) {
input
.properties
.push(("ui_page".to_string(), NodeValue::Text(page.clone())));
}
if matches!(value_type, ValueType::Color) {
let semantic = deduce_color_semantic(def, &group_labels);
input.properties.push((
"color_semantic".to_string(),
NodeValue::Text(semantic.to_string()),
));
// display min/max 取第 0 维(plugin.cpp:418-424)。
input.properties.push((
"min".to_string(),
NodeValue::Float(prop_double(&def.props, ofx::P_DISPLAY_MIN, 0)),
));
input.properties.push((
"max".to_string(),
NodeValue::Float(prop_double(&def.props, ofx::P_DISPLAY_MAX, 0)),
));
let hint = prop_str(&def.props, ofx::P_HINT, 0);
if !hint.is_empty() {
input
.properties
.push(("tooltip".to_string(), NodeValue::Text(hint)));
}
}
if matches!(value_type, ValueType::Combo | ValueType::StrCombo) {
let mut option_labels = Vec::new();
let mut option_values = Vec::new();
let label_count = def.props.dimension(ofx::P_CHOICE_OPTION);
let value_count = def.props.dimension(ofx::P_CHOICE_ENUM);
for i in 0..label_count {
option_labels.push(prop_str(&def.props, ofx::P_CHOICE_OPTION, i));
}
for i in 0..value_count {
option_values.push(prop_str(&def.props, ofx::P_CHOICE_ENUM, i));
}
if option_labels.is_empty() && !option_values.is_empty() {
option_labels = option_values.clone();
}
if option_values.is_empty() && !option_labels.is_empty() {
option_values = option_labels.clone();
}
// ChoiceOrder 稳定排序(plugin.cpp:449-472)。
let order_count = def.props.dimension(ofx::P_CHOICE_ORDER);
if order_count == option_labels.len() && option_labels.len() == option_values.len() {
let mut indices: Vec<usize> = (0..option_labels.len()).collect();
indices.sort_by_key(|&i| prop_int(&def.props, ofx::P_CHOICE_ORDER, i));
option_labels = indices.iter().map(|&i| option_labels[i].clone()).collect();
option_values = indices.iter().map(|&i| option_values[i].clone()).collect();
}
// combo 选项经重复键属性携带(NodeValue 无字符串表变体;
// 消费方按 ("combo_option", _) 全量收集,str_combo 的
// 值表为 ("combo_value", _)——C++ set_combo_box_strings /
// "combo_value_str" 的等价物)。
for label in &option_labels {
input.properties.push((
"combo_option".to_string(),
NodeValue::Text(label.clone()),
));
}
if matches!(value_type, ValueType::StrCombo) {
for value in &option_values {
input.properties.push((
"combo_value".to_string(),
NodeValue::Text(value.clone()),
));
}
}
}
core.add_input(input);
}
// clip → 纹理输入(plugin.cpp:492-501)。
let mut has_texture_input = false;
for clip in &inst.clips {
if clip.name == "Output" {
continue;
}
let mut input = Input::new(&clip.name, ValueType::Texture, NodeValue::None);
input.display_name = clip_label_for_name(&clip.name, Some(&clip.props));
core.add_input(input);
has_texture_input = true;
}
// effect_input 选择(plugin.cpp:503-514)。
if core.has_input(SOURCE_CLIP) {
core.effect_input = SOURCE_CLIP.to_string();
} else if core.has_input(TEXTURE_INPUT) {
core.effect_input = TEXTURE_INPUT.to_string();
} else if has_texture_input {
let mut input = Input::new(TEXTURE_INPUT, ValueType::Texture, NodeValue::None);
input.display_name = "Texture".to_string();
core.add_input(input);
core.effect_input = TEXTURE_INPUT.to_string();
}
core
}
/// 上下文的显示子分类(plugin.cpp:281-290)。
fn sub_category_for(context: &str) -> &'static str {
match context {
"OfxImageEffectContextFilter" => "Filter",
"OfxImageEffectContextGenerator" => "Generator",
"OfxImageEffectContextTransition" => "Transition",
_ => "General",
}
}
/// 插件描述符的显示名(plugin.cpp PluginNode::name:517-524)。
fn plugin_display_name(inst: &Instance) -> String {
let label = prop_str(&inst.plugin.descriptor.props, ofx::PROP_LABEL, 0);
if label.is_empty() {
inst.plugin.identifier.clone()
} else {
label
}
}
/// 插件描述符的描述文本(plugin.cpp PluginNode::description
/// :531-538)。
fn plugin_description(inst: &Instance) -> String {
prop_str(&inst.plugin.descriptor.props, PROP_PLUGIN_DESCRIPTION, 0)
}
// ---------------------------------------------------------------------------
// 节点构造(每次建图都新建实例——C++ 库条目共享一个实例是 Qt 父子
// 所有权模型;Rust 侧每节点独占实例才能支持 duplicate 与并发渲染)
// ---------------------------------------------------------------------------
/// 为 (identifier, context) 建一个插件节点(新实例 + 注册表登记)。
fn create_plugin_node(
identifier: &str,
context: &str,
) -> Option<(NodeCore, Box<dyn NodeBehavior>)> {
let inst = Host::global()
.create_instance(identifier, Some(context))
.ok()?;
let core = build_core(&inst.value);
let name = plugin_display_name(&inst.value);
let description = plugin_description(&inst.value);
let sub_category = sub_category_for(context).to_string();
let id = register_instance(inst);
let node = PluginNode::new(
PluginInstanceHandle(id),
name,
identifier.to_string(),
description,
sub_category,
);
Some((core, Box::new(node)))
}
/// 扫描宿主插件缓存并向节点工厂注册动态条目(C++
/// `NodeFactory::register_plugin_nodes`factory.cpp:148-186)。
/// 返回新注册的 type id 列表;已存在的 id 跳过(register_dynamic
/// 去重,对齐 C++ existing_ids 检查)。
pub fn register_plugin_nodes() -> Vec<String> {
install_render_executor();
let host = Host::global();
let mut registered = Vec::new();
for i in 0..host.cache.count() {
let Some(plugin) = host.cache.at(i) else {
continue;
};
// 上下文选择:filter 优先,否则第一个(factory.cpp:171-177)。
let context = if plugin.contexts.iter().any(|c| c == "OfxImageEffectContextFilter") {
"OfxImageEffectContextFilter".to_string()
} else {
match plugin.contexts.first() {
Some(c) => c.clone(),
None => {
eprintln!(
"Skipping OFX plugin with no contexts: {}",
plugin.identifier
);
continue;
}
}
};
// 元数据实例(name/description;建完即弃)。
let Ok(inst) = host.create_instance(&plugin.identifier, Some(&context)) else {
continue;
};
let name = plugin_display_name(&inst.value);
let description = plugin_description(&inst.value);
let sub_category = sub_category_for(&context).to_string();
drop(inst);
let identifier = plugin.identifier.clone();
let create: DynNodeConstructor = Arc::new(move || {
create_plugin_node(&identifier, &context)
.unwrap_or_else(oaknode::nodes::plugin::create)
});
let meta = DynamicNodeMeta {
type_id: plugin.identifier.clone(),
name,
categories: vec![Category::OpenFx],
sub_category,
description,
create,
};
if oaknode::factory::Factory::global().register_dynamic(meta) {
registered.push(plugin.identifier.clone());
}
}
registered
}
// ---------------------------------------------------------------------------
// 渲染执行器 + duplicator(依赖反转的 oakplugin 侧半环)
// ---------------------------------------------------------------------------
/// 字符串族参数注入(POD 无字符串表达;直接 set_ofx)。
fn set_string_param(inst: &Instance, key: &str, expected_type: &str, value: &str) {
let Some(p) = inst.params.find(key) else {
return;
};
if p.def.ofx_type != expected_type {
return;
}
let Ok(cs) = CString::new(value) else {
return;
};
let pv = if expected_type == ofx::TYPE_STRING {
ParamValue::String(cs)
} else {
ParamValue::StrChoice(cs)
};
p.set_ofx(pv);
}
/// executor 槽实现:JobSpec::Plugin → render_driver::render_frame。
fn execute_plugin_job(
req: &oakrender::eval::PluginJobRequest<'_>,
) -> oakrender::error::Result<oakrender::texture::Texture> {
use oakrender::error::Error;
let oakrender::eval::JobSpec::Plugin {
instance,
time,
effect_input_id,
inputs,
values,
} = req.spec
else {
return Err(Error::Invalid);
};
let inst = instance_from_id(*instance).ok_or_else(|| {
Error::Failed(format!("插件实例 {instance} 未登记(实例已释放?)"))
})?;
if req.src.is_dummy() {
return Err(Error::Failed("plugin job 无可用输入纹理".into()));
}
// 参数注入:数值族走 render_driver 的 POD 覆盖;字符串族 POD 无
// 表达,这里直接 set_ofx(对齐 pluginrenderer.cpp 的
// StringInstance::set 分支)。
let mut pod_values = Vec::new();
for (key, nv) in values {
match nv {
NodeValue::Text(s) => set_string_param(&inst.value, key, ofx::TYPE_STRING, s),
NodeValue::StrCombo(s) => set_string_param(&inst.value, key, ofx::TYPE_STRCHOICE, s),
NodeValue::PushButton | NodeValue::None => {}
other => {
if let Some(v) = crate::node::Value::from_node_value(other) {
pod_values.push((key.clone(), v));
}
}
}
}
// 输出纹理:与输入同尺寸的 F32 帧(render_driver 校验 F32)。
let dst_frame = oakrender::eval::generate_frame(
oakcore_rs::Rational::from_double(*time),
req.src.size(),
oakcore_rs::PixelFormat::F32,
)?;
let job = crate::render_driver::RenderJob {
time: *time,
dst: oakrender::texture::Texture::wrap_frame(dst_frame),
src: Some(req.src.clone()),
effect_input_id: effect_input_id.clone(),
inputs: inputs.clone(),
values: pod_values,
renderer: None,
clear_destination: false,
interactive: false,
};
let (out, _rois) = crate::render_driver::render_frame(&inst.value, &job)
.map_err(|e| Error::Failed(format!("插件渲染失败:{e:?}")))?;
Ok(out)
}
/// duplicator 槽实现:duplicate() 经注册表换新实例。
fn duplicate_instance(old: PluginInstanceHandle) -> Option<PluginInstanceHandle> {
let inst = instance_from_id(old.0)?;
let identifier = inst.value.plugin.identifier.clone();
let context = inst.value.context.clone();
let new_inst = Host::global()
.create_instance(&identifier, Some(&context))
.ok()?;
Some(PluginInstanceHandle(register_instance(new_inst)))
}
static EXECUTOR_INSTALLED: OnceLock<()> = OnceLock::new();
/// 把渲染执行器装进 oakrender 的 executor 槽、duplicator 装进
/// oaknode(幂等)。[`register_plugin_nodes`] 已内含;app 侧单独
/// 初始化渲染管线时也可直接调。
pub fn install_render_executor() {
EXECUTOR_INSTALLED.get_or_init(|| {
oakrender::eval::set_plugin_executor(Some(Arc::new(execute_plugin_job)));
oaknode::nodes::plugin::set_plugin_duplicator(Some(Arc::new(duplicate_instance)));
});
}
// ---------------------------------------------------------------------------
// 测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_type_table_covers_all_ofx_kinds() {
assert_eq!(
input_type_for(ofx::TYPE_INTEGER),
Some(ValueType::Int)
);
assert_eq!(
input_type_for(ofx::TYPE_DOUBLE),
Some(ValueType::Float)
);
assert_eq!(
input_type_for(ofx::TYPE_BOOLEAN),
Some(ValueType::Boolean)
);
assert_eq!(
input_type_for(ofx::TYPE_STRING),
Some(ValueType::Text)
);
assert_eq!(
input_type_for(ofx::TYPE_RGB),
Some(ValueType::Color)
);
assert_eq!(
input_type_for(ofx::TYPE_RGBA),
Some(ValueType::Color)
);
assert_eq!(
input_type_for(ofx::TYPE_CHOICE),
Some(ValueType::Combo)
);
assert_eq!(
input_type_for(ofx::TYPE_DOUBLE2D),
Some(ValueType::Vec2)
);
assert_eq!(
input_type_for(ofx::TYPE_INTEGER2D),
Some(ValueType::Vec2)
);
assert_eq!(
input_type_for(ofx::TYPE_DOUBLE3D),
Some(ValueType::Vec3)
);
assert_eq!(
input_type_for(ofx::TYPE_INTEGER3D),
Some(ValueType::Vec3)
);
assert_eq!(
input_type_for(ofx::TYPE_STRCHOICE),
Some(ValueType::StrCombo)
);
assert_eq!(
input_type_for(ofx::TYPE_BYTES),
Some(ValueType::Binary)
);
assert_eq!(
input_type_for(ofx::TYPE_CUSTOM),
Some(ValueType::Binary)
);
assert_eq!(
input_type_for(ofx::TYPE_PUSHBUTTON),
Some(ValueType::PushButton)
);
// 容器与未知类型:跳过。
assert_eq!(input_type_for(ofx::TYPE_GROUP), None);
assert_eq!(input_type_for(ofx::TYPE_PAGE), None);
assert_eq!(input_type_for("OfxParamTypeParametric"), None);
}
#[test]
fn color_semantic_rules() {
fn def_with(label: &str, ofx_type: &str) -> ParamDef {
let def = ParamDef {
props: PropertySet::new(),
name: "p".into(),
ofx_type: ofx_type.into(),
default: ParamValue::Container,
};
def.props.set_one(
ofx::PROP_LABEL,
PropValue::String(CString::new(label).unwrap()),
);
def
}
let groups = HashMap::new();
// 非颜色类型恒 "color"。
assert_eq!(
deduce_color_semantic(&def_with("Anything", ofx::TYPE_DOUBLE), &groups),
"color"
);
// 规则 1:颜色关键词。
assert_eq!(
deduce_color_semantic(&def_with("Tint Color", ofx::TYPE_RGB), &groups),
"color"
);
// 规则 2:标量关键词。
assert_eq!(
deduce_color_semantic(&def_with("Gamma Adjust", ofx::TYPE_RGBA), &groups),
"scalar"
);
}
#[test]
fn color_semantic_display_range_rule() {
let def = ParamDef {
props: PropertySet::new(),
name: "rgb".into(),
ofx_type: ofx::TYPE_RGB.into(),
default: ParamValue::Container,
};
// 默认 (0,0,0) 全相等前先被规则 3 拦截:display max 越界。
def.props
.set_one(ofx::P_DISPLAY_MAX, PropValue::Double(2.0));
let groups = HashMap::new();
assert_eq!(deduce_color_semantic(&def, &groups), "scalar");
}
#[test]
fn clip_labels_special_case_names() {
assert_eq!(clip_label_for_name("Source", None), "Source");
assert_eq!(clip_label_for_name("SourceFrom", None), "From");
assert_eq!(clip_label_for_name("SourceTo", None), "To");
assert_eq!(clip_label_for_name("Overlay", None), "Overlay");
let props = PropertySet::new();
props.set_one(
ofx::PROP_LABEL,
PropValue::String(CString::new("Matte").unwrap()),
);
assert_eq!(clip_label_for_name("Overlay", Some(&props)), "Matte");
}
#[test]
fn instance_registry_roundtrip() {
// 注册表对不存在的键返回 None;摘除未登记键 no-op。
assert!(instance_from_id(u64::MAX).is_none());
unregister_instance(u64::MAX);
}
}
+10
View File
@@ -126,6 +126,8 @@ pub(crate) const V_DOUBLE_TYPE_PLAIN: &str = "OfxParamDoubleTypePlain";
pub(crate) const P_DEFAULT_COORD_SYS: &str = "OfxParamPropDefaultCoordinateSystem";
/// kOfxParamCoordinatesCanonical。
pub(crate) const V_COORD_CANONICAL: &str = "OfxParamCoordinatesCanonical";
/// kOfxParamCoordinatesNormalisedofxParam.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";
/// kOfxParamPageSkipRowpage 子项哨兵,ofxParam.h:178)。
pub(crate) const PAGE_SKIP_ROW: &str = "OfxParamPageSkipRow";
/// kOfxParamPageSkipColumnpage 子项哨兵,ofxParam.h:186)。
pub(crate) const PAGE_SKIP_COLUMN: &str = "OfxParamPageSkipColumn";
/// kOfxParamPropCustomInterpCallbackV1。
pub(crate) const P_CUSTOM_INTERP: &str = "OfxParamPropCustomCallbackV1";
/// kOfxParamPropPageChild。
+82 -5
View File
@@ -17,13 +17,57 @@
//! 进度上报(Progress suite 的宿主侧)。
//!
//! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的
//! 回调出 crateM9 的 facade 回调模式,不设全局状态)。
//! 取消是粘滞的:一旦回调答 false,本报告器的 [`is_cancelled`]
//! image effect suite 的 abort 查询)持续为真。
//! 回调出 crateM9 的 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<dyn Fn(&str, &str) -> Box<dyn UiProgressReporter> + Send + Sync>;
static REPORTER_FACTORY: OnceLock<Mutex<Option<ReporterFactory>>> = OnceLock::new();
fn factory_slot() -> &'static Mutex<Option<ReporterFactory>> {
REPORTER_FACTORY.get_or_init(|| Mutex::new(None))
}
/// 注册/清除进度 UI 工厂(app 接线点;覆盖式)。
pub fn set_reporter_factory(factory: Option<ReporterFactory>) {
*factory_slot().lock().unwrap_or_else(|e| e.into_inner()) = factory;
}
pub(crate) fn reporter_factory() -> Option<ReporterFactory> {
factory_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// 是否已注册 UI 工厂(render 路径据此决定是否装静默报告器)。
pub fn has_reporter_factory() -> bool {
reporter_factory().is_some()
}
/// 进度回调:签名与 `include/plugin/instance.h` 的
/// `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<ProgressFn>,
userdata: usize,
/// UI 报告器(progressStart 经 [`reporter_factory`] 现造;
/// C 回调优先,无回调时才用)。
ui: Mutex<Option<Box<dyn UiProgressReporter>>>,
/// 取消标志(粘滞;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,
}
}
}
}
+2 -1
View File
@@ -129,7 +129,8 @@ pub fn texture_create(
/// Rust 等价物。恒 0GL 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
}
+61 -19
View File
@@ -133,13 +133,15 @@ pub fn end_sequence(
/// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。
///
/// 返回各输入 clip 的 RoIclip 名 → 矩形;Phase 2 供测试断言,
/// 宿主不据此裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器
/// 行为一致)。
/// 返回装配完成的输出纹理与各输入 clip 的 RoIclip 名 → 矩形;
/// 值型纹理下输出写入 `job.dst` 的本地副本并随返回值交付——CPU
/// 纹理的 `to_frame` 是拷贝,就地写不回只读的 `job.dst`)。宿主不
/// 据 RoI 裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器行为
/// 一致。
pub fn render_frame(
inst: &Instance,
job: &RenderJob,
) -> crate::error::Result<Vec<(String, OfxRectD)>> {
) -> crate::error::Result<(Texture, Vec<(String, OfxRectD)>)> {
use crate::error::Error;
// 1. 实例锁(pluginrenderer.cpp:1436-1444OlivePluginInstance 非
@@ -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/RoDpluginrenderer.cpp:1595-1603x2 = 宽 × 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. 输入 clipRoD 与格式(pluginrenderer.cpp:1627-1665Phase 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(())
}
+35 -7
View File
@@ -84,10 +84,29 @@ pub struct ProgressSuiteV2 {
pub end: unsafe extern "C" fn(*mut c_void) -> c_int,
}
/// progressStart:括号起点,OKlabel/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:括号起点;携 labelv2 另携 message)经
/// [`crate::progress`] 工厂现造 UI 报告器(无工厂/已有报告器时
/// no-op),状态恒 OK。
unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, label: *const c_char) -> c_int {
caught(|| {
let label = unsafe { decode(label) }.to_string();
CURRENT.with(|c| {
if let Some(r) = c.borrow().as_ref() {
r.install_ui(&label, "");
}
});
status::OK
})
}
unsafe extern "C" fn progress_update_v1(_handle: *mut c_void, progress: c_double) -> c_int {
@@ -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 {
+53 -6
View File
@@ -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<dyn Fn() -> Option<ViewerTimeInfo> + Send + Sync>;
static ACTIVE_VIEWER: OnceLock<Mutex<Option<ActiveViewerProvider>>> = OnceLock::new();
fn viewer_slot() -> &'static Mutex<Option<ActiveViewerProvider>> {
ACTIVE_VIEWER.get_or_init(|| Mutex::new(None))
}
/// 注册/清除活动 viewer 提供器(app 接线点;覆盖式)。
pub fn set_active_viewer_provider(provider: Option<ActiveViewerProvider>) {
*viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = provider;
}
fn active_viewer() -> Option<ViewerTimeInfo> {
let provider = viewer_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()?;
provider()
}
/// 函数表布局(OfxTimeLineSuiteV1)。
#[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;
}
// 渲染上下文缺省 → 0headless 默认)。
*time = render_ctx().map_or(0.0, |c| c.time);
// 渲染上下文 → 活动 viewer → 0headless 默认)。
*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
+372
View File
@@ -0,0 +1,372 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 阶段 6a 端到端:OFX 插件 → 节点工厂 → 节点图 → 渲染出帧。
//!
//! 链路:`scan_path`(最小测试插件,cbits/oak_test_plugin.c)→
//! `node_factory::register_plugin_nodes`(动态注册)→
//! `Factory::create_any`(参数翻译的输入表)→ Graph 连接常量纹理
//! 源 → `Traverser::evaluate` + `RenderEvalHooks`(解 PluginJobPayload
//! 经 render_driver 出帧)→ 像素断言。
//!
//! 测试插件未构建时全部 skip(common 约定)。宿主单例经
//! `common::with_host` 串行化。
mod common;
use oakcore_rs::{PixelFormat, Rational};
use oaknode::factory::Factory;
use oaknode::graph::Graph;
use oaknode::node::{NodeBehavior, NodeCore};
use oaknode::traverser::{EvalRequest, Traverser};
use oaknode::value::{NodeValue, ValueType};
use oakplugin::host::Host;
use oakrender::texture::Texture;
const PLUGIN_ID: &str = "org.oak.test-plugin";
const IDENTITY_ID: &str = "org.oak.test-plugin.identity";
/// 常量纹理源节点:推一张填充实色的 F32 帧(测试专用行为)。
struct ConstSource {
rgba: [f32; 4],
size: (i32, i32),
}
impl NodeBehavior for ConstSource {
fn name(&self) -> &str {
"ConstSource"
}
fn type_id(&self) -> &str {
"test.const-source"
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(ConstSource {
rgba: self.rgba,
size: self.size,
}))
}
fn value(
&self,
_core: &NodeCore,
_inputs: &oaknode::value::NodeValueRow,
time: Rational,
table: &mut oaknode::value::NodeValueTable,
) {
let mut frame =
oakrender::eval::generate_frame(time, self.size, PixelFormat::F32).unwrap();
for pixel in frame.data.chunks_exact_mut(16) {
for (i, v) in self.rgba.iter().enumerate() {
pixel[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
}
table.push(
ValueType::Texture,
NodeValue::Texture(oaknode::handle::make_owned(Texture::wrap_frame(frame))),
None,
);
}
}
/// 扫描 + 注册(幂等;宿主不可用返回 false = skip)。
fn scan_and_register() -> bool {
let Some(dir) = common::test_plugin_scan_dir() else {
common::skip("最小测试插件未构建");
return false;
};
if Host::global().cache.scan_path(&dir).is_err() {
common::skip("测试插件扫描失败");
return false;
}
oakplugin::node_factory::register_plugin_nodes();
true
}
/// 取输出表的渲染纹理(resolve 后的真纹理盒)。
fn rendered_texture(
table: &oaknode::value::NodeValueTable,
) -> Texture {
let NodeValue::Texture(handle) = table
.get(ValueType::Texture)
.expect("根输出应有纹理")
else {
panic!("纹理槽不是 Texture 值");
};
unsafe { oaknode::handle::get_checked::<Texture>(handle) }
.cloned()
.expect("纹理盒必须是渲染产物(PluginJobPayload 已 resolve")
}
fn first_pixel(texture: &Texture) -> [f32; 4] {
let Texture::Cpu(frame) = texture else {
panic!("期望 CPU 帧");
};
let mut out = [0f32; 4];
for i in 0..4 {
out[i] = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap());
}
out
}
/// 注册 + 参数翻译:动态条目、输入类型/默认值/显示名/隐藏标记/
/// combo 选项/effect_input(对齐 plugin.cpp 构造函数)。
#[test]
fn plugin_nodes_register_with_translated_inputs() {
common::with_host(|| {
if !scan_and_register() {
return;
}
let entries = Factory::global().dynamic_entries();
assert!(
entries.iter().any(|m| m.type_id == PLUGIN_ID),
"CPU 变体应注册为动态节点"
);
assert!(
entries.iter().any(|m| m.type_id == IDENTITY_ID),
"identity 变体应注册为动态节点"
);
let meta = entries.iter().find(|m| m.type_id == PLUGIN_ID).unwrap();
assert_eq!(meta.sub_category, "Filter");
assert_eq!(
meta.categories,
vec![oaknode::node::Category::OpenFx]
);
let (core, behavior) = Factory::global()
.create_any(PLUGIN_ID)
.expect("create_any 应建出插件节点");
// gainDouble → Float,默认 0.0,显示名 Gain,带 display
// min/max 属性(-2/2)。
let gain = core.get_input("gain").expect("gain 输入");
assert_eq!(gain.value_type, ValueType::Float);
assert_eq!(gain.default, NodeValue::Float(0.0));
assert_eq!(gain.display_name, "Gain");
// min/max/tooltip 属性 C++ 只对颜色输入设置
// plugin.cpp:407-430 的 k_color 分支);Double 参数无。
assert!(gain
.properties
.iter()
.all(|(k, _)| k != "min" && k != "max"));
// modeChoice → Combo,两个选项 Fast/High。
let mode = core.get_input("mode").expect("mode 输入");
assert_eq!(mode.value_type, ValueType::Combo);
let options: Vec<String> = mode
.properties
.iter()
.filter(|(k, _)| k == "combo_option")
.map(|(_, v)| match v {
NodeValue::Text(s) => s.clone(),
_ => panic!("combo_option 应是 Text"),
})
.collect();
assert_eq!(options, vec!["Fast".to_string(), "High".to_string()]);
// debugsecret → hidden。
let debug = core.get_input("debug").expect("debug 输入");
assert!(
debug.flags & oaknode::input::flags::HIDDEN != 0,
"secret 参数应隐藏"
);
// labelString → Text。
let label = core.get_input("label").expect("label 输入");
assert_eq!(label.value_type, ValueType::Text);
// Source clip → 纹理输入;effect_input 选中 Source。
let source = core.get_input("Source").expect("Source 输入");
assert_eq!(source.value_type, ValueType::Texture);
assert_eq!(core.effect_input, "Source");
// 行为是持真实实例句柄的 PluginNode。
let plugin = behavior
.as_any()
.and_then(|a| a.downcast_ref::<oaknode::nodes::plugin::PluginNode>())
.expect("行为应是 PluginNode");
assert!(!plugin.instance_handle().is_null());
Host::global().shutdown();
});
}
/// CPU 端到端:常量源 → 插件节点(render 填常量 0.5/alpha 1)→
/// 输出帧像素断言。
#[test]
fn plugin_renders_constant_frame_end_to_end() {
common::with_host(|| {
if !scan_and_register() {
return;
}
let (core, behavior) = Factory::global()
.create_any(PLUGIN_ID)
.expect("create_any");
let mut graph = Graph::new();
let src_id = graph.add_node(
NodeCore::new(),
Box::new(ConstSource {
rgba: [0.2, 0.4, 0.6, 1.0],
size: (4, 4),
}),
);
let plug_id = graph.add_node(core, behavior);
graph
.connect(src_id, plug_id, "Source", -1)
.expect("Source 连接");
let mut traverser = Traverser::new();
let mut hooks = oakrender::eval::RenderEvalHooks::new();
let table = traverser
.evaluate(
&graph,
&EvalRequest::new(plug_id, Rational::new(0, 1)),
&mut hooks,
)
.expect("evaluate 应成功");
let texture = rendered_texture(&table);
assert_eq!(texture.size(), (4, 4));
// 测试插件 render 无视输入,填常量 0.5alpha=1)。
assert_eq!(first_pixel(&texture), [0.5, 0.5, 0.5, 1.0]);
Host::global().shutdown();
});
}
/// isIdentity 透传:identity 变体声明恒透传 Source → 输出应等于
/// 输入帧(render_driver 的 passthrough 短路)。
#[test]
fn identity_variant_passes_source_through() {
common::with_host(|| {
if !scan_and_register() {
return;
}
let (core, behavior) = Factory::global()
.create_any(IDENTITY_ID)
.expect("create_any(identity)");
let mut graph = Graph::new();
let src_id = graph.add_node(
NodeCore::new(),
Box::new(ConstSource {
rgba: [0.25, 0.75, 0.5, 1.0],
size: (2, 2),
}),
);
let plug_id = graph.add_node(core, behavior);
graph
.connect(src_id, plug_id, "Source", -1)
.expect("Source 连接");
let mut traverser = Traverser::new();
let mut hooks = oakrender::eval::RenderEvalHooks::new();
let table = traverser
.evaluate(
&graph,
&EvalRequest::new(plug_id, Rational::new(0, 1)),
&mut hooks,
)
.expect("evaluate 应成功");
let texture = rendered_texture(&table);
assert_eq!(first_pixel(&texture), [0.25, 0.75, 0.5, 1.0]);
Host::global().shutdown();
});
}
/// 参数覆盖路径:Text/StrCombo 走 set_ofx,数值走 POD——经 set 后
/// 实例参数值可读回(翻译注入的回归保护)。
#[test]
fn param_overrides_reach_instance() {
common::with_host(|| {
if !scan_and_register() {
return;
}
let inst = Host::global()
.create_instance(PLUGIN_ID, None)
.expect("实例");
let id = oakplugin::node_factory::register_instance(inst.clone());
// 数值覆盖(gain = 1.25)。
let job_values = vec![(
"gain".to_string(),
oaknode::value::NodeValue::Float(1.25),
)];
let pod: Vec<(String, oakplugin::node::Value)> = job_values
.iter()
.filter_map(|(k, v)| {
oakplugin::node::Value::from_node_value(v).map(|p| (k.clone(), p))
})
.collect();
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
.unwrap();
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
.unwrap();
let job = oakplugin::render_driver::RenderJob {
time: 0.0,
dst: Texture::wrap_frame(dst),
src: Some(Texture::wrap_frame(src)),
effect_input_id: Some("Source".into()),
inputs: Vec::new(),
values: pod,
renderer: None,
clear_destination: false,
interactive: false,
};
oakplugin::render_driver::render_frame(&inst.value, &job)
.expect("render_frame 应成功");
let gain = inst.value.params.find("gain").unwrap().get();
assert_eq!(
gain,
oakplugin::param::ParamValue::Double([1.25, 0.0, 0.0], 1)
);
// NaN 覆盖回退默认(gain 默认 0.0)。
let pod_nan = vec![(
"gain".to_string(),
oakplugin::node::Value::float(f64::NAN),
)];
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
.unwrap();
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
.unwrap();
let job = oakplugin::render_driver::RenderJob {
time: 0.0,
dst: Texture::wrap_frame(dst),
src: Some(Texture::wrap_frame(src)),
effect_input_id: Some("Source".into()),
inputs: Vec::new(),
values: pod_nan,
renderer: None,
clear_destination: false,
interactive: false,
};
oakplugin::render_driver::render_frame(&inst.value, &job)
.expect("NaN 覆盖不应失败");
assert_eq!(
inst.value.params.find("gain").unwrap().get(),
oakplugin::param::ParamValue::Double([0.0, 0.0, 0.0], 1)
);
oakplugin::node_factory::unregister_instance(id);
Host::global().shutdown();
});
}