Video GOP Decode Flight:
像调度高铁一样实现丝滑剪辑

桌面剪辑软件的预览为什么可以很丝滑?我们最初给出的答案很直接:它们更靠近 Native,能够使用 FFmpeg 和硬件加速。于是 StarCut 也尝试把解码放进 Tauri 原生侧,再把结果交给 WebView。这个方案听起来足够“底层”,实际运行后却没有解决问题。FFmpeg 解码本身并不慢,真正拖住画面的是解码之后那段路:每一张全尺寸像素都要穿过 Tauri 与网页之间的 IPC 通道。
一张 4K RGBA 帧约 31.6 MiB,30 fps 接近每秒 1 GiB 的原始像素。只要这些帧穿过 IPC、落进 JavaScript 或 WASM 内存,再重新上传 GPU,前面获得的硬件解码优势很快就会被数据搬运抵消。这个弯路让高分辨率实时预览只剩下两个不可退让的原则:硬件加速与零拷贝。任何绕开硬件解码,或让全尺寸像素离开 GPU 主链路的方案,都会把瓶颈重新带回计算与搬运,无法持续提供丝滑的剪辑体验。
一、解码管线的选择
结论先行:Web 与 Tauri 只适配不同的媒体来源,后面的解码管线完全共享。
最终采用的管线由四个角色组成。素材进入项目时先生成包含 sample、DTS、PTS、关键帧和源字节范围的 SCIX 侧车索引:Web 端由 MediaBunny fork 建立,Tauri 端由 Rust/FFmpeg 索引器建立,两端遵守同一份索引契约;Video Worker 根据当前时序需求查询 SCIX,直接 Range 读取所需 GOP,并负责 Flight 调度;WebCodecs 接收压缩样本,调用硬件解码器输出 VideoFrame;帧缓存与 OffscreenCanvas 再把这些 GPU 可用的画面交给最终合成。MediaBunny 与 FFmpeg 负责在导入阶段把媒体读明白,WebCodecs 负责实时解码,Worker 则把读取、调度、解码和呈现组织成一条不经过主线程中转的管线。
Web 与 Tauri 只在媒体入口处分开。Web 从 HTTP 或 OPFS 读取素材,Tauri 通过 starcut:// 暴露本地文件;进入统一媒体源以后,索引、GOP Flight、WebCodecs、帧缓存与 GPU 合成都复用同一套实现。这样既保留链接即入口的 Web 产品形态,也能在桌面环境直接使用本地文件。
这条管线并不是因为其他技术“不够底层”,而是逐条排除以后,只有它同时满足硬件加速、零拷贝、精确控制和跨端复用。
- 为什么不用
<video>? 它的目标是稳定播放媒体,而不是向编辑器开放解码现场。currentTime没有 sample 级准确契约,内部 GOP 路线、解码队列和输出帧也不可控制;精确 Seek、多层原子合成、路线复用和缩略图共享都无法建立在它上面。 - 为什么不在主线程直接使用 WebCodecs? 解码器虽然没变,Range 读取、调度、输出回调和画面合成却会与界面交互争抢同一条线程。把 OffscreenCanvas、媒体读取和完整解码运行时放进 Video Worker,主线程只发布最新状态,不参与逐帧工作。
- 为什么不用 FFmpeg 解码? FFmpeg 的解码速度不是问题。只要最终画面仍由 WebView 呈现,Native 解出的完整像素就必须穿过 IPC,再上传 GPU;一张 4K 帧多走一次这条路,足以吞掉解码侧的优势。FFmpeg 因此不进入实时预览主链路。
- 为什么不用 FFmpeg.wasm 或其他 WASM 解码器? 这类方案通常在 CPU 中解码,把像素写入 WASM 线性内存,再交给 Canvas 或 GPU。它既没有直接获得 WebCodecs 的硬件视频表面,又在最重的像素数据上增加了内存边界。换一种语言,没有改变这条数据路径。
- 为什么不直接用 Swift、Metal 或 Rust 写全 Native? Full Native 当然可以丝滑,但会把 Web 与桌面拆成两套编辑器、两套渲染和两套 Agent 运行环境。这里需要的不是只在一个平台上跑得快,而是同一条高性能管线同时服务 Web 与 Tauri。
- 为什么不把整条管线交给 MediaBunny? MediaBunny 擅长容器解析、编码兼容和 packet 读取,却不知道项目中有哪些 Clip 正在参与合成,也不知道播放、Scrub、时间线缩略图和 GPU 预算此刻如何竞争。它位于媒体能力层;项目级时序需求和 GOP 调度仍由编辑器掌握。
最终分工很明确:MediaBunny 与 FFmpeg 在导入阶段解析并索引媒体,Native 适配本地文件,Worker 隔离实时运行时,WebCodecs 完成硬件解码,VideoFrame 与 OffscreenCanvas 保住 GPU 主链路。选择的结果不是某一个库,而是一条完整的数据路径。
二、语言不是瓶颈,架构才是瓶颈
把决策留在 TypeScript,把像素重活交给原生媒体与 GPU 管线。
做高性能媒体系统时,很容易先把问题归因于语言:JavaScript 不够快,那就换 Rust;浏览器不够快,那就上 WASM 或 Native。这个判断只有在比较同一段 CPU 计算时才成立。视频预览首先是一个物流问题:解码是否走硬件、全尺寸像素有没有离开 GPU 路径、同一 GOP 被重复解了几遍,以及 GPU 资源是否及时释放,往往比调度代码本身跑快多少更重要。
这其实是一个不需要 benchmark 也能理解的常识:搬一张 32 MiB 的完整画面,与给几十个时间点排优先级,根本不是同一数量级。把后一个队列用 Rust 写快一倍,抵消不了前一个画面多搬一次。即使用 Native 侧的 FFmpeg 很快完成解码,只要像素还要经过 Bridge 进入 WebView,主链路仍然绕了远路;典型的 WASM 软件解码器则会把结果写进线性内存,随后再交给浏览器并上传 GPU。两条路径都在最重的数据上增加了边界。
WASM 很适合补充浏览器缺少的 CPU 算法、格式解析和离线处理,也可以与 WebCodecs 组合;它并不天然拥有浏览器的硬件视频解码和 GPU 表面。只要最终仍由 WebCodecs 提供硬件能力,性能优势来自媒体管线的放置,而不是 .wasm 这个后缀。
看起来反直觉的是,实时视频控制面仍然可以使用纯 TypeScript。原因不是 TypeScript 突然比 Rust 更快,而是它只做自己擅长的事:计算时间需求、合并 GOP 路线、分配解码器、决定缓存驻留,并管理 VideoFrame 生命周期。媒体索引已经在导入阶段生成,重解码由 WebCodecs 交给浏览器与硬件,最终合成继续留在 Canvas / GPU 路径。TypeScript 不循环处理 4K 像素,也不实现 H.264 或 HEVC 解码器。
我们要验证的,是同一套 Web 技术运行时在合理架构下能够获得成熟桌面剪辑软件那种跟手的播放与 Scrub。不要指望用 Rust、WASM 或 Native 逃避架构问题;用更快的语言实现一条错误的数据流,得到的仍然是一条错误的数据流。
零拷贝不是一个 API,而是一条不绕路的数据拓扑
生产者直接连接消费者;主线程不充当媒体数据的中转站。
这里的零拷贝特指解码后的全尺寸像素主链路。硬件解码得到的 VideoFrame 不落地成中间 RGBA,不穿过 Native IPC,也不进入 WASM 线性内存,而是直接进入 Canvas / GPU 合成。索引、控制消息、压缩包和小尺寸缩略图仍按各自成本正常传递,不能因为边角数据允许复制,就降低像素主线的标准。
如果 Video Worker 取一段 GOP 还要先请求主线程,再由主线程读取媒体,最后把字节转回 Worker,那么压缩数据虽然比 RGBA 小,主线程仍然会成为所有解码路线共同经过的收费站。Video Worker 因此直接对已经解析好的媒体地址发起 Range 请求:Web 环境读取 HTTP 或 OPFS,桌面环境读取 starcut:// 提供的本地文件适配。主线程只负责界面、素材地址和工程变化,不转运媒体字节。
播放头又是另一类数据。它更新频繁,Worker 真正需要的是“此刻最新的位置”,而不是按顺序处理主线程积压的每一条旧消息。一块共享控制内存持续发布 Playhead、Seek 序号、播放状态和时间线视口。主线程用 Atomics 提交新版本,Video Worker 与 Audio Worker 直接等待变化;没有每帧结构化序列化,没有消息队列,也不需要轮询。
音频链路遵循同样的拓扑。Audio Worker 把混合后的 PCM 写进 SAB 环形缓冲区,AudioWorklet 从同一块内存直接消费;主线程不接收、不转发,也不复制音频块。Worker 之间如果需要交换连续数据,也应使用 SAB 或点对点 MessagePort,而不是默认经由主线程路由。
数据确实需要跨线程时,协议按数据性质选择:高频共享状态进入 SAB;OffscreenCanvas、ImageBitmap 和 ArrayBuffer 这类大对象通过 Transferable 转移所有权;工程操作、任务结果等小而有语义的信息才使用普通消息。缩略图无需先编码成 PNG 或 WebP、到了另一端再解码,媒体索引也无需复制一份 backing store。发送方交出对象,接收方成为新的拥有者——这和 Rust 的 move 在思想上相似:所有权沿数据流前进,而不是两边各留一份。
这套原则并不是“所有东西都要放进 SAB”。共享内存适合持续变化的共同状态,Transferable 适合单一拥有者的大对象,普通消息适合小命令。架构的高度不在于选中了哪个 API,而在于先认清数据是谁产生、谁消费、是否需要共享,然后为它安排一条没有多余节点的路径。
三、视频为什么不能从任意一帧开始解码
编码器用帧之间的依赖换取更小的文件,编辑器则要在随机访问时偿还这笔成本。
如果视频把每个时刻都保存成一张完整图片,存储和带宽很快就会耗尽。实际编码会利用相邻画面的相似性:背景没有变化,就不必在每一帧里重新保存整个背景。
从依赖关系看,可以先把压缩视频中的画面理解成三类。I 帧保存能够独立恢复的画面;P 帧引用过去的参考画面,只记录运动和变化;B 帧还可以同时引用前后两侧的参考画面。编码器把一段互相依赖的画面组织成 GOP(Group of Pictures),用更少的数据表达一段连续运动。
这个结构非常适合从头向后播放。解码器已经握有前面的参考画面,只要继续接收后续压缩包即可。随机跳到 GOP 中间就不同了:目标帧往往只保存了“相对另一帧发生了什么”,不能脱离依赖单独恢复。编辑器必须先找到可用的随机访问起点,再沿依赖关系解到目标。
B 帧还会带来另一个区别。压缩包进入解码器的顺序由 DTS 描述,画面应该出现在时间线上的位置由 PTS 描述;存在前后引用时,两者可能不同。H.264、HEVC 等编码又有开放 GOP、CRA/RASL 和不同的随机访问规则。因此,编辑器不能把文件中的第 N 个 packet 直接当作屏幕上的第 N 帧,也不能只看到一个 isKey 就假设它一定是完整起点。
四、为什么顺着播放很舒服,来回拖动却很难
编码越依赖前后帧,文件越紧凑,随机访问需要经过的路线也越长。
如果素材全部由 I 帧组成,任意位置都可以直接开始,几乎不再需要 GOP 路线调度。这是最轻松的路径,代价是压缩效率显著下降。专业软件可以预先转码为全帧内代理文件,以磁盘换取更稳定的剪辑体验;对浏览器和本地优先项目来说,为每个素材再保存一份大体积全 I 帧视频,会迅速消耗 OPFS 或本地硬盘预算,也延长素材可用前的等待。
这套方案选择直接面对用户原始视频中的长 GOP。交互难度由低到高形成了清楚的阶梯:
- 全 I 帧素材,任何位置都能直接开始;
- Play,沿一个方向顺序解码;
- Jump Play 与暂停 Seek,从新的 GOP 起点尽快到达目标;
- Scrub,目标持续移动、反向,旧工作与新工作不断交叠;
- Timeline Zoom / Scroll,在大范围内重新生成稀疏缩略图需求,同时不能拖慢前台画面。
这篇文章的大部分调度设计,都在处理后三种情况,尤其是 Scrub 和时间线缩略图。它们会把有限的解码器、GPU 帧、内存和 I/O 同时拉向不同位置。
五、一次随机访问到底要走多远
时间线给出的是目标时刻;压缩视频决定了抵达目标所需的真实路线。
把前面的帧依赖放进一次实际的随机访问,时间线上的目标时刻会变成三个步骤:
- 根据目标时刻找到对应的媒体样本;
- 向前寻找它所属 GOP 的关键样本;
- 从关键样本开始按解码顺序推进,直到目标帧输出。
GOP 没有一个适用于所有视频的固定上限,它由编码器配置、帧率、场景切换和交付方式共同决定。有的交付链路会主动缩短间隔,例如 Apple HLS 的 I-frame 序列建议每一到两秒提供一个关键帧;长 GOP 文件则很容易达到数百帧。x264 的默认最大关键帧间隔和 x265 的默认最大帧内周期都是 250 帧,x265 甚至允许配置为无限 GOP。换成 30 fps,250 帧已经覆盖约 8.3 秒。因此,200 帧 GOP 并不是一个只存在于极端测试里的数字。
把目标帧近似看作在一个固定长度 GOP 内均匀分布,就能直观看到随机访问的代价。设 GOP 长度为 G:直接命中关键帧的概率只有 1/G;目标与关键帧之间平均相隔 (G-1)/2 帧;最差要从关键帧一路推进 G-1 帧。若把起点和目标本身也算入解码提交量,平均需要解 (G+1)/2 个样本,最差需要解完整个 GOP。
| GOP 长度 | 30 fps 对应时长 | 直接命中关键帧 | 平均向后推进 | 最差向后推进 |
|---|---|---|---|---|
| 30 帧 | 1.0 秒 | 3.33% | 14.5 帧 | 29 帧 |
| 60 帧 | 2.0 秒 | 1.67% | 29.5 帧 | 59 帧 |
| 120 帧 | 4.0 秒 | 0.83% | 59.5 帧 | 119 帧 |
| 200 帧 | 6.7 秒 | 0.50% | 99.5 帧 | 199 帧 |
| 250 帧 | 8.3 秒 | 0.40% | 124.5 帧 | 249 帧 |
所以,在 200 帧 GOP 中随手点中一个位置,99.5% 的概率都不能直接从目标开始;平均要偿还约一半 GOP 的依赖,最不走运时接近完整解一遍。一次 Seek 尚且如此,Scrub 在相邻目标之间连续跳动时,如果每次都回到关键帧重新出发,重复工作会迅速放大。这正是后面 GOP Flight 要组织和复用的成本。
这里还有两个容易被界面隐藏的事实。第一,解码输入遵循 DTS 顺序,画面呈现遵循 PTS 顺序;包含 B 帧时,两者并不相同。第二,浏览器收到的往往是远程视频,随机访问不仅要解码,还要先读取对应 GOP 的字节范围。
因此,编辑器需要一份可靠的媒体索引:它把时间映射到样本,记录 PTS、DTS、关键帧、字节偏移和大小。索引先指出“从哪个站发车、需要读取哪段轨道”,解码器才开始真正工作。实现中会为媒体生成轻量的侧车索引文件(sidecar),并通过 HTTP Range 只读取相关 GOP,取一帧不必先下载完整视频。
这也是后续所有调度的物理边界:交互可以任意跳转,解码路线仍必须尊重压缩视频的依赖顺序。
MediaBunny 负责读懂媒体,调度仍然留在编辑器
MediaBunny 解决“怎样读懂媒体”,GOP Flight 解决“此刻应该为项目解哪些帧”。
确定使用 WebCodecs 以后,Web 端一个很自然的选择是直接把媒体读取和解码都交给 MediaBunny。它的容器解析、编码适配和 EncodedPacketSink 已经处理了大量兼容性工作,能够按解码顺序遍历 packet,也可以把 packet 直接交给 WebCodecs。我们没有重写这一层,而是在项目中维护了一份窄范围的 MediaBunny fork。
直接使用上游版本时,我们缺少的是面向调度的完整 packet 索引。界面给出一个目标时刻,运行时除了 PTS、时长和是否为关键包,还需要知道:
- packet 在原始文件中的精确
offset + length,才能把同一 GOP 合并成 Range 读取; - 独立于 PTS 的 DTS 与完整解码顺序,才能正确处理 B 帧;
- 解码配置与关键包信息,才能从真实随机访问点建立路线。
其中最关键的是 packet 的源字节地址。没有这项元信息,运行时只能再次经过 demuxer 取 payload,无法提前把多个目标合成一个确定的 GOP 字节范围。DTS 同样不能靠 PTS 猜测;B 帧、开放 GOP 和 HEVC 的 CRA/RASL 都会让这种猜测失效。
这份 fork 增加了 metadata-only 的完整 packet index,补齐 decode order、PTS、DTS、duration、key flag、encoded byte length 与 source byte range,并保留 hvc1 / hev1 等 WebCodecs 配置差异。索引只在素材导入时生成,再投影成紧凑的 SCIX sidecar;实时运行时直接查询 SCIX,不让容器解析进入每一次 Play、Seek 或 Scrub 的热路径。对于无法提供连续源字节范围或准确 DTS 的格式,索引阶段会明确拒绝该条直读契约,而不会用猜测填空。
这些能力很适合回馈 MediaBunny 上游,我们也希望后续版本能够原生提供。它们增强的是包级物理地址和时间信息,并不要求 MediaBunny 理解一款视频编辑器的整个运行现场。
解码调度没有直接交给 MediaBunny,是因为这层问题超出了单个媒体文件。MediaBunny 不知道一个素材在项目中被几个 Clip 复用,不知道全局时间怎样映射到源时间,也不知道当前 FrameSet 还缺哪一层、时间线正在请求哪些缩略图、GPU 还剩多少预算。它提供很好的 packet 与解码能力;编辑器在上面组织跨来源、跨交互的需求、优先级、缓存和 Flight 车队。这个边界让两边都保持清楚。
六、GOP Flight:让不同需求共乘一条路线
需求可以随交互不断替换,已经付出的读取、解码与参考帧状态则应尽量沿用。
GOP Flight 不是把一次 Seek 包装成一个任务,也不是多开几个 VideoDecoder。它建立了一层介于“用户此刻想看哪里”和“压缩视频实际上要怎样解”之间的运行时:上层可以不断改变目标,下层持续维护已经启程的解码路线,二者通过协调而不是反复重启连接起来。
这套设计可以分成四层理解:
- 时序需求记录当前真实意图。播放头、方向、速度、可见范围和视频层共同形成一份不可变快照;新快照会替换旧快照,历史意图不会无限累积。
- 纯规划器把工程时间投影到源媒体时间,量化到真实样本,按来源与样本去重,再按照真实随机访问点划分 GOP 路线。它只计算“需要什么”,不持有解码器状态。
- 运行协调器把新路线与正在行驶的 Flight 对照。能继续向前的路线更新到站表,仍有价值的在途输出继续等待,已经无关的推测工作停止追加,只有失效或资源压力才破坏物理解码状态。
- 物理 Flight保存一趟路线已经付出的成本:GOP 压缩字节、解码器参考状态、当前推进位置、已提交但尚未输出的样本,以及等待交付的目标站点。当前画面、Scrub 预测和缩略图都可以成为沿途乘客。
这四层建立了几条硬约束:同一来源、同一 GOP 的公共前缀,在一趟 Flight 足以服务时不应重复解码;最新需求是唯一有效的需求事实;在途输出算作覆盖,但不算完成;近似画面可以改善反馈,不能取消精确解码义务;温热路线的复用必须服从解码器、I/O 与帧内存预算。后面的播放、Scrub、缩略图与车队策略,都是这些约束在不同现场里的展开。
先把交互翻译成时序需求
交互方式负责产生需求;解码层只处理点、连续区间、有限范围和均匀网格。
最直接的实现,是分别维护播放队列、Seek 任务、Scrub 任务和缩略图任务。但这些名字来自界面,不来自视频。两个不同交互产生的目标帧,可能来自同一个素材、落在同一个 GOP,甚至就是同一个样本。过早把它们拆成独立通道,会让物理解码层失去合并工作的机会。
系统先在全局时间线上形成一份不可变的时序需求快照。它描述当前播放头、运动方向、速度、可见时间范围和参与合成的视频层,再通过 Clip 的偏移、裁切和速度关系,把全局时间投影到每个源媒体。
投影后的需求只保留四种几何:
| 几何 | 表达的需求 |
|---|---|
point |
当前必须准确交付的一个样本 |
stream |
随播放头滚动的连续窗口 |
range |
一段有限范围内的稠密或显式目标 |
grid |
按时间线刻度产生的稀疏均匀采样 |
这四种几何不是播放、Seek、Scrub 的别名。一次停下来的 Scrub 可以产生准确的 point,播放会同时产生当前 point 和向前的 stream,缩略图通常产生 grid。解码规划器只需要回答:哪些源样本尚未覆盖,它们分别属于哪个 GOP,哪些可以共乘同一条路线。
统一需求还有一个重要作用:调度、缓存和呈现看到的是同一份时序事实。否则很容易出现调度器已经追向新位置,呈现层仍按旧的 Scrub 规则选帧,或者缓存把马上要用的帧当作普通后台结果淘汰。
一趟 Flight 怎样抵达多个目标帧
Flight 是一条可继续推进的物理解码路线;目标站可以来自不同交互。
规划器先把需求量化到真实媒体样本,再按“来源、样本”去重,最后按随机访问关键帧分组。落在同一个 GOP 的当前画面、Scrub 预测点和缩略图站点,会形成一张共同的到站表。
一趟 Flight 保存三类状态:从哪个关键样本出发,已经推进到哪里,前方还有哪些目标帧尚未交付。它会读取对应 GOP 的压缩字节,按 DTS 顺序把样本送进 WebCodecs,并根据输出帧的 PTS,把它交给帧缓存、当前画面或缩略图订阅者。
例如,目标 A、B、C 位于同一个 GOP。分别执行三次 Seek,会三次经过相同的关键帧和前缀;Flight 则从关键帧出发一次,经过 A 时交付当前画面,经过 B 时填充缩略图,到 C 时交付运动预测。只要路线仍有价值,已经建立的参考帧状态和当前位置都可以继续使用。
这里需要区分“到站表”和“正在行驶的车”。需求规划每次都可以产生新的到站表,物理 Flight 却不应跟着整条重建。前者是当前想要什么,后者是为这些目标已经付出了哪些读取和解码成本。把两者分开,需求才能更新,路线也才能复用。
目标改变时,正在路上的 Flight 怎么办
目标变化触发的是需求协调,不是对所有解码工作的全局清空。
Scrub 时,鼠标位置可能每十几毫秒变化一次。如果每次变化都重置解码器,系统会不断丢掉已经读取的 GOP、已经提交的样本和即将输出的帧;用户越快拖动,真正到达屏幕的结果反而越少。
每次需求变化后都会重新计算目标,但新目标会与现有 Flight 协调:
- 新目标仍在当前路线前方,就更新到站表并继续前进;
- 已经提交给解码器、而且仍能服务新目标的输出,继续等待到达;
- 一条路线暂时失去任务,但保留有价值的解码位置时,可以作为温热检查点短暂停靠;
- 只有素材失效、运行时销毁,或资源压力要求让路时,才终止相应物理工作。
WebCodecs 的输入队列归零,不代表所有输出已经出现。为避免另一趟 Flight 重复追同一帧,系统把“已提交、尚未输出”的样本视为在途覆盖;帧真正进入缓存后,它才变成已经物化的覆盖。这层区分让系统既不会把一个尚未兑现的输入当成成功,也不会为同一目标反复发车。
同样,reset 和 flush 也不是普通的路线切换按钮。它们会改变解码器的参考状态,下一次输入往往必须重新从关键样本开始。Flight 因此优先通过更新目标和停止后续提交来改道,把硬重置留给真正需要破坏现有路线的边界。
七、播放、Seek 与 Scrub 为什么有不同的画面契约
同一套解码器服务多种交互,但每种交互对“现在可以显示什么”有不同要求。
播放要求当前时刻的所有可见视频层准确对齐。Seek 也需要在用户确认的位置交付精确帧。快速 Scrub 则更看重持续反馈:如果目标附近已经有一张方向一致、误差受限的缓存帧,先把它显示出来,往往比让画面停住更符合操作预期。
因此,这套设计把“精确解码义务”和“当前呈现选择”分开:
| 场景 | 允许呈现 | 精确目标是否继续 |
|---|---|---|
| 播放 | 当前时刻的准确合成帧 | 是,必须满足 |
| 确认后的 Seek | 目标位置的准确帧 | 是,必须满足 |
| 移动 Scrub | 误差受限的邻近缓存帧 | 是,仍保持未完成 |
| Scrub 停留 | 停留位置的准确帧 | 是,必须收敛 |
近似画面只能改善反馈,不能冒充任务完成。只要精确帧还没有到达,调度器仍会保留它的需求。
多层视频还需要一个更严格的规则:画面以完整 FrameSet 原子呈现。如果当前合成需要三层视频,而新时刻只有两层到达,编辑器会暂时保留上一份完整画面,而不是把不同时刻的层拼在一起。这避免了高层已经前进、底层仍停在旧位置的视觉撕裂。
快速 Scrub 怎样跟上鼠标,又在停下时变准确
运动阶段用有限预算换连续反馈;停止输入后,需求立即收敛到准确位置。
Scrub 不是一连串互不相关的 Seek。播放头的位置、速度和加速度揭示了用户正在向哪里浏览。调度器以当前点为最高优先级,再沿运动方向布置少量稀疏预测站点;拖动越快,站点向前覆盖得越远,方向反转时则保留一小段反向保护,避免手势回拉后完全没有可用帧。
当前策略最多布置 10 个方向性预测点。允许呈现的邻近帧误差会随速度和加速度变化,同时受时间线当前刻度约束;慢速移动时,选择范围收紧到目标两侧很近的样本。这样既能在高速拖动时维持视觉连续性,也不会在精细定位时用明显错误的帧蒙混过去。
当连续 80 毫秒没有新的 Scrub 输入,系统把状态视为停留。预测窗口退场,当前点转为与确认 Seek 相同的精确需求。用户不需要再松手触发另一套解码流程;同一份时序需求自然从“运动中的反馈”收敛到“停下后的确认”。
这些参数是当前实现的交互策略,不是普适常数。它们需要随设备性能、素材 GOP 结构、时间线缩放和用户研究继续校准。关键在于,预测的范围、可显示的误差和最终精确义务分别受到明确约束。
八、有限资源下如何管理缓存、缩略图与 Flight 车队
越靠近屏幕的资源越快、越贵、容量越小;调度决定哪些帧值得留下,以及哪些后台工作此刻可以发车。
如果 GPU 足够大,能够永久保存所有已经解出的帧,很多调度问题都会消失:一次解码以后,播放、Seek、Scrub 和缩略图都直接查缓存即可。真实设备给不了这个前提。
按 RGBA 上限估算,一张 3840×2160 视频帧约 31.6 MiB,16 张已经接近 506 MiB,在 30 fps 视频中还不到 0.6 秒。实际 VideoFrame 的像素格式和硬件表面占用会因平台而异,但约束更加直接:未关闭的帧会占用解码器输出池,保存过多甚至会让后续解码停住。
资源因此被分成几层:
| 层级 | 保存内容 | 主要用途 |
|---|---|---|
| GPU 活跃层 | 原生 VideoFrame |
当前 FrameSet、播放前方、Scrub 预测 |
| RAM 温热层 | 回读后的像素 | Seek 邻域、稀疏锚点、可恢复结果 |
| GOP 压缩层 | 已读取的压缩字节 | 同一 GOP 再次发车时避免重复 I/O |
| 文件层 | 原始媒体与 SourceIndex | 最终权威来源,按 Range 冷读取 |
GPU 帧被降级到 RAM 需要 copyTo(),RAM 帧重新呈现又要构造 VideoFrame 并上传;层级迁移本身也有成本。缓存策略不能简单地“满了就 LRU”,它需要知道当前 point、播放方向、Scrub 预测、缩略图网格和在途 Flight。当前画面附近的帧保持热,稀疏且昂贵的锚点可以留在 RAM,压缩 GOP 则用较小空间保存可重新解码的路线。
于是,缓存和调度其实在回答同一个问题:有限资源应该保护哪些未来。Flight 减少重复路线,多级缓存保留路线的不同产物;任何一方脱离当前时序需求独立决策,都会在 GPU、RAM 和 I/O 之间制造新的工作放大。
时间线缩略图为什么不需要第二套解码器
缩略图以低优先级网格进入统一需求,与前台共享已经经过的 GOP 路线。
时间线根据可见范围、刻度和 overscan 产生均匀的缩略图网格。它们与当前画面一样,最终都只是源媒体上的样本需求。因此,缩略图没有第二套专用解码器:前台 Flight 恰好经过某个网格样本时,同一输出可以直接服务缩略图;只有前台稳定、资源有余量时,网格才独立推动低优先级路线。
网格中的目标帧尚未到达时,同一格内已经存在的邻近样本可以先作为临时候选,但准确网格点仍保持未满足。随着滚动和缩放,进入可见区域的缩略图会被固定,离开区域的资源被释放,避免整条时间线长期占据 GPU 和内存。
缩略图在 Worker 侧完成 cover 裁切和一次性缩放,再以统一的 50×30 ImageBitmap 传给主线程。这样一张 RGBA 缩略图只有 6,000 字节,主线程不需要重复缩放完整视频帧,也不用承受大量大尺寸像素跨线程传输。
共享解码并不意味着缩略图可以随时争抢资源。当前画面缺失时,网格规划会退让;播放仍有连续窗口要补齐时,缩略图也不会占满解码车道。复用发生在共同路线与共同输出上,优先级仍由观看现场决定。
车多不等于调度好:Flight 车队怎样受资源约束
解码器、正在运行的路线、压缩字节、解码帧和 I/O 是五种不同资源,必须分别限流。
多开几个 VideoDecoder 并不能自动换来流畅。解码器会占用硬件和参考帧状态,Range 请求占用网络与连接,输出帧占用 CPU/GPU 内存,过多推测工作还会挤掉眼前真正需要的帧。
当前实现允许最多六个驻留解码器,但通常只让两条后台或预取 Flight 同时推进。当任何一个当前合成层缺帧时,前台工作可以使用整个可用车队;当前画面稳定后,多余能力才流向播放前方、Scrub 预测和缩略图。相同优先级的来源按解码样本或字节配额轮转,避免一个长 GOP 的素材长期独占解码器。
温热路线的保留也不采用普通 LRU。系统更关心它保留了多少有价值的 GOP 前缀,以及当前位置离未来目标有多近。一条刚用过但已经越过目标的路线,可能不如一条稍旧、却能直接继续向前的路线值得保留。
资源预算被分开管理:驻留解码器数量、活跃 Flight 数量、Range I/O、压缩 GOP 字节、解码帧字节和推测目标数量各自有边界。不能因为内存还有余量就无限增加并行解码,也不能因为有空闲解码器就让缩略图淹没网络。这个分离让调度器知道真正紧张的是什么,而不是用一个模糊的“缓存大小”代替所有成本。
九、怎样考验一套视频解码管线
Scrub 检验连续随机访问;Timeline Zoom / Scroll 检验大范围网格更新。两者会同时暴露数据绕路与重复解码。
这套管线包含两项彼此独立的主张。第一项是数据路径:完整像素留在解码与 GPU 合成一侧,主线程和跨端 Bridge 不参与逐帧搬运。第二项是解码调度:需求变化时复用同一 GOP 已经付出的读取与解码工作,而不是把每个目标都当作一次新的 Seek。只测最终帧率,会把两种收益混在一起;它们需要分别建立基线。
| 验证对象 | 合理基线 | 关键观测 |
|---|---|---|
| 零拷贝数据主链路 | WebCodecs 后回读完整像素;桌面环境可增加 Native RGBA Bridge 作为历史基线 | 全帧 readback / copy 次数、跨边界字节、主线程长任务、CPU/GPU 占用、反馈延迟 |
| GOP Flight 调度 | 每个目标独立随机访问;播放、Scrub 与缩略图使用分离队列 | 重复 GOP 前缀、提交样本数、Range 字节、decoder reset、无效输出、峰值驻留 |
Play 和单次 Seek 适合检查基础正确性,真正拉开调度差异的是两类连续操作。第一类是 Scrub:播放头高速移动、反向再停下,系统既要持续给出画面,又要在停止后准确收敛。第二类是 Timeline Zoom / Scroll:可见范围和刻度不断变化,缩略图网格大面积换站,同时播放或当前画面仍应保持优先。两类轨迹还应与不同 GOP 长度、分辨率、编码格式和视频层数交叉,避免只对一种素材得出结论。
评估交互结果至少需要同时回答五个问题:
- 移动 Scrub 有多少输入能够快速得到视觉反馈;
- 停止 Scrub 或完成 Seek 后,精确帧是否稳定到达;
- 为了呈现这些帧,共提交了多少样本、读取了多少字节;
- 多来源播放中,最长缺帧和来源饥饿持续多久;
- 解码器、在途帧、GPU 帧和压缩字节的峰值是否受控。
针对时间线还需要记录:一次 Zoom / Scroll 改变了多少网格站点,多久形成第一屏有效覆盖,离场缩略图是否及时释放,以及这些后台工作给当前 FrameSet 带来了多少额外饥饿。
目前的开发 trace 已经验证了一些方向。在一组移动 Scrub 场景中,三次运行的中位反馈覆盖约为 61.3%,最大饥饿约 65 毫秒,停止后的精确帧在三次运行中均到达。三层视频播放的近期运行中,精确合成帧覆盖从早期的 91.1% 提升到约 97.5%–98.0%。缩略图网格预热后,同一组 Scrub trace 的反馈覆盖由约 27%–31% 提升到约 47%–49%,最大饥饿从 192–218 毫秒下降到 59–71 毫秒。
这些数字来自开发环境中的 pilot traces,能够说明共享网格、前台优先和路线复用值得继续验证,但还不能替代正式实验。投稿或形成严谨结论前,还需要冻结硬件、浏览器版本、视频集合、GOP 分布和交互轨迹,增加无状态 Seek、按模式分离队列、无在途覆盖、无温热路线等基线,并报告重复次数和置信区间。零拷贝主链路也需要单独记录全帧 readback、主线程长任务和跨边界字节,才能把架构判断从常识推导推进到可复现实验。
十、相关工作与边界
现有工作提供了解码机制、随机访问和预览策略;GOP Flight 关注编辑器运行时怎样统一协调这些能力。
WebCodecs 让浏览器应用直接控制编码和解码队列,也允许 VideoDecoder 在 Worker 中运行;它定义的是底层媒体接口,没有替应用决定播放、Seek、Scrub 与缩略图怎样共享资源。HTTP Range Requests 则提供了按字节读取媒体区间的标准基础。
Gao 等人在 2011 年研究了高压缩视频中的准确低延迟 Seek,通过预取和丢弃降低从关键帧抵达目标帧的等待;Swift、Swifter 与 Spread Loading 等工作分别探索了低清预览、预缓存网格和由粗到细的交互式视频加载。近期针对交互视频检索的实验也继续比较关键帧 Scrub、低分辨率低 GOP 代理和逐帧预览的取舍。
这些工作共同说明:关键帧随机访问、预取、低清预览、缩略图和缓存都不是新问题。GOP Flight 的设计重点,是把编辑器中不断切换的多种时序需求投影到同一批源样本,在同一 GOP 内协调路线,并明确区分在途覆盖、物化缓存、近似呈现和精确义务。它更接近一套面向视频编辑器的运行时组织方法,而不是一种新的编解码算法。
十一、结语
我们借用高铁,是因为它有起点、有路线、有站点、有时刻表,也有有限的车和轨道。编辑器的性能同样来自组织:什么现在必须到达,什么可以顺路交付,目标改变后哪些工作仍有价值,以及资源紧张时谁先通行。
当这些问题由同一份时序需求回答,播放、Seek、Scrub 和缩略图就不再是四套彼此争抢的解码功能,而成为同一张视频运行图上的不同旅程。
GOP Flight 也不只存在于架构图和性能 trace 里,它已经运行在上线的 StarCut 浏览器视频编辑器中。打开一个普通的长 GOP 视频,快速拖动播放头、来回 Scrub,再缩放和滚动时间线;这些看似简单的操作,就是本文整套数据路径与调度设计每天接受检验的现场。
StarCut 实际运行:在多层时间线中快速 Scrub,并持续刷新预览画面与时间线缩略图。
如果你也想试试浏览器里的丝滑剪辑,可以直接打开 starcut.io 体验。