Files
oak-editor/docs/zh/plans/external-plugin-protocol.md
T
Mike-Solar 3cc6f75ccc docs: archive completed plans, add the external plugin system design
- 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.
2026-08-25 03:03:28 +08:00

549 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Oak 外部插件协议规范(OPP/1)
> 本文是 [`external-plugin-system.md`](external-plugin-system.md) 的**协议全文**
> 冻结到可实现、可写 SDK 的粒度:传输分帧、消息信封、握手、全部 RPC 方法与事件、
> 错误码、shm 数据面布局、UI 协议。实现(`oak-plugin-host`、`oakxp-c`、`oakxp`
> Python 包)以本文为准;与设计文档冲突时**以本文为准**。
>
> **版本**:协议主版本 `1``"api": 1`)。同一主版本内只增不删(§13)。
>
> **面向**`oak-plugin-host` 实现者、插件 SDK 作者、插件作者。
---
## 1. 传输层与分帧
- 通道:插件进程的 `stdin`Oak→plugin)与 `stdout`plugin→Oak),全双工。
- 分帧:**NDJSON**——每条消息是**一行** UTF-8 JSON,以 `\n` 结尾;消息内
不得出现裸换行(JSON 序列化默认满足)。禁用 BOM。
- 消息大小上限 **16 MiB**;超限 Oak 直接判定协议错误并杀死插件。
- **`stdout` 纪律**:只允许协议消息。插件日志走 `stderr`Oak 捕获进日志面板)
`session.log`(§8.1)。SDK 必须在初始化时把第三方库的 stdout 输出重定向
到 stderr。
- 关闭语义:Oak 关闭插件 stdin 写端 = 要求插件退出(等价于收到
`session.shutdown` 后的超时强杀,见 §4.4)。
## 2. 消息信封(JSON-RPC 2.0
严格遵循 JSON-RPC 2.0**双向**:两方都可以发 Request 与 Notification。
```json
// Request(期望响应)
{"jsonrpc":"2.0","id":42,"method":"timeline.split_clip","params":{...}}
// Response 成功
{"jsonrpc":"2.0","id":42,"result":{...}}
// Response 失败
{"jsonrpc":"2.0","id":42,"error":{"code":-32001,"message":"not in edit transaction"}}
// Notification(无 id,无响应)
{"jsonrpc":"2.0","method":"playback.playhead_moved","params":{...}}
```
- `id`:字符串或整数,由**发送方**自定命名空间(同一连接上两方的 id 可能
撞车,接收方配对时只看自己发出的 id——标准行为)。
- 允许任意数量的 in-flight 请求;**同一事务(§6)内的变更请求,Oak 严格按
到达顺序串行执行**。其余请求不保证相对顺序。
- `params` 一律为对象(不用位置参数)。
- 需要用户确认的请求(§7.3),Oak 在用户裁决前**不返回响应**;插件不得假设
超时,SDK 默认请求超时设为 120 s。
## 3. 错误码
标准码(-32700/-32600/-32601/-32602/-32603)按 JSON-RPC 规范。应用码占用
JSON-RPC 保留的 server-error 段:
| code | 常量 | 含义 |
|---|---|---|
| -32000 | `CAPABILITY_DENIED` | 插件无此方法所需能力位 |
| -32001 | `NOT_IN_TRANSACTION` | 变更方法缺少有效 `txn` |
| -32002 | `TRANSACTION_CONFLICT` | 事务被其他持有者占用 |
| -32003 | `ENTITY_NOT_FOUND` | id 失效(删除/工程重载后),`data.entity` 带原 id |
| -32004 | `RATE_LIMITED` | 触发限流,`data.retry_after_ms` 给重试间隔 |
| -32005 | `SHM_EXHAUSTED` | shm 池无空闲槽,先 `shm.release` |
| -32006 | `CONFIRMATION_DENIED` | 用户在确认弹窗中拒绝 |
| -32007 | `FRAME_TOO_LARGE` | 请求帧超过槽容量,调小 `max_size` |
| -32008 | `INVALID_STATE` | 当前状态不允许(如无打开的工程) |
`error.data` 可选,结构化附加信息(见上表)。Oak 侧合成错误(插件进程已死、
握手失败)不进协议,直接体现在 Oak 的插件管理器 UI。
## 4. 生命周期
### 4.1 握手(细化设计文档 §2.2:由插件发起)
Oak spawn 插件后,插件必须在 **10 s** 内发出第一个消息——`session.hello`
```json
// plugin → Oak
{"jsonrpc":"2.0","id":1,"method":"session.hello","params":{
"api":1,
"name":"ai-cut",
"version":"0.1.0",
"capabilities":["project.read","timeline.read","timeline.edit","render.frame","ui.panel"],
"panels":[{"id":"chat","title":"AI 剪辑","ui":"declarative"}],
"subscribe":["project.opened","timeline.structure_changed"]
}}
// Oak → plugin
{"jsonrpc":"2.0","id":1,"result":{
"api":1,
"oak_version":"0.4.0",
"granted":["project.read","timeline.read","timeline.edit","render.frame","ui.panel"],
"features":["ui.pixel"],
"shm":{"down":{"name":"oakxp-d-1234","slots":8,"slot_bytes":16777216}}
}}
```
- `capabilities` 必须是 manifest 声明集的子集;`granted` 是 Oak 实际授予的
子集(用户可能在安装时裁剪)。插件按 `granted` 工作。
- `features`:Oak 支持的可选特性清单,用于同主版本内的能力探测(§13)。
- `shm.down`:Oak→plugin 方向的帧槽池(§10),握手时已创建,插件自行 attach。
- 超时或首消息不是 `session.hello`Oak 杀进程,标记插件启动失败。
### 4.2 心跳
握手成功后,Oak 每 **2 s** 发一次:
```json
{"jsonrpc":"2.0","id":"ping-317","method":"session.ping"}
```
插件应在 **5 s** 内响应(`"result":{}`)。**连续 3 次**超时或 stdout EOF/进程
退出 = 崩溃:有界重启(manifest `[restart]`,默认 `max=5`、退避 1 s 起倍增)。
重启后重新走 §4.1;所有 id 与事务令牌作废,插件须重新拉取状态。
### 4.3 事件订阅
`session.hello.subscribe` 是初始订阅;运行时用:
```json
{"method":"events.subscribe","params":{"events":["playback.playhead_moved"],"unsubscribe":["export.progress"]}}
```
事件目录见 §9。订阅需要对应的 read 类能力位(§7.2 各事件标注)。
### 4.4 关闭
Oak 退出或用户禁用插件:
```json
// Oak → pluginnotification
{"jsonrpc":"2.0","method":"session.shutdown","params":{"reason":"app_quit"}}
```
插件应在 **2 s** 内自行退出(保存自己的状态);超时 SIGTERM,再 2 s SIGKILL
Windows`TerminateProcess`)。插件主动崩溃/退出按 §4.2 崩溃路径处理。
## 5. 公共数据类型
| 类型 | JSON 表示 | 说明 |
|---|---|---|
| `Rational`(时间) | `{"num":3,"den":25}` | 秒为单位的有理数,`den>0`。全协议**唯一**时间表示 |
| `TimeRange` | `{"in":Rational,"out":Rational}` | 左闭右开 |
| `EntityId` | 不透明字符串 | 素材/序列/轨道/块/节点/事务/作业统一为字符串 id。**插件不得解析格式**;会话内稳定,工程重载后全部作废(靠 `project.opened` + 重新拉取恢复) |
| `Color` | `"#RRGGBB"``"#RRGGBBAA"` | |
| `FrameRef` | 见下 | 一帧位图的引用,两种形态 |
`FrameRef`
```json
// shm 形态(默认)
{"shm":{"region":"down","slot":3},"format":"bgra8","width":1920,"height":1080,
"stride":7680,"bytes":8294400,"time":{"num":3,"den":25}}
// inline 形态(bytes ≤ 64 KiB 时 Oak 可选用)
{"inline":"base64...","format":"png","width":320,"height":180,"time":{...}}
```
- `format``"bgra8"`(shm 原始位图,行优先、顶左原点)或 `"png"`(已编码,
inline 专用)。
- shm 形态的槽位**借用**自 `down` 池,插件用完必须 `shm.release`(§10.3),
否则触发 `SHM_EXHAUSTED`;借用带 30 s 租约,超时 Oak 强制回收。
## 6. 编辑事务协议
一切变更方法(§8 各方法标注"事务:是")必须携带 `txn` 参数;事务令牌由
`edit.begin` 签发:
```json
{"id":10,"method":"edit.begin","params":{"label":"AI: 粗剪访谈片段"}}
{"id":10,"result":{"txn":"t12"}}
{"id":11,"method":"timeline.split_clip","params":{"txn":"t12","clip":"...","time":{"num":3,"den":25}}}
{"id":12,"method":"edit.commit","params":{"txn":"t12"}}
```
规则(钉死):
1. **全局单持**:同一时刻全 Oak 只有一个未决事务。`edit.begin` 冲突返回
`TRANSACTION_CONFLICT``data.retry_after_ms` 提示重试。插件不得长持事务
(建议 < 5 s);Oak 对 60 s 未提交的事务强制 `abort`
2. `commit` = 一组 UndoCommand 压栈,历史面板显示"插件名:label",一次
Ctrl-Z 整体撤销。`abort` = 已执行的变更逆序回滚,不留痕迹。
3. 事务内变更按到达顺序串行执行(§2);任一变更失败,**前面已成功的保持
有效**,由插件决定 `commit` 还是 `abort`——Oak 不自动回滚。
4. 崩溃时未决事务自动 `abort`:插件崩了也不会留下半截编辑。
5. `edit.undo`/`edit.redo` 不需要 `txn`,撤销的是整个 UndoStack(包括用户
自己的操作)——插件应只在用户明确要求时调用。
## 7. 能力位
### 7.1 能力清单(v1 冻结)
| 能力 | 覆盖的方法/事件 |
|---|---|
| `project.read` | `project.get_info``project.opened/modified/closed` 事件 |
| `project.edit` | `project.open/save`(且需事务) |
| `media.read` | `media.probe/list_footage` |
| `media.import` | `media.import_footage`(且需事务) |
| `timeline.read` | `timeline.get_structure``timeline.structure_changed` 事件 |
| `timeline.edit` | `timeline.*` 全部变更方法(且需事务) |
| `node.read` | `node.list_types/get_params` |
| `node.edit` | `node.add_effect/set_param/set_keyframe/remove`(且需事务) |
| `render.frame` | `render.get_frame/get_thumbnails/get_audio_levels` |
| `playback` | `playback.*``playback.*` 事件 |
| `export` | `export.start/cancel``export.*` 事件 |
| `ui.panel` | `ui.*`(声明式);`ui.event` 事件 |
| `ui.pixel` | `ui.attach_surface/frame_ready`(像素面,§11.2 |
### 7.2 检查时机
`HostApi` 在每个方法入口查 `granted`;越权返回 `CAPABILITY_DENIED` 并记
Oak 日志。事件订阅同理(订阅未授权事件返回 `CAPABILITY_DENIED`
`data.event` 指明哪个)。
### 7.3 用户确认
`*.edit``media.import``export``edit.undo/redo` 属于**确认类**Oak 弹窗
"插件 X 请求:split_clip n17:2 @ 3/25 [允许] [本会话内允许] [拒绝]"。拒绝返回
`CONFIRMATION_DENIED`;"本会话内允许"缓存到 Oak 会话结束。用户在插件设置里
可把某插件整设为"自动允许"。确认类方法清单与能力位一一对应,见 §8 各方法
"确认"列。
## 8. 宿主 API 方法(v1 全量)
通用列:**事务**=是否需要 `txn`;**确认**=是否触发 §7.3 弹窗。所有方法均可
返回 §3 通用错误,不再逐条列出。
### 8.1 会话
| 方法 | params | result | 说明 |
|---|---|---|---|
| `session.hello` | §4.1 | §4.1 | 首消息,仅此一次 |
| `session.ping` | | `{}` | Oak→plugin 方向 |
| `session.shutdown` | `{reason}` | notification | Oak→plugin |
| `session.log` | `{level:"debug"\|"info"\|"warn"\|"error", message}` | notification | plugin→Oak,进 Oak 日志面板。高频日志请走 stderr |
| `events.subscribe` | `{events:[], unsubscribe:[]}` | `{subscribed:[]}` | §4.3 |
### 8.2 `project.*`
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `project.get_info` | 否 | 否 | `{}` | `{path\|null, name, modified, sequences:[{id,name,fps:Rational,duration:Rational}]}` |
| `project.open` | 是 | 是 | `{txn, path}` | `{name}` |
| `project.save` | 是 | 是 | `{txn, path?}` | `{path}` |
无打开工程时读取方法返回 `INVALID_STATE`
### 8.3 `media.*`
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `media.probe` | 否 | 否 | `{path}` | `{duration:Rational, streams:[{type:"video"\|"audio"\|"subtitle", codec, width?, height?, fps?:Rational, sample_rate?, channels?}]}` |
| `media.list_footage` | 否 | 否 | `{}` | `{footage:[{id,name,path,duration:Rational}]}` |
| `media.import_footage` | 是 | 是 | `{txn, paths:[...]}` | `{footage:[{id,name,duration:Rational}]}`(跳过失败项,`errors:[{path,message}]` 单列) |
### 8.4 `timeline.*`
```json
// timeline.get_structure {sequence} →
{"result":{"sequence":{"id":"…","name":"访谈成片","fps":{"num":25,"den":1},
"duration":{"num":183,"den":25},
"tracks":[
{"id":"…","type":"video","index":0,"clips":[
{"id":"…","name":"A001.mp4","footage":"…",
"in":{"num":0,"den":1},"out":{"num":72,"den":25},
"media_in":{"num":10,"den":1},"enabled":true}
]},
{"id":"…","type":"audio","index":0,"clips":[]}
]}}}
```
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `timeline.get_structure` | 否 | 否 | `{sequence}` | 见上 |
| `timeline.add_track` | 是 | 是 | `{txn, sequence, type:"video"\|"audio", index?}` | `{track}` |
| `timeline.place_clip` | 是 | 是 | `{txn, sequence, track, footage, in:Rational, media_in?}` | `{clip}` |
| `timeline.split_clip` | 是 | 是 | `{txn, clip, time:Rational}` | `{clips:[id,id]}` |
| `timeline.trim_clip` | 是 | 是 | `{txn, clip, side:"in"\|"out", time:Rational}` | `{clip}` |
| `timeline.move_clip` | 是 | 是 | `{txn, clip, in:Rational, track?}` | `{clip}` |
| `timeline.delete_clip` | 是 | 是 | `{txn, clip}` | `{}` |
| `timeline.ripple_delete` | 是 | 是 | `{txn, clip}` | `{}` |
| `timeline.add_transition` | 是 | 是 | `{txn, clip, side:"in"\|"out", type, duration:Rational}` | `{transition}` |
| `timeline.add_marker` | 是 | 是 | `{txn, sequence, time:Rational, name?, color?:Color}` | `{marker}` |
| `timeline.set_workarea` | 是 | 是 | `{txn, sequence, range:TimeRange}` | `{}` |
`time`/`in`/`out` 一律为序列时间轴上的有理秒。越界/重叠冲突返回
`INVALID_STATE``data.reason` 说明。
### 8.5 `node.*`(效果与参数)
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `node.list_types` | 否 | 否 | `{category?:"effect"\|"transition"\|"all"}` | `{types:[{id,name,category}]}`(含 OFX 动态类型,id 即 OFX identifier |
| `node.add_effect` | 是 | 是 | `{txn, clip, effect, index?}` | `{node}` |
| `node.get_params` | 否 | 否 | `{node}` | `{params:[{key,name,type:"float"\|"int"\|"bool"\|"string"\|"color"\|"vec2"\|"choice", value, default, min?, max?, choices?:[]}]}` |
| `node.set_param` | 是 | 是 | `{txn, node, key, value}` | `{}` |
| `node.set_keyframe` | 是 | 是 | `{txn, node, key, time:Rational, value}` | `{}` |
| `node.remove` | 是 | 是 | `{txn, node}` | `{}` |
`value` 的 JSON 类型随 `type``float/int`→number`bool`→boolean
`string/choice`→string`color`→Color`vec2``[x,y]`
### 8.6 `render.*`AI 视觉闭环的取帧口)
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `render.get_frame` | 否 | 否 | `{sequence?, footage?, time:Rational, max_size?:{width,height}, format?:"bgra8"\|"png"}` | `{frame:FrameRef}` |
| `render.get_thumbnails` | 否 | 否 | `{sequence?, footage?, range:TimeRange, count, height?:180}` | `{frames:[FrameRef,…]}`(等间隔采样,`count` ≤ 64 |
| `render.get_audio_levels` | 否 | 否 | `{sequence, range:TimeRange, resolution?:100}` | `{channels, peaks:inline base64 float32le 数组(channels×resolution}` |
- `sequence``footage` 二选一,都缺省返回 `INVALID_PARAMS(-32602)`
- `max_size` 超槽容量(§4.1 `slot_bytes`)返回 `FRAME_TOO_LARGE`
- 限流(§12):默认每插件 8 帧/s、短边 ≤ 1080,超限 `RATE_LIMITED`
- 渲染走引擎 ticket/进程池路径,**不阻塞 GUI 线程**;典型延迟 50500 ms
插件侧应并发流水线化而不是串行等帧。
### 8.7 `playback.*`
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `playback.play` | 否 | 否 | `{sequence?}` | `{}` |
| `playback.pause` | 否 | 否 | `{}` | `{}` |
| `playback.seek` | 否 | 否 | `{time:Rational}` | `{}` |
| `playback.get_state` | 否 | 否 | `{}` | `{playing, time:Rational, sequence\|null}` |
### 8.8 `export.*`
| 方法 | 事务 | 确认 | params | result |
|---|---|---|---|---|
| `export.start` | 否 | 是 | `{sequence, output_path, preset?:string}` | `{job}` |
| `export.cancel` | 否 | 否 | `{job}` | `{}` |
`preset` 引用 Oak 导出预设名;自定义编码参数(分辨率/码率/封装)v1 不开放,
需要时按 §13 加 `encoding` 对象。进度经 `export.progress` 事件推送(§9)。
### 8.9 `shm.*`
| 方法 | params | result |
|---|---|---|
| `shm.release` | `{slots:[{region:"down", slot:3}, …]}` | `{}`notification 亦可) |
详见 §10。
### 8.10 `ui.*`
见 §11(声明式与像素面两条路径共用 `ui.*` 命名空间)。
## 9. 事件(Oak→plugin notification
| 事件 | 所需能力 | params | 频率 |
|---|---|---|---|
| `project.opened` | `project.read` | `{path, name}` | |
| `project.modified` | `project.read` | `{modified}` | 状态翻转时 |
| `project.closed` | `project.read` | `{}` | |
| `timeline.structure_changed` | `timeline.read` | `{sequence, hint:"full"\|{"clips_added":[],"clips_removed":[],"clips_moved":[]}}` | 变更合并后发,≤ 10 Hz |
| `playback.playhead_moved` | `playback` | `{sequence, time:Rational}` | ≤ 30 Hz,只发最新值 |
| `playback.state_changed` | `playback` | `{playing}` | |
| `export.progress` | `export` | `{job, fraction:0..1, eta_ms?\|null}` | ≤ 4 Hz |
| `export.done` | `export` | `{job, ok, output_path?, error?}` | |
| `ui.event` | `ui.panel` | §11 | 输入事件实时;pointer_move ≤ 60 Hz 合并 |
`hint` 是优化提示:插件可永远按 `"full"` 处理(重新 `get_structure`),
`hint` 对象仅当下发增量安全时出现。
## 10. shm 数据面
### 10.1 区域与方向
- `down`Oak→plugin(渲染帧)。Oak 在握手前创建,握手响应携带
`{name, slots, slot_bytes}`;插件 `shm_open`+`mmap` 只读 attach。
- `up`plugin→Oak(像素面 UI 位图)。Oak 在 `ui.attach_surface` 时按需创建
(每个像素面板一个区域),result 携带同名结构;插件可写 attach。
- POSIX`shm_open`/`mmap`Windows`CreateFileMappingW`/`MapViewOfFile`
名称不带前导 `/` 的语义差异由 SDK 抹平。
### 10.2 无头部、无锁(钉死)
**shm 内不放任何元数据、不放锁**。槽位布局:槽 `i` 的字节区间
`[i*slot_bytes, (i+1)*slot_bytes)`,位图从偏移 0 开始,格式/宽/高/步长全部
由控制面消息携带(`FrameRef` / `ui.frame_ready`)。槽位有效性由 RPC 配对界定:
- `down`:从携带该槽的 Response/事件到达,到插件 `shm.release`(或 30 s 租约
到期)为止,Oak 保证不写该槽。
- `up`:从 `ui.frame_ready` 发出,到 Oak 回 `ui.surface_ack` 为止,插件保证
不写该槽。
因为控制面与数据面一一配对,不需要 seqlock/环形缓冲那套(render-worker 的
SPSC ring 是高频流式场景,本协议是请求-响应场景,刻意简化)。
### 10.3 流控
- `down``slots` 个槽(默认 8)。插件未释放的借用数达到 `slots` 后,
`render.*` 一律 `SHM_EXHAUSTED`。批量取帧的插件必须流水线化 release。
- `up` 区域固定 3 槽(三缓冲)。`ui.frame_ready` 未收到 `surface_ack` 的槽
不得复用;3 槽全在飞行中时插件应丢弃新帧(UI 丢帧安全)。
- 租约:`down` 借用 30 s 未 release,Oak 强制回收并记日志(视为插件 bug)。
## 11. UI 协议
### 11.1 声明式 UI
面板在 `session.hello.panels` 声明 `"ui":"declarative"`。Oak 为其创建
`PluginPanel`(可关闭/可停靠的 DockPanel),初始为空。
**控件树下发**(全量替换):
```json
{"id":31,"method":"ui.set_tree","params":{"panel":"chat","root":
{"type":"column","gap":8,"children":[
{"type":"chat_log","id":"log","grow":true},
{"type":"row","gap":4,"children":[
{"type":"text_input","id":"prompt","placeholder":"描述你的剪辑意图…","grow":true},
{"type":"button","id":"send","text":"执行"}]},
{"type":"progress","id":"job","visible":false}
]}}}
```
**增量更新**`ui.set_props {panel, id, props:{…}}`,只改给出的属性;
不存在的 `id` 返回 `ENTITY_NOT_FOUND`。结构性增删用全量 `set_tree`
(树规模小,不做 diff 协议)。
**控件目录(v1 冻结)**
| type | 关键 props | 事件(`kind` |
|---|---|---|
| `column` / `row` | `gap, grow, children[]` | |
| `label` | `text, color?` | |
| `button` | `text, enabled?` | `click` |
| `text_input` | `text, placeholder?, enabled?` | `change{text}`, `submit{text}` |
| `text_area` | `text, readonly?` | `change{text}` |
| `list` | `items:[{id,text}], selected?` | `select{id}` |
| `chat_log` | `entries:[{role:"user"\|"assistant"\|"system", text}]`set_props 追加) | |
| `image` | `source:{inline_base64}``{shm:{region,slot},width,height,stride,format}` | |
| `progress` | `fraction:0..1, indeterminate?, text?` | |
| `slider` | `value, min, max, step?` | `change{value}` |
| `checkbox` | `checked, text` | `change{checked}` |
| `separator` / `spacer` | | |
**事件上行**
```json
{"jsonrpc":"2.0","method":"ui.event","params":
{"panel":"chat","id":"send","kind":"click"}}
```
面板被用户关闭:`ui.event {panel, kind:"panel_closed"}`Oak 重新打开时插件
会收到 `ui.event {kind:"panel_shown"}`,插件应重发 `ui.set_tree`
**通知**`ui.notify {level:"info"\|"warn"\|"error", text}` → Oak 状态栏 toast。
### 11.2 像素面 UI
面板声明 `"ui":"pixel"`(需 `ui.pixel` 能力,握手 `features` 里有才可用)。
```json
// 1) 建表面:Oak 创建 up 区域
{"id":40,"method":"ui.attach_surface","params":{"panel":"paint","width":960,"height":540,"dpi":2.0}}
{"id":40,"result":{"shm":{"region":"up","name":"oakxp-u-1234-paint","slots":3,
"slot_bytes":8294400},"format":"bgra8"}}
// 2) 插件画好一帧 → 通知(notification
{"jsonrpc":"2.0","method":"ui.frame_ready","params":
{"panel":"paint","slot":1,"width":960,"height":540,"stride":7680,
"dirty":[0,0,960,540]}}
// 3) Oak 合成完毕 → acknotification),槽位可复用
{"jsonrpc":"2.0","method":"ui.surface_ack","params":{"panel":"paint","slot":1}}
```
**输入事件下行**`ui.event``id` 固定为 `"surface"`):
| kind | params 增量 |
|---|---|
| `resize` | `{width, height, dpi}`(插件用新尺寸重画并 `frame_ready` |
| `pointer_move` / `pointer_down` / `pointer_up` | `{x, y, button?, modifiers:["shift","ctrl",…]}`(逻辑坐标,已除 dpi |
| `scroll` | `{x, y, dx, dy, modifiers}` |
| `key_down` / `key_up` | `{key, text?, modifiers}``key` 为 USB HID usage name 字符串,如 `"A"`/`"Enter"` |
| `focus` / `blur` | `{}` |
pointer_move 合并到 ≤ 60 Hz。IME 合成串、剪贴板、拖拽:v1 不做(设计文档
§4.2 已声明边界)。
## 12. 限流与配额(v1 默认值)
| 资源 | 默认 | 超限行为 |
|---|---|---|
| `render.get_frame` | 8 帧/s(令牌桶,burst 4 | `RATE_LIMITED` + `retry_after_ms` |
| 渲染帧短边 | ≤ 1080 px | `RATE_LIMITED`(插件调小 `max_size` |
| `get_thumbnails` | `count` ≤ 64/次 | `INVALID_PARAMS` |
| 单条消息 | ≤ 16 MiB | 协议错误,杀进程 |
| `down` 借用 | ≤ `slots`8 | `SHM_EXHAUSTED` |
| 未决事务时长 | ≤ 60 s | 强制 `abort` |
配额随握手响应的 `limits` 字段下发(v1 可缺省 = 上表默认);插件以 `limits`
为准,不要硬编码。
## 13. 版本演进规则
1. `api` 主版本只在**破坏性变更**时 +1;Oak 同时支持的旧主版本数 ≥ 1。
2. 同主版本内:只准**新增**方法/事件/可选参数/能力位;不得改语义、不得删、
不得把可选参数变必填。
3. 可选能力经握手 `features` 字符串集探测(如 `"ui.pixel"``"thumbs.contact_sheet"`),
插件用前必查。
4. 插件声明的 `api` 高于 Oak 支持:握手返回 `INVALID_PARAMS`
`data.supported_api` 给出 Oak 侧主版本,插件应降级或退出。
## 14. 附录:AI 粗剪会话示例(完整报文流水)
```jsonc
// ── 握手
{"jsonrpc":"2.0","id":1,"method":"session.hello","params":{
"api":1,"name":"roughcut","version":"0.2.0",
"capabilities":["project.read","timeline.read","timeline.edit","render.frame","ui.panel"],
"panels":[{"id":"chat","title":"AI 粗剪","ui":"declarative"}],
"subscribe":["timeline.structure_changed"]}}
{"jsonrpc":"2.0","id":1,"result":{
"api":1,"oak_version":"0.4.0","granted":["project.read","timeline.read","timeline.edit","render.frame","ui.panel"],
"features":["ui.pixel"],
"shm":{"down":{"name":"oakxp-d-7812","slots":8,"slot_bytes":16777216}}}}
// ── 扫时间线:取 12 张缩略图拼 contact sheet 回喂 LLM
{"jsonrpc":"2.0","id":2,"method":"render.get_thumbnails","params":{
"sequence":"sq1","range":{"in":{"num":0,"den":1},"out":{"num":600,"den":1}},
"count":12,"height":180}}
{"jsonrpc":"2.0","id":2,"result":{"frames":[
{"shm":{"region":"down","slot":0},"format":"bgra8","width":320,"height":180,"stride":1280,"bytes":230400,"time":{"num":0,"den":1}},
]}}
{"jsonrpc":"2.0","id":3,"method":"shm.release","params":{"slots":[
{"region":"down","slot":0}, ]}}
// ── LLM 判定 83.2s141.6s 为废片 → 事务化下刀(用户确认后执行)
{"jsonrpc":"2.0","id":4,"method":"edit.begin","params":{"label":"AI 粗剪:删除 83.2141.6s 废片"}}
{"jsonrpc":"2.0","id":4,"result":{"txn":"t7"}}
{"jsonrpc":"2.0","id":5,"method":"timeline.split_clip","params":{"txn":"t7","clip":"blk9","time":{"num":416,"den":5}}}
{"jsonrpc":"2.0","id":5,"result":{"clips":["blk9","blk9b"]}}
{"jsonrpc":"2.0","id":6,"method":"timeline.split_clip","params":{"txn":"t7","clip":"blk9b","time":{"num":708,"den":5}}}
{"jsonrpc":"2.0","id":6,"result":{"clips":["blk9b","blk9c"]}}
{"jsonrpc":"2.0","id":7,"method":"timeline.ripple_delete","params":{"txn":"t7","clip":"blk9b"}}
{"jsonrpc":"2.0","id":7,"result":{}}
{"jsonrpc":"2.0","id":8,"method":"edit.commit","params":{"txn":"t7"}}
{"jsonrpc":"2.0","id":8,"result":{}}
// ── 视觉验证:取切口后一帧确认画面正确
{"jsonrpc":"2.0","id":9,"method":"render.get_frame","params":{
"sequence":"sq1","time":{"num":416,"den":5},"max_size":{"width":960,"height":540}}}
{"jsonrpc":"2.0","id":9,"result":{"frame":{"shm":{"region":"down","slot":0},
"format":"bgra8","width":960,"height":540,"stride":3840,"bytes":2073600,
"time":{"num":416,"den":5}}}}
{"jsonrpc":"2.0","id":10,"method":"shm.release","params":{"slots":[{"region":"down","slot":0}]}}
// ── 结构变化推送(Oak 合并后下发)
{"jsonrpc":"2.0","method":"timeline.structure_changed","params":{
"sequence":"sq1","hint":{"clips_added":["blk9b","blk9c"],"clips_removed":[],"clips_moved":[]}}}
```