Files
oak-editor/docs/zh/plans/render-pipeline-threads.md
T
Mike-Solar df3957b0dc 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.
2026-09-22 20:54:03 +08:00

698 lines
52 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.
# 渲染管线改造:单解码线程 + 单渲染线程 + 队列流水线 + GPU 零拷贝
> 面向实现者的任务书(2026-09-11)。本文只描述方案与工作项,不含已执行的代码修改。
>
> 用户要求(原文要点):
> 1. 解码一个单独的线程执行——线程,不是进程
> 2. 渲染一个单独的线程执行——同样线程
> 3. 主进程负责显示上屏
> 4. OpenFX 隔离在单独的进程,只有一个进程,崩了重新拉起来
> 5. 使用队列提交渲染任务
> 6. 流水线式作业:解码完成立刻进渲染队列,渲染完成进上屏队列
> 7. GPU 内存零拷贝——内置效果全是 GPU 效果,除非遇见 CPU OpenFX 特效,不回读;
> 哪怕为此要写平台特定代码(分支实现 CUDA/Vulkan/OpenGL/DirectX 通用)
> 8. 参考原版 Olive(github.com/olive-editor/olive)的渲染管线
> 9. 参考原版 Olive 重写 resolve:match + Job 枚举(crates/oak-node/src/jobs.rs)
> 单次循环完成,而不是四次扫描
>
> 补充硬性要求(2026-09-11,用户追加):
> **解码必须尽可能 GPU,并与渲染共用同一片 GPU 内存——解码这一步也是零拷贝;
> CPU 解码后上传只作为 fallback。解码实现优先 FFmpeg(硬解),次选手写 GPU
> 解码代码。** 本文 §2.7/§3.1/§3.4/§3.6/§4/§6 均按此修订。
>
> 补充硬性要求二(2026-09-11,用户追加):
> **Job 图做成货真价实的图存储结构**(不是线性表,也不是二维数组),允许非
> 全连通;每个项目图固定一对**虚拟输入节点 / 虚拟输出节点**(默认相连、不可
> 删除、要在节点编辑器里显示出来);resolve 从输入节点开始**广度优先搜索**,
> 逐个 match 处理 Job,直到全部分支汇聚于输出节点。见 §3.8。
>
> 配套调研:上游 Olive 已浅克隆到 `.cache/olive-upstream`(gitignore 内),本文
> 引用其文件时写作 `upstream/app/...`。
## 1. 背景:为什么改
当前渲染在**多进程**里跑(M15 进程隔离战役引入):主进程经 NDJSON+shm 向 N 个
`oak-worker` 子进程派发 ticket,每个子进程单线程同步渲染。这个模型的前提(GPU
工作可以靠多进程并行、进程隔离保护一切)对**内置效果全 GPU 化之后的 Oak 不再
成立**:
- **GPU 是单队列**。wgpu 设备只有一个提交队列,N 个进程各自建 GPU 上下文并不能
并行 GPU 工作,反而为每帧付出 GPU→CPU 回读 + shm 搬运 + 上屏前 CPU→GPU 重传
的三次额外拷贝。
- **每个 worker 重复持有**解码器会话、OCIO 处理器缓存、着色器缓存与 GPU 上下文,
内存与启动成本都按进程数翻倍。
- 进程间只能传 CPU 像素(shm 槽),GPU 纹理无法跨进程直接流动(除非上平台句柄
外联,成本高于收益)。
线程模型则完全够用:崩溃隔离只需要覆盖**真正会崩的东西——第三方 OpenFX 插件**,
而插件恰好本就是 CPU 边界(绝大多数 OFX 插件读写 CPU 图像),天然适合单独进程。
内置 GPU 效果不会崩出进程外(wgpu 的错误模型是校验+设备丢失,不是段错误),
不需要进程隔离。
## 2. 现状(关键事实,均带坐标)
### 2.1 进程池渲染后端
- `crates/oak-render/src/procpool.rs:1-40`:TicketArena → ProcessDispatcher →
N 个 WorkerHandle,stdio NDJSON 控制面 + shm FrameSlotPool 数据面;worker 崩溃
后已认领帧重新入队并重生进程(有界重启)。
- `crates/oak-worker/src/worker.rs`:子进程是单线程 NDJSON 循环,
`handle_render_batch_stream` 在循环线程上同步渲染整批 ticket——worker 内部
**没有**任何渲染线程,并行只来自进程池。
- `crates/oak-render/src/scheduler.rs`:预览帧调度(交错分片认领);自动缓存由
`autocacher.rs` 驱动。
### 2.2 帧传输是 CPU/shm,显示前再上传 GPU
- `crates/oak-app/src/oakui/renderops.rs:550-555`:`RenderedFrame` 只有
`CpuF32 { .. }` 与 `Shm(ShmFrameRef)` 两种——无论渲染在不在 GPU 上完成,
到上屏路径的都是 CPU 像素,显示时再经 staging 上传(M15 S2 已确认播放路径
主堆拷贝为 0,但 GPU↔CPU 的两次搬运仍在)。
- `crates/oak-core/src/texture.rs:183-202`:`Texture` 已有 `Gpu { token, backend,
ctx, .. }` / `Cpu(Frame)` 双变体,GPU 纹理自持 `Arc<dyn GpuContextLike>`——
**零拷贝改造的数据类型基础已经存在**,缺的是"全链路不把 GPU 纹理下载回来"
的管线。
### 2.3 解码在 worker 内随叫随做,硬解帧全部回读 CPU,无流水线
- `crates/oak-render/src/eval.rs:988-990`:解码器会话本就进程级共享
(按 (filename, stream) 键控、内部互斥),但解码动作发生在每个 worker 的
ticket 处理里:一帧的"解码→渲染→回读"是串行的,帧与帧之间没有重叠。
- `render_footage_frame`(eval.rs,FootageJob 的落点)同步解码一帧并缩放到
目标尺寸。
- **硬件解码已经存在,但硬件帧全部下载回 CPU**(详见 §2.7)——GPU 解码
这块砖已经有了,缺的是"不把砖搬回 CPU"的下半截。
### 2.4 resolve 四次扫描表
- `crates/oak-render/src/eval.rs:919-930`:`RenderEvalHooks::resolve` 依次调用
`resolve_plugin_jobs` → `resolve_footage_jobs` → `resolve_shader_jobs` →
`resolve_color_transform_jobs`(eval.rs:406/480/520/561)。每个函数各自
`for (_, value, _) in table.rows_mut()` 全表扫描,逐行用
`handle::get_checked::<T>` 探测载荷类型——一张表被遍历 4 遍,每行最多被
错误类型探测 3 次。
- `crates/oak-node/src/jobs.rs:35-40`:`Job` 枚举已存在(Footage/Shader/Plugin/
ColorTransform),但**节点并没有把 Job 枚举塞进表里**——表里塞的是裸载荷
box,靠 resolve 端逐类型猜测。注释自述"the graph-shaped job model lands in
v0.6"(jobs.rs:31-33)。
- `CacheJob` 缺失:`process_video_cache_job`(eval.rs:391-399)直接返回
"deferred" 错误,磁盘帧缓存加载没有 Job 表达。
### 2.5 OpenFX 在每个 worker 进程里渲染
- `crates/oak-worker/src/worker.rs:100-129`:OFX 渲染发生在 worker 进程内
(注释自述"crash isolation"),进度经 `plugin_progress` NDJSON 事件回报,
取消经主进程广播 `plugin_cancel`。N 个 worker = N 份插件实例宿主,插件崩溃
杀掉的是某一个 worker(procpool 重生),语义正确但宿主数量与位置都不是
设计出来的,是进程池的副产品。
- 插件失败时回退紫帧(eval.rs:386 `purple_frame`)的约定已存在。
### 2.6 原版 Olive 的参考点(upstream/app/...)
- `render/rendermanager.h:38-63`:`RenderThread`(QThread)内部一个
`std::list<RenderTicketPtr> queue_` + `QWaitCondition`;RenderManager 持有
**一个** video 线程、**一个** audio 线程、波形线程池、一个 dry-run 线程
(rendermanager.cpp:123-150 按类型派发)。
- `render/renderprocessor.cpp:137` `Run()`:一个 ticket 的处理全流程
(取参数 → GenerateTexture → 隔行处理 → 按需 GenerateFrame/写缓存 →
Finish)。
- `node/traverser.cpp:351` `ResolveJobs`:**单函数 job 分发**——先递归
resolve 子 job(job 的参数里嵌的 job),再按类型(CacheJob/
ColorTransformJob/ShaderJob/GenerateJob/FootageJob)调对应 Process*,
结果写回 value。这就是用户要求的"match + Job 枚举、单次循环"的范式。
- `render/job/*.h`:AcceleratedJob 基类 + 各 Job 的字段集(与我们 jobs.rs
的载荷一一对应,可对照补全)。
### 2.7 硬解已接通但逐帧下载(解码零拷贝的现状缺口)
- `crates/oak-codec/src/hwdecode.rs:17-42`:FFmpeg 8 的 hwaccel 模型已落地
——建平台硬件设备上下文(`av_hwdevice_ctx_create`)挂到软解码器上,
VideoToolbox(macOS)/ CUDA(NVDEC)→VAAPI(Linux)/ D3D11VA→CUDA
(Windows)按候选顺序尝试,软解兜底;`OAK_HWACCEL=0` 与配置项
`HardwareDecoding` 双开关;`HW_TRANSFERS` 计数器证明 hwaccel 真生效。
- **但每帧硬件表面都用 `av_hwframe_transfer_data` 下载到系统内存**再走
swscale(hwdecode.rs:41-42 自述)——这就是"解码零拷贝"要消灭的那次
下载。项目 FFmpeg 构建已带齐平台硬解
(`--enable-nvdec/vaapi/vdpau/d3d11va/dxva2/videotoolbox`,
tooling/ffmpeg/build-ffmpeg.sh)。
- 上游 Olive **没有硬解**(`.cache/olive-upstream/app/codec/decoder.cpp`
全文无 hw_device/hwaccel 引用,纯软解+上传)——本计划的 GPU 解码超出
Olive 的范围,参考对象改为 FFmpeg hwaccel API 与本仓库 hwdecode.rs 已有
的设备模型;FFmpeg 覆盖不到的路径才手写 GPU 解码(Vulkan Video /
直接 NVDEC 绑定,见 §3.6)。
## 3. 目标架构
```text
┌──────────────────────────── 主进程 ────────────────────────────┐
│ │
│ UI 线程(gpui) 调度层(manager/scheduler/autocacher) │
│ │ 消费上屏队列 │ 把播放/导出/缓存请求编成 ticket │
│ │ │ 投到 decode/render 队列 │
│ ▼ ▼ │
│ 上屏队列 ◄── present ── 渲染线程(唯一) ── render 队列 ◄──┐ │
│ (GPU 纹理) │ 持有唯一 GpuContext │ │
│ │ 图求值 + 全部 GPU pass │ │
│ ▼ │ │
│ decode 队列 ◄── 解码线程(唯一)─────────┘ │
│ │ 持有全部解码器会话+硬解设备 │
│ │ 硬解→GPU 导入(零拷贝,§3.6) │
│ │ 软解→staging 上传(仅 fallback) │
│ │
│ ┌─── 仅当链上出现 CPU OpenFX 特效时 ───┐ │
│ ▼ │ │
│ OFX 主机进程(唯一,oak-ofx-host) │ │
│ NDJSON 控制面 + shm/(后续)GPU 句柄数据面 │ │
│ 崩溃→有界重生+在途 job 重投 │ │
└────────────────────────────────────────────────┘
```
### 3.1 三个执行角色
1. **解码线程(唯一)**:独占全部解码器会话(eval.rs:988 的进程级会话表
顺理成章地归它所有——会话本就互斥,集中后连互斥都可以去掉)。从
decode 队列取 FootageJob 解码,**产出直接落在 GPU 上、与渲染共用同一
片 GPU 内存**:硬解帧(NV12/P010 等 YUV 表面)经平台互操作原样导入为
GPU 纹理(§3.6,零拷贝),**不执行** `av_hwframe_transfer_data`;
CPU 解码 + staging 上传只作为硬解不可用时的 fallback(沿用
`OAK_HWACCEL` / `HardwareDecoding` 开关,hwdecode.rs:56/102)。
硬解表面到工作色彩空间 RGBA 的转换不再走 CPU swscale,而是渲染线程
上的一个内置 "YUV→RGB" GPU pass(色度上采样 + 色彩矩阵着色器,
与 §3.5 的全 GPU 中间纹理一致)。连续播放时由调度层预取(§3.4)。
2. **渲染线程(唯一)**:独占进程唯一 `GpuContext`(`GpuContext::shared()`
之外的第二条获取路径收编到这里;texture.rs:178-181 的 Arc 自持模型
不变)。从 render 队列取"图快照 + 时刻"作业,跑图求值与全部 GPU
pass,产出 `Texture::Gpu` 投到上屏队列。
3. **主进程(上屏)**:UI 线程从上屏队列取 GPU 纹理直接显示(§3.5 的上屏
互操作)。导出/磁盘缓存等 CPU 消费者在队列出口处显式回读——回读只
发生在这些边界。
### 3.2 OpenFX 隔离进程(唯一)
- 新建 `oak-ofx-host` 可执行(或 oak-worker 的 `--ofx-host` 模式):进程内
加载全部 OFX 插件实例,渲染线程经 IPC 把 PluginJob 发给它,输入纹理
回读成 CPU 帧经 shm 传入,输出经 shm 传回后再上传 GPU。**这是全链路唯一
的计划内回读点**(仅当链上真有 CPU OFX 插件)。
- 崩溃处理复用 procpool 已验证的机制(procpool.rs:1-40:EOF/非零退出→
在途帧重新入队、进程重生、有界重启),但池大小恒为 1;重复失败回退
紫帧(沿用 eval.rs:386 约定)。
- `plugin_progress` / `plugin_cancel` 协议(worker.rs:100-129)原样搬运。
- GPU 型 OFX 插件(OpenGL/CUDA 上下文的)本期不支持直通,按 CPU 插件处理
(文档明示;真有需要时按 §3.6 的平台分支再开 GPU 句柄通道)。
> **M3 落地回填(2026-09-12)**:
>
> - 采用 **`oak-worker --ofx-host` 模式**(新增 `oak-worker/src/ofx_host.rs`),
> 不新增可执行目标,省掉打包/路径解析的分支。宿主启动即
> `install_render_executor` + `Host::cache.scan()`,按**插件标识符**
> (`shared_plugin_instance`,跨进程稳定)解析宿主本地实例。
> - 客户端在 `oak-render/src/ofxhost.rs`:`RenderManager` 在 **Pipeline
> 后端**创建并安装进程级客户端(首个个 PluginJob 才 spawn,空闲不占进程),
> `eval::process_plugin_job` 优先走它,失败/缺席再回退进程内 executor(进程池
> 后端在 M4 之前仍按现状在每个 worker 内渲染,见 §5 回退策略)。
> 数据面为输入/输出两个 `FrameSlotPool`(handshake 的 `input_*` 字段首次启用):
> 命名 clip + src 各写一个输入槽,插件输出写输出槽;槽容量/数量按 job 需求
> 自动扩容(重启一次宿主,无在途任务时安全)。
> - 崩溃:reader 线程 EOF 即判死,`submit` 在同一调用内**重生宿主并重投同一
> job**(输入帧只回读一次,重投不重复回读);连续
> `HOST_MAX_FAILURES=3` 次崩溃后熔断,`process_plugin_job` 落紫帧
> (`plugin_job_host_failure_yields_purple_frame` 直接断言)。
> - 进度/取消:宿主 reporter 即时 flush `plugin_progress`(不再是 worker 的
> 批末缓冲,进度条可实时更新);宿主用独立 stdin 线程,`plugin_cancel`
> 在该线程内直接置黏性 flag(渲染中也能生效),下次 `progressStart` 复位;
> `request_plugin_cancel_all` 同时广播给 worker 池与宿主。取消的生效粒度
> 取决于插件是否回调 progressUpdate(与现状契约一致)。
> - 验收测试(`oak-worker/tests/ofx_host.rs`,真实宿主 + 内置测试插件):
> 渲染与进度转发;`--ofx-crash-once` 崩溃后重投成功;`--ofx-crash-always`
> 连续三次崩溃后熔断(紫帧由 eval 单测覆盖);慢速测试插件
> (`org.oak.test-plugin.slow`,progressUpdate x20ms)上取消在途渲染;
> 并发 submit 由客户端互斥串行化。
### 3.3 队列与 ticket
- 对上层(oak-app/oak-cli)**ticket API 不变**:RenderManager 仍是唯一入口,
完成仍走 exactly-once 的 TicketPayload。
- 对内,ticket 变成队列项:三条 SPSC/MPSC 环(decode/render/present),
复用 `crates/oak-render/src/ipc.rs` 已有的无锁环实现;取消仍走
`cancelatom`(Olive 的 CancelAtom 对应物)。
- 优先级:交互(seek/单帧刷新)> 播放预取 > 导出 > 自动缓存。调度层
(scheduler.rs)从"分片给 N 个进程"改为"按优先级与依赖关系投队列"。
### 3.4 流水线作业
- 调度层维护播放方向的预取窗口(autocacher 已有近似逻辑):帧 N 在渲染
线程上跑时,帧 N+1..N+k 的 FootageJob 已在解码线程上解码。
- 依赖表达:FootageJob 先进 decode 队列;渲染线程遇到"输入纹理是未决
FootageJob"时不是同步解码,而是把该渲染作业挂起到依赖完成(依赖计数
到位后自动入 render 队列)——Olive 是同步解码(renderprocessor.cpp:325
ProcessVideoFootage 在渲染线程内解码),我们按用户要求做真流水线。
- **硬解帧的流水走向**:解码线程产出的硬件表面(YUV)以平台句柄形态
(DMA-BUF fd / D3D11 纹理 / CVPixelBuffer / CUDA 数组)经 §3.6 的互操作
导入为 GPU 纹理后投 render 队列;帧的生命周期由"持有 AVHWFramesContext
引用的 GPU 纹理包装"管理,渲染线程消费完(YUV→RGB pass 采样结束)即
释放回硬解帧池。全路径无 `av_hwframe_transfer_data`。
- 背压:三条队列都有界;上屏消费慢(暂停、窗口最小化)时 render 队列满 →
解码暂停预取;导出时上屏队列直通导出消费者,不存在"没人收"的积压。
> **M4 落地回填(2026-09-15)**:
>
> - 渲染队列按 `JobSchedule.priority` 排序(Seek > Playback > Background,
> 同类 FIFO);解码队列把 rendezvous `Request` 排在 `Prefetch` 之前
> (渲染要帧绝不排在推测解码后面),`Sync` 屏障保持 FIFO。
> - 播放预取:Pipeline 收到 Playback job 时立即按 montage/footage 推导
> `DecodeRequest`(媒体时间/尺寸/F32 与 eval 完全一致,footage 路径取
> `force_format.unwrap_or(F32)`),投给解码线程,于是帧 N 在渲染线程跑
> GPU pass 时解码线程已在解帧 N+1。`pipeline_prefetch_is_the_frame_the_render_request_uses`
> 用"停在解码前"的确定性时序断言 LRU 命中且只解一次;`pipeline_playback_prefetches_ahead_of_the_render`
> 只证明"每个 post 恰好投递一次预取、每帧只解一次"。
> - 上屏窗口:`PreviewWindow` 的槽位泛化为 `PreviewSlot::{Shm, Video}`,
> 线程管线的 `TicketPayload::Video` 直接入窗、由 `cpu_frame` 消费;
> `PipelineBackend::preview_window_capacity` 返回渲染队列余量,播放提交
> 被限制在余量内不阻塞 UI;Seek 提交允许**超额插队**(队列满时也立即
> 返回,插到 Background/Playback 之前),因为优先级排序只对已入队的 job
> 生效——若 Seek 被挡在门外,UI 会等一个导出/缓存帧跑完。`cancel_preview_frame`
> 丢弃已过 playhead 的排队帧,按 `(sequence, frame, version)` 全键匹配
> (两个监视器可同帧号同版本,只按帧匹配会误杀另一序列)。
> - 自动缓存优先级修正:`PreviewAutoCacher::start_range_job` 改走
> `submit_video_background`(Background),不再以 Seek 插到播放之前——
> 否则 Seek 超额插队会让逐张 autocache 帧绕过队列容量约束、倒挂
> §3.3 的"交互 > 播放 > 导出 > 自动缓存";`single_frame` 交互帧保持
> Seek(`range_jobs_are_background_and_single_frames_stay_seek`)。
> - 取消即停(审计 B):`Job.cancelled` 钩子由 arena 从 slot 的 cancel 原子
> 安装,`execute_job` 在生产前检查——`TicketArena::cancel` 之后仍未开跑的
> 帧直接以 `Error::State` 完成,不再烧一遍 GPU/CPU 再丢弃;交付仍是
> exactly-once(`pipeline_skips_a_cancelled_job` 断言 producer 未运行)。
> - 双份帧缓存(审计 C):eval 内层 `decoded_frames`(24 帧)才是缓存主拷贝,
> `DecodeService` LRU 只是解码线程→渲染请求的交接缓冲,已由 8 帧降到
> `DECODE_LRU_CAP=2`;1080p F32 下副缓存常驻 ~8×33MB → ~2×33MB,交接
> 未命中由 eval 缓存兜底(不再解码,只多一次拷贝)。彻底合并两份缓存需
> 让 Texture/缓存持有共享 Arc 帧,另行立项。
> - **基准对比**(本机 release,`oak-render/examples/bench_playback`,
> 1080p/25fps MPEG-2 源、240 帧@480p(853×480) / 128 帧@1080p;两个后端
> 统一输出 F32 帧、同尺寸,否则进程池的 BGRA8 槽位与管线的 in-process
> F32 帧字节量/路径不同,数字不可比):
>
> | 用例 | fps | 首帧延迟 | CPU(user+sys) |
> |---|---|---|---|
> | processes 480p(17 worker) | 48.3 | 1416 ms | 23.6 s |
> | pipeline 480p | 78.6 | 127 ms | 3.0 s |
> | processes 1080p(4 worker) | 49.2 | 396 ms | 12.0 s |
> | pipeline 1080p | 28.5 | 161 ms | 4.5 s |
>
> 结论:线程管线在代理尺寸下全面胜出,首帧延迟低一个数量级;1080p 满幅
> 的峰值吞吐低于多 worker 进程池(单解码线程 vs 4 个并行 worker),但
> CPU 低约 2.7×、首帧快约 2.5×,且 28 fps 足够覆盖 24/25 fps 实时播放。
> 按 M4 验收口径记录:CPU 不升、首帧不劣化成立;"fps 不低于"在 1080p
> 峰值吞吐上不成立(代理播放成立)。
>
> "main-heap frame copies" 只统计进程池 shm 路径(管线不经过它),两边
> 的 0 都不构成"管线无堆拷贝"的证据——in-process 帧在 eval 缓存与
> service LRU 边界仍有 `Frame.data` 深拷贝(M5 收窄),文档与测试不得
> 再宣称管线零堆拷贝。
>
> 根因是**这一刻解码仍是 CPU 软解**(M5 才上硬解):进程池的 4 个 worker
> 各自跑一个软解器并行解帧,线程管线按 §3.1 只有一条解码线程。**不要**
> 为此把解码线程池化:GPU 硬解单元(NVDEC/VAAPI/VideoToolbox)是单设备
> 单提交队列(§1 的出发点),§3.6 的零拷贝导入还要求"解码表面与渲染共
> 用同一片 GPU 内存",多解码线程只会争同一队列与显存池。M5 落地后单
> 解码线程就是正确形态。提前提升软解吞吐的正确方向是让 FFmpeg 解码器
> 自身多线程(frame/slice threading),而不是并列多个解码线程——本期
> 不立项。
### 3.5 GPU 零拷贝与上屏
- 内置效果全 GPU:图求值产生的中间纹理全部是 `Texture::Gpu`,整个 resolve
过程不分配 CPU 帧;`generate_frame`(eval.rs:941)这类 CPU 帧生产点改为
GPU 清屏。
- 上屏:gpui 自身用 wgpu 渲染。**首选**让预览画布与渲染线程共用同一
wgpu device(gpui 暴露 device/queue 的途径需调研 gpui 侧 API;若不可行,
次选方案是渲染线程把帧 blit 进一块与画布共享的纹理,或退一步用现有的
staging 上传但只此一处)。这一条的落地细节作为 M2 的第一个技术攻关点,
攻关结论回填本小节。
- CPU 边界的显式回读点只有三处:CPU OFX 插件(§3.2)、导出编码器输入、
磁盘帧缓存写入(FrameHashCache::SaveCacheFrame 对应物)。
> **M2 攻关结论(2026-09-11 回填)**:共享 device 路线可行且已落地,前提是
> **引擎与 gpui 统一到同一个 wgpu 大版本**。攻关发现:
>
> 1. gpui 的 `Window::gpu_context()`(Linux/FreeBSD)确实暴露窗口的
> `(Arc<wgpu::Device>, Arc<wgpu::Queue>)`,但 vendored gpui_wgpu 用的是
> **wgpu 29**,而引擎此前是 **wgpu 25**——两个大版本的 `wgpu::Texture`
> 是不同类型,纹理无法跨越。M2 把 `oak-core`/`oak-render` 升到
> **wgpu 29 + naga 29**(`backend.rs` 的 8 处破坏性 API 改动;其余代码
> 只经 `GpuContext`),版本鸿沟消除。
> 2. `GpuContext::adopt(device, queue, kind)` 采用宿主 device;
> `register_context`(`oak-app/src/oakui/gpu.rs`)在窗口建立时调用
> `install_shared` 把它装进进程级 shared 槽,渲染线程因此在预览器同一
> device 上出帧。`GpuContext::texture_handle` 把引擎纹理的
> `Arc<wgpu::Texture>` 交给 gpui 的 `SurfaceSource::Texture`,上屏零拷贝。
> 3. **色彩管理必须留在链上**:GPU 路径不能跳过 output node 与显示器 ICC。
> 做法是把「工作空间 → 输出规格(`colormath::working_to_display_target`)
> → 显示器 ICC(`displaycolor::apply_f32_rgba`)」在 CPU 上用**原有精确
> 实现**烘焙成 65³ 3D LUT(`oak-core::lut::Lut3d`,域
> `[-0.25, 4]³`),经 `GpuContext::set_display_lut` 上传为 GPU 3D 纹理,
> 由 `present_texture` 的 WGSL pass 做手工三线性插值(不依赖
> `FLOAT32_FILTERABLE`)。设置/显示器/ICC 变化(`displaycolor::generation`
> 或项目色彩设置)时重建。CPU 路径的 `apply_f32_rgba` 一行未动,GPU 与
> CPU 逐点一致(测试对拍,f16 输出量化内)。同一套 LUT 机制也用于图内
> `ColorTransformJob`:`process_color_transform_job` 对 GPU 纹理把 OCIO
> processor 经 CPU 参考烘焙成 3D LUT,用 `GpuContext::apply_color_lut`
> 在 GPU 上应用(`color_transform_lut` 按 processor cache id 缓存),不再
> pass-through;只有无法烘焙时才走一次显式回读。
> 4. **平台边界**:macOS/Windows 的 gpui 暂不暴露 device(macOS 走
> `oak_bridge` IOSurface 的未来路线,见 acescg 计划 P2),此时
> `present_gpu_frame` 返回 `None`,`to_display` 走**单点显式回读**
> (唯一一次 download,之后仍 CPU 上传到 gpui)。Linux 上若引擎
> device 不是 adopted(例如进程池 worker 的独立 device),同样回退这条
> 单点路径。CPU OFX、导出编码器、磁盘缓存三处边界显式回读不变。
> `install_shared` 对「引擎已创建但尚未创建任何 GPU 资源」的上下文
> 允许被 UI 设备**替换**(时序守卫:开窗前任何 `shared()` 触碰都不会
> 静默丢掉零拷贝上屏),用过的上下文拒绝替换并记一条错误日志。
> 5. 进程池后端(默认)仍在 worker 内完成图求值后**显式回读**成 shm 槽
> (oak-worker/worker.rs 的 graph 分支),这是进程模型的必然;M4 通过
> 验收前进程池仍是默认,线程管线(`OAK_PIPELINE=threads`)才走上述
> 零拷贝上屏。
> 6. 已知取舍:GPU 帧没有 `Frame::timestamp`(GPU 路径不需要);scopes/
> 取色器的 CPU 兜底图是 1×1 占位(需要时可作为显式回读点按需填充);
> 上屏每帧新建一张 `Rgba16Float` 目标纹理(与 gpui 的取帧生命周期一致,
> 后续可做纹理环)。
### 3.6 平台互操作分支(解码零拷贝与上屏,用户硬性要求)
**解码必须尽可能 GPU,并与渲染共用同一片 GPU 内存;CPU 解码后上传只作为
fallback。** 解码上传与上屏共用一层 `gpuinteop` 抽象,按后端分支实现。
**解码实现优先 FFmpeg 硬解**(hwdecode.rs 的设备模型 + 下表的导入路径),
**FFmpeg 覆盖不到的才手写 GPU 解码**(Vulkan Video 扩展 / 直接 NVDEC
绑定——仅当某种编码或平台组合 FFmpeg 没有 hwaccel 时立项,不提前写):
| 平台/后端 | 硬解(FFmpeg hwaccel)→ GPU 导入(零拷贝) | fallback | 上屏/外部共享 |
|---|---|---|---|
| Linux + NVIDIA | NVDEC(CUDA 设备)→ cuArray → CUDA-Vulkan 外部内存互操作(`cuImportExternalMemory` 系)导入 wgpu 纹理 | 软解→staging 上传 | wgpu 同源纹理直通 |
| Linux + AMD/Intel | VAAPI → VASurface → DMA-BUF fd → Vulkan 外部内存(`VK_EXT_external_memory_dma_buf` + `VK_EXT_image_drm_format_modifier`) | 软解→staging 上传 | 同源直通;跨进程必要时 DMA-BUF fd |
| Windows | D3D11VA → D3D11 纹理 → DXGI shared HANDLE(NT 句柄)导入 wgpu(DX12/Vulkan 后端均可);NVDEC 候选经 CUDA 互操作 | 软解→staging 上传 | D3D11 shared HANDLE / DXGI |
| macOS | VideoToolbox → CVPixelBuffer → IOSurface → Metal 纹理(wgpu Metal 后端直接包裹) | 软解→staging 上传 | IOSurface 共享 |
| OpenGL(遗留/调试用) | 不做硬解导入 | 软解→glTexSubImage2D | 仅调试后端 |
要点:
- 硬解表面通常是 YUV(NV12/P010),**导入后不是 RGB**——YUV→RGB(色度
上采样 + BT.601/709/2020 矩阵 + 全/窄范围)是渲染线程上的内置 GPU
pass(着色器),替代今天的 CPU swscale;HDR 元数据随纹理流转。
- 导入失败(驱动缺 modifier、句柄类型不支持等)按帧回退到
`av_hwframe_transfer_data` + staging 上传,单帧失败不污染后续帧。
- 实施顺序:staging fallback 先行(正确性基线,任何平台都可跑),然后按
Linux(NVDEC/VAAPI) → Windows(D3D11VA) → macOS(VideoToolbox) 的顺序
落地零拷贝导入,每行一个独立 PR、独立开关、可单独回退。
> **M5 启动回填(2026-09-15)**:
>
> - **步骤 0(staging fallback 基线)已具备**:`oak-codec/src/hwdecode.rs`
> 的设备候选/可用性缓存/双开关(`OAK_HWACCEL=0`、配置
> `HardwareDecoding`),`ffmpeg.rs:956-964` 的硬解帧
> `av_hwframe_transfer_data` → swscale → F32 CPU 帧,`HW_TRANSFERS`
> 计数证明 transfer 发生;软解路径该计数恒 0;M2 的 YUV→RGB GPU pass
> 已就绪。oak-codec 141 测试绿。**要消灭的就是那条 transfer+swscale。**
> - **首个导入 PR(Linux VAAPI/NVDEC → wgpu)**:解码线程在
> `transfer_to_cpu` **之前**调用新的 `gpuinterop::try_import_hw_frame`;
> 成功则直接产出持有硬件表面的 `Texture::Gpu`(YUV 平面 + 色彩元数据,
> 交给 M2 的 YUV→RGB pass),失败按帧回退 staging(单帧失败不污染后续)。
> 计数器:`HW_IMPORTS`(导入成功数)与硬解路径 `HW_TRANSFERS==0`;
> 开关逐平台独立 + `OAK_HWACCEL=0` 全软解;开/关对比测试断言像素一致。
> VAAPI 走 DMA-BUF fd + `VK_EXT_external_memory_dma_buf`,NVDEC 走
> CUDA-Vulkan 外部内存导入;需要 `wgpu` raw Vulkan 设备(`as_hal`)+
> 格式 modifier 协商,失败即回退。
> - **本机开发环境**:RTX 5070 Ti(NVDEC/CUDA)+ 第二块 `/dev/dri`
> 设备(VAAPI),FFmpeg 支持 cuda/vaapi/vulkan —— Linux 两条导入路径
> 可真机验证;CI(lavapipe,无 hwaccel)只跑 fallback、计数器与
> 接口单测。
> **M5 落地回填(2026-09-17)**:
>
> - **数据面**:`Texture` 新增瞬态变体 `Texture::Planar`(`PlanarFormat`
> = NV12/P010,携带 luma/chroma 两个 registry token、YUV 矩阵/范围、
> 源 primaries/trc 与 keep-alive guard);`GpuContext::run_planar_yuv_to_rgb`
> 是 M2 三平面 pass 的双平面(NV12/P010)变体,8 位输入的窄范围展开由
> `YuvTransform::from_matrix_depth(matrix, full_range, 8)` 精确给出
> (16 位构造与原公式逐位一致)。
> - **解码侧**(`oak-codec/src/gpuinterop.rs`):`try_import_hw_frame` 在
> `transfer_to_cpu` **之前**尝试导入;计数器 `HW_IMPORTS` /
> `HW_IMPORT_FALLBACKS`,硬解导入路径 `HW_TRANSFERS` 保持 0;连续 8 次
> 失败后进程内熔断(单帧失败不污染后续帧)。开关:全局
> `GpuDecodeImport` + `OAK_GPU_IMPORT=0`,逐平台
> `GpuDecodeImportVaapi` / `GpuDecodeImportD3d11` /
> `GpuDecodeImportVideotoolbox`(各自 `OAK_GPU_IMPORT_*` 环境变量)。
> **只有 host 安装的共享设备**(`GpuContext::host_gpu_installed`,即 app
> 的上屏 device)才走导入:worker/CLI/测试里惰性创建的私有 device 继续
> CPU 路径,避免把进程池/导出悄悄拉上 GPU。零拷贝只服务原生尺寸 + F32
> 请求(不能缩放/改格式);代理尺寸等按帧回退 staging(解码器会话缓存
> 复用同一帧,回退不重解)。
> - **平台实现**:
> - **Linux(真机验证)**:VAAPI → `av_hwframe_map`(DRM_PRIME) → 每层
> dup fd + `VkImportMemoryFdInfoKHR`(`VK_EXT_external_memory_dma_buf`
> + `VK_EXT_image_drm_format_modifier`,显式 plane layout)→
> `wgpu_hal::vulkan::texture_from_raw` + `create_texture_from_hal`;
> 导入的 VkImage/内存由 wgpu-hal 的 drop callback 释放,AVFrame 的引用
> 作为 guard 一并持有到 GPU 用完。NVDEC/CUDA 表面没有公开句柄导出,按
> 帧回退 staging;因此 Linux 设备候选顺序改为 **VAAPI 优先、CUDA 兜底**
> (§3.6 的"优先通用 DMA-BUF"),本机 RTX 5070 Ti + nvidia-vaapi-driver
> 实测 NV12 block-linear modifier 导入可用。
> - **Windows(cfg 隔离,待 CI 编译验证)**:D3D11VA 纹理经
> `IDXGIResource1::CreateSharedHandle` 取 NT 句柄 → 手建多平面
> NV12/P010 VkImage(`VK_KHR_external_memory_win32` 导入句柄)→
> 同一纹理注册 `Plane0`/`Plane1` 两个 token;COM 侧在 oak-codec,
> Vulkan 侧在 oak-core。
> - **macOS(cfg 隔离,待 CI 编译验证)**:VideoToolbox 的 CVPixelBuffer
> → `CVPixelBufferGetIOSurface` → `newTextureWithDescriptor:iosurface:plane:`
> 两个平面 Metal 纹理 → `wgpu_hal::metal::texture_from_raw`;Metal 纹理
> 无 drop callback,帧引用经 `ImportedHwFrame::keep_alive` 挂在
> `PlanarTexture` 上随纹理释放。
> - **渲染侧**(`oak-render/eval.rs`):`render_footage_frame` 把 planar
> 变体在渲染线程解析为工作空间 RGBA —— YUV→RGB pass + 源→工作空间变换
> (legacy sRGB 直通;ACEScg 下按 (primaries, trc) 把
> `colormath::decode_to_acescg` 烘焙成 3D LUT 在 GPU 应用,缓存 16 个);
> 解析失败按帧回退 CPU staging(import 关掉重解一次,解码器缓存兜底)。
> 图/蒙太奇/导出等消费者只会看到 `Gpu`/`Cpu`。
> - **一个必要修复**:ffmpeg-next 的 `Video::clone` 用 `av_frame_copy` 深拷
> 数据,**不会**复制硬件表面引用(克隆的 VAAPI 帧 transfer 报 EINVAL)。
> 硬解帧缓存改用内部 `RefFrame`(`av_frame_ref` 语义)持有。
> - **验收**:`oak-render/tests/footage_import_test.rs`(真机 RTX 5070 Ti):
> 导入路径 `HW_TRANSFERS==0`、结果 `Texture::Gpu`、与 staging 逐像素
> max diff < 0.15(swscale 色度滤波差异,同硬解/软解测试口径);ACEScg
> 下与 CPU 精确变换 max diff 0.011(LUT 三线性插值);已知红蓝图案断言。
> `gpuinterop` 单测覆盖"无 host device 不导入/开关关闭/位深";oak-core
> 单测覆盖 8/16 位变换系数;montage 原生尺寸 + host GPU 回归测试见下。
> 全 workspace `cargo test --workspace` 全绿(全部测试套件)。
> - **遗留(M5 未完全关闭)**:Windows(D3D11VA 共享句柄)/macOS
> (IOSurface)两行是 cfg 代码,**从未在对应平台编译过**;host 可达性
> 与 Windows 数组 slice/feature 门已在桌面审查后修复(见下面"审计修复"
> 第 4 条),但平台编译与真机硬件测试仍待补;P010 与 HDR 元数据未单独
> 实测;代理尺寸的 GPU 缩放导入(省掉 native→proxy 的回退)未立项。
> 本节的"阶段性验收"不等于 M5 收官。
> **M5 审计修复(2026-09-17)**:对 M5 落地的自审发现并修复以下问题:
>
> 1. **montage 静默黑帧(严重,已实机复现并修复)**:`render_footage_frame`
> 可返回解析后的 `Texture::Gpu`,而 CPU 蒙太奇合成器只接
> `Texture::Cpu`,其余 `_ => continue` 直接跳过该 clip——序列尺寸 ==
> 素材原生尺寸 + host GPU 已装 + 硬解 + 线程管线(导出最常见情形)时
> 整条序列输出全透明黑且无任何报错。修复:新增
> `render_footage_frame_staged`(`DecodeRequest.allow_import=false`
> 进 cache key),montage 一律走 staging;合成器遇到意外的 `Gpu`
> 纹理显式回读而不是丢弃,`Planar` 则报错。回归测试
> `footage_import_test::montage_native_size_with_host_gpu_composites_the_clip`
> (先按单素材原生尺寸导入并缓存 planar,再合成 montage,断言图案 +
> "无新导入 + 发生 staging transfer")。
> 2. **计数器/熔断语义**:NVDEC 设备、平台开关关闭、不可导入的表面布局
> 原本都被记成"失败"并喂给连续失败熔断(NVDEC 8 帧后熔断、
> `HW_IMPORT_FALLBACKS` 无界增长)。现在平台导入是三态结果:
> `Imported / Unsupported / Failed`;只有真正尝试后失败才计
> `HW_IMPORT_FALLBACKS` 并参与熔断,新增 `HW_IMPORT_UNSUPPORTED`
> 单独观测(含单测)。
> 3. **硬件表面驻留**:eval 帧 LRU 只允许最多
> `MAX_CACHED_PLANAR_FRAMES = 4` 个 planar 条目(每张经 keep-alive
> pin 一个解码器表面);淘汰只丢弃导入,解码器会话缓存仍在,不重解码。
> 4. **平台状态如实标注 + 桌面审查修复(2026-09-17 二次)**:
> Windows/macOS 两行仍未在对应平台编译;符号级复核(对照本机 registry
> 里的 windows 0.62.2 / objc2-metal 0.3.2 / ash 0.38)发现并修复:
> - **macOS**:IOSurface/Metal 路径符号全部匹配(`RG8Unorm`/`RG16Unorm`
> 命名无误——审查中"应为小写 Rg"的说法经源码核对不成立);
> `newTextureWithDescriptor_iosurface_plane`、`texture_from_raw`
> 六参签名、`MTLStorageMode::Shared`、P010 MSB 容器与
> `from_matrix_depth(16)` 均核对一致。**仍未编译/实测**。
> - **Windows 三重失效**(能编译但不可用)已修:
> i. **不可达**:`install_shared` 唯一生产调用点(adopt gpui 设备)
> 只在 Linux/FreeBSD 编译,Windows/macOS 上 `host_gpu_installed()`
> 恒 false,导入路径是死代码。新增
> `GpuContext::mark_host_context()` + `oakui::gpu::register_engine_context()`:
> 非 Linux 平台的窗口建立时把引擎自建的 shared 上下文登记为 host
> 设备(worker/CLI/测试不调用,保持 CPU 路径)。
> ii. **feature 门互斥**:host 设备现在是引擎 `create()` 建的,
> 会按适配器能力请求 `TEXTURE_FORMAT_NV12/P010`;将来若仍是
> adopt 设备而缺 feature,`import_d3d11_shared_texture` 的
> State/Invalid 错误现在归类为 `Unsupported`(不计数、不熔断)。
> iii. **array slice 丢帧**:FFmpeg D3D11VA 帧是表面数组的 slice
> (`data[1]` 在 0..ArraySize-1 循环),原实现只收 slice 0。
> 现在导入按 `arrayLayers = ArraySize` 建 VkImage,registry 条目
> 携带 `layer`(`GpuTexture.layer`),采样视图用
> `base_array_layer`/`array_layer_count: 1` 选中该 slice;视图必须
> 显式 `dimension: D2`(wgpu-core 按纹理层数推导 D2Array,不看
> view range,否则与 planar pass 的 D2 布局校验冲突);UV token 的
> 登记尺寸用 chroma 半尺寸(planar pass 由它推采样比例)。
> - **二次桌面复核追加修复**:`DmaBufPlane` 的 re-export 曾漏 cfg
> (非 Linux 上 `cargo check` 直接 E0432);`import_iosurface_plane`
> 改为 `unsafe fn`(解引用调用方裸 IOSurface 指针,附 `# Safety`)。
> 平台编译 + 真机实测仍是 M5 关闭的前置。
> 5. **口径与小项**:"94 个测试目标"改称"全部测试套件";P010/HDR 未实测;
> Vulkan `initial_layout: UNDEFINED` 的首次 transition 属驱动依赖行为
> (本机正确,异驱动出伪影时优先排查这里)。
### 3.7 resolve 重写(M0a,独立先行)
对照 upstream `node/traverser.cpp:351 ResolveJobs`:
1. **节点往表里塞 `Job` 枚举**,不再塞裸载荷:jobs.rs 的 `Job` 补全为
`Footage / Shader / Plugin / ColorTransform / Cache`(CacheJob 载荷:
缓存路径 + 时刻 + 回退纹理,对应 eval.rs:391-399 目前 deferred 的
`process_video_cache_job`;Generate/Sample 暂不立 Job——生成器走
ShaderJob 变体、音频走 SampleBuffer 直通,评审时如需再补)。
2. `RenderEvalHooks::resolve` 改为**单次循环**:逐行取载荷→一次
`match` 分发到 `process_footage_job / process_shader_job /
process_plugin_job / process_color_transform_job / process_cache_job`;
每个 process_* 在消耗自己的子值前**递归 resolve 子 job**(Olive 的
`for subval: ResolveJobs(subval)` 范式,traverser.cpp:362-366)——
例如 ShaderJob 的 effect 输入里嵌着 FootageJob 时先解码再跑 pass。
3. 类型探测从"每行最多 4 次 get_checked 试错"降为"一次枚举判别"。
4. 现有四个 `resolve_*_jobs` 函数的逐类型逻辑原样搬进对应 process_*,
行为不变(本步是纯结构改造,现有测试全部应无修改通过;新增
CacheJob 的磁盘加载测试)。
### 3.8 Job 图:虚拟输入/输出节点与广度优先求值(用户硬性要求)
节点图的求值不再把"每个节点往一张线性表里 push 值、resolve 扫表找活干"
当作模型(jobs.rs:31-33 自述的临时形态、§2.4 的四次扫描即其症状),而是
**Job 图即图**:
1. **图存储是货真价实的邻接结构**:求值以 `oak_node::graph::Graph` 的
节点/连接为骨架,节点把 `Job` 枚举(§3.7-1 补全后的)挂在自己的输出
上;没有"全图拍平成表"的中间形态,也没有二维数组。允许**非全连通**:
与输出无关的分支不参与成帧(下述活集规则)。
2. **每个项目图固定一对虚拟节点**:
- `GraphInput`(虚拟输入节点):BFS 的唯一**起点**。它自身不产生
像素——它是"图的数据入口"语义(单帧单 clip 时其后挂素材/生成器;
序列合成时由轨道合成结构向其馈入)。只有输出端口。
- `GraphOutput`(虚拟输出节点):所有终末分支的**汇聚点**,它的值
就是这一帧。只有输入端口。
- 两节点**默认相连、不可删除、不可复制**(图模型层强约束:新建图
自带这对节点;`remove_node`/`duplicate` 对它们拒绝;序列化把它们
作为图的固定端点写入/读出)。
- **要在节点编辑器里显示出来**(`crates/oak-app/src/panels/node_editor.rs`):
与普通节点同等的渲染与连线交互,但禁删、禁复制、禁改名;样式上
与真节点区分(固定标题/图标),连线规则校验(输入节点不接受入线、
输出节点不接受出线)。
3. **resolve = 从输入节点出发的广度优先搜索**:每个项目/序列的求值以
`GraphInput` 为根做 BFS,出队一个节点就用一次 `match`(§3.7-2 的
五个 process_*)处理它挂出的 Job,**直到全部分支汇聚于 `GraphOutput`
为止**。遍历规则:
- **多输入汇合**:入度到零才出队(Kahn 形态的 BFS)——一个节点的
全部输入都 resolve 完毕它才进入处理队列;这保证汇合节点拿到的
每一路输入都是成品纹理。
- **多输出分叉**:一个节点的输出可以喂多个后继,沿邻接边自然扇出;
后继各自按自己的入度等待。
- **顺序语义**:出队序即 Job 处理序,且对同一图是确定性的(邻接按
连接建立序迭代)——用户明确要求"节点被应用的先后顺序可能影响
最终画面",该顺序由图结构显式表达,而非由表扫描次序隐含。
- **环**:visited 集防死循环;发现回边时报错并断开该分支(图模型
现有约束本就不鼓励环,此处把行为写成明文)。
- **活集**:正向(从 GraphInput)可达 ∩ 反向(从 GraphOutput)可达
的节点才是活集;不可达节点从不入队(非全连通图的天然剪枝),
正向可达但到不了输出的分支是可选的二次剪枝(先求正确,再求省)。
4. **与 resolve 重写的合流**:§3.7 的单循环 match 是本 BFS 的"出队即
处理"循环体;M0 分两步走——先在现有线性表上完成枚举化与单循环
(M0a,纯结构改造、独立可验收),再把表升级为图、引入虚拟端点与
BFS(M0b,见 §4)。上游 Olive 没有虚拟端点概念(其遍历从 viewer
输出节点倒推),这一对端点是 Oak 自己的设计,语义对齐用户的
"从输入开始、汇聚于输出"。
## 4. 里程碑
| 里程碑 | 内容 | 验收 |
|---|---|---|
| **M0a Job 枚举化 + 单循环 resolve** | §3.7 全量:Job 枚举补全(含 CacheJob)并挂进输出表、resolve 单循环 match、子 job 递归 resolve | 全 workspace 测试绿;新增 CacheJob 磁盘缓存往返测试;`resolve_*_jobs` 四函数删除 |
| **M0b Job 图 + 虚拟端点 + BFS** | §3.8 全量:图固定 GraphInput/GraphOutput 虚拟节点(默认相连、禁删禁复制、序列化往返)、节点编辑器显示两节点、resolve 改为从输入节点的 Kahn 形态 BFS | 新增测试:多输入汇合等齐全部输入、多输出分叉各自成帧、非全连通图不可达节点不执行、环报错断支、虚拟节点删除/复制被拒、序列化往返后端点仍在;节点编辑器 UI 测试(端点可见、入线/出线规则);既有测试全绿 |
| **M1 线程管线骨架** | 解码/渲染/上屏三线程+三队列进 oak-render(`pipeline` 模块);RenderManager 增加线程后端,进程池后端保留,`OAK_PIPELINE=processes` 可回退 | 同一套渲染测试在两个后端下都绿(测试矩阵化);播放/seek/导出 smoke 等价 |
| **M2 GPU 零拷贝** | 图内全程 `Texture::Gpu`(合成/转场/调整层不再逐帧回读);wgpu 29 统一 + 采用 gpui device(§3.5 攻关已回填);GPU 色彩管理(工作空间→输出规格→显示器 ICC 烘焙 3D LUT,GPU 执行);导出/缓存/OFX 三处边界显式回读;内置 YUV→RGB GPU pass(M5 解码导入的依赖项,解码接线随 M5) | 图播放路径 **GPU→CPU 回读为 0**(`oak_core::backend::gpu_transfer_counters` 计数断言,M1 帧缓存范式);`RenderedFrame::Gpu` + `to_display` 上屏在 adopted device 上零拷贝(app 测试);YUV→RGB pass 与 `colormath::yuv444p16_to_rgb_f32` 对拍;全 workspace 测试绿 |
| **M3 OFX 独立进程** | `oak-worker --ofx-host` 单进程宿主 + `oak-render/ofxhost` 客户端(Pipeline 后端安装,首 job 惰性 spawn);PluginJob 经 NDJSON + 输入/输出 shm 槽;崩溃重生+在途 job 重投+三次熔断紫帧;`plugin_progress`/`plugin_cancel` 搬运(宿主即时 flush) | 植入确定性崩溃钩子:`--ofx-crash-once` 杀掉宿主 → 在途 job 重投成功;`--ofx-crash-always` 连续三次崩溃 → 客户端熔断、eval 紫帧;进度事件(含 0.5/1.0)到达 app 回调;取消 flag 语义单测(`oak-worker/tests/ofx_host.rs` + eval/ofx_host 单测) |
| **M4 流水线预取** | 渲染队列优先级(Seek>Playback>Background);Playback job 投递即预取 Footage 解码(解码队列 Request 优先于 Prefetch);`PreviewSlot` 泛化让线程管线入窗;队列余量作播放背压 + Seek 超额插队保证 UI 不停摆 | 确定性单测:停在解码前的时序下预取命中 LRU 且只解一次(可证伪"读前于渲染");Seek 在队列满时超额插队并按序优先执行;`cancel_preview_frame` 按 (sequence,frame,version) 全键匹配;播放预取 smoke(prefetches==decodes==帧数);`bench_playback` 双后端同格式(F32)对比留档(§3.4 回填:CPU 与首帧不劣化成立;1080p 峰值吞吐低于多 worker 池,代理尺寸胜出) |
| **M5 GPU 解码零拷贝** | §3.6 表逐行落地:staging fallback 基线 → Linux NVDEC/VAAPI 导入 → Windows D3D11VA 导入 → macOS VideoToolbox 导入;FFmpeg 无 hwaccel 的组合才评估手写 GPU 解码 | 硬解路径 `HW_TRANSFERS` 计数归零(不再下载);逐平台导入开/关对比测试;每行独立 PR 可回退 |
依赖关系:M0a 独立;M0b 依赖 M0a;M1 依赖 M0a+M0b;M2 依赖 M1;M3 依赖
M1(与 M2 可并行);M4 依赖 M2;M5 依赖 M2(YUV→RGB pass 与互操作抽象),
可拆成并行子项。
**全程验收(用户要求)**:M5 完成、整个任务收官后,跑一轮**分支覆盖率**
(branch coverage)测量并留档,作为管线改造整体的质量闸门。
**阶段性测量已完成(2026-09-17,审计修复之前)**:nightly
`-Zcoverage-options=branch` 全 workspace 采集,项目源码(排除 vendored
gpui/ocio/标准库)TOTAL = 区域 53.60% / 函数 49.90% / 行 53.15% /
**分支 27.94%**;关键文件分支覆盖率:`pipeline.rs` 45.37%、`backend.rs`
45.51%、`texture.rs` 54.17%、`eval.rs` 43.53%、`gpuinterop.rs` 50.00%
(硬件导入的真机分支;平台错误回退分支单机走不到)。完整报告与采集/合并
命令见 `docs/zh/plans/render-pipeline-threads-m5-branch-coverage.txt`。
**该轮采集早于 2026-09-17 审计修复(montage/计数器/驻留),且
Windows/macOS 行尚未在对应平台编译——它是阶段性质量快照,不构成 M5
收官证明;M5 关闭以平台编译与真机平台测试完成为准,届时重测。**
另经 2026-09-17 复查:该留档的多对象合并被**陈旧测试二进制**拉低
(同文件多对象 51% vs 干净对象 95%),干净重测的基线是
**行 81.72% / 分支 55.45%**(见 `test-coverage-90-80.md` §4.1 勘误);
覆盖率门禁以干净对象集为准。
## 5. 不变量与边界
- **不动**:ticket API(oak-app/oak-cli 无感)、撤销/重做、缓存磁盘格式、
OFX 插件 ABI、CI 的 OFX fixture 探针(ci.yml 的
scan_probe 流程)。**例外(M0b 明确改变的两处)**:图模型新增
GraphInput/GraphOutput 固定端点,序列化格式随之携带这对端点(旧工程
载入时自动补挂,等价于一次无损迁移,配套序列化往返测试)。
- **回退开关**:线程后端落地期间进程池后端完整保留,`OAK_PIPELINE` 环境
变量 + 配置项双开关;M4 验收通过前进程池仍是默认。硬解导入逐平台
独立开关(§3.6),`OAK_HWACCEL=0` 一键回到全软解。
- **线程亲和**:wgpu device 可在专用线程独占使用(Device/Queue 均 Send);
渲染线程是唯一触摸 `GpuContext` 的线程;硬解帧池与硬件设备上下文
(AVHWDeviceContext)归解码线程所有,硬件表面经互操作导入后才交给
渲染线程消费(§3.4)。
- **音频**:本计划只覆盖视频管线;音频采样链(SampleJob 对应路径)维持
现状,如需统一另立计划。
## 6. 风险与对策
1. **上屏互操作不确定**(gpui 的 wgpu device 能否共享):**M2 已攻关并落地**
(结论见 §3.5 回填):把引擎从 wgpu 25 升到 29 后,`register_context`
采用 gpui 的 device,`Texture::Gpu` 的原始纹理经
`SurfaceSource::Texture` 直通 gpui,上屏零拷贝;颜色由 CPU 烘焙的 3D LUT
在 GPU 应用,不跳过色彩管理。macOS/Windows 的 gpui 暂不暴露 device,
自动回退到单点 staging(仍只此一处)。
2. **解码器线程安全性**:oak-codec 会话当前按进程级互斥共享,集中到一个
线程后语义更简单,但 hwaccel 解码上下文可能有线程亲和(VAAPI/NVDEC),
M1 先做软解路径,hwaccel 随 M5 逐项验证。
3. **OFX 插件的 GPU 直通**:本期明确不支持,文档化;插件渲染结果统一按
CPU 帧回传(§3.2)。
4. **测试环境无 GPU**:CI 的 lavapipe/xvfb 路径已在跑 wgpu(ci.yml 的
Test 步骤),线程后端必须在该环境下同样可用;`GpuContext::create`
的 CPU 回退(backend.rs:1422 测试所示)保持可用。
5. **BFS 求值的兼容性**(M0b):虚拟端点改变了图模型与求值顺序的显性
语义,是全部里程碑里对既有行为扰动最大的一步。对策:M0a 先把
枚举化与单循环做掉(行为不变、纯结构),M0b 单独成 PR、配全套
§4 列举的行为测试;旧工程载入自动补挂端点。
6. **GPU 解码零拷贝的平台碎片化**(M5):各平台导入路径(DMA-BUF
modifier 协商、DXGI NT 句柄、IOSurface、CUDA-Vulkan 互操作)都有
驱动/版本坑,且 wgpu 对外部内存导入的公开 API 覆盖有限,可能要
落到 wgpu-hal 原生句柄层写 unsafe 胶水。对策:staging fallback 是
永久基线(任何导入失败按帧回退);按 §3.6 顺序逐平台落地,每平台
独立开关;CUDA-Vulkan 互操作作为 NVDEC 路径的最后手段(先试
更通用的 DMA-BUF——Linux 上 NVIDIA 也经 VAAPI 可达时优先 VAAPI)。
7. **范围蔓延**:手写 GPU 解码严格限于"FFmpeg 没有对应 hwaccel"的
组合(用户原话"次选"),不提前立项;Job 图的序列化格式版本化
不在本期(沿用工程文件现有版本策略)。