// 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 .
//! 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) {
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 {
match props.get(name, 0)? {
oakplugin::property::Value::Int(i) => Some(i),
_ => None,
}
}
fn tex_string(props: &oakplugin::property::PropertySet, name: &str) -> Option {
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(),
®ion 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::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>) };
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()) };
});
}