docs: coverage plan and review reports; drop the stray artifact

Adds the 90/80 coverage plan and the two-round review report, updates the
M5 backfill and plan index, moves finished plans to completed/, and
removes the machine-specific tarpaulin HTML report from the tree.
This commit is contained in:
2026-09-22 20:54:03 +08:00
parent 18abdec423
commit df3957b0dc
12 changed files with 2156 additions and 811 deletions
@@ -0,0 +1,390 @@
# 全链路 ACEScg + F32 色彩管线改造计划
> **⚠️ 已被实际架构取代(2026-08-28)**:本文档是 gpui 中心视角的旧方案
> (要求三套渲染器各自实现离屏 F32 场景 + 输出节点 pass)。实际实现采用
> **引擎侧色彩架构**:oak 引擎完成全部色彩工作(解码→ACEScg F32 工作空间
> →输出节点转项目输出色域),gpui 渲染器只做直通 blit + 窗口内容色域声明
> (Wayland color-management-v1 / macOS layer.colorspace / Windows
> SetColorSpace1),UI 保持 sRGB 不进 ACEScg。实际色彩数学在
> `crates/oak-common/src/colormath.rs`,显示策略在
> `../../../../crates/oak-app/src/oakui/displaycolor.rs`,内容色域声明 API 在 gpui 的
> `WindowContentColorspace`/`set_content_colorspace`。本文档仅作历史参考,
> 以代码为准。
> 面向实现者的任务书。配套审计:2026-08-27 色彩管理链路审计(会话记录,
> 结论摘要见本文"现状"一节)。本计划只描述改造方案与工作项,**不包含任何
> 已执行的代码修改**。
>
> 仓库边界说明:本仓库(oak-gpui)包含 gpui 核心、三个平台渲染器
> (`gpui_macos` / `gpui_windows` / `gpui_wgpu` / `gpui_linux`)与
> `oak_bridge` 视频桥。媒体解码、节点图求值在 oak 主仓库(引擎),
> 本计划为其定义**色彩契约**,并标注哪些工作项需要主仓库配合。
>
> 原则:
> 1. 输入节点把素材转换为 ACEScg + F32;
> 2. 输出节点转换回目标色域,目标色域由项目设置指定,未指定则为 sRGB;
> 3. 中间全过程使用 ACEScg + F32;
> 4. 三个平台(macOS / Windows / Linux-Wayland)都正确处理颜色:
> 应用只输出**色度学上明确的颜色空间**(如 sRGB),把"到显示器"的最后
> 一次映射交给操作系统,绝不自行施加显示器 ICC 变换,杜绝二次映射。
---
## 1. 目标与动机
Oak 是视频编辑器。现状管线是"全链路 gamma 编码 sRGB + UNORM 交换链",
这对 UI 够用,但对视频编辑有三个根本缺陷:
1. **精度不足**:8-bit gamma 编码值做混合/插值/滤镜会产生色带与
暗部误差;多次处理链(特效叠加)累积量化损失。
2. **色彩空间不可控**:素材可能是 BT.709 / Display P3 / BT.2020 HDR,
现状要么被当 sRGB 直通(YUV 路径还硬编码了 BT.601 矩阵),要么由
各平台着色器各算各的,三端结果不一致。
3. **输出目标不可配置**:交付色域(sRGB / P3 / BT.2020)应由项目设置
决定,现状没有这个概念。
目标架构:**场景线性(scene-linear)工作空间 = ACEScg(AP1 基色,线性
传递),数据精度 = F32**。ACEScg 是影视工业标准工作空间(AP1 色域覆盖
BT.709/P3/BT.2020 大部分,线性光,负值可表示,矩阵运算友好)。
## 2. 现状摘要(审计结论)
- 颜色以 `Hsla` 存于场景(`crates/gpui/src/color.rs:697` 的
`ColorSpace` 枚举只影响渐变插值,与显示无关);三套着色器
(`shaders.metal` / `shaders.hlsl` / `shaders.wgsl`)各自做
HSL→RGB,输出 gamma 编码 sRGB 值。
- 交换链一律非 SRGB 的 UNORM:macOS `BGRA8Unorm`
(`metal_renderer.rs:236`),Windows `DXGI_FORMAT_B8G8R8A8_UNORM`
(`directx_renderer.rs:39`),wgpu 偏好 `Rgb10a2Unorm`/`Rgba16Float`
(`wgpu_renderer.rs:125`)。OS 把内容当 sRGB,各映射一次——当前没有
二次映射。
- 已知缺陷(本计划顺带修复):
- 三端渐变插值不一致(wgpu 的 `linear_to_srgba`/`srgba_to_linear`
双重编码,源于错误注释"`hsla_to_rgba` 返回 linear sRGB");
- Windows HLSL 的 `linear_to_srgb`/`srgb_to_linear` 定义与名称互换,
Oklab 渐变方向全反;
- macOS YUV 直通路径硬编码 BT.601 full-range 矩阵
(`shaders.metal:893`);
- Wayland 未用 `color-management-v1` 声明表面色彩空间(依赖已启用
`staging` feature,见 `gpui_linux/Cargo.toml:104`,协议源码在依赖
树中,未接线);
- `OAK_MACOS_LAYER_COLORSPACE=display` 直通标记只取主显示器
(`display_colorspace.rs:32`),窗口跨屏/换 ICC 后过期
(`window.rs:2362` 不更新)。
## 3. 目标架构总览
```
素材(视频/图片, 任意源色域) UI 颜色(Hsla) 文本/图标
│ 输入节点 │ │
│ 源色域→ACEScg(F32) │ HSL→sRGB→linear→AP1 │ 覆盖率掩码,
▼ ▼ ▼ 颜色同 UI
┌──────────────────────────────────────────────────────────────────┐
│ 场景合成:ACEScg + F32(混合/渐变/模糊/滤镜) │
│ scene texture(RGBA32F) → blur/group/path intermediates(F32) │
└──────────────────────────────────────────────────────────────────┘
│ 输出节点(每个渲染器一个共享语义的最终 pass)
│ ACEScg → 目标色域(项目设置, 默认 sRGB):
│ AP1→目标基色矩阵 → 色域映射/钳制 → 目标传递函数编码
│ → (可选)抖动
▼
交换链(8/10-bit UNORM, 携带"目标色域"语义) → 平台呈现
macOS: CAMetalLayer colorspace = 目标色域 → ColorSync 映射到显示器
Windows: DXGI 默认 sRGB(显式 SetColorSpace1) → DWM/ACM 映射
Wayland: color-management-v1 声明 image description → 合成器映射
```
关键不变量:**全仓库内不存在"显示器 ICC 变换"**。输出节点的目标永远是
色度学空间(sRGB / Display P3 / BT.2020+PQ…),最后一次到显示器的映射
由操作系统完成。macOS 的 `OAK_MACOS_LAYER_COLORSPACE=display` 直通模式
在本计划中退役(见 P2.4)。
## 4. 契约与配置类型(P0 产出)
新增(建议放 `crates/gpui/src/color.rs` 或新模块 `color_pipeline.rs`):
```rust
/// 渲染管线工作模式。本 fork 默认 AcesCg;保留 SrgbLegacy 供
/// gpui-ce 上游用户与回退调试。
pub enum ColorPipeline { SrgbLegacy, AcesCg }
/// 输出节点的"目标色域",由项目设置指定;未指定 = Srgb。
pub struct OutputColorSpec {
pub gamut: OutputGamut, // Srgb | DisplayP3 | Bt2020
pub transfer: OutputTransfer, // Srgb | Gamma22 | Pq | Hlg
pub peak_nits: Option<f32>, // HDR 目标才需要
}
impl Default for OutputColorSpec { /* sRGB + sRGB 传递 */ }
```
- `WindowOptions` / `WgpuSurfaceConfig`(`wgpu_renderer.rs:155`)/
macOS `MacWindow` 构造参数 / Windows 渲染器构造参数,各加
`color_pipeline` 与 `output: OutputColorSpec` 字段,默认
`AcesCg + OutputColorSpec::default()`。
- 运行时可变:`Window::set_output_color_space(spec)` → 重建输出
pass uniform;若位深/格式需要变化则重配交换链(wgpu
`surface.configure`;Windows 重建 swapchain;macOS 更新
`layer.colorspace` 与像素格式)。Oak 主仓库的项目设置面板调用它。
- Surface 契约(输入节点交付面):`paint_surface` 传入的纹理必须是
**ACEScg 线性、F32(`Rgba32Float`)**;`oak_bridge::SurfaceFormat`
(`crates/oak_bridge/src/surface.rs`)相应更新。纹理附带可选的
元数据(源色域标签)仅用于引擎内部,交给 gpui 时一律已转换。
- 色彩数学集中在一个模块(矩阵 + 传递函数 + 单元测试),三套着色器
的常量与之保持一致(矩阵同时写进 WGSL/Metal/HLSL,测试比对数值)。
## 5. 输入节点(素材 → ACEScg F32)
"输入节点"在本仓库内对应四个入口:
1. **视频/引擎帧**(`paint_surface`,`window.rs:4192/4211`,
`elements/surface.rs`):
- 转换在 oak 引擎侧完成(源色域从解码器元数据读取:
BT.709/BT.2020/P3、transfer、full/limited range),经
`oak_bridge` 交付 `Rgba32Float` ACEScg 纹理。本仓库只定义契约 +
验证(格式不符时 `log::error` 并拒绝,沿用
`metal_renderer.rs:1857` 的检查模式)。
- **退役 macOS 的 YUV 直通路径**(`metal_renderer.rs:1857-1864`
与 `shaders.metal:893` 的 BT.601 矩阵):解码→转换放引擎侧后,
gpui 不再需要 YUV 采样。过渡期保留但标记 deprecated。
2. **位图/图标**(polychrome sprites,atlas 上传):
atlas 插入时做一次性转换:sRGB 解码 → AP1 矩阵,存为
F32(或 F16,见风险节)atlas 格式。涉及
`metal_atlas.rs` / `wgpu_atlas.rs` / `directx_atlas.rs`。
3. **UI 颜色**(`Hsla`):在着色器内转换——`hsla_to_rgba` 改为
`hsla_to_acescg`:HSL→gamma-sRGB → sRGB EOTF → 线性 sRGB →
AP1 矩阵(sRGB(D65)→ACEScg(D60 白点,矩阵含色适应)):
```
0.6131324224 0.3395230762 0.0473341514
0.0701922769 0.9163536767 0.0134540464
0.0206157712 0.1095697056 0.8698145232
```
(数值以 ACES 官方规范核准版为准,P0 单测锁定。)
4. **截图/回读**(`render_to_image` / `render_scene_to_image`,
`metal_renderer.rs:679/777`):默认经过输出节点,得到与显示器
一致的 sRGB 编码图;另留内部接口输出 ACEScg 原值供调试/测试。
## 6. 中间处理链(ACEScg + F32)
- 场景渲染目标、模糊乒乓、内容滤镜组纹理、path 中间纹理全部改为
`Rgba32Float`(wgpu:`wgpu_renderer.rs:1290+` 的
`ensure_blur_textures` 与 `create_path_intermediate`;Windows:
`create_path_intermediate_texture` 等;macOS:新增离屏场景纹理,
见 P2.1)。
- **混合/插值/模糊全部天然变为线性光**——顺带修复审计发现的三端
渐变不一致与 gamma 混合偏暗问题。`ColorSpace::Srgb` 渐变语义改为
"在线性 sRGB 中插值"(端点先 AP1→linear-sRGB),`Oklab` 改为
"AP1→linear-sRGB→Oklab 插值→返回",三端共用同一套函数,统一行为。
- 覆盖掩码类数据(字形 alpha、path 覆盖率)不属于颜色,保持低精度
(R8 / F16)即可;只有**颜色**走 F32。
- 文本外观:现有 `ZED_FONTS_GAMMA` / enhanced-contrast 参数
(`wgpu_renderer.rs:2667` 的 `RenderingParameters`,及三套
`shaders_subpixel`/color_text_raster)是为 gamma 空间调的,线性化
后需重新调参(P5.4);覆盖率校正本身保留在覆盖率域。
- 抖动(现 `shaders.metal:1243` 等处的 ±2/255 渐变抖动)移到
**输出节点编码之后**,线性域内抖动无意义。
## 7. 输出节点(ACEScg → 目标色域)
每个渲染器增加一个最终全屏 pass(三份实现、同一语义;建议先在
wgpu 端定型再移植):
1. `AP1 → 目标基色`矩阵(目标为 sRGB 时即上面矩阵的逆)。
2. **色域映射**:P1 用简单钳制(UI 颜色几乎不越界);越界严重的
视频内容后续升级为色度压缩(列入开放问题)。
3. **传递函数编码**:`sRGB OETF` / `pow(1/2.2)` / `PQ` / `HLG`。
4. **HDR→SDR 目标时**需要色调映射(ACES RRT+ODT 或更简单的 roll-off);
列入 P6,首期只做同动态范围目标。
5. 编码后抖动(见上节)。
6. 输出到交换链(仍为 8/10-bit UNORM;`OAK_DISPLAY_BIT_DEPTH` 语义
不变)。
随之而来的结构变化:**三个渲染器都必须"离屏场景 + 最终 blit"**。
Windows 已有离屏场景(`directx_renderer.rs:421-578` 的 blur 路径,
泛化为常开);wgpu 已有 blit 基建(`fs_blur_downsample` 的 1:1 拷贝
分支);**macOS 目前是直绘 drawable,需要新增离屏场景纹理**——这是
macOS 端最大的结构改动(P2.1),注意 `presents_with_transaction`
直显模式(`metal_renderer.rs:641`)与 offscreen 的相互作用。
## 8. 平台呈现(单次映射原则)
- **macOS**:layer 像素格式保持 `BGRA8Unorm`;`layer.colorspace`
设为**输出目标色域**(默认 `CGColorSpaceCreateWithName(kCGColorSpaceSRGB)`,
目标为 P3 时设为 P3),ColorSync 完成到显示器的唯一一次映射。
替换 `OAK_MACOS_LAYER_COLORSPACE` 逻辑(`metal_renderer.rs:254-266`
与 `display_colorspace.rs`):**不再使用显示器色彩空间直通**。
窗口跨屏时(`window_did_change_screen`,`window.rs:2362`)无需
重打标记——标记的是内容色域而非显示器,ColorSync 自动按当前屏映射
(这正是修复审计缺陷之处)。HDR 输出(P6)再启用
`wantsExtendedDynamicRangeContent` + EDR headroom。
- **Windows**:交换链格式与现状一致(`B8G8R8A8_UNORM`,
`directx_renderer.rs:39`)。增加显式声明:cast
`IDXGISwapChain1 → IDXGISwapChain3`,调用 `SetColorSpace1`
(默认 `DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709`;P3/BT.2020 目标
在 P6 加对应值)。显式声明消除对"默认即 sRGB"的隐式依赖。
- **Linux/Wayland**:实现 `color-management-v1`(wayland-protocols
`staging` feature 已在,`gpui_linux/Cargo.toml:104`):
- `client.rs` 绑定 `wp_color_manager_v1`(对齐方式参照现有
`wp_fractional_scale_manager_v1` 的接法,`client.rs:66`);
- 每个窗口 `wl_surface`(`wayland/window.rs:539` 创建、rwh 句柄
已在本仓库手里)取 `wp_color_management_surface_v1`,
`set_image_description` = sRGB(BT.709 基色 + sRGB 传递,用
params creator 构造;目标色域变化时更新);
- 合成器不支持该协议时静默降级(内容本来就是 sRGB,合成器默认假设
也是 sRGB,行为不变);
- **绝不在应用侧做显示器映射**——维持审计结论:Wayland 的颜色管理
不可关闭,程序只声明、不代劳。
- **X11**:无协议可用,维持 sRGB 直通,文档注明广色域屏过饱和属
系统限制。
## 9. 分阶段工作项
### P0. 契约与色彩数学基础(无渲染行为变化)
- [ ] `OutputColorSpec` / `ColorPipeline` 类型与默认值(§4)。
- [ ] 色彩数学模块:sRGB↔ACEScg 矩阵、EOTF/OETF、PQ/HLG 占位;
参考实现 + 单测(含与已知测试向量的比对,如 sRGB 红/绿/蓝
原色在 ACEScg 下的坐标)。
- [ ] 三套着色器共用的矩阵/函数清单(哪些函数要改、改成什么),
写成对照表放进实现 PR。
- [ ] `WindowOptions` / 各渲染器配置字段贯通(此阶段
`SrgbLegacy` 行为与现状完全一致,`AcesCg` 先不启用)。
- 验收:`cargo test` 全绿;`SrgbLegacy` 下截图与改造前逐像素一致
(现有 visual test 基线)。
### P1. wgpu 渲染器(Linux)先行试点
- [ ] `shaders.wgsl`:`hsla_to_rgba` → `hsla_to_acescg`;渐变/
Oklab/over/blur 全部改为线性语义;删除双重编码路径
(`shaders.wgsl:417-421, 473` 审计缺陷顺带消除)。
- [ ] 场景/模糊/组/路径中间纹理改 `Rgba32Float`
(`wgpu_renderer.rs` 的 `ensure_blur_textures`、
`create_path_intermediate`、`RenderingParameters` 的
MSAA 采样数适配——部分后端不支持 32F MSAA,需降级策略)。
- [ ] 新增输出节点 pass(§7),交换链仍用
`preferred_surface_formats()`(`wgpu_renderer.rs:125`)。
- [ ] atlas 改线性(polychrome);字形覆盖率保持。
- [ ] Surface 元素按契约采样 `Rgba32Float`
(`wgpu_renderer.rs:1880` 的 `draw_surfaces`);格式校验。
- [ ] Wayland `color-management-v1` 接线(§8)。
- 验收:`examples/legacy/gradient.rs` 三端一致(本阶段与 macOS 对照
用截图比对);KWin/启用色彩管理的合成器下声明生效(协议日志或
合成器调试工具确认),不支持的合成器无回归;模糊/滤镜视觉测试通过。
### P2. macOS Metal 渲染器
- [ ] **结构改造:离屏场景纹理(F32)+ 输出节点 pass → drawable**
(`metal_renderer.rs` 的 `draw`/`draw_primitives` 重构,
注意 `presents_with_transaction`、`next_drawable` 超时处理与
`render_to_image`/`render_scene_to_image` 测试路径)。
- [ ] `shaders.metal` 与 `shaders.wgsl` 对齐(线性语义、
统一的渐变/Oklab 实现、pow(2.2) 近似换精确 sRGB TF)。
- [ ] atlas 线性化(`metal_atlas.rs`)。
- [ ] `layer.colorspace` = 输出目标色域;移除
`OAK_MACOS_LAYER_COLORSPACE`/`display_colorspace.rs` 直通逻辑
(与 oak 主仓库协调:主仓库停止设置该 env)。
- [ ] 退役 YUV 直通路径(§5.1),`oak_bridge` 交付格式契约更新
(`Rgba32Float`;`Bgra8Unorm` 过渡期保留并打警告)。
- 验收:广色域显示器上 UI 颜色与"系统设置-显示器-P3/sRGB 切换"的
行为一致(ColorSync 单次映射);跨屏移动窗口颜色不变;
visual test 基线更新并通过。
### P3. Windows D3D11 渲染器
- [ ] 离屏场景泛化为常开(现有 `scene_rtv/scene_srv` 机制,
`directx_renderer.rs:421-578`)+ F32 中间纹理。
- [ ] `shaders.hlsl` 对齐:修复 `linear_to_srgb`/`srgb_to_linear`
名称/定义互换(审计缺陷),统一线性语义。
- [ ] 输出节点 pass 替换 `dx_blit`(`directx_renderer.rs:1116`)。
- [ ] `IDXGISwapChain3::SetColorSpace1` 显式声明(§8)。
- [ ] atlas 线性化(`directx_atlas.rs`)。
- 验收:与 Linux/macOS 的截图逐像素近似比对(容差来自抖动/驱动);
Win11 ACM 显示器上行为正确;透明窗口(DComposition,
premultiplied)无回归。
### P4. 三端一致性与视频链路收口
- [ ] 三端渐变/混合/文本外观交叉比对(用 `gradient` example +
新增 color-checker example:24 色卡 + 灰阶 + 色域边界色)。
- [ ] 引擎侧输入节点联调(oak 主仓库):解码元数据→ACEScg 转换、
`oak_bridge` F32 交付、Windows/Linux 的
`paint_surface(wgpu::Texture)` 直连(oak-app-rewrite.md W3
遗留项一并完成)。
- [ ] 项目设置→`OutputColorSpec` 的运行时切换联调(改项目设置后
不重启窗口即生效)。
- 验收:同一项目在三平台导出的检视器画面一致;切换目标色域
(默认 sRGB ↔ P3)立即可见且与外部参考(如系统色彩管理应用)
观感一致。
### P5. 文本与外观回归
- [ ] 线性空间下的文本参数重调(`ZED_FONTS_GAMMA` 等,
`RenderingParameters`),subpixel 覆盖率校正在覆盖率域重推;
提供 A/B 对比工具。
- [ ] 主题/调色板审视:UI 颜色在 ACEScg 管线下的最终呈现与旧管线
应逐像素等价(sRGB→ACEScg→sRGB 往返),若有偏差定位到具体
着色器路径。
- 验收:现有 visual tests 全绿;文本在明/暗背景下的可读性评审通过。
### P6. HDR 与广色域输出(二期,可与主仓库排期解耦)
- [ ] `OutputGamut::Bt2020` + `PQ/HLG`:输出节点色调映射选型
(候选:ACES RRT+ODT / Khronos PBR Neutral / 简单 roll-off),
先在 wgpu 端原型。
- [ ] macOS:`wantsExtendedDynamicRangeContent` + EDR headroom 监听;
`Rgba16Float` 交换链(EAC 模式)。
- [ ] Windows:HDR swapchain(`DXGI_FORMAT_R16G16B16A16_FLOAT` +
`SetColorSpace1(DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)`),
查询 `DXGI_OUTPUT_DESC1` 的 HDR 状态。
- [ ] Wayland:image description 声明 BT.2020+PQ;跟随
`preferred` 反馈。
- [ ] 输入侧:HDR 素材(PQ/HLG 源)在引擎侧转 ACEScg 的场景参考
语义定义(与色调映射策略联动)。
- 验收:HDR 显示器上高光细节保留、SDR 内容不炸白;三端行为对齐。
## 10. 测试策略
- **单测**:色彩数学(矩阵往返误差 < 1e-6、传递函数锚点值、
色域边界钳制行为)。
- **headless 截图**:`render_scene_to_image` 走输出节点,锁定
golden image;`SrgbLegacy` 模式保留旧基线用于回归。
- **跨端比对**:color-checker example 三端截图自动比对
(容差需显式定义,抖动用固定种子)。
- **真实显示器**:P2/P3/P6 验收需要广色域/EDR/HDR 显示器 + 目视或
色度计;CI 无 GPU 环境跳过(沿用 `oak_bridge` demo 的做法)。
## 11. 风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
| F32 目标带宽/显存 ~4×(模糊乒乓最明显) | 低端 GPU 掉帧 | 提供 `Rgba16Float` 降级开关(视觉差异对 8-bit 交付可忽略);模糊半分辨率已存在;先测量再优化 |
| 32F MSAA 部分后端不支持 | path 抗锯齿退化 | `RenderingParameters::path_sample_count` 已有降级逻辑,F32 下按需降到 1× 或用 F16 中间层做 MSAA |
| macOS 离屏化破坏直显模式性能 | 帧延迟/掉帧 | 保留 `presents_with_transaction` 语义,输出 pass 与 present 同 command buffer 提交;基准对比改造前后 |
| 文本外观变化(线性混合显细) | 可读性回归 | P5 专项;覆盖率校正留覆盖率域;参数可调 |
| Wayland 协议可用性参差 | 声明不生效 | 降级路径 = 现状(合成器按 sRGB 处理,内容恰为 sRGB,无损) |
| 与上游 zed 分叉进一步扩大 | 合并成本 | 改动集中在渲染器/着色器(上游也在快速变动),核心场景结构不动;`SrgbLegacy` 保持与上游行为一致 |
| 引擎侧输入节点未就绪(主仓库依赖) | P4 联调阻塞 | gpui 侧先用合成测试纹理验证契约;YUV 旧路径过渡期保留 |
## 12. 兼容性说明
- `gpui-ce` 的其他使用者:`ColorPipeline::SrgbLegacy` 与现状
逐像素一致,可作默认逃生口;本 fork(oak)默认 `AcesCg`。
- `OAK_DISPLAY_BIT_DEPTH` 语义不变(只影响交换链位深)。
- `OAK_MACOS_LAYER_COLORSPACE` 在 P2 移除,需同步通知主仓库
(该 env 由主仓库设置,见审计)。
- 截图/视觉测试的像素基线在 P1-P3 各平台切换时一次性更新,
更新前后用 `SrgbLegacy` 双跑确认差异全部来自预期语义变化。
## 13. 开放问题(实施前需拍板)
1. 中间链 F32 是否允许按设备能力降级 F16(Apple Silicon/现代独显
上两者带宽差异显著)?建议:默认 F32,配置项允许 F16。
2. 色域映射算法:首期钳制是否可接受(视频内容可能越界)?
还是 P1 就上色度压缩?
3. HDR→SDR 色调映射选型(P6)——影响输入侧"场景参考"语义定义,
建议 P6 启动时单独评审。
4. Wayland 下目标色域为非 sRGB(P3/BT.2020)时,是否要求合成器
支持对应 image description,还是回退 sRGB 输出(合成器能力查询
`wp_color_manager_v1` 的 render intent/primaries 反馈)?
5. `ColorSpace::Oklab` 渐变在线性管线下的语义:Oklab 本为感知
均匀空间,输入应使用线性 sRGB——与 CSS `oklab` 一致,三端统一后
无歧义,但需确认与现有设计稿的视觉差异可接受。
@@ -0,0 +1,548 @@
# 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 → plugin(notification)
{"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 线程**;典型延迟 50–500 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 合成完毕 → ack(notification),槽位可复用
{"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.2s–141.6s 为废片 → 事务化下刀(用户确认后执行)
→ {"jsonrpc":"2.0","id":4,"method":"edit.begin","params":{"label":"AI 粗剪:删除 83.2–141.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":[]}}}
```
@@ -0,0 +1,334 @@
# 外部功能插件系统设计(进程隔离 + 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 足够。
@@ -0,0 +1,155 @@
# OpenFX-Misc 洁净室 GPU 重写(内置特效扩充)与特效分类/折叠计划
> 面向实现者的任务书(2026-09-10)。本文只描述方案与工作项,不含已执行的代码修改。
> 用户要求:"把 OpenFX-Misc 洁净室重写为 GPU 版本,作为内置特效加入进去,并给内置特效
> 加分类和折叠分类的功能。"参考源码已克隆到本机 `/tmp/ofx-misc`(上游
> `github.com/cgvirus/OpenFX-Misc`,GPL2,86 个插件目录,README 有完整清单)。
>
> 法律/工程边界:**洁净室**指不复制其代码——我们只读其算法描述与参数语义,
> GLSL 与节点代码全部自写。仓内节点实现模式已有 60+ 先例(oak-node/src/nodes/*.rs),
> 本计划实质是"参照 OpenFX-Misc 的特效清单,按仓内既有节点模式补齐内置特效"。
## 1. 现状
### 1.1 节点/渲染管线(完全够用)
- 内置特效 = `oak-node/src/nodes/*.rs` 的 `NodeBehavior` 实现:声明输入(`Input`),
`value()` 推 `ShaderJobPayload`(`../../../../crates/oak-node/src/jobs.rs`),`shader_code()`
返回 GLSL 片段。渲染端 `crates/oak-render/src/eval.rs::process_shader_job`:
编译(naga→WGSL,**不支持 GLSL switch**——新 shader 一律 if/else,教训见提交
`37df1d3d3`)、按名绑定全部纹理参数、嵌套 payload 递归(深度上限 8)、
`resolution_in` 自动锚定序列分辨率(提交 `fc9060424`)、`iterations` 多轮 +
`previous_iteration_in` 反馈。GPU 像素测试模式:`eval.rs tests::eval_node_row`。
- 坐标/基准约定:像素空间以**画面中心**为原点(transform 语义,提交 `d028a45ff`);
像素尺寸参数(半径/宽度/距离)按序列分辨率解释。
- 已有同类特效(避免重复):blur、opacity、transform、crop、flip(Distort 系)、
merge、mrg(生成器 alpha-over)、math、chromakey、colordifferencekey、despill、
solid、polygon、shape、noise、ociobase/lut/grading、whitebalance、threewaycolor、
mask、stroke、dropshadow、displaytransform、cornerpin(假实现,另案)、
tile/swirl/ripple/wave(Distort 系)、trigonometry、volume、pan。
### 1.2 特效库 UI
- `crates/oak-app/src/oakui/effectchain.rs::addable_effects`:内置(`group: None`)
+ OFX 动态条目(`group: Some(子类)`),排序已按组+名字。
- `../../../../crates/oak-app/src/panels/effect_library.rs`:渲染时组头已存在
(`group_header()`,内置统一一个 "Built-in" 头),**不可折叠**;有搜索框。
- 检查器"添加特效"菜单(`panels/inspector.rs:157`)吃同一张 `addable_effects` 表。
## 2. 目标
1. 参照 OpenFX-Misc 清单,按 GPU 版本洁净室重写一批常用特效,作为**内置特效**
(oak-node 原生节点,非 OFX 运行时)加入。
2. 内置特效按功能分类(Color / Filter / Keying / Distort / Generator / Merge / Time),
特效库与检查器添加菜单都按分类分组,**分类可折叠**(折叠状态持久化)。
## 3. 特效分批(实现范围)
### Tier 1(本批必做,算法简单、GLSL 直译,全部像素可测)
| 特效(参考) | 分类 | 输入(节点参数) | 算法要点 |
|---|---|---|---|
| ColorCorrectOFX | Color | saturation/contrast/gamma/gain/offset(各 5 组:master/shadows/midtones/highlights)太多了→**简化为全局 5 参数**(saturation/contrast/gamma/gain/offset) | 逐像素 `offset+gain*pow(x,gamma)`,contrast 绕 0.18 灰,saturation 绕 luma |
| GammaOFX | Color | gamma(单值) | `pow(x, 1/g)` |
| SaturationOFX | Color | saturation | luma 插值(与 whitebalance/threeway 不重复:它最简) |
| InvertOFX | Color | channel 开关(RGBA) | `1-x`(按通道掩码) |
| ClampOFX | Color | min/max | clamp 每通道 |
| ColorMatrixOFX | Color | 4x4 矩阵(16 float) | 矩阵×RGBA(uniform mat4 已有先例:transform_in) |
| GradeOFX | Color | blackPoint/whitePoint/blackOut/whiteOut/gamma | 黑白点重映射 |
| DirBlurOFX | Filter | amount/angle | 方向模糊(迭代采样 N=16,角度→方向向量) |
| SharpenCImg | Filter | amount | unsharp mask:x + amount·(x − blur(x))(blur 复用现有迭代模糊,嵌套 payload) |
| EdgeDetectCImg | Filter | threshold/通道 | Sobel 幅值 |
| Dilate/ErodeCImg | Filter | radius/shape(rect) | 3×3~7×7 结构元 max/min(radius 控制迭代轮数) |
| DissolveOFX | Merge | mix(0..1)、第二输入 blend_in | 加权平均(merge.rs 双输入绑定已有先例;转场功能的原子件) |
| KeyMixOFX | Merge | mask_in、blend_in | 按 mask 拷贝(mask 绑定已有先例:chromakey 的 garbage/core matte) |
| PreMult/UnpremultOFX | Merge | channel 选择 | rgb *= a / rgb /= a(0 保护) |
| PositionOFX | Distort | offset xy(整数 px) | 采样偏移(resolution_in 换算) |
| MirrorOFX | Distort | horizontal/vertical | 翻转采样(flip 节点已有?若有重复则跳过——实现时先查 flip.rs 覆盖面) |
| CheckerBoardOFX | Generator | size/color1/color2 | 程序化棋盘格 |
| ColorBarsOFX | Generator | SMPTE/100%/75% | 彩条(分段填色) |
| RampOFX | Generator | point0/point1/color0/color1 | 线性渐变 |
| Rand(噪声已有) | — | — | **跳过**(noise.rs 已覆盖) |
| Constant(solid 已有) | — | — | **跳过** |
合计约 17 个新节点(Mirror 可能合并/跳过)。
### Tier 2(第二批,涉及曲线/对数/卷积/积雨云)
HSVTool(色相替换+keyer 能力)、Quantize(海报化/抖动)、Log2Lin/PLogLin、
ClipTest(斑马纹超范围指示)、Matrix3x3/Matrix5x5(通用卷积)、GodRays(径向
辉光,迭代采样)、ColorLookup(分通道曲线——**复用现有曲线编辑器**
`gpui_widgets::curve_editor` + `oak_plugin::param_curve` 的 JSON 模型,参数为 Text)。
### Tier 3(明确不做,写明理由)
- Roto(要主机遮罩编辑)、TrackerPM(点跟踪,需交互与多帧)、Card3D(3D 投影)、
STMap/IDistort(位移图输入——其实可做,列 Tier 2 备选)、Shadertoy(沙盒运行时)、
全部 Views/立体声(无多视图管线)、CImg 重型族(DenoiseSharpen/Smooth* PDE/Inpaint——
迭代 PDE 不适合实时 GPU 预览)、**全部时间域**(FrameBlend/FrameHold/Retime/
TimeBlur/SlitScan/TimeOffset/AppendClip——`ShaderJobPayload` 只能采当前时刻纹理,
多时刻采样需要 job 管线扩展,**单独立案**,不在本计划)。
## 4. 节点实现模板(所有新节点统一)
每个新节点 = `oak-node/src/nodes/` 一个文件,遵循既有模式(参照 `opacity.rs` /
`colorcorrect` 无、参照 `blur.rs`/`math.rs`):
1. 常量输入 id + `create()`(输入、默认值、min/max、combo 字符串、`VIDEO_EFFECT` 标志、
`core.effect_input = "tex_in"`;双输入节点参考 merge.rs 的 base/blend)。
2. `value()`:无纹理直通(参考各节点的 `// CPP-PARITY` 注释体例),否则推
`ShaderJobPayload`(`shader_id: ""`,`iterations: 1`)。
3. `shader_code()`:GLSL 片段(ove_texcoord/frag_color;**禁用 switch**;
像素尺寸参数用 `resolution_in`;采样偏移用中心原点像素空间与否按特效语义——
颜色类与坐标无关,几何类参照 transform 的中心原点)。
4. `register()` 进 `nodes/mod.rs` 的注册表。
5. 单元测试(输入默认值/隐藏标志/job 参数)+ **`../../../../crates/oak-render/src/eval.rs`
GPU 像素测试**(eval_node_row 模式,无 GPU 自动跳过)。颜色类用纯色输入断言
输出值;几何/模糊类用点/块图案断言位移/扩散。
## 5. 分类与折叠(UI)
1. **内置特效分类**:`addable_effects()` 的内置分支改为 `group: Some(分类)`,
分类取自节点 `categories()` 首个 `Category` 映射:
`Category::Color→"调色"`(或英文 "Color",跟 i18n key)、`Filter→"滤镜"`、
`Distort→"扭曲"`、`Keyer→"键控"`、`Generator→"生成器"`、`Merge→"合成"`、
`Time→"时间"`、`Math→"数学"`、`Channel→"通道"`。映射函数放
`effectchain.rs`(`node_category_key` 已有类似物,见 engine.rs:206,
但该函数是给节点编辑器菜单的 i18n key,特效库分组可直接复用同一 key 体系)。
i18n:8 语言加 `effect_library.group.<key>`。
2. **折叠**:`effect_library.rs` 组头加点击折叠/展开(箭头 ▶/▼ + 组名):
- 面板 struct 增加 `collapsed: std::collections::HashSet<String>`(组 key),
点击组头切换;渲染时折叠组跳过其子行。
- 持久化:`oak_core::configstore`(参照现有 `UseProxyMedia` 等键的读写模式),
键 `EffectLibraryCollapsed`(逗号分隔组 key 列表)。
- 检查器的添加菜单(inspector.rs:157 的菜单构建)同样按组分组
(menu.rs 支持子菜单——组做子菜单,比折叠更适合菜单形态;实现时确认
`MenuItem::with_submenu` 用法,与 proxy_submenu 一致)。
3. 搜索时忽略折叠状态(搜索命中强制展开显示,已在循环内自然满足:
搜索非空时不跳过子行)。
## 6. 工作项(可分配给子代理的最小单元)
- **W1 Tier1 颜色组(6 节点)**:ColorCorrect/Gamma/Saturation/Invert/Clamp/Grade。
- **W2 Tier1 矩阵+卷积组(4 节点)**:ColorMatrix/EdgeDetect/Dilate/Erode
(+Tier2 的 Matrix3x3/5x5 若顺利一并)。
- **W3 Tier1 模糊/锐化组(2 节点)**:DirBlur/Sharpen。
- **W4 Tier1 合成组(4 节点)**:Dissolve/KeyMix/PreMult/Unpremult。
- **W5 Tier1 几何+生成器组(4~5 节点)**:Position/Mirror(或跳过)/CheckerBoard/
ColorBars/Ramp。
- **W6 分类与折叠 UI**:§5 全部(addable_effects 分组 + 特效库折叠 + 持久化 +
检查器子菜单 + i18n)。
- **W7 Tier2 批**:HSVTool/Quantize/Log2Lin/ClipTest/ColorLookup/GodRays
(W1-W6 完成并审查后再派)。
W1-W5 互相独立(不同文件),可并行派 5 个子代理;W6 独立;每个子代理须交付:
节点实现 + 单元测试 + GPU 像素测试 + `cargo test -p oak-node -p oak-render` 绿。
**统一禁令**:GLSL 不写 switch;不动 eval.rs/traverser 等管线文件(冲突根);
遵循 nodes/ 既有文件体例(GPL 头、CPP-PARITY 注释、输入常量文档)。
## 7. 验收标准
1. Tier1 全部节点出现在特效库对应分类下,可加到 clip,画面效果正确(GPU 测试
逐节点覆盖核心算法)。
2. 特效库分类可折叠,重启 app 折叠状态保留;检查器添加菜单按分类分组。
3. 搜索框在任何折叠状态下都能搜到特效。
4. `cargo test --workspace` 全绿。
@@ -0,0 +1,176 @@
# 文本素材化与结构化文本编辑器改造计划
> 面向实现者的任务书(2026-09-10)。本文只描述方案与工作项,不含已执行的代码修改。
> 提出背景(用户原话):"不能让用户手工输入 HTML;输入框输入不了内容(已修复,见 §0);
> 应该让用户手工编辑文本、手工设置字体、字号、位置、字体颜色、轮廓、发光;文字应该是一个
> 单独的素材而不是一个特效——文字作为 clip 被拖动到时间轴上,而不是作为特效被拖动到检查器;
> 文字不应该被放在特效那里,应该在项目的'新建序列'旁边添加一个'添加文本素材'。"
## 0. 已先行修复(不在本计划范围)
- 输入框无法输入:参数视图每个引擎 tick 都把引擎值重刷进输入框,击键下一帧即被清掉。
已改为聚焦期间跳过重同步(`../../../../crates/oak-app/src/panels/ofx_params.rs` sync_values 的
Text 分支,与曲线编辑器拖拽保护同款),含回归测试
`text_field_keeps_in_progress_edits_while_focused`。**已提交**(`5f8db8e31`)。
## 1. 现状
### 1.1 节点层
- `../../../../crates/oak-node/src/nodes/textv3.rs`(type id `org.olivevideoeditor.Olive.text3`):
当前"Text"特效。输入仅 `text_in`(**HTML 原文**,默认值是
`<p style='font-size: 72pt; color: white;'>Sample Text</p>`)、`valign_in`、
`use_args_in`、`args_in`,外加 ShapeNodeBase 继承的 `pos_in`/`size_in`/`color_in`。
字体、字号、颜色全部编码在 HTML 里;轮廓、发光**根本不存在**。
`create()` 设 `VIDEO_EFFECT` 标志 → 出现在特效库/检查器"添加特效"菜单
(`crates/oak-app/src/oakui/effectchain.rs::addable_effects`,内置特效取
`VIDEO_EFFECT && !DONT_SHOW_IN_CREATE_MENU`)。
- `textv1.rs`(`textgenerator`):旧版,已带 `DONT_SHOW_IN_CREATE_MENU`。
- `textbackend.rs`:**只有 hook 层**。`TextLayoutRequest{text, mode, font_family,
font_size_pt, dots_per_meter, wrap_width, center_horizontally}` +
`set_text_backends(measure, render)` 两个函数指针。**没有任何已安装的文本引擎**,
即当前 text3 根本无法真正出字(后端决策被刻意推迟到 facade 层,见模块文档)。
- 字体引擎可用性:workspace lockfile 已有 `cosmic-text 0.19.0`(gpui 文本系统在用)与
`swash`。无需新增重量级依赖即可实现后端;GPU 侧轮廓/发光可作为覆盖率纹理的后处理
pass 实现,不走字体引擎。
### 1.2 素材/时间轴层
- bin 条目 = 项目根文件夹 `FolderBehavior.children`(`../../../../crates/oak-app/src/oakui/projectbrowser.rs`,
`roots()/children()`,条目 id = 节点 identity,名称 = `core.label`)。
- 时间轴接受 bin 拖放:footage 走 `engine.drop_footage_at` →
`graphops::place_footage_clip`(footage 节点连到 clip `tex_in`)。**没有**非 footage
条目的拖放路径。
- clip 由生成器喂入的机制已存在:clip 的 `tex_in` 可以接任意节点输出(多机位、
效果链都是这么接的,见 `effectchain::insert` 的连线模式)。
- 项目面板"新建序列"按钮:`crates/oak-app/src/panels/project_explorer.rs:246`,
emit `NewSequenceRequested` → app.rs 订阅处理。
### 1.3 渲染层
- text3 的 `value()` 产出 shader job(`ShaderJobPayload`),渲染端
`process_shader_job` 编译执行。文本栅格化在节点侧经 textbackend 完成
(当前无后端 → 空)。轮廓/发光若做 GPU 后处理,可复用同一个 job 管线
(多级 `iterations` 或嵌套 payload 都已支持)。
## 2. 目标
1. 用户永远看不到、也输不了 HTML。文本内容、字体、字号、位置、颜色、轮廓、发光
全部是结构化字段。
2. 文字是**素材**:项目面板"新建序列"旁出现"添加文本素材",创建后出现在 bin 里,
可拖到时间轴成为 clip;选中文本 clip 在检查器里编辑上述字段。
3. 特效库不再出现 text3(迁移完成后隐藏;迁移期不破坏旧项目)。
## 3. 方案
### 3.1 textv3 节点结构化输入(节点层)
给 `textv3.rs` 增加输入(保留 `text_in` 作内部合成载体与旧项目兼容):
| 新输入 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `plain_text_in` | Text | `"文本"` | 纯文本内容(多行) |
| `font_family_in` | Combo(StrCombo) | 空=后端默认 | 字体族(选项由后端枚举注入;手输亦可) |
| `font_size_in` | Float | 72.0 | 字号 pt,min 1 |
| `text_color_in` | Color | (1,1,1,1) | 字体颜色(替代 HTML color) |
| `outline_enabled_in` | Boolean | false | 轮廓开关 |
| `outline_color_in` | Color | (0,0,0,1) | 轮廓颜色 |
| `outline_width_in` | Float | 2.0 | 轮廓宽度 px,min 0 |
| `glow_enabled_in` | Boolean | false | 发光开关 |
| `glow_color_in` | Color | (1,1,0,1) | 发光颜色 |
| `glow_radius_in` | Float | 8.0 | 发光半径 px,min 0 |
- `value()` 变更:不再把用户文本当 HTML。若装了后端,用 `TextLayoutMode::PlainText`
+ `font_family_in`/`font_size_in` 布局;颜色经 `color_in`(已有的 shape 基类输入,
改名为 UI 上呈现为"字体颜色"还是保留 `color_in` 复用——**实现时选复用 `color_in`**,
少一个冗余输入;`text_color_in` 不建)。轮廓/发光:
- 首选 **GPU 后处理**:文本覆盖率纹理 → 轮廓 = 覆盖率膨胀(dilate)+底色垫底合成;
发光 = 覆盖率高斯模糊+加色合成。两者都是现成模糊/合成 shader 的组合,
作为 text3 `value()` 内的嵌套 payload 链(eval 已支持嵌套递归,深度上限 8)。
- 轮廓膨胀/模糊模糊 kernel 复用 `blur.rs` 的 box blur 迭代模式即可(视觉可接受,
避免新写高斯)。
- `text_in` 保留但改为**内部输入**(UI 隐藏,`input::flags::HIDDEN`):
旧项目文件里它是 HTML,载入时若 `plain_text_in` 为空而 `text_in` 非空,
`Retranslate`/加载钩子里做一次 HTML→纯文本剥离(简单正则去标签即可,
写 `strip_html_to_plain()` 单测覆盖)。
- `valign_in`/`use_args_in`/`args_in` 保留原样(已是 HIDDEN|STATIC 或正常输入)。
### 3.2 文本后端(cosmic-text)
- 新增 `../../../../crates/oak-app/src/oakui/textengine.rs`(app 层安装 hook,oak-node 不加依赖):
- 用 lockfile 已有的 `cosmic-text`(在 oak-app 的 Cargo.toml 提升为直接依赖,
版本与 gpui 一致 0.19,避免双版本)。
- 实现 `measure`/`render` 两个 `fn`,在 `RealEngine::create`(或 app 启动)
调 `oak_node::nodes::textbackend::set_text_backends(Some(..), Some(..))`。
(注意 textbackend 模块在 oak-node 是私有 mod 还是 pub——实现时若私有需改
`pub mod textbackend`;hook 函数签名是 plain `fn`,跨 crate 直接传。)
- `render` 输出 RGBA premultiplied(channel_count=4,白字默认色——节点侧
`color_in` 着色在 shader 里做,与 v1/v3 的 C++ 语义一致)。
- 字体枚举:`cosmic_text::fontdb` 系统字体库 → `font_family_in` 的 combo 选项
由引擎 `effect_params` 组装时注入(`combo_option` 属性)。
- 风险:cosmic-text 的 CJK 字体回退(fontdb 自带 fallback 链,Linux 上
Noto Sans CJK 通常可用);多行/换行由 wrap_width + 文本含 `\n` 覆盖。
### 3.3 素材化(bin + 时间轴)
- **创建入口**:项目面板标题栏"新建序列"按钮旁加"添加文本素材"按钮
(`project_explorer.rs` header,新 emit `NewTextFootageRequested`;app.rs 订阅)。
行为:在项目根文件夹创建一个 text3 生成器节点(`core.label = "文本"`),
作为 bin 条目出现。引擎方法 `AppEngine::create_text_footage(cx) -> Result<u64, String>`
(real 实现:graphops 建节点 + 挂到 root folder children + undoable;
mock 实现:记一条假条目)。
- **拖放到时间轴**:时间轴 drop 目前只认 footage。扩展 `drop_footage_at`
(或新增 `drop_generator_at`):若拖入的 bin 条目是 text3 节点,则
`block_clip_create` + 把 text3 节点连到 clip `tex_in` + 放置到轨道
(undoable,一条 undo)。clip 时长默认 5 秒(可拖长)。判定"条目是 text3":
`graphops` 按 identity 取节点比较 type_id。
- **bin 删除**:复用现有 `delete_entry`(从文件夹移除 + 断开图连接,已 undoable)。
文本 clip 删除走现有 clip 删除路径。
- **检查器编辑**:选中文本 clip 时,检查器显示其 **生成器节点**的参数
(现在选中 clip 显示效果链;对 generator-fed clip,链头即 text3——
检查器需要一个小改动:当 clip 的 `tex_in` 直连一个 generator 节点时,
把该生成器的参数也列出(或直接把选择路由到生成器节点)。
**实现时确定**:倾向"generator 节点作为链的第一张卡展示"——
`selected_effect_cards` 已经遍历 chain,chain() 目前把喂入节点(footage/
生成器)都算作链尾一张卡(见 effectchain.rs chain() 的已知怪癖),
text3 参数会自然出现;需要的是 text3 的参数在 build_control 下呈现为
结构化字段(文本=多行输入、字体=combo、颜色=颜色选择器、数值=spin)。
- **特效库隐藏 text3**:`textv3.rs::create()` 的 flags 增加
`DONT_SHOW_IN_CREATE_MENU`。旧项目里已存在的 text3 特效链节点**不受影响**
(只是不能再新增)。此项放在最后做,确认素材路径可用后再隐藏。
### 3.4 UI 文案(i18n)
8 个语言文件新增:`project.add_text_footage`(添加文本素材)、
`text.font_family`(字体)、`text.font_size`(字号)、`text.outline`(轮廓)、
`text.outline_width`(轮廓宽度)、`text.glow`(发光)、`text.glow_radius`(发光半径)、
`text.content`(文本内容)。zh-CN/en-US 翻译,其余语言给英文。
## 4. 工作项(可分配给子代理的最小单元)
1. **W1 节点输入扩展**:textv3 新输入 + `value()` 纯文本路径 + `text_in` 隐藏 +
HTML 剥离迁移 + 节点单测(输入存在/默认值/隐藏标志/纯文本 job 参数)。
2. **W2 轮廓/发光 GPU 后处理**:text3 `value()` 嵌套 payload(膨胀/模糊/合成),
eval.rs GPU 像素测试(白字黑轮廓边缘检测、发光半径扩散检测)。
3. **W3 cosmic-text 后端**:textengine.rs + hook 安装 + 字体枚举注入 +
集成测试(装后端后 text3 渲染出非空纹理,GPU 测试)。
4. **W4 素材创建+拖放**:面板按钮 + `create_text_footage` + drop 扩展 +
i18n + app 层测试(mock:按钮 emit;real:创建后 bin 有条目、拖放后轨道有 clip
且 tex_in 连到 text3)。
5. **W5 检查器结构化呈现**:确认 text3 参数以结构化字段出现在文本 clip 的检查器
(含聚焦保护已修的多行文本输入);颜色走 OfxColorPicker。
6. **W6 特效库隐藏 + 收尾**:DONT_SHOW_IN_CREATE_MENU、全量测试、文档更新
(docs/zh 如有特效清单)。
依赖顺序:W1→W2/W3(可并行)→W4→W5→W6。W2 与 W3 独立。
建议 W1+W2 一个子代理、W3 一个、W4+W5 一个、W6 收尾由主代理审查后执行。
## 5. 验收标准
1. 项目面板点"添加文本素材"→ bin 出现"文本"条目;拖到时间轴 → 出现文本 clip。
2. 选中文本 clip,检查器可编辑:内容(多行)、字体、字号、位置、颜色、轮廓
(开关/颜色/宽度)、发光(开关/颜色/半径);全程无 HTML 可见。
3. 编辑任一字段,暂停的画面立即更新(依赖已提交的暂停刷新修复)。
4. 特效库/添加特效菜单中不再出现 Text/text3。
5. 含旧 text3 特效的项目能打开、能渲染(HTML 自动剥成纯文本进 `plain_text_in`)。
6. `cargo test --workspace` 全绿,新增 GPU 测试在无 GPU 环境跳过。