refactor: drop internal bridge/ffi layers; exporter family lands

Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are
gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/
oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain
Rust, CHandle marshalling shrinks to the oakengine boundary, and tests
call the Rust APIs directly (pure C-ABI wrapper tests removed where
the domain layer already covers the behavior).

exporter.h family implemented: oakengine_export_render (CLI contract),
oakengine_export_render_with_params (was a stub), last_error and
progress callback; synchronous path reuses task_create_export +
start_sync. Fixes on the way: oaktask video ticket self-deadlock,
audio params dropped on the export path, codec encoder AAC slicing and
H.264 time base. Real-mp4 tests cover both entry points, progress and
the illegal-argument matrix.

Also: oakstorage session maps null project handles to None (version-
info path), configstore test double literal 3.14 -> 3.15 (clippy PI
lint), oakaudio output callback scratch buffer + env-aware P1 test,
cli media round-trip test uses a generated 16-frame clip (no more
minute-long debug runs).
This commit is contained in:
2026-08-16 00:33:45 +08:00
parent 2248be8567
commit ab1a2e9c7b
293 changed files with 27826 additions and 90128 deletions
-576
View File
@@ -1,576 +0,0 @@
// 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/>.
//! bridge 层测试:param↔oaknode、clip↔oakrender、undo 打包。
//!
//! 依赖 liboaknode/liboakrender/liboakundo 的用例(standalone 树
//! 环境,外层 ctest 驱动)在 cargo 内以库内桩替代
//! (`--features test-stubs`):桥调用面一致,桩提供节点值/命令/
//! 帧状态访问器(见 `bridge::{node,render,undo}::stub`)。未启用
//! 桩时同用例走 dlsym 缺失的降级路径(可解释错误 / no-op)。
//!
//! 系统级冒烟:[`system_misc_ofx_bundle_smoke`] 在
//! `/Library/OFX/Plugins/Misc.ofx.bundle` 存在时扫描真实 OFX 插件
//! 并 create_instance(absent 时 skip)。
mod common;
use std::ffi::{c_int, c_void, CString};
use oakplugin::clip::ClipInstance;
use oakplugin::descriptor::ClipDescriptor;
use oakplugin::handle::CHandle;
use oakplugin::image::{BitDepth, Components, Image};
use oakplugin::instance::{Instance, OfxRectD, RenderScale};
use oakplugin::param::ParamInstance;
use oakplugin::property::PropertySet;
use oakplugin::suites::param::suite_v1 as param_suite;
use oakplugin::suites::tag;
const OK: c_int = 0;
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
/// 简易纹理句柄(桩 ctx 约定,见 bridge::render::stub:src=0xA2、
/// dst=0xA1)。
fn fake_texture(ctx: usize) -> CHandle {
CHandle {
ctx: ctx as *mut c_void,
addref: None,
release: None,
abi_version: 1,
}
}
/// 用 param suite 的 paramDefine 造一个指定 OFX 类型的参数实例
/// (describe 产物 → createInstance)。
fn make_param(ofx_type: &str) -> ParamInstance {
let mut desc = oakplugin::descriptor::EffectDescriptor::new();
let s = param_suite();
let dhandle = tag::make(&desc.props as *const PropertySet, tag::DESCRIPTOR);
let t = cs(ofx_type);
let n = cs("p");
let mut ph: *mut c_void = std::ptr::null_mut();
let r = unsafe { (s.param_define)(dhandle, t.as_ptr(), n.as_ptr(), &mut ph) };
assert_eq!(r, OK, "paramDefine {ofx_type} 失败: {r}");
ParamInstance::from_def(*desc.params.pop().expect("刚 define 的参数"))
}
/// 简易 clip 实例。
fn make_clip(name: &str) -> ClipInstance {
ClipInstance::from_descriptor(&ClipDescriptor {
props: PropertySet::new(),
name: name.into(),
})
}
/// 扫描测试插件并创建实例,绑定 `identity`(0 = 不绑定),返回
/// (实例, gain 参数句柄)。插件不可用时 skip 并返回 None。
fn bound_inst(
identity: usize,
) -> Option<(
std::sync::Arc<oakplugin::handle::RefBox<Instance>>,
*mut c_void,
)> {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return None;
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin::ffi::oakplugin_host_scan(dirs.as_ptr(), 1) };
let inst = oakplugin::host::Host::global()
.create_instance(TEST_PLUGIN_ID, None)
.ok()?;
inst.value.bind_node(identity);
let ih = tag::make(&inst.value.props as *const PropertySet, tag::INSTANCE);
let name = cs("gain");
let mut ph: *mut c_void = std::ptr::null_mut();
let s = param_suite();
let r = unsafe { (s.param_get_handle)(ih, name.as_ptr(), &mut ph, std::ptr::null_mut()) };
assert_eq!(r, OK, "paramGetHandle(gain) 失败: {r}");
Some((inst, ph))
}
/// 节点→插件:oaknode 输入值变化经桥写入 OFX 参数(类型映射表
/// 逐行:int/float/bool/color/vec2/vec3/combo/str_combo)。
#[test]
fn node_to_param_type_mapping() {
use oakplugin::bridge::node::{node_value_type as T, Value};
// Integer ← INT(num 载荷)。
let p = make_param(oakplugin::param::TYPE_INTEGER);
let mut v = Value::default();
v.r#type = T::INT;
v.num = 7;
p.set_from_node(&v);
assert_eq!(p.get(), oakplugin::param::ParamValue::Int([7, 0, 0], 1));
// Double ← FLOAT(f[0] 载荷)。
let p = make_param(oakplugin::param::TYPE_DOUBLE);
p.set_from_node(&Value::float(1.5));
assert_eq!(
p.get(),
oakplugin::param::ParamValue::Double([1.5, 0.0, 0.0], 1)
);
// Boolean ← BOOL(num 0/1)。
let p = make_param(oakplugin::param::TYPE_BOOLEAN);
p.set_from_node(&Value::bool_(true));
assert_eq!(p.get(), oakplugin::param::ParamValue::Bool(true));
// Choice ← COMBO。
let p = make_param(oakplugin::param::TYPE_CHOICE);
p.set_from_node(&Value::combo(2));
assert_eq!(p.get(), oakplugin::param::ParamValue::Choice(2));
// RGBA ← COLOR(r,g,b,a)。
let p = make_param(oakplugin::param::TYPE_RGBA);
p.set_from_node(&Value::color(0.1, 0.2, 0.3, 0.9));
assert_eq!(
p.get(),
oakplugin::param::ParamValue::Color([0.1, 0.2, 0.3, 0.9], 4)
);
// RGB ← COLOR(alpha 忽略,恒 0 占位)。
let p = make_param(oakplugin::param::TYPE_RGB);
p.set_from_node(&Value::color(0.1, 0.2, 0.3, 0.5));
assert_eq!(
p.get(),
oakplugin::param::ParamValue::Color([0.1, 0.2, 0.3, 0.0], 3)
);
// Double2D ← VEC2;Double3D ← VEC3。
let p = make_param(oakplugin::param::TYPE_DOUBLE2D);
p.set_from_node(&Value::vec(&[1.0, 2.0]));
assert_eq!(
p.get(),
oakplugin::param::ParamValue::Double([1.0, 2.0, 0.0], 2)
);
let p = make_param(oakplugin::param::TYPE_DOUBLE3D);
p.set_from_node(&Value::vec(&[1.0, 2.0, 3.0]));
assert_eq!(
p.get(),
oakplugin::param::ParamValue::Double([1.0, 2.0, 3.0], 3)
);
// Integer2D ← VEC2 / Integer3D ← VEC3(浮点截断为 int)。
let p = make_param(oakplugin::param::TYPE_INTEGER2D);
p.set_from_node(&Value::vec(&[1.5, 2.5]));
assert_eq!(p.get(), oakplugin::param::ParamValue::Int([1, 2, 0], 2));
let p = make_param(oakplugin::param::TYPE_INTEGER3D);
p.set_from_node(&Value::vec(&[1.5, 2.5, 3.5]));
assert_eq!(p.get(), oakplugin::param::ParamValue::Int([1, 2, 3], 3));
// String 参数 + STRING 节点类型:POD 不携带数据 → 值不变
// (保持默认空串;字符串值走 facade 的字符串 API)。
let p = make_param(oakplugin::param::TYPE_STRING);
p.set_from_node(&Value::string());
assert!(matches!(p.get(), oakplugin::param::ParamValue::String(s) if s.to_bytes().is_empty()));
// 类型不匹配 → 忽略(保持现值)。
let p = make_param(oakplugin::param::TYPE_DOUBLE);
p.set_ofx(oakplugin::param::ParamValue::Double([5.0, 0.0, 0.0], 1));
p.set_from_node(&Value::int(9)); // INT 不是 FLOAT
assert_eq!(
p.get(),
oakplugin::param::ParamValue::Double([5.0, 0.0, 0.0], 1)
);
}
/// 插件→节点:paramSetValue(插件自改)经 instanceChanged 桥回写
/// oaknode,且打包为一条 undo 命令(undo/redo 后值正确)。
#[test]
fn param_to_node_undoable_writeback() {
common::with_host(|| {
let Some((inst, gain_h)) = bound_inst(42) else {
return;
};
let s = param_suite();
#[cfg(feature = "test-stubs")]
{
use oakplugin::bridge::{node, undo};
node::stub::reset();
undo::stub::reset();
node::stub::register_node(42);
node::stub::set_input(42, "gain", node::Value::float(0.5));
// 插件自改 → 回写 + 单命令立即 redo。
let r = unsafe { (s.param_set_value)(gain_h, 0.75) };
assert_eq!(r, OK, "paramSetValue 失败: {r}");
let cur = node::stub::input(42, "gain").expect("已回写节点输入");
assert_eq!(cur.value.f[0], 0.75);
// undoable:命令捕获 prev/next 且已应用(redo 语义)。
let rec = undo::stub::last_command().expect("回写产生命令");
assert_eq!(rec.node, 42);
assert_eq!(rec.input, "gain");
assert!(!rec.is_multi);
assert!(rec.applied);
assert_eq!(rec.prev.f[0], 0.5, "prev = 修改前节点值");
assert_eq!(rec.next.f[0], 0.75, "next = 修改后值");
// undo → 恢复旧值;redo → 再应用。
undo::stub::undo(rec.id);
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.5);
undo::stub::redo(rec.id);
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.75);
// 字符串参数:POD 无数据 → 经字符串桥回写。
node::stub::set_input_string(42, "label", "old");
let name = cs("label");
let mut lh: *mut c_void = std::ptr::null_mut();
let ih = tag::make(&inst.value.props as *const PropertySet, tag::INSTANCE);
assert_eq!(
unsafe { (s.param_get_handle)(ih, name.as_ptr(), &mut lh, std::ptr::null_mut()) },
OK
);
let hello = cs("hello");
assert_eq!(unsafe { (s.param_set_value)(lh, hello.as_ptr()) }, OK);
let cur = node::stub::input(42, "label").expect("字符串已回写");
assert_eq!(cur.string.as_deref(), Some("hello"));
}
#[cfg(not(feature = "test-stubs"))]
{
// 无 liboaknode:桥符号缺失 → node_from_identity 空句柄,
// 回写 no-op;paramSetValue 仍成功且值本地生效。
let r = unsafe { (s.param_set_value)(gain_h, 0.75) };
assert_eq!(r, OK);
let mut out = 0.0;
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
assert_eq!(out, 0.75);
}
});
}
/// 未绑定节点的实例收到 instanceChanged:no-op 不崩(身份注册表
/// 查无此项的路径)。
#[test]
fn writeback_without_bound_node_is_noop() {
common::with_host(|| {
let Some((inst, gain_h)) = bound_inst(0) else {
return;
};
let s = param_suite();
// identity 0(未绑定):回写直接短路,值只写本地参数。
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.5) }, OK);
let mut out = 0.0;
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
assert_eq!(out, 0.5);
#[cfg(feature = "test-stubs")]
{
use oakplugin::bridge::{node, undo};
node::stub::reset();
undo::stub::reset();
node::stub::register_node(9);
// 已绑定但身份在注册表查无 → node_from_identity 空句柄。
inst.value.bind_node(9999);
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.6) }, OK);
assert!(
undo::stub::records().is_empty(),
"身份查无不应产生命令:{:?}",
undo::stub::records()
);
// 已登记身份但输入名不存在 → 桥返回错误,同样 no-op。
inst.value.bind_node(9);
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.7) }, OK);
assert!(undo::stub::records().is_empty(), "输入不存在不应产生命令");
// 节点输入值保持未回写(本地参数值仍有效)。
let mut out = 0.0;
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
assert_eq!(out, 0.7);
}
});
}
/// clip 输入:oakrender 纹理挂接后 fetch_image 读回的像素与
/// 纹理内容一致(F32 格式不丢精度)。
#[test]
fn clip_texture_roundtrip_f32() {
let clip = make_clip("Source");
let scale = RenderScale { x: 1.0, y: 1.0 };
// 未挂纹理 → NotFound(两模式一致)。
assert!(matches!(
clip.fetch_image(0.0, scale, None),
Err(oakplugin::error::Error::NotFound)
));
// 子区域第 1 期不支持 → Failed。
assert!(matches!(
clip.fetch_image(
0.0,
scale,
Some(OfxRectD {
x1: 0.0,
y1: 0.0,
x2: 2.0,
y2: 2.0
})
),
Err(oakplugin::error::Error::Failed(_))
));
#[cfg(feature = "test-stubs")]
{
use oakplugin::bridge::render::{self, PIXEL_FORMAT_F32, PIXEL_FORMAT_U8};
render::stub::reset();
// 构造可识别的 F32 RGBA 帧(r=行, g=列, b=0, a=1)。
let (w, h) = (8usize, 4usize);
let mut pixels = Vec::with_capacity(w * h * 16);
for y in 0..h {
for x in 0..w {
for v in [y as f32, x as f32, 0.0, 1.0] {
pixels.extend_from_slice(&v.to_le_bytes());
}
}
}
render::stub::setup_src(w as i32, h as i32, PIXEL_FORMAT_F32, pixels.clone());
let tex = fake_texture(0xA2);
clip.set_input_texture(tex, 0.0);
let img = clip
.fetch_image(0.0, scale, None)
.expect("fetch_image 成功");
assert_eq!(img.pixels(), &pixels, "F32 像素 round-trip 不丢精度");
assert_eq!(img.components(), Components::Rgba);
assert_eq!(img.depth(), BitDepth::Float);
// dummy 纹理输入 → 空输入(NotFound)。
render::stub::set_dummy(0xA2, true);
assert!(matches!(
clip.fetch_image(0.0, scale, None),
Err(oakplugin::error::Error::NotFound)
));
render::stub::set_dummy(0xA2, false);
// 非 F32 输入帧 → 明确失败。
render::stub::setup_src(w as i32, h as i32, PIXEL_FORMAT_U8, vec![0u8; w * h * 4]);
assert!(matches!(
clip.fetch_image(0.0, scale, None),
Err(oakplugin::error::Error::Failed(_))
));
}
}
/// 输出:store_output_image 产出的纹理在 oakrender 侧可读且
/// 像素一致;dummy 纹理输入按空输入处理。
#[test]
fn output_image_to_texture() {
let clip = make_clip("Output");
let img = Image::allocate(
BitDepth::Float,
Components::Rgba,
OfxRectD {
x1: 0.0,
y1: 0.0,
x2: 8.0,
y2: 4.0,
},
);
// 未挂输出纹理 → NotFound(两模式一致)。
assert!(matches!(
clip.store_output_image(&img),
Err(oakplugin::error::Error::NotFound)
));
#[cfg(feature = "test-stubs")]
{
use oakplugin::bridge::render::{self, PIXEL_FORMAT_F32, PIXEL_FORMAT_U8};
render::stub::reset();
render::stub::setup_dst(8, 4, PIXEL_FORMAT_F32);
let tex = fake_texture(0xA1);
clip.set_output_texture(tex, 0.0);
// 填非平凡像素后回写 → oakrender 侧读到的与图像一致。
let mut img = img;
for (i, b) in img.pixels_mut().iter_mut().enumerate() {
*b = (i % 256) as u8;
}
let tex2 = clip
.store_output_image(&img)
.expect("store_output_image 成功");
assert_eq!(tex2.ctx, tex.ctx, "返回挂入的纹理句柄");
assert_eq!(render::stub::dst_pixels(), img.pixels(), "输出像素一致");
// dummy 输出纹理 → NotFound。
render::stub::set_dummy(0xA1, true);
assert!(matches!(
clip.store_output_image(&img),
Err(oakplugin::error::Error::NotFound)
));
render::stub::set_dummy(0xA1, false);
// 帧尺寸与图像不一致 → 明确失败。
render::stub::setup_dst(16, 4, PIXEL_FORMAT_F32);
assert!(matches!(
clip.store_output_image(&img),
Err(oakplugin::error::Error::Failed(_))
));
// 非 F32 输出帧 → 明确失败。
render::stub::setup_dst(8, 4, PIXEL_FORMAT_U8);
assert!(matches!(
clip.store_output_image(&img),
Err(oakplugin::error::Error::Failed(_))
));
}
}
/// undo 打包:一次编辑事务(paramEditBegin/End)内的多次参数
/// 修改合并为一条命令。
#[test]
fn edit_transaction_coalescing() {
common::with_host(|| {
let Some((_inst, gain_h)) = bound_inst(42) else {
return;
};
let s = param_suite();
#[cfg(feature = "test-stubs")]
{
use oakplugin::bridge::{node, undo};
node::stub::reset();
undo::stub::reset();
node::stub::register_node(42);
node::stub::set_input(42, "gain", node::Value::float(0.0));
// 编辑事务内两次回写 → 一条 multi。
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.25) }, OK);
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.5) }, OK);
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
let recs = undo::stub::records();
let multi = recs.iter().find(|r| r.is_multi).expect("存在 multi 命令");
assert_eq!(multi.children.len(), 2, "两次修改合并为一条 multi");
for c in &multi.children {
let child = recs.iter().find(|r| r.id == *c).expect("子命令在表");
assert!(!child.is_multi);
assert!(child.applied, "子命令已 redo 生效");
assert_eq!(child.node, 42);
assert_eq!(child.input, "gain");
}
// 值即时生效(事务内子命令 redo;editEnd 的 multi redo 幂等)。
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.5);
// 事务外回写 → 独立单命令(不并入 multi)。
let before = undo::stub::records().len();
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.75) }, OK);
let after = undo::stub::records().len();
assert_eq!(after, before + 1, "事务外每次回写一条独立命令");
let last = undo::stub::last_command().unwrap();
assert!(!last.is_multi);
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.75);
// 嵌套 editBegin/End:内层结束不提交(multi 保留未 free),
// 最外层结束才提交(redo + free)。
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
assert_eq!(unsafe { (s.param_set_value)(gain_h, 1.0) }, OK);
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
let newest_multi = || {
undo::stub::records()
.iter()
.filter(|r| r.is_multi)
.max_by_key(|r| r.id)
.cloned()
.expect("存在 multi")
};
let m = newest_multi();
assert_eq!(m.children.len(), 1, "事务内回写并入新 multi");
assert!(!m.freed, "内层 editEnd 不提交");
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
assert!(newest_multi().freed, "最外层 editEnd 提交(redo+free)");
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 1.0);
}
#[cfg(not(feature = "test-stubs"))]
{
// 无桥:editBegin/End 与回写 no-op;参数值本地生效。
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.25) }, OK);
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
let mut out = 0.0;
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
assert_eq!(out, 0.25);
}
});
}
/// 系统级冒烟:真实 OFX 插件 bundle(/Library/OFX/Plugins/
/// Misc.ofx.bundle,本机安装的标准 openfx-misc)在存在时扫描 →
/// describe → create_instance → 协商;absent 时 skip。
///
/// 这是 0 期 golden master 的轻量替代(0 期基建可能尚未落地——见
/// README「与 M11 §3.5 的对照」):只验"能加载、能建实例、协商
/// 有结果",不比对快照。
#[test]
fn system_misc_ofx_bundle_smoke() {
let bundle = std::path::Path::new("/Library/OFX/Plugins/Misc.ofx.bundle");
if !bundle.is_dir() {
common::skip("/Library/OFX/Plugins/Misc.ofx.bundle 不存在");
return;
}
common::with_host(|| {
use oakplugin::host::Host;
let host = Host::global();
// 只扫 Misc:临时目录内建软链(scan_path 会 canonicalize)。
let tmp = std::env::temp_dir().join(format!("oak-misc-scan-{}", std::process::id()));
let link = tmp.join("Misc.ofx.bundle");
if !link.exists() {
std::fs::create_dir_all(&tmp).ok();
std::os::unix::fs::symlink(bundle, &link).expect("建软链");
}
host.cache.scan_path(&tmp).expect("扫描 Misc.ofx.bundle");
std::fs::remove_dir_all(&tmp).ok();
// bundle 内的插件已 describe 入缓存。
let ids: Vec<String> = (0..host.cache.count())
.filter_map(|i| host.cache.at(i).map(|p| p.identifier.clone()))
.collect();
if ids.is_empty() {
// 本机 bundle 若为 Natron 分支构建(describe 返回
// MissingHostFeature),扫描必然为空——README 已记录该
// 不兼容;兼容 bundle 的机器上此处照常断言。
common::skip("/Library/OFX/Plugins/Misc.ofx.bundle 为 Natron 分支构建(describe 返回 MissingHostFeature)或空 bundle");
return;
}
println!("Misc.ofx.bundle 扫描到 {} 个插件:{ids:?}", ids.len());
// create_instance 冒烟(首个插件的首选上下文)。
let first = host.cache.at(0).expect("首个插件").identifier.clone();
let inst = host.create_instance(&first, None);
assert!(inst.is_ok(), "create_instance({first}) 应成功");
// 协商冒烟(输出 clip 的分量/位深/帧率应返回)。
let inst = inst.unwrap();
let prefs = inst.value.get_clip_preferences();
assert!(
prefs.is_ok(),
"getClipPreferences({first}) 应成功: {prefs:?}"
);
});
}
+21 -41
View File
@@ -17,39 +17,31 @@
//! ofxColour(M11 §4):宿主 OCIO 能力宣告、实例协商属性、输入 clip
//! 工作空间(ACEScg)、GetOutputColourspace 往返(偏好采纳 +
//! 交叉引用解析 + 输出写回)。
//!
//! 单库化后经 Rust API 直连(`Host::global().create_instance`),不再
//! 经已删除的 `oakplugin_host_scan`/`oakplugin_instance_create` C ABI。
mod common;
use std::ffi::{c_void, CString};
use std::sync::Arc;
use oakplugin::ffi::{oakplugin_host_scan, oakplugin_instance_create, oakplugin_instance_free};
use oakplugin::handle::{get, CHandle};
use oakplugin::handle::RefBox;
use oakplugin::host::Host;
use oakplugin::instance::Instance;
use oakplugin::property::Value;
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
fn scan_and_create(id: &str) -> CHandle {
/// 扫描并创建实例;不可用返回 None(skip)。
fn scan_and_create(id: &str) -> Option<Arc<RefBox<Instance>>> {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return CHandle::null();
return None;
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
let id = cs(id);
unsafe { oakplugin_instance_create(id.as_ptr()) }
}
fn instance_of(
h: &CHandle,
) -> Option<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>> {
unsafe { get::<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>>(h) }
.cloned()
let dir = common::test_plugin_scan_dir().unwrap();
let host = Host::global();
host.cache.scan_path(&dir).ok()?;
host.create_instance(id, None).ok()
}
fn prop_str(props: &oakplugin::property::PropertySet, name: &str) -> Option<String> {
@@ -84,11 +76,9 @@ fn host_declares_ocio_capability() {
#[test]
fn instance_and_clip_colourspace_props() {
common::with_host(|| {
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
let Some(inst) = scan_and_create(TEST_PLUGIN_ID) else {
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
assert_eq!(
prop_str(&inst.value.props, "OfxImageEffectPropColourManagementStyle").as_deref(),
Some("OfxImageEffectColourManagementOCIO")
@@ -128,7 +118,6 @@ fn instance_and_clip_colourspace_props() {
.unwrap_or(true),
"输出 clip 色彩空间在 GetOutputColourspace 前未设"
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
@@ -137,11 +126,9 @@ fn instance_and_clip_colourspace_props() {
#[test]
fn get_output_colourspace_cross_reference() {
common::with_host(|| {
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
let Some(inst) = scan_and_create(TEST_PLUGIN_ID) else {
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let cs = inst
.value
.get_output_colourspace(&[])
@@ -159,7 +146,6 @@ fn get_output_colourspace_cross_reference() {
Some("ACEScg"),
"输出 clip 已写回解析后的色彩空间"
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
@@ -168,17 +154,14 @@ fn get_output_colourspace_cross_reference() {
#[test]
fn get_output_colourspace_preferred() {
common::with_host(|| {
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
let Some(inst) = scan_and_create(TEST_PLUGIN_ID) else {
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let cs = inst
.value
.get_output_colourspace(&["ACEScg".to_string(), "linear".to_string()])
.expect("GetOutputColourspace 应成功");
assert_eq!(cs, "ACEScg", "偏好列表第一个被采纳");
unsafe { oakplugin_instance_free(&mut h) };
});
}
@@ -186,11 +169,9 @@ fn get_output_colourspace_preferred() {
#[test]
fn resolve_colourspace_edge_cases() {
common::with_host(|| {
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
let Some(inst) = scan_and_create(TEST_PLUGIN_ID) else {
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
assert_eq!(
inst.value
.resolve_colourspace("OfxColourspace_NoSuchClip".to_string()),
@@ -202,6 +183,5 @@ fn resolve_colourspace_edge_cases() {
"ACEScg",
"非交叉引用原样返回"
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
+2 -3
View File
@@ -20,9 +20,8 @@
//! 运行时装配成 oak-test-plugin.ofx.bundle):filter 上下文、
//! Double 参数 gain、双 clip(Source/Output)。插件未构建时相关
//! 用例经 [`skip`] 提前返回。
//! 测试桩在库内(bridge::render::stub,`--features test-stubs`):
//! 全链路像素路径可在 cargo test 跑通;真实链路在 standalone
//! ctest。本文档的 run 命令见 crate README。
//! 单库化后像素路径经 oakrender 值模型(`oakrender::texture::Texture`)
//! 驱动;渲染 goldens 待该迁移落地。
use std::path::PathBuf;
-231
View File
@@ -1,231 +0,0 @@
// 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/>.
//! FFI 出口层(ffi.rs)契约测试:host.h 面。
//!
//! 每个导出函数至少一正常一错误路径(项目铁律)。
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
//! 需要最小测试插件(build.rs 编,common::test_plugin_dir 装配);
//! 不可用时 skip。
mod common;
use std::ffi::{c_char, c_int, CStr, CString};
use oakplugin::ffi::{
oakplugin_host_init, oakplugin_host_plugin_count, oakplugin_host_plugin_id_at,
oakplugin_host_plugin_label, oakplugin_host_scan, oakplugin_host_set_message_handler,
oakplugin_host_shutdown,
};
const E_INVALID: i32 = -90001;
const E_NOT_FOUND: i32 = -90004;
const OK: i32 = 0;
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
/// 扫描测试插件;失败/缺失返回 false(调用方 skip)。
fn scan_test_plugin() -> bool {
let Some(dir) = common::test_plugin_scan_dir() else {
common::skip("最小测试插件未构建");
return false;
};
let dir = cs(dir.to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) == OK }
}
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
/// init/shutdown 幂等:重复 init 无副作用;shutdown 后再 init 可用。
#[test]
fn host_init_shutdown_idempotent() {
common::with_host(|| {
assert_eq!(unsafe { oakplugin_host_init() }, OK);
assert_eq!(unsafe { oakplugin_host_init() }, OK);
unsafe { oakplugin_host_shutdown() };
assert_eq!(unsafe { oakplugin_host_init() }, OK);
unsafe { oakplugin_host_shutdown() };
});
}
/// scan 默认路径(NULL/0):返回 OAKPLUGIN_OK 或 E_FAILED(无插件
/// 目录的机器),二者之外不允许别的码。
#[test]
fn host_scan_default_paths() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let r = unsafe { oakplugin_host_scan(std::ptr::null(), 0) };
assert!(r == OK || r == -90003, "scan(NULL,0) = {r}");
unsafe { oakplugin_host_shutdown() };
});
}
/// scan 指定目录(测试插件所在):插件计数 ≥1 且能找到测试插件
/// 标识;目录不存在返回 E_FAILED 而不崩。
#[test]
fn host_scan_explicit_dir() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
assert!(scan_test_plugin(), "测试插件扫描失败");
assert!(unsafe { oakplugin_host_plugin_count() } >= 1);
// 标识可见(两段式第一段)。
let len = unsafe { oakplugin_host_plugin_id_at(0, std::ptr::null_mut(), 0) };
assert!(len > 0);
let mut buf = vec![0u8; len as usize];
let r = unsafe { oakplugin_host_plugin_id_at(0, buf.as_mut_ptr() as *mut c_char, len) };
assert_eq!(r, OK);
assert_eq!(
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }
.to_str()
.unwrap(),
TEST_PLUGIN_ID
);
// 目录不存在:静默跳过返回 OK 而不崩(C++ olivehost.cpp:59-62
// 的 add_plugin_path 语义——声明原期望 E_FAILED,与参照系
// 不符,以 C++ 行为为准)。
let nope = cs("/nonexistent/ofx/plugins");
let dirs = [nope.as_ptr()];
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
unsafe { oakplugin_host_shutdown() };
});
}
/// plugin_id_at:两段式(先问长度再取内容);越界索引返回
/// E_NOT_FOUND;缓冲区不足时返回所需长度且不越界写。
#[test]
fn host_plugin_id_at_two_stage() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
if !scan_test_plugin() {
unsafe { oakplugin_host_shutdown() };
return;
}
// 越界 → E_NOT_FOUND。
assert_eq!(
unsafe { oakplugin_host_plugin_id_at(99, std::ptr::null_mut(), 0) },
E_NOT_FOUND
);
// 缓冲区不足:返回所需长度(> 0),不越界写(哨兵在 [4]——
// [3] 是 4 字节缓冲内的 NUL 位)。
let mut small = [0x7fu8; 5];
let r = unsafe { oakplugin_host_plugin_id_at(0, small.as_mut_ptr() as *mut c_char, 4) };
assert!(r > 4);
assert_eq!(small[4], 0x7f, "缓冲区外不应被写");
unsafe { oakplugin_host_shutdown() };
});
}
/// plugin_label:已知插件返回非空 label;未知标识 E_NOT_FOUND;
/// NULL 参数 E_INVALID。
#[test]
fn host_plugin_label_paths() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
if !scan_test_plugin() {
unsafe { oakplugin_host_shutdown() };
return;
}
let id = cs(TEST_PLUGIN_ID);
let len = unsafe { oakplugin_host_plugin_label(id.as_ptr(), std::ptr::null_mut(), 0) };
assert!(len > 0);
let mut buf = vec![0u8; len as usize];
assert_eq!(
unsafe {
oakplugin_host_plugin_label(id.as_ptr(), buf.as_mut_ptr() as *mut c_char, len)
},
OK
);
assert!(!unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }
.to_bytes()
.is_empty());
// 未知标识。
let nope = cs("org.oak.nope");
assert_eq!(
unsafe { oakplugin_host_plugin_label(nope.as_ptr(), std::ptr::null_mut(), 0) },
E_NOT_FOUND
);
// NULL 参数。
assert_eq!(
unsafe { oakplugin_host_plugin_label(std::ptr::null(), std::ptr::null_mut(), 0) },
E_INVALID
);
unsafe { oakplugin_host_shutdown() };
});
}
/// message handler:注册后测试插件的 message 回调到达(级别与
/// 文本透传,含 %d 格式化);注册 NULL 后消息被丢弃而不崩。
#[test]
fn host_message_handler_dispatch() {
common::with_host(|| {
use oakplugin::ffi::{oakplugin_instance_create, oakplugin_instance_free};
unsafe extern "C" fn capture(
type_: *const c_char,
message: *const c_char,
userdata: *mut std::ffi::c_void,
) -> c_int {
let v = unsafe { &mut *(userdata as *mut Vec<(String, String)>) };
v.push((
unsafe { CStr::from_ptr(type_) }
.to_string_lossy()
.into_owned(),
unsafe { CStr::from_ptr(message) }
.to_string_lossy()
.into_owned(),
));
0
}
unsafe { oakplugin_host_init() };
if !scan_test_plugin() {
unsafe { oakplugin_host_shutdown() };
return;
}
// 注册捕获器 → 创建实例(插件在 createInstance 发 message)。
let mut captured: Vec<(String, String)> = Vec::new();
unsafe {
oakplugin_host_set_message_handler(
Some(capture),
&mut captured as *mut _ as *mut std::ffi::c_void,
)
};
let id = cs(TEST_PLUGIN_ID);
let mut h = unsafe { oakplugin_instance_create(id.as_ptr()) };
assert!(!h.is_null());
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].0, "OfxMessageMessage");
assert_eq!(captured[0].1, "created id=7", "v1 变长格式化应生效");
// 注销 → 再创建不崩、无捕获。
unsafe { oakplugin_host_set_message_handler(None, std::ptr::null_mut()) };
captured.clear();
let mut h2 = unsafe { oakplugin_instance_create(id.as_ptr()) };
assert!(!h2.is_null());
assert!(captured.is_empty());
unsafe { oakplugin_instance_free(&mut h2) };
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
});
}
-573
View File
@@ -1,573 +0,0 @@
// 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/>.
//! FFI 出口层契约测试:instance.h 面(含 M11 §2.1 内省族)。
//!
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
//! render 路径经 oakrender 测试桩(`--features test-stubs`);
//! 未启用时相关用例 skip。
mod common;
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use oakplugin::ffi::{
node_value_type, oakplugin_debug_alive_count, oakplugin_instance_cancel,
oakplugin_instance_clip_count, oakplugin_instance_clip_info, oakplugin_instance_clip_name,
oakplugin_instance_create, oakplugin_instance_free, oakplugin_instance_get_param,
oakplugin_instance_get_param_string, oakplugin_instance_param_choice_count,
oakplugin_instance_param_choice_label, oakplugin_instance_param_count,
oakplugin_instance_param_default_double, oakplugin_instance_param_default_string,
oakplugin_instance_param_display_range, oakplugin_instance_param_hint,
oakplugin_instance_param_label, oakplugin_instance_param_name, oakplugin_instance_param_parent,
oakplugin_instance_param_secret, oakplugin_instance_param_type, oakplugin_instance_render,
oakplugin_instance_set_param, oakplugin_instance_set_param_string,
oakplugin_instance_set_progress_cb, OakNodeValue,
};
use oakplugin::handle::CHandle;
const E_INVALID: i32 = -90001;
const E_NOT_FOUND: i32 = -90004;
const OK: i32 = 0;
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
/// 扫描测试插件并创建实例;不可用返回空句柄。
fn create_instance() -> CHandle {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return CHandle::null();
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan_import(dirs.as_ptr(), 1) };
let id = cs(TEST_PLUGIN_ID);
unsafe { oakplugin_instance_create(id.as_ptr()) }
}
// 避免重复 import host_scan(ffi 模块内路径统一)。
use oakplugin::ffi::oakplugin_host_scan as oakplugin_host_scan_import;
/// 两段式读字符串。
fn two_stage(
get: unsafe extern "C" fn(CHandle, c_int, *mut c_char, c_int) -> c_int,
h: CHandle,
index: c_int,
) -> Option<String> {
let len = unsafe { get(h, index, std::ptr::null_mut(), 0) };
if len <= 0 {
return None;
}
let mut buf = vec![0u8; len as usize];
let r = unsafe { get(h, index, buf.as_mut_ptr() as *mut c_char, len) };
if r != OK {
return None;
}
Some(
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }
.to_string_lossy()
.into_owned(),
)
}
/// 简易实例句柄(测试桩纹理用)。
fn fake_texture(ctx: usize) -> CHandle {
CHandle {
ctx: ctx as *mut c_void,
addref: None,
release: None,
abi_version: 1,
}
}
/// create/free:已知插件返回非空句柄;free 后 alive 回基线;
/// free(NULL)/free(空) no-op。
#[test]
fn instance_create_free() {
common::with_host(|| {
unsafe { oakplugin_host_scan_import(std::ptr::null(), 0) };
let base = unsafe { oakplugin_debug_alive_count() };
let mut h = create_instance();
assert!(!h.is_null());
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
unsafe { oakplugin_instance_free(&mut h) };
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base);
// free(NULL)/free(空) no-op。
unsafe { oakplugin_instance_free(std::ptr::null_mut()) };
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// create 未知标识:返回空句柄(init 失败返回空,契约 §a)。
#[test]
fn instance_create_unknown_id() {
common::with_host(|| {
let id = cs("org.oak.does-not-exist");
let h = unsafe { oakplugin_instance_create(id.as_ptr()) };
assert!(h.is_null());
});
}
/// set/get_param(double):round-trip 一致;未知参数名
/// E_NOT_FOUND;空句柄 E_INVALID;out 为 NULL 时 E_INVALID。
#[test]
fn instance_param_double_roundtrip() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let name = cs("gain");
let mut v = OakNodeValue::default();
v.r#type = node_value_type::FLOAT;
v.f = [1.25, 0.0, 0.0, 0.0];
assert_eq!(
unsafe { oakplugin_instance_set_param(h, name.as_ptr(), &v) },
OK
);
let mut out = OakNodeValue::default();
assert_eq!(
unsafe { oakplugin_instance_get_param(h, name.as_ptr(), &mut out) },
OK
);
assert_eq!(out.r#type, node_value_type::FLOAT);
assert_eq!(out.f[0], 1.25);
// 未知参数名 → E_NOT_FOUND。
let nope = cs("nope");
assert_eq!(
unsafe { oakplugin_instance_set_param(h, nope.as_ptr(), &v) },
E_NOT_FOUND
);
// 空句柄 → E_INVALID。
assert_eq!(
unsafe { oakplugin_instance_set_param(CHandle::null(), name.as_ptr(), &v) },
E_INVALID
);
// out NULL → E_INVALID。
assert_eq!(
unsafe { oakplugin_instance_get_param(h, name.as_ptr(), std::ptr::null_mut()) },
E_INVALID
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// set/get_param_string:round-trip 一致(含空串与长串两段式)。
#[test]
fn instance_param_string_roundtrip() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let name = cs("label");
let value = cs("hello 你好");
assert_eq!(
unsafe { oakplugin_instance_set_param_string(h, name.as_ptr(), value.as_ptr()) },
OK
);
// 两段式。
let len = unsafe {
oakplugin_instance_get_param_string(h, name.as_ptr(), std::ptr::null_mut(), 0)
};
assert!(len > 0);
let mut buf = vec![0u8; len as usize];
assert_eq!(
unsafe {
oakplugin_instance_get_param_string(
h,
name.as_ptr(),
buf.as_mut_ptr() as *mut c_char,
len,
)
},
OK
);
assert_eq!(
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_string_lossy(),
"hello 你好"
);
// 空串。
let empty = cs("");
assert_eq!(
unsafe { oakplugin_instance_set_param_string(h, name.as_ptr(), empty.as_ptr()) },
OK
);
assert_eq!(
unsafe {
oakplugin_instance_get_param_string(h, name.as_ptr(), std::ptr::null_mut(), 0)
},
1
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// render:对测试插件渲一帧,输出纹理像素符合插件的常量填充
/// (0.5 RGBA F32,alpha=1)。time 为 NaN 时 E_INVALID。
#[cfg(feature = "test-stubs")]
#[test]
fn instance_render_one_frame() {
common::with_host(|| {
use oakplugin::bridge::render::stub;
let mut h = create_instance();
if h.is_null() {
return;
}
// 输出帧 320×240 F32。
stub::setup_dst(320, 240, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let dst = fake_texture(0xA1);
let src = CHandle::null(); // 无输入(插件只写常量)
assert_eq!(unsafe { oakplugin_instance_render(h, dst, src, 0.0) }, OK);
// 断言:常量 0.5,alpha 1.0(每像素 4 通道 × 4 字节 F32)。
let pixels = stub::dst_pixels();
let n = pixels.len() / 16;
assert_eq!(n, 320 * 240, "像素数 = len/16 = {n}");
for i in 0..n {
// 像素基址 = i * 16(4 通道 × 4 字节)。
let f = |o: usize| {
f32::from_le_bytes(
pixels[i * 16 + o * 4..i * 16 + o * 4 + 4]
.try_into()
.unwrap(),
)
};
assert_eq!(f(0), 0.5, "pixel {i} r");
assert_eq!(f(1), 0.5, "pixel {i} g");
assert_eq!(f(2), 0.5, "pixel {i} b");
assert_eq!(f(3), 1.0, "pixel {i} a");
}
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// render 默认构建(真实 oakrender 链接):NULL 输出纹理在触达
/// oakrender 之前被插件层校验拒绝(E_INVALID),不崩溃。真实纹理
/// 路径需要 GL/GPU,属集成测试范畴。
#[cfg(not(feature = "test-stubs"))]
#[test]
fn instance_render_one_frame() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let dst = CHandle::null();
assert_eq!(
unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) },
-90001
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// progress 回调:render 期间被调用且进度单调不减;回调返回非 0
/// 时 render 以取消码结束(插件检查 progressUpdate 结果并中止)。
#[cfg(feature = "test-stubs")]
#[test]
fn instance_progress_callback() {
common::with_host(|| {
use oakplugin::bridge::render::stub;
let mut h = create_instance();
if h.is_null() {
return;
}
let mut seen: Vec<f64> = Vec::new();
unsafe extern "C" fn capture(p: f64, userdata: *mut c_void) -> c_int {
let v = unsafe { &mut *(userdata as *mut Vec<f64>) };
v.push(p);
0
}
unsafe {
oakplugin_instance_set_progress_cb(h, Some(capture), &mut seen as *mut _ as *mut c_void)
};
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let dst = fake_texture(0xA1);
assert_eq!(
unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) },
OK
);
assert_eq!(seen, vec![0.5], "插件在 render 内报一次 0.5");
// 取消回调(非 0 = 中止)。
unsafe extern "C" fn abort_cb(_p: f64, _u: *mut c_void) -> c_int {
1
}
unsafe { oakplugin_instance_set_progress_cb(h, Some(abort_cb), std::ptr::null_mut()) };
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) };
assert_eq!(r, -90003, "取消应使 render 失败");
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// cancel:无活动渲染时 no-op;置位后 render 立即以取消码返回。
#[test]
fn instance_cancel_semantics() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
// 无活动渲染:OK no-op。
assert_eq!(unsafe { oakplugin_instance_cancel(h) }, OK);
// 置位后 render 入口即取消(先备好合法输出帧,确保走到
// render 的取消检查而不是帧校验)。
assert_eq!(unsafe { oakplugin_instance_cancel(h) }, OK);
#[cfg(feature = "test-stubs")]
oakplugin::bridge::render::stub::setup_dst(
64,
64,
oakplugin::bridge::render::PIXEL_FORMAT_F32,
);
let dst = fake_texture(0xA1);
assert_eq!(
unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) },
-90003
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 内省:param_count > 0;name/type/label/hint/parent 与插件定义
/// 一致;index 越界 E_NOT_FOUND。
#[test]
fn introspection_param_fields() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let count = unsafe { oakplugin_instance_param_count(h) };
assert!(count >= 4, "gain/mode/debug/label,count={count}");
// gain 的字段。
assert_eq!(
two_stage(oakplugin_instance_param_name, h, 0),
Some("gain".into())
);
assert_eq!(
two_stage(oakplugin_instance_param_type, h, 0),
Some("OfxParamTypeDouble".into())
);
assert_eq!(
two_stage(oakplugin_instance_param_label, h, 0),
Some("Gain".into())
);
assert_eq!(
two_stage(oakplugin_instance_param_hint, h, 0),
Some(String::new())
);
assert_eq!(
two_stage(oakplugin_instance_param_parent, h, 0),
Some(String::new())
);
// 越界 → E_NOT_FOUND。
assert_eq!(
unsafe { oakplugin_instance_param_name(h, count, std::ptr::null_mut(), 0) },
E_NOT_FOUND
);
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 内省:secret/display_range/choice 三族在测试插件的对应参数上
/// 逐字段断言。
#[test]
fn introspection_secret_range_choice() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let count = unsafe { oakplugin_instance_param_count(h) };
// 按名定位索引。
let mut gain = -1;
let mut mode = -1;
let mut debug = -1;
for i in 0..count {
match two_stage(oakplugin_instance_param_name, h, i).as_deref() {
Some("gain") => gain = i,
Some("mode") => mode = i,
Some("debug") => debug = i,
_ => {}
}
}
assert!(gain >= 0 && mode >= 0 && debug >= 0);
// display range(gain:-2..2)。
let (mut min, mut max) = (0.0, 0.0);
assert_eq!(
unsafe { oakplugin_instance_param_display_range(h, gain, &mut min, &mut max) },
OK
);
assert_eq!((min, max), (-2.0, 2.0));
// secret(debug = 1;gain = 0)。
let mut s = 0;
assert_eq!(
unsafe { oakplugin_instance_param_secret(h, debug, &mut s) },
OK
);
assert_eq!(s, 1);
assert_eq!(
unsafe { oakplugin_instance_param_secret(h, gain, &mut s) },
OK
);
assert_eq!(s, 0);
// choice(mode:2 个选项;选项内容见 introspection_choice_labels)。
assert_eq!(unsafe { oakplugin_instance_param_choice_count(h, mode) }, 2);
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// choice label/value 的带选项两段式。
#[test]
fn introspection_choice_labels() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let count = unsafe { oakplugin_instance_param_count(h) };
let mut mode = -1;
for i in 0..count {
if two_stage(oakplugin_instance_param_name, h, i).as_deref() == Some("mode") {
mode = i;
}
}
assert!(mode >= 0);
assert_eq!(unsafe { oakplugin_instance_param_choice_count(h, mode) }, 2);
for c in 0..2 {
let len = unsafe {
oakplugin_instance_param_choice_label(h, mode, c, std::ptr::null_mut(), 0)
};
assert!(len > 0);
let mut buf = vec![0u8; len as usize];
assert_eq!(
unsafe {
oakplugin_instance_param_choice_label(
h,
mode,
c,
buf.as_mut_ptr() as *mut c_char,
len,
)
},
OK
);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }
.to_string_lossy()
.into_owned();
assert!(s == "Fast" || s == "High", "选项 {c} = {s}");
}
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 内省:默认值(double 族与字符串)。
#[test]
fn introspection_defaults() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
let count = unsafe { oakplugin_instance_param_count(h) };
let mut gain = -1;
let mut label = -1;
for i in 0..count {
match two_stage(oakplugin_instance_param_name, h, i).as_deref() {
Some("gain") => gain = i,
Some("label") => label = i,
_ => {}
}
}
let mut d = 99.0;
assert_eq!(
unsafe { oakplugin_instance_param_default_double(h, gain, 0, &mut d) },
OK
);
assert_eq!(d, 0.0);
let s = two_stage(oakplugin_instance_param_default_string, h, label);
assert_eq!(s, Some(String::new()));
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 内省:clip_count/name/info(含 Output clip;optional 标记)。
#[test]
fn introspection_clips() {
common::with_host(|| {
let mut h = create_instance();
if h.is_null() {
return;
}
assert_eq!(unsafe { oakplugin_instance_clip_count(h) }, 2);
let names: Vec<String> = (0..2)
.map(|i| two_stage(oakplugin_instance_clip_name, h, i).unwrap_or_default())
.collect();
assert!(names.contains(&"Source".into()), "{names:?}");
assert!(names.contains(&"Output".into()), "{names:?}");
for i in 0..2 {
let mut optional = -1;
let len = unsafe {
oakplugin_instance_clip_info(h, i, &mut optional, std::ptr::null_mut(), 0)
};
assert!(len > 0);
let mut buf = vec![0u8; len as usize];
assert_eq!(
unsafe {
oakplugin_instance_clip_info(
h,
i,
&mut optional,
buf.as_mut_ptr() as *mut c_char,
len,
)
},
OK
);
assert!(optional == 0, "测试插件 clip 非 optional");
}
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// debug_alive_count:create 增、free 减;全部释放后回零基线。
#[test]
fn alive_count_accounting() {
common::with_host(|| {
unsafe { oakplugin_host_scan_import(std::ptr::null(), 0) };
let base = unsafe { oakplugin_debug_alive_count() };
let mut h = create_instance();
if h.is_null() {
return;
}
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
unsafe { oakplugin_instance_free(&mut h) };
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base);
});
}
-456
View File
@@ -1,456 +0,0 @@
// 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/>.
//! OpenGL 渲染路径测试(M11 §4)。
//!
//! GL 测试策略:
//! - `--features test-stubs`:库内桩 GL 渲染器(renderer_is_open_gl /
//! texture_create / texture_id 等)模拟 GPU——suite 往返、GL render
//! 路径、错误路径全链路可跑(无需真实 GPU/liboakrender);
//! - 默认模式(无 liboakrender 符号):GL 决策回退 CPU、GL suite 在
//! 无上下文时返回 kOfxStatErrMissingHostFeature——用例断言该优雅
//! 降级("无 GPU 优雅跳过")。
//! - 真实 GPU golden(EXR 容差比对)为 M11 0 期基建 + 本机人工确认
//! 项:`OAK_GPU_TESTS` 环境变量门,见 common::gpu_available。
mod common;
use std::ffi::{c_char, c_void, CString};
use oakplugin::ffi::{
oakplugin_host_scan, oakplugin_host_set_message_handler, oakplugin_instance_create,
oakplugin_instance_free, oakplugin_instance_render_begin_sequence,
oakplugin_instance_render_end_sequence, oakplugin_instance_render_job,
};
use oakplugin::handle::CHandle;
use oakplugin::suites::gl_render::GlRenderSuiteV1;
use oakplugin::suites::{fetch_suite, status, tag};
const OK: i32 = 0;
const GL_PLUGIN_ID: &str = "org.oak.test-plugin.gl";
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
/// 简易句柄(桩纹理/渲染器)。
fn fake_handle(ctx: usize) -> CHandle {
CHandle {
ctx: ctx as *mut c_void,
addref: None,
release: None,
abi_version: 1,
}
}
/// 扫描测试插件并创建 GL 变体实例;不可用返回空句柄。
fn create_gl_instance() -> CHandle {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return CHandle::null();
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
let id = cs(GL_PLUGIN_ID);
unsafe { oakplugin_instance_create(id.as_ptr()) }
}
/// GL suite 表(fetchSuite 分发表)。
fn gl_suite() -> Option<&'static GlRenderSuiteV1> {
unsafe { fetch_suite("OfxImageEffectOpenGLRenderSuite", 1) }
.map(|p| unsafe { &*(p as *const GlRenderSuiteV1) })
}
/// 构造一个带输入纹理的 Source clip(实例期 clip 句柄)。
fn source_clip_handle(
tex: CHandle,
) -> (*mut c_void, std::sync::Arc<oakplugin::clip::ClipInstance>) {
let desc = oakplugin::descriptor::ClipDescriptor::new("Source");
let clip = std::sync::Arc::new(oakplugin::clip::ClipInstance::from_descriptor(&desc));
clip.set_input_texture(tex, 0.0);
let h = tag::make(
&clip.props as *const oakplugin::property::PropertySet,
tag::CLIP,
);
(h, clip)
}
/// 当前渲染/GL 上下文注入(suite 的 TLS 依赖)。
fn inject_gl_ctx(renderer: CHandle, output: CHandle) {
oakplugin::suites::set_render_ctx(Some(oakplugin::suites::RenderCtx {
time: 0.0,
scale: oakplugin::instance::RenderScale { x: 1.0, y: 1.0 },
range: oakplugin::instance::OfxRangeD { min: 0.0, max: 1.0 },
}));
oakplugin::suites::set_gl_ctx(Some(oakplugin::suites::GlCtx {
renderer,
output_texture: output,
gl_pixel_depth: "OfxBitDepthFloat",
}));
}
/// 读纹理句柄(属性集)的 int 属性。
fn tex_int(props: &oakplugin::property::PropertySet, name: &str) -> Option<i32> {
match props.get(name, 0)? {
oakplugin::property::Value::Int(i) => Some(i),
_ => None,
}
}
fn tex_string(props: &oakplugin::property::PropertySet, name: &str) -> Option<String> {
match props.get(name, 0)? {
oakplugin::property::Value::String(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
}
}
/// 清理 TLS(每个用例结尾)。
fn clear_ctxs() {
oakplugin::suites::set_gl_ctx(None);
oakplugin::suites::set_render_ctx(None);
}
/// clipLoadTexture 往返:输入 clip 建纹理、属性齐全、clipFreeTexture
/// 释放;Output clip 返回附着输出纹理;flushResources → ReplyDefault。
#[cfg(feature = "test-stubs")]
#[test]
fn clip_load_texture_roundtrip() {
use oakplugin::bridge::render::stub;
common::with_host(|| {
stub::reset();
stub::set_gl_available(true);
let renderer = stub::make_gl_renderer();
let dst = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let src = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
inject_gl_ctx(renderer, dst);
let suite = gl_suite().expect("GL suite 已注册");
// 输入 clip。
let (clip_h, _clip) = source_clip_handle(src);
let before = stub::gl_texture_ids();
let mut tex: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(suite.clip_load_texture)(
clip_h,
0.0,
std::ptr::null(),
std::ptr::null(),
&mut tex
),
0
);
}
assert!(!tex.is_null(), "应返回纹理属性集句柄");
let props = unsafe { &*(tag::strip(tex) as *const oakplugin::property::PropertySet) };
let idx = tex_int(props, "OfxImageEffectPropOpenGLTextureIndex").expect("texture index");
assert!(idx > 0, "新建纹理 id 为正:{idx}");
assert_eq!(
tex_int(props, "OfxImageEffectPropOpenGLTextureTarget"),
Some(0x0DE1),
"GL_TEXTURE_2D"
);
assert_eq!(
tex_string(props, "OfxImageEffectPropPixelDepth").as_deref(),
Some("OfxBitDepthFloat")
);
assert_eq!(
tex_string(props, "OfxImageEffectPropComponents").as_deref(),
Some("OfxImageComponentRGBA")
);
assert!(props.get("OfxImagePropBounds", 0).is_some(), "Bounds");
assert!(props.get("OfxImagePropRowBytes", 0).is_some(), "RowBytes");
assert!(
props.get("OfxImagePropUniqueIdentifier", 0).is_some(),
"UniqueIdentifier"
);
// 注册表新增一个纹理。
let after = stub::gl_texture_ids();
assert_eq!(after.len(), before.len() + 1, "clipLoadTexture 建新纹理");
assert!(after.contains(&idx), "新纹理 id 在注册表");
// clipFreeTexture:删输入纹理。
unsafe {
assert_eq!((suite.clip_free_texture)(tex), 0);
}
assert!(!stub::gl_texture_ids().contains(&idx), "输入纹理已删除");
// 重复释放 → BadHandle。
unsafe {
assert_eq!((suite.clip_free_texture)(tex), status::ERR_BAD_HANDLE);
}
// Output clip:返回附着输出纹理;free 不删纹理(宿主读)。
let mut out: *mut c_void = std::ptr::null_mut();
let out_clip_instance = oakplugin::clip::ClipInstance::from_descriptor(
&oakplugin::descriptor::ClipDescriptor::new("Output"),
);
let out_clip = tag::make(
&out_clip_instance.props as *const oakplugin::property::PropertySet,
tag::CLIP,
);
unsafe {
assert_eq!(
(suite.clip_load_texture)(
out_clip,
0.0,
std::ptr::null(),
std::ptr::null(),
&mut out
),
0
);
}
assert!(!out.is_null());
let props = unsafe { &*(tag::strip(out) as *const oakplugin::property::PropertySet) };
let dst_id = tex_int(props, "OfxImageEffectPropOpenGLTextureIndex").unwrap();
assert!(
stub::gl_texture_ids().contains(&dst_id),
"输出纹理 id 来自附着目标"
);
unsafe {
assert_eq!((suite.clip_free_texture)(out), 0);
}
assert!(
stub::gl_texture_ids().contains(&dst_id),
"Output free 不删纹理"
);
// flushResources:无宿主缓存 → ReplyDefault。
unsafe {
assert_eq!((suite.flush_resources)(), status::REPLY_DEFAULT);
}
clear_ctxs();
});
}
/// 错误路径:无 GL 上下文 → MissingHostFeature;空 out/BadHandle;
/// 子区域 → Failed;请求分量不匹配 → Failed。
#[cfg(feature = "test-stubs")]
#[test]
fn clip_load_texture_error_paths() {
use oakplugin::bridge::render::stub;
common::with_host(|| {
stub::reset();
stub::set_gl_available(true);
let renderer = stub::make_gl_renderer();
let dst = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let src = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let suite = gl_suite().expect("GL suite 已注册");
// 无 GL 上下文(非 GL 渲染期)→ MissingHostFeature。
let (clip_h, _clip) = source_clip_handle(src);
let mut tex: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(suite.clip_load_texture)(
clip_h,
0.0,
std::ptr::null(),
std::ptr::null(),
&mut tex
),
status::ERR_MISSING_HOST_FEATURE
);
// 空 out → BadHandle;空 clip → BadHandle。
assert_eq!(
(suite.clip_load_texture)(
clip_h,
0.0,
std::ptr::null(),
std::ptr::null(),
std::ptr::null_mut()
),
status::ERR_BAD_HANDLE
);
assert_eq!(
(suite.clip_load_texture)(
std::ptr::null_mut(),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut tex
),
status::ERR_BAD_HANDLE
);
// clipFreeTexture 空句柄 → BadHandle。
assert_eq!(
(suite.clip_free_texture)(std::ptr::null_mut()),
status::ERR_BAD_HANDLE
);
}
// 有 GL 上下文后:子区域(Phase 2 不支持)→ Failed。
inject_gl_ctx(renderer, dst);
let region = oakplugin::instance::OfxRectD {
x1: 0.0,
y1: 0.0,
x2: 2.0,
y2: 2.0,
};
unsafe {
assert_eq!(
(suite.clip_load_texture)(
clip_h,
0.0,
std::ptr::null(),
&region as *const _ as *const c_void,
&mut tex
),
status::FAILED
);
}
// 请求分量不匹配(RGBA 输入 + GLFormatRGB)→ Failed。
let fmt = cs("OfxImageEffectGLFormatRGB");
unsafe {
assert_eq!(
(suite.clip_load_texture)(clip_h, 0.0, fmt.as_ptr(), std::ptr::null(), &mut tex),
status::FAILED
);
}
// 匹配请求(GLFormatRGBA)→ OK。
let fmt = cs("OfxImageEffectGLFormatRGBA");
unsafe {
assert_eq!(
(suite.clip_load_texture)(clip_h, 0.0, fmt.as_ptr(), std::ptr::null(), &mut tex),
0
);
assert_eq!((suite.clip_free_texture)(tex), 0);
}
clear_ctxs();
});
}
/// 无桩模式(默认构建):GL suite 在无上下文时返回 MissingHostFeature
/// (优雅降级,不崩);render_job 带渲染器句柄时回退 CPU 路径。
#[cfg(not(feature = "test-stubs"))]
#[test]
fn gl_path_graceful_without_gpu() {
common::with_host(|| {
let suite = gl_suite().expect("GL suite 已注册");
let desc = oakplugin::descriptor::ClipDescriptor::new("Source");
let clip = oakplugin::clip::ClipInstance::from_descriptor(&desc);
let clip_h = tag::make(&clip.props as *const _, tag::CLIP);
let mut tex: *mut c_void = std::ptr::null_mut();
unsafe {
// 无 GL 上下文 → MissingHostFeature(规范语义)。
assert_eq!(
(suite.clip_load_texture)(
clip_h,
0.0,
std::ptr::null(),
std::ptr::null(),
&mut tex
),
status::ERR_MISSING_HOST_FEATURE
);
// flushResources → ReplyDefault。
assert_eq!((suite.flush_resources)(), status::REPLY_DEFAULT);
}
});
}
/// GL render 路径端到端(test-stubs):render_job 带 GL 渲染器 →
/// 插件收到 OpenGLEnabled=1、attach/detach 配对、clipLoadTexture
/// 取 Source/Output 纹理(插件经 message suite 上报索引)。
#[cfg(feature = "test-stubs")]
#[test]
fn gl_render_path_drives_plugin() {
use oakplugin::bridge::render::stub;
common::with_host(|| {
stub::reset();
let mut h = create_gl_instance();
if h.is_null() {
return;
}
// 消息捕获(插件 GL 行为的上报通道)。
let captured: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
{
let cap = captured.clone();
unsafe extern "C" fn capture(
_type: *const c_char,
message: *const c_char,
userdata: *mut c_void,
) -> std::ffi::c_int {
let v = unsafe { &mut *(userdata as *mut std::sync::Mutex<Vec<String>>) };
if !message.is_null() {
let m = unsafe { std::ffi::CStr::from_ptr(message) }
.to_string_lossy()
.into_owned();
v.lock().unwrap_or_else(|e| e.into_inner()).push(m);
}
0
}
unsafe {
oakplugin_host_set_message_handler(Some(capture), &*cap as *const _ as *mut c_void)
};
}
// GL 环境:渲染器可用;dst/src 均为 GL 纹理(2×2 F32)。
stub::set_gl_available(true);
let renderer = stub::make_gl_renderer();
let dst = stub::make_gl_texture(2, 2, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let src = stub::make_gl_texture(2, 2, oakplugin::bridge::render::PIXEL_FORMAT_F32);
// begin → job → end(序列括号)。
assert_eq!(
unsafe { oakplugin_instance_render_begin_sequence(h, 0.0, 1.0, 0) },
OK
);
let r = unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
cs("Source").as_ptr(),
src,
std::ptr::null(),
0,
std::ptr::null(),
0,
renderer,
)
};
assert_eq!(r, OK, "GL render_job 应成功");
assert_eq!(
unsafe { oakplugin_instance_render_end_sequence(h, 0.0, 1.0, 0) },
OK
);
// 插件上报:attach/detach 配对、Source 纹理索引、Output 纹理索引。
let msgs = captured.lock().unwrap_or_else(|e| e.into_inner()).clone();
let joined = msgs.join("|");
assert!(joined.contains("gl-attached"), "attach 应调用:{joined}");
assert!(joined.contains("gl-detached"), "detach 应调用:{joined}");
assert!(
joined.contains("gl-source-index="),
"Source 纹理索引:{joined}"
);
assert!(
joined.contains("gl-output-index="),
"Output 纹理索引:{joined}"
);
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_set_message_handler(None, std::ptr::null_mut()) };
});
}
+3 -4
View File
@@ -68,10 +68,9 @@ fn negotiated_clip_preferences_match() {
/// 的 `Property::Set` 句柄语义与本 crate 的打标句柄约定不兼容
/// (插件侧属性写入在进入本宿主属性套件前失败 → describe 返回
/// kOfxStatErrMissingHostFeature)。真实插件的端到端加载/建实例/
/// 协商路径由 [`crate::bridge_test` 的 `system_misc_ofx_bundle_smoke`]
/// (Misc.ofx.bundle,标准 openfx-misc 构建)覆盖。CImg 兼容属
/// M11 §3.5「CImg 全量 describe/协商冒烟」的宿主保真缺口——属性
/// 套件适配插件侧 `Property::Set` 句柄后摘除本注解。
/// 协商路径由 Misc.ofx.bundle(标准 openfx-misc 构建)冒烟覆盖。
/// CImg 兼容属 M11 §3.5「CImg 全量 describe/协商冒烟」的宿主保真
/// 缺口——属性套件适配插件侧 `Property::Set` 句柄后摘除本注解。
#[test]
#[ignore = "本机 CImg.ofx.bundle 为 Natron 分支构建,属性套件句柄语义不兼容(describe 返回 MissingHostFeature);Misc 系统冒烟已覆盖真实插件路径"]
fn cimg_full_describe_smoke() {
-251
View File
@@ -1,251 +0,0 @@
// 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/>.
//! 生命周期测试:实例创建/销毁配对、重复扫描、渲染中取消、
//! alive 泄漏断言。对应 M11 §3.5 验收线。
//!
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
mod common;
use std::ffi::{c_char, c_int, c_void, CString};
use oakplugin::ffi::{
oakplugin_debug_alive_count, oakplugin_host_init, oakplugin_host_plugin_count,
oakplugin_host_scan, oakplugin_host_shutdown, oakplugin_instance_cancel,
oakplugin_instance_create, oakplugin_instance_free, oakplugin_instance_render,
oakplugin_instance_set_progress_cb,
};
use oakplugin::handle::CHandle;
const OK: i32 = 0;
const E_FAILED: i32 = -90003;
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
fn fake_texture(ctx: usize) -> CHandle {
CHandle {
ctx: ctx as *mut c_void,
addref: None,
release: None,
abi_version: 1,
}
}
/// 扫描并创建实例;不可用返回空句柄。
fn create_instance() -> CHandle {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return CHandle::null();
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
let id = cs(TEST_PLUGIN_ID);
unsafe { oakplugin_instance_create(id.as_ptr()) }
}
/// createInstance → destroyInstance 严格配对:alive 计数回到基线。
#[test]
fn instance_create_destroy_pairing() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let base = unsafe { oakplugin_debug_alive_count() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
return;
}
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
unsafe { oakplugin_instance_free(&mut h) };
assert_eq!(
unsafe { oakplugin_debug_alive_count() },
base,
"destroy 后 alive 必须回基线"
);
unsafe { oakplugin_host_shutdown() };
});
}
/// 重复 scan 同一路径:插件列表不重复、顺序稳定(缓存去重语义,
/// HS: ofxhPluginCache.cpp)。
#[test]
fn rescan_is_idempotent() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
unsafe { oakplugin_host_shutdown() };
return;
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
let first = unsafe { oakplugin_host_plugin_count() };
assert!(first >= 1);
// 再扫两次:数量不变。
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
assert_eq!(
unsafe { oakplugin_host_plugin_count() },
first,
"重复扫描不得重复加载"
);
unsafe { oakplugin_host_shutdown() };
});
}
/// render 中途取消:进度回调返回非 0 后,插件的 progressUpdate 收到
/// 取消并中止 render;输出纹理不写半帧(测试插件在取消路径返回
/// Failed,宿主不落帧)。
#[cfg(feature = "test-stubs")]
#[test]
fn render_cancellation_is_atomic() {
common::with_host(|| {
use oakplugin::bridge::render::stub;
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
return;
}
unsafe extern "C" fn abort_cb(_p: f64, _u: *mut c_void) -> c_int {
1
}
unsafe { oakplugin_instance_set_progress_cb(h, Some(abort_cb), std::ptr::null_mut()) };
// 预置输出帧并填 0xAA 哨兵:取消路径不得写半帧。
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let before = stub::dst_pixels();
assert!(!before.is_empty());
let dst = fake_texture(0xA1);
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) };
assert_eq!(r, E_FAILED, "取消应使 render 失败");
// 全有或全无:失败时不写。
assert_eq!(stub::dst_pixels(), before, "取消后输出不得被写");
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
});
}
/// 压测:同一插件 256 次 create/render/destroy 循环后 alive 回到
/// 基线(当年内存问题的回归防线)。
#[cfg(feature = "test-stubs")]
#[test]
fn create_render_destroy_loop_no_leak() {
common::with_host(|| {
use oakplugin::bridge::render::stub;
unsafe { oakplugin_host_init() };
let base = unsafe { oakplugin_debug_alive_count() };
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
unsafe { oakplugin_host_shutdown() };
return;
}
stub::setup_dst(16, 16, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let dst = fake_texture(0xA1);
for i in 0..256 {
let mut h = create_instance();
assert!(!h.is_null());
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), i as f64) };
assert_eq!(r, OK);
unsafe { oakplugin_instance_free(&mut h) };
assert_eq!(
unsafe { oakplugin_debug_alive_count() },
base,
"第 {i} 次循环后泄漏"
);
}
unsafe { oakplugin_host_shutdown() };
});
}
/// host_shutdown 时仍有活实例:宿主发 destroy 通知并卸载 bundle,
/// 之后该实例的任何 render 立即取消(不碰已卸载入口);再 create
/// 返回空句柄(缓存已清空——声明原期望 E_STATE,本实现的 create
/// 契约是"失败返回空句柄",以实例化为准)。
#[test]
fn shutdown_with_live_instances() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
return;
}
// 带活实例 shutdown:不崩;实例被打取消标记。
unsafe { oakplugin_host_shutdown() };
// C 侧句柄仍持 Arc:实例内存存活,但 render 必须失败。
let dst = fake_texture(0xA1);
assert_eq!(
unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) },
E_FAILED
);
unsafe { oakplugin_instance_free(&mut h) };
assert_eq!(
unsafe { oakplugin_debug_alive_count() },
0,
"句柄释放后归零"
);
// 之后再 create:插件已卸载 → 空句柄。
let id = cs(TEST_PLUGIN_ID);
let h2 = unsafe { oakplugin_instance_create(id.as_ptr()) };
assert!(h2.is_null());
});
}
/// 多实例并发:同一插件 16 实例并发 render(宿主侧不串——插件
/// 全局状态由插件自保)。
#[cfg(feature = "test-stubs")]
#[test]
fn concurrent_instances_render() {
common::with_host(|| {
use oakplugin::bridge::render::stub;
unsafe { oakplugin_host_init() };
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
unsafe { oakplugin_host_shutdown() };
return;
}
// 16 个实例(句柄数组;经线程并发 render)。
let mut handles: Vec<CHandle> = (0..16).map(|_| create_instance()).collect();
assert!(handles.iter().all(|h| !h.is_null()));
stub::setup_dst(32, 32, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let dst = fake_texture(0xA1);
let threads: Vec<_> = handles
.iter()
.enumerate()
.map(|(i, h)| {
let h = *h;
std::thread::spawn(move || unsafe {
oakplugin_instance_render(h, dst, CHandle::null(), i as f64)
})
})
.collect();
for t in threads {
assert_eq!(t.join().unwrap(), OK);
}
for h in handles.iter_mut() {
unsafe { oakplugin_instance_free(h) };
}
assert_eq!(unsafe { oakplugin_debug_alive_count() }, 0);
unsafe { oakplugin_host_shutdown() };
});
}
+43 -84
View File
@@ -21,44 +21,31 @@
//! (回写 region)、isIdentity 恒非透传、无 field——需要插件变体
//! 的矩阵案(回退链、身份透传、field)标记 `// TODO(plugin)`,
//! 随 M11 §2.4 测试插件的变体落地。
//!
//! 单库化后经 Rust API 直连(`Host::global().create_instance`),不再
//! 经已删除的 `oakplugin_host_init`/`oakplugin_instance_create` C ABI。
mod common;
use std::ffi::{c_char, c_void, CString};
use std::sync::Arc;
use oakplugin::ffi::{
oakplugin_host_init, oakplugin_host_scan, oakplugin_host_shutdown, oakplugin_instance_create,
oakplugin_instance_free,
};
use oakplugin::handle::{get, CHandle};
const OK: i32 = 0;
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
use oakplugin::handle::RefBox;
use oakplugin::host::Host;
use oakplugin::instance::Instance;
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
/// 扫描并创建实例;不可用返回空句柄。
fn create_instance() -> CHandle {
/// 扫描并创建实例;不可用返回 None(skip)。
fn create_instance() -> Option<Arc<RefBox<Instance>>> {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return CHandle::null();
return None;
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
let id = cs(TEST_PLUGIN_ID);
unsafe { oakplugin_instance_create(id.as_ptr()) }
}
/// 句柄 → Instance 引用。
fn instance_of(
h: &CHandle,
) -> Option<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>> {
unsafe { get::<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>>(h) }
.cloned()
let dir = common::test_plugin_scan_dir().unwrap();
let host = Host::global();
let _ = host.cache.scan();
host.cache.scan_path(&dir).ok()?;
host.create_instance(TEST_PLUGIN_ID, None).ok()
}
/// 协商顺序:测试插件声明 RGBA/F32、帧率 24——协商结果必须
@@ -68,13 +55,10 @@ fn instance_of(
#[test]
fn clip_preferences_component_depth_matrix() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let prefs = inst.value.get_clip_preferences().expect("协商应成功");
assert_eq!(prefs.output_components, "OfxImageComponentRGBA");
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
@@ -93,8 +77,7 @@ fn clip_preferences_component_depth_matrix() {
.map(|v| format!("{v:?}")),
Some("String(\"OfxImageComponentRGBA\")".into())
);
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -103,17 +86,13 @@ fn clip_preferences_component_depth_matrix() {
#[test]
fn bit_depth_fallback_chain() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let prefs = inst.value.get_clip_preferences().unwrap();
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -123,20 +102,16 @@ fn bit_depth_fallback_chain() {
#[test]
fn region_of_definition_default_and_override() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let rod = inst
.value
.get_region_of_definition(0.0, oakplugin::instance::RenderScale { x: 1.0, y: 1.0 })
.expect("getRoD 应成功");
assert_eq!((rod.x1, rod.y1, rod.x2, rod.y2), (0.0, 0.0, 1920.0, 1080.0));
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -146,13 +121,10 @@ fn region_of_definition_default_and_override() {
#[test]
fn regions_of_interest_writeback() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let region = oakplugin::instance::OfxRectD {
x1: 10.0,
y1: 20.0,
@@ -183,8 +155,7 @@ fn regions_of_interest_writeback() {
(source.x1, source.y1, source.x2, source.y2),
(10.0, 20.0, 100.0, 200.0)
);
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -193,17 +164,13 @@ fn regions_of_interest_writeback() {
#[test]
fn is_identity_shortcircuit() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let identity = inst.value.is_identity(1.5).expect("isIdentity 应成功");
assert!(identity.is_none(), "测试插件恒非透传:{identity:?}");
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -212,17 +179,13 @@ fn is_identity_shortcircuit() {
#[test]
fn field_passthrough() {
common::with_host(|| {
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let prefs = inst.value.get_clip_preferences().unwrap();
assert_eq!(prefs.field, "OfxFieldNone", "插件声明的 field 应透传");
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -232,13 +195,10 @@ fn field_passthrough() {
fn sequence_render_brackets() {
common::with_host(|| {
use oakplugin::instance::OfxRangeD;
unsafe { oakplugin_host_init() };
let mut h = create_instance();
if h.is_null() {
unsafe { oakplugin_host_shutdown() };
let Some(inst) = create_instance() else {
Host::global().shutdown();
return;
}
let inst = instance_of(&h).expect("句柄应可解析");
};
let range = OfxRangeD {
min: 10.0,
max: 200.0,
@@ -248,7 +208,6 @@ fn sequence_render_brackets() {
// begin → timeline 上下文带范围(经 render 设置;单测直达
// RenderCtx 的接线见 suites::timeline 测试)。
unsafe { oakplugin_instance_free(&mut h) };
unsafe { oakplugin_host_shutdown() };
Host::global().shutdown();
});
}
@@ -1,343 +0,0 @@
// 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/>.
//! render 驱动测试(M11 §4):pluginrenderer.cpp 语义收编的 CPU 路径
//! ——序列括号、render_job 多输入/参数覆盖、isIdentity 短路、输出
//! 装配像素断言。GL 路径见 gl_render_test.rs。
mod common;
use std::ffi::{c_char, c_int, c_void, CString};
use oakplugin::ffi::{
node_value_type, oakplugin_host_scan, oakplugin_instance_create, oakplugin_instance_free,
oakplugin_instance_get_param, oakplugin_instance_render_begin_sequence,
oakplugin_instance_render_end_sequence, oakplugin_instance_render_job, OakNodeValue,
OakPluginJobTexture, OakPluginJobValue,
};
use oakplugin::handle::CHandle;
const OK: i32 = 0;
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
const IDENTITY_PLUGIN_ID: &str = "org.oak.test-plugin.identity";
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
fn fake_texture(ctx: usize) -> CHandle {
CHandle {
ctx: ctx as *mut c_void,
addref: None,
release: None,
abi_version: 1,
}
}
fn scan_and_create(id: &str) -> CHandle {
if common::test_plugin_scan_dir().is_none() {
common::skip("最小测试插件未构建");
return CHandle::null();
}
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
let dirs = [dir.as_ptr()];
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
let id = cs(id);
unsafe { oakplugin_instance_create(id.as_ptr()) }
}
/// 序列括号 + render_job(CPU):begin → job → end;输出像素为测试
/// 插件常量填充(0.5 RGBA F32,alpha=1);RoI 计算成功。
#[cfg(feature = "test-stubs")]
#[test]
fn render_job_cpu_path_and_sequence_brackets() {
use oakplugin::bridge::render::stub;
common::with_host(|| {
stub::reset();
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
return;
}
stub::setup_dst(8, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
let dst = fake_texture(0xA1);
// 未 begin 序列直接 job 也可用(单帧;序列括号是优化语义)。
assert_eq!(
unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
std::ptr::null(),
CHandle::null(),
std::ptr::null(),
0,
std::ptr::null(),
0,
CHandle::null(),
)
},
OK
);
// begin/end 括号配对。
assert_eq!(
unsafe { oakplugin_instance_render_begin_sequence(h, 0.0, 10.0, 0) },
OK
);
assert_eq!(
unsafe { oakplugin_instance_render_end_sequence(h, 0.0, 10.0, 0) },
OK
);
// 像素断言:8×4 常量 0.5 / alpha 1。
let pixels = stub::dst_pixels();
let n = pixels.len() / 16;
assert_eq!(n, 32);
for i in 0..n {
let f = |o: usize| {
f32::from_le_bytes(
pixels[i * 16 + o * 4..i * 16 + o * 4 + 4]
.try_into()
.unwrap(),
)
};
assert_eq!(f(0), 0.5, "pixel {i} r");
assert_eq!(f(3), 1.0, "pixel {i} a");
}
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 多输入 + 参数覆盖:inputs 表按 clip 名挂输入纹理;values 覆盖
/// gain 参数(驱动注入实例参数,插件经 param 读取——测试插件 render
/// 不读 gain,故断言回写成功即可)。
#[cfg(feature = "test-stubs")]
#[test]
fn render_job_multi_input_and_param_overrides() {
use oakplugin::bridge::render::stub;
common::with_host(|| {
stub::reset();
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
return;
}
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
stub::setup_src(
4,
4,
oakplugin::bridge::render::PIXEL_FORMAT_F32,
vec![0u8; 4 * 4 * 16],
);
let dst = fake_texture(0xA1);
let src = fake_texture(0xA2);
// 参数覆盖:gain = 1.5(FLOAT)。
let mut value = OakNodeValue::default();
value.r#type = node_value_type::FLOAT;
value.f = [1.5, 0.0, 0.0, 0.0];
let gain = cs("gain");
let job_values = [OakPluginJobValue {
key: gain.as_ptr(),
value,
}];
// 多输入表:Source → src 纹理。
let source = cs("Source");
let job_inputs = [OakPluginJobTexture {
clip: source.as_ptr(),
texture: src,
}];
assert_eq!(
unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
std::ptr::null(),
CHandle::null(),
job_inputs.as_ptr(),
1,
job_values.as_ptr(),
1,
CHandle::null(),
)
},
OK
);
// 覆盖已生效:经 C ABI 读回 gain。
let mut out = OakNodeValue::default();
let g = cs("gain");
assert_eq!(
unsafe { oakplugin_instance_get_param(h, g.as_ptr(), &mut out) },
OK
);
assert_eq!(out.f[0], 1.5, "gain 覆盖应已注入实例参数");
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// isIdentity 短路:identity 变体插件返回 "Source" → render_job 不调
/// render action,直接把 Source 的帧拷入输出(像素与输入一致)。
#[cfg(feature = "test-stubs")]
#[test]
fn render_job_is_identity_shortcircuit() {
use oakplugin::bridge::render::stub;
common::with_host(|| {
stub::reset();
let mut h = scan_and_create(IDENTITY_PLUGIN_ID);
if h.is_null() {
return;
}
// 输入帧:每像素 r=0.1,g=0.2,b=0.3,a=1.0。
let mut pixels = Vec::new();
for _ in 0..4 * 4 {
for v in [0.1f32, 0.2, 0.3, 1.0] {
pixels.extend_from_slice(&v.to_le_bytes());
}
}
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
stub::setup_src(
4,
4,
oakplugin::bridge::render::PIXEL_FORMAT_F32,
pixels.clone(),
);
let dst = fake_texture(0xA1);
let src = fake_texture(0xA2);
assert_eq!(
unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
cs("Source").as_ptr(),
src,
std::ptr::null(),
0,
std::ptr::null(),
0,
CHandle::null(),
)
},
OK
);
// 输出 == 输入(透传帧)。
let out_pixels = stub::dst_pixels();
assert_eq!(out_pixels, pixels, "isIdentity 透传应逐字节拷贝输入帧");
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 默认构建(无桩):render_job 缺桥符号 → 明确失败码而非崩溃
/// (优雅降级;GL 用例同)。
#[cfg(not(feature = "test-stubs"))]
#[test]
fn render_job_graceful_without_bridge() {
common::with_host(|| {
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
return;
}
// NULL 输出纹理在触达 oakrender 前被插件层校验拒绝(E_INVALID)。
let dst = CHandle::null();
let r = unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
std::ptr::null(),
CHandle::null(),
std::ptr::null(),
0,
std::ptr::null(),
0,
CHandle::null(),
)
};
assert_eq!(r, -90001, "NULL 输出纹理 → E_INVALID(不崩溃)");
unsafe { oakplugin_instance_free(&mut h) };
});
}
/// 驱动错误路径:非 F32 输出 → 明确失败;取消标记 → 明确失败。
#[cfg(feature = "test-stubs")]
#[test]
fn render_job_error_paths() {
use oakplugin::bridge::render::stub;
use oakplugin::ffi::{oakplugin_instance_cancel, oakplugin_instance_render_job};
common::with_host(|| {
stub::reset();
let mut h = scan_and_create(TEST_PLUGIN_ID);
if h.is_null() {
return;
}
// 非 F32 输出(U8)→ read_dst 拒绝。
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_U8);
let dst = fake_texture(0xA1);
let r = unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
std::ptr::null(),
CHandle::null(),
std::ptr::null(),
0,
std::ptr::null(),
0,
CHandle::null(),
)
};
assert_eq!(r, -90003, "非 F32 输出 → E_FAILED");
// 取消标记 → 驱动入口短路。
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
assert_eq!(unsafe { oakplugin_instance_cancel(h) }, OK);
let r = unsafe {
oakplugin_instance_render_job(
h,
dst,
0.0,
0,
0,
std::ptr::null(),
CHandle::null(),
std::ptr::null(),
0,
std::ptr::null(),
0,
CHandle::null(),
)
};
assert_eq!(r, -90003, "已取消 → E_FAILED");
unsafe { oakplugin_instance_free(&mut h) };
});
}