diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs index d8b015b02..d4c29aaeb 100644 --- a/crates/oak-worker/src/worker.rs +++ b/crates/oak-worker/src/worker.rs @@ -43,6 +43,7 @@ //! shm slots and publish `frame_ready` / `frame_failed` (protocol v2). use std::io::{self, BufRead, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -55,9 +56,10 @@ use oakrender::ticket::{AudioTicketParams, MontageClip, VideoTicketParams}; use crate::ipc::{ error_message, write_message, AudioTicketSpec, BatchTicketSpec, FrameSlotPool, FrameSlotMeta, - HandshakeMsg, LoadGraphMsg, RenderAudioBatchMsg, RenderBatchMsg, RenderFrameMsg, - SharedMemoryRegion, ShmMode, TYPE_CANCEL, TYPE_HANDSHAKE, TYPE_LOAD_GRAPH, TYPE_RENDER_AUDIO_BATCH, - TYPE_RENDER_BATCH, TYPE_RENDER_FRAME, TYPE_SHUTDOWN, SLOT_FORMAT_AUDIO_F32, SLOT_FORMAT_BGRA8, + HandshakeMsg, LoadGraphMsg, PluginProgressMsg, RenderAudioBatchMsg, RenderBatchMsg, + RenderFrameMsg, SharedMemoryRegion, ShmMode, TYPE_CANCEL, TYPE_HANDSHAKE, TYPE_LOAD_GRAPH, + TYPE_PLUGIN_CANCEL, TYPE_RENDER_AUDIO_BATCH, TYPE_RENDER_BATCH, TYPE_RENDER_FRAME, + TYPE_SHUTDOWN, SLOT_FORMAT_AUDIO_F32, SLOT_FORMAT_BGRA8, }; use crate::{log_error, PROTOCOL_VERSION}; @@ -77,6 +79,101 @@ struct LoadedGraph { project_copy: u64, } +// --------------------------------------------------------------------------- +// Worker-side plugin-progress forwarding +// --------------------------------------------------------------------------- +// +// OFX plugin rendering happens in this process (crash isolation), so the +// main process's inline progress reporter is not in effect here. The +// worker installs its own progress reporter factory (see +// [`install_worker_progress_factory`]) whose reporters push `plugin_progress` +// NDJSON events into [`WORKER_PROGRESS_EVENTS`]; the main loop drains the +// buffer after each control message ([`flush_worker_progress`]). Plugin +// renders are synchronous on the loop thread, so the buffer only ever +// mutates there — a plain `Mutex` suffices. +// +// Cancel: the main process broadcasts `plugin_cancel` (the progress +// dialog's Cancel button); the worker sets [`WORKER_PLUGIN_CANCEL`] and +// every live reporter answers false (the plugin aborts at its next +// progressUpdate). Mirrors the main-process reporter factory: a fresh +// render (progressStart) resets the sticky flag. Because the worker +// processes control messages between batches, an in-flight frame +// completes before the cancel is observed (batch granularity); the main +// process stops the render loop separately (export cancel atom / preview +// window invalidation). + +/// The worker's sticky plugin-cancel flag (set by the `plugin_cancel` +/// control message, read by every live progress reporter). +static WORKER_PLUGIN_CANCEL: AtomicBool = AtomicBool::new(false); + +/// Buffered `plugin_progress` events awaiting the next flush. +static WORKER_PROGRESS_EVENTS: Mutex> = Mutex::new(Vec::new()); + +/// Queue one `plugin_progress` NDJSON event for the main loop to flush. +fn push_worker_progress(fraction: f64, label: &str, message: &str) { + let event = PluginProgressMsg { + label: label.to_string(), + message: message.to_string(), + fraction, + } + .to_json(); + WORKER_PROGRESS_EVENTS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(event); +} + +/// Write every buffered progress event to `out` (called by the main loop +/// after each control message; `out` is the NDJSON stdout writer). +fn flush_worker_progress(out: &mut impl Write) { + let events = std::mem::take( + &mut *WORKER_PROGRESS_EVENTS + .lock() + .unwrap_or_else(|e| e.into_inner()), + ); + for event in events { + if write_message(out, &event).is_err() { + break; + } + } + let _ = out.flush(); +} + +/// The worker-side `UiProgressReporter`: forwards (label, message, +/// fraction) to the main process and honours the sticky cancel flag. +struct WorkerProgressReporter { + label: String, + message: String, +} + +impl oakplugin::progress::UiProgressReporter for WorkerProgressReporter { + fn update(&mut self, progress: f64) -> bool { + push_worker_progress(progress, &self.label, &self.message); + !WORKER_PLUGIN_CANCEL.load(Ordering::Relaxed) + } + + fn end(&mut self) { + // progressEnd: forward completion (fraction 1.0) so the app closes + // the progress dialog without waiting for a 1.0 update. + push_worker_progress(1.0, &self.label, &self.message); + } +} + +/// Install the worker-side progress reporter factory. Called from +/// [`WorkerSession::initialize_runtime`]; mirrors the main-process factory +/// (a fresh progressStart resets the sticky cancel flag). +fn install_worker_progress_factory() { + oakplugin::progress::set_reporter_factory(Some(Arc::new(|label, message| { + // A fresh render begins: reset the sticky cancel flag. + WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed); + push_worker_progress(0.0, label, message); + Box::new(WorkerProgressReporter { + label: label.to_string(), + message: message.to_string(), + }) + }))); +} + // --------------------------------------------------------------------------- // Renderer (backend selection) // --------------------------------------------------------------------------- @@ -248,6 +345,11 @@ impl WorkerSession { "runtime: registered {} OFX plugin node type(s)", registered.len() )); + // Worker-side plugin progress forwarding (see the module docs): the + // plugin progress suite runs in this process, so progress events + // must cross the IPC boundary to reach the app's progress dialog. + log_error("runtime: installing worker plugin-progress reporter factory"); + install_worker_progress_factory(); log_error( "runtime: config / frame manager / disk manager / project \ serializer have no Rust backing in the worker binary; skipped", @@ -292,6 +394,13 @@ impl WorkerSession { // cancel: the worker does synchronous single-frame work // (nothing in flight), so a cancel produces no response. TYPE_CANCEL => None, + // plugin_cancel: the user cancelled the plugin render; sticky + // until the next progressStart resets it (main-process + // semantics). + TYPE_PLUGIN_CANCEL => { + WORKER_PLUGIN_CANCEL.store(true, Ordering::Relaxed); + None + } TYPE_SHUTDOWN => { self.shutdown_requested = true; None @@ -1123,6 +1232,8 @@ pub fn worker_main(backend: &str) -> i32 { exit_code = 1; break; } + // Plugin progress events buffered during the batch go out now. + flush_worker_progress(&mut out); continue; } // M15 S3: render_audio_batch streams the same way (audio range @@ -1133,6 +1244,7 @@ pub fn worker_main(backend: &str) -> i32 { exit_code = 1; break; } + flush_worker_progress(&mut out); continue; } _ => {} @@ -1149,6 +1261,8 @@ pub fn worker_main(backend: &str) -> i32 { break; } } + // Progress events buffered while serving the control message. + flush_worker_progress(&mut out); } exit_code } diff --git a/crates/oakplugin/README.md b/crates/oakplugin/README.md index 2aa202192..c6e7e2e7b 100644 --- a/crates/oakplugin/README.md +++ b/crates/oakplugin/README.md @@ -19,6 +19,8 @@ src/ property.rs memory.rs image_effect.rs param.rs message.rs progress.rs timeline.rs multithread.rs gl_render.rs OfxImageEffectOpenGLRenderSuiteV1(M11 §4 新增) + draw.rs OfxDrawSuiteV1(OFX 1.5,interact 绘制;真实 GL 渲染) + interact.rs OfxInteractSuiteV1 + Interact 宿主对象(interact 宿主) host.rs Host 单例:bundle 扫描、插件缓存、action 分发 descriptor.rs EffectDescriptor/ClipDescriptor(describe 的产物) instance.rs Instance:action 调用面(render/协商/RoD/RoI/isIdentity/ @@ -92,22 +94,40 @@ src/ OpenGLTextureTarget/PixelDepth/Components/PreMultiplication/ RenderScale/PixelAspectRatio/Bounds/ROD/RowBytes/Field/ UniqueIdentifier);存活表持有 Box(句柄地址稳定)。 - Output clip 的渲染目标绑定由调用方契约保证(等价 C++ - `attach_output_texture`);clipFreeTexture 对 Output 不删纹理。 + **OpenGLTextureIndex 是真实 GL 纹理名**(GL 模式;0 = CPU 回退)。 + Output clip 的渲染目标绑定由宿主完成(等价 C++ + `attach_output_texture`);clipFreeTexture 对 Output 不删纹理(宿主 + 回读后删除)。 - GL render action:`Instance::render_gl`(与 CPU `render` 并存)—— action 序列 kOfxActionOpenGLContextAttached → render(in args 带 kOfxImageEffectPropOpenGLEnabled=1)→ OpenGLContextDetached; - GL 模式无 CPU 输出回读,渲染结果留在附着纹理上。 + 渲染结果留在 FBO 附着的输出 GL 纹理上,render 返回后宿主 + **glReadPixels 回读**装帧(方案 B,见下)。 - **GL 上下文规则**(ofxGPURender.h "OpenGL Current Context"):宿主 只在 Render/Begin/EndSequenceRender/Attach/Detach 期间要求上下文 - current——本实现的约定是调用方(oakrender PluginJob 路径)在 - 进入 render_job 前把渲染器上下文置为 current 并附着输出纹理 - (文档见 include/plugin/instance.h 的新增声明)。 + current——本实现的约定是 render 驱动一次 `gl_bridge::acquire` 整个 + GL render action(上下文 current 到 guard drop),suite 回调期间 + 恒 current。 - 格式协商:插件描述符声明 kOfxImageEffectPropOpenGLRenderSupported ("false"/"true"/"needed")与 kOfxOpenGLPropPixelDepth(可选位深 列表);宿主 `pick_gl_pixel_depth` 按管线 F32 约束选型(声明列表 不含 Float → GL 模式不可行,回退 CPU)。use_opengl 决策在 - render_driver。 + render_driver(插件 GL 声明 + 深度协商 + 桥可用)。 + +### GL 互操作桥(方案 B 落地,`gl_bridge.rs`) + +- **macOS 真实实现**(Core OpenGL / CGL,`OpenGL.framework` 直链, + 无新 crate):进程级共享离屏上下文(3.2 core profile、offline + renderer 允许),`acquire()` 全局串行 + 置当前线程 current。为每次 + GL 渲染建输出 GL 纹理(尺寸 = 目标帧;F32 → RGBA32F、U8 → RGBA8) + + FBO 挂载;插件直接画进 FBO;render 返回后 `glReadPixels` 回读 + (垂直翻转、U8 归一化 F32),与 CPU 路径输出格式一致。 +- 输入 clip 在 GL 模式经桥上传为真实 GL 纹理(clipFreeTexture 删除)。 +- GL 失败回退 CPU(对齐现有失败语义)。Linux(EGL)/Windows(WGL) + 预留 cfg stub。 +- 验证:`gl_bridge.rs` 单元测试(clear 已知颜色 → 回读逐像素断言, + `OAK_GPU_TESTS` 门)+ `tests/gl_render_test.rs` 端到端(GL 测试插件 + 真实走 GL 路径、输出已知颜色)。 ### ofxColour(OFX 1.4) @@ -150,6 +170,63 @@ src/ (输入 clip 纹理)两个 POD 随附。GL 契约(上下文 current + 输出 附着)见头文件文档注释。 +## OFX Interact 宿主(interact + Draw suite,M11 §5) + +插件自定义 UI 的宿主侧:Interact 实例在主进程创建(UI 事件宿主),与 +worker 进程里的渲染实例并存(OFX 允许同一插件多实例)。 + +- **`suites/interact.rs`**: + - `Instance::new_interact`(任务契约):取插件声明的 overlay interact + 入口(kOfxImageEffectPluginPropOverlayInteractV2 优先、V1 次之;未 + 声明则插件 main entry)→ 建 `Interact`(属性表 + tagged INTERACT + handle)→ 发 `kOfxActionNewInteract`。插件返回 OK → Some;返回 + ReplyDefault 且未声明 overlay 入口(无 interact)→ None;错误 → None。 + - 生命周期:`describe`(kOfxActionDescribe == kOfxActionDescribeInteract) + → `create_instance`(kOfxActionCreateInstance)→ 事件 → `destroy` + (kOfxActionDestroyInstance);**与效果实例同生命周期**——实例销毁 + (`Instance::notify_destroy`)时先连带销毁 interact。 + - 调用面(宿主→插件,返回值如实透传):`draw(viewport, pixel_scale, + time, background)`、`pen_motion(pen_pos, pen_down)`、`pen_down`、 + `pen_up`、`key_down(key_sym, key_string)`、`key_up`、`idle`(任务契约 + 的 kOfxInteractActionIdle,属宿主扩展)、`gain_focus`/`lose_focus`。 + pen 坐标为视口像素,inArgs 另带 canonical 位置(viewport/像素比)、 + viewport 位置、压力(两态笔映射 0/1);key 带 kOfxPropKeySym + (ofxKeySyms.h 关键码)+ kOfxPropKeyString。返回 kOfxStatOK = 插件 + 已处理(宿主不再把事件给其他对象),ReplyDefault = 未处理。 + - `OfxInteractSuiteV1`(ofxInteract.h:534):interactSwapBuffers / + interactRedraw 把请求记入 `swap_requested`/`redraw_requested` + (app 侧轮询面);interactGetPropertySet 返回 interact 属性集句柄。 + - interact 属性集走现有 property suite:PixelScale / ViewportSize / + BackgroundImage / SuggestedColour / SlaveToParam / BackgroundColour / + BitDepth / HasAlpha(ofxInteract.h 的 PropertiesInteract;ViewportSize + 取 OFX 1.3 名 "OfxInteractPropViewport"、BackgroundImage 与 Idle 为 + 任务契约扩展)。 +- **`suites/draw.rs`**(`OfxDrawSuiteV1`,OFX 1.5,vendored ofxDrawSuite.h): + getColour / setColour / setLineWidth / setLineStipple / draw / drawText。 + 状态(colour/lineWidth/stipple)保存在 `DrawContext`(每次 draw action + 创建、经存活表注册,draw 返回摘除——draw 外调用 → kOfxStatFailed)。 + GL 绘制是**真实渲染**:gl_bridge 的 CGL 上下文是 3.2 core(无固定管 + 线),draw 用最小着色器 + VAO/VBO 按正交投影画线/矩形/多边形/椭圆, + 非不透明色按 "over" 合成。drawText 无字体光栅化器 → 如实返回 + kOfxStatErrUnsupported。非 macOS 无 GL 桥 → kOfxStatFailed。 +- **GL 上下文模型**:宿主在调 draw 前经 `gl_bridge::acquire` 保持 + current 整个 action(与 WG1 渲染路径同模型);`acquire` 支持同线程 + 可重入(测试"一次 acquire 覆盖 FBO 装配 → draw → 回读")。 +- vendored 头新增:`ofxDrawSuite.h`、`ofxKeySyms.h`(kOfxPropKeySym/ + kOfxPropKeyString + kOfxKey_*)、`ofxPixels.h`(OfxRGBAColourF)—— + 官方 openfx(BSD-3-Clause),与既有 vendored 头同源。 +- 验证:`tests/interact_test.rs`(生命周期 + 事件参数逐条断言插件侧 + marker 记录;Escape key_down 的 ReplyDefault 透传;实例销毁连带 + destroy)+ GL draw 端到端(`OAK_GPU_TESTS` 门:插件 glClear 暗背景 + + Draw suite setColour(0.9,0.1,0.2,1) + draw(Rectangle) → 回读断言矩形 + 颜色与背景)。 + +app 接线(WG3b)公共 API:`Instance::new_interact` / `Instance::interact` / +`Instance::describe_interact`、`Interact::{describe, create_instance, draw, +pen_motion, pen_down, pen_up, key_down, key_up, idle, gain_focus, lose_focus, +destroy, props, swap_requested, redraw_requested}`、`host::KEY_*` 关键码、 +`fetch_suite("OfxInteractSuite"/"OfxDrawSuite", 1)`。 + ## 与 M11 §3.5 验收的对照(第 1+2 期现状) | 验收项 | 状态 | @@ -243,12 +320,15 @@ crates/,不动 src/(app)与 gpui/。 `oakrender::eval::set_plugin_executor`(依赖反转),duplicator 装进 `oaknode::nodes::plugin::set_plugin_duplicator`。 - `set_project_extent(w,h)`:normalised 坐标默认值换算基准。 -- **`gl_bridge.rs`(新增,spike 文档)**:`texture_id` 桩的 GL 互 - 操作评估——方案 A(wgpu-hal GL 互操作)在 macOS 不可行(Metal - 后端无 GL 命名空间);方案 B(独立离屏 GL 上下文 + 回读 + - wgpu upload)技术可行但暂缓(无真实 GL 插件可验证 + 需新 GL 依 - 赖 + 每帧同步回读 stall)。`texture_id` 保持恒 0,GL 插件经 CPU - render action 正确出帧。 +- **`gl_bridge.rs`(方案 B 落地)**:`texture_id` 桩的 GL 互操作评估 + 结论——方案 A(wgpu-hal GL 互操作)在 macOS 不可行(Metal 后端无 + GL 命名空间);**方案 B(独立离屏 GL 上下文 + 回读)已落地**: + macOS CGL 离屏上下文(进程级共享、`acquire()` 串行 + current)、 + 输出 GL 纹理/FBO 挂载、glReadPixels 回读装帧(垂直翻转、格式转 + 换)、输入 clip 真实 GL 纹理上传。use_opengl 决策与 GL suite 的 + OpenGLTextureIndex 接通真实 GL 名。Linux(EGL)/Windows(WGL) + 预留 cfg stub。验证:单元测试(clear → 回读断言)+ 端到端 + (GL 测试插件真实渲染)经 `OAK_GPU_TESTS` 门。 - **`progress.rs`**:新增 `UiProgressReporter` trait + `ReporterFactory` + `set_reporter_factory`(app 注入点)。render 未装 C 回调时装静默报告器,progressStart 携 (label,message) 经 @@ -297,9 +377,10 @@ cargo tarpaulin --out stdout --features test-stubs # 覆盖率门槛 (`bridge::*::stub`),ffi/clip/param 的桥调用(含像素读写、 节点回写、undo 打包)全链路可跑——**覆盖率以该模式为准** (M11 第 1 期实测 82.93% 行覆盖;第 2 期门槛 ≥80%,见 COVERAGE); -- 最小测试插件(cbits/oak_test_plugin.c,三个入口: - org.oak.test-plugin / org.oak.test-plugin.gl / - org.oak.test-plugin.identity)由 build.rs 编译为共享库, +- 最小测试插件(cbits/oak_test_plugin.c,四个入口: + org.oak.test-plugin / org.oak.test-plugin.gl / + org.oak.test-plugin.identity / org.oak.test-plugin.interact)由 + build.rs 编译为共享库, `common::test_plugin_dir` 运行时装配成 bundle;不可用时相关用例 skip; - 宿主单例无锁:触碰宿主面的用例经 `common::with_host` 串行化。 @@ -336,6 +417,9 @@ TDD:测试声明与实现声明同步冻结(tests/,函数体 `todo!()`) (偏好采纳、交叉引用解析、输出写回;ACEScg 工作空间)。 - `gl_render_test.rs` — GL suite 往返/错误路径/像素深度协商矩阵 + GL render 路径端到端(无 GPU 优雅跳过策略见上)。 +- `interact_test.rs` — Interact 宿主:生命周期 + 事件调用面(插件侧 + marker 逐条断言)、Escape 状态透传、实例销毁连带、draw 真实 GL 渲染 + 断言(`OAK_GPU_TESTS` 门)。 - `render_driver_test.rs` — render_job CPU 路径(序列括号、多输入、 参数覆盖、isIdentity 透传像素断言、无桩降级)。 - `bridge_test.rs` — node/render/undo 三桥(`--features test-stubs` diff --git a/crates/oakplugin/build.rs b/crates/oakplugin/build.rs index ad5c56b6a..e4d2e9de0 100644 --- a/crates/oakplugin/build.rs +++ b/crates/oakplugin/build.rs @@ -46,15 +46,22 @@ fn build_test_plugin() { } else { ("-shared", "so") }; + let mut args = vec![ + "-fPIC".to_string(), + "-Iofx".to_string(), + "cbits/oak_test_plugin.c".to_string(), + link_flag.to_string(), + "-o".to_string(), + format!("{out}/oak_test_plugin.{ext}"), + ]; + // GL 端到端测试:插件 GL 变体在 macOS 上真实绘制(glClear),需要 + // OpenGL.framework。 + if cfg!(target_os = "macos") { + args.push("-framework".into()); + args.push("OpenGL".into()); + } let status = Command::new(&cc) - .args([ - "-fPIC", - "-Iofx", - "cbits/oak_test_plugin.c", - link_flag, - "-o", - &format!("{out}/oak_test_plugin.{ext}"), - ]) + .args(&args) .status() .expect("编译测试插件失败"); assert!(status.success(), "测试插件编译失败"); diff --git a/crates/oakplugin/cbits/oak_test_plugin.c b/crates/oakplugin/cbits/oak_test_plugin.c index 2082e45b3..5843c287b 100644 --- a/crates/oakplugin/cbits/oak_test_plugin.c +++ b/crates/oakplugin/cbits/oak_test_plugin.c @@ -32,17 +32,28 @@ */ #include +#include #include +#include #include "ofxCore.h" #include "ofxColour.h" +#include "ofxDrawSuite.h" #include "ofxGPURender.h" #include "ofxImageEffect.h" +#include "ofxInteract.h" +#include "ofxKeySyms.h" #include "ofxMessage.h" #include "ofxParam.h" #include "ofxProgress.h" #include "ofxProperty.h" +/* GL 端到端测试的真实绘制需要 GL 命令(仅 macOS 有真实 CGL 桥; + * Linux/Windows 的 GL 桥是 stub,绘制代码随 __APPLE__ 排除)。 */ +#ifdef __APPLE__ +#include +#endif + /* kOfxImageEffectGLFormatRGBA 在 vendored ofxOpenGLRender.h 是 stub * 未收录(OpenFX 1.4 规范名),按规范定义。kOfxImageEffectPropIsIdentity * 同理(vendored ofxImageEffect.h 只文档化了该属性,未给宏)。 */ @@ -56,9 +67,48 @@ static const OfxParameterSuiteV1 *g_paramSuite = NULL; static const OfxMessageSuiteV1 *g_messageSuite = NULL; static const OfxProgressSuiteV1 *g_progressSuite = NULL; static const OfxImageEffectOpenGLRenderSuiteV1 *g_glSuite = NULL; +static const OfxDrawSuiteV1 *g_drawSuite = NULL; +static const OfxInteractSuiteV1 *g_interactSuite = NULL; + +/* 宿主扩展(任务契约;vendored ofxInteract.h 未收录的 action 名与属性 + * 名)。kOfxInteractPropViewportSize 取 OFX 1.3 的规范名 + * "OfxInteractPropViewport"(vendored 1.5 头已删);BackgroundImage 与 + * NewInteract/Idle 是本宿主扩展。 */ +#ifndef kOfxActionNewInteract +#define kOfxActionNewInteract "OfxActionNewInteract" +#endif +#ifndef kOfxInteractActionIdle +#define kOfxInteractActionIdle "OfxInteractActionIdle" +#endif +#ifndef kOfxInteractPropViewportSize +#define kOfxInteractPropViewportSize "OfxInteractPropViewport" +#endif +#ifndef kOfxInteractPropBackgroundImage +#define kOfxInteractPropBackgroundImage "OfxInteractPropBackgroundImage" +#endif + +/* push button 的 instanceChanged 调用计数(宿主按下按钮后应路由 + * kOfxActionInstanceChanged/UserEdited 到这里)。 */ +static int g_button_instance_changed = 0; /* ---------- suite 便捷封装 ---------- */ +/* 记录一次 push button 的 instanceChanged:计数自增,并在 + * OAK_TEST_PLUGIN_INSTANCECHANGED_MARKER 指向的文件追加一行(宿主 + * 测试断言用;未设环境变量时静默)。 */ +static void record_button_instance_changed(void) +{ + g_button_instance_changed++; + const char *marker = getenv("OAK_TEST_PLUGIN_INSTANCECHANGED_MARKER"); + if (!marker) + return; + FILE *f = fopen(marker, "a"); + if (!f) + return; + fprintf(f, "button instanceChanged count=%d\n", g_button_instance_changed); + fclose(f); +} + static OfxStatus propSetString(OfxPropertySetHandle h, const char *name, int index, const char *v) { return g_propSuite->propSetString(h, name, index, v); @@ -69,6 +119,11 @@ static OfxStatus propSetStringN(OfxPropertySetHandle h, const char *name, int co return g_propSuite->propSetStringN(h, name, count, v); } +static OfxStatus propSetPointer(OfxPropertySetHandle h, const char *name, int index, void *v) +{ + return g_propSuite->propSetPointer(h, name, index, v); +} + static OfxStatus propSetDoubleN(OfxPropertySetHandle h, const char *name, int count, const double *v) { return g_propSuite->propSetDoubleN(h, name, count, v); @@ -117,6 +172,8 @@ static void setHost(OfxHost *host) g_progressSuite = (const OfxProgressSuiteV1 *)host->fetchSuite(host->host, kOfxProgressSuite, 1); g_glSuite = (const OfxImageEffectOpenGLRenderSuiteV1 *)host->fetchSuite( host->host, kOfxOpenGLRenderSuite, 1); + g_drawSuite = (const OfxDrawSuiteV1 *)host->fetchSuite(host->host, kOfxDrawSuite, 1); + g_interactSuite = (const OfxInteractSuiteV1 *)host->fetchSuite(host->host, kOfxInteractSuite, 1); } /* ---------- action 实现 ---------- */ @@ -179,6 +236,13 @@ static OfxStatus actionDescribe(const void *handle, int is_gl) return st; propSetString(paramProps, kOfxPropLabel, 0, "Label"); + /* push button:无值,按下经 instanceChanged (UserEdited) 通知 + * 插件(ofxCore.h kOfxActionInstanceChanged)。 */ + st = g_paramSuite->paramDefine((OfxParamSetHandle)handle, kOfxParamTypePushButton, "button", ¶mProps); + if (st != kOfxStatOK) + return st; + propSetString(paramProps, kOfxPropLabel, 0, "Button"); + /* clip:Source + Output。 */ OfxPropertySetHandle clipProps = NULL; st = g_imageEffectSuite->clipDefine((OfxImageEffectHandle)handle, "Source", &clipProps); @@ -341,6 +405,17 @@ static OfxStatus actionGLDetached(OfxImageEffectHandle handle) /* GL render:经 OpenGL suite 取 Source 与 Output 纹理并上报索引 * (宿主侧测试经 message 捕获断言)。GL 未使能时回退 CPU render。 */ +static void draw_gl_test_pattern(void) +{ +#ifdef __APPLE__ + /* 把当前绑定帧缓冲(宿主为输出帧挂的 FBO)清成已知颜色。 */ + glClearColor(0.1f, 0.2f, 0.3f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); +#else + (void)0; +#endif +} + static OfxStatus actionRenderGL(OfxImageEffectHandle inst, OfxPropertySetHandle inArgs) { int gl_enabled = 0; @@ -391,6 +466,11 @@ static OfxStatus actionRenderGL(OfxImageEffectHandle inst, OfxPropertySetHandle if (st != kOfxStatOK) return st; + /* 真实绘制:把宿主已绑定的输出 FBO 清成已知颜色 + * (0.1, 0.2, 0.3, 1.0)——宿主 glReadPixels 回读后应得到该颜色, + * 这是"插件真实走 GL 路径且输出正确"的端到端证据。 */ + draw_gl_test_pattern(); + return kOfxStatOK; } @@ -432,6 +512,17 @@ static OfxStatus mainEntry(const char *action, const void *handle, OfxPropertySe if (strcmp(action, kOfxImageEffectActionRender) == 0) { return actionRender((OfxImageEffectHandle)handle, inArgs); } + if (strcmp(action, kOfxActionInstanceChanged) == 0) { + /* 宿主按下 push button:kOfxPropName == "button"、 + * kOfxPropChangeReason == kOfxChangeUserEdited。只对 button + * 计数(其余参数变更静默)。 */ + char *name = NULL; + g_propSuite->propGetString(inArgs, kOfxPropName, 0, &name); + if (name && strcmp(name, "button") == 0) { + record_button_instance_changed(); + } + return kOfxStatOK; + } return kOfxStatReplyDefault; } @@ -496,6 +587,184 @@ static OfxStatus mainEntryID(const char *action, const void *handle, return mainEntry(action, handle, inArgs, outArgs); } +/* ---------- interact 变体(M11 §5):自定义 UI 宿主验证 ---------- */ + +/* interact 调用记录:追加到 OAK_TEST_PLUGIN_INTERACT_MARKER 指向的文件 + * (宿主测试断言用;未设环境变量时静默)。 */ +static void interact_record(const char *fmt, ...) +{ + const char *marker = getenv("OAK_TEST_PLUGIN_INTERACT_MARKER"); + if (!marker) + return; + FILE *f = fopen(marker, "a"); + if (!f) + return; + va_list ap; + va_start(ap, fmt); + vfprintf(f, fmt, ap); + va_end(ap); + fclose(f); +} + +/* 记录 pen 事件的真实参数(inArgs 的 canonical/视口/压力属性)。 */ +static void record_pen(const char *action, OfxPropertySetHandle inArgs) +{ + int vp[2] = { 0, 0 }; + double canon[2] = { 0.0, 0.0 }; + double pressure = 0.0; + g_propSuite->propGetIntN(inArgs, kOfxInteractPropPenViewportPosition, 2, vp); + g_propSuite->propGetDoubleN(inArgs, kOfxInteractPropPenPosition, 2, canon); + g_propSuite->propGetDouble(inArgs, kOfxInteractPropPenPressure, 0, &pressure); + interact_record("%s vp=%d,%d canon=%g,%g pressure=%g\n", action, vp[0], vp[1], canon[0], + canon[1], pressure); +} + +/* 记录 key 事件的真实参数(keySym/keyString)。 */ +static void record_key(const char *action, OfxPropertySetHandle inArgs) +{ + int sym = 0; + char *s = NULL; + g_propSuite->propGetInt(inArgs, kOfxPropKeySym, 0, &sym); + g_propSuite->propGetString(inArgs, kOfxPropKeyString, 0, &s); + interact_record("%s sym=%d str=%s\n", action, sym, s ? s : ""); +} + +/* kOfxInteractActionDraw:读 inArgs(viewport/pixelScale/time/ + * backgroundImage/drawContext),经 Draw suite setColour + 原生 GL 画 + * 已知色块(矩形 10..30 canonical)。macOS 上真实绘制;非 macOS 跳过 + * GL。返回 OK。 */ +static OfxStatus actionInteractDraw(OfxPropertySetHandle inArgs) +{ + double vp[2] = { 0.0, 0.0 }; + double scale[2] = { 1.0, 1.0 }; + double t = 0.0; + void *bg = NULL; + void *drawCtx = NULL; + g_propSuite->propGetDoubleN(inArgs, kOfxInteractPropViewportSize, 2, vp); + g_propSuite->propGetDoubleN(inArgs, kOfxInteractPropPixelScale, 2, scale); + g_propSuite->propGetDouble(inArgs, kOfxPropTime, 0, &t); + g_propSuite->propGetPointer(inArgs, kOfxInteractPropBackgroundImage, 0, &bg); + g_propSuite->propGetPointer(inArgs, kOfxInteractPropDrawContext, 0, &drawCtx); + + /* Draw suite 状态真实读写:setColour 写、getColour 读(宿主色板)。 */ + double setcol[4] = { 0.0, 0.0, 0.0, 0.0 }; + double getcol[4] = { 0.0, 0.0, 0.0, 0.0 }; + int setcol_st = kOfxStatFailed, getcol_st = kOfxStatFailed; + if (g_drawSuite && drawCtx) { + OfxRGBAColourF col = { 0.9f, 0.1f, 0.2f, 1.0f }; + setcol_st = g_drawSuite->setColour(drawCtx, &col); + setcol[0] = col.r; + setcol[1] = col.g; + setcol[2] = col.b; + setcol[3] = col.a; + OfxRGBAColourF bgc = { 0.0f, 0.0f, 0.0f, 0.0f }; + getcol_st = g_drawSuite->getColour(drawCtx, kOfxStandardColourOverlayBackground, &bgc); + getcol[0] = bgc.r; + getcol[1] = bgc.g; + getcol[2] = bgc.b; + getcol[3] = bgc.a; + } + + int draw_st = kOfxStatFailed; +#ifdef __APPLE__ + /* 清成暗背景 + Draw suite 画实心矩形(canonical 坐标;宿主经正交 + * 投影映射到视口)。 */ + glClearColor(0.05f, 0.05f, 0.05f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + if (g_drawSuite && drawCtx) { + OfxPointD pts[2] = { { 10.0, 10.0 }, { 30.0, 30.0 } }; + draw_st = g_drawSuite->draw(drawCtx, kOfxDrawPrimitiveRectangle, pts, 2); + } +#else + (void)0; +#endif + + interact_record("draw vp=%gx%g scale=%gx%g t=%g bg=%p setcol_st=%d setcol=%g,%g,%g,%g " + "getcol_st=%d getcol=%g,%g,%g,%g draw_st=%d\n", + vp[0], vp[1], scale[0], scale[1], t, bg, setcol_st, setcol[0], setcol[1], + setcol[2], setcol[3], getcol_st, getcol[0], getcol[1], getcol[2], getcol[3], + draw_st); + return kOfxStatOK; +} + +/* interact 入口(经 kOfxImageEffectPluginPropOverlayInteractV2 声明): + * 只处理 interact 的 action;效果侧 action 一律 ReplyDefault(不会被 + * 宿主以效果 handle 调到这里)。 */ +static OfxStatus mainEntryInteract(const char *action, const void *handle, + OfxPropertySetHandle inArgs, OfxPropertySetHandle outArgs) +{ + (void)handle; + (void)outArgs; + if (strcmp(action, kOfxActionNewInteract) == 0) { + interact_record("new_interact\n"); + return kOfxStatOK; + } + if (strcmp(action, kOfxActionDescribe) == 0) { + interact_record("describe\n"); + return kOfxStatOK; + } + if (strcmp(action, kOfxActionCreateInstance) == 0) { + interact_record("create\n"); + return kOfxStatOK; + } + if (strcmp(action, kOfxActionDestroyInstance) == 0) { + interact_record("destroy\n"); + return kOfxStatOK; + } + if (strcmp(action, kOfxInteractActionDraw) == 0) { + return actionInteractDraw(inArgs); + } + if (strcmp(action, kOfxInteractActionPenMotion) == 0) { + record_pen("pen_motion", inArgs); + return kOfxStatOK; + } + if (strcmp(action, kOfxInteractActionPenDown) == 0) { + record_pen("pen_down", inArgs); + return kOfxStatOK; + } + if (strcmp(action, kOfxInteractActionPenUp) == 0) { + record_pen("pen_up", inArgs); + return kOfxStatOK; + } + if (strcmp(action, kOfxInteractActionKeyDown) == 0) { + int sym = 0; + g_propSuite->propGetInt(inArgs, kOfxPropKeySym, 0, &sym); + record_key("key_down", inArgs); + /* Escape → ReplyDefault(宿主应透传该状态:插件未处理,事件 + * 可交视图中的其他对象)。 */ + if (sym == kOfxKey_Escape) + return kOfxStatReplyDefault; + return kOfxStatOK; + } + if (strcmp(action, kOfxInteractActionKeyUp) == 0) { + record_key("key_up", inArgs); + return kOfxStatOK; + } + if (strcmp(action, kOfxInteractActionIdle) == 0) { + interact_record("idle\n"); + return kOfxStatOK; + } + /* GainFocus/LoseFocus 等:默认(ReplyDefault = 未处理,宿主可继续)。 */ + return kOfxStatReplyDefault; +} + +/* interact 变体的效果侧 main entry:describe 建参数/clip 并声明 overlay + * interact V2 入口(指向 mainEntryInteract);其余效果 action 委托 base + * mainEntry。 */ +static OfxStatus mainEntryInteractEffect(const char *action, const void *handle, + OfxPropertySetHandle inArgs, OfxPropertySetHandle outArgs) +{ + if (strcmp(action, kOfxActionDescribe) == 0 || + strcmp(action, kOfxImageEffectActionDescribeInContext) == 0) { + OfxStatus st = actionDescribe(handle, 0); + if (st != kOfxStatOK) + return st; + return propSetPointer(handle, kOfxImageEffectPluginPropOverlayInteractV2, 0, + (void *)mainEntryInteract); + } + return mainEntry(action, handle, inArgs, outArgs); +} + /* ---------- 导出 ---------- */ static const OfxPlugin test_plugin = { @@ -528,9 +797,19 @@ static const OfxPlugin test_plugin_id = { /* mainEntry */ mainEntryID, }; +static const OfxPlugin test_plugin_interact = { + /* pluginApi */ kOfxImageEffectPluginApi, + /* apiVersion */ kOfxImageEffectPluginApiVersion, + /* pluginIdentifier */ "org.oak.test-plugin.interact", + /* pluginVersionMajor */ 1, + /* pluginVersionMinor */ 0, + /* setHost */ setHost, + /* mainEntry */ mainEntryInteractEffect, +}; + OfxExport int OfxGetNumberOfPlugins(void) { - return 3; + return 4; } OfxExport OfxPlugin *OfxGetPlugin(int nth) @@ -541,5 +820,7 @@ OfxExport OfxPlugin *OfxGetPlugin(int nth) return (OfxPlugin *)&test_plugin_gl; if (nth == 2) return (OfxPlugin *)&test_plugin_id; + if (nth == 3) + return (OfxPlugin *)&test_plugin_interact; return NULL; } diff --git a/crates/oakplugin/ofx/ofxDrawSuite.h b/crates/oakplugin/ofx/ofxDrawSuite.h new file mode 100644 index 000000000..afa2c944a --- /dev/null +++ b/crates/oakplugin/ofx/ofxDrawSuite.h @@ -0,0 +1,183 @@ +#ifndef _ofxDraw_h_ +#define _ofxDraw_h_ + +#include "ofxCore.h" +#include "ofxPixels.h" + +// Copyright OpenFX and contributors to the OpenFX project. +// SPDX-License-Identifier: BSD-3-Clause + + +#ifdef __cplusplus +extern "C" { +#endif + +/** @file ofxDrawSuite.h +API for host- and GPU API-independent drawing. +@version Added in OpenFX 1.5 +*/ + + +/** @brief the string that names the DrawSuite, passed to OfxHost::fetchSuite */ +#define kOfxDrawSuite "OfxDrawSuite" + +/** @brief Blind declaration of an OFX drawing context + */ +typedef struct OfxDrawContext *OfxDrawContextHandle; + +/** @brief The Draw Context handle + + @propdef + type: pointer + dimension: 1 + */ +#define kOfxInteractPropDrawContext "OfxInteractPropDrawContext" + +/** @brief Defines valid values for OfxDrawSuiteV1::getColour */ +typedef enum OfxStandardColour +{ + kOfxStandardColourOverlayBackground, + kOfxStandardColourOverlayActive, + kOfxStandardColourOverlaySelected, + kOfxStandardColourOverlayDeselected, + kOfxStandardColourOverlayMarqueeFG, + kOfxStandardColourOverlayMarqueeBG, + kOfxStandardColourOverlayText +} OfxStandardColour; + +/** @brief Defines valid values for OfxDrawSuiteV1::setLineStipple */ +typedef enum OfxDrawLineStipplePattern +{ + kOfxDrawLineStipplePatternSolid, // ----- + kOfxDrawLineStipplePatternDot, // ..... + kOfxDrawLineStipplePatternDash, // - - - + kOfxDrawLineStipplePatternAltDash, // - - - + kOfxDrawLineStipplePatternDotDash // .-.-.- +} OfxDrawLineStipplePattern; + +/** @brief Defines valid values for OfxDrawSuiteV1::draw */ + +typedef enum OfxDrawPrimitive +{ + kOfxDrawPrimitiveLines, + kOfxDrawPrimitiveLineStrip, + kOfxDrawPrimitiveLineLoop, + kOfxDrawPrimitiveRectangle, + kOfxDrawPrimitivePolygon, + kOfxDrawPrimitiveEllipse +} OfxDrawPrimitive; + +/** @brief Defines text alignment values for OfxDrawSuiteV1::drawText */ +typedef enum OfxDrawTextAlignment +{ + kOfxDrawTextAlignmentLeft = 0x0001, + kOfxDrawTextAlignmentRight = 0x0002, + kOfxDrawTextAlignmentTop = 0x0004, + kOfxDrawTextAlignmentBottom = 0x0008, + kOfxDrawTextAlignmentBaseline = 0x0010, + kOfxDrawTextAlignmentCenterH = (kOfxDrawTextAlignmentLeft | kOfxDrawTextAlignmentRight), + kOfxDrawTextAlignmentCenterV = (kOfxDrawTextAlignmentTop | kOfxDrawTextAlignmentBaseline) +} OfxDrawTextAlignment; + +/** @brief OFX suite that allows an effect to draw to a host-defined display context. + To use this, the plugin must use kOfxImageEffectPluginPropOverlayInteractV2. +*/ +typedef struct OfxDrawSuiteV1 { + /** @brief Retrieves the host's desired draw colour for + + \arg \c context draw context + \arg \c std_colour desired colour type + \arg \c colour returned RGBA colour + + @returns + - ::kOfxStatOK - the colour was returned + - ::kOfxStatErrValue - std_colour was invalid + - ::kOfxStatFailed - failure, e.g. if function is called outside kOfxInteractActionDraw + */ + OfxStatus (*getColour)(OfxDrawContextHandle context, OfxStandardColour std_colour, OfxRGBAColourF *colour); + + /** @brief Sets the colour for future drawing operations (lines, filled shapes and text) + + \arg \c context draw context + \arg \c colour RGBA colour + + The host should use "over" compositing when using a non-opaque colour. + + @returns + - ::kOfxStatOK - the colour was changed + - ::kOfxStatFailed - failure, e.g. if function is called outside kOfxInteractActionDraw + */ + OfxStatus (*setColour)(OfxDrawContextHandle context, const OfxRGBAColourF *colour); + + /** @brief Sets the line width for future line drawing operations + + \arg \c context draw context + \arg \c width line width + + Use width 0 for a single pixel line or non-zero for a smooth line of the desired width + + The host should adjust for screen density. + + @returns + - ::kOfxStatOK - the width was changed + - ::kOfxStatFailed - failure, e.g. if function is called outside kOfxInteractActionDraw + */ + OfxStatus (*setLineWidth)(OfxDrawContextHandle context, float width); + + /** @brief Sets the stipple pattern for future line drawing operations + + \arg \c context draw context + \arg \c pattern desired stipple pattern + + @returns + - ::kOfxStatOK - the pattern was changed + - ::kOfxStatErrValue - pattern was not valid + - ::kOfxStatFailed - failure, e.g. if function is called outside kOfxInteractActionDraw + */ + OfxStatus (*setLineStipple)(OfxDrawContextHandle context, OfxDrawLineStipplePattern pattern); + + /** @brief Draws a primitive of the desired type + + \arg \c context draw context + \arg \c primitive desired primitive + \arg \c points array of points in the primitive + \arg \c point_count number of points in the array + + kOfxDrawPrimitiveLines - like GL_LINES, n points draws n/2 separated lines + kOfxDrawPrimitiveLineStrip - like GL_LINE_STRIP, n points draws n-1 connected lines + kOfxDrawPrimitiveLineLoop - like GL_LINE_LOOP, n points draws n connected lines + kOfxDrawPrimitiveRectangle - draws an axis-aligned filled rectangle defined by 2 opposite corner points + kOfxDrawPrimitivePolygon - like GL_POLYGON, draws a filled n-sided polygon + kOfxDrawPrimitiveEllipse - draws a axis-aligned elliptical line (not filled) within the rectangle defined by 2 opposite corner points + + @returns + - ::kOfxStatOK - the draw was completed + - ::kOfxStatErrValue - invalid primitive, or point_count not valid for primitive + - ::kOfxStatFailed - failure, e.g. if function is called outside kOfxInteractActionDraw + */ + OfxStatus (*draw)(OfxDrawContextHandle context, OfxDrawPrimitive primitive, const OfxPointD *points, int point_count); + + + /** @brief Draws text at the specified position + + \arg \c context draw context + \arg \c text text to draw (UTF-8 encoded) + \arg \c pos position at which to align the text + \arg \c alignment text alignment flags (see kOfxDrawTextAlignment*) + + The text font face and size are determined by the host. + + @returns + - ::kOfxStatOK - the text was drawn + - ::kOfxStatErrValue - text or pos were not defined + - ::kOfxStatFailed - failure, e.g. if function is called outside kOfxInteractActionDraw + */ + OfxStatus (*drawText)(OfxDrawContextHandle context, const char *text, const OfxPointD *pos, int alignment); + +} OfxDrawSuiteV1; + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/crates/oakplugin/ofx/ofxKeySyms.h b/crates/oakplugin/ofx/ofxKeySyms.h new file mode 100644 index 000000000..fce6e8f49 --- /dev/null +++ b/crates/oakplugin/ofx/ofxKeySyms.h @@ -0,0 +1,529 @@ +#ifndef _ofxKeySyms_h_ +#define _ofxKeySyms_h_ + +// Copyright OpenFX and contributors to the OpenFX project. +// SPDX-License-Identifier: BSD-3-Clause + + +/** + \addtogroup PropertiesGeneral +*/ +/*@{*/ + +/** @brief Property used to indicate which a key on the keyboard or a button on a button device has been pressed + +This property represents a raw key press, it does not represent the 'character value' of the key. + +This property is associated with a ::kOfxPropKeyString property, which encodes the UTF8 +value for the keypress/button press. Some keys (for example arrow keys) have no UTF8 equivalent. + +Some keys, especially on non-english language systems, may have a UTF8 value, but \em not a keysym values, in these +cases, the keysym will have a value of kOfxKey_Unknown, but the ::kOfxPropKeyString property will still be set with +the UTF8 value. + + - Valid Values - one of any specified by #defines in the file ofxKeySyms.h. + @propdef + type: int + dimension: 1 + cname: kOfxPropKeySym + */ +#define kOfxPropKeySym "kOfxPropKeySym" + +/** @brief This property encodes a single keypresses that generates a unicode code point. The value is stored as a UTF8 string. + +This property represents the UTF8 encode value of a single key press by a user in an OFX interact. + +This property is associated with a ::kOfxPropKeySym which represents an integer value for the key press. Some keys (for example arrow keys) have no UTF8 equivalent, +in which case this is set to the empty string "", and the associate ::kOfxPropKeySym is set to the equivalent raw key press. + +Some keys, especially on non-english language systems, may have a UTF8 value, but \em not a keysym values, in these +cases, the keysym will have a value of kOfxKey_Unknown, but the ::kOfxPropKeyString property will still be set with +the UTF8 value. + + - Valid Values - a UTF8 string representing a single character, or the empty string. + @propdef + type: string + dimension: 1 + cname: kOfxPropKeyString +*/ +#define kOfxPropKeyString "kOfxPropKeyString" + +/* + The keysyms below have been lifted wholesale out of the X-Windows key symbol header file. Only + the names have been changed to protect the innocent. +*/ + +/*@}*/ + +/* +Copyright (c) 1987, 1994 X Consortium + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of the X Consortium shall +not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization +from the X Consortium. + + +Copyright 1987 by Digital Equipment Corporation, Maynard, Massachusetts + + All Rights Reserved + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Digital not be +used in advertising or publicity pertaining to distribution of the +software without specific, written prior permission. + +DIGITAL DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING +ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL +DIGITAL BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR +ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + +**/ + +/** + \defgroup KeySyms OFX Key symbols + + These keysymbols are used as values by the ::kOfxPropKeySym property to indicate the value +of a key that has been pressed. A corresponding ::kOfxPropKeyString property is also set to +contain the unicode value of the key (if it has one). + + The special keysym ::kOfxKey_Unknown is used to set the ::kOfxPropKeySym property in cases +where the key has a UTF8 value which is not supported by the symbols below. + +*/ +/*@{*/ + +#define kOfxKey_Unknown 0x0 + +/* + * TTY Functions, cleverly chosen to map to ascii, for convenience of + * programming, but could have been arbitrary (at the cost of lookup + * tables in client code. + */ +#define kOfxKey_BackSpace 0xFF08 /* back space, back char */ +#define kOfxKey_Tab 0xFF09 +#define kOfxKey_Linefeed 0xFF0A /* Linefeed, LF */ +#define kOfxKey_Clear 0xFF0B +#define kOfxKey_Return 0xFF0D /* Return, enter */ +#define kOfxKey_Pause 0xFF13 /* Pause, hold */ +#define kOfxKey_Scroll_Lock 0xFF14 +#define kOfxKey_Sys_Req 0xFF15 +#define kOfxKey_Escape 0xFF1B +#define kOfxKey_Delete 0xFFFF /* Delete, rubout */ + + + +/* International & multi-key character composition */ + +#define kOfxKey_Multi_key 0xFF20 /* Multi-key character compose */ +#define kOfxKey_SingleCandidate 0xFF3C +#define kOfxKey_MultipleCandidate 0xFF3D +#define kOfxKey_PreviousCandidate 0xFF3E + +/* Japanese keyboard support */ + +#define kOfxKey_Kanji 0xFF21 /* Kanji, Kanji convert */ +#define kOfxKey_Muhenkan 0xFF22 /* Cancel Conversion */ +#define kOfxKey_Henkan_Mode 0xFF23 /* Start/Stop Conversion */ +#define kOfxKey_Henkan 0xFF23 /* Alias for Henkan_Mode */ +#define kOfxKey_Romaji 0xFF24 /* to Romaji */ +#define kOfxKey_Hiragana 0xFF25 /* to Hiragana */ +#define kOfxKey_Katakana 0xFF26 /* to Katakana */ +#define kOfxKey_Hiragana_Katakana 0xFF27 /* Hiragana/Katakana toggle */ +#define kOfxKey_Zenkaku 0xFF28 /* to Zenkaku */ +#define kOfxKey_Hankaku 0xFF29 /* to Hankaku */ +#define kOfxKey_Zenkaku_Hankaku 0xFF2A /* Zenkaku/Hankaku toggle */ +#define kOfxKey_Touroku 0xFF2B /* Add to Dictionary */ +#define kOfxKey_Massyo 0xFF2C /* Delete from Dictionary */ +#define kOfxKey_Kana_Lock 0xFF2D /* Kana Lock */ +#define kOfxKey_Kana_Shift 0xFF2E /* Kana Shift */ +#define kOfxKey_Eisu_Shift 0xFF2F /* Alphanumeric Shift */ +#define kOfxKey_Eisu_toggle 0xFF30 /* Alphanumeric toggle */ +#define kOfxKey_Zen_Koho 0xFF3D /* Multiple/All Candidate(s) */ +#define kOfxKey_Mae_Koho 0xFF3E /* Previous Candidate */ + +/* Cursor control & motion */ + +#define kOfxKey_Home 0xFF50 +#define kOfxKey_Left 0xFF51 /* Move left, left arrow */ +#define kOfxKey_Up 0xFF52 /* Move up, up arrow */ +#define kOfxKey_Right 0xFF53 /* Move right, right arrow */ +#define kOfxKey_Down 0xFF54 /* Move down, down arrow */ +#define kOfxKey_Prior 0xFF55 /* Prior, previous */ +#define kOfxKey_Page_Up 0xFF55 +#define kOfxKey_Next 0xFF56 /* Next */ +#define kOfxKey_Page_Down 0xFF56 +#define kOfxKey_End 0xFF57 /* EOL */ +#define kOfxKey_Begin 0xFF58 /* BOL */ + + +/* Misc Functions */ + +#define kOfxKey_Select 0xFF60 /* Select, mark */ +#define kOfxKey_Print 0xFF61 +#define kOfxKey_Execute 0xFF62 /* Execute, run, do */ +#define kOfxKey_Insert 0xFF63 /* Insert, insert here */ +#define kOfxKey_Undo 0xFF65 /* Undo, oops */ +#define kOfxKey_Redo 0xFF66 /* redo, again */ +#define kOfxKey_Menu 0xFF67 +#define kOfxKey_Find 0xFF68 /* Find, search */ +#define kOfxKey_Cancel 0xFF69 /* Cancel, stop, abort, exit */ +#define kOfxKey_Help 0xFF6A /* Help */ +#define kOfxKey_Break 0xFF6B +#define kOfxKey_Mode_switch 0xFF7E /* Character set switch */ +#define kOfxKey_script_switch 0xFF7E /* Alias for mode_switch */ +#define kOfxKey_Num_Lock 0xFF7F + +/* Keypad Functions, keypad numbers cleverly chosen to map to ascii */ + +#define kOfxKey_KP_Space 0xFF80 /* space */ +#define kOfxKey_KP_Tab 0xFF89 +#define kOfxKey_KP_Enter 0xFF8D /* enter */ +#define kOfxKey_KP_F1 0xFF91 /* PF1, KP_A, ... */ +#define kOfxKey_KP_F2 0xFF92 +#define kOfxKey_KP_F3 0xFF93 +#define kOfxKey_KP_F4 0xFF94 +#define kOfxKey_KP_Home 0xFF95 +#define kOfxKey_KP_Left 0xFF96 +#define kOfxKey_KP_Up 0xFF97 +#define kOfxKey_KP_Right 0xFF98 +#define kOfxKey_KP_Down 0xFF99 +#define kOfxKey_KP_Prior 0xFF9A +#define kOfxKey_KP_Page_Up 0xFF9A +#define kOfxKey_KP_Next 0xFF9B +#define kOfxKey_KP_Page_Down 0xFF9B +#define kOfxKey_KP_End 0xFF9C +#define kOfxKey_KP_Begin 0xFF9D +#define kOfxKey_KP_Insert 0xFF9E +#define kOfxKey_KP_Delete 0xFF9F +#define kOfxKey_KP_Equal 0xFFBD /* equals */ +#define kOfxKey_KP_Multiply 0xFFAA +#define kOfxKey_KP_Add 0xFFAB +#define kOfxKey_KP_Separator 0xFFAC /* separator, often comma */ +#define kOfxKey_KP_Subtract 0xFFAD +#define kOfxKey_KP_Decimal 0xFFAE +#define kOfxKey_KP_Divide 0xFFAF + +#define kOfxKey_KP_0 0xFFB0 +#define kOfxKey_KP_1 0xFFB1 +#define kOfxKey_KP_2 0xFFB2 +#define kOfxKey_KP_3 0xFFB3 +#define kOfxKey_KP_4 0xFFB4 +#define kOfxKey_KP_5 0xFFB5 +#define kOfxKey_KP_6 0xFFB6 +#define kOfxKey_KP_7 0xFFB7 +#define kOfxKey_KP_8 0xFFB8 +#define kOfxKey_KP_9 0xFFB9 + + + +/* + * Auxiliary Functions; note the duplicate definitions for left and right + * function keys; Sun keyboards and a few other manufactures have such + * function key groups on the left and/or right sides of the keyboard. + * We've not found a keyboard with more than 35 function keys total. + */ + +#define kOfxKey_F1 0xFFBE +#define kOfxKey_F2 0xFFBF +#define kOfxKey_F3 0xFFC0 +#define kOfxKey_F4 0xFFC1 +#define kOfxKey_F5 0xFFC2 +#define kOfxKey_F6 0xFFC3 +#define kOfxKey_F7 0xFFC4 +#define kOfxKey_F8 0xFFC5 +#define kOfxKey_F9 0xFFC6 +#define kOfxKey_F10 0xFFC7 +#define kOfxKey_F11 0xFFC8 +#define kOfxKey_L1 0xFFC8 +#define kOfxKey_F12 0xFFC9 +#define kOfxKey_L2 0xFFC9 +#define kOfxKey_F13 0xFFCA +#define kOfxKey_L3 0xFFCA +#define kOfxKey_F14 0xFFCB +#define kOfxKey_L4 0xFFCB +#define kOfxKey_F15 0xFFCC +#define kOfxKey_L5 0xFFCC +#define kOfxKey_F16 0xFFCD +#define kOfxKey_L6 0xFFCD +#define kOfxKey_F17 0xFFCE +#define kOfxKey_L7 0xFFCE +#define kOfxKey_F18 0xFFCF +#define kOfxKey_L8 0xFFCF +#define kOfxKey_F19 0xFFD0 +#define kOfxKey_L9 0xFFD0 +#define kOfxKey_F20 0xFFD1 +#define kOfxKey_L10 0xFFD1 +#define kOfxKey_F21 0xFFD2 +#define kOfxKey_R1 0xFFD2 +#define kOfxKey_F22 0xFFD3 +#define kOfxKey_R2 0xFFD3 +#define kOfxKey_F23 0xFFD4 +#define kOfxKey_R3 0xFFD4 +#define kOfxKey_F24 0xFFD5 +#define kOfxKey_R4 0xFFD5 +#define kOfxKey_F25 0xFFD6 +#define kOfxKey_R5 0xFFD6 +#define kOfxKey_F26 0xFFD7 +#define kOfxKey_R6 0xFFD7 +#define kOfxKey_F27 0xFFD8 +#define kOfxKey_R7 0xFFD8 +#define kOfxKey_F28 0xFFD9 +#define kOfxKey_R8 0xFFD9 +#define kOfxKey_F29 0xFFDA +#define kOfxKey_R9 0xFFDA +#define kOfxKey_F30 0xFFDB +#define kOfxKey_R10 0xFFDB +#define kOfxKey_F31 0xFFDC +#define kOfxKey_R11 0xFFDC +#define kOfxKey_F32 0xFFDD +#define kOfxKey_R12 0xFFDD +#define kOfxKey_F33 0xFFDE +#define kOfxKey_R13 0xFFDE +#define kOfxKey_F34 0xFFDF +#define kOfxKey_R14 0xFFDF +#define kOfxKey_F35 0xFFE0 +#define kOfxKey_R15 0xFFE0 + +/* Modifiers */ + +#define kOfxKey_Shift_L 0xFFE1 /* Left shift */ +#define kOfxKey_Shift_R 0xFFE2 /* Right shift */ +#define kOfxKey_Control_L 0xFFE3 /* Left control */ +#define kOfxKey_Control_R 0xFFE4 /* Right control */ +#define kOfxKey_Caps_Lock 0xFFE5 /* Caps lock */ +#define kOfxKey_Shift_Lock 0xFFE6 /* Shift lock */ + +#define kOfxKey_Meta_L 0xFFE7 /* Left meta */ +#define kOfxKey_Meta_R 0xFFE8 /* Right meta */ +#define kOfxKey_Alt_L 0xFFE9 /* Left alt */ +#define kOfxKey_Alt_R 0xFFEA /* Right alt */ +#define kOfxKey_Super_L 0xFFEB /* Left super */ +#define kOfxKey_Super_R 0xFFEC /* Right super */ +#define kOfxKey_Hyper_L 0xFFED /* Left hyper */ +#define kOfxKey_Hyper_R 0xFFEE /* Right hyper */ + +#define kOfxKey_space 0x020 +#define kOfxKey_exclam 0x021 +#define kOfxKey_quotedbl 0x022 +#define kOfxKey_numbersign 0x023 +#define kOfxKey_dollar 0x024 +#define kOfxKey_percent 0x025 +#define kOfxKey_ampersand 0x026 +#define kOfxKey_apostrophe 0x027 +#define kOfxKey_quoteright 0x027 /* deprecated */ +#define kOfxKey_parenleft 0x028 +#define kOfxKey_parenright 0x029 +#define kOfxKey_asterisk 0x02a +#define kOfxKey_plus 0x02b +#define kOfxKey_comma 0x02c +#define kOfxKey_minus 0x02d +#define kOfxKey_period 0x02e +#define kOfxKey_slash 0x02f +#define kOfxKey_0 0x030 +#define kOfxKey_1 0x031 +#define kOfxKey_2 0x032 +#define kOfxKey_3 0x033 +#define kOfxKey_4 0x034 +#define kOfxKey_5 0x035 +#define kOfxKey_6 0x036 +#define kOfxKey_7 0x037 +#define kOfxKey_8 0x038 +#define kOfxKey_9 0x039 +#define kOfxKey_colon 0x03a +#define kOfxKey_semicolon 0x03b +#define kOfxKey_less 0x03c +#define kOfxKey_equal 0x03d +#define kOfxKey_greater 0x03e +#define kOfxKey_question 0x03f +#define kOfxKey_at 0x040 +#define kOfxKey_A 0x041 +#define kOfxKey_B 0x042 +#define kOfxKey_C 0x043 +#define kOfxKey_D 0x044 +#define kOfxKey_E 0x045 +#define kOfxKey_F 0x046 +#define kOfxKey_G 0x047 +#define kOfxKey_H 0x048 +#define kOfxKey_I 0x049 +#define kOfxKey_J 0x04a +#define kOfxKey_K 0x04b +#define kOfxKey_L 0x04c +#define kOfxKey_M 0x04d +#define kOfxKey_N 0x04e +#define kOfxKey_O 0x04f +#define kOfxKey_P 0x050 +#define kOfxKey_Q 0x051 +#define kOfxKey_R 0x052 +#define kOfxKey_S 0x053 +#define kOfxKey_T 0x054 +#define kOfxKey_U 0x055 +#define kOfxKey_V 0x056 +#define kOfxKey_W 0x057 +#define kOfxKey_X 0x058 +#define kOfxKey_Y 0x059 +#define kOfxKey_Z 0x05a +#define kOfxKey_bracketleft 0x05b +#define kOfxKey_backslash 0x05c +#define kOfxKey_bracketright 0x05d +#define kOfxKey_asciicircum 0x05e +#define kOfxKey_underscore 0x05f +#define kOfxKey_grave 0x060 +#define kOfxKey_quoteleft 0x060 /* deprecated */ +#define kOfxKey_a 0x061 +#define kOfxKey_b 0x062 +#define kOfxKey_c 0x063 +#define kOfxKey_d 0x064 +#define kOfxKey_e 0x065 +#define kOfxKey_f 0x066 +#define kOfxKey_g 0x067 +#define kOfxKey_h 0x068 +#define kOfxKey_i 0x069 +#define kOfxKey_j 0x06a +#define kOfxKey_k 0x06b +#define kOfxKey_l 0x06c +#define kOfxKey_m 0x06d +#define kOfxKey_n 0x06e +#define kOfxKey_o 0x06f +#define kOfxKey_p 0x070 +#define kOfxKey_q 0x071 +#define kOfxKey_r 0x072 +#define kOfxKey_s 0x073 +#define kOfxKey_t 0x074 +#define kOfxKey_u 0x075 +#define kOfxKey_v 0x076 +#define kOfxKey_w 0x077 +#define kOfxKey_x 0x078 +#define kOfxKey_y 0x079 +#define kOfxKey_z 0x07a +#define kOfxKey_braceleft 0x07b +#define kOfxKey_bar 0x07c +#define kOfxKey_braceright 0x07d +#define kOfxKey_asciitilde 0x07e + +#define kOfxKey_nobreakspace 0x0a0 +#define kOfxKey_exclamdown 0x0a1 +#define kOfxKey_cent 0x0a2 +#define kOfxKey_sterling 0x0a3 +#define kOfxKey_currency 0x0a4 +#define kOfxKey_yen 0x0a5 +#define kOfxKey_brokenbar 0x0a6 +#define kOfxKey_section 0x0a7 +#define kOfxKey_diaeresis 0x0a8 +#define kOfxKey_copyright 0x0a9 +#define kOfxKey_ordfeminine 0x0aa +#define kOfxKey_guillemotleft 0x0ab /* left angle quotation mark */ +#define kOfxKey_notsign 0x0ac +#define kOfxKey_hyphen 0x0ad +#define kOfxKey_registered 0x0ae +#define kOfxKey_macron 0x0af +#define kOfxKey_degree 0x0b0 +#define kOfxKey_plusminus 0x0b1 +#define kOfxKey_twosuperior 0x0b2 +#define kOfxKey_threesuperior 0x0b3 +#define kOfxKey_acute 0x0b4 +#define kOfxKey_mu 0x0b5 +#define kOfxKey_paragraph 0x0b6 +#define kOfxKey_periodcentered 0x0b7 +#define kOfxKey_cedilla 0x0b8 +#define kOfxKey_onesuperior 0x0b9 +#define kOfxKey_masculine 0x0ba +#define kOfxKey_guillemotright 0x0bb /* right angle quotation mark */ +#define kOfxKey_onequarter 0x0bc +#define kOfxKey_onehalf 0x0bd +#define kOfxKey_threequarters 0x0be +#define kOfxKey_questiondown 0x0bf +#define kOfxKey_Agrave 0x0c0 +#define kOfxKey_Aacute 0x0c1 +#define kOfxKey_Acircumflex 0x0c2 +#define kOfxKey_Atilde 0x0c3 +#define kOfxKey_Adiaeresis 0x0c4 +#define kOfxKey_Aring 0x0c5 +#define kOfxKey_AE 0x0c6 +#define kOfxKey_Ccedilla 0x0c7 +#define kOfxKey_Egrave 0x0c8 +#define kOfxKey_Eacute 0x0c9 +#define kOfxKey_Ecircumflex 0x0ca +#define kOfxKey_Ediaeresis 0x0cb +#define kOfxKey_Igrave 0x0cc +#define kOfxKey_Iacute 0x0cd +#define kOfxKey_Icircumflex 0x0ce +#define kOfxKey_Idiaeresis 0x0cf +#define kOfxKey_ETH 0x0d0 +#define kOfxKey_Eth 0x0d0 /* deprecated */ +#define kOfxKey_Ntilde 0x0d1 +#define kOfxKey_Ograve 0x0d2 +#define kOfxKey_Oacute 0x0d3 +#define kOfxKey_Ocircumflex 0x0d4 +#define kOfxKey_Otilde 0x0d5 +#define kOfxKey_Odiaeresis 0x0d6 +#define kOfxKey_multiply 0x0d7 +#define kOfxKey_Ooblique 0x0d8 +#define kOfxKey_Ugrave 0x0d9 +#define kOfxKey_Uacute 0x0da +#define kOfxKey_Ucircumflex 0x0db +#define kOfxKey_Udiaeresis 0x0dc +#define kOfxKey_Yacute 0x0dd +#define kOfxKey_THORN 0x0de +#define kOfxKey_ssharp 0x0df +#define kOfxKey_agrave 0x0e0 +#define kOfxKey_aacute 0x0e1 +#define kOfxKey_acircumflex 0x0e2 +#define kOfxKey_atilde 0x0e3 +#define kOfxKey_adiaeresis 0x0e4 +#define kOfxKey_aring 0x0e5 +#define kOfxKey_ae 0x0e6 +#define kOfxKey_ccedilla 0x0e7 +#define kOfxKey_egrave 0x0e8 +#define kOfxKey_eacute 0x0e9 +#define kOfxKey_ecircumflex 0x0ea +#define kOfxKey_ediaeresis 0x0eb +#define kOfxKey_igrave 0x0ec +#define kOfxKey_iacute 0x0ed +#define kOfxKey_icircumflex 0x0ee +#define kOfxKey_idiaeresis 0x0ef +#define kOfxKey_eth 0x0f0 +#define kOfxKey_ntilde 0x0f1 +#define kOfxKey_ograve 0x0f2 +#define kOfxKey_oacute 0x0f3 +#define kOfxKey_ocircumflex 0x0f4 +#define kOfxKey_otilde 0x0f5 +#define kOfxKey_odiaeresis 0x0f6 +#define kOfxKey_division 0x0f7 +#define kOfxKey_oslash 0x0f8 +#define kOfxKey_ugrave 0x0f9 +#define kOfxKey_uacute 0x0fa +#define kOfxKey_ucircumflex 0x0fb +#define kOfxKey_udiaeresis 0x0fc +#define kOfxKey_yacute 0x0fd +#define kOfxKey_thorn 0x0fe +#define kOfxKey_ydiaeresis 0x0ff + +/*@}*/ + +#endif diff --git a/crates/oakplugin/ofx/ofxPixels.h b/crates/oakplugin/ofx/ofxPixels.h new file mode 100644 index 000000000..4de4f8d61 --- /dev/null +++ b/crates/oakplugin/ofx/ofxPixels.h @@ -0,0 +1,62 @@ +#ifndef _ofxPixels_h_ +#define _ofxPixels_h_ + +// Copyright OpenFX and contributors to the OpenFX project. +// SPDX-License-Identifier: BSD-3-Clause + + +#ifdef __cplusplus +extern "C" { +#endif + +/** @file ofxPixels.h +Contains pixel struct definitions +*/ + +/** @brief Defines an 8 bit per component RGBA pixel */ +typedef struct OfxRGBAColourB { + unsigned char r, g, b, a; +}OfxRGBAColourB; + +/** @brief Defines a 16 bit per component RGBA pixel */ +typedef struct OfxRGBAColourS { + unsigned short r, g, b, a; +}OfxRGBAColourS; + +/** @brief Defines a floating point component RGBA pixel */ +typedef struct OfxRGBAColourF { + float r, g, b, a; +}OfxRGBAColourF; + + +/** @brief Defines a double precision floating point component RGBA pixel */ +typedef struct OfxRGBAColourD { + double r, g, b, a; +}OfxRGBAColourD; + + +/** @brief Defines an 8 bit per component RGB pixel */ +typedef struct OfxRGBColourB { + unsigned char r, g, b; +}OfxRGBColourB; + +/** @brief Defines a 16 bit per component RGB pixel */ +typedef struct OfxRGBColourS { + unsigned short r, g, b; +}OfxRGBColourS; + +/** @brief Defines a floating point component RGB pixel */ +typedef struct OfxRGBColourF { + float r, g, b; +}OfxRGBColourF; + +/** @brief Defines a double precision floating point component RGB pixel */ +typedef struct OfxRGBColourD { + double r, g, b; +}OfxRGBColourD; + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/crates/oakplugin/src/gl_bridge.rs b/crates/oakplugin/src/gl_bridge.rs index 659c2d0bf..9fea73b51 100644 --- a/crates/oakplugin/src/gl_bridge.rs +++ b/crates/oakplugin/src/gl_bridge.rs @@ -14,55 +14,983 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! GL 互操作桥(阶段 6a spike 结论;实现未落地)。 +//! GL 互操作桥(方案 B 落地:离屏 GL 上下文 + 纹理/FBO + 回读)。 //! -//! ## 背景 +//! ## 背景(阶段 6a spike 结论,已落地) //! //! OFX 的 `OpenGLRender` 扩展要求宿主把 clip 纹理以 **GL 纹理名** -//! (`kOfxImageEffectPropOpenGLTextureIndex`)递给插件,插件直接画 -//! 进输出纹理。oak 的渲染后端是 wgpu,[`crate::render::texture_id`] -//! 因此是恒 0 的桩:render_driver 的 `use_opengl` 决策据它恒回退 -//! CPU 路径(GL 插件经 CPU render action 仍可工作)。本模块记录把 -//! 桩替换为真实 GL 互操作的评估。 +//! (`kOfxImageEffectPropOpenGLTextureIndex`)递给插件,插件直接画进 +//! 输出纹理。oak 的渲染后端是 wgpu(macOS 为 Metal),Metal 与 GL +//! 纹理名之间没有共享命名空间(方案 A 不可行,详见 0.1 版的 spike +//! 文档)。本模块实现**方案 B**: //! -//! ## 方案 A:wgpu-hal GL 互操作 —— 不可行(macOS) +//! ```text +//! 宿主自建原生离屏 GL 上下文(macOS = CGL) +//! → 为每帧建输出 GL 纹理 + FBO(插件 render 画进附着纹理) +//! → 插件经 GL suite 取到真实纹理名(OpenGLTextureIndex) +//! → render 返回后 glReadPixels 回读为 CPU 帧 +//! → 按目标帧格式转换(F32/RGBA8)装帧 +//! ``` //! -//! wgpu-hal 的 GL 互操作面(`hal::api::Gles` 的 adapter/texture -//! 互转)只在实例本身就是 GLES 后端时存在。oak 在 macOS 上的 wgpu -//! 实例是 **Metal**(wgpu 支持矩阵中 macOS/iOS 仅 Metal 为一等后 -//! 端;OpenGL 需 ANGLE 转译层且仅 GLES 3.0,见 -//! 的 Supported Platforms 表与 -//! CHANGELOG #4185"GLES backend optional on macOS")。Metal 后端与 -//! GL 纹理名之间没有共享命名空间,wgpu 也不暴露跨后端纹理导入。 -//! 即便为插件强行把整条管线切到 wgpu GLES 后端,也是以全局渲染性 -//! 能换单一插件路径,方向错误。**结论:放弃。** +//! 渲染跑在 oak-worker 进程内(M15 进程隔离),GL 上下文在 worker +//! 进程内创建即可;主进程无需 GL。 //! -//! ## 方案 B:独立离屏 GL 上下文 + 回读 + wgpu 上传 —— 技术可行, -//! 暂缓 +//! ## 上下文生命周期与线程模型 //! -//! 路径:宿主自建原生 GL 上下文(macOS 为 CGL;OpenGL 自 10.14 起 -//! 弃用但仍可用),与插件共享纹理命名空间(CGL share group)→ 插件 -//! render 画进 FBO 附着纹理 → `glReadPixels`/PBO 回读为 CPU 帧 → -//! 经 [`oakrender::backend::GpuContextLike::upload`] 上传成 wgpu -//! 纹理。接线点已就位: +//! - **进程级共享单例**:`OnceLock` 惰性创建(`imp::context`),一次 +//! 创建、整个进程共用(CGL 上下文可在任意线程置为 current,但不能 +//! 同时)。 +//! - **串行化**:[`acquire`] 取全局 `Mutex` 并把上下文置为当前线程 +//! current;返回 [`GlGuard`],drop 时清 current 并放锁。OFX 插件 +//! 渲染已由实例锁(`Instance::render_lock`)串行化,但不同实例可在 +//! 不同线程渲染——GL 侧必须再串行一次,保证同一时刻只有一条线程 +//! 持有 current 上下文。 +//! - **current 的跨调用保持**:ofxGPURender.h 要求宿主在 Render/ +//! Begin/EndSequenceRender 期间让上下文保持 current(插件在 suite +//! 调用之间直接发 GL 命令)。因此 render 驱动**一次** acquire 整个 +//! render action(含 suite 回调),[`GlGuard`] 跨整个 action 持有; +//! 本模块其余函数假定上下文已 current(guard 已持有),不再加锁。 +//! - **同线程可重入**:[`acquire`] 在同一线程已持外层 guard 时只递增 +//! 线程局部嵌套深度(上下文已 current,不重取锁);最外层 guard +//! drop 时才清 current 放锁。interact draw 的宿主测试需要"一次 +//! acquire 覆盖 FBO 装配 → 插件绘制 → 回读"而 `Interact::draw` +//! 内部又要自行 acquire(宿主侧每次 action 前都要保证 current)—— +//! 嵌套 acquire 免去死锁。 +//! - **清理**:进程退出时 `OnceLock` 里的 [`GlContext`] drop,销毁 +//! CGL 上下文与像素格式。 //! -//! 1. [`crate::render::texture_id`] 返回真实 GL 名(当前恒 0); -//! 2. [`crate::render_driver::render_frame`] 的 `use_opengl` 分支 -//! (`plugin_supports_opengl && depth_ok && dst_id != 0`)随之 -//! 生效,走 [`crate::instance::Instance::render_gl`]; -//! 3. GL suite([`crate::suites::gl_render`])的纹理索引属性写出真 -//! 值。 +//! ## 平台 //! -//! 暂缓理由: -//! - 需引入新依赖(`glow` + CGL 绑定)与上下文生命周期/线程模型 -//! 管理(OFX 插件可在自起线程回调 suite); -//! - 每帧同步回读是一次 GPU stall,性能上只在"插件本来就是 GL 加 -//! 速"时划算,而当前无任何真实 GL OFX 插件可验证(测试 `.gl` -//! 变体只验证 suite 调用面); -//! - CPU 回退路径完整可用,GL 插件经 render action 正确出帧。 +//! - **macOS(真实实现)**:Core OpenGL(`OpenGL.framework`, +//! `#[link(name = "OpenGL", kind = "framework")]`,无需新 crate)。 +//! 像素格式:3.2 core profile、offline renderer 允许(无 GPU 的无头 +//! 环境可回退软件渲染)、24 位颜色 + 8 位 alpha。离屏渲染(FBO) +//! 无需 drawable,不 swap buffer。 +//! - **Linux(预留 EGL)**:`cfg(target_os = "linux")` 走 stub,全部 +//! 返回失败/`false`(use_opengl 决策据此回退 CPU)。接线点: +//! `imp` 换成 EGL(`eglGetDisplay/eglCreateContext` + GLES3), +//! FFI 同构,GL 命令一致。 +//! - **Windows(预留 WGL)**:同理,stub。接线点:`wglCreateContext` +//! + `wglMakeCurrent`,FFI 同构。 //! -//! ## TODO(phase-GL) +//! ## 回读格式转换(真实正确) //! -//! 实现方案 B:CGL 上下文工厂 + share group、`texture_id` 真值化、 -//! 回读→上传流水线,以及一个真实 GL 插件的端到端验证(golden 帧比 -//! 较)。在此之前,[`crate::render::texture_id`] 保持恒 0 桩。 +//! 输出 GL 纹理的内部格式按目标帧格式选择(管线 F32 → `GL_RGBA32F`, +//! U8 目标 → `GL_RGBA8`)。`glReadPixels` 按纹理类型回读(`GL_FLOAT` +//! 或 `GL_UNSIGNED_BYTE`),垂直翻转(GL 原点左下 vs 帧缓冲原点左上), +//! U8 按 `v/255.0` 归一化到 F32——与 CPU 路径输出格式(F32 RGBA) +//! 一致,经 `write_output_frame` 装配。 + +use std::sync::Arc; + +use crate::image::Image; +use crate::render::VideoParams; + +// ---- 对外 API(平台无关签名;实现按平台 cfg 分派)---------------------- + +/// GL 上下文是否可用(离屏上下文可创建)。use_opengl 决策用它当 +/// "目标纹理有有效 GL 名" 的门:桥能为目标帧建出真实 GL 纹理 ⟺ +/// 上下文可用。惰性创建,幂等。 +pub fn gl_available() -> bool { + imp::gl_available() +} + +/// 取得进程级 GL 上下文并把本线程置为 current(全局串行)。 +/// 返回的 guard 在 drop 时清 current 并放锁。失败(无 GL / 上下文 +/// 创建失败)→ 调用方回退 CPU。 +pub fn acquire() -> crate::error::Result { + imp::acquire() +} + +/// 创建输出 GL 纹理(尺寸 = 目标帧,内部格式按 `params.format`: +/// F32 → RGBA32F,U8 → RGBA8),返回真实 GL 纹理名。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current([`GlGuard`] 持有期间调用)。 +pub fn create_output_texture(w: i32, h: i32, params: &VideoParams) -> crate::error::Result { + imp::create_output_texture(w, h, params) +} + +/// 创建输入 GL 纹理(RGBA32F)并把 F32 像素数据上传,返回真实 GL +/// 纹理名。`pixels` 必须为 `w*h*4*4` 字节(紧凑 RGBA F32,行优先)。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current([`GlGuard`] 持有期间调用)。 +pub fn create_input_texture(w: i32, h: i32, pixels: &[u8]) -> crate::error::Result { + imp::create_input_texture(w, h, pixels) +} + +/// 删除 GL 纹理。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn delete_gl_texture(name: i32) { + imp::delete_gl_texture(name); +} + +/// 创建 FBO 并把 `tex` 挂为颜色附件(绑定并校验完整性)。 +/// 返回 FBO 名(当前已绑定)。失败时已清理。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn create_fbo(tex: i32, w: i32, h: i32) -> crate::error::Result { + imp::create_fbo(tex, w, h) +} + +/// 绑定 FBO(0 = 系统帧缓冲)。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn bind_fbo(fbo: i32) { + imp::bind_fbo(fbo); +} + +/// 删除 FBO。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn delete_fbo(fbo: i32) { + imp::delete_fbo(fbo); +} + +/// 设置视口(像素坐标)。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn set_viewport(w: i32, h: i32) { + imp::set_viewport(w, h); +} + +/// 回读当前绑定 FBO 并装配成 F32 RGBA 图像(垂直翻转、U8 归一化)。 +/// 返回的 Image 与 CPU 路径输出格式一致,可直接交 +/// [`crate::render_driver::write_output_frame`]。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current、目标 FBO 仍绑定。 +pub fn read_pixels_to_image(w: i32, h: i32, params: &VideoParams) -> crate::error::Result> { + imp::read_pixels_to_image(w, h, params) +} + +/// 设置 clear 颜色(FBO 测试/失败紫屏用)。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn gl_clear_color(r: f32, g: f32, b: f32, a: f32) { + imp::gl_clear_color(r, g, b, a); +} + +/// 清除颜色缓冲(FBO 测试用;插件正常路径自行发 GL 命令)。 +/// +/// # Safety +/// +/// 要求 GL 上下文已 current。 +pub fn gl_clear() { + imp::gl_clear(); +} + +/// 当前 GL 实现的版本串(真实上下文证据;测试用)。 +pub fn gl_version_string() -> Option { + imp::gl_version_string() +} + +/// GL 上下文句柄(持有全局锁 + 上下文 current;drop 时清 current 放锁)。 +pub struct GlGuard { + _inner: imp::GuardInner, +} + +/// 当前线程是否持有 GL current(嵌套 guard 内也算;[`acquire`] 成功 +/// 后为 true)。无 GL 的调用方(Draw suite 在 draw action 外被调)据此 +/// 不触 GL 命令——macOS 无 current 上下文时调 GL 是未定义行为。 +pub fn is_current() -> bool { + imp::is_current() +} + +// ---- 平台实现 ---------------------------------------------------------- + +#[cfg(target_os = "macos")] +mod imp { + use super::*; + use crate::image::{BitDepth, Components}; + use std::ffi::{c_char, c_void}; + use std::sync::{Arc, Mutex, OnceLock}; + + // ---- OpenGL 常量(GL 规范值,非 OFX 宏)---- + const GL_TEXTURE_2D: u32 = 0x0DE1; + const GL_RGBA: u32 = 0x1908; + const GL_UNSIGNED_BYTE: u32 = 0x1401; + const GL_FLOAT: u32 = 0x1406; + const GL_RGBA32F: u32 = 0x8814; + const GL_RGBA8: u32 = 0x8058; + const GL_TEXTURE_MIN_FILTER: u32 = 0x2801; + const GL_TEXTURE_MAG_FILTER: u32 = 0x2800; + const GL_NEAREST: u32 = 0x2600; + const GL_TEXTURE_WRAP_S: u32 = 0x2802; + const GL_TEXTURE_WRAP_T: u32 = 0x2803; + const GL_CLAMP_TO_EDGE: u32 = 0x812F; + const GL_FRAMEBUFFER: u32 = 0x8D40; + const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0; + const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5; + const GL_COLOR_BUFFER_BIT: u32 = 0x4000; + const GL_PACK_ALIGNMENT: u32 = 0x0D05; + const GL_UNPACK_ALIGNMENT: u32 = 0x0CF5; + const GL_VERSION: u32 = 0x1F02; + const GL_NO_ERROR: u32 = 0; + + // CGL 像素格式属性(CGLPixelFormatAttribute,CGL.h)。 + const K_CGL_PFA_OPENGL_PROFILE: i32 = 99; + const K_CGLOGLP_VERSION_3_2_CORE: i32 = 0x3200; + const K_CGL_PFA_ALLOW_OFFLINE_RENDERERS: i32 = 96; + const K_CGL_PFA_COLOR_SIZE: i32 = 8; + const K_CGL_PFA_ALPHA_SIZE: i32 = 11; + + /// macOS:CGL + OpenGL 的 FFI 声明(直接链接 OpenGL.framework)。 + /// + /// # Safety + /// + /// 全部是系统 framework 导出函数;指针参数语义见各函数注释。 + /// CGL* 类型用 `*mut c_void` 表示不透明对象指针(CGLContextObj / + /// CGLPixelFormatObj)。 + #[link(name = "OpenGL", kind = "framework")] + unsafe extern "C" { + /// 按属性列表选像素格式(成功返回 kCGLNoError=0;`npix` 是 + /// CGL 要求返回的像素格式计数,本桥只取格式对象,可丢弃—— + /// 但实参必须给足,否则是未定义行为)。 + fn CGLChoosePixelFormat(attrs: *const i32, pix: *mut *mut c_void, npix: *mut i32) -> i32; + /// 建上下文(share = NULL 无共享组)。返回 kCGLNoError=0。 + fn CGLCreateContext(pix: *mut c_void, share: *mut c_void, ctx: *mut *mut c_void) -> i32; + fn CGLDestroyPixelFormat(pix: *mut c_void); + fn CGLDestroyContext(ctx: *mut c_void); + /// 置为当前线程的 current 上下文(NULL = 清)。返回 kCGLNoError=0。 + fn CGLSetCurrentContext(ctx: *mut c_void) -> i32; + + fn glGenTextures(n: i32, textures: *mut u32); + fn glDeleteTextures(n: i32, textures: *const u32); + fn glBindTexture(target: u32, texture: u32); + fn glTexParameteri(target: u32, pname: u32, param: i32); + fn glPixelStorei(pname: u32, param: i32); + /// 分配/上传 2D 纹理图像。`pixels` 为 NULL 时只分配。 + fn glTexImage2D( + target: u32, + level: i32, + internalformat: i32, + width: i32, + height: i32, + border: i32, + format: u32, + type_: u32, + pixels: *const c_void, + ); + fn glGenFramebuffers(n: i32, framebuffers: *mut u32); + fn glDeleteFramebuffers(n: i32, framebuffers: *const u32); + fn glBindFramebuffer(target: u32, framebuffer: u32); + fn glFramebufferTexture2D(target: u32, attachment: u32, textarget: u32, texture: u32, level: i32); + fn glCheckFramebufferStatus(target: u32) -> u32; + fn glViewport(x: i32, y: i32, width: i32, height: i32); + fn glClearColor(r: f32, g: f32, b: f32, a: f32); + fn glClear(mask: u32); + fn glReadPixels( + x: i32, + y: i32, + width: i32, + height: i32, + format: u32, + type_: u32, + pixels: *mut c_void, + ); + fn glGetError() -> u32; + /// 返回当前上下文的信息字符串(非空 = 上下文真实可用)。 + fn glGetString(name: u32) -> *const c_char; + } + + /// CGL 上下文单例(OnceLock 惰性创建;Send+Sync 见下)。 + pub struct GlContext { + ctx: *mut c_void, + pixel_format: *mut c_void, + } + + // SAFETY: CGL 上下文对象可在任意线程经 CGLSetCurrentContext 置为 + // current,但不能同时被两条线程使用——本模块用全局 Mutex 串行化 + // 一切置 current 与 GL 调用,指针只在线程间传递(Arc 共享)而从不 + // 并发解引用;Drop 只在进程退出(单线程)执行。Send/Sync 因此安全。 + unsafe impl Send for GlContext {} + unsafe impl Sync for GlContext {} + + impl Drop for GlContext { + fn drop(&mut self) { + // SAFETY: 进程退出期单线程;先清 current 再销毁对象,顺序 + // 满足 CGL 文档(context 必须先取消 current 再 destroy)。 + unsafe { + CGLSetCurrentContext(std::ptr::null_mut()); + if !self.ctx.is_null() { + CGLDestroyContext(self.ctx); + } + if !self.pixel_format.is_null() { + CGLDestroyPixelFormat(self.pixel_format); + } + } + } + } + + static GL_CONTEXT: OnceLock, String>> = OnceLock::new(); + + /// GL 全局串行锁:同一时刻只有一条线程把共享上下文置为 current。 + static GL_LOCK: Mutex<()> = Mutex::new(()); + + fn lock() -> std::sync::MutexGuard<'static, ()> { + GL_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn err(msg: &str) -> crate::error::Error { + crate::error::Error::Failed(format!("gl_bridge: {msg}")) + } + + /// 惰性创建进程级 CGL 上下文(幂等)。 + fn context() -> crate::error::Result<&'static Arc> { + let res = GL_CONTEXT.get_or_init(create_context); + match res { + Ok(c) => Ok(c), + Err(e) => Err(err(&format!("CGL 上下文创建失败:{e}"))), + } + } + + fn create_context() -> Result, String> { + // SAFETY: CGLChoosePixelFormat 把像素格式对象写入 pix;属性表 + // 以 0 结尾(kCGLPFAOpenGLProfile 之后是 profile 值、kCGLPFA + // AllowOfflineRenderers 后跟 1;见 CGL.h 的 attr 配对约定)。 + let attrs = [ + K_CGL_PFA_OPENGL_PROFILE, + K_CGLOGLP_VERSION_3_2_CORE, + K_CGL_PFA_ALLOW_OFFLINE_RENDERERS, + 1, + K_CGL_PFA_COLOR_SIZE, + 24, + K_CGL_PFA_ALPHA_SIZE, + 8, + 0, + ]; + unsafe { + let mut pix: *mut c_void = std::ptr::null_mut(); + // npix 是 CGL 的 out 参数(像素格式计数);本桥不需要,但 + // 必须给足实参(CGL 会写它)。 + let mut npix: i32 = 0; + let st = CGLChoosePixelFormat(attrs.as_ptr(), &mut pix, &mut npix); + if st != 0 || pix.is_null() { + return Err(format!("CGLChoosePixelFormat 失败:{st}")); + } + let mut ctx: *mut c_void = std::ptr::null_mut(); + // share = NULL:无共享组(本进程唯一 GL 使用方,无需与其他 + // 上下文共享命名空间)。 + let st = CGLCreateContext(pix, std::ptr::null_mut(), &mut ctx); + if st != 0 || ctx.is_null() { + CGLDestroyPixelFormat(pix); + return Err(format!("CGLCreateContext 失败:{st}")); + } + Ok(Arc::new(GlContext { ctx, pixel_format: pix })) + } + } + + /// guard 持有期:全局锁 + 上下文 current。 + pub struct GuardInner { + /// 最外层 guard 持有全局锁;同线程嵌套 guard 为 None(只递增 + /// 深度,见 [`acquire`])。 + _lock: Option>, + _ctx: Arc, + } + + /// 当前线程的 acquire 嵌套深度。>0 = 本线程已持外层 guard(上下文 + /// 已 current);嵌套 acquire 只递增深度,不重取锁。 + thread_local! { + static ACQUIRE_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + pub fn is_current() -> bool { + ACQUIRE_DEPTH.with(|d| d.get() > 0) + } + + pub fn acquire() -> crate::error::Result { + let ctx = context()?.clone(); + let depth = ACQUIRE_DEPTH.with(|d| d.get()); + if depth > 0 { + // 嵌套:上下文已 current(外层 guard 持有期),只递增深度。 + ACQUIRE_DEPTH.with(|d| d.set(depth + 1)); + return Ok(GlGuard { + _inner: GuardInner { + _lock: None, + _ctx: ctx, + }, + }); + } + let guard = lock(); + // SAFETY: ctx 是有效 CGLContextObj;置 current 后本线程可发 GL + // 命令。失败返回非 0。 + let st = unsafe { CGLSetCurrentContext(ctx.ctx) }; + if st != 0 { + drop(guard); + return Err(err(&format!("CGLSetCurrentContext 失败:{st}"))); + } + ACQUIRE_DEPTH.with(|d| d.set(1)); + Ok(GlGuard { + _inner: GuardInner { + _lock: Some(guard), + _ctx: ctx, + }, + }) + } + + impl Drop for GuardInner { + fn drop(&mut self) { + let depth = ACQUIRE_DEPTH.with(|d| d.get()); + if depth <= 1 { + // 最外层:清 current(NULL)并释放全局锁。LIFO 栈纪律 + // 保证外层 drop 前所有嵌套 guard 已 drop(深度归 1), + // 故 _lock 必为 Some。 + ACQUIRE_DEPTH.with(|d| d.set(0)); + // SAFETY: 清 current(NULL)后放锁;见模块文档的线程模型。 + unsafe { + CGLSetCurrentContext(std::ptr::null_mut()); + } + drop(self._lock.take()); + } else { + // 嵌套 guard drop:只递减深度,不清 current/不放锁。 + ACQUIRE_DEPTH.with(|d| d.set(depth - 1)); + } + } + } + + pub fn gl_available() -> bool { + context().is_ok() + } + + /// GL 错误检查(测试断言用;返回最近错误或 GL_NO_ERROR)。 + fn check_error(what: &str) -> crate::error::Result<()> { + // SAFETY: glGetError 无参数,读取当前上下文的错误状态。 + let e = unsafe { glGetError() }; + if e != GL_NO_ERROR { + Err(err(&format!("{what} 失败:GL error 0x{e:x}"))) + } else { + Ok(()) + } + } + + /// 目标帧格式 → 输出 GL 纹理格式。 + /// 返回 (internal format, readback type, bytes per channel)。 + fn gl_texture_format(params: &VideoParams) -> crate::error::Result<(u32, u32, usize)> { + match params.format { + f if f == crate::render::PIXEL_FORMAT_F32 => Ok((GL_RGBA32F, GL_FLOAT, 4)), + f if f == crate::render::PIXEL_FORMAT_U8 => Ok((GL_RGBA8, GL_UNSIGNED_BYTE, 1)), + f => Err(err(&format!( + "目标帧格式 {f} 不支持 GL 输出(仅 F32/U8)" + ))), + } + } + + /// 建纹理并设采样参数(FBO 完整性要求非 mipmap 的 min filter 与 + /// clamp-to-edge)。 + fn new_texture(w: i32, h: i32, internal: u32) -> crate::error::Result { + if w <= 0 || h <= 0 { + return Err(err("纹理尺寸非法")); + } + unsafe { + let mut tex: u32 = 0; + glGenTextures(1, &mut tex); + if tex == 0 { + return Err(err("glGenTextures 失败")); + } + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST as i32); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST as i32); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE as i32); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE as i32); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + // internalformat 是 GLenum(无符号);C 签名里 glTexImage2D + // 的 internalformat 实为 GLint——此处值 < 2^31 安全。 + glTexImage2D( + GL_TEXTURE_2D, + 0, + internal as i32, + w, + h, + 0, + GL_RGBA, + GL_FLOAT, + std::ptr::null(), + ); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glBindTexture(GL_TEXTURE_2D, 0); + check_error("glTexImage2D")?; + Ok(tex) + } + } + + pub fn create_output_texture( + w: i32, + h: i32, + params: &VideoParams, + ) -> crate::error::Result { + let (internal, _, _) = gl_texture_format(params)?; + new_texture(w, h, internal).map(|t| t as i32) + } + + pub fn create_input_texture(w: i32, h: i32, pixels: &[u8]) -> crate::error::Result { + let tex = new_texture(w, h, GL_RGBA32F)?; + // 上传 F32 RGBA 像素(紧凑,行优先;先确保 unpack alignment=1)。 + let expected = (w as usize) * (h as usize) * 4 * 4; + if pixels.len() != expected { + let _ = delete_gl_texture(tex as i32); + return Err(err(&format!( + "输入像素长度 {} 与 w*h*4*4={expected} 不符", + pixels.len() + ))); + } + unsafe { + glBindTexture(GL_TEXTURE_2D, tex); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA32F as i32, + w, + h, + 0, + GL_RGBA, + GL_FLOAT, + pixels.as_ptr() as *const c_void, + ); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + glBindTexture(GL_TEXTURE_2D, 0); + } + check_error("输入纹理上传")?; + Ok(tex as i32) + } + + pub fn delete_gl_texture(name: i32) { + if name <= 0 { + return; + } + // SAFETY: name 是之前 glGenTextures 产出的纹理名;删除后不可 + // 再用(本桥保证不再引用)。要求上下文 current。 + unsafe { + let t = name as u32; + glDeleteTextures(1, &t); + } + } + + pub fn create_fbo(tex: i32, _w: i32, _h: i32) -> crate::error::Result { + if tex <= 0 { + return Err(err("FBO 附着纹理无效")); + } + unsafe { + let mut fbo: u32 = 0; + glGenFramebuffers(1, &mut fbo); + if fbo == 0 { + return Err(err("glGenFramebuffers 失败")); + } + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D( + GL_FRAMEBUFFER, + GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, + tex as u32, + 0, + ); + let status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if status != GL_FRAMEBUFFER_COMPLETE { + glDeleteFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + return Err(err(&format!("FBO 不完整:0x{status:x}"))); + } + Ok(fbo as i32) + } + } + + pub fn bind_fbo(fbo: i32) { + // SAFETY: 绑定 0(系统帧缓冲)或本桥创建的 FBO 名。 + unsafe { + glBindFramebuffer(GL_FRAMEBUFFER, fbo as u32); + } + } + + pub fn delete_fbo(fbo: i32) { + if fbo <= 0 { + return; + } + // SAFETY: fbo 是本桥 glGenFramebuffers 的产物;删除前先解除绑定。 + unsafe { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + let f = fbo as u32; + glDeleteFramebuffers(1, &f); + } + } + + pub fn set_viewport(w: i32, h: i32) { + // SAFETY: 视口是 GL 上下文状态,直接写。 + unsafe { + glViewport(0, 0, w, h); + } + } + + /// 回读原始像素(紧凑、行优先、GL 原点左下)。 + fn read_pixels(w: i32, h: i32, format: u32, type_: u32, bpc: usize) -> crate::error::Result> { + let size = (w as usize) * (h as usize) * 4 * bpc; + let mut buf = vec![0u8; size]; + unsafe { + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, w, h, format, type_, buf.as_mut_ptr() as *mut c_void); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + } + check_error("glReadPixels")?; + Ok(buf) + } + + pub fn read_pixels_to_image( + w: i32, + h: i32, + params: &VideoParams, + ) -> crate::error::Result> { + let (_, gl_type, bpc) = gl_texture_format(params)?; + let raw = read_pixels(w, h, GL_RGBA, gl_type, bpc)?; + let (w, h) = (w as usize, h as usize); + let mut img = Image::allocate( + BitDepth::Float, + Components::Rgba, + crate::instance::OfxRectD { + x1: 0.0, + y1: 0.0, + x2: w as f64, + y2: h as f64, + }, + ); + let dst = img.pixels_mut(); + let src_row = w * 4 * bpc; + let dst_row = w * 4 * 4; + for y in 0..h { + // 垂直翻转:GL 读回原点在左下,帧缓冲原点在左上。 + let src_y = h - 1 - y; + let src = &raw[src_y * src_row..(src_y + 1) * src_row]; + let d = &mut dst[y * dst_row..(y + 1) * dst_row]; + for x in 0..w { + let (r, g, b, a): (f32, f32, f32, f32) = if bpc == 4 { + let p = &src[x * 16..x * 16 + 16]; + ( + f32::from_ne_bytes(p[0..4].try_into().unwrap()), + f32::from_ne_bytes(p[4..8].try_into().unwrap()), + f32::from_ne_bytes(p[8..12].try_into().unwrap()), + f32::from_ne_bytes(p[12..16].try_into().unwrap()), + ) + } else { + let p = &src[x * 4..x * 4 + 4]; + ( + p[0] as f32 / 255.0, + p[1] as f32 / 255.0, + p[2] as f32 / 255.0, + p[3] as f32 / 255.0, + ) + }; + let o = x * 16; + d[o..o + 4].copy_from_slice(&r.to_ne_bytes()); + d[o + 4..o + 8].copy_from_slice(&g.to_ne_bytes()); + d[o + 8..o + 12].copy_from_slice(&b.to_ne_bytes()); + d[o + 12..o + 16].copy_from_slice(&a.to_ne_bytes()); + } + } + Ok(Arc::new(img)) + } + + pub fn gl_clear_color(r: f32, g: f32, b: f32, a: f32) { + // SAFETY: 写当前上下文的 clear 颜色状态。 + unsafe { + glClearColor(r, g, b, a); + } + } + + pub fn gl_clear() { + // SAFETY: 清除当前绑定帧缓冲的颜色缓冲。 + unsafe { + glClear(GL_COLOR_BUFFER_BIT); + } + } + + pub fn gl_version_string() -> Option { + // SAFETY: glGetString 返回当前上下文内驻字符串;拷贝成 Rust + // String 后再返回,不持指针。 + unsafe { + let p = glGetString(GL_VERSION); + if p.is_null() { + None + } else { + Some(std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()) + } + } + } +} + +/// 非 macOS:GL 桥 stub(Linux 预留 EGL、Windows 预留 WGL 的接线点; +/// 全部失败/false,use_opengl 决策回退 CPU)。 +#[cfg(not(target_os = "macos"))] +mod imp { + use super::*; + + /// Linux 接线点:替换为 EGL(eglGetDisplay → eglInitialize → + /// eglChooseConfig → eglCreateContext → eglMakeCurrent);Windows + /// 替换为 WGL(wglCreateContext/wglMakeCurrent)。签名与 macOS + /// 实现同构。 + pub struct GuardInner; + + pub fn gl_available() -> bool { + false + } + + pub fn is_current() -> bool { + false + } + + pub fn acquire() -> crate::error::Result { + Err(crate::error::Error::Failed("gl_bridge: 平台无 GL 实现(Linux/Windows stub)".into())) + } + + pub fn create_output_texture(_w: i32, _h: i32, _p: &VideoParams) -> crate::error::Result { + Err(crate::error::Error::Failed("gl_bridge: 平台无 GL 实现".into())) + } + + pub fn create_input_texture(_w: i32, _h: i32, _p: &[u8]) -> crate::error::Result { + Err(crate::error::Error::Failed("gl_bridge: 平台无 GL 实现".into())) + } + + pub fn delete_gl_texture(_name: i32) {} + + pub fn create_fbo(_tex: i32, _w: i32, _h: i32) -> crate::error::Result { + Err(crate::error::Error::Failed("gl_bridge: 平台无 GL 实现".into())) + } + + pub fn bind_fbo(_fbo: i32) {} + + pub fn delete_fbo(_fbo: i32) {} + + pub fn set_viewport(_w: i32, _h: i32) {} + + pub fn read_pixels_to_image( + _w: i32, + _h: i32, + _p: &VideoParams, + ) -> crate::error::Result> { + Err(crate::error::Error::Failed("gl_bridge: 平台无 GL 实现".into())) + } + + pub fn gl_clear_color(_r: f32, _g: f32, _b: f32, _a: f32) {} + + pub fn gl_clear() {} + + pub fn gl_version_string() -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::image::{BitDepth, Components}; + use crate::render::VideoParams; + + /// GL 不可用时 skip(无 GPU 无头环境);可用时返回 guard。 + fn maybe_gl() -> Option { + match acquire() { + Ok(g) => Some(g), + Err(e) => { + println!("SKIP: GL 上下文不可用:{e}"); + None + } + } + } + + fn f32_params(w: i32, h: i32) -> VideoParams { + let mut p = VideoParams::default(); + p.width = w; + p.height = h; + p.format = crate::render::PIXEL_FORMAT_F32; + p + } + + fn u8_params(w: i32, h: i32) -> VideoParams { + let mut p = VideoParams::default(); + p.width = w; + p.height = h; + p.format = crate::render::PIXEL_FORMAT_U8; + p + } + + /// 真实 GL 上下文证据:acquire 后能取到非空版本串。 + #[test] + fn gl_context_is_real() { + let Some(_g) = maybe_gl() else { + return; + }; + let v = gl_version_string(); + assert!(v.is_some(), "真实 CGL 上下文应返回 GL_VERSION"); + let v = v.unwrap(); + // macOS 上 Apple 的 OpenGL 实现构建于 Metal 之上,GL_VERSION + // 形如 "4.1 Metal - 90.5"(无头/有头皆然)——只断言非空。 + assert!(!v.trim().is_empty(), "GL_VERSION 应非空,got {v:?}"); + assert!(gl_available(), "上下文可用后 gl_available 应为 true"); + } + + /// 上下文单例与跨线程 current 串行:两条线程轮流 acquire 都能 + /// 拿到真实上下文(不崩、版本一致)。 + #[test] + fn gl_context_thread_serialized() { + let mut handles = Vec::new(); + for _ in 0..4 { + handles.push(std::thread::spawn(|| { + let Some(_g) = maybe_gl() else { + return; + }; + let v = gl_version_string().unwrap_or_default(); + assert!(!v.is_empty()); + })); + } + for h in handles { + h.join().unwrap(); + } + } + + /// 输出纹理 + FBO + clear + 回读 round-trip(F32 目标):clear 到 + /// 已知颜色 → glReadPixels 读回,逐像素断言。 + #[test] + fn fbo_clear_readback_f32() { + let Some(_g) = maybe_gl() else { + return; + }; + let (w, h) = (4i32, 2i32); + let params = f32_params(w, h); + let tex = create_output_texture(w, h, ¶ms).expect("输出纹理应可建"); + assert!(tex > 0, "GL 纹理名必须为真实非零 GL 名,got {tex}"); + let fbo = create_fbo(tex, w, h).expect("FBO 应完整"); + bind_fbo(fbo); + set_viewport(w, h); + gl_clear_color(0.1, 0.2, 0.3, 1.0); + gl_clear(); + let img = read_pixels_to_image(w, h, ¶ms).expect("回读应成功"); + delete_fbo(fbo); + delete_gl_texture(tex); + assert_eq!(img.components(), Components::Rgba); + assert_eq!(img.depth(), BitDepth::Float); + assert_eq!(img.bounds().x2, w as f64); + assert_eq!(img.bounds().y2, h as f64); + let px = &img.pixels()[0..16]; + let px_f: Vec = px.chunks_exact(4).map(|c| f32::from_ne_bytes(c.try_into().unwrap())).collect(); + assert!((px_f[0] - 0.1).abs() < 1e-6, "R={}", px_f[0]); + assert!((px_f[1] - 0.2).abs() < 1e-6, "G={}", px_f[1]); + assert!((px_f[2] - 0.3).abs() < 1e-6, "B={}", px_f[2]); + assert!((px_f[3] - 1.0).abs() < 1e-6, "A={}", px_f[3]); + // 整帧同色(clear 覆盖全 FBO)。 + let all: Vec = img.pixels()[..] + .chunks_exact(4) + .map(|c| f32::from_ne_bytes(c.try_into().unwrap())) + .collect(); + assert!(all.chunks(4).all(|px| (px[0] - 0.1).abs() < 1e-6), "整帧应为清屏色"); + } + + /// U8 目标:clear → GL_RGBA8 存储 → 回读归一化为 F32(容差内)。 + #[test] + fn fbo_clear_readback_u8() { + let Some(_g) = maybe_gl() else { + return; + }; + let (w, h) = (3i32, 3i32); + let params = u8_params(w, h); + let tex = create_output_texture(w, h, ¶ms).expect("U8 输出纹理应可建"); + let fbo = create_fbo(tex, w, h).expect("FBO 应完整"); + bind_fbo(fbo); + set_viewport(w, h); + gl_clear_color(0.5, 0.25, 0.0, 1.0); + gl_clear(); + let img = read_pixels_to_image(w, h, ¶ms).expect("U8 回读应成功"); + delete_fbo(fbo); + delete_gl_texture(tex); + let px_f: Vec = img.pixels()[0..16] + .chunks_exact(4) + .map(|c| f32::from_ne_bytes(c.try_into().unwrap())) + .collect(); + // 0.5*255=127.5→128(round),128/255≈0.50196;0.25→64→0.25098。 + assert!((px_f[0] - 0.502).abs() < 0.01, "R={}", px_f[0]); + assert!((px_f[1] - 0.251).abs() < 0.01, "G={}", px_f[1]); + assert!(px_f[2].abs() < 0.01, "B={}", px_f[2]); + assert!((px_f[3] - 1.0).abs() < 0.01, "A={}", px_f[3]); + } + + /// 输入纹理上传:建 RGBA32F 纹理 + 上传已知像素,读回纹理内容 + /// (经 FBO 附着 + glReadPixels)与源像素一致。 + #[test] + fn input_texture_upload_roundtrip() { + let Some(_g) = maybe_gl() else { + return; + }; + let (w, h) = (2i32, 2i32); + let mut px = Vec::new(); + for i in 0..(w * h) as usize { + for c in 0..4 { + px.extend_from_slice(&((i * 4 + c) as f32 * 0.1).to_ne_bytes()); + } + } + let tex = create_input_texture(w, h, &px).expect("输入纹理应可建"); + let params = f32_params(w, h); + let fbo = create_fbo(tex, w, h).expect("FBO 应完整"); + bind_fbo(fbo); + let img = read_pixels_to_image(w, h, ¶ms).expect("回读应成功"); + delete_fbo(fbo); + delete_gl_texture(tex); + let got: Vec = img.pixels()[0..w as usize * h as usize * 16] + .chunks_exact(4) + .map(|c| f32::from_ne_bytes(c.try_into().unwrap())) + .collect(); + let want: Vec = px + .chunks_exact(4) + .map(|c| f32::from_ne_bytes(c.try_into().unwrap())) + .collect(); + // 源是逐像素相异序列:read_pixels_to_image 垂直翻转(GL 左下 → + // 帧左上)后,最终图像第 r 行 = 源第 (h-1-r) 行。 + let stride = w as usize * 4; + for (i, g) in got.iter().enumerate() { + let row = i / stride; + let inv_row = (h as usize - 1 - row) as usize; + let expected = want[inv_row * stride + i % stride]; + assert!( + (g - expected).abs() < 1e-6, + "pixel {i}: got {g} want {expected}" + ); + } + } + + /// 同线程可重入 acquire:外层 guard 持有期再 acquire 不死锁,GL + /// 调用仍可达;嵌套 guard drop 后上下文保持 current(外层仍有效), + /// 外层 drop 后清 current。模拟 interact draw 的"一次 acquire 覆盖 + /// FBO 装配 → 插件绘制 → 回读"场景。 + #[test] + fn acquire_is_reentrant_on_same_thread() { + let Some(_outer) = maybe_gl() else { + return; + }; + // 嵌套 acquire(interact::draw 内部路径)。 + let inner = acquire().expect("嵌套 acquire 应成功(同线程重入)"); + let v1 = gl_version_string(); + assert!(v1.is_some(), "嵌套 guard 期间 GL 应仍 current"); + drop(inner); + // 外层仍持有:GL 仍 current。 + let v2 = gl_version_string(); + assert!(v2.is_some(), "嵌套 guard drop 后外层仍应 current"); + // 再套一层(深度 2)验证深度计数不串。 + { + let inner2 = acquire().expect("二次嵌套应成功"); + assert!(gl_version_string().is_some()); + drop(inner2); + } + } +} diff --git a/crates/oakplugin/src/host.rs b/crates/oakplugin/src/host.rs index 41a773b84..a85dbead4 100644 --- a/crates/oakplugin/src/host.rs +++ b/crates/oakplugin/src/host.rs @@ -181,9 +181,155 @@ pub(crate) const ACTION_GL_CONTEXT_ATTACHED: &str = "OfxActionOpenGLContextAttac pub(crate) const ACTION_GL_CONTEXT_DETACHED: &str = "kOfxActionOpenGLContextDetached"; /// kOfxImageEffectActionGetOutputColourspace(ofxColour.h:283)。 pub(crate) const ACTION_GET_OUTPUT_COLOURSPACE: &str = "OfxImageEffectActionGetOutputColourspace"; +/// kOfxActionInstanceChanged(ofxCore.h:449):宿主侧参数/时间变更 +/// 通知。按下 push button 后宿主须以 kOfxChangeUserEdited 的原因调用 +/// 它(ofxCore.h:405-435 的 inArgs 契约)。 +pub(crate) const ACTION_INSTANCE_CHANGED: &str = "OfxActionInstanceChanged"; +// ---- Interact(OFX 自定义交互;ofxInteract.h / ofxDrawSuite.h / ofxKeySyms.h)---- + +/// kOfxActionNewInteract:宿主创建 interact 时发给 interact 入口的 +/// 首个 action。vendored ofxInteract.h 未定义该宏(官方头文件无此 +/// action;任务契约按 "向插件 main entry 发 kOfxActionNewInteract" +/// 命名)——取值 "OfxActionNewInteract",与 OFX action 命名惯例一致。 +/// 插件不实现该 action 时返回 kOfxStatReplyDefault(宿主视为"无 +/// interact"或"不参与 NewInteract 协议",见 [`crate::instance::Instance::new_interact`])。 +pub(crate) const ACTION_NEW_INTERACT: &str = "OfxActionNewInteract"; +/// kOfxInteractActionIdle:宿主空闲泵(任务契约;vendored ofxInteract.h +/// 未收录——OFX 官方规范无 Idle action,属本宿主扩展,取值 +/// "OfxInteractActionIdle")。插件实现与否自愿;未处理返回 +/// kOfxStatReplyDefault。 +pub(crate) const ACTION_INTERACT_IDLE: &str = "OfxInteractActionIdle"; +/// kOfxInteractActionDraw(ofxInteract.h:265)。 +pub(crate) const ACTION_INTERACT_DRAW: &str = "OfxInteractActionDraw"; +/// kOfxInteractActionPenMotion(ofxInteract.h:302)。 +pub(crate) const ACTION_INTERACT_PEN_MOTION: &str = "OfxInteractActionPenMotion"; +/// kOfxInteractActionPenDown(ofxInteract.h:340)。 +pub(crate) const ACTION_INTERACT_PEN_DOWN: &str = "OfxInteractActionPenDown"; +/// kOfxInteractActionPenUp(ofxInteract.h:376)。 +pub(crate) const ACTION_INTERACT_PEN_UP: &str = "OfxInteractActionPenUp"; +/// kOfxInteractActionKeyDown(ofxInteract.h:410)。 +pub(crate) const ACTION_INTERACT_KEY_DOWN: &str = "OfxInteractActionKeyDown"; +/// kOfxInteractActionKeyUp(ofxInteract.h:443)。 +pub(crate) const ACTION_INTERACT_KEY_UP: &str = "OfxInteractActionKeyUp"; +/// kOfxInteractActionGainFocus(ofxInteract.h:501)。 +pub(crate) const ACTION_INTERACT_GAIN_FOCUS: &str = "OfxInteractActionGainFocus"; +/// kOfxInteractActionLoseFocus(ofxInteract.h:526)。 +pub(crate) const ACTION_INTERACT_LOSE_FOCUS: &str = "OfxInteractActionLoseFocus"; + +/// kOfxInteractPropPixelScale(ofxInteract.h:58):canonical→屏幕像素 +/// 换算比例(Double×2)。 +pub(crate) const PROP_INTERACT_PIXEL_SCALE: &str = "OfxInteractPropPixelScale"; +/// kOfxInteractPropViewportSize(OFX 1.3 命名 "OfxInteractPropViewport"; +/// vendored 1.5 头文件已删,任务契约要求 draw inArgs 携带视口尺寸)。 +pub(crate) const PROP_INTERACT_VIEWPORT_SIZE: &str = "OfxInteractPropViewport"; +/// kOfxInteractPropBackgroundImage:任务契约的 draw inArgs 背景图像 +/// 句柄(Pointer;无背景时为空——本宿主 Phase 1 无合成背景,恒空)。 +/// 非官方 OFX 属性(官方只有 BackgroundColour),属本宿主扩展。 +pub(crate) const PROP_INTERACT_BACKGROUND_IMAGE: &str = "OfxInteractPropBackgroundImage"; +/// kOfxInteractPropBackgroundColour(ofxInteract.h:71):宿主视口背景色 +/// (Double×3)。 +pub(crate) const PROP_INTERACT_BACKGROUND_COLOUR: &str = "OfxInteractPropBackgroundColour"; +/// kOfxInteractPropSuggestedColour(ofxInteract.h:86):宿主建议的 overlay +/// 颜色(Double×3;宿主不支持颜色选择时返回 ReplyDefault)。 +pub(crate) const PROP_INTERACT_SUGGESTED_COLOUR: &str = "OfxInteractPropSuggestedColour"; +/// kOfxInteractPropSlaveToParam(ofxInteract.h:50):值变化触发 interact +/// 重绘的参数名(String×N)。 +pub(crate) const PROP_INTERACT_SLAVE_TO_PARAM: &str = "OfxInteractPropSlaveToParam"; +/// kOfxInteractPropPenPosition(ofxInteract.h:95):笔的 canonical 位置 +/// (Double×2,只读 inArgs)。 +pub(crate) const PROP_INTERACT_PEN_POSITION: &str = "OfxInteractPropPenPosition"; +/// kOfxInteractPropPenViewportPosition(ofxInteract.h:104):笔的视口像素 +/// 位置(Int×2,只读 inArgs)。 +pub(crate) const PROP_INTERACT_PEN_VIEWPORT_POSITION: &str = "OfxInteractPropPenViewportPosition"; +/// kOfxInteractPropPenPressure(ofxInteract.h:114):笔压(Double×1, +/// 0..1;两态笔映射 0/1)。 +pub(crate) const PROP_INTERACT_PEN_PRESSURE: &str = "OfxInteractPropPenPressure"; +/// kOfxInteractPropBitDepth(ofxInteract.h:122):interact 帧缓冲位深 +/// (Int×1,只读)。 +pub(crate) const PROP_INTERACT_BIT_DEPTH: &str = "OfxInteractPropBitDepth"; +/// kOfxInteractPropHasAlpha(ofxInteract.h:132):interact 帧缓冲是否含 +/// alpha(Int×1,只读)。 +pub(crate) const PROP_INTERACT_HAS_ALPHA: &str = "OfxInteractPropHasAlpha"; +/// kOfxInteractPropDrawContext(ofxDrawSuite.h:34):Draw suite 上下文句柄 +/// (Pointer;draw inArgs 携带,插件取来传给 Draw suite 函数)。 +pub(crate) const PROP_INTERACT_DRAW_CONTEXT: &str = "OfxInteractPropDrawContext"; +/// kOfxPropKeySym(ofxKeySyms.h:30):键盘事件的关键码(Int×1)。 +pub(crate) const PROP_KEY_SYM: &str = "kOfxPropKeySym"; +/// kOfxPropKeyString(ofxKeySyms.h:49):键盘事件的 UTF-8 字符(String×1)。 +pub(crate) const PROP_KEY_STRING: &str = "kOfxPropKeyString"; +/// kOfxImageEffectPluginPropOverlayInteractV2(ofxImageEffect.h:825): +/// 插件声明的 overlay interact 入口(Pointer→OfxPluginEntryPoint;V2 +/// 要求 Draw suite 绘制)。 +pub(crate) const PROP_OVERLAY_INTERACT_V2: &str = "OfxImageEffectPluginPropOverlayInteractV2"; +/// kOfxImageEffectPluginPropOverlayInteractV1(ofxImageEffect.h:812)。 +pub(crate) const PROP_OVERLAY_INTERACT_V1: &str = "OfxImageEffectPluginPropOverlayInteractV1"; +/// kOfxImageEffectPropSupportsOverlays(ofxImageEffect.h:801):宿主是否 +/// 允许插件在输出图像上绘制 overlay(能力宣告)。 +pub(crate) const PROP_SUPPORTS_OVERLAYS: &str = "OfxImageEffectPropSupportsOverlays"; + +// ---- OFX 关键码(ofxKeySyms.h;X11 keysym 值,测试/宿主常用子集)---- +// +// 公共:app 侧(WG3b)经 [`crate::suites::interact::Interact::key_down`]/ +// `key_up` 传关键码。 + +/// kOfxKey_Unknown(ofxKeySyms.h:121)。 +pub const KEY_UNKNOWN: i32 = 0x0; +/// kOfxKey_BackSpace(ofxKeySyms.h:128)。 +pub const KEY_BACKSPACE: i32 = 0xFF08; +/// kOfxKey_Tab(ofxKeySyms.h:129)。 +pub const KEY_TAB: i32 = 0xFF09; +/// kOfxKey_Return(ofxKeySyms.h:132)。 +pub const KEY_RETURN: i32 = 0xFF0D; +/// kOfxKey_Escape(ofxKeySyms.h:136)。 +pub const KEY_ESCAPE: i32 = 0xFF1B; +/// kOfxKey_Delete(ofxKeySyms.h:137)。 +pub const KEY_DELETE: i32 = 0xFFFF; +/// kOfxKey_Home(ofxKeySyms.h:172)。 +pub const KEY_HOME: i32 = 0xFF50; +/// kOfxKey_Left(ofxKeySyms.h:173)。 +pub const KEY_LEFT: i32 = 0xFF51; +/// kOfxKey_Up(ofxKeySyms.h:174)。 +pub const KEY_UP: i32 = 0xFF52; +/// kOfxKey_Right(ofxKeySyms.h:175)。 +pub const KEY_RIGHT: i32 = 0xFF53; +/// kOfxKey_Down(ofxKeySyms.h:176)。 +pub const KEY_DOWN: i32 = 0xFF54; +/// kOfxKey_Page_Up(ofxKeySyms.h:178)。 +pub const KEY_PAGE_UP: i32 = 0xFF55; +/// kOfxKey_Page_Down(ofxKeySyms.h:180)。 +pub const KEY_PAGE_DOWN: i32 = 0xFF56; +/// kOfxKey_End(ofxKeySyms.h:181)。 +pub const KEY_END: i32 = 0xFF57; +/// kOfxKey_F1(ofxKeySyms.h:252)。 +pub const KEY_F1: i32 = 0xFFBE; +/// kOfxKey_Shift_L(ofxKeySyms.h:315)。 +pub const KEY_SHIFT_L: i32 = 0xFFE1; +/// kOfxKey_Control_L(ofxKeySyms.h:317)。 +pub const KEY_CONTROL_L: i32 = 0xFFE3; +/// kOfxKey_Alt_L(ofxKeySyms.h:323)。 +pub const KEY_ALT_L: i32 = 0xFFE9; +/// kOfxKey_space(ofxKeySyms.h:331)。 +pub const KEY_SPACE: i32 = 0x020; +/// kOfxKey_a(ofxKeySyms.h:398)。 +pub const KEY_A: i32 = 0x061; +/// kOfxKey_z(ofxKeySyms.h:423)。 +pub const KEY_Z: i32 = 0x07a; + +/// kOfxPropChangeReason(ofxCore.h:763):instanceChanged 的 inArgs 里 +/// 说明变更来源(UserEdited / PluginEdited / Time)。 +pub(crate) const PROP_CHANGE_REASON: &str = "OfxPropChangeReason"; +/// kOfxChangeUserEdited(ofxCore.h:792)。 +pub(crate) const CHANGE_USER_EDITED: &str = "OfxChangeUserEdited"; +/// kOfxChangePluginEdited(ofxCore.h:795)。 +pub(crate) const CHANGE_PLUGIN_EDITED: &str = "OfxChangePluginEdited"; +/// kOfxChangeTime(ofxCore.h:798)。 +pub(crate) const CHANGE_TIME: &str = "OfxChangeTime"; /// kOfxPropTime(ofxCore.h:613)。 pub(crate) const PROP_TIME: &str = "OfxPropTime"; +/// kOfxPropEffectInstance(ofxCore.h:776):interact/渲染 inArgs 里指向 +/// 效果实例句柄的指针属性。 +pub(crate) const PROP_EFFECT_INSTANCE: &str = "OfxPropEffectInstance"; /// kOfxImageEffectPropRenderScale。 pub(crate) const PROP_RENDER_SCALE: &str = "OfxImageEffectPropRenderScale"; /// kOfxImageEffectPropRenderWindow。 @@ -341,6 +487,30 @@ pub struct Plugin { unsafe impl Send for Plugin {} unsafe impl Sync for Plugin {} +/// 插件入口函数类型(`OfxPluginEntryPoint`,ofxCore.h:84)。 +pub(crate) type EntryPoint = unsafe extern "C" fn( + action: *const c_char, + handle: *const c_void, + in_args: *mut c_void, + out_args: *mut c_void, +) -> i32; + +/// 插件声明的 overlay interact 入口(ofxImageEffect.h:825/812): +/// V2 优先、V1 次之;两者都未声明返回 None。属性值是 +/// `Pointer→OfxPluginEntryPoint`(宿主预定义,插件 describe 期写入)。 +pub(crate) fn overlay_interact_entry(props: &PropertySet) -> Option { + for name in [PROP_OVERLAY_INTERACT_V2, PROP_OVERLAY_INTERACT_V1] { + if let Some(Value::Pointer(p)) = props.get(name, 0) { + if !p.is_null() { + // 属性值是函数指针(void* 存放);转换与 dlsym_fn 同款 + // (调用方保证类型正确)。 + return Some(unsafe { std::mem::transmute_copy(&p) }); + } + } + } + None +} + impl Plugin { /// 调插件的 action。`handle` 视 action 而定(describe 时为 /// descriptor,render 时为 instance)。`in_args`/`out_args` 按 @@ -364,6 +534,27 @@ impl Plugin { let action = cs(action); unsafe { (self.entry)(action.as_ptr(), handle as *const c_void, in_ptr, out_ptr) } } + + /// 按指定入口函数调用(interact 的 overlay 入口与 main entry 可 + /// 不同——插件经 kOfxImageEffectPluginPropOverlayInteractV2 声明)。 + /// 语义同 [`Plugin::call_action`]。 + /// + /// # Safety + /// `entry` 必须是插件导出的合法 OfxPluginEntryPoint;`handle`/参数 + /// 集与 action 匹配。 + pub(crate) unsafe fn call_entry( + &self, + entry: EntryPoint, + action: &str, + handle: *mut c_void, + in_args: &PropertySet, + out_args: &PropertySet, + ) -> i32 { + let in_ptr = in_args as *const PropertySet as *mut c_void; + let out_ptr = out_args as *const PropertySet as *mut c_void; + let action = cs(action); + unsafe { (entry)(action.as_ptr(), handle as *const c_void, in_ptr, out_ptr) } + } } // ---- PluginCache --------------------------------------------------------- @@ -730,6 +921,11 @@ fn init_descriptor_props(props: &PropertySet, bundle: &Path) { "OfxImageEffectPluginPropOverlayInteractV1", Value::Pointer(std::ptr::null_mut()), ); + // overlay interact V2(ofxImageEffect.h:825):插件 describe 期声明 + // 自定义交互入口(Pointer→OfxPluginEntryPoint;V2 要求 Draw suite + // 绘制);宿主预定义空指针默认(propSet 不创建属性,宿主预定义 + // 属性宇宙,与 V1 同款)。 + props.set_one(PROP_OVERLAY_INTERACT_V2, Value::Pointer(std::ptr::null_mut())); props.set_one("OfxImageEffectPropSupportsMultiResolution", Value::Int(1)); props.set_one(PROP_SUPPORTS_TILES, Value::Int(1)); props.set_one("OfxImageEffectPropTemporalClipAccess", Value::Int(0)); @@ -886,6 +1082,7 @@ impl Host { cancel: std::sync::atomic::AtomicBool::new(false), edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), }; init_instance_props(&instance.props, &instance); @@ -989,6 +1186,9 @@ fn init_host_props(props: &PropertySet) { // GL 能力宣告(M11 §4;ofxGPURender.h "OpenGL House Keeping": // 宿主在描述符置 "true")。 props.set_one(PROP_GL_RENDER_SUPPORTED, Value::String(cs("true"))); + // overlay 能力宣告(ofxImageEffect.h:801):宿主允许插件在输出 + // 图像上绘制 overlay(interact draw 的前置)。 + props.set_one(PROP_SUPPORTS_OVERLAYS, Value::Int(1)); // ofxColour 能力宣告(M11 §4):OCIO 模式 + native 配置列表。 props.set_one(PROP_COLOUR_STYLE, Value::String(cs(COLOUR_STYLE_OCIO))); props.define( diff --git a/crates/oakplugin/src/instance.rs b/crates/oakplugin/src/instance.rs index ef7b2d3b7..ad0afdb2b 100644 --- a/crates/oakplugin/src/instance.rs +++ b/crates/oakplugin/src/instance.rs @@ -122,6 +122,9 @@ pub struct Instance { /// pluginrenderer.cpp:1436-1444 的 instance_lock——渲染路径非 /// 线程安全,并发 render 必须互斥)。 pub render_lock: std::sync::Mutex<()>, + /// 关联的 interact(`new_interact` 创建;与实例同生命周期, + /// notify_destroy 时连带销毁)。 + pub interact: std::sync::Mutex>>, } /// 实例销毁路径:先通知 destroyInstance action,再摘除 param 回写登记。 @@ -641,12 +644,16 @@ impl Instance { /// 要求上下文 current——本实现的约定是 oakrender 的 PluginJob /// 路径在进入前做好),且 `output_texture` 已附着为渲染器输出 /// 目标(等价 C++ `PluginRenderer::attach_output_texture`)。 + /// `output_gl_texture` 为宿主为输出帧建的真实 GL 纹理名(GL 模式 + /// 下 clipLoadTexture(Output) 的 OpenGLTextureIndex 以它为准); + /// None = CPU 回退语义。 /// /// action 序列:kOfxActionOpenGLContextAttached → render(in args /// 带 kOfxImageEffectPropOpenGLEnabled=1)→ /// kOfxActionOpenGLContextDetached(ofxGPURender.h:345-371; /// attach/detach 必须配对)。渲染结果留在 GL 输出纹理上(插件 - /// 直接画进附着目标),宿主不做 CPU 回读;GL 模式下 + /// 直接画进附着目标),宿主经 glReadPixels 回读(render 驱动 + /// 负责,见 [`crate::gl_bridge`]);GL 模式下 /// clipGetImage(Output) 不可用(插件按规范走 OpenGL suite—— /// ofxGPURender.h "the effect SHOULD access all its images through /// the OpenGL suite")。render 返回前对未释放的输入 GL 纹理做 @@ -658,6 +665,7 @@ impl Instance { window: OfxRectD, renderer: crate::render::Renderer, output_texture: crate::render::Texture, + output_gl_texture: Option, ) -> crate::error::Result<()> { use crate::host::ACTION_RENDER; @@ -682,6 +690,7 @@ impl Instance { renderer, output_texture, gl_pixel_depth, + output_gl_texture, })); // GL 模式无 CPU 输出图像(current_output 保持 None)。 @@ -963,6 +972,143 @@ impl Instance { Ok(()) } + /// kOfxActionInstanceChanged 的宿主侧分发(ofxCore.h:405-435 的 + /// inArgs 契约): + /// + /// - kOfxPropType = kOfxTypeParameter(参数值变更) + /// - kOfxPropName = 变更的参数名 + /// - kOfxPropChangeReason = kOfxChange*(UserEdited / PluginEdited / + /// Time) + /// - kOfxPropTime = 变更发生时的效果时间(Image Effect 插件专属) + /// - kOfxImageEffectPropRenderScale = 当前渲染比例 + /// + /// 宿主按下 push button 后以 UserEdited 调用它(push button 无值, + /// 插件的反应全在 instanceChanged 里;真实插件如 CImg 依赖此动作 + /// 刷新内部状态)。返回非 OK/ReplyDefault 状态码时为 Err。 + pub fn instance_changed( + &self, + param_name: &str, + reason: crate::param::ChangeReason, + time: f64, + scale: RenderScale, + ) -> crate::error::Result<()> { + use crate::host::{ + ACTION_INSTANCE_CHANGED, CHANGE_PLUGIN_EDITED, CHANGE_TIME, CHANGE_USER_EDITED, + PROP_CHANGE_REASON, PROP_RENDER_SCALE, PROP_TIME, + }; + use crate::property::Value; + + let in_args = PropertySet::new(); + in_args.set_one( + crate::param::PROP_TYPE, + Value::String(CString::new(crate::param::TYPE_PARAMETER).unwrap()), + ); + in_args.set_one( + crate::param::PROP_NAME, + Value::String(CString::new(param_name).unwrap()), + ); + let reason_str = match reason { + crate::param::ChangeReason::UserEdited => CHANGE_USER_EDITED, + crate::param::ChangeReason::PluginEdited => CHANGE_PLUGIN_EDITED, + crate::param::ChangeReason::TimeChanged => CHANGE_TIME, + }; + in_args.set_one( + PROP_CHANGE_REASON, + Value::String(CString::new(reason_str).unwrap()), + ); + in_args.set_one(PROP_TIME, Value::Double(time)); + in_args.define( + PROP_RENDER_SCALE, + vec![Value::Double(scale.x), Value::Double(scale.y)], + ); + + let inst_handle = crate::suites::tag::make( + &self.props as *const PropertySet, + crate::suites::tag::INSTANCE, + ); + let out = PropertySet::new(); + let stat = unsafe { + self.plugin + .call_action(ACTION_INSTANCE_CHANGED, inst_handle, &in_args, &out) + }; + if stat != crate::suites::status::OK && stat != crate::suites::status::REPLY_DEFAULT { + return Err(crate::error::Error::Failed(format!( + "instanceChanged 失败:{stat}" + ))); + } + Ok(()) + } + + /// 创建关联的 interact(主进程 UI 事件宿主;任务契约的 + /// `new_interact(instance)`)。与 worker 进程里的渲染实例并存—— + /// OFX 允许同一插件多实例,interact 是独立对象(独立 handle), + /// 经 [`crate::suites::interact::Interact::handle`] 与效果实例区分。 + /// + /// 流程:插件声明的 overlay interact 入口(V2 优先、V1 次之; + /// ofxImageEffect.h:825/812)作为 interact 的入口——未声明则用插件 + /// main entry(任务契约)→ 建 [`Interact`](属性表 + tagged handle) + /// → 向入口发 `kOfxActionNewInteract`。 + /// + /// 返回值: + /// - `Some(interact)`:创建成功(NewInteract 返回 OK;或插件未实现 + /// NewInteract 但声明了 overlay interact——真实 overlay 插件不认 + /// NewInteract,走 describe/create 序列)。 + /// - `None`:插件无 interact(NewInteract 返回 kOfxStatReplyDefault + /// 且未声明 overlay 入口)或返回错误状态。 + /// + /// 创建后调用方继续 [`Interact::describe`] → [`Interact::create_instance`] + /// 完成实例化;销毁随实例自动连带([`Instance::notify_destroy`])。 + pub fn new_interact(&self) -> Option> { + use crate::host::overlay_interact_entry; + use crate::suites::interact::Interact; + + let overlay = overlay_interact_entry(&self.plugin.descriptor.props); + let entry = overlay.unwrap_or(self.plugin.entry); + let has_overlay = overlay.is_some(); + + let effect_handle = crate::suites::tag::make( + &self.props as *const PropertySet, + crate::suites::tag::INSTANCE, + ); + let interact = Interact::new(self.plugin.clone(), entry, effect_handle); + + let empty = PropertySet::new(); + let st = interact.call(crate::host::ACTION_NEW_INTERACT, &empty, &empty); + let accepted = match st { + crate::suites::status::OK => true, + // 插件未实现 NewInteract 但声明了 overlay interact → 仍创建 + //(走 describe/create 的官方序列)。 + crate::suites::status::REPLY_DEFAULT if has_overlay => true, + _ => false, + }; + if !accepted { + return None; + } + *self + .interact + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(interact.clone()); + Some(interact) + } + + /// 已创建的 interact(None = 未创建)。 + pub fn interact(&self) -> Option> { + self.interact + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + /// describe_interact(任务契约):对已创建的 interact 发 + /// `kOfxActionDescribe`(kOfxActionDescribeInteract)。未创建 → + /// kOfxStatErrBadHandle。 + pub fn describe_interact(&self) -> i32 { + match self.interact() { + Some(i) => i.describe(), + None => crate::suites::status::ERR_BAD_HANDLE, + } + } + /// 销毁(destroyInstance action)。析构由 `Arc>` /// 归零驱动;此处只做 action 通知,幂等([`Instance::drop`] 的 /// `destroyed` 门保证只发一次)。 @@ -973,6 +1119,17 @@ impl Instance { crate::suites::tag::INSTANCE, ); let empty = PropertySet::new(); + // interact 与实例同生命周期:实例销毁前先销毁 interact + //(ofxInteract.h kOfxActionDestroyInstanceInteract 的 \pre 要求 + // 实例成员尚未销毁)。 + if let Some(i) = self + .interact + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + i.destroy(); + } // 通知失败只记日志(销毁路径不可回滚)。 let stat = unsafe { self.plugin diff --git a/crates/oakplugin/src/lib.rs b/crates/oakplugin/src/lib.rs index 95e83752a..136e8a0bc 100644 --- a/crates/oakplugin/src/lib.rs +++ b/crates/oakplugin/src/lib.rs @@ -33,9 +33,11 @@ //! 删除:oaknode/oakrender/oakundo 均以 path 依赖直接链接。桥以 //! 直接 Rust 类型重建([`node`] 的身份注册表与 //! `set_input_*_undoable`;[`render`] 的 `Texture`/`Frame`/`Renderer` -//! 值类型),仅 GPU 相关且 wgpu 模型无等价物的调用面保留标注桩 -//! ([`render::texture_id`]——GL 命名空间不存在,见 `// STUB` -//! 标记)。 +//! 值类型)。GL 纹理名(旧 `oakrender_texture_id` 的 GL 命名空间) +//! 由 [`gl_bridge`](方案 B:离屏 CGL 上下文 + 回读)提供; +//! [`render::texture_id`] 对 oakrender 纹理保持恒 0(wgpu/Metal 无 +//! GL 命名空间),use_opengl 决策与 GL suite 的 OpenGLTextureIndex +//! 改从 [`gl_bridge`] 取真实名。 //! //! ## 句柄纪律(全 crate 最高优先级约定) //! diff --git a/crates/oakplugin/src/node_factory.rs b/crates/oakplugin/src/node_factory.rs index 11ccf4478..2ceb2c118 100644 --- a/crates/oakplugin/src/node_factory.rs +++ b/crates/oakplugin/src/node_factory.rs @@ -110,15 +110,14 @@ pub fn registered_instance_count() -> usize { /// inspector's button widget). /// /// OFX push buttons carry no value: the host's press signal is a single -/// set on the parameter (the plugin reacts in its own instanceChanged -/// action). Locates the instance and parameter, type-checks, then -/// `set_ofx`. Returns false when the instance/parameter is unknown or -/// the parameter is not a push button. -/// -/// TODO(instanceChanged): per the OFX contract the press should be -/// routed to the plugin as kOfxActionInstanceChanged (UserEdited); -/// oakplugin has no dispatcher for that action yet — for now the value -/// is only marked, and the plugin-side reaction is future work. +/// set on the parameter, then the press is routed to the plugin as +/// `kOfxActionInstanceChanged` (UserEdited) so the plugin reacts in its +/// own instanceChanged action (the OFX contract, ofxCore.h:405-435; +/// real plugins such as CImg depend on that action). Locates the +/// instance and parameter, type-checks, marks the value, then dispatches +/// the action. Returns false when the instance/parameter is unknown or +/// the parameter is not a push button; an instanceChanged failure is +/// logged and still reported as a successful press (the value was set). pub fn push_button_clicked(instance: u64, param_name: &str) -> bool { let Some(inst) = instance_from_id(instance) else { return false; @@ -130,6 +129,19 @@ pub fn push_button_clicked(instance: u64, param_name: &str) -> bool { return false; } p.set_ofx(ParamValue::PushButton); + // The current render context supplies the time / render scale when the + // press happens during a render; outside one, time 0 and scale 1:1. + let (time, scale) = crate::suites::render_ctx() + .map(|ctx| (ctx.time, ctx.scale)) + .unwrap_or((0.0, crate::instance::RenderScale { x: 1.0, y: 1.0 })); + if let Err(e) = inst.value.instance_changed( + param_name, + crate::param::ChangeReason::UserEdited, + time, + scale, + ) { + eprintln!("push_button_clicked: instanceChanged failed for \"{param_name}\": {e}"); + } true } @@ -853,6 +865,10 @@ pub fn install_render_executor() { mod tests { use super::*; + /// Records what the mock plugin entry saw, so the push-button test can + /// assert the kOfxActionInstanceChanged routing and its inArgs. + static PUSH_ENTRY_CALLS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + #[test] fn input_type_table_covers_all_ofx_kinds() { assert_eq!( @@ -1000,11 +1016,31 @@ mod tests { use crate::param::{ParamDef, ParamInstance, ParamSetInstance}; unsafe extern "C" fn dummy_entry( - _: *const c_char, + action: *const c_char, _: *const c_void, - _: *mut c_void, + in_args: *mut c_void, _: *mut c_void, ) -> i32 { + if !action.is_null() { + let action = unsafe { + std::ffi::CStr::from_ptr(action).to_string_lossy().into_owned() + }; + if action == crate::host::ACTION_INSTANCE_CHANGED { + let mut calls = PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()); + calls.push(action); + // The instanceChanged inArgs contract (ofxCore.h:405-435): + // kOfxPropChangeReason = kOfxChangeUserEdited. + let props = unsafe { &*(in_args as *const crate::property::PropertySet) }; + let reason = props.get(crate::host::PROP_CHANGE_REASON, 0); + let reason = match reason { + Some(crate::property::Value::String(s)) => { + s.to_string_lossy().into_owned() + } + _ => String::new(), + }; + calls.push(reason); + } + } 0 } let plugin = Arc::new(Plugin { @@ -1039,6 +1075,7 @@ mod tests { cancel: std::sync::atomic::AtomicBool::new(false), edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), }; register_instance(Arc::new(RefBox { refs: AtomicU32::new(1), @@ -1047,15 +1084,21 @@ mod tests { } /// push_button_clicked:实例/参数查无与类型不匹配 → false;命中 → - /// true 并触发一次 set。 + /// true、置 PushButton 值并把按下路由为 kOfxActionInstanceChanged + /// (UserEdited,inArgs 带 change reason / name / type / time / + /// render scale)。 #[test] - fn push_button_clicked_requires_a_push_button_param() { + fn push_button_clicked_routes_instance_changed() { + *PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new(); let id = instance_with_push_button(); assert!(push_button_clicked(id, "button")); - // 类型不匹配 / 查无参数 / 查无实例。 + // 类型不匹配 / 查无参数 / 查无实例:不触发 entry。 assert!(!push_button_clicked(id, "gain")); assert!(!push_button_clicked(id, "nope")); assert!(!push_button_clicked(u64::MAX, "button")); + // 只 "button" 那次按下进了插件 entry,且 reason 是 UserEdited。 + let calls = PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert_eq!(calls, vec!["OfxActionInstanceChanged", "OfxChangeUserEdited"]); unregister_instance(id); } } diff --git a/crates/oakplugin/src/progress.rs b/crates/oakplugin/src/progress.rs index b0c6b3036..3a63b8771 100644 --- a/crates/oakplugin/src/progress.rs +++ b/crates/oakplugin/src/progress.rs @@ -39,6 +39,9 @@ use std::sync::{Arc, Mutex, OnceLock}; pub trait UiProgressReporter: Send { /// 上报进度(0.0..=1.0);false = 取消。 fn update(&mut self, progress: f64) -> bool; + /// progressEnd 通知(默认 no-op;app 可据此关闭进度 UI / 把完成 + /// 事件转发过 IPC——worker 侧的进度回传依赖它)。 + fn end(&mut self) {} } /// 报告器工厂:progressStart 携 (label, message) 调用,现造一个 @@ -160,4 +163,12 @@ impl ProgressReporter { pub(crate) fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::Relaxed) } + + /// progressEnd 钩子:转发给 UI 报告器(无报告器时 no-op)。 + pub(crate) fn end_ui(&self) { + let mut slot = self.ui.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(ui) = slot.as_mut() { + ui.end(); + } + } } diff --git a/crates/oakplugin/src/render.rs b/crates/oakplugin/src/render.rs index 13ed1b35c..0cca0641b 100644 --- a/crates/oakplugin/src/render.rs +++ b/crates/oakplugin/src/render.rs @@ -33,10 +33,11 @@ //! - 像素格式常量直接别名 [`oakcore_rs::PixelFormat`]。 //! //! 保留桩(GPU 相关、wgpu 模型无直接 Rust 等价物): -//! [`texture_id`]——wgpu 后端没有 OpenGL 纹理名(旧 C ABI 的 -//! `oakrender_texture_id` 语义是 GL 命名空间),恒 0;GL suite 的 -//! `OpenGLTextureIndex` 属性与 render 驱动的 use_opengl 决策据此 -//! 回退 CPU 路径。 +//! [`texture_id`]——oakrender 纹理(wgpu/CPU)没有 OpenGL 纹理名, +//! 恒 0。**GL 模式的真实 GL 纹理名来自 [`crate::gl_bridge`]**(方案 B +//! 已落地):render 驱动为输出帧建 GL 纹理 + FBO,GL suite 的 +//! `OpenGLTextureIndex` 属性据此返回真实名;use_opengl 决策改用 +//! [`crate::gl_bridge::gl_available`] 作"目标纹理有有效 GL 名"的门。 /// `oakrender_video_params` POD — single-lib unification: aliases the /// oakrender crate's struct (identical layout; @@ -124,13 +125,15 @@ pub fn texture_create( Ok(Texture::wrap_frame(frame)) } -/// STUB: oakrender C ABI deleted (single-lib),且 wgpu 后端没有 -/// OpenGL 纹理名——旧 `oakrender_texture_id` 的 GL 命名空间语义无 -/// Rust 等价物。恒 0(GL suite 的 `OpenGLTextureIndex` 属性与 -/// render 驱动的 use_opengl 决策据此回退 CPU 路径;GPU 上传若落地 -/// 走 `oakrender::backend::GpuContextLike::upload` 的 wgpu token, -/// 不暴露 GL id)。真实化的评估与方案见 [`crate::gl_bridge`] -/// (阶段 6a spike:方案 A 不可行,方案 B 暂缓)。 +/// oakrender 纹理在 GL 命名空间中的纹理名。oakrender 纹理(wgpu +/// Metal / CPU 帧)没有 OpenGL 纹理名,恒 0。 +/// +/// **GL 模式的真实纹理名不由本函数提供**:宿主自建的离屏 GL 纹理 +/// (输出帧 + FBO 挂载、输入 clip 上传)由 [`crate::gl_bridge`] 产生, +/// 经 GL suite 的 `OpenGLTextureIndex` 属性直接写出;use_opengl 决策 +/// 用 [`crate::gl_bridge::gl_available`] 当"目标纹理有有效 GL 名"的 +/// 门(桥能为目标帧建出真实 GL 纹理 ⟺ 可用)。评估历史见 +/// [`crate::gl_bridge`] 模块文档。 pub fn texture_id(_texture: &Texture) -> i32 { 0 } diff --git a/crates/oakplugin/src/render_driver.rs b/crates/oakplugin/src/render_driver.rs index 9d740d06b..a18ae2ead 100644 --- a/crates/oakplugin/src/render_driver.rs +++ b/crates/oakplugin/src/render_driver.rs @@ -47,8 +47,10 @@ //! 10. 参数覆盖(pluginrenderer.cpp:132-290 apply_param_overrides); //! 11. render action:CPU 路径经 [`crate::instance::Instance::render`] //! (输出装配:图像 → 目标纹理帧,行跨度感知);GL 路径经 -//! [`crate::instance::Instance::render_gl`](插件直接画进已附着 -//! 的输出纹理,无 CPU 回读)。 +//! [`crate::instance::Instance::render_gl`] + [`crate::gl_bridge`] +//! (方案 B:宿主建离屏上下文,插件画进 FBO 附着的输出 GL 纹理, +//! render 返回后 glReadPixels 回读装配——输出格式与 CPU 路径一致; +//! GL 失败回退 CPU)。 //! //! ## begin/end 序列括号 //! @@ -112,6 +114,10 @@ pub fn begin_sequence( gl: Option, ) -> crate::error::Result<()> { if gl.is_some() { + // GL 模式:ofxGPURender.h "OpenGL Current Context" 要求 + // BeginSequenceRender 期间上下文 current(插件可能在此分配 GL + // 资源)。acquire 覆盖整个 action;返回即清 current。 + let _guard = crate::gl_bridge::acquire()?; inst.begin_sequence_render_gl(range) } else { inst.begin_sequence_render(range) @@ -125,6 +131,7 @@ pub fn end_sequence( gl: Option, ) -> crate::error::Result<()> { if gl.is_some() { + let _guard = crate::gl_bridge::acquire()?; inst.end_sequence_render_gl(range) } else { inst.end_sequence_render(range) @@ -153,18 +160,18 @@ pub fn render_frame( } // 2. use_opengl(pluginrenderer.cpp:1446-1457):插件声明 GL 支持 - // 且渲染器是 OpenGL 且目标纹理有 GL id 且像素深度协商可行 - // (管线 F32 满足插件 kOfxOpenGLPropPixelDepth 声明)。 - // `texture_id` 为桩恒 0(wgpu 无 GL 命名空间)→ 本决策恒回退 - // CPU 路径,GL 分支保留给 GL 后端落地。 + // 且渲染器是 OpenGL 且像素深度协商可行(管线 F32 满足插件 + // kOfxOpenGLPropPixelDepth 声明)且目标纹理有有效 GL 名(= 桥能为 + // 目标帧建出真实 GL 纹理 ⟺ 离屏上下文可用,[`crate::gl_bridge`])。 + // 任一不满足 → 回退 CPU 路径(GL 分支在下方真正渲染)。 let use_opengl = match job.renderer.as_ref() { Some(r) if render::renderer_is_open_gl(r) => { let plugin_gl = plugin_supports_opengl(inst); let depth_ok = crate::suites::gl_render::pick_gl_pixel_depth(&inst.plugin.descriptor.props) .is_some(); - let dst_id = render::texture_id(&job.dst); - plugin_gl && depth_ok && dst_id != 0 + let gl_name_ok = crate::gl_bridge::gl_available(); + plugin_gl && depth_ok && gl_name_ok } _ => false, }; @@ -276,20 +283,103 @@ pub fn render_frame( // 输出装配(pluginrenderer.cpp:1762-1834 的 CPU 路径)。 write_output_frame(&mut dst, &output)?; } else { - // GL 路径:插件直接画进已附着的输出纹理( - // pluginrenderer.cpp:1784-1834 的 GL 分支);无 CPU 回读。 - inst.render_gl( - job.time, + // GL 路径(方案 B,见 [`crate::gl_bridge`]):宿主自建离屏 + // 上下文,为输出帧建 GL 纹理 + FBO(插件直接画进附着的输出 + // 纹理,等价 C++ attach_output_texture),render 返回后 + // glReadPixels 回读装帧(pluginrenderer.cpp:1784-1834 的 GL + // 分支 + 本实现的回读装配)。GL 失败回退 CPU(对齐现有失败 + // 语义;最终失败仍上抛)。 + match render_gl_frame( + inst, + job, RenderScale { x: 1.0, y: 1.0 }, render_window, - job.renderer.clone().unwrap(), - dst.clone(), - )?; + w, + h, + &dst_params, + ) { + Ok(image) => write_output_frame(&mut dst, &image)?, + Err(gl_err) => { + eprintln!("[PLUGIN] GL 渲染失败,回退 CPU:{gl_err}"); + let output = std::sync::Arc::new(Image::allocate( + crate::image::BitDepth::Float, + components, + OfxRectD { + x1: 0.0, + y1: 0.0, + x2: w, + y2: h, + }, + )); + inst.render( + job.time, + RenderScale { x: 1.0, y: 1.0 }, + render_window, + output.clone(), + )?; + write_output_frame(&mut dst, &output)?; + } + } } Ok((dst, zip_rois(inst, &rois))) } +/// GL 渲染一帧(render_frame 的 GL 分支主体;返回回读装配完成的 +/// F32 RGBA 图像)。 +/// +/// 流程:acquire 离屏上下文(本线程 current,全局串行)→ 建输出 GL +/// 纹理(尺寸 = 目标帧,格式按 `dst_params`)→ FBO 挂载并绑定 → +/// 视口 → [`Instance::render_gl`](插件画进附着纹理;真实纹理名经 +/// GlCtx 注入,clipLoadTexture(Output) 返回它)→ glReadPixels 回读 +/// 装配(垂直翻转 + 格式转换)→ 清理 FBO/纹理。任何一步失败返回 +/// Err,调用方回退 CPU。 +fn render_gl_frame( + inst: &Instance, + job: &RenderJob, + scale: RenderScale, + window: OfxRectD, + w: f64, + h: f64, + dst_params: &render::VideoParams, +) -> crate::error::Result> { + use crate::error::Error; + + // 离屏上下文(进程级共享;本线程 current 直到 guard drop)。 + let _guard = crate::gl_bridge::acquire()?; + let (wpx, hpx) = (w as i32, h as i32); + // 输出 GL 纹理 + FBO 挂载(GL_RGBA32F/GL_RGBA8 按目标帧格式)。 + let gl_tex = crate::gl_bridge::create_output_texture(wpx, hpx, dst_params)?; + let fbo = crate::gl_bridge::create_fbo(gl_tex, wpx, hpx)?; + crate::gl_bridge::bind_fbo(fbo); + crate::gl_bridge::set_viewport(wpx, hpx); + + // render action(OpenGLEnabled=1;输出纹理真实名经 GlCtx 注入)。 + let render_res = inst.render_gl( + job.time, + scale, + window, + job.renderer.clone().unwrap(), + job.dst.clone(), + Some(gl_tex), + ); + + // 回读前防御性重绑 FBO + 视口(规范下插件不解除输出绑定,但个别 + // 插件可能改绑/改视口——重绑保证 glReadPixels 读的是输出纹理)。 + crate::gl_bridge::bind_fbo(fbo); + crate::gl_bridge::set_viewport(wpx, hpx); + + // 回读(FBO 仍绑定、输出 GL 纹理仍存活)。 + let readback = crate::gl_bridge::read_pixels_to_image(wpx, hpx, dst_params); + + // 清理(guard drop 时清 current 并放锁)。 + crate::gl_bridge::delete_fbo(fbo); + crate::gl_bridge::delete_gl_texture(gl_tex); + + render_res?; + readback.map_err(|e| Error::Failed(format!("GL 回读失败:{e}"))) +} + /// 把输入 clip 名与 RoI 列表配对(与 `clips` 顺序一致)。 fn zip_rois(inst: &Instance, rois: &[OfxRectD]) -> Vec<(String, OfxRectD)> { inst.clips diff --git a/crates/oakplugin/src/suites/draw.rs b/crates/oakplugin/src/suites/draw.rs new file mode 100644 index 000000000..ca49f6069 --- /dev/null +++ b/crates/oakplugin/src/suites/draw.rs @@ -0,0 +1,795 @@ +// 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 . + +//! OfxDrawSuiteV1(OFX 1.5,vendored ofxDrawSuite.h):宿主绘制套件。 +//! +//! 插件在 `kOfxInteractActionDraw` 内经 `kOfxInteractPropDrawContext` +//! 取到绘制上下文(本模块的 [`DrawContext`]),再调 suite 函数画图: +//! getColour / setColour / setLineWidth / setLineStipple / draw / +//! drawText。 +//! +//! ## 上下文模型 +//! +//! - [`DrawContext`] 在每次 draw action 前由宿主创建、经存活表 +//! [`LIVE_CONTEXTS`] 注册(句柄 = 堆地址;对应 [`crate::suites::tag`] +//! 的句柄纪律),draw 返回后摘除。插件只在 draw action 内持有上下文 +//! ——ofxDrawSuite.h 各函数文档注明 "failure, e.g. if function is +//! called outside kOfxInteractActionDraw" → 存活表查不到即 +//! kOfxStatFailed。 +//! - 状态(colour / lineWidth / stipple / viewport / pixelScale)保存在 +//! [`DrawContext`],draw action 之间不共享。 +//! - GL 命令要求上下文 current:宿主在调 `kOfxInteractActionDraw` 前经 +//! [`crate::gl_bridge::acquire`] 保持 current(draw action 全期)。 +//! +//! ## 绘制实现(真实 GL) +//! +//! gl_bridge 的 CGL 上下文是 **3.2 core profile**(无固定管线, +//! glBegin 不可用)——本套件的 draw 用最小着色器 + VAO/VBO 真实渲染: +//! 正交投影把 canonical 坐标映射到 NDC(canonical 宽 = viewport / +//! pixelScale),非不透明色按 "over" 合成(ofxDrawSuite.h setColour +//! 文档要求)。非 macOS 无 GL 桥 → kOfxStatFailed。 +//! +//! ## drawText +//! +//! 本宿主无字体光栅化器(GL 内画字形需要字库/曲线上采样,超出 Phase +//! 1 范围),如实返回 kOfxStatErrUnsupported,不假装画了字。 + +use std::collections::HashMap; +use std::ffi::{c_char, c_float, c_int, c_void, CStr}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{LazyLock, Mutex}; + +use crate::suites::status; + +/// `OfxStandardColour`(ofxDrawSuite.h:37-46)枚举值。 +pub mod std_colour { + /// kOfxStandardColourOverlayBackground。 + pub const BACKGROUND: i32 = 0; + /// kOfxStandardColourOverlayActive。 + pub const ACTIVE: i32 = 1; + /// kOfxStandardColourOverlaySelected。 + pub const SELECTED: i32 = 2; + /// kOfxStandardColourOverlayDeselected。 + pub const DESELECTED: i32 = 3; + /// kOfxStandardColourOverlayMarqueeFG。 + pub const MARQUEE_FG: i32 = 4; + /// kOfxStandardColourOverlayMarqueeBG。 + pub const MARQUEE_BG: i32 = 5; + /// kOfxStandardColourOverlayText。 + pub const TEXT: i32 = 6; +} + +/// `OfxDrawLineStipplePattern`(ofxDrawSuite.h:49-56)枚举值。 +pub mod stipple { + /// kOfxDrawLineStipplePatternSolid。 + pub const SOLID: i32 = 0; + /// kOfxDrawLineStipplePatternDot。 + pub const DOT: i32 = 1; + /// kOfxDrawLineStipplePatternDash。 + pub const DASH: i32 = 2; + /// kOfxDrawLineStipplePatternAltDash。 + pub const ALT_DASH: i32 = 3; + /// kOfxDrawLineStipplePatternDotDash。 + pub const DOT_DASH: i32 = 4; +} + +/// `OfxDrawPrimitive`(ofxDrawSuite.h:60-68)枚举值。 +pub mod primitive { + /// kOfxDrawPrimitiveLines(n 点画 n/2 条独立线段)。 + pub const LINES: i32 = 0; + /// kOfxDrawPrimitiveLineStrip。 + pub const LINE_STRIP: i32 = 1; + /// kOfxDrawPrimitiveLineLoop。 + pub const LINE_LOOP: i32 = 2; + /// kOfxDrawPrimitiveRectangle(轴对齐实心矩形,2 对角点)。 + pub const RECTANGLE: i32 = 3; + /// kOfxDrawPrimitivePolygon(实心 n 边形)。 + pub const POLYGON: i32 = 4; + /// kOfxDrawPrimitiveEllipse(2 对角点包围盒内的轴对齐椭圆**线框**)。 + pub const ELLIPSE: i32 = 5; +} + +/// `OfxRGBAColourF`(ofxPixels.h):Draw suite 的颜色值类型。 +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OfxRGBAColourF { + /// 红。 + pub r: f32, + /// 绿。 + pub g: f32, + /// 蓝。 + pub b: f32, + /// alpha。 + pub a: f32, +} + +/// `OfxPointD`(ofxCore.h:819):canonical 坐标点。 +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OfxPointD { + /// x。 + pub x: f64, + /// y。 + pub y: f64, +} + +/// Draw suite 上下文:一次 draw action 期间插件的绘制状态。 +/// +/// 宿主在 [`crate::suites::interact::Interact::draw`] 内创建并注册, +/// draw 返回后摘除。`viewport`/`pixel_scale` 在创建时快照(canonical +/// → NDC 正交投影用);颜色/线宽/虚线样式由插件经 suite 函数写入。 +pub struct DrawContext { + /// 当前绘制颜色(RGBA;setColour 写入,draw 时作为着色器 uniform)。 + pub colour: [f32; 4], + /// 当前线宽(setLineWidth;GL 线绘制 glLineWidth)。 + pub line_width: f32, + /// 当前虚线样式(setLineStipple;`stipple::*` 枚举)。 + pub stipple: i32, + /// 视口尺寸(像素;创建时快照)。 + pub viewport: (f64, f64), + /// canonical→屏幕像素比例(创建时快照)。 + pub pixel_scale: (f64, f64), +} + +impl DrawContext { + /// 新上下文(默认颜色不透明黑、线宽 1、实线)。 + pub fn new(viewport: (f64, f64), pixel_scale: (f64, f64)) -> Self { + Self { + colour: [0.0, 0.0, 0.0, 1.0], + line_width: 1.0, + stipple: stipple::SOLID, + viewport, + pixel_scale, + } + } +} + +/// 存活绘制上下文表:draw action 期间注册(地址 → 盒值)。 +/// 插件只在 draw 内持有句柄,draw 返回即摘除(同 +/// [`crate::suites::gl_render`] 的 LIVE_TEXTURES 模式)。 +static LIVE_CONTEXTS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn lock() -> std::sync::MutexGuard<'static, HashMap>> { + LIVE_CONTEXTS.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// 注册上下文并返回句柄(宿主 draw action 调用;句柄写入 inArgs 的 +/// kOfxInteractPropDrawContext)。 +pub(crate) fn make_context(viewport: (f64, f64), pixel_scale: (f64, f64)) -> *mut c_void { + let ctx = Box::new(DrawContext::new(viewport, pixel_scale)); + let addr = &*ctx as *const DrawContext as usize; + lock().insert(addr, ctx); + addr as *mut c_void +} + +/// 摘除上下文(draw action 返回后调用;后续 suite 调用 → Failed)。 +pub(crate) fn drop_context(handle: *mut c_void) { + lock().remove(&(handle as usize)); +} + +/// 公共入口模板:panic 兜底。 +fn caught(f: impl FnOnce() -> Result<(), c_int>) -> c_int { + catch_unwind(AssertUnwindSafe(f)).map_or_else( + |_| status::FAILED, + |r| r.map_or_else(|c| c, |()| status::OK), + ) +} + +/// 上下文句柄解析(存活表反查;draw action 外 → Failed,ofxDrawSuite.h +/// 的 "outside kOfxInteractActionDraw" 语义)。 +fn resolve(handle: *mut c_void) -> Result<&'static DrawContext, c_int> { + if handle.is_null() { + return Err(status::ERR_BAD_HANDLE); + } + let addr = handle as usize; + lock() + .get(&addr) + .map(|b| unsafe { &*(&**b as *const DrawContext) }) + .ok_or(status::FAILED) +} + +/// 可变访问(resolve 的写路径;draw 期单线程,存活保证同 resolve)。 +fn resolve_mut(handle: *mut c_void) -> Result<(), c_int> { + resolve(handle).map(|_| ()) +} + +// ---- 宿主标准颜色板(getColour)---- + +/// 宿主标准颜色板:按 `OfxStandardColour` 枚举序(ofxDrawSuite.h:37-46)。 +const PALETTE: [[f32; 4]; 7] = [ + // BACKGROUND:宿主视口背景(本宿主 Phase 1 无合成背景,黑)。 + [0.0, 0.0, 0.0, 1.0], + // ACTIVE:进行中/激活的 overlay 元素。 + [1.0, 0.84, 0.0, 1.0], + // SELECTED:选中元素。 + [0.0, 1.0, 0.0, 1.0], + // DESELECTED:未选中元素。 + [1.0, 1.0, 1.0, 0.5], + // MARQUEE_FG:框选前景。 + [1.0, 1.0, 1.0, 1.0], + // MARQUEE_BG:框选背景。 + [0.0, 0.0, 0.0, 0.5], + // TEXT:overlay 文本。 + [1.0, 1.0, 1.0, 1.0], +]; + +unsafe extern "C" fn get_colour( + ctx: *mut c_void, + std_colour: c_int, + out: *mut OfxRGBAColourF, +) -> c_int { + caught(|| { + if out.is_null() { + return Err(status::ERR_VALUE); + } + let _ = resolve(ctx)?; + let c = PALETTE + .get(std_colour as usize) + .ok_or(status::ERR_VALUE)?; + unsafe { + *out = OfxRGBAColourF { + r: c[0], + g: c[1], + b: c[2], + a: c[3], + }; + } + Ok(()) + }) +} + +unsafe extern "C" fn set_colour(ctx: *mut c_void, colour: *const OfxRGBAColourF) -> c_int { + caught(|| { + if colour.is_null() { + return Err(status::ERR_VALUE); + } + let _ = resolve_mut(ctx)?; + let v = unsafe { &*colour }; + let addr = ctx as usize; + let mut live = lock(); + let slot = live.get_mut(&addr).expect("resolve 已保证存活"); + slot.colour = [v.r, v.g, v.b, v.a]; + Ok(()) + }) +} + +unsafe extern "C" fn set_line_width(ctx: *mut c_void, width: c_float) -> c_int { + caught(|| { + let _ = resolve_mut(ctx)?; + let addr = ctx as usize; + let mut live = lock(); + let slot = live.get_mut(&addr).expect("resolve 已保证存活"); + slot.line_width = width; + Ok(()) + }) +} + +unsafe extern "C" fn set_line_stipple(ctx: *mut c_void, pattern: c_int) -> c_int { + caught(|| { + if !(0..=stipple::DOT_DASH).contains(&pattern) { + return Err(status::ERR_VALUE); + } + let _ = resolve_mut(ctx)?; + let addr = ctx as usize; + let mut live = lock(); + let slot = live.get_mut(&addr).expect("resolve 已保证存活"); + slot.stipple = pattern; + Ok(()) + }) +} + +unsafe extern "C" fn draw( + ctx: *mut c_void, + prim: c_int, + points: *const OfxPointD, + count: c_int, +) -> c_int { + caught(|| { + if !(0..=primitive::ELLIPSE).contains(&prim) { + return Err(status::ERR_VALUE); + } + if count < 0 || (count > 0 && points.is_null()) { + return Err(status::ERR_VALUE); + } + // 每原语的合法点数(ofxDrawSuite.h draw 文档)。 + let valid = match prim { + primitive::LINES => count >= 2 && count % 2 == 0, + primitive::LINE_STRIP | primitive::LINE_LOOP => count >= 2, + primitive::RECTANGLE | primitive::ELLIPSE => count == 2, + primitive::POLYGON => count >= 3, + _ => false, + }; + if !valid { + return Err(status::ERR_VALUE); + } + let c = resolve(ctx)?; + let pts: Vec = (0..count as usize) + .map(|i| unsafe { *points.add(i) }) + .collect(); + // 真实 GL 渲染(macOS 3.2 core;非 macOS stub → Failed)。 + gl_imp::render(c, prim, &pts) + }) +} + +unsafe extern "C" fn draw_text( + ctx: *mut c_void, + text: *const c_char, + pos: *const OfxPointD, + _alignment: c_int, +) -> c_int { + caught(|| { + if text.is_null() || pos.is_null() { + return Err(status::ERR_VALUE); + } + let _ = resolve(ctx)?; + // 确认文本有效(UTF-8);随后如实拒绝(无字体光栅化器)。 + let _ = unsafe { CStr::from_ptr(text) } + .to_str() + .map_err(|_| status::ERR_VALUE)?; + Err(status::ERR_UNSUPPORTED) + }) +} + +/// 函数表布局(与 SDK `OfxDrawSuiteV1` 逐字段一致;ofxDrawSuite.h:85-177)。 +#[repr(C)] +pub struct DrawSuiteV1 { + /// getColour:宿主标准颜色板取色。 + pub get_colour: unsafe extern "C" fn(*mut c_void, c_int, *mut OfxRGBAColourF) -> c_int, + /// setColour:设置后续绘制颜色。 + pub set_colour: unsafe extern "C" fn(*mut c_void, *const OfxRGBAColourF) -> c_int, + /// setLineWidth:设置后续线宽。 + pub set_line_width: unsafe extern "C" fn(*mut c_void, c_float) -> c_int, + /// setLineStipple:设置后续虚线样式。 + pub set_line_stipple: unsafe extern "C" fn(*mut c_void, c_int) -> c_int, + /// draw:绘制原语(点数组,canonical 坐标)。 + pub draw: unsafe extern "C" fn(*mut c_void, c_int, *const OfxPointD, c_int) -> c_int, + /// drawText:绘制文本(Phase 1 返回 kOfxStatErrUnsupported)。 + pub draw_text: unsafe extern "C" fn(*mut c_void, *const c_char, *const OfxPointD, c_int) -> c_int, +} + +/// 函数表实例。 +pub fn suite_v1() -> &'static DrawSuiteV1 { + static SUITE: std::sync::OnceLock = std::sync::OnceLock::new(); + SUITE.get_or_init(|| DrawSuiteV1 { + get_colour, + set_colour, + set_line_width, + set_line_stipple, + draw, + draw_text, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 标准颜色板:getColour 按枚举取宿主色板(越界 → ErrValue)。 + #[test] + fn get_colour_palette() { + let ctx = make_context((640.0, 480.0), (1.0, 1.0)); + let mut out = OfxRGBAColourF { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }; + unsafe { + assert_eq!(get_colour(ctx, std_colour::ACTIVE, &mut out), status::OK); + } + assert_eq!((out.r, out.g, out.b, out.a), (1.0, 0.84, 0.0, 1.0)); + // 越界枚举 → ErrValue。 + unsafe { + assert_eq!(get_colour(ctx, 99, &mut out), status::ERR_VALUE); + assert_eq!(get_colour(ctx, -1, &mut out), status::ERR_VALUE); + // 空输出指针 → ErrValue。 + assert_eq!( + get_colour(ctx, std_colour::BACKGROUND, std::ptr::null_mut()), + status::ERR_VALUE + ); + } + drop_context(ctx); + } + + /// setColour 真实写入状态:再 setLineWidth/setLineStipple 后从存活 + /// 表读回(draw action 期插件视图)。 + #[test] + fn set_colour_and_line_state() { + let ctx = make_context((100.0, 100.0), (2.0, 2.0)); + let col = OfxRGBAColourF { r: 0.9, g: 0.1, b: 0.2, a: 1.0 }; + unsafe { + assert_eq!(set_colour(ctx, &col), status::OK); + assert_eq!(set_line_width(ctx, 3.5), status::OK); + assert_eq!(set_line_stipple(ctx, stipple::DASH), status::OK); + assert_eq!(set_line_stipple(ctx, 99), status::ERR_VALUE); + } + + let addr = ctx as usize; + let live = lock(); + let c = live.get(&addr).unwrap(); + assert_eq!(c.colour, [0.9, 0.1, 0.2, 1.0]); + assert_eq!(c.line_width, 3.5); + assert_eq!(c.stipple, stipple::DASH); + drop(live); + drop_context(ctx); + } + + /// 摘除后调用 → Failed("outside kOfxInteractActionDraw" 语义)。 + #[test] + fn draw_suite_outside_draw_fails() { + let ctx = make_context((10.0, 10.0), (1.0, 1.0)); + drop_context(ctx); + let col = OfxRGBAColourF { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; + let mut out = OfxRGBAColourF { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }; + let s = std::ffi::CString::new("x").unwrap(); + let pos = OfxPointD { x: 0.0, y: 0.0 }; + let pts = [OfxPointD { x: 0.0, y: 0.0 }, OfxPointD { x: 10.0, y: 10.0 }]; + unsafe { + assert_eq!(set_colour(ctx, &col), status::FAILED); + assert_eq!(get_colour(ctx, std_colour::TEXT, &mut out), status::FAILED); + assert_eq!(set_line_width(ctx, 1.0), status::FAILED); + assert_eq!(set_line_stipple(ctx, stipple::SOLID), status::FAILED); + // 合法参数(避开空指针检查)→ 摘除后 resolve 失败 → Failed。 + assert_eq!(draw(ctx, primitive::RECTANGLE, pts.as_ptr(), 2), status::FAILED); + // drawText 同样 Failed(先 resolve 失败)。 + assert_eq!(draw_text(ctx, s.as_ptr(), &pos, 0), status::FAILED); + } + } + + /// draw 原语参数校验(不触 GL):非法原语/点数 → ErrValue。 + #[test] + fn draw_primitive_argument_validation() { + let ctx = make_context((10.0, 10.0), (1.0, 1.0)); + let pts = [OfxPointD { x: 0.0, y: 0.0 }, OfxPointD { x: 10.0, y: 10.0 }]; + unsafe { + // 非法原语枚举。 + assert_eq!(draw(ctx, 99, pts.as_ptr(), 2), status::ERR_VALUE); + assert_eq!(draw(ctx, -1, pts.as_ptr(), 2), status::ERR_VALUE); + // 点数不合法:LINES 奇数点。 + assert_eq!(draw(ctx, primitive::LINES, pts.as_ptr(), 3), status::ERR_VALUE); + // RECTANGLE 需恰好 2 点。 + assert_eq!(draw(ctx, primitive::RECTANGLE, pts.as_ptr(), 3), status::ERR_VALUE); + // POLYGON 需 ≥3 点。 + assert_eq!(draw(ctx, primitive::POLYGON, pts.as_ptr(), 2), status::ERR_VALUE); + // 空指针 + 正点数。 + assert_eq!(draw(ctx, primitive::LINES, std::ptr::null(), 2), status::ERR_VALUE); + // 合法参数:环境相关的 GL 路径(无 current 上下文时宿主可能 + // 失败)——参数校验已过即可,具体状态不在此断言。 + let st = draw(ctx, primitive::RECTANGLE, pts.as_ptr(), 2); + assert!( + st == status::OK || st == status::FAILED, + "合法参数应 OK(有 GL)或 Failed(无 GL 上下文),got {st}" + ); + } + drop_context(ctx); + } + + /// drawText:有效参数 → Unsupported(如实拒绝);空指针 → ErrValue。 + #[test] + fn draw_text_is_honest_unsupported() { + let ctx = make_context((10.0, 10.0), (1.0, 1.0)); + let s = std::ffi::CString::new("hello").unwrap(); + let pos = OfxPointD { x: 1.0, y: 2.0 }; + unsafe { + assert_eq!(draw_text(ctx, s.as_ptr(), &pos, 0), status::ERR_UNSUPPORTED); + assert_eq!(draw_text(ctx, std::ptr::null(), &pos, 0), status::ERR_VALUE); + } + drop_context(ctx); + } +} + +// ---- GL 绘制实现(平台分派)-------------------------------------------- + +#[cfg(target_os = "macos")] +mod gl_imp { + use super::{DrawContext, OfxPointD}; + use crate::suites::status; + use std::ffi::c_void; + + // ---- GL 常量(GL 规范值)---- + const GL_FALSE: i32 = 0; + const GL_FLOAT: u32 = 0x1406; + const GL_ARRAY_BUFFER: u32 = 0x8892; + const GL_STATIC_DRAW: u32 = 0x88E4; + const GL_FRAGMENT_SHADER: u32 = 0x8B30; + const GL_VERTEX_SHADER: u32 = 0x8B31; + const GL_COMPILE_STATUS: u32 = 0x8B81; + const GL_LINK_STATUS: u32 = 0x8B82; + const GL_TRIANGLES: u32 = 0x0004; + const GL_LINES: u32 = 0x0001; + const GL_LINE_STRIP: u32 = 0x0003; + const GL_LINE_LOOP: u32 = 0x0002; + const GL_BLEND: u32 = 0x0BE2; + const GL_SRC_ALPHA: u32 = 0x0302; + const GL_ONE_MINUS_SRC_ALPHA: u32 = 0x0303; + const GL_NO_ERROR: u32 = 0; + + // # Safety: 全部是 OpenGL.framework 导出函数;参数语义见各函数 + // 注释。调用前提:GL 上下文 current(宿主 draw action 已 acquire)。 + #[link(name = "OpenGL", kind = "framework")] + unsafe extern "C" { + fn glGenVertexArrays(n: i32, arrays: *mut u32); + fn glBindVertexArray(array: u32); + fn glGenBuffers(n: i32, buffers: *mut u32); + fn glBindBuffer(target: u32, buffer: u32); + fn glBufferData(target: u32, size: isize, data: *const c_void, usage: u32); + fn glCreateShader(type_: u32) -> u32; + fn glShaderSource(shader: u32, count: i32, string: *const *const i8, length: *const i32); + fn glCompileShader(shader: u32); + fn glGetShaderiv(shader: u32, pname: u32, params: *mut i32); + fn glDeleteShader(shader: u32); + fn glCreateProgram() -> u32; + fn glAttachShader(program: u32, shader: u32); + fn glLinkProgram(program: u32); + fn glGetProgramiv(program: u32, pname: u32, params: *mut i32); + fn glUseProgram(program: u32); + fn glGetAttribLocation(program: u32, name: *const i8) -> i32; + fn glVertexAttribPointer( + index: u32, + size: i32, + type_: u32, + normalized: u8, + stride: i32, + pointer: *const c_void, + ); + fn glEnableVertexAttribArray(index: u32); + fn glGetUniformLocation(program: u32, name: *const i8) -> i32; + fn glUniformMatrix4fv(location: i32, count: i32, transpose: u8, value: *const f32); + fn glUniform4f(location: i32, x: f32, y: f32, z: f32, w: f32); + fn glDrawArrays(mode: u32, first: i32, count: i32); + fn glLineWidth(width: f32); + fn glEnable(cap: u32); + fn glBlendFunc(sfactor: u32, dfactor: u32); + fn glGetError() -> u32; + } + + /// 顶点着色器(canonical 坐标 + 颜色 uniform;投影映射到 NDC)。 + const VERT_SRC: &[u8] = b"#version 150\n\ + in vec2 a_pos;\n\ + uniform mat4 u_proj;\n\ + uniform vec4 u_col;\n\ + out vec4 v_col;\n\ + void main() {\n\ + v_col = u_col;\n\ + gl_Position = u_proj * vec4(a_pos, 0.0, 1.0);\n\ + }\n\0"; + + const FRAG_SRC: &[u8] = b"#version 150\n\ + in vec4 v_col;\n\ + out vec4 frag_color;\n\ + void main() { frag_color = v_col; }\n\0"; + + fn compile_shader(kind: u32, src: &[u8]) -> Result { + // SAFETY: glCreateShader/glShaderSource/glCompileShader 均为当前 + // 上下文的合法调用;src 是 NUL 结尾串(b"...\0")。 + let sh = unsafe { glCreateShader(kind) }; + if sh == 0 { + return Err(status::FAILED); + } + unsafe { + let ptr = src.as_ptr() as *const i8; + glShaderSource(sh, 1, &ptr, std::ptr::null()); + glCompileShader(sh); + } + let mut ok: i32 = 0; + // SAFETY: glGetShaderiv 写 ok。 + unsafe { glGetShaderiv(sh, GL_COMPILE_STATUS, &mut ok) }; + if ok == GL_FALSE { + // SAFETY: sh 是本函数 glCreateShader 的产物。 + unsafe { glDeleteShader(sh) }; + return Err(status::FAILED); + } + Ok(sh) + } + + /// 惰性资源组:(program, vao, vbo)。进程级缓存;VAO/VBO 固定复用, + /// 每次 draw 用 glBufferData 重灌顶点(避免逐次分配/泄漏)。 + fn resources() -> Result<(u32, u32, u32), i32> { + static RES: std::sync::OnceLock> = std::sync::OnceLock::new(); + *RES.get_or_init(|| { + let vs = match compile_shader(GL_VERTEX_SHADER, VERT_SRC) { + Ok(s) => s, + Err(e) => return Err(e), + }; + let fs = match compile_shader(GL_FRAGMENT_SHADER, FRAG_SRC) { + Ok(s) => s, + Err(e) => { + // SAFETY: vs 是本闭包编译成功的着色器。 + unsafe { glDeleteShader(vs) }; + return Err(e); + } + }; + let prog = unsafe { glCreateProgram() }; + if prog == 0 { + // SAFETY: vs/fs 均有效。 + unsafe { + glDeleteShader(vs); + glDeleteShader(fs); + } + return Err(status::FAILED); + } + unsafe { + glAttachShader(prog, vs); + glAttachShader(prog, fs); + glLinkProgram(prog); + } + let mut ok: i32 = 0; + // SAFETY: glGetProgramiv 写 ok。 + unsafe { glGetProgramiv(prog, GL_LINK_STATUS, &mut ok) }; + // 链接后可删着色器对象(程序保留可执行版本)。 + unsafe { + glDeleteShader(vs); + glDeleteShader(fs); + } + if ok == GL_FALSE { + return Err(status::FAILED); + } + // SAFETY: 生成并绑定固定 VAO/VBO(3.2 core)。 + let mut vao: u32 = 0; + let mut vbo: u32 = 0; + unsafe { + glGenVertexArrays(1, &mut vao); + glBindVertexArray(vao); + glGenBuffers(1, &mut vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + } + Ok((prog, vao, vbo)) + }) + } + + /// canonical→NDC 正交投影(列主序 4x4)。 + fn ortho(cw: f64, ch: f64) -> [f32; 16] { + let (cw, ch) = (cw.max(1.0) as f32, ch.max(1.0) as f32); + [ + 2.0 / cw, 0.0, 0.0, 0.0, + 0.0, 2.0 / ch, 0.0, 0.0, + 0.0, 0.0, -1.0, 0.0, + -1.0, -1.0, 0.0, 1.0, + ] + } + + /// 按原语生成 GL 顶点(canonical 坐标;已按图元类型合法)。 + fn vertices(prim: i32, pts: &[OfxPointD]) -> (u32, Vec) { + match prim { + super::primitive::LINES => ( + GL_LINES, + pts.iter().flat_map(|p| [p.x as f32, p.y as f32]).collect(), + ), + super::primitive::LINE_STRIP => ( + GL_LINE_STRIP, + pts.iter().flat_map(|p| [p.x as f32, p.y as f32]).collect(), + ), + super::primitive::LINE_LOOP => ( + GL_LINE_LOOP, + pts.iter().flat_map(|p| [p.x as f32, p.y as f32]).collect(), + ), + super::primitive::RECTANGLE => { + let (x1, y1) = (pts[0].x as f32, pts[0].y as f32); + let (x2, y2) = (pts[1].x as f32, pts[1].y as f32); + // 两个三角形组成实心矩形(对角点定义)。 + ( + GL_TRIANGLES, + vec![ + x1, y1, x2, y1, x1, y2, + x1, y2, x2, y1, x2, y2, + ], + ) + } + super::primitive::POLYGON => { + // 三角扇:0,i,i+1(凸多边形约定)。 + let mut v = Vec::new(); + for i in 1..pts.len() - 1 { + v.extend_from_slice(&[ + pts[0].x as f32, pts[0].y as f32, + pts[i].x as f32, pts[i].y as f32, + pts[i + 1].x as f32, pts[i + 1].y as f32, + ]); + } + (GL_TRIANGLES, v) + } + super::primitive::ELLIPSE => { + // 椭圆**线框**(文档),包围盒内 64 段线环。 + let (cx, cy) = ((pts[0].x + pts[1].x) / 2.0, (pts[0].y + pts[1].y) / 2.0); + let (rx, ry) = ( + (pts[1].x - pts[0].x).abs() / 2.0, + (pts[1].y - pts[0].y).abs() / 2.0, + ); + let n = 64; + let mut v = Vec::with_capacity(n * 2); + for i in 0..n { + let a = std::f64::consts::TAU * i as f64 / n as f64; + v.push((cx + rx * a.cos()) as f32); + v.push((cy + ry * a.sin()) as f32); + } + (GL_LINE_LOOP, v) + } + _ => (GL_TRIANGLES, Vec::new()), + } + } + + /// 真实渲染:绑定固定 VAO/VBO、重灌顶点、设置 uniform、绘制。 + /// 要求 GL 上下文 current(宿主 draw action 已 acquire);无 current + /// 上下文时不发任何 GL 命令(macOS 无 current 调 GL 是未定义行为, + /// 直接失败)。 + pub fn render(ctx: &DrawContext, prim: i32, pts: &[OfxPointD]) -> Result<(), i32> { + if !crate::gl_bridge::is_current() { + return Err(status::FAILED); + } + let (prog, vao, vbo) = resources().map_err(|_| status::FAILED)?; + let (mode, verts) = vertices(prim, pts); + if verts.is_empty() { + return Err(status::ERR_VALUE); + } + // 合成:非不透明色 "over"(ofxDrawSuite.h setColour 文档)。 + if ctx.colour[3] < 1.0 { + // SAFETY: 开启混合并设因子(上下文 current)。 + unsafe { + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } + } + // SAFETY: 以下 GL 调用均要求上下文 current(宿主已保证);对象 + // 名来自 resources()(本进程唯一 GL 使用方)。 + unsafe { + glUseProgram(prog); + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + glBufferData( + GL_ARRAY_BUFFER, + (verts.len() * 4) as isize, + verts.as_ptr() as *const c_void, + GL_STATIC_DRAW, + ); + + let a_pos = glGetAttribLocation(prog, b"a_pos\0".as_ptr() as *const i8); + if a_pos < 0 { + return Err(status::FAILED); + } + glVertexAttribPointer(a_pos as u32, 2, GL_FLOAT, 0, 0, std::ptr::null()); + glEnableVertexAttribArray(a_pos as u32); + + let u_proj = glGetUniformLocation(prog, b"u_proj\0".as_ptr() as *const i8); + let u_col = glGetUniformLocation(prog, b"u_col\0".as_ptr() as *const i8); + let cw = ctx.viewport.0 / ctx.pixel_scale.0.max(1e-6); + let ch = ctx.viewport.1 / ctx.pixel_scale.1.max(1e-6); + let m = ortho(cw, ch); + glUniformMatrix4fv(u_proj, 1, 0, m.as_ptr()); + glUniform4f(u_col, ctx.colour[0], ctx.colour[1], ctx.colour[2], ctx.colour[3]); + + if ctx.line_width > 0.0 { + glLineWidth(ctx.line_width); + } + glDrawArrays(mode, 0, (verts.len() / 2) as i32); + + // 解除绑定(对象保留复用)。 + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glUseProgram(0); + } + let err = unsafe { glGetError() }; + if err != GL_NO_ERROR { + return Err(status::FAILED); + } + Ok(()) + } +} + +/// 非 macOS:Draw suite 的 GL 绘制 stub(无 GL 桥 → Failed)。 +#[cfg(not(target_os = "macos"))] +mod gl_imp { + use super::{DrawContext, OfxPointD}; + use crate::suites::status; + + pub fn render(_ctx: &DrawContext, _prim: i32, _pts: &[OfxPointD]) -> Result<(), i32> { + Err(status::FAILED) + } +} diff --git a/crates/oakplugin/src/suites/gl_render.rs b/crates/oakplugin/src/suites/gl_render.rs index 83c05eea4..adae14a01 100644 --- a/crates/oakplugin/src/suites/gl_render.rs +++ b/crates/oakplugin/src/suites/gl_render.rs @@ -27,29 +27,36 @@ //! RenderScale/PixelAspectRatio/Bounds/RegionOfDefinition/RowBytes/ //! Field/UniqueIdentifier);宿主侧存强引用([`LIVE_TEXTURES`]), //! clipFreeTexture 摘除即释放(对应 HS 的 get/release 配对)。 +//! **OpenGLTextureIndex 是真实 GL 纹理名**(GL 模式,见 +//! [`crate::gl_bridge`] 方案 B):Output clip 是宿主为输出帧建的 +//! GL 纹理(经 GlCtx 注入,render 驱动负责建/FBO 挂载/回读/删除); +//! 输入 clip 是本 suite 在 GL 渲染期经桥上传图像得到的 GL 纹理 +//! (clipFreeTexture 删除)。0 = CPU 回退(无 GL 名,插件按规范回退)。 //! - Output clip:`format` 忽略,宿主返回已附着的输出纹理句柄—— //! 绑定渲染目标的动作(ofxGPURender.h "the host must bind the //! resulting texture as the current color buffer")由调用方约定: -//! GL render 驱动的 C ABI 契约要求 oakrender 在进入 render_job 前 -//! 已把输出纹理附着为渲染器输出目标(等价 C++ 的 -//! `PluginRenderer::attach_output_texture`)。clipFreeTexture 对 -//! Output 只释放句柄、不删纹理(宿主还要读它)。 -//! - 输入纹理经 [`crate::render`] 在渲染器上创建(CPU 帧 → -//! GL 上传)。纹理格式:全链路 F32 约束下,像素深度按 clip 协商 -//! 结果(恒 F32);`format` 参数(kOfxImageEffectGLFormat*)若 -//! 请求的分量与协商分量不符,Phase 2 不做转换 → Failed(规范要求 -//! "host ensures it gives the requested format"——宁可显式失败也 -//! 不静默给错格式)。ofxGPURender.h 注明"宿主无需按 Clip -//! Preferences 把图像重映射到插件请求的位深",插件以纹理句柄的 -//! PixelDepth/Components 为准。 +//! GL render 驱动在进入 render_gl 前已把输出 GL 纹理挂进 FBO 并 +//! 绑定(等价 C++ 的 `PluginRenderer::attach_output_texture`)。 +//! clipFreeTexture 对 Output 只释放句柄、不删纹理(宿主还要回读它, +//! 回读后由 render 驱动删除)。 +//! - 输入纹理在 GL 模式经 [`crate::gl_bridge::create_input_texture`] +//! 上传(CPU 帧 → GL 纹理);CPU 模式(无 GL 上下文)维持 +//! [`crate::render`] 的 CPU 纹理。纹理格式:全链路 F32 约束下, +//! 像素深度按 clip 协商结果(恒 F32);`format` 参数 +//! (kOfxImageEffectGLFormat*)若请求的分量与协商分量不符,Phase 2 +//! 不做转换 → Failed(规范要求 "host ensures it gives the requested +//! format"——宁可显式失败也不静默给错格式)。ofxGPURender.h 注明 +//! "宿主无需按 Clip Preferences 把图像重映射到插件请求的位深", +//! 插件以纹理句柄的 PixelDepth/Components 为准。 //! - `flushResources`:宿主在 render 之间不缓存 GPU 资源(纹理随 //! clipFreeTexture 立即释放)→ 无可释放 → kOfxStatReplyDefault //! (规范:"nothing the host could do")。 //! - GL 上下文规则(ofxGPURender.h "OpenGL Current Context"):宿主 //! 只在 Render/BeginSequenceRender/EndSequenceRender/Attach/Detach -//! 期间要求上下文 current;本实现的约定是调用方(oakrender -//! PluginJob 路径)在调用前置好上下文,本 suite 经 -//! [`crate::suites::gl_ctx`] TLS 取渲染器句柄。 +//! 期间要求上下文 current;本实现的约定是 render 驱动一次 acquire +//! 整个 GL render action([`crate::gl_bridge::acquire`]),本 suite +//! 回调期间上下文恒 current,经 [`crate::suites::gl_ctx`] TLS 取 +//! 渲染器句柄。 use std::collections::HashMap; use std::ffi::{c_char, c_double, c_int, c_void, CStr, CString}; @@ -164,37 +171,64 @@ mod tests { // ---- 存活纹理表 ----------------------------------------------------------- /// 存活 GL 纹理表:clipLoadTexture 产出(props 地址 → 属性集 + -/// 纹理值 + 是否输出 clip);clipFreeTexture 摘除即释放——对应 -/// HS 的 get/release 配对(HS: ofxhImageEffect.cpp:2336-2351)。 -/// 纹理是 oakrender 值(drop 自动释放后端 token;原 -/// `texture_free` 调用面随值模型删除)。 +/// 纹理值 + 是否输出 clip + 真实 GL 纹理名[可选]);clipFreeTexture +/// 摘除即释放——对应 HS 的 get/release 配对 +/// (HS: ofxhImageEffect.cpp:2336-2351)。纹理是 oakrender 值(drop +/// 自动释放后端 token;原 `texture_free` 调用面随值模型删除)。 +/// +/// `gl_texture`:GL 模式下输入 clip 经 [`crate::gl_bridge`] 建的真实 +/// GL 纹理名(输出 clip 恒 None——宿主自建并负责生命周期,见 +/// [`crate::gl_bridge`] 模块文档);摘除时经 +/// [`delete_gl_texture_if_gl`] 删除。 /// /// 属性集必须**装箱**(Box 稳定堆地址):纹理句柄指向它,函数返回后 /// 必须仍存活;栈上临时变量会悬垂(phase-2 实现初版的 bug)。 static LIVE_TEXTURES: std::sync::LazyLock< - Mutex, crate::render::Texture, bool)>>, + Mutex, crate::render::Texture, bool, Option)>>, > = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); /// 登记纹理(clipLoadTexture 内部;`props` 装箱后取地址为句柄基址)。 +/// `gl_texture`:真实 GL 纹理名(输入 clip GL 模式;无则 None)。 fn register( props: Box, texture: crate::render::Texture, is_output: bool, + gl_texture: Option, ) -> usize { let addr = &*props as *const PropertySet as usize; LIVE_TEXTURES .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(addr, (props, texture, is_output)); + .insert(addr, (props, texture, is_output, gl_texture)); addr } +/// 删除 GL 纹理(上下文 current 时;否则留给下次 GL 渲染前的兜底—— +/// 正常路径 clipFreeTexture/purge 都在 GL render 期,上下文必 current)。 +fn delete_gl_texture_if_gl(name: i32) { + if crate::suites::gl_ctx().is_some() { + crate::gl_bridge::delete_gl_texture(name); + } +} + /// 释放全部残留输入纹理(GL render action 返回后的安全网:规范要求 /// 插件在 action 返回前 clipFreeTexture 全部句柄;遗漏的输入纹理在 -/// 此释放,输出纹理保留——宿主还要读它)。 +/// 此释放,输出纹理保留——宿主还要读它)。GL 输入纹理同步删除 +/// (上下文 current;render_gl 在清 GlCtx TLS 前调用本函数)。 pub(crate) fn purge_leftovers() { let mut live = LIVE_TEXTURES.lock().unwrap_or_else(|e| e.into_inner()); - live.retain(|_, (_, _, is_output)| *is_output); + let dropped: Vec> = live + .iter() + .filter(|(_, (_, _, is_output, _))| !*is_output) + .map(|(_, (_, _, _, gl))| *gl) + .collect(); + live.retain(|_, (_, _, is_output, _)| *is_output); + drop(live); + for gl in dropped { + if let Some(gl) = gl { + delete_gl_texture_if_gl(gl); + } + } } /// 公共入口模板:panic 兜底。 @@ -240,12 +274,11 @@ fn clip_string(clip: &ClipInstance, name: &str, default: &str) -> String { } /// 构造纹理属性集(ofxGPURender.h 规定的属性;输入与输出共用, -/// 只是数据来源不同)。`OpenGLTextureIndex` 经 -/// [`crate::render::texture_id`](桩恒 0——wgpu 后端无 GL 命名空间; -/// CPU 回退下插件按规范回退,纹理内容仍可经 clipGetImage 取用)。 +/// 只是数据来源不同)。`OpenGLTextureIndex` 为真实 GL 纹理名 +/// (`texture_index`;0 = 无 GL 名——CPU 回退下插件按规范回退,纹理 +/// 内容仍可经 clipGetImage 取用)。 #[allow(clippy::too_many_arguments)] fn make_texture_props( - texture: &crate::render::Texture, width: f64, height: f64, components: crate::image::Components, @@ -254,12 +287,10 @@ fn make_texture_props( par: f64, scale: crate::instance::RenderScale, row_bytes: i32, + texture_index: i32, ) -> PropertySet { let props = PropertySet::new(); - props.set_one( - GL_TEXTURE_INDEX, - Value::Int(crate::render::texture_id(texture)), - ); + props.set_one(GL_TEXTURE_INDEX, Value::Int(texture_index)); props.set_one(GL_TEXTURE_TARGET, Value::Int(GL_TEXTURE_2D)); props.set_one( crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, @@ -343,8 +374,10 @@ unsafe extern "C" fn clip_load_texture( if w <= 0.0 || h <= 0.0 { return Err(status::ERR_BAD_HANDLE); } + // OpenGLTextureIndex = 宿主为输出帧建的真实 GL 纹理名 + // (render 驱动 GL 分支经 GlCtx 注入;0 = CPU 回退)。 + let index = gl.output_gl_texture.unwrap_or(0); let props = make_texture_props( - &tex, w, h, crate::image::Components::Rgba, @@ -353,8 +386,9 @@ unsafe extern "C" fn clip_load_texture( clip_par(c), scale, (w as i32) * 4 * 4, + index, ); - let addr = register(Box::new(props), tex, true); + let addr = register(Box::new(props), tex, true, None); unsafe { *out = tag::make(addr as *const PropertySet, tag::PROPERTY_SET) }; return Ok(()); } @@ -386,6 +420,17 @@ unsafe extern "C" fn clip_load_texture( if w <= 0.0 || h <= 0.0 { return Err(status::FAILED); } + // GL 模式:把输入图像上传成真实 GL 纹理(RGBA32F;上下文 + // current——render 驱动已 acquire),OpenGLTextureIndex 返回 + // 真实名;GL 上传失败 → 显式 Failed(插件按规范继续)。非 GL + // 上下文(本函数仅在 GL 渲染期可达,此分支是 CPU 纹理兜底): + // 仍建 CPU 纹理、索引 0。 + let gl_tex = crate::gl_bridge::create_input_texture( + w as i32, + h as i32, + image.pixels(), + ) + .ok(); let params = crate::render::VideoParams { width: w as i32, height: h as i32, @@ -395,7 +440,6 @@ unsafe extern "C" fn clip_load_texture( let tex = crate::render::texture_create(¶ms, image.pixels(), image.row_bytes() as i32) .map_err(|_| status::ERR_MEMORY)?; let props = make_texture_props( - &tex, w, h, components, @@ -404,8 +448,9 @@ unsafe extern "C" fn clip_load_texture( clip_par(c), scale, image.row_bytes() as i32, + gl_tex.unwrap_or(0), ); - let addr = register(Box::new(props), tex, false); + let addr = register(Box::new(props), tex, false, gl_tex); unsafe { *out = tag::make(addr as *const PropertySet, tag::PROPERTY_SET) }; Ok(()) }) @@ -440,9 +485,9 @@ fn clip_par(c: &ClipInstance) -> f64 { .unwrap_or(1.0) } -/// clipFreeTexture:释放纹理(输入 clip 删除纹理值;Output 只释放 -/// 句柄不删纹理——宿主还要读它)。纹理是值:摘除表条目即 drop -/// (原 `texture_free` 调用面随值模型删除)。 +/// clipFreeTexture:释放纹理(输入 clip 删除纹理值并释放真实 GL +/// 纹理;Output 只释放句柄不删纹理——宿主还要读它)。纹理是值: +/// 摘除表条目即 drop(原 `texture_free` 调用面随值模型删除)。 unsafe extern "C" fn clip_free_texture(texture_handle: *mut c_void) -> c_int { caught(|| { if texture_handle.is_null() { @@ -454,7 +499,13 @@ unsafe extern "C" fn clip_free_texture(texture_handle: *mut c_void) -> c_int { live.remove(&addr) }; match entry { - Some((_props, _texture, _is_output)) => Ok(()), + Some((_props, _texture, _is_output, gl_tex)) => { + // 输入 clip 的真实 GL 纹理随释放删除(上下文 current)。 + if let Some(gl) = gl_tex { + delete_gl_texture_if_gl(gl); + } + Ok(()) + } None => Err(status::ERR_BAD_HANDLE), } }) diff --git a/crates/oakplugin/src/suites/image_effect.rs b/crates/oakplugin/src/suites/image_effect.rs index cd48c18a0..61805be68 100644 --- a/crates/oakplugin/src/suites/image_effect.rs +++ b/crates/oakplugin/src/suites/image_effect.rs @@ -579,6 +579,7 @@ mod tests { cancel: std::sync::atomic::AtomicBool::new(false), edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), }); let ih = tag::make(&inst.props as *const PropertySet, tag::INSTANCE); unsafe { diff --git a/crates/oakplugin/src/suites/interact.rs b/crates/oakplugin/src/suites/interact.rs new file mode 100644 index 000000000..0867e4e67 --- /dev/null +++ b/crates/oakplugin/src/suites/interact.rs @@ -0,0 +1,814 @@ +// 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 . + +//! OfxInteractSuiteV1 + [`Interact`] 宿主对象。 +//! +//! Interact 是插件自定义 UI 的宿主侧对象(主进程 UI 事件宿主):与 +//! worker 进程里的渲染实例并存(OFX 允许同一插件多实例),经 +//! [`crate::instance::Instance::new_interact`] 创建、绑定到效果实例。 +//! +//! ## 生命周期 +//! +//! ```text +//! new_interact → kOfxActionNewInteract(创建;插件拒绝 → None) +//! describe → kOfxActionDescribe(kOfxActionDescribeInteract) +//! create_instance→ kOfxActionCreateInstance(kOfxActionCreateInstanceInteract) +//! draw/pen/key/idle → kOfxInteractAction*(宿主→插件) +//! destroy → kOfxActionDestroyInstance(kOfxActionDestroyInstanceInteract; +//! Instance 销毁时自动连带) +//! ``` +//! +//! 所有 action 走 `Interact::call` → 插件入口(overlay interact V2/V1 +//! 入口,未声明则插件 main entry;任务契约:new_interact 向插件入口 +//! 发 kOfxActionNewInteract)。返回值(OfxStatus)如实透传。 +//! +//! ## 语义(ofxInteract.h) +//! +//! - pen/key action 的返回:kOfxStatOK = 插件已处理该事件,宿主不应再 +//! 把事件传给视图中其他交互对象;kOfxStatReplyDefault = 插件未处理, +//! 宿主可自行处置。 +//! - pen 坐标:调用方给 viewport 像素;inArgs 另带 canonical 坐标 +//! (kOfxInteractPropPenPosition,= viewport 像素 / pixelScale)与 +//! pixelScale、pressure(两态笔按 ofxInteract.h 映射 0/1)。 +//! - key:kOfxPropKeySym(ofxKeySyms.h 关键码)+ kOfxPropKeyString +//! (UTF-8;无 UTF8 编码的键为空串)。 +//! - draw:宿主在调用前经 [`crate::gl_bridge::acquire`] 保持 GL current +//! 整个 action(插件发原生 GL 命令或经 Draw suite,ofxInteract.h +//! "the openGL context for this interact has been set");inArgs 带 +//! viewport/pixelScale/time/backgroundImage/backgroundColour/ +//! drawContext。 + +use std::ffi::{c_int, c_void, CString}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::host::{EntryPoint, Plugin}; +use crate::property::{PropertySet, Value}; +use crate::suites::{status, tag}; + +fn cs(s: &str) -> CString { + CString::new(s).unwrap() +} + +/// Interact 实例。`Arc` 管理生命周期(app 侧持句柄 + +/// [`crate::instance::Instance`] 的 interact 字段)。 +/// +/// `#[repr(C)]` + props 在偏移 0(句柄约定:interact handle = +/// `&props` | INTERACT 标签;property suite 剥标签后直读属性集)。 +#[repr(C)] +pub struct Interact { + /// interact 实例级属性集(偏移 0,句柄约定)。 + pub props: PropertySet, + /// 所属插件。 + pub plugin: Arc, + /// interact 的入口(overlay interact V2/V1 入口,未声明则插件 + /// main entry)。interact 的所有 action 都发到这里。 + pub entry: EntryPoint, + /// 效果实例句柄(tagged INSTANCE;interact action inArgs 的 + /// kOfxPropEffectInstance)。不透明令牌,不解引用。 + pub effect_handle: *mut c_void, + /// 插件经 interactSwapBuffers 请求的缓冲交换(app 侧轮询后清零)。 + pub swap_requested: AtomicBool, + /// 插件经 interactRedraw 请求的重绘(app 侧轮询后清零)。 + pub redraw_requested: AtomicBool, + /// destroy 是否已通知(幂等门;Instance 销毁连带触发)。 + pub destroyed: AtomicBool, +} + +// SAFETY: effect_handle 是不透明令牌(同 [`crate::property::Value::Pointer`], +// 从不在此对象上解引用);entry 是函数指针,调用永远发生在宿主控制的 +// action 分发点;其余字段(Arc/Mutex/AtomicBool/PropertySet)均线程安全。 +// Interact 经 Instance.interact 字段跨线程可达,因此 Send/Sync 成立。 +unsafe impl Send for Interact {} +unsafe impl Sync for Interact {} + +impl Interact { + /// 构造(宿主内部;[`crate::instance::Instance::new_interact`] 调)。 + /// `effect_handle` 为 tagged INSTANCE 句柄(不透明令牌)。 + pub(crate) fn new( + plugin: Arc, + entry: EntryPoint, + effect_handle: *mut c_void, + ) -> Arc { + let i = Arc::new(Interact { + props: PropertySet::new(), + plugin, + entry, + effect_handle, + swap_requested: AtomicBool::new(false), + redraw_requested: AtomicBool::new(false), + destroyed: AtomicBool::new(false), + }); + init_interact_props(&i.props); + i + } + + /// interact handle(tagged INTERACT)。 + pub(crate) fn handle(&self) -> *mut c_void { + tag::make(&self.props as *const PropertySet, tag::INTERACT) + } + + /// 向插件入口发一个 interact action。 + /// + /// # Safety + /// handle 与 action 匹配(Interact 生命周期由宿主控制;destroy 后 + /// 不再调用)。 + pub(crate) fn call(&self, action: &str, in_args: &PropertySet, out_args: &PropertySet) -> i32 { + unsafe { + self.plugin.call_entry(self.entry, action, self.handle(), in_args, out_args) + } + } + + /// 取 effect 实例句柄(inArgs 的 kOfxPropEffectInstance)。 + fn effect_instance_prop(&self) -> Value { + Value::Pointer(self.effect_handle) + } + + /// 当前 pixelScale(interact props,默认 1,1)。 + fn pixel_scale(&self) -> (f64, f64) { + read_double2(&self.props, crate::host::PROP_INTERACT_PIXEL_SCALE) + .unwrap_or((1.0, 1.0)) + } + + /// describe(kOfxActionDescribe == kOfxActionDescribeInteract, + /// ofxInteract.h:171)。inArgs/outArgs 冗余为 NULL。 + pub fn describe(&self) -> i32 { + let empty = PropertySet::new(); + self.call(crate::host::ACTION_DESCRIBE, &empty, &empty) + } + + /// createInstance(kOfxActionCreateInstance == + /// kOfxActionCreateInstanceInteract,ofxInteract.h:198)。 + /// draw/pen/key 的前置(ofxInteract.h 各 action 的 \pre)。 + pub fn create_instance(&self) -> i32 { + let empty = PropertySet::new(); + self.call(crate::host::ACTION_CREATE_INSTANCE, &empty, &empty) + } + + /// draw action(ofxInteract.h:265)。宿主在调用前 + /// [`crate::gl_bridge::acquire`] 保持 GL current 整个 action;inArgs + /// 带 kOfxInteractPropPixelScale/ViewportSize/BackgroundColour/ + /// BackgroundImage/Time/RenderScale/DrawContext + kOfxPropEffectInstance。 + /// + /// `background_image`:宿主持有的背景图像句柄(本宿主 Phase 1 无 + /// 合成背景,传 None 即空——任务契约"可空")。 + /// + /// 返回值:插件的 OfxStatus 如实透传;GL 上下文不可用(非 macOS / + /// 无上下文)时返回 kOfxStatErrMissingHostFeature(宿主侧失败,未 + /// 触达插件)。 + pub fn draw( + &self, + viewport_size: (f64, f64), + pixel_scale: (f64, f64), + time: f64, + background_image: Option<*mut c_void>, + ) -> i32 { + // 1. GL current 整个 draw action(插件发原生 GL 命令或经 Draw + // suite;失败即无法绘制 → 不触达插件)。 + let _guard = match crate::gl_bridge::acquire() { + Ok(g) => g, + Err(_) => return status::ERR_MISSING_HOST_FEATURE, + }; + + // 2. Draw suite 上下文(存活注册;draw 返回后摘除)。 + let draw_ctx = crate::suites::draw::make_context(viewport_size, pixel_scale); + + let in_args = PropertySet::new(); + in_args.define( + crate::host::PROP_INTERACT_PIXEL_SCALE, + vec![Value::Double(pixel_scale.0), Value::Double(pixel_scale.1)], + ); + in_args.define( + crate::host::PROP_INTERACT_VIEWPORT_SIZE, + vec![Value::Double(viewport_size.0), Value::Double(viewport_size.1)], + ); + in_args.set_one( + crate::host::PROP_INTERACT_BACKGROUND_IMAGE, + Value::Pointer(background_image.unwrap_or(std::ptr::null_mut())), + ); + in_args.define( + crate::host::PROP_INTERACT_BACKGROUND_COLOUR, + vec![Value::Double(0.0), Value::Double(0.0), Value::Double(0.0)], + ); + in_args.set_one(crate::host::PROP_TIME, Value::Double(time)); + in_args.define( + crate::host::PROP_RENDER_SCALE, + vec![Value::Double(1.0), Value::Double(1.0)], + ); + in_args.set_one(crate::host::PROP_INTERACT_DRAW_CONTEXT, Value::Pointer(draw_ctx)); + in_args.set_one(crate::host::PROP_EFFECT_INSTANCE, self.effect_instance_prop()); + + // 3. interact 实例属性同步(kOfxInteractPropPixelScale 等是实例 + // 属性集上只读的"当前状态",ofxInteract.h:58)。 + self.props.define( + crate::host::PROP_INTERACT_PIXEL_SCALE, + vec![Value::Double(pixel_scale.0), Value::Double(pixel_scale.1)], + ); + self.props.define( + crate::host::PROP_INTERACT_VIEWPORT_SIZE, + vec![Value::Double(viewport_size.0), Value::Double(viewport_size.1)], + ); + self.props.set_one( + crate::host::PROP_INTERACT_BACKGROUND_IMAGE, + Value::Pointer(background_image.unwrap_or(std::ptr::null_mut())), + ); + + let out = PropertySet::new(); + let st = self.call(crate::host::ACTION_INTERACT_DRAW, &in_args, &out); + crate::suites::draw::drop_context(draw_ctx); + st + } + + /// 装配 pen 类 action 的 inArgs(PenMotion/PenDown/PenUp 共用, + /// ofxInteract.h:274-321)。 + fn pen_in_args(&self, pen_viewport: (f64, f64), pressure: f64, time: f64) -> PropertySet { + let (ps_x, ps_y) = self.pixel_scale(); + // canonical = viewport 像素 / pixelScale(pixelScale 是 + // canonical→屏幕像素的换算比例)。 + let in_args = PropertySet::new(); + in_args.set_one(crate::host::PROP_EFFECT_INSTANCE, self.effect_instance_prop()); + in_args.define( + crate::host::PROP_INTERACT_PIXEL_SCALE, + vec![Value::Double(ps_x), Value::Double(ps_y)], + ); + in_args.define( + crate::host::PROP_INTERACT_BACKGROUND_COLOUR, + vec![Value::Double(0.0), Value::Double(0.0), Value::Double(0.0)], + ); + in_args.set_one(crate::host::PROP_TIME, Value::Double(time)); + in_args.define( + crate::host::PROP_RENDER_SCALE, + vec![Value::Double(1.0), Value::Double(1.0)], + ); + in_args.define( + crate::host::PROP_INTERACT_PEN_POSITION, + vec![ + Value::Double(pen_viewport.0 / ps_x.max(1e-9)), + Value::Double(pen_viewport.1 / ps_y.max(1e-9)), + ], + ); + in_args.define( + crate::host::PROP_INTERACT_PEN_VIEWPORT_POSITION, + vec![ + Value::Int(pen_viewport.0.round() as i32), + Value::Int(pen_viewport.1.round() as i32), + ], + ); + in_args.set_one(crate::host::PROP_INTERACT_PEN_PRESSURE, Value::Double(pressure)); + in_args + } + + /// pen_motion(ofxInteract.h:302)。`pen_viewport` 为视口像素坐标; + /// `pen_down` 表示笔是否按下(两态笔按 ofxInteract.h:114 映射压力 + /// 1.0/0.0)。 + pub fn pen_motion(&self, pen_viewport: (f64, f64), pen_down: bool, time: f64) -> i32 { + let pressure = if pen_down { 1.0 } else { 0.0 }; + let in_args = self.pen_in_args(pen_viewport, pressure, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_PEN_MOTION, &in_args, &out) + } + + /// pen_down(ofxInteract.h:340)。压力 1.0。 + pub fn pen_down(&self, pen_viewport: (f64, f64), time: f64) -> i32 { + let in_args = self.pen_in_args(pen_viewport, 1.0, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_PEN_DOWN, &in_args, &out) + } + + /// pen_up(ofxInteract.h:376)。压力 0.0。 + pub fn pen_up(&self, pen_viewport: (f64, f64), time: f64) -> i32 { + let in_args = self.pen_in_args(pen_viewport, 0.0, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_PEN_UP, &in_args, &out) + } + + /// 装配 key 类 action 的 inArgs(KeyDown/KeyUp 共用, + /// ofxInteract.h:384-442)。 + fn key_in_args(&self, key_sym: i32, key_string: &str, time: f64) -> PropertySet { + let in_args = PropertySet::new(); + in_args.set_one(crate::host::PROP_EFFECT_INSTANCE, self.effect_instance_prop()); + in_args.set_one(crate::host::PROP_KEY_SYM, Value::Int(key_sym)); + in_args.set_one(crate::host::PROP_KEY_STRING, Value::String(cs(key_string))); + in_args.set_one(crate::host::PROP_TIME, Value::Double(time)); + in_args.define( + crate::host::PROP_RENDER_SCALE, + vec![Value::Double(1.0), Value::Double(1.0)], + ); + in_args + } + + /// key_down(ofxInteract.h:410)。`key_sym` 为 ofxKeySyms.h 关键码; + /// `key_string` 为 UTF-8 字符(无 UTF8 编码的键为空串)。 + pub fn key_down(&self, key_sym: i32, key_string: &str, time: f64) -> i32 { + let in_args = self.key_in_args(key_sym, key_string, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_KEY_DOWN, &in_args, &out) + } + + /// key_up(ofxInteract.h:443)。 + pub fn key_up(&self, key_sym: i32, key_string: &str, time: f64) -> i32 { + let in_args = self.key_in_args(key_sym, key_string, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_KEY_UP, &in_args, &out) + } + + /// idle(宿主空闲泵;任务契约的 kOfxInteractActionIdle,属本宿主 + /// 扩展)。插件未实现时返回 kOfxStatReplyDefault。 + pub fn idle(&self) -> i32 { + let empty = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_IDLE, &empty, &empty) + } + + /// gain_focus(ofxInteract.h:501):pen/key 事件的前置 + /// (ofxInteract.h 各 action 的 \pre 要求 interact 已获焦点)。 + pub fn gain_focus(&self, time: f64) -> i32 { + let in_args = self.pen_in_args((0.0, 0.0), 0.0, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_GAIN_FOCUS, &in_args, &out) + } + + /// lose_focus(ofxInteract.h:526)。 + pub fn lose_focus(&self, time: f64) -> i32 { + let in_args = self.pen_in_args((0.0, 0.0), 0.0, time); + let out = PropertySet::new(); + self.call(crate::host::ACTION_INTERACT_LOSE_FOCUS, &in_args, &out) + } + + /// destroy(kOfxActionDestroyInstance == kOfxActionDestroyInstanceInteract, + /// ofxInteract.h:227)。幂等:只通知一次。返回状态忽略(析构不可 + /// 回滚,ofxInteract.h "what is returned is moot")。 + pub fn destroy(&self) { + if self + .destroyed + .swap(true, Ordering::Relaxed) + { + return; + } + let empty = PropertySet::new(); + self.call(crate::host::ACTION_DESTROY_INSTANCE, &empty, &empty); + } +} + +/// interact 实例属性表(ofxInteract.h 的 PropertiesInteract 子集)。 +fn init_interact_props(props: &PropertySet) { + props.define( + crate::host::PROP_INTERACT_PIXEL_SCALE, + vec![Value::Double(1.0), Value::Double(1.0)], + ); + props.define( + crate::host::PROP_INTERACT_VIEWPORT_SIZE, + vec![Value::Double(0.0), Value::Double(0.0)], + ); + props.set_one( + crate::host::PROP_INTERACT_BACKGROUND_IMAGE, + Value::Pointer(std::ptr::null_mut()), + ); + props.define( + crate::host::PROP_INTERACT_SUGGESTED_COLOUR, + vec![Value::Double(1.0), Value::Double(1.0), Value::Double(1.0)], + ); + props.define(crate::host::PROP_INTERACT_SLAVE_TO_PARAM, vec![]); + props.define( + crate::host::PROP_INTERACT_BACKGROUND_COLOUR, + vec![Value::Double(0.0), Value::Double(0.0), Value::Double(0.0)], + ); + // 帧缓冲位深:离屏 FBO 是 RGBA32F → 每分量 32 bit,含 alpha。 + props.set_one(crate::host::PROP_INTERACT_BIT_DEPTH, Value::Int(32)); + props.set_one(crate::host::PROP_INTERACT_HAS_ALPHA, Value::Int(1)); +} + +/// 从属性集读 Double×2(缺失/类型不符 → None)。 +fn read_double2(props: &PropertySet, name: &str) -> Option<(f64, f64)> { + let x = match props.get(name, 0)? { + Value::Double(d) => d, + _ => return None, + }; + let y = match props.get(name, 1)? { + Value::Double(d) => d, + _ => return None, + }; + Some((x, y)) +} + +// ---- OfxInteractSuiteV1(ofxInteract.h:534-544)------------------------ + +/// interact 句柄解析(tag INTERACT)。 +fn resolve_interact(handle: *mut c_void) -> Result<&'static Interact, c_int> { + if handle.is_null() { + return Err(status::ERR_BAD_HANDLE); + } + unsafe { + if tag::kind(handle) != tag::INTERACT { + return Err(status::ERR_BAD_HANDLE); + } + // Interact 的 props 在偏移 0:剥标签后的地址即 &Interact。 + Ok(&*(tag::strip(handle) as *const Interact)) + } +} + +/// 公共入口模板:panic 兜底。 +fn caught(f: impl FnOnce() -> Result<(), c_int>) -> c_int { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_or_else( + |_| status::FAILED, + |r| r.map_or_else(|c| c, |()| status::OK), + ) +} + +/// interactSwapBuffers:插件请求宿主交换 GL 缓冲(宿主 UI 视图中)。 +/// 本宿主无 UI 视图,把请求记入 `swap_requested`(app 侧轮询后清零)并 +/// 返回 OK——宿主服务真实生效(请求被记录、可被 UI 消费)。 +unsafe extern "C" fn interact_swap_buffers(handle: *mut c_void) -> c_int { + caught(|| { + let i = resolve_interact(handle)?; + i.swap_requested.store(true, Ordering::Relaxed); + Ok(()) + }) +} + +/// interactRedraw:插件请求宿主重绘 interact 视图。同上记入 +/// `redraw_requested`(app 侧轮询)。 +unsafe extern "C" fn interact_redraw(handle: *mut c_void) -> c_int { + caught(|| { + let i = resolve_interact(handle)?; + i.redraw_requested.store(true, Ordering::Relaxed); + Ok(()) + }) +} + +/// interactGetPropertySet:返回 interact 的属性集句柄(tagged INTERACT)。 +unsafe extern "C" fn interact_get_property_set( + handle: *mut c_void, + property: *mut *mut c_void, +) -> c_int { + caught(|| { + if property.is_null() { + return Err(status::ERR_VALUE); + } + let i = resolve_interact(handle)?; + unsafe { *property = tag::make(&i.props as *const PropertySet, tag::INTERACT) }; + Ok(()) + }) +} + +/// 函数表布局(与 SDK `OfxInteractSuiteV1` 逐字段一致;ofxInteract.h:534)。 +#[repr(C)] +pub struct InteractSuiteV1 { + /// interactSwapBuffers。 + pub interact_swap_buffers: unsafe extern "C" fn(*mut c_void) -> c_int, + /// interactRedraw。 + pub interact_redraw: unsafe extern "C" fn(*mut c_void) -> c_int, + /// interactGetPropertySet。 + pub interact_get_property_set: unsafe extern "C" fn(*mut c_void, *mut *mut c_void) -> c_int, +} + +/// 函数表实例。 +pub fn suite_v1() -> &'static InteractSuiteV1 { + static SUITE: std::sync::OnceLock = std::sync::OnceLock::new(); + SUITE.get_or_init(|| InteractSuiteV1 { + interact_swap_buffers, + interact_redraw, + interact_get_property_set, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::{c_char, CStr}; + use std::sync::Mutex; + + fn cs(s: &str) -> CString { + CString::new(s).unwrap() + } + + /// interact 属性表:PixelScale/ViewportSize/SuggestedColour/ + /// SlaveToParam/BitDepth/HasAlpha 等按头文件预置,经 property suite + /// 可读写。 + #[test] + fn interact_props_initialized() { + let props = PropertySet::new(); + init_interact_props(&props); + // Value 无 PartialEq(指针/CString 成员),用 matches! 断言。 + assert!( + matches!( + props.get(crate::host::PROP_INTERACT_PIXEL_SCALE, 0), + Some(Value::Double(1.0)) + ), + "PixelScale[0] 应为 1.0" + ); + assert_eq!(props.dimension(crate::host::PROP_INTERACT_PIXEL_SCALE), 2); + assert!( + matches!( + props.get(crate::host::PROP_INTERACT_SUGGESTED_COLOUR, 2), + Some(Value::Double(1.0)) + ), + "SuggestedColour[2] 应为 1.0" + ); + assert_eq!(props.dimension(crate::host::PROP_INTERACT_SLAVE_TO_PARAM), 0); + assert!( + matches!( + props.get(crate::host::PROP_INTERACT_BIT_DEPTH, 0), + Some(Value::Int(32)) + ), + "BitDepth 应为 32" + ); + assert!( + matches!( + props.get(crate::host::PROP_INTERACT_HAS_ALPHA, 0), + Some(Value::Int(1)) + ), + "HasAlpha 应为 1" + ); + } + + /// read_double2 帮助函数。 + #[test] + fn read_double2_helper() { + let props = PropertySet::new(); + props.define("P", vec![Value::Double(2.0), Value::Double(3.0)]); + assert_eq!(read_double2(&props, "P"), Some((2.0, 3.0))); + assert_eq!(read_double2(&props, "Q"), None); + } + + /// 空指针/错标签 → ErrBadHandle(interact suite 的句柄校验)。 + #[test] + fn interact_suite_handle_validation() { + unsafe { + assert_eq!(interact_swap_buffers(std::ptr::null_mut()), status::ERR_BAD_HANDLE); + assert_eq!(interact_redraw(std::ptr::null_mut()), status::ERR_BAD_HANDLE); + let mut out: *mut c_void = std::ptr::null_mut(); + assert_eq!( + interact_get_property_set(std::ptr::null_mut(), &mut out), + status::ERR_BAD_HANDLE + ); + // 错标签:裸 PropertySet(标签 0)当 interact 用。 + let props = PropertySet::new(); + let raw = &props as *const PropertySet as *mut c_void; + assert_eq!(interact_swap_buffers(raw), status::ERR_BAD_HANDLE); + } + } + + /// interact suite 服务端:swap/redraw 请求被记录(app 侧轮询面), + /// getPropertySet 返回 interact 属性集句柄。 + #[test] + fn interact_suite_requests_are_recorded() { + // 用假插件构造 Interact(不进插件入口——只测 suite 服务端)。 + let plugin = crate::host::Plugin { + identifier: "fake".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::from("fake"), + contexts: Vec::new(), + descriptor: crate::descriptor::EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry: dummy_entry, + ofx_plugin: std::ptr::null_mut(), + }; + let interact = Interact::new(Arc::new(plugin), dummy_entry, std::ptr::null_mut()); + let handle = interact.handle(); + + unsafe { + assert_eq!(interact_swap_buffers(handle), status::OK); + assert_eq!(interact_redraw(handle), status::OK); + } + assert!(interact.swap_requested.load(Ordering::Relaxed)); + assert!(interact.redraw_requested.load(Ordering::Relaxed)); + + let mut props_handle: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!(interact_get_property_set(handle, &mut props_handle), status::OK); + } + assert_eq!(tag::kind(props_handle), tag::INTERACT); + assert_eq!( + tag::strip(props_handle) as usize, + &interact.props as *const PropertySet as usize + ); + + // property suite 可直读 interact 属性集(句柄约定打通)。 + let addr = tag::strip(props_handle); + let set: &PropertySet = unsafe { &*addr }; + assert!( + matches!( + set.get(crate::host::PROP_INTERACT_BIT_DEPTH, 0), + Some(Value::Int(32)) + ), + "经 interact 句柄的 property suite 应读到 BitDepth=32" + ); + + // 摘除引用计数:Arc 释放(interact 已无引用)。 + drop(interact); + } + + /// 假入口:所有 action 返回 ReplyDefault(不触达真实插件)。 + unsafe extern "C" fn dummy_entry( + _action: *const c_char, + _handle: *const c_void, + _in: *mut c_void, + _out: *mut c_void, + ) -> c_int { + status::REPLY_DEFAULT + } + + /// call 面透传:entry 收到 action 与 tagged handle(假插件记录)。 + #[test] + fn interact_call_dispatches_to_entry() { + static CALLED_ACTION: Mutex> = Mutex::new(Vec::new()); + unsafe extern "C" fn recording_entry( + action: *const c_char, + handle: *const c_void, + _in: *mut c_void, + _out: *mut c_void, + ) -> c_int { + CALLED_ACTION + .lock() + .unwrap() + .push(unsafe { CStr::from_ptr(action) }.to_string_lossy().into_owned()); + // handle 必须是 tagged INTERACT。 + assert_eq!(tag::kind(handle as *mut c_void), tag::INTERACT); + status::OK + } + let plugin = crate::host::Plugin { + identifier: "fake".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::from("fake"), + contexts: Vec::new(), + descriptor: crate::descriptor::EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry: recording_entry, + ofx_plugin: std::ptr::null_mut(), + }; + let interact = Interact::new(Arc::new(plugin), recording_entry, std::ptr::null_mut()); + + assert_eq!(interact.describe(), status::OK); + assert_eq!(interact.create_instance(), status::OK); + assert_eq!(interact.pen_motion((10.0, 20.0), true, 5.0), status::OK); + assert_eq!(interact.pen_down((10.0, 20.0), 5.0), status::OK); + assert_eq!(interact.pen_up((10.0, 20.0), 5.0), status::OK); + assert_eq!(interact.key_down(crate::host::KEY_A, "a", 5.0), status::OK); + assert_eq!(interact.key_up(crate::host::KEY_A, "a", 5.0), status::OK); + assert_eq!(interact.idle(), status::OK); + interact.destroy(); + + let calls = CALLED_ACTION.lock().unwrap().clone(); + let want = [ + crate::host::ACTION_DESCRIBE, + crate::host::ACTION_CREATE_INSTANCE, + crate::host::ACTION_INTERACT_PEN_MOTION, + crate::host::ACTION_INTERACT_PEN_DOWN, + crate::host::ACTION_INTERACT_PEN_UP, + crate::host::ACTION_INTERACT_KEY_DOWN, + crate::host::ACTION_INTERACT_KEY_UP, + crate::host::ACTION_INTERACT_IDLE, + crate::host::ACTION_DESTROY_INSTANCE, + ]; + assert_eq!(calls, want, "interact action 序列应按序透传到入口"); + } + + /// pen 事件的真实参数:inArgs 的 canonical 位置/视口位置/压力按 + /// pixelScale 换算(假插件读取记录)。 + #[test] + fn pen_in_args_canonical_conversion() { + // 记录 pen_motion 的 inArgs 关键属性。 + static CAPTURED: Mutex> = Mutex::new(None); + unsafe extern "C" fn capture_entry( + _action: *const c_char, + _handle: *const c_void, + in_args: *mut c_void, + _out: *mut c_void, + ) -> c_int { + if in_args.is_null() { + return status::OK; + } + let set = unsafe { &*(in_args as *const PropertySet) }; + // 视口位置(Int×2)与 canonical 位置(Double×2)与压力。 + let vx = match set.get(crate::host::PROP_INTERACT_PEN_VIEWPORT_POSITION, 0) { + Some(Value::Int(i)) => i, + _ => 0, + }; + let px = match set.get(crate::host::PROP_INTERACT_PEN_POSITION, 0) { + Some(Value::Double(d)) => d, + _ => 0.0, + }; + let pressure = match set.get(crate::host::PROP_INTERACT_PEN_PRESSURE, 0) { + Some(Value::Double(d)) => d, + _ => 0.0, + }; + *CAPTURED.lock().unwrap() = Some((vx as f64, px, pressure)); + status::OK + } + let plugin = crate::host::Plugin { + identifier: "fake".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::from("fake"), + contexts: Vec::new(), + descriptor: crate::descriptor::EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry: capture_entry, + ofx_plugin: std::ptr::null_mut(), + }; + let interact = Interact::new(Arc::new(plugin), capture_entry, std::ptr::null_mut()); + // 设 pixelScale=2 → canonical = viewport/2。 + interact.props.define( + crate::host::PROP_INTERACT_PIXEL_SCALE, + vec![Value::Double(2.0), Value::Double(2.0)], + ); + + assert_eq!(interact.pen_motion((10.0, 20.0), true, 0.0), status::OK); + let (vx, px, pressure) = CAPTURED.lock().unwrap().take().unwrap(); + assert_eq!(vx, 10.0); + assert_eq!(px, 5.0); + assert_eq!(pressure, 1.0); + + // pen_up 压力 0。 + assert_eq!(interact.pen_up((10.0, 20.0), 0.0), status::OK); + let (_, _, pressure) = CAPTURED.lock().unwrap().take().unwrap(); + assert_eq!(pressure, 0.0); + } + + /// key 事件真实参数:keySym/keyString 进 inArgs。 + #[test] + fn key_in_args_carry_sym_and_string() { + static CAPTURED: Mutex> = Mutex::new(None); + unsafe extern "C" fn capture_entry( + _action: *const c_char, + _handle: *const c_void, + in_args: *mut c_void, + _out: *mut c_void, + ) -> c_int { + let set = unsafe { &*(in_args as *const PropertySet) }; + let sym = match set.get(crate::host::PROP_KEY_SYM, 0) { + Some(Value::Int(i)) => i, + _ => 0, + }; + let s = match set.get(crate::host::PROP_KEY_STRING, 0) { + Some(Value::String(c)) => c.to_string_lossy().into_owned(), + _ => String::new(), + }; + *CAPTURED.lock().unwrap() = Some((sym, s)); + status::OK + } + let plugin = crate::host::Plugin { + identifier: "fake".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::from("fake"), + contexts: Vec::new(), + descriptor: crate::descriptor::EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry: capture_entry, + ofx_plugin: std::ptr::null_mut(), + }; + let interact = Interact::new(Arc::new(plugin), capture_entry, std::ptr::null_mut()); + assert_eq!(interact.key_down(crate::host::KEY_RETURN, "", 1.0), status::OK); + let (sym, s) = CAPTURED.lock().unwrap().take().unwrap(); + assert_eq!(sym, crate::host::KEY_RETURN); + assert_eq!(s, ""); + assert_eq!(interact.key_up(crate::host::KEY_A, "a", 1.0), status::OK); + let (sym, s) = CAPTURED.lock().unwrap().take().unwrap(); + assert_eq!(sym, crate::host::KEY_A); + assert_eq!(s, "a"); + } + + /// destroy 幂等:只发一次。 + #[test] + fn destroy_is_idempotent() { + static COUNT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); + unsafe extern "C" fn counting_entry( + action: *const c_char, + _handle: *const c_void, + _in: *mut c_void, + _out: *mut c_void, + ) -> c_int { + let a = unsafe { CStr::from_ptr(action) }.to_string_lossy(); + if a == crate::host::ACTION_DESTROY_INSTANCE { + COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + status::OK + } + let plugin = crate::host::Plugin { + identifier: "fake".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::from("fake"), + contexts: Vec::new(), + descriptor: crate::descriptor::EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry: counting_entry, + ofx_plugin: std::ptr::null_mut(), + }; + let interact = Interact::new(Arc::new(plugin), counting_entry, std::ptr::null_mut()); + interact.destroy(); + interact.destroy(); + assert_eq!(COUNT.load(std::sync::atomic::Ordering::Relaxed), 1); + } +} diff --git a/crates/oakplugin/src/suites/mod.rs b/crates/oakplugin/src/suites/mod.rs index 836601a22..19fca7bc6 100644 --- a/crates/oakplugin/src/suites/mod.rs +++ b/crates/oakplugin/src/suites/mod.rs @@ -26,8 +26,10 @@ //! //! 参照:HS: ofxhImageEffect.cpp:2776(fetchSuite 分发表与版本协商)。 +pub mod draw; pub mod gl_render; pub mod image_effect; +pub mod interact; pub mod memory; pub mod message; pub mod multithread; @@ -103,6 +105,9 @@ pub mod tag { pub const CLIP: usize = 5; /// [`crate::image::Image`](clipGetImage 的产物)。 pub const IMAGE: usize = 6; + /// [`crate::suites::interact::Interact`](interact 实例 handle; + /// 实例期 effect/param-set 之外的独立交互对象)。 + pub const INTERACT: usize = 7; /// 打标签(宿主创建对象句柄用;公开供宿主/测试构造句柄)。 pub fn make(props: *const crate::property::PropertySet, t: usize) -> *mut std::ffi::c_void { @@ -183,6 +188,11 @@ pub struct GlCtx { /// 全链路 F32,由 render_gl 按插件 kOfxOpenGLPropPixelDepth 协商 /// 后填入——纹理句柄的 kOfxImageEffectPropPixelDepth 以它为准)。 pub gl_pixel_depth: &'static str, + /// 已附着的**真实 GL 输出纹理名**(GL 模式下宿主为输出帧建的 + /// GL 纹理 + FBO 颜色附件;clipLoadTexture(Output) 的 + /// OpenGLTextureIndex 以它为准——ofxGPURender.h 要求宿主把输出 + /// 纹理绑定为当前颜色缓冲)。None = 无真实 GL 名(CPU 回退)。 + pub output_gl_texture: Option, } thread_local! { @@ -213,6 +223,7 @@ pub(crate) const OFX_API_VERSION: i32 = 105; /// OfxMultiThreadSuite v1。 /// 第 2 期追加:OfxImageEffectOpenGLRenderSuite v1(GL 路径); /// ofxColour 无 suite 表(纯属性 + GetOutputColourspace action)。 +/// 第 3 期追加:OfxInteractSuite v1、OfxDrawSuite v1(interact 宿主)。 pub fn fetch_suite(name: &str, version: i32) -> Option<*const std::ffi::c_void> { let suite: *const std::ffi::c_void = match (name, version) { ("OfxPropertySuite", 1) => ptr(property::suite_v1()), @@ -226,6 +237,8 @@ pub fn fetch_suite(name: &str, version: i32) -> Option<*const std::ffi::c_void> ("OfxTimeLineSuite", 1) => ptr(timeline::suite_v1()), ("OfxMultiThreadSuite", 1) => ptr(multithread::suite_v1()), ("OfxImageEffectOpenGLRenderSuite", 1) => ptr(gl_render::suite_v1()), + ("OfxInteractSuite", 1) => ptr(interact::suite_v1()), + ("OfxDrawSuite", 1) => ptr(draw::suite_v1()), _ => return None, }; Some(suite) @@ -240,7 +253,7 @@ fn ptr(p: &'static T) -> *const std::ffi::c_void { mod tests { use super::*; - /// 八张 suite 的分发表:版本精确匹配、未知版本/名字 → None。 + /// 十张 suite 的分发表:版本精确匹配、未知版本/名字 → None。 #[test] fn fetch_suite_dispatch() { assert!(fetch_suite("OfxPropertySuite", 1).is_some()); @@ -254,10 +267,14 @@ mod tests { assert!(fetch_suite("OfxTimeLineSuite", 1).is_some()); assert!(fetch_suite("OfxMultiThreadSuite", 1).is_some()); assert!(fetch_suite("OfxImageEffectOpenGLRenderSuite", 1).is_some()); + assert!(fetch_suite("OfxInteractSuite", 1).is_some()); + assert!(fetch_suite("OfxDrawSuite", 1).is_some()); assert!(fetch_suite("OfxPropertySuite", 2).is_none()); assert!(fetch_suite("OfxMessageSuite", 3).is_none()); assert!(fetch_suite("OfxImageEffectOpenGLRenderSuite", 2).is_none()); + assert!(fetch_suite("OfxInteractSuite", 2).is_none()); + assert!(fetch_suite("OfxDrawSuite", 2).is_none()); assert!(fetch_suite("OfxBogusSuite", 1).is_none()); assert!(fetch_suite("", 1).is_none()); } @@ -276,6 +293,7 @@ mod tests { tag::PARAM_INSTANCE, tag::CLIP, tag::IMAGE, + tag::INTERACT, ] { let h = tag::make(props, t); assert_eq!(tag::kind(h), t); @@ -340,6 +358,7 @@ mod tests { renderer, output_texture: tex.clone(), gl_pixel_depth: "OfxBitDepthFloat", + output_gl_texture: Some(42), })); let got = gl_ctx().unwrap(); assert_eq!( @@ -348,6 +367,7 @@ mod tests { ); assert!(got.output_texture.is_dummy()); assert_eq!(got.gl_pixel_depth, "OfxBitDepthFloat"); + assert_eq!(got.output_gl_texture, Some(42)); set_gl_ctx(None); assert!(gl_ctx().is_none()); } diff --git a/crates/oakplugin/src/suites/param.rs b/crates/oakplugin/src/suites/param.rs index bfd64a8d4..c54a6787c 100644 --- a/crates/oakplugin/src/suites/param.rs +++ b/crates/oakplugin/src/suites/param.rs @@ -816,6 +816,7 @@ mod tests { cancel: std::sync::atomic::AtomicBool::new(false), edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), }); (inst.clone(), instance_handle(&inst)) } diff --git a/crates/oakplugin/src/suites/progress.rs b/crates/oakplugin/src/suites/progress.rs index 04cc2e6f3..2b071a2f9 100644 --- a/crates/oakplugin/src/suites/progress.rs +++ b/crates/oakplugin/src/suites/progress.rs @@ -114,7 +114,14 @@ unsafe extern "C" fn progress_update_v1(_handle: *mut c_void, progress: c_double } unsafe extern "C" fn progress_end_v1(_handle: *mut c_void) -> c_int { - caught(|| status::OK) + caught(|| { + CURRENT.with(|c| { + if let Some(r) = c.borrow().as_ref() { + r.end_ui(); + } + }); + status::OK + }) } unsafe extern "C" fn progress_start_v2( diff --git a/crates/oakplugin/tests/gl_render_test.rs b/crates/oakplugin/tests/gl_render_test.rs new file mode 100644 index 000000000..da90ec2b7 --- /dev/null +++ b/crates/oakplugin/tests/gl_render_test.rs @@ -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 . + +//! 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 { + 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 消息" + ); + }); +} diff --git a/crates/oakplugin/tests/interact_test.rs b/crates/oakplugin/tests/interact_test.rs new file mode 100644 index 000000000..d8fbe373c --- /dev/null +++ b/crates/oakplugin/tests/interact_test.rs @@ -0,0 +1,372 @@ +// 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 . + +//! Interact 宿主端到端:生命周期 + action 调用面 + Draw suite 真实 GL。 +//! +//! 链路:扫描最小测试插件的 interact 变体(org.oak.test-plugin.interact, +//! describe 期声明 kOfxImageEffectPluginPropOverlayInteractV2 → 指向 +//! mainEntryInteract)→ `new_interact`(发 kOfxActionNewInteract)→ +//! describe → create_instance → pen/key/idle 事件 → draw(宿主 +//! gl_bridge 保持 GL current,插件经 Draw suite setColour + 原生 GL 画 +//! 已知色块)→ 回读断言。 +//! +//! 断言即"真实调用到插件"的证据:插件把每次 action 的实参写入 +//! `OAK_TEST_PLUGIN_INTERACT_MARKER` 指向的标记文件(unix 下 +//! `va_list` 由 stdarg.h 承担),测试逐行断言;GL 断言另读回 FBO 像素 +//! 比对插件绘制颜色(与 CPU 路径可区分)。 +//! +//! 门:事件用例不依赖 GL,任何平台可跑;draw 用例要求 macOS + +//! `OAK_GPU_TESTS`(GL 验收约定,CI 一律跳过)。 + +mod common; + +use std::path::PathBuf; + +use oakplugin::suites::{interact::Interact, status}; + +const INTERACT_PLUGIN_ID: &str = "org.oak.test-plugin.interact"; +const BASE_PLUGIN_ID: &str = "org.oak.test-plugin"; +/// 插件把 interact action 记录写入该环境变量指向的文件。 +const MARKER_ENV: &str = "OAK_TEST_PLUGIN_INTERACT_MARKER"; + +/// 扫描测试插件 + 注册节点(幂等;不可用 → false = skip)。 +fn scan_and_register() -> bool { + let Some(dir) = common::test_plugin_scan_dir() else { + common::skip("最小测试插件未构建"); + return false; + }; + if oakplugin::host::Host::global().cache.scan_path(&dir).is_err() { + common::skip("测试插件扫描失败"); + return false; + } + oakplugin::node_factory::register_plugin_nodes(); + true +} + +/// 独立标记文件路径(临时目录,进程 id 防串)。 +fn marker_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "oak-interact-{}-{}.log", + std::process::id(), + tag + )) +} + +/// 读标记文件全部行。 +fn read_marker(path: &PathBuf) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|l| l.to_string()) + .collect() +} + +/// 无 interact 的插件:new_interact → None(main entry 对 +/// kOfxActionNewInteract 返回 ReplyDefault 且未声明 overlay 入口)。 +#[test] +fn base_plugin_has_no_interact() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let inst = oakplugin::host::Host::global() + .create_instance(BASE_PLUGIN_ID, None) + .expect("base 插件实例应可建"); + assert!( + inst.value.new_interact().is_none(), + "无 interact 的插件 new_interact 应为 None" + ); + oakplugin::host::Host::global().shutdown(); + }); +} + +/// 生命周期 + 事件调用面:new_interact → describe → create → pen/key/ +/// idle → destroy。逐条断言插件侧真实记录与参数。 +#[test] +fn interact_lifecycle_and_events() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let marker = marker_path("events"); + let _ = std::fs::remove_file(&marker); + unsafe { std::env::set_var(MARKER_ENV, &marker) }; + + let inst = oakplugin::host::Host::global() + .create_instance(INTERACT_PLUGIN_ID, None) + .expect("interact 变体实例应可建"); + + let interact_arc = inst + .value + .new_interact() + .expect("interact 变体应创建 interact"); + let interact: &Interact = &interact_arc; + assert_eq!(interact.describe(), status::OK); + assert_eq!(interact.create_instance(), status::OK); + + // 事件:坐标为视口像素;pixelScale 默认 1 → canonical == 视口。 + assert_eq!(interact.pen_motion((10.0, 20.0), true, 5.0), status::OK); + assert_eq!(interact.pen_down((30.0, 40.0), 5.0), status::OK); + assert_eq!(interact.pen_up((30.0, 40.0), 5.0), status::OK); + assert_eq!( + interact.key_down(oakplugin::host::KEY_A, "a", 5.0), + status::OK + ); + assert_eq!(interact.key_up(oakplugin::host::KEY_A, "a", 5.0), status::OK); + assert_eq!(interact.idle(), status::OK); + interact.destroy(); + + unsafe { std::env::remove_var(MARKER_ENV) }; + let lines = read_marker(&marker); + let _ = std::fs::remove_file(&marker); + + // 生命周期序列(按序)。 + let seq = ["new_interact", "describe", "create", "destroy"]; + let pos: Vec> = seq + .iter() + .map(|s| lines.iter().position(|l| l == s)) + .collect(); + assert!( + pos.iter().all(|p| p.is_some()), + "标记文件缺生命周期动作:{lines:?}" + ); + let pos: Vec = pos.into_iter().map(|p| p.unwrap()).collect(); + assert!( + pos.windows(2).all(|w| w[0] < w[1]), + "生命周期顺序应为 new_interact→describe→create→destroy:{lines:?}" + ); + + // pen 事件:真实参数(视口位置/canonical/压力;C %g 无尾零)。 + assert!( + lines.iter().any(|l| l == "pen_motion vp=10,20 canon=10,20 pressure=1"), + "pen_motion 参数不符:{lines:?}" + ); + assert!( + lines.iter().any(|l| l == "pen_down vp=30,40 canon=30,40 pressure=1"), + "pen_down 参数不符:{lines:?}" + ); + assert!( + lines.iter().any(|l| l == "pen_up vp=30,40 canon=30,40 pressure=0"), + "pen_up 参数不符:{lines:?}" + ); + + // key 事件:真实 keySym/keyString。 + assert!( + lines.iter().any(|l| l == "key_down sym=97 str=a"), + "key_down 参数不符:{lines:?}" + ); + assert!( + lines.iter().any(|l| l == "key_up sym=97 str=a"), + "key_up 参数不符:{lines:?}" + ); + assert!( + lines.iter().any(|l| l == "idle"), + "idle 未记录:{lines:?}" + ); + + oakplugin::host::Host::global().shutdown(); + }); +} + +/// 返回值如实透传:插件对 Escape 的 key_down 返回 kOfxStatReplyDefault +/// (未处理),宿主原样返回 14。 +#[test] +fn interact_passthrough_plugin_status() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let marker = marker_path("status"); + let _ = std::fs::remove_file(&marker); + unsafe { std::env::set_var(MARKER_ENV, &marker) }; + + let inst = oakplugin::host::Host::global() + .create_instance(INTERACT_PLUGIN_ID, None) + .expect("interact 变体实例应可建"); + let interact = inst.value.new_interact().expect("interact 应可建"); + + // Escape 的 key_down:插件返回 ReplyDefault → 宿主透传。 + let st = interact.key_down(oakplugin::host::KEY_ESCAPE, "", 0.0); + assert_eq!(st, status::REPLY_DEFAULT, "插件 Escape → ReplyDefault 应透传"); + + unsafe { std::env::remove_var(MARKER_ENV) }; + let lines = read_marker(&marker); + let _ = std::fs::remove_file(&marker); + assert!( + lines.iter().any(|l| l == "key_down sym=65307 str="), + "Escape key_down 应真实到达插件:{lines:?}" + ); + + oakplugin::host::Host::global().shutdown(); + }); +} + +/// 实例销毁连带销毁 interact(同一生命周期):drop 实例 → 插件收到 +/// destroy(interact 的 kOfxActionDestroyInstance)。 +#[test] +fn instance_destroy_cleans_up_interact() { + common::with_host(|| { + if !scan_and_register() { + return; + } + let marker = marker_path("destroy"); + let _ = std::fs::remove_file(&marker); + unsafe { std::env::set_var(MARKER_ENV, &marker) }; + + let inst = oakplugin::host::Host::global() + .create_instance(INTERACT_PLUGIN_ID, None) + .expect("interact 变体实例应可建"); + let _interact = inst.value.new_interact().expect("interact 应可建"); + + // drop 实例(Arc 归零 → Drop → notify_destroy → interact destroy)。 + drop(inst); + + unsafe { std::env::remove_var(MARKER_ENV) }; + let lines = read_marker(&marker); + let _ = std::fs::remove_file(&marker); + assert!( + lines.iter().any(|l| l == "destroy"), + "实例销毁应连带 interact destroy:{lines:?}" + ); + + oakplugin::host::Host::global().shutdown(); + }); +} + +/// draw 端到端(真实 GL):宿主 acquire → 建输出纹理/FBO → 调 draw → +/// 插件 glClear 暗背景 + Draw suite setColour(0.9,0.1,0.2,1) + +/// draw(Rectangle 10..30) → 回读断言矩形区域颜色与背景色。 +/// +/// 同时断言 Draw suite 状态真实往返:setColour/getColour 在插件侧返回 +/// OK(记录在标记文件)。 +#[test] +fn interact_draw_renders_plugin_colours() { + common::with_host(|| { + if !cfg!(target_os = "macos") { + common::skip("GL 桥仅 macOS 实现"); + return; + } + if !common::gpu_available() { + common::skip("GL 测试需 OAK_GPU_TESTS"); + return; + } + if !oakplugin::gl_bridge::gl_available() { + common::skip("本机无可用 GL 上下文"); + return; + } + if !scan_and_register() { + return; + } + let marker = marker_path("draw"); + let _ = std::fs::remove_file(&marker); + unsafe { std::env::set_var(MARKER_ENV, &marker) }; + + let inst = oakplugin::host::Host::global() + .create_instance(INTERACT_PLUGIN_ID, None) + .expect("interact 变体实例应可建"); + let interact = inst.value.new_interact().expect("interact 应可建"); + + let (w, h) = (64i32, 64i32); + let params = oakplugin::render::VideoParams { + width: w, + height: h, + format: oakplugin::render::PIXEL_FORMAT_F32, + ..Default::default() + }; + // 一次 acquire 覆盖 FBO 装配 → draw(内部嵌套 acquire)→ 回读。 + let _guard = oakplugin::gl_bridge::acquire().expect("acquire 应成功"); + let tex = oakplugin::gl_bridge::create_output_texture(w, h, ¶ms) + .expect("输出纹理应可建"); + let fbo = oakplugin::gl_bridge::create_fbo(tex, w, h).expect("FBO 应完整"); + oakplugin::gl_bridge::bind_fbo(fbo); + oakplugin::gl_bridge::set_viewport(w, h); + + let st = interact.draw((64.0, 64.0), (1.0, 1.0), 5.0, None); + assert_eq!(st, status::OK, "draw 应返回插件 OK"); + + let img = oakplugin::gl_bridge::read_pixels_to_image(w, h, ¶ms).expect("回读应成功"); + oakplugin::gl_bridge::delete_fbo(fbo); + oakplugin::gl_bridge::delete_gl_texture(tex); + drop(_guard); + + unsafe { std::env::remove_var(MARKER_ENV) }; + let lines = read_marker(&marker); + let _ = std::fs::remove_file(&marker); + + // Draw suite 状态真实往返(插件侧记录)。 + assert!( + lines.iter().any(|l| { + l.starts_with("draw vp=64x64") && l.contains("setcol_st=0") + && l.contains("setcol=0.9,0.1,0.2,1") + && l.contains("getcol_st=0") && l.contains("getcol=0,0,0,1") + && l.contains("draw_st=0") + }), + "draw 记录不符(setcol/getcol/draw 应 OK):{lines:?}" + ); + + // 像素断言:矩形区域 (10..30)² 的 GL 像素 → 翻转后 frame y 33..53。 + let px = |x: usize, y: usize| -> [f32; 4] { + let p = &img.pixels()[y * (w as usize) * 4 + x * 4..y * (w as usize) * 4 + x * 4 + 4]; + [ + f32::from_ne_bytes(p[0..4].try_into().unwrap()), + f32::from_ne_bytes(p[4..8].try_into().unwrap()), + f32::from_ne_bytes(p[8..12].try_into().unwrap()), + f32::from_ne_bytes(p[12..16].try_into().unwrap()), + ] + }; + // 角上(rect 外)应为插件清屏暗背景。 + let corner = px(0, 0); + for (i, v) in [0.05f32, 0.05, 0.05, 1.0].iter().enumerate() { + assert!( + (corner[i] - v).abs() < 1e-4, + "角像素[{i}] = {},期望暗背景 {v}", + corner[i] + ); + } + // rect 内部(frame (20,40) = GL (20,23))应为 setColour 颜色。 + let inside = px(20, 40); + for (i, v) in [0.9f32, 0.1, 0.2, 1.0].iter().enumerate() { + assert!( + (inside[i] - v).abs() < 1e-4, + "矩形内像素[{i}] = {},期望 {v}", + inside[i] + ); + } + // 整帧只应有两种颜色(背景 + 矩形),矩形覆盖 ~400 像素。 + let (mut rect_n, mut clear_n) = (0usize, 0usize); + for y in 0..h as usize { + for x in 0..w as usize { + let c = px(x, y); + let is_rect = (0..4).all(|i| (c[i] - [0.9, 0.1, 0.2, 1.0][i]).abs() < 1e-3); + let is_clear = (0..4).all(|i| (c[i] - [0.05, 0.05, 0.05, 1.0][i]).abs() < 1e-3); + if is_rect { + rect_n += 1; + } else if is_clear { + clear_n += 1; + } else { + panic!("意外颜色像素 ({x},{y}): {c:?}"); + } + } + } + assert!( + (rect_n as i32 - 400).abs() <= 16, + "矩形应覆盖 ~400 像素,got {rect_n}" + ); + assert_eq!(rect_n + clear_n, (w * h) as usize, "整帧应为背景+矩形两色"); + + oakplugin::host::Host::global().shutdown(); + }); +} diff --git a/crates/oakplugin/tests/push_button_test.rs b/crates/oakplugin/tests/push_button_test.rs new file mode 100644 index 000000000..69fef3205 --- /dev/null +++ b/crates/oakplugin/tests/push_button_test.rs @@ -0,0 +1,79 @@ +// 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 . + +//! push button → `kOfxActionInstanceChanged` 路由(端到端)。 +//! +//! 链路:`scan_path`(最小测试插件,cbits/oak_test_plugin.c,describe +//! 里定义了 push button 参数 "button",instanceChanged 时按 +//! `OAK_TEST_PLUGIN_INSTANCECHANGED_MARKER` 指向的文件追加一行)→ +//! `create_instance` → `push_button_clicked`(置值 + 路由 +//! UserEdited instanceChanged)→ 插件记录回调次数。 +//! +//! 测试插件未构建时 skip(common 约定)。宿主单例经 `common::with_host` +//! 串行化。 + +mod common; + +use oakplugin::host::Host; + +const PLUGIN_ID: &str = "org.oak.test-plugin"; + +#[test] +fn push_button_press_routes_instance_changed() { + common::with_host(|| { + let Some(dir) = common::test_plugin_scan_dir() else { + common::skip("最小测试插件未构建"); + return; + }; + if Host::global().cache.scan_path(&dir).is_err() { + common::skip("测试插件扫描失败"); + return; + } + let inst = Host::global() + .create_instance(PLUGIN_ID, None) + .expect("实例"); + let id = oakplugin::node_factory::register_instance(inst.clone()); + + // The C plugin appends one line per instanceChanged("button") to the + // marker file; the assertion reads it back. + let marker = std::env::temp_dir().join(format!("oak-push-{}.log", std::process::id())); + let _ = std::fs::remove_file(&marker); + std::env::set_var("OAK_TEST_PLUGIN_INSTANCECHANGED_MARKER", &marker); + + // The button param exists and is a push button. + let p = inst.value.params.find("button").expect("button 参数"); + assert_eq!(p.def.ofx_type, oakplugin::param::TYPE_PUSHBUTTON); + + // First press: set + routed to the plugin. + assert!(oakplugin::node_factory::push_button_clicked(id, "button")); + // Unknown / non-button params are rejected without touching the entry. + assert!(!oakplugin::node_factory::push_button_clicked(id, "gain")); + assert!(!oakplugin::node_factory::push_button_clicked(id, "nope")); + assert!(!oakplugin::node_factory::push_button_clicked(u64::MAX, "button")); + // Second press on the real button: routed again. + assert!(oakplugin::node_factory::push_button_clicked(id, "button")); + + // Exactly the two real presses reached the plugin's instanceChanged. + let log = std::fs::read_to_string(&marker).expect("marker 文件应存在"); + assert_eq!(log.lines().count(), 2, "marker log:\n{log}"); + assert!(log.contains("instanceChanged"), "marker log:\n{log}"); + + std::env::remove_var("OAK_TEST_PLUGIN_INSTANCECHANGED_MARKER"); + let _ = std::fs::remove_file(&marker); + oakplugin::node_factory::unregister_instance(id); + Host::global().shutdown(); + }); +} diff --git a/crates/oakplugin/tests/suites_test.rs b/crates/oakplugin/tests/suites_test.rs index c8cdb781e..26b95990b 100644 --- a/crates/oakplugin/tests/suites_test.rs +++ b/crates/oakplugin/tests/suites_test.rs @@ -124,6 +124,7 @@ fn make_instance() -> (std::sync::Arc, *mut c_void) { cancel: std::sync::atomic::AtomicBool::new(false), edit: std::sync::Mutex::new(oakplugin::instance::EditTransaction::new()), render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), }); let h = tag::make(&inst.props as *const PropertySet, tag::INSTANCE); (inst, h) @@ -309,6 +310,7 @@ fn image_effect_clip_image_pairing() { cancel: std::sync::atomic::AtomicBool::new(false), edit: std::sync::Mutex::new(oakplugin::instance::EditTransaction::new()), render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), }); let ih = tag::make(&inst.props as *const PropertySet, tag::INSTANCE); let mut clip_h: *mut c_void = std::ptr::null_mut(); diff --git a/crates/oakrender/src/ipc.rs b/crates/oakrender/src/ipc.rs index ab1e55b27..1d8f3c3fa 100644 --- a/crates/oakrender/src/ipc.rs +++ b/crates/oakrender/src/ipc.rs @@ -119,6 +119,15 @@ pub const TYPE_FRAME_FAILED: &str = "frame_failed"; /// `"render_audio_batch"` (protocol v2, M15 S3): a batch of audio range /// pulls rendered into the same shm slot transport as video frames. pub const TYPE_RENDER_AUDIO_BATCH: &str = "render_audio_batch"; +/// `"plugin_progress"` (protocol v2): worker->main — one OFX plugin +/// progress event (progressStart/Update/End forwarded over the control +/// plane). The main process drains it into the plugin-progress dialog. +pub const TYPE_PLUGIN_PROGRESS: &str = "plugin_progress"; +/// `"plugin_cancel"` (protocol v2): main->worker — the user cancelled the +/// plugin render. The worker sets its sticky cancel flag; every live +/// progress reporter then answers false (the plugin aborts at its next +/// progressUpdate). +pub const TYPE_PLUGIN_CANCEL: &str = "plugin_cancel"; /// Wire-format slot format for 8-bit BGRA frames (M15 S1). The viewer /// preview path requests BGRA8 so the worker converts its F32 pipeline @@ -389,6 +398,46 @@ pub struct FrameFailedMsg { pub error: String, } +/// `plugin_progress` (worker->main) — one OFX plugin progress event +/// forwarded over the control plane. +/// +/// The oakplugin progress suite runs in the worker process (plugin +/// rendering is process-isolated); the worker installs a progress reporter +/// factory whose reporters push these messages to stdout. Wire shape is +/// intentionally the same as the main-process `PluginProgressEvent`: +/// progressStart arrives with fraction 0 and label/message set, +/// progressUpdate with the fraction, progressEnd with fraction 1.0 (the +/// app closes the dialog on >= 1.0, mirroring the main-process reporter — +/// the `UiProgressReporter` trait has no end hook, so completion is +/// inferred from the fraction there too). +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct PluginProgressMsg { + /// The plugin's progressStart label. + pub label: String, + /// The plugin's progressStart message. + pub message: String, + /// Progress fraction in 0.0..=1.0. + pub fraction: f64, +} + +impl PluginProgressMsg { + /// Build the wire `plugin_progress` value. + pub fn to_json(&self) -> Value { + json!({ + "type": TYPE_PLUGIN_PROGRESS, + "label": self.label, + "message": self.message, + "fraction": self.fraction, + }) + } +} + +/// The wire `plugin_cancel` message (main->worker; no payload). +pub fn plugin_cancel_json() -> Value { + json!({ "type": TYPE_PLUGIN_CANCEL }) +} + /// Build a worker-side error report, mirroring `error_message()` in /// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when /// non-zero. @@ -1227,6 +1276,43 @@ mod tests { assert_eq!(round, value); } + #[test] + fn plugin_progress_message_round_trips() { + let msg = PluginProgressMsg { + label: "render".into(), + message: "pass 1".into(), + fraction: 0.5, + }; + let value = msg.to_json(); + assert_eq!(value["type"], TYPE_PLUGIN_PROGRESS); + assert_eq!(value["label"], "render"); + assert_eq!(value["message"], "pass 1"); + assert_eq!(value["fraction"], 0.5); + + // The NDJSON line parses back to the same payload (the wire + // contract the worker writes and the dispatcher reads). + let line = serde_json::to_string(&value).unwrap(); + let parsed: PluginProgressMsg = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed, msg); + } + + #[test] + fn plugin_progress_defaults_on_missing_fields() { + // A minimal message (e.g. progressEnd forwarded as fraction 1.0 + // without touching label/message) deserializes cleanly. + let m: PluginProgressMsg = + serde_json::from_str(r#"{"type":"plugin_progress","fraction":1.0}"#).unwrap(); + assert_eq!(m.fraction, 1.0); + assert!(m.label.is_empty()); + assert!(m.message.is_empty()); + } + + #[test] + fn plugin_cancel_wire_shape() { + let value = plugin_cancel_json(); + assert_eq!(value["type"], TYPE_PLUGIN_CANCEL); + } + #[test] fn render_frame_parse_accepts_cpp_field_names() { let json = r#"{"type":"render_frame","ticket":42,"node":"abcd","time_num":1,"time_den":24,"width":1920,"height":1080,"format":-1,"channels":0,"mode":0,"input_slot":-1,"input_slots":[],"has_color_transform":false,"color_output":"","color_view":"","color_look":""}"#; diff --git a/crates/oakrender/src/procpool.rs b/crates/oakrender/src/procpool.rs index c9d745431..ca966737a 100644 --- a/crates/oakrender/src/procpool.rs +++ b/crates/oakrender/src/procpool.rs @@ -62,7 +62,7 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak}; use std::time::{Duration, Instant}; use serde_json::{json, Value}; @@ -70,10 +70,11 @@ use serde_json::{json, Value}; use crate::error::{Error, Result}; use crate::ipc::{ write_message, AudioTicketSpec, BatchAcceptedMsg, BatchTicketSpec, FrameFailedMsg, FrameReadyMsg, - FrameSlotMeta, FrameSlotPool, HandshakeMsg, HelloCapsMsg, RenderAudioBatchMsg, RenderBatchMsg, - SharedMemoryRegion, ShmMode, WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED, - TYPE_ERROR, TYPE_FRAME_FAILED, TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS, - TYPE_RENDER_AUDIO_BATCH, + FrameSlotMeta, FrameSlotPool, HandshakeMsg, HelloCapsMsg, PluginProgressMsg, RenderAudioBatchMsg, + RenderBatchMsg, SharedMemoryRegion, ShmMode, WireMontageClip, SLOT_FORMAT_BGRA8, + TYPE_BATCH_ACCEPTED, TYPE_ERROR, TYPE_FRAME_FAILED, TYPE_FRAME_READY, TYPE_HANDSHAKE, + TYPE_HELLO_CAPS, TYPE_PLUGIN_CANCEL, TYPE_PLUGIN_PROGRESS, TYPE_RENDER_AUDIO_BATCH, + plugin_cancel_json, }; use crate::scheduler::{FrameKey, FrameRequest, PreviewScheduler, SubmitOutcome}; use crate::ticket::{ @@ -118,6 +119,59 @@ pub fn reset_main_heap_frame_copies() { MAIN_FRAME_COPIES.store(0, Ordering::Relaxed); } +// --------------------------------------------------------------------------- +// Plugin-progress forwarding (worker -> main) and cancel broadcast +// --------------------------------------------------------------------------- + +/// The app-facing plugin-progress callback: invoked by the dispatcher (on +/// the UI tick's poll) for every worker-forwarded `plugin_progress` line +/// (label, message, fraction). The app's [`crate::oakui::ofx`] wiring +/// forwards these into its `PluginProgressEvent` channel. `Arc` so the +/// registry can hand out cheap clones (the callback is `Fn`, not `Clone`). +pub type PluginProgressCb = Arc; + +static PLUGIN_PROGRESS_CB: OnceLock>> = OnceLock::new(); + +/// Register (or clear) the plugin-progress forwarding callback. +pub fn set_plugin_progress_cb(cb: Option) { + *PLUGIN_PROGRESS_CB + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()) = cb; +} + +fn plugin_progress_cb() -> Option { + PLUGIN_PROGRESS_CB + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} + +/// Weak handle to the live dispatcher, registered by +/// [`ProcessDispatcher::new`] so the cancel broadcast can reach the +/// workers without threading a handle through the app. +static DISPATCHER: OnceLock>> = OnceLock::new(); + +fn dispatcher_slot() -> &'static Mutex> { + DISPATCHER.get_or_init(|| Mutex::new(Weak::new())) +} + +/// Broadcast a `plugin_cancel` message to every alive worker: the user +/// cancelled the plugin render; the workers set their sticky cancel flag +/// and their live progress reporters answer false from then on (the +/// plugin aborts at its next progressUpdate). Falls back to a no-op when +/// no dispatcher is live (inline/test backends). +pub fn request_plugin_cancel_all() { + let dispatcher = dispatcher_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .upgrade(); + if let Some(dispatcher) = dispatcher { + dispatcher.broadcast_plugin_cancel(); + } +} + // --------------------------------------------------------------------------- // ShmRegionView — one worker segment as seen from the main process // --------------------------------------------------------------------------- @@ -718,7 +772,7 @@ impl ProcessDispatcher { }; let bin = resolve_worker_bin(&config)?; let (events_tx, events_rx) = mpsc::channel(); - Ok(Arc::new(ProcessDispatcher { + let dispatcher = Arc::new(ProcessDispatcher { inner: Mutex::new(Inner { config, bin, @@ -734,7 +788,12 @@ impl ProcessDispatcher { started: false, shutting_down: false, }), - })) + }); + // Register the weak handle for the plugin-cancel broadcast. + *dispatcher_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Arc::downgrade(&dispatcher); + Ok(dispatcher) } /// Spawn all workers and wait for the handshakes (bounded by @@ -902,6 +961,22 @@ impl ProcessDispatcher { } } + /// Broadcast the plugin-cancel signal to every alive worker (the user + /// cancelled the plugin render from the progress dialog). The message + /// is a fire-and-forget control line; the worker sets its sticky cancel + /// flag and the next reporter update answers false. A send failure + /// recycles that worker (it will restart on the next pump). + pub fn broadcast_plugin_cancel(&self) { + let mut inner = lock(&self.inner); + for handle in inner.workers.iter_mut() { + if matches!(handle.state, WorkerState::Alive | WorkerState::Starting) { + if self.send_json(handle, &plugin_cancel_json()).is_err() { + handle.state = WorkerState::Dead; + } + } + } + } + // ---- internals ------------------------------------------------------ fn pump(&self, inner: &mut Inner, fired: &mut Vec<(Completion, TicketResult)>) { @@ -1046,6 +1121,16 @@ impl ProcessDispatcher { } } } + TYPE_PLUGIN_PROGRESS => { + // A worker forwarded an OFX plugin progress event; hand it + // to the app's registered callback (which drives the + // plugin-progress dialog). + if let Ok(progress) = serde_json::from_value::(msg) { + if let Some(cb) = plugin_progress_cb() { + cb(progress.label, progress.message, progress.fraction); + } + } + } _ => {} } } @@ -1902,4 +1987,55 @@ mod tests { reset_main_heap_frame_copies(); assert_eq!(main_heap_frame_copies(), 0); } + + /// A worker's `plugin_progress` NDJSON line is forwarded to the + /// registered app callback as (label, message, fraction) — the seam + /// that drives the main-process plugin-progress dialog. + #[test] + fn plugin_progress_line_forwards_to_callback() { + let config = DispatcherConfig { + worker_bin: Some(std::path::PathBuf::from("/bin/true")), + workers: 1, + slots_per_worker: 2, + width: 16, + height: 16, + batch_size: 1, + ..Default::default() + }; + let dispatcher = ProcessDispatcher::new(config).expect("dispatcher"); + // `new` registered the weak handle (the cancel broadcast seam). + assert!(dispatcher_slot().lock().unwrap().upgrade().is_some()); + + let received: Arc>> = Arc::new(Mutex::new(Vec::new())); + set_plugin_progress_cb(Some(Arc::new({ + let received = received.clone(); + move |label, message, fraction| { + received.lock().unwrap().push((label, message, fraction)); + } + }))); + + // A fake worker handle so on_line has a target (no spawn needed). + { + let mut inner = dispatcher.inner.lock().unwrap_or_else(|e| e.into_inner()); + let key = SharedMemoryRegion::make_key(std::process::id() as i64, 999); + let shm = ShmRegionView::create(&key, 2, 256).expect("shm"); + inner.workers.push(WorkerHandle::shell(0, 0, shm, 2, 256)); + } + + let mut fired = Vec::new(); + { + let mut inner = dispatcher.inner.lock().unwrap_or_else(|e| e.into_inner()); + dispatcher.on_line( + &mut inner, + 0, + r#"{"type":"plugin_progress","label":"render","message":"pass 1","fraction":0.5}"#, + &mut fired, + ); + } + set_plugin_progress_cb(None); + + let events = received.lock().unwrap().clone(); + assert_eq!(events.len(), 1); + assert_eq!(events[0], ("render".to_string(), "pass 1".to_string(), 0.5)); + } }