Files
oak-editor/crates/oakplugin/tests/ffi_instance_test.rs
T
Mike-Solar 18ff60f147 feat(engine): clip move, clip effect_input, mandatory static FFmpeg
- oakengine_sequence_move_clip implemented for real (oaktimeline
  TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple
  trim bugs the stub was hiding); same-track via the frozen C ABI,
  cross-track supported by the module command
- oaknode clip blocks now declare a tex_in texture input and set
  effect_input to it, so timeline clips can host effect chains; facade
  test covers effect insert/remove on a real clip
- oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a
  Homebrew upgrade left the system ffmpeg .pc pointing at a deleted
  dav1d Cellar path, breaking links); reads a git-ignored workspace
  .env for IDEs that cannot inject env vars (RustRover); links the C++
  stdlib for C++ codec libs (svt-av1)
- oakengine re-exports oaknode so tests share one crate instance;
  it_node uses the direct instance's value type where it calls the
  module FFI (the --workspace dev-dependency feature split builds
  oaknode twice)
2026-08-11 23:04:48 +08:00

574 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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_scanffi 模块内路径统一)。
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_paramdouble):round-trip 一致;未知参数名
/// E_NOT_FOUND;空句柄 E_INVALIDout 为 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_stringround-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 F32alpha=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.5alpha 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 > 0name/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/labelcount={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 rangegain-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));
// secretdebug = 1gain = 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);
// choicemode2 个选项;选项内容见 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 clipoptional 标记)。
#[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_countcreate 增、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);
});
}