- docs/zh/plans: finished plans move to completed/ (the RIIR series, the event-bridge and dependency plans, the v04 manual test plan). - New design docs: the external (functional) plugin system (process-isolated, JSON-RPC/shm) and its protocol. - ai-agent-design refreshed; README pointers follow the moves.
335 lines
19 KiB
Markdown
335 lines
19 KiB
Markdown
# 外部功能插件系统设计(进程隔离 + JSON-RPC/shm)
|
||
|
||
> 本文是 Oak **功能性插件系统**的总体设计,面向没有当前对话记忆的执行者,自包含。
|
||
>
|
||
> **定位**:与 `oak-plugin`(OpenFX 宿主)正交。OFX 管"效果/滤镜"这类图像处理插件;
|
||
> 本系统管"功能/工作流"插件——插件可以**调用 Oak 内部功能**(建工程、导入素材、
|
||
> 时间线编辑、加效果、取帧、导出)并**绘制自己的 UI 面板**。旗舰用例是 AI 剪辑插件:
|
||
> 给多模态 AI 一组工具,让它自己"看"视频(取帧回喂)并执行剪辑——外部程序因此
|
||
> 必须能完整操作 Oak。
|
||
>
|
||
> **红线**:
|
||
> 1. 插件代码**永不进入 Oak 主进程**(不 dlopen、不链接任何 Rust 库)。一个插件
|
||
> 一个独立进程,插件崩溃不得连带 Oak。
|
||
> 2. 插件可以是任何语言(C++/Python/Node…),协议必须是**语言无关的文本协议 +
|
||
> 共享内存数据面**,不发明需要链接 Rust/C ABI 的绑定。
|
||
> 3. 插件的一切编辑动作**必须可撤销**(UndoStack 事务),默认"确认后执行"。
|
||
> 4. 复用既有基础设施,不新造轮子:渲染进程隔离(`oak-render/src/procpool.rs` +
|
||
> `oak-render/src/ipc.rs`,M15 已落地)的 **NDJSON over stdio + shm 帧槽** 模式
|
||
> 就是本系统传输层的范本。
|
||
>
|
||
> **协议全文**(消息信封、握手、方法/事件目录、错误码、shm 布局、UI 协议)
|
||
> 冻结在 [`external-plugin-protocol.md`](external-plugin-protocol.md)(OPP/1);
|
||
> 实现以协议文档为准。
|
||
|
||
---
|
||
|
||
## 1. 关键决策
|
||
|
||
### 1.1 进程模型:插件 = 独立可执行文件(推荐),而非"库 + 宿主进程加载"
|
||
|
||
两种候选:
|
||
|
||
- **A. 插件即进程**:每个插件是一个独立可执行文件(Python 插件则是
|
||
`python3 main.py` 这样的启动命令),Oak 按清单(manifest)spawn,经 stdio 说话。
|
||
即 LSP / MCP 模型。
|
||
- **B. 插件即库 + 通用宿主进程**:插件编译成动态库,由一个 `oak-plugin-host`
|
||
进程 dlopen 它,宿主进程再与 Oak 通信。
|
||
|
||
**定为 A**,理由:
|
||
|
||
1. **B 只是名义上更隔离**。dlopen 进宿主进程后,插件崩溃杀掉的是宿主进程,
|
||
效果与 A 完全相同;但 B 要求宿主进程按语言分别内嵌加载器(C++ 用 dlopen,
|
||
Python 得内嵌解释器或再起子进程),复杂度显著高于 A,没有换来任何隔离收益。
|
||
2. **A 对解释型语言天然成立**。Python/Node 插件本来就是"一个命令",B 模型下
|
||
反而要多包一层。
|
||
3. **A 与仓库既有模式一致**:`oak-worker` 就是"Oak spawn 一个可执行文件 +
|
||
NDJSON 握手 + shm 附加",含崩溃检测、有界重启(`MAX_RESTARTS=5`)、握手超时。
|
||
插件宿主直接照搬这套生命周期管理。
|
||
4. **协议实现在 SDK,不在宿主**。担心"每个插件重写一遍协议"用 SDK 解决:
|
||
官方提供 C/C++ 头文件库与 Python 包(各 ~200 行,见 §6),插件作者只写
|
||
`on_request(method, params)` 回调。
|
||
|
||
代价(明说):每种语言需要一个薄 SDK;stdio 单通道对极高频事件(如逐帧
|
||
playhead 推送)有序列化开销——用事件合并/降频缓解(§4.4),不另开 socket。
|
||
|
||
### 1.2 IPC:JSON-RPC 2.0 over stdin/stdout(控制面)+ shm(数据面)
|
||
|
||
- **控制面**:严格 [JSON-RPC 2.0](https://www.jsonrpc.org/specification),
|
||
NDJSON 分帧(一行一个消息,与 `oak-render/src/ipc.rs` 相同)。**双向**:
|
||
Oak→plugin 发请求(UI 事件、配置下发、shutdown),plugin→Oak 也发请求
|
||
(调内部功能,即 §3 宿主 API),靠 `id` 配对,notification 做事件推送。
|
||
选 JSON-RPC 而非自定义协议:所有语言都有现成实现,且规范本身解决了
|
||
双向请求/通知/错误码问题。
|
||
- **stdio 纪律**:`stdout` 只走协议消息;插件日志一律写 `stderr`,Oak 捕获后
|
||
进日志面板(LSP 惯例)。绝不允许第三方库污染 stdout——SDK 提供
|
||
`redirect_stdout_to_stderr()` 之类的防护。
|
||
- **数据面**:帧/缩略图/波形/插件 UI 位图走 POSIX shm(Windows 用
|
||
`CreateFileMappingW`),消息体只带 `shm 名 + 槽位元数据`(宽/高/格式/步长)。
|
||
直接泛化 `oak-render/src/ipc.rs` 的 `SharedMemoryRegion` / `FrameSlotPool`,
|
||
不新设计。小数据(几 KB 的缩略图)允许内联 base64,阈值建议 64 KiB。
|
||
|
||
```
|
||
Oak 主进程 插件进程(每插件一个)
|
||
┌─────────────────────┐ stdio ┌──────────────────────────┐
|
||
│ PluginHost (每插件) │◄────────►│ 插件 SDK │
|
||
│ ├ 后台 IO 线程 │ NDJSON │ └ 插件逻辑(任意语言) │
|
||
│ ├ 崩溃检测/有界重启 │ JSON-RPC │ │
|
||
│ └ 调用编排到引擎线程 │ │ │
|
||
│ HostApi 实现 ────────┼─► 编排到 oak-app 引擎线程(mpsc/gpui)│
|
||
│ PluginPanel (gpui) │ │ │
|
||
└─────────┬───────────┘ └────────────┬─────────────┘
|
||
│ shm(帧槽池,双向) │
|
||
└────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 2. 生命周期与进程管理
|
||
|
||
### 2.1 清单与发现
|
||
|
||
插件是一个目录(或 `.oakplugin` 包),内含 `plugin.toml`:
|
||
|
||
```toml
|
||
id = "com.example.ai-cut"
|
||
name = "AI 剪辑助手"
|
||
version = "0.1.0"
|
||
api = 1 # 协议主版本,见 §2.2
|
||
|
||
[process]
|
||
# {plugin_dir} 由 Oak 替换;Python 插件就写解释器命令
|
||
command = ["python3", "{plugin_dir}/main.py"]
|
||
env_passthrough = ["PATH", "HOME"]
|
||
|
||
# 能力声明(§5),安装时向用户展示
|
||
capabilities = ["project.read", "media.read", "timeline.edit",
|
||
"render.frame", "export", "ui.panel"]
|
||
|
||
[restart]
|
||
max = 5 # 对齐 procpool 的 MAX_RESTARTS
|
||
backoff_ms = 1000
|
||
```
|
||
|
||
发现路径(对齐 OFX 的发现习惯):`~/.oak/plugins/`、应用内 `plugins/`、
|
||
环境变量 `OAK_PLUGIN_PATH`。Oak 启动时扫描 → 展示在"插件管理器"面板 →
|
||
用户启用后才 spawn(不自动启动未启用插件)。
|
||
|
||
### 2.2 握手与心跳
|
||
|
||
```
|
||
Oak ──► {"method":"handshake","params":{"protocol":1,"oak_version":"...",
|
||
"shm":{"region":"oakxp-1234","slots":8,"slot_bytes":16777216}}}
|
||
Oak ◄── {"result":{"name":"ai-cut","api":1,"capabilities":[...],
|
||
"panels":[{"id":"chat","title":"AI 剪辑"}]}}
|
||
```
|
||
|
||
- 握手超时(对齐 procpool 的实现)→ 判定启动失败,标记插件不可用。
|
||
- 之后 Oak 每 2s 发 `ping`,连续 3 次未响应或 stdout EOF → 判定崩溃:
|
||
该插件的面板显示"已崩溃 [重启]"徽标,未完成的宿主 API 调用全部以
|
||
`PLUGIN_DEAD` 错误返回,按 `restart.max` 有界自动重启。
|
||
- **重启无状态恢复**:协议设计为"注册式"——插件重连后重新走握手、重新注册
|
||
面板。Oak 侧不丢数据:已提交的编辑早已进 UndoStack,与插件存亡无关。
|
||
|
||
### 2.3 Oak 侧组件
|
||
|
||
新增叶子 crate **`oak-plugin-host`**(与 `oak-worker` 平级的消费者角色,
|
||
不动引擎模块):
|
||
|
||
- `PluginHost`:spawn/管道/NDJSON 读写(独立 IO 线程,`std::sync::mpsc` 与
|
||
gpui `cx.spawn` 编排回引擎线程——沿用 app 现有 `set_progress_tx` 模式,
|
||
不引入 tokio)。
|
||
- `HostApi`:把插件请求翻译成内部调用(§3),执行前查能力位(§5)。
|
||
- `PluginPanel`:实现 gpui `DockPanel` 的通用面板壳,注册进
|
||
`AppPanelRegistry`(`crates/oak-app/src/panels/mod.rs` 目前是硬编码
|
||
panel ids——需加一处"动态 panel 注册"扩展点,这是 app 侧唯一的新机制)。
|
||
- `ShmPool`:泛化自 `oak-render/src/ipc.rs`。
|
||
|
||
---
|
||
|
||
## 3. 宿主 API(插件调用 Oak 内部功能)
|
||
|
||
策展而非全量。插件看不到"内部函数",看到的是一组**版本化的 RPC 方法**,
|
||
每个方法是现有 `graphops`/`renderops`/`oak_task` API 的组合(下表"落到哪里"
|
||
均为现有代码位置)。协议主版本 `api` 保证:同一主版本内只增不删。
|
||
|
||
### 3.1 编辑事务(铁律 3 的落地)
|
||
|
||
所有变更类方法必须包在事务里:
|
||
|
||
```json
|
||
{"id":10,"method":"edit.begin","params":{"label":"AI: 粗剪访谈片段"}}
|
||
{"id":11,"method":"timeline.split_clip","params":{"clip":"n17","time":"3/25"}}
|
||
{"id":12,"method":"timeline.ripple_delete","params":{"clip":"n18"}}
|
||
{"id":13,"method":"edit.commit"}
|
||
```
|
||
|
||
`edit.begin/commit` 映射到 `oak_undo::undostack` 的 UndoCommand 分组:一次
|
||
事务 = 一次 Ctrl-Z。`edit.abort` 回滚整组。**未在事务内的变更调用直接报错**,
|
||
从协议上杜绝不可撤销的编辑。
|
||
|
||
### 3.2 方法面(v1)
|
||
|
||
| 方法族 | 方法(摘要) | 落到哪里 | 所需能力 |
|
||
|---|---|---|---|
|
||
| `project.*` | `open` / `save` / `get_info` / 事件 `project.modified` | `oak_storage::Session`、`oak_node::serializer` | `project.read` / `project.edit` |
|
||
| `media.*` | `probe` / `import_footage` / `list_footage` / `get_streams` | `oak_app::oakui::graphops::import_footage`、`oak_codec` 探测 | `media.read` / `media.import` |
|
||
| `timeline.*` | `get_structure`(序列/轨道/块树)、`place_clip`、`split_clip`、`trim`、`move`、`ripple_delete`、`add_transition`、`add_marker`、`set_workarea` | `graphops::place_footage_clip` / `split_clip` / …、`oak-timeline` 命令族 | `timeline.read` / `timeline.edit` |
|
||
| `node.*` | `list_types`(含 OFX 动态类型)、`add_effect`、`set_param`、`set_keyframe`、`get_params` | `Factory::global()`、`engine.rs::add_effect/set_effect_param`、`set_value_at_time_command` | `node.read` / `node.edit` |
|
||
| `render.*` | `get_frame(time)`→shm、`get_thumbnails(range,n)`、`get_audio_levels(range)` | `renderops::render_sequence_frame` / `render_audio_range`、缩略图缓存 | `render.frame` |
|
||
| `playback.*` | `play` / `pause` / `seek` / 事件 `playhead_moved` | `EngineGateway`(`request_frame/play/pause/seek`) | `playback` |
|
||
| `export.*` | `start(params)` / `cancel` / 事件 `export.progress` | `renderops::spawn_export`、`oak_task::export::EncodingParams` | `export` |
|
||
| `ui.*` | 见 §4 | `PluginPanel` + gpui_widgets | `ui.panel` |
|
||
| `edit.*` | `begin` / `commit` / `abort` / `undo` / `redo` | `oak_undo` | 随变更方法 |
|
||
|
||
**取帧→AI 通路**(旗舰用例的关键路径,对齐 `ai-agent-design.md` §2.2):
|
||
`render.get_frame {sequence, time, max_size}` → 引擎经 ticket/进程池渲染 →
|
||
BGRA 进 shm 槽 → 返回 `{shm_slot, width, height, format}`;插件侧 SDK 一行
|
||
`frame.to_png_bytes()`(OIIO/stb_image_write 或 Pillow)即可回喂多模态模型。
|
||
`get_thumbnails` 一次取 N 帧拼 contact sheet,供"扫时间线定位内容"。
|
||
**限流**:取帧调用带每插件速率与分辨率上限(默认 8 fps / 1920 宽),防止批量
|
||
取帧拖垮渲染进程池。
|
||
|
||
### 3.3 事件(Oak→插件 notification)
|
||
|
||
`project.opened/modified`、`timeline.structure_changed`(增量,非全量)、
|
||
`playhead_moved`(§4.4 降频)、`export.progress/done`、`ui.*` 输入事件(§4.2)、
|
||
`shutdown`(Oak 退出前发,插件应在 2s 内退出,否则 SIGTERM→SIGKILL)。
|
||
|
||
---
|
||
|
||
## 4. 插件 UI
|
||
|
||
gpui 没有 webview,也不可能让 Python 插件直接调 gpui。提供**两条路径**,
|
||
插件按需在握手时声明(可同时用):
|
||
|
||
### 4.1 声明式 UI(v1 基线,推荐大多数插件用)
|
||
|
||
插件用 JSON 描述控件树,Oak 用 gpui_widgets 渲染成 `PluginPanel` 内容:
|
||
|
||
```json
|
||
{"method":"ui.set_tree","params":{"panel":"chat","root":
|
||
{"type":"column","children":[
|
||
{"type":"chat_log","id":"log"},
|
||
{"type":"row","children":[
|
||
{"type":"text_input","id":"prompt","placeholder":"描述你的剪辑意图…"},
|
||
{"type":"button","id":"send","text":"执行"}]},
|
||
{"type":"progress","id":"job"}
|
||
]}}}
|
||
```
|
||
|
||
控件集 v1 保持小:`column/row/label/button/text_input/list/chat_log/image/
|
||
progress/slider/checkbox`。用户在面板里的交互以 `ui.event {id, kind, value}`
|
||
推给插件;插件用 `ui.set_props {id, props}` 增量更新(不做全量重绘 diff,
|
||
控件树很小,全量 `set_tree` 也行)。
|
||
|
||
收益:**零崩溃面**(插件不画一个像素)、风格与 Oak 一致、实现量最小。
|
||
AI 剪辑插件的聊天面板、操作日志、确认按钮,这套完全够。
|
||
|
||
### 4.2 像素面 UI(完整能力路径)
|
||
|
||
插件自己用任意工具包(Qt/imgui/web 引擎——在**自己的进程**里)离屏渲染,
|
||
把 BGRA 位图经 shm 推给 Oak,Oak 在 `PluginPanel` 里原样贴图:
|
||
|
||
```
|
||
插件 ──shm 写帧──► ui.frame_ready {panel, slot, dirty_rect} ──► Oak 贴图
|
||
插件 ◄── ui.event {kind:"pointer_down|pointer_move|key|scroll|focus",
|
||
x,y,button,modifiers,dpi_scale} ◄── gpui 事件转发
|
||
```
|
||
|
||
- 这是既有 **OFX Interact GL-overlay 路径**(`oak_plugin::gl_bridge` +
|
||
`oakui/ofx.rs::forward_interact_pointer/key` + `program_viewer` 合成)的
|
||
进程外泛化:把"插件 GL 离屏 + readback 合成 + 事件转发"换成
|
||
"插件进程离屏 + shm + 事件经 IPC 转发",事件模型照抄 interact 的。
|
||
- resize 时 Oak 发 `ui.resize {width,height,dpi}`,插件按新尺寸重渲染;
|
||
帧槽数 ≥2 做双缓冲,`frame_ready` 携带脏矩形减少合成开销。
|
||
- v1 明确不做:IME 合成串转发、剪贴板互通、跨进程拖拽(需要时另立文档)。
|
||
|
||
### 4.3 其他 UI 形态
|
||
|
||
- **独立窗口**:插件进程自己开 OS 窗口,Oak 不管——始终允许,无需协议支持,
|
||
集成度差,适合调试工具类插件。
|
||
- **监看器叠加层**:OFX Interact 那种画在节目监视器上的 overlay,属于
|
||
"效果交互"范畴,继续归 OFX;功能插件如需 viewer overlay(如 AI 打点预览),
|
||
列为 v2 候选,复用 `program_viewer` 的合成点。
|
||
|
||
### 4.4 事件降频
|
||
|
||
`playhead_moved`、`pointer_move` 这类高频事件:Oak 侧合并到 30 Hz 上限、
|
||
只发最新值(对齐 viewer 的刷新语义),避免 stdio 被事件洪水淹没。
|
||
|
||
---
|
||
|
||
## 5. 能力、确认与安全
|
||
|
||
- **能力位**:manifest `capabilities` 声明,安装/升级时向用户展示差异;
|
||
`HostApi` 在每次调用入口检查,越权调用返回 `CAPABILITY_DENIED` 并记日志。
|
||
v1 能力集合即 §3.2 表右列。
|
||
- **确认模式**(继承 `ai-agent-design.md` §6):`*.edit` 与 `export` 类调用
|
||
默认弹"插件 X 请求执行:split_clip n17 @ 3/25 [允许] [允许本会话] [拒绝]";
|
||
用户可在插件设置里改为自动。
|
||
- **可撤销**:事务分组进 UndoStack,历史面板里显示为"插件名:事务标签",
|
||
用户可整段撤销(§3.1)。
|
||
- **限流与配额**:取帧速率/分辨率上限(§3.2);单插件 shm 池有上限;
|
||
单请求参数大小上限(防内存炸弹)。
|
||
- **密钥**:插件需要 API key 走自己的环境变量/自己的配置文件,
|
||
**绝不写入 Oak 工程文件**(.ove 里只允许存插件 id + 版本,对齐 OFX
|
||
`<plugins>` 段的语义)。
|
||
- **不做沙箱**:本系统隔离的是"崩溃",不是"恶意"——插件进程与 Oak 同用户
|
||
权限。恶意插件防护(seccomp/签名/商店审核)明确出范围。
|
||
|
||
---
|
||
|
||
## 6. 插件 SDK 与参考插件
|
||
|
||
- **`oakxp-c`**(头文件-only C/C++ SDK,放 `shared/include/oakxp/`):
|
||
NDJSON 分帧、JSON-RPC 收发、shm 附加、回调注册。无第三方依赖
|
||
(JSON 用内置极简 parser,或允许作者自选)。这是 C ABI 纪律下唯一
|
||
允许插件 #include 的东西——**纯协议,不含任何 Oak 内部类型**。
|
||
- **`oakxp`(Python 包)**:`pip install oakxp` 或随 Oak 分发;
|
||
`asyncio` 友好但非强制;`frame.to_png()` 依赖 Pillow(可选 extra)。
|
||
- **参考插件**(验收的一部分):
|
||
1. `examples/plugin-echo`:C++,注册一个声明式面板,按钮触发
|
||
`project.get_info` 并显示——验证协议与 UI 基线。
|
||
2. `examples/plugin-roughcut`:Python,接多模态 LLM,实现
|
||
"聊天指令 → get_thumbnails 扫时间线 → 事务化 split/ripple_delete →
|
||
get_frame 验证"的 AI 粗剪闭环——**它就是 ai-agent-design.md 的落地形态**。
|
||
|
||
### 与 `ai-agent-design.md` 的关系
|
||
|
||
该文档写于 RIIR 拆分前,假设"C ABI 小库 + 引擎内置 MCP server"。RIIR 与
|
||
M15(渲染进程隔离)完成后,更优路径是:**AI 能力不进引擎,作为一个外部
|
||
插件**跑在本系统上;MCP 仍可作为该插件对外的协议(插件自己起 MCP server
|
||
连 LLM 客户端),Oak 内核始终对 AI 无感知。本文落地后,`ai-agent-design.md`
|
||
的 M1/M2(工具面、取帧通路)由 §3.2 取代,M3(AI 面板)由 §4.1 取代。
|
||
|
||
---
|
||
|
||
## 7. 里程碑
|
||
|
||
| 里程碑 | 内容 | 验收 |
|
||
|---|---|---|
|
||
| **P1 传输与生命周期** | `oak-plugin-host`:spawn/握手/心跳/崩溃检测/有界重启;JSON-RPC 双向收发;`oakxp-c` 最小 SDK;echo 插件跑通 `project.get_info` | 杀掉插件进程:Oak 不崩、面板显示崩溃徽标、可重启;握手超时路径有测试 |
|
||
| **P2 宿主 API 核心** | `edit.*` 事务 + `project/media/timeline/node` 方法族 + 能力检查 | 插件完成"导入素材→铺轨→切开→波纹删除→加效果→改参数",逐步可在历史面板撤销;越权调用被拒 |
|
||
| **P3 取帧与导出** | `render.*` shm 数据面、`export.*` 事件、限流 | 黄金帧校验(复用 render-worker 端到端 harness):插件取到的帧与 viewer 一致;连续取帧不拖垮进程池 |
|
||
| **P4 声明式 UI** | `PluginPanel` + 动态 panel 注册 + `ui.*` 控件集 | echo 插件面板交互全通;控件树快照测试 |
|
||
| **P5 像素面 UI** | shm 贴图 + 输入转发 + resize/DPI | 参考 imgui 插件 60fps 交互无撕裂;事件转发对齐 interact 语义 |
|
||
| **P6 Python SDK 与 AI 粗剪** | `oakxp` 包 + `plugin-roughcut` | Mock LLM 录制/回放(无网络 CI)跑通"看图→下刀→验证"闭环 |
|
||
|
||
P1–P3 是系统地基,任何插件都依赖;P4/P5 可并行;P6 随时可开始(SDK 与
|
||
宿主 API 稳定后)。
|
||
|
||
---
|
||
|
||
## 8. 明确不做(边界)
|
||
|
||
- **不**取代 OFX:图像处理节点仍走 `oak-plugin`(渲染在 worker 进程内已有
|
||
隔离)。功能插件如需注册新节点类型,v2 再评估(机制上是现成的
|
||
`Factory::register_dynamic`)。
|
||
- **不**做插件沙箱、签名、商店(§5)。
|
||
- **不**做跨机器/网络插件(stdio only;socket 传输变体留作以后,协议本身
|
||
不绑定 stdio)。
|
||
- **不**为插件发明新的引擎内部机制:宿主 API 全部是现有
|
||
`graphops/renderops/oak_task` 的组合(铁律 3 同源于 ai-agent-design)。
|
||
- **不**引入 tokio 到 app 路径;IO 线程 + mpsc + gpui executor 足够。
|