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.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
// 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 配对)。
|
||||
//!
|
||||
//! 门:macOS(Linux/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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 捕获插件 message(gl-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 消息"
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user