Files
oak-editor/crates/oakplugin/tests/gl_render_test.rs
T
Mike-Solar b4ceaa9cab feat(oakplugin): GL render bridge, color picker, push-button action, worker progress, Interact host
- gl_bridge: macOS CGL offscreen context (process-wide singleton,
  serialized GlGuard), real GL output textures/FBOs, glReadPixels
  readback with vertical flip and format conversion; use_opengl now
  really engages for OpenGLRenderSupported plugins (verified with real
  GL rendering: C smoke 11/11, unit tests, GL e2e).
- OfxColor: color params get a swatch button plus a real picker popup
  (RGBA sliders, live preview, hex input, undoable commit) replacing
  the four spinboxes.
- Push buttons route kOfxActionInstanceChanged (UserEdited) per the
  OFX contract; test plugin asserts the callback.
- Worker-side plugin progress flows to the main-process progress
  dialog over the NDJSON control channel, with cancel propagation.
- OFX Interact host: NewInteract/Describe lifecycle, Draw/Pen/Key/Idle
  action surface with proper in-args, DrawSuite v1 host implementation
  sharing the gl_bridge context; interact test plugin verifies the
  event stream and real GL drawing.
2026-08-19 22:14:54 +08:00

263 lines
8.6 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/>.
//! GL 渲染桥端到端:真实 CGL 离屏上下文 + 输出纹理/FBO + 回读。
//!
//! 链路(方案 B,见 [`oakplugin::gl_bridge`] 模块文档):扫描最小测试
//! 插件的 GL 变体(org.oak.test-plugin.gl,声明 OpenGLRenderSupported=
//! "true" + F32)→ 用 GL 后端渲染器(fake `GpuContextLike`kind=Gl
//! 构造 RenderJob → `render_frame` 的 use_opengl 决策命中 → 桥建 CGL
//! 上下文 + 输出 GL 纹理 + FBO → 插件 render 走 GL 路径(attach/detach、
//! clipLoadTexture 拿真实纹理名、glClear 清成已知颜色)→ 宿主
//! glReadPixels 回读装帧。
//!
//! 断言即"真实 GL 渲染"的证据:
//! 1. 输出像素 = 插件 GL 清屏色 (0.1, 0.2, 0.3, 1.0)CPU 路径恒填
//! 0.5,二者可区分);
//! 2. 插件经 message 上报的 gl-source-index / gl-output-index 都是
//! 非零真实 GL 纹理名(CPU 桩恒 0);
//! 3. gl-attached / gl-detached 动作发生(attach/detach 配对)。
//!
//! 门:macOSLinux/Windows 的 GL 桥是 stub+ `OAK_GPU_TESTS`GL
//! 验收约定,CI 一律跳过)。
mod common;
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::sync::Arc;
use oakcore_rs::{PixelFormat, Rational};
use oakplugin::host::Host;
use oakplugin::render::{Renderer, Texture};
use oakrender::backend::{BackendKind, GpuContextLike};
use oakrender::texture::Frame;
const GL_PLUGIN_ID: &str = "org.oak.test-plugin.gl";
/// GL 后端渲染器(fake `GpuContextLike`;只声明 kind=Gl,让 use_opengl
/// 决策命中。GL 路径不用它的 upload/download/blit——输出是 CPU 帧,
/// 渲染发生在桥自建的 CGL 上下文)。
struct FakeGlRenderer;
impl GpuContextLike for FakeGlRenderer {
fn kind(&self) -> BackendKind {
BackendKind::Gl
}
fn destroy_texture(&self, _token: u64) {}
fn upload(&self, _token: u64, _frame: &Frame) -> oakrender::error::Result<()> {
Ok(())
}
fn download(&self, _token: u64) -> oakrender::error::Result<Frame> {
Ok(Frame::new())
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&oakrender::color::ColorProcessor>,
) -> oakrender::error::Result<()> {
Ok(())
}
}
/// 捕获插件 messagegl-test 消息流)。
unsafe extern "C" fn capture_msg(
type_: *const c_char,
message: *const c_char,
userdata: *mut 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(),
));
1
}
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
fn first_pixel(texture: &Texture) -> [f32; 4] {
let Texture::Cpu(frame) = texture else {
panic!("期望 CPU 帧");
};
let mut out = [0f32; 4];
for i in 0..4 {
out[i] = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap());
}
out
}
/// 扫描 + 注册(幂等;宿主不可用返回 false = skip)。
fn scan_and_register() -> bool {
let Some(dir) = common::test_plugin_scan_dir() else {
common::skip("最小测试插件未构建");
return false;
};
if Host::global().cache.scan_path(&dir).is_err() {
common::skip("测试插件扫描失败");
return false;
}
oakplugin::node_factory::register_plugin_nodes();
true
}
/// GL 端到端:插件 GL 变体真实走 GL 路径并输出已知颜色。
#[test]
fn gl_plugin_renders_through_real_gl_path() {
common::with_host(|| {
if !cfg!(target_os = "macos") {
common::skip("GL 桥仅 macOS 实现(Linux/Windows stub");
return;
}
if !common::gpu_available() {
common::skip("GL 测试需 OAK_GPU_TESTS(本机 GPU 验收;CI 一律跳过)");
return;
}
if !oakplugin::gl_bridge::gl_available() {
common::skip("本机无可用 GL 上下文");
return;
}
if !scan_and_register() {
return;
}
let mut captured: Vec<(String, String)> = Vec::new();
oakplugin::suites::message::set_handler(
Some(capture_msg),
&mut captured as *mut _ as *mut c_void,
);
let inst = Host::global()
.create_instance(GL_PLUGIN_ID, None)
.expect("GL 变体实例应可建");
let reg = oakplugin::node_factory::register_instance(inst.clone());
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (4, 4), PixelFormat::F32)
.unwrap();
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (4, 4), PixelFormat::F32)
.unwrap();
let renderer: Renderer = Arc::new(FakeGlRenderer);
let job = oakplugin::render_driver::RenderJob {
time: 0.0,
dst: Texture::wrap_frame(dst),
src: Some(Texture::wrap_frame(src)),
effect_input_id: Some("Source".into()),
inputs: Vec::new(),
values: Vec::new(),
renderer: Some(renderer),
clear_destination: false,
interactive: false,
};
let (out, _rois) = oakplugin::render_driver::render_frame(&inst.value, &job)
.expect("GL render_frame 应成功");
oakplugin::node_factory::unregister_instance(reg);
oakplugin::suites::message::set_handler(None, std::ptr::null_mut());
Host::global().shutdown();
// 1. 输出像素 = 插件 GL 清屏色(CPU 路径恒 0.5,可区分)。
let px = first_pixel(&out);
for (i, v) in [0.1, 0.2, 0.3, 1.0].iter().enumerate() {
assert!(
(px[i] - v).abs() < 1e-4,
"GL 回读像素[{i}] = {},期望 {v}GL 路径未生效?)",
px[i]
);
}
// 2. 真实 GL 纹理名(非零)+ 3. attach/detach 配对。
let msg: Vec<&str> = captured.iter().map(|(_, m)| m.as_str()).collect();
for need in [
"gl-attached",
"gl-detached",
"gl-source-index=",
"gl-output-index=",
] {
assert!(
msg.iter().any(|m| m.contains(need)),
"应捕获到 {need},实际 {msg:?}"
);
}
for m in msg.iter().filter(|m| m.contains("gl-source-index=")) {
let v: i32 = m.rsplit('=').next().unwrap().parse().unwrap();
assert!(v > 0, "输入 clip 的 OpenGLTextureIndex 应为真实非零 GL 名,got {v}");
}
for m in msg.iter().filter(|m| m.contains("gl-output-index=")) {
let v: i32 = m.rsplit('=').next().unwrap().parse().unwrap();
assert!(v > 0, "输出 clip 的 OpenGLTextureIndex 应为真实非零 GL 名,got {v}");
}
});
}
/// CPU 回退对照:无 GL 渲染器(renderer=None)时,GL 变体插件走 CPU
/// 路径(actionRenderGL 的 !gl_enabled 回退 actionRender),输出恒
/// 0.5——与 [`gl_plugin_renders_through_real_gl_path`] 的 0.1,0.2,0.3
/// 区分,证明 GL 分支只在 renderer 为 GL 时启用。
#[test]
fn gl_plugin_falls_back_to_cpu_without_gl_renderer() {
common::with_host(|| {
if !scan_and_register() {
return;
}
let mut captured: Vec<(String, String)> = Vec::new();
oakplugin::suites::message::set_handler(
Some(capture_msg),
&mut captured as *mut _ as *mut c_void,
);
let inst = Host::global()
.create_instance(GL_PLUGIN_ID, None)
.expect("GL 变体实例应可建");
let reg = oakplugin::node_factory::register_instance(inst.clone());
let dst = oakrender::eval::generate_frame(Rational::new(0, 1), (4, 4), PixelFormat::F32)
.unwrap();
let src = oakrender::eval::generate_frame(Rational::new(0, 1), (4, 4), PixelFormat::F32)
.unwrap();
let job = oakplugin::render_driver::RenderJob {
time: 0.0,
dst: Texture::wrap_frame(dst),
src: Some(Texture::wrap_frame(src)),
effect_input_id: Some("Source".into()),
inputs: Vec::new(),
values: Vec::new(),
renderer: None,
clear_destination: false,
interactive: false,
};
let (out, _rois) = oakplugin::render_driver::render_frame(&inst.value, &job)
.expect("CPU 回退 render_frame 应成功");
oakplugin::node_factory::unregister_instance(reg);
oakplugin::suites::message::set_handler(None, std::ptr::null_mut());
Host::global().shutdown();
let px = first_pixel(&out);
assert_eq!(px, [0.5, 0.5, 0.5, 1.0], "无 GL 渲染器应走 CPU 恒 0.5");
// 未走 GL:不应有 gl-* 消息流。
assert!(
!captured.iter().any(|(_, m)| m.contains("gl-output-index")),
"CPU 回退不应产生 GL suite 消息"
);
});
}