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:
@@ -221,6 +221,67 @@ src/
|
||||
能力。修复:`init_descriptor_props` 补齐预定义(默认
|
||||
"false"/空数组/None)。
|
||||
|
||||
## 阶段 6a:OpenFX 引擎接线(oaknode/oakrender/oakplugin 收编)
|
||||
|
||||
把 OFX 插件接进 oaknode 节点工厂与 oakrender 评估环的接线层
|
||||
(此前插件侧只有宿主/suite/渲染驱动,未进节点图)。全部落在
|
||||
crates/,不动 src/(app)与 gpui/。
|
||||
|
||||
- **`node_factory.rs`(新增)**:
|
||||
- 实例注册表:`register_instance/instance_from_id/unregister_instance`
|
||||
(u64 键 ↔ `Arc<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` 保持恒 0,GL 插件经 CPU
|
||||
render action 正确出帧。
|
||||
- **`progress.rs`**:新增 `UiProgressReporter` trait +
|
||||
`ReporterFactory` + `set_reporter_factory`(app 注入点)。render
|
||||
未装 C 回调时装静默报告器,progressStart 携 (label,message) 经
|
||||
工厂现造 UI 报告器;update 返回 false 即取消。
|
||||
- **`suites/timeline.rs`**:新增 `ViewerTimeInfo` +
|
||||
`ActiveViewerProvider` + `set_active_viewer_provider`(app 注入
|
||||
点)。timeline suite 的 getTime/getTimeBounds 在渲染上下文缺失时
|
||||
回退活动 viewer 时间源。
|
||||
- **`render_driver.rs`**:`apply_param_overrides` 增 Double 标量
|
||||
NaN/Inf 清洗(回退默认并告警)+ Min/Max 钳制(对齐
|
||||
pluginrenderer.cpp:155-177)。
|
||||
- **`clip.rs`**:`fetch_image` 支持 U8/U16/F16 输入帧归一化转 F32
|
||||
(对齐 oliveclip.cpp setInputTexture 的格式转换路径);转换中的
|
||||
NaN/Inf 清洗为 0(oliveclip.cpp copy_pixels 的 scrub);新增
|
||||
`f16_to_f32` 手写位转换。
|
||||
- **oakrender `eval.rs`**:`JobSpec::Plugin` 扩为携带
|
||||
instance/time/effect_input_id/inputs/values;新增
|
||||
`PluginExecutor` + `set_plugin_executor` 依赖反转槽;
|
||||
`process_plugin_job` 经执行器出帧,失败回退紫帧 (1,0,1,1);
|
||||
`RenderEvalHooks` 实现 `RenderHooks::resolve` 解
|
||||
`PluginJobPayload` 盒并执行。
|
||||
- **oaknode**:`factory.rs` 动态注册面(`register_dynamic`/
|
||||
`dynamic_entries`/`create_any` 等);`nodes/plugin.rs` 重写为
|
||||
PluginJobPayload 值模型 + duplicator 槽;`traverser.rs` 纹理输入
|
||||
直通;`handle.rs` `get_checked<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
@@ -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 清洗为 0(oliveclip.cpp copy_pixels 的 scrub)。
|
||||
/// `region` 只支持 None(整帧)——子区域随 renderer 桥落地。
|
||||
pub fn fetch_image(
|
||||
&self,
|
||||
time: f64,
|
||||
@@ -183,10 +214,14 @@ impl ClipInstance {
|
||||
// 纹理 → CPU 帧(GPU 纹理后端下载;帧随 drop 释放)。
|
||||
let frame = crate::render::texture_get_frame(&texture)?;
|
||||
let params = frame.video_params();
|
||||
if params.format != PIXEL_FORMAT_F32 {
|
||||
let format = params.format;
|
||||
if format != PIXEL_FORMAT_F32
|
||||
&& format != crate::render::PIXEL_FORMAT_U8
|
||||
&& format != oakcore_rs::PixelFormat::U16 as i32
|
||||
&& format != oakcore_rs::PixelFormat::F16 as i32
|
||||
{
|
||||
return Err(Error::Failed(format!(
|
||||
"输入帧格式 {} 非 F32(第 1 期约束)",
|
||||
params.format
|
||||
"输入帧格式 {format} 不支持(仅 U8/U16/F16/F32)"
|
||||
)));
|
||||
}
|
||||
let (w, h) = (params.width as f64, params.height as f64);
|
||||
@@ -208,19 +243,71 @@ impl ClipInstance {
|
||||
if src.is_null() {
|
||||
return Err(Error::Failed("帧无数据".into()));
|
||||
}
|
||||
// 行优先拷贝(帧行跨度经 linesize 读取——真实 oakrender 帧可
|
||||
// 有行填充;目标 Image 恒紧凑。M11 §4 修复:phase 1 假设紧凑
|
||||
// 行,对真实 oakrender 的填充帧会写错列)。
|
||||
// 行优先 + 格式转换(帧行跨度经 linesize 读取——真实
|
||||
// oakrender 帧可有行填充;目标 Image 恒紧凑 F32)。
|
||||
// U8/U16/F16 输入归一化到 [0,1] 浮点(对齐 oliveclip.cpp
|
||||
// setInputTexture 的 swscale 转换路径:插件侧永远见到协商位
|
||||
// 深);F32/转换结果中的 NaN/Inf 清洗为 0(oliveclip.cpp
|
||||
// copy_pixels 的 scrub——CImg 对 NaN 未定义行为)。
|
||||
let channels = components.channel_count();
|
||||
let tight = (w as usize) * channels * 4;
|
||||
let samples_per_row = (w as usize) * channels;
|
||||
let src_bpc = match format {
|
||||
f if f == crate::render::PIXEL_FORMAT_U8 => 1,
|
||||
f if f == oakcore_rs::PixelFormat::U16 as i32 => 2,
|
||||
f if f == oakcore_rs::PixelFormat::F16 as i32 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let tight_src = samples_per_row * src_bpc;
|
||||
let row = frame.linesize_bytes();
|
||||
let row = if row > 0 { row } else { tight };
|
||||
let row = if row > 0 { row } else { tight_src };
|
||||
let src_bytes = unsafe { std::slice::from_raw_parts(src, row * h as usize) };
|
||||
let dst = image.pixels_mut();
|
||||
let mut scrubbed = false;
|
||||
for y in 0..h as usize {
|
||||
let s = y * row;
|
||||
let d = y * tight;
|
||||
dst[d..d + tight].copy_from_slice(&src_bytes[s..s + tight]);
|
||||
for i in 0..samples_per_row {
|
||||
let v = match format {
|
||||
f if f == crate::render::PIXEL_FORMAT_U8 => {
|
||||
src_bytes[s + i] as f32 / 255.0
|
||||
}
|
||||
f if f == oakcore_rs::PixelFormat::U16 as i32 => {
|
||||
let off = s + i * 2;
|
||||
let bits = u16::from_le_bytes([src_bytes[off], src_bytes[off + 1]]);
|
||||
bits as f32 / 65535.0
|
||||
}
|
||||
f if f == oakcore_rs::PixelFormat::F16 as i32 => {
|
||||
let off = s + i * 2;
|
||||
let bits = u16::from_le_bytes([src_bytes[off], src_bytes[off + 1]]);
|
||||
let v = f16_to_f32(bits);
|
||||
if v.is_nan() || v.is_infinite() {
|
||||
scrubbed = true;
|
||||
0.0
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let off = s + i * 4;
|
||||
let v = f32::from_le_bytes([
|
||||
src_bytes[off],
|
||||
src_bytes[off + 1],
|
||||
src_bytes[off + 2],
|
||||
src_bytes[off + 3],
|
||||
]);
|
||||
if v.is_nan() || v.is_infinite() {
|
||||
scrubbed = true;
|
||||
0.0
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
};
|
||||
let d = (y * samples_per_row + i) * 4;
|
||||
dst[d..d + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if scrubbed {
|
||||
eprintln!("[PLUGIN] NaN/Inf scrubbed from input frame data during fetch");
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
@@ -303,3 +390,27 @@ impl ClipInstance {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn f16_to_f32_covers_special_values() {
|
||||
// 常规值:1.0 = 0x3C00,-2.0 = 0xC000,0.5 = 0x3800。
|
||||
assert_eq!(f16_to_f32(0x3C00), 1.0);
|
||||
assert_eq!(f16_to_f32(0xC000), -2.0);
|
||||
assert_eq!(f16_to_f32(0x3800), 0.5);
|
||||
// 零与负零。
|
||||
assert_eq!(f16_to_f32(0x0000), 0.0);
|
||||
assert_eq!(f16_to_f32(0x8000).to_bits(), (0.0f32).to_bits() | (1 << 31));
|
||||
// 非规格数:最小正规格数 2^-14 ≈ 0.00006104;2^-24 是最小非
|
||||
// 规格数之一。
|
||||
assert!((f16_to_f32(0x0400) - 2f32.powi(-14)).abs() < 1e-12);
|
||||
assert!((f16_to_f32(0x0001) - 2f32.powi(-24)).abs() < 1e-12);
|
||||
// Inf/NaN。
|
||||
assert!(f16_to_f32(0x7C00).is_infinite() && f16_to_f32(0x7C00) > 0.0);
|
||||
assert!(f16_to_f32(0xFC00).is_infinite() && f16_to_f32(0xFC00) < 0.0);
|
||||
assert!(f16_to_f32(0x7E00).is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 互操作的评估。
|
||||
//!
|
||||
//! ## 方案 A:wgpu-hal GL 互操作 —— 不可行(macOS)
|
||||
//!
|
||||
//! wgpu-hal 的 GL 互操作面(`hal::api::Gles` 的 adapter/texture
|
||||
//! 互转)只在实例本身就是 GLES 后端时存在。oak 在 macOS 上的 wgpu
|
||||
//! 实例是 **Metal**(wgpu 支持矩阵中 macOS/iOS 仅 Metal 为一等后
|
||||
//! 端;OpenGL 需 ANGLE 转译层且仅 GLES 3.0,见
|
||||
//! <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 为 CGL;OpenGL 自 10.14 起
|
||||
//! 弃用但仍可用),与插件共享纹理命名空间(CGL share group)→ 插件
|
||||
//! render 画进 FBO 附着纹理 → `glReadPixels`/PBO 回读为 CPU 帧 →
|
||||
//! 经 [`oakrender::backend::GpuContextLike::upload`] 上传成 wgpu
|
||||
//! 纹理。接线点已就位:
|
||||
//!
|
||||
//! 1. [`crate::render::texture_id`] 返回真实 GL 名(当前恒 0);
|
||||
//! 2. [`crate::render_driver::render_frame`] 的 `use_opengl` 分支
|
||||
//! (`plugin_supports_opengl && depth_ok && dst_id != 0`)随之
|
||||
//! 生效,走 [`crate::instance::Instance::render_gl`];
|
||||
//! 3. GL suite([`crate::suites::gl_render`])的纹理索引属性写出真
|
||||
//! 值。
|
||||
//!
|
||||
//! 暂缓理由:
|
||||
//! - 需引入新依赖(`glow` + CGL 绑定)与上下文生命周期/线程模型
|
||||
//! 管理(OFX 插件可在自起线程回调 suite);
|
||||
//! - 每帧同步回读是一次 GPU stall,性能上只在"插件本来就是 GL 加
|
||||
//! 速"时划算,而当前无任何真实 GL OFX 插件可验证(测试 `.gl`
|
||||
//! 变体只验证 suite 调用面);
|
||||
//! - CPU 回退路径完整可用,GL 插件经 render action 正确出帧。
|
||||
//!
|
||||
//! ## TODO(phase-GL)
|
||||
//!
|
||||
//! 实现方案 B:CGL 上下文工厂 + share group、`texture_id` 真值化、
|
||||
//! 回读→上传流水线,以及一个真实 GL 插件的端到端验证(golden 帧比
|
||||
//! 较)。在此之前,[`crate::render::texture_id`] 保持恒 0 桩。
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_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<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/未知 → invalid(C++ 末尾
|
||||
// return QVariant())。
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 每插件的默认值缓存(C++ g_plugin_param_defaults,plugin.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 ¶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<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";
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 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<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);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,8 @@ pub(crate) const V_DOUBLE_TYPE_PLAIN: &str = "OfxParamDoubleTypePlain";
|
||||
pub(crate) const P_DEFAULT_COORD_SYS: &str = "OfxParamPropDefaultCoordinateSystem";
|
||||
/// kOfxParamCoordinatesCanonical。
|
||||
pub(crate) const V_COORD_CANONICAL: &str = "OfxParamCoordinatesCanonical";
|
||||
/// kOfxParamCoordinatesNormalised(ofxParam.h:514)。
|
||||
pub(crate) const V_COORD_NORMALISED: &str = "OfxParamCoordinatesNormalised";
|
||||
/// kOfxParamPropShowTimeMarker。
|
||||
pub(crate) const P_SHOW_TIME_MARKER: &str = "OfxParamPropShowTimeMarker";
|
||||
/// kOfxParamPropDimensionLabel。
|
||||
@@ -138,6 +140,14 @@ pub(crate) const V_STRING_SINGLE_LINE: &str = "OfxParamStringIsSingleLine";
|
||||
pub(crate) const P_STRING_FILE_EXISTS: &str = "OfxParamPropStringFilePathExists";
|
||||
/// kOfxParamPropChoiceOption。
|
||||
pub(crate) const P_CHOICE_OPTION: &str = "OfxParamPropChoiceOption";
|
||||
/// kOfxParamPropChoiceOrder。
|
||||
pub(crate) const P_CHOICE_ORDER: &str = "OfxParamPropChoiceOrder";
|
||||
/// kOfxParamPropChoiceEnum。
|
||||
pub(crate) const P_CHOICE_ENUM: &str = "OfxParamPropChoiceEnum";
|
||||
/// kOfxParamPageSkipRow(page 子项哨兵,ofxParam.h:178)。
|
||||
pub(crate) const PAGE_SKIP_ROW: &str = "OfxParamPageSkipRow";
|
||||
/// kOfxParamPageSkipColumn(page 子项哨兵,ofxParam.h:186)。
|
||||
pub(crate) const PAGE_SKIP_COLUMN: &str = "OfxParamPageSkipColumn";
|
||||
/// kOfxParamPropCustomInterpCallbackV1。
|
||||
pub(crate) const P_CUSTOM_INTERP: &str = "OfxParamPropCustomCallbackV1";
|
||||
/// kOfxParamPropPageChild。
|
||||
|
||||
@@ -17,13 +17,57 @@
|
||||
//! 进度上报(Progress suite 的宿主侧)。
|
||||
//!
|
||||
//! 对应 C++ 的 `PluginProgressReporter`。进度/取消经 facade 注册的
|
||||
//! 回调出 crate(M9 的 facade 回调模式,不设全局状态)。
|
||||
//! 取消是粘滞的:一旦回调答 false,本报告器的 [`is_cancelled`]
|
||||
//! (image effect suite 的 abort 查询)持续为真。
|
||||
//! 回调出 crate(M9 的 facade 回调模式)。取消是粘滞的:一旦回调
|
||||
//! 答 false,本报告器的 [`is_cancelled`](image effect suite 的
|
||||
//! abort 查询)持续为真。
|
||||
//!
|
||||
//! ## app 注入点(阶段 6a)
|
||||
//!
|
||||
//! facade C 回调之外,app 可经 [`set_reporter_factory`] 注册一个
|
||||
//! 工厂:渲染未装 C 回调时,Progress suite 的 progressStart 携
|
||||
//! (label, message) 从工厂现造一个 [`UiProgressReporter`](如进度
|
||||
//! 对话框),progressUpdate 转发给它,返回 false 即取消。对应 C++
|
||||
//! `PluginProgressDialogReporter` 的创建路径。
|
||||
|
||||
use std::ffi::c_int;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// 进度 UI 报告器(app 实现:对话框、状态栏等)。`update` 返回
|
||||
/// false 表示用户请求取消(映射 kOfxStatReplyNo)。
|
||||
pub trait UiProgressReporter: Send {
|
||||
/// 上报进度(0.0..=1.0);false = 取消。
|
||||
fn update(&mut self, progress: f64) -> bool;
|
||||
}
|
||||
|
||||
/// 报告器工厂:progressStart 携 (label, message) 调用,现造一个
|
||||
/// [`UiProgressReporter`]。
|
||||
pub type ReporterFactory =
|
||||
Arc<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ pub fn texture_create(
|
||||
/// Rust 等价物。恒 0(GL suite 的 `OpenGLTextureIndex` 属性与
|
||||
/// render 驱动的 use_opengl 决策据此回退 CPU 路径;GPU 上传若落地
|
||||
/// 走 `oakrender::backend::GpuContextLike::upload` 的 wgpu token,
|
||||
/// 不暴露 GL id)。
|
||||
/// 不暴露 GL id)。真实化的评估与方案见 [`crate::gl_bridge`]
|
||||
/// (阶段 6a spike:方案 A 不可行,方案 B 暂缓)。
|
||||
pub fn texture_id(_texture: &Texture) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
@@ -133,13 +133,15 @@ pub fn end_sequence(
|
||||
|
||||
/// 渲染一帧(`render_plugin` 的 Rust 移植;逐段行号对照见模块文档)。
|
||||
///
|
||||
/// 返回各输入 clip 的 RoI(clip 名 → 矩形;Phase 2 供测试断言,
|
||||
/// 宿主不据此裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器
|
||||
/// 行为一致)。
|
||||
/// 返回装配完成的输出纹理与各输入 clip 的 RoI(clip 名 → 矩形;
|
||||
/// 值型纹理下输出写入 `job.dst` 的本地副本并随返回值交付——CPU
|
||||
/// 纹理的 `to_frame` 是拷贝,就地写不回只读的 `job.dst`)。宿主不
|
||||
/// 据 RoI 裁剪输入——输入由 oakrender 整帧提供,与 C++ 渲染器行为
|
||||
/// 一致。
|
||||
pub fn render_frame(
|
||||
inst: &Instance,
|
||||
job: &RenderJob,
|
||||
) -> crate::error::Result<Vec<(String, OfxRectD)>> {
|
||||
) -> crate::error::Result<(Texture, Vec<(String, OfxRectD)>)> {
|
||||
use crate::error::Error;
|
||||
|
||||
// 1. 实例锁(pluginrenderer.cpp:1436-1444:OlivePluginInstance 非
|
||||
@@ -167,8 +169,10 @@ pub fn render_frame(
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// 目标帧与参数(F32 校验;输出装配的依据)。
|
||||
// 目标帧与参数(F32 校验;输出装配的依据)。dst 取本地副本:
|
||||
// 值型纹理下输出装配写回副本,随返回值交付(job.dst 只读)。
|
||||
let (dst_params, w, h) = read_dst(&job.dst)?;
|
||||
let mut dst = job.dst.clone();
|
||||
let par = pixel_aspect(&dst_params);
|
||||
// 规范坐标的 RoI/RoD(pluginrenderer.cpp:1595-1603:x2 = 宽 × PAR)。
|
||||
let region_of_interest = OfxRectD {
|
||||
@@ -209,7 +213,7 @@ pub fn render_frame(
|
||||
.find(|c| c.name == "Output")
|
||||
.ok_or_else(|| Error::Failed("实例无 Output clip".into()))?;
|
||||
output_clip.set_region_of_definition(region_of_interest, job.time);
|
||||
output_clip.set_output_texture(Some(job.dst.clone()), job.time);
|
||||
output_clip.set_output_texture(Some(dst.clone()), job.time);
|
||||
|
||||
// 6. 输入 clip:RoD 与格式(pluginrenderer.cpp:1627-1665;Phase 2
|
||||
// 全链路 F32 → 格式选择恒等,无转换路径)。
|
||||
@@ -244,8 +248,8 @@ pub fn render_frame(
|
||||
// 9. isIdentity 短路(ofxRendering "Identity Effects"):插件声明
|
||||
// 本帧等价于某输入 clip → 直接透传该 clip 在透传时间的帧。
|
||||
if let Some((t, clip_name)) = inst.is_identity(job.time)? {
|
||||
passthrough(inst, &clip_name, t, &job.dst)?;
|
||||
return Ok(zip_rois(inst, &rois));
|
||||
passthrough(inst, &clip_name, t, &mut dst)?;
|
||||
return Ok((dst, zip_rois(inst, &rois)));
|
||||
}
|
||||
|
||||
// 10. 参数覆盖(pluginrenderer.cpp:1729-1731 + 132-290)。
|
||||
@@ -270,7 +274,7 @@ pub fn render_frame(
|
||||
output.clone(),
|
||||
)?;
|
||||
// 输出装配(pluginrenderer.cpp:1762-1834 的 CPU 路径)。
|
||||
write_output_frame(&job.dst, &output)?;
|
||||
write_output_frame(&mut dst, &output)?;
|
||||
} else {
|
||||
// GL 路径:插件直接画进已附着的输出纹理(
|
||||
// pluginrenderer.cpp:1784-1834 的 GL 分支);无 CPU 回读。
|
||||
@@ -279,11 +283,11 @@ pub fn render_frame(
|
||||
RenderScale { x: 1.0, y: 1.0 },
|
||||
render_window,
|
||||
job.renderer.clone().unwrap(),
|
||||
job.dst.clone(),
|
||||
dst.clone(),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(zip_rois(inst, &rois))
|
||||
Ok((dst, zip_rois(inst, &rois)))
|
||||
}
|
||||
|
||||
/// 把输入 clip 名与 RoI 列表配对(与 `clips` 顺序一致)。
|
||||
@@ -373,7 +377,7 @@ fn passthrough(
|
||||
inst: &Instance,
|
||||
clip_name: &str,
|
||||
t: f64,
|
||||
dst: &Texture,
|
||||
dst: &mut Texture,
|
||||
) -> crate::error::Result<()> {
|
||||
use crate::error::Error;
|
||||
let clip = inst
|
||||
@@ -395,18 +399,51 @@ fn apply_param_overrides(inst: &Instance, values: &[(String, crate::node::Value)
|
||||
let Some(p) = inst.params.find(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(pv) = crate::param::param_value_from_node(v, &p.def.ofx_type) else {
|
||||
let Some(mut pv) = crate::param::param_value_from_node(v, &p.def.ofx_type) else {
|
||||
continue;
|
||||
};
|
||||
// Double 标量的 NaN/Inf 清洗 + Min/Max 钳制
|
||||
// (pluginrenderer.cpp:155-177:坏值回退默认并告警,再按
|
||||
// kOfxParamPropMin/Max 钳制;多维 Double 族 C++ 无此检查)。
|
||||
if p.def.ofx_type == crate::param::TYPE_DOUBLE {
|
||||
if let crate::param::ParamValue::Double(d, 1) = &mut pv {
|
||||
if d[0].is_nan() || d[0].is_infinite() {
|
||||
eprintln!(
|
||||
"[PLUGIN] NaN/Inf in double param {key} replacing with default"
|
||||
);
|
||||
d[0] = prop_double(&p.def.props, crate::param::P_DEFAULT, 0);
|
||||
}
|
||||
if let Some(Value::Double(min)) = p.def.props.get(crate::param::P_MIN, 0) {
|
||||
if d[0] < min {
|
||||
d[0] = min;
|
||||
}
|
||||
}
|
||||
if let Some(Value::Double(max)) = p.def.props.get(crate::param::P_MAX, 0) {
|
||||
if d[0] > max {
|
||||
d[0] = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p.set_ofx(pv);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读属性的 Double 值(缺失 0.0;Int 提升)。
|
||||
fn prop_double(props: &crate::property::PropertySet, name: &str, index: usize) -> f64 {
|
||||
match props.get(name, index) {
|
||||
Some(Value::Double(v)) => v,
|
||||
Some(Value::Int(v)) => v as f64,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 CPU 图像写入目标纹理(行优先、行跨度感知;F32 校验)。
|
||||
/// Phase 2 输出装配的公共落点(CPU render 路径与 isIdentity 透传
|
||||
/// 共用)。GPU 目标纹理经后端 upload 回写(`Texture::Gpu` 分支)。
|
||||
pub(crate) fn write_output_frame(dst: &Texture, image: &Image) -> crate::error::Result<()> {
|
||||
pub(crate) fn write_output_frame(dst: &mut Texture, image: &Image) -> crate::error::Result<()> {
|
||||
use crate::error::Error;
|
||||
// CPU 纹理就地写入;GPU 纹理经下载帧改写后 upload 回写。
|
||||
let mut frame = render::texture_get_frame(dst)?;
|
||||
let params = frame.video_params();
|
||||
if params.format != render::PIXEL_FORMAT_F32 {
|
||||
@@ -430,11 +467,16 @@ pub(crate) fn write_output_frame(dst: &Texture, image: &Image) -> crate::error::
|
||||
let s = y * tight;
|
||||
dst_bytes[d..d + tight].copy_from_slice(&pixels[s..s + tight]);
|
||||
}
|
||||
// GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写
|
||||
// (CPU 纹理无需上传)。
|
||||
if let Texture::Gpu { token, ctx, .. } = dst {
|
||||
ctx.upload(*token, &frame)
|
||||
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
|
||||
match dst {
|
||||
// 就地写回(值型 CPU 纹理:to_frame 是拷贝,必须写回本体)。
|
||||
Texture::Cpu(f) => {
|
||||
f.data = frame.data;
|
||||
}
|
||||
// GPU 目标纹理:拷贝只落在下载帧上,经后端 upload 回写。
|
||||
Texture::Gpu { token, ctx, .. } => {
|
||||
ctx.upload(*token, &frame)
|
||||
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -84,10 +84,29 @@ pub struct ProgressSuiteV2 {
|
||||
pub end: unsafe extern "C" fn(*mut c_void) -> c_int,
|
||||
}
|
||||
|
||||
/// progressStart:括号起点,OK(label/message 留作未来 UI 展示,
|
||||
/// 第 1 期不建模)。
|
||||
unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, _label: *const c_char) -> c_int {
|
||||
caught(|| status::OK)
|
||||
/// C 字符串解码(空指针/非法 UTF-8 → 空串)。
|
||||
unsafe fn decode<'a>(p: *const c_char) -> &'a str {
|
||||
if p.is_null() {
|
||||
return "";
|
||||
}
|
||||
unsafe { std::ffi::CStr::from_ptr(p) }
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// progressStart:括号起点;携 label(v2 另携 message)经
|
||||
/// [`crate::progress`] 工厂现造 UI 报告器(无工厂/已有报告器时
|
||||
/// no-op),状态恒 OK。
|
||||
unsafe extern "C" fn progress_start_v1(_handle: *mut c_void, label: *const c_char) -> c_int {
|
||||
caught(|| {
|
||||
let label = unsafe { decode(label) }.to_string();
|
||||
CURRENT.with(|c| {
|
||||
if let Some(r) = c.borrow().as_ref() {
|
||||
r.install_ui(&label, "");
|
||||
}
|
||||
});
|
||||
status::OK
|
||||
})
|
||||
}
|
||||
|
||||
unsafe extern "C" fn progress_update_v1(_handle: *mut c_void, progress: c_double) -> c_int {
|
||||
@@ -100,10 +119,19 @@ unsafe extern "C" fn progress_end_v1(_handle: *mut c_void) -> c_int {
|
||||
|
||||
unsafe extern "C" fn progress_start_v2(
|
||||
_handle: *mut c_void,
|
||||
_label: *const c_char,
|
||||
_message: *const c_char,
|
||||
label: *const c_char,
|
||||
message: *const c_char,
|
||||
) -> c_int {
|
||||
caught(|| status::OK)
|
||||
caught(|| {
|
||||
let label = unsafe { decode(label) }.to_string();
|
||||
let message = unsafe { decode(message) }.to_string();
|
||||
CURRENT.with(|c| {
|
||||
if let Some(r) = c.borrow().as_ref() {
|
||||
r.install_ui(&label, &message);
|
||||
}
|
||||
});
|
||||
status::OK
|
||||
})
|
||||
}
|
||||
|
||||
unsafe extern "C" fn progress_update_v2(_handle: *mut c_void, progress: c_double) -> c_int {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
// 渲染上下文缺省 → 0(headless 默认)。
|
||||
*time = render_ctx().map_or(0.0, |c| c.time);
|
||||
// 渲染上下文 → 活动 viewer → 0(headless 默认)。
|
||||
*time = render_ctx()
|
||||
.map(|c| c.time)
|
||||
.or_else(|| active_viewer().map(|v| v.time))
|
||||
.unwrap_or(0.0);
|
||||
status::OK
|
||||
})
|
||||
}
|
||||
@@ -77,7 +120,11 @@ unsafe extern "C" fn timeline_get_time_bounds(
|
||||
if min.is_null() || max.is_null() {
|
||||
return status::ERR_VALUE;
|
||||
}
|
||||
let r = render_ctx().map_or((0.0, 0.0), |c| (c.range.min, c.range.max));
|
||||
// 渲染上下文 → 活动 viewer → (0,0)(headless 默认)。
|
||||
let r = render_ctx()
|
||||
.map(|c| (c.range.min, c.range.max))
|
||||
.or_else(|| active_viewer().map(|v| (v.range_min, v.range_max)))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
*min = r.0;
|
||||
*max = r.1;
|
||||
status::OK
|
||||
|
||||
@@ -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 应建出插件节点");
|
||||
|
||||
// gain:Double → Float,默认 0.0,显示名 Gain,带 display
|
||||
// min/max 属性(-2/2)。
|
||||
let gain = core.get_input("gain").expect("gain 输入");
|
||||
assert_eq!(gain.value_type, ValueType::Float);
|
||||
assert_eq!(gain.default, NodeValue::Float(0.0));
|
||||
assert_eq!(gain.display_name, "Gain");
|
||||
// min/max/tooltip 属性 C++ 只对颜色输入设置
|
||||
// (plugin.cpp:407-430 的 k_color 分支);Double 参数无。
|
||||
assert!(gain
|
||||
.properties
|
||||
.iter()
|
||||
.all(|(k, _)| k != "min" && k != "max"));
|
||||
|
||||
// mode:Choice → Combo,两个选项 Fast/High。
|
||||
let mode = core.get_input("mode").expect("mode 输入");
|
||||
assert_eq!(mode.value_type, ValueType::Combo);
|
||||
let options: Vec<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()]);
|
||||
|
||||
// debug:secret → hidden。
|
||||
let debug = core.get_input("debug").expect("debug 输入");
|
||||
assert!(
|
||||
debug.flags & oaknode::input::flags::HIDDEN != 0,
|
||||
"secret 参数应隐藏"
|
||||
);
|
||||
|
||||
// label:String → Text。
|
||||
let label = core.get_input("label").expect("label 输入");
|
||||
assert_eq!(label.value_type, ValueType::Text);
|
||||
|
||||
// Source clip → 纹理输入;effect_input 选中 Source。
|
||||
let source = core.get_input("Source").expect("Source 输入");
|
||||
assert_eq!(source.value_type, ValueType::Texture);
|
||||
assert_eq!(core.effect_input, "Source");
|
||||
|
||||
// 行为是持真实实例句柄的 PluginNode。
|
||||
let plugin = behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<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.5(alpha=1)。
|
||||
assert_eq!(first_pixel(&texture), [0.5, 0.5, 0.5, 1.0]);
|
||||
|
||||
Host::global().shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
/// isIdentity 透传:identity 变体声明恒透传 Source → 输出应等于
|
||||
/// 输入帧(render_driver 的 passthrough 短路)。
|
||||
#[test]
|
||||
fn identity_variant_passes_source_through() {
|
||||
common::with_host(|| {
|
||||
if !scan_and_register() {
|
||||
return;
|
||||
}
|
||||
let (core, behavior) = Factory::global()
|
||||
.create_any(IDENTITY_ID)
|
||||
.expect("create_any(identity)");
|
||||
|
||||
let mut graph = Graph::new();
|
||||
let src_id = graph.add_node(
|
||||
NodeCore::new(),
|
||||
Box::new(ConstSource {
|
||||
rgba: [0.25, 0.75, 0.5, 1.0],
|
||||
size: (2, 2),
|
||||
}),
|
||||
);
|
||||
let plug_id = graph.add_node(core, behavior);
|
||||
graph
|
||||
.connect(src_id, plug_id, "Source", -1)
|
||||
.expect("Source 连接");
|
||||
|
||||
let mut traverser = Traverser::new();
|
||||
let mut hooks = oakrender::eval::RenderEvalHooks::new();
|
||||
let table = traverser
|
||||
.evaluate(
|
||||
&graph,
|
||||
&EvalRequest::new(plug_id, Rational::new(0, 1)),
|
||||
&mut hooks,
|
||||
)
|
||||
.expect("evaluate 应成功");
|
||||
|
||||
let texture = rendered_texture(&table);
|
||||
assert_eq!(first_pixel(&texture), [0.25, 0.75, 0.5, 1.0]);
|
||||
|
||||
Host::global().shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
/// 参数覆盖路径:Text/StrCombo 走 set_ofx,数值走 POD——经 set 后
|
||||
/// 实例参数值可读回(翻译注入的回归保护)。
|
||||
#[test]
|
||||
fn param_overrides_reach_instance() {
|
||||
common::with_host(|| {
|
||||
if !scan_and_register() {
|
||||
return;
|
||||
}
|
||||
let inst = Host::global()
|
||||
.create_instance(PLUGIN_ID, None)
|
||||
.expect("实例");
|
||||
let id = oakplugin::node_factory::register_instance(inst.clone());
|
||||
|
||||
// 数值覆盖(gain = 1.25)。
|
||||
let job_values = vec![(
|
||||
"gain".to_string(),
|
||||
oaknode::value::NodeValue::Float(1.25),
|
||||
)];
|
||||
let pod: Vec<(String, oakplugin::node::Value)> = job_values
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
oakplugin::node::Value::from_node_value(v).map(|p| (k.clone(), p))
|
||||
})
|
||||
.collect();
|
||||
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||
.unwrap();
|
||||
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||
.unwrap();
|
||||
let job = oakplugin::render_driver::RenderJob {
|
||||
time: 0.0,
|
||||
dst: Texture::wrap_frame(dst),
|
||||
src: Some(Texture::wrap_frame(src)),
|
||||
effect_input_id: Some("Source".into()),
|
||||
inputs: Vec::new(),
|
||||
values: pod,
|
||||
renderer: None,
|
||||
clear_destination: false,
|
||||
interactive: false,
|
||||
};
|
||||
oakplugin::render_driver::render_frame(&inst.value, &job)
|
||||
.expect("render_frame 应成功");
|
||||
let gain = inst.value.params.find("gain").unwrap().get();
|
||||
assert_eq!(
|
||||
gain,
|
||||
oakplugin::param::ParamValue::Double([1.25, 0.0, 0.0], 1)
|
||||
);
|
||||
|
||||
// NaN 覆盖回退默认(gain 默认 0.0)。
|
||||
let pod_nan = vec![(
|
||||
"gain".to_string(),
|
||||
oakplugin::node::Value::float(f64::NAN),
|
||||
)];
|
||||
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||
.unwrap();
|
||||
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32)
|
||||
.unwrap();
|
||||
let job = oakplugin::render_driver::RenderJob {
|
||||
time: 0.0,
|
||||
dst: Texture::wrap_frame(dst),
|
||||
src: Some(Texture::wrap_frame(src)),
|
||||
effect_input_id: Some("Source".into()),
|
||||
inputs: Vec::new(),
|
||||
values: pod_nan,
|
||||
renderer: None,
|
||||
clear_destination: false,
|
||||
interactive: false,
|
||||
};
|
||||
oakplugin::render_driver::render_frame(&inst.value, &job)
|
||||
.expect("NaN 覆盖不应失败");
|
||||
assert_eq!(
|
||||
inst.value.params.find("gain").unwrap().get(),
|
||||
oakplugin::param::ParamValue::Double([0.0, 0.0, 0.0], 1)
|
||||
);
|
||||
|
||||
oakplugin::node_factory::unregister_instance(id);
|
||||
Host::global().shutdown();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user