从 RTC 视角理解音视频剪辑系统的架构设计

Patrick 2024-11-17 #Audio/Video

RTC(实时通信)和 NLE(非线性编辑)是音视频技术栈中的两大应用领域。两者使用几乎相同的技术原语——Codec、GPU Texture、Frame Buffer、A/V Sync——但组装方式截然不同:Codec 在管线中的位置互换,时间模型从墙上时钟变为用户定义的坐标系,Buffer 从吸收网络抖动变为吸收处理耗时波动,丢帧策略从标准操作变为某些场景下的绝对禁忌。

本文从 RTC 的视角出发,系统对照 RTC 和 NLE 在核心概念上的设计差异,分析两者为何在同一套基础设施上长出了完全不同的架构。

下面两条管线展示了这种差异的全貌:

mermaid
UTF-8|11 Lines|
flowchart LR
    subgraph RTC["RTC 管线 -- 编码在前,解码在后"]
        RC[采集] --> RPre[前处理] --> REnc[编码] --> RPack[打包/传输]
        RPack --> RJB[Jitter Buffer] --> RDec[解码] --> RRnd[渲染]
    end

    subgraph NLE["NLE 管线 -- 解码在前,编码在后"]
        NSrc[源素材] --> NDec[解码] --> NGraph["图引擎\n多轨合成+特效"]
        NGraph --> NPreview[预览上屏]
        NGraph --> NEnc[编码] --> NMux[封装导出]
    end

两条管线共享四组技术原语,但各自承担不同的角色:

  • Codec:在 RTC 管线前端编码、后端解码,在 NLE 管线中位置互换——前端解码、后端编码。
  • GPU Texture:在 RTC 中用于渲染上屏,在 NLE 中用于多轨合成和特效处理。
  • Frame Buffer:在 RTC 中以 Jitter Buffer 形态吸收网络抖动,在 NLE 中以解码队列和预览帧缓冲吸收处理耗时波动。
  • A/V Sync:在 RTC 中靠 RTCP SR + NTP 做跨网络时钟对齐,在 NLE 中靠时间轴统一坐标天然保证。

非破坏性 vs 实时有损:一切差异的根源

RTC 的根本约束是一个数字:延迟预算。从摄像头采集光子到对端屏幕发出光子(glass-to-glass latency),通常要控制在 400ms 以内——超过这个阈值,对话不再自然,交互开始崩塌。

这个数字决定了一切。为了守住延迟预算,RTC 系统在管线的每一环都做”尽力而为”的妥协:

  • 帧来不及渲染?丢掉这一帧,下一帧还会来。
  • 带宽不够?降分辨率、降码率,画面糊一点但不卡。
  • 丢包了?NACK 重传或 FEC 前向纠错尝试恢复,来不及就跳过,绝不为一个包堵住整条管线。
  • Jitter Buffer 太浅会卡顿?可以调深换取更平滑的播放,但代价是更大的延迟——又一次取舍。

每一个环节都在延迟和质量之间做取舍。在这个范式下不存在”撤销”——发出去的流不可逆,已经丢弃的帧无法恢复。RTC 的时间只向前流动,系统的职责是在每一个当下用可用资源给出最优结果。这不是设计缺陷,是实时通信的本质特征。

NLE 的根本约束来自完全不同的维度:不是延迟,而是精度和可逆性。

NLE 的第一条设计公理——原始素材文件永远不被修改。用户从 source.mp4 中截取第 5 秒到第 15 秒,放在时间轴的第 3 秒位置,以 2x 速度播放,叠加一个高斯模糊——这一整套操作不会改动 source.mp4 的任何数据。系统存储的是一组编辑指令:对哪个源文件、取哪一段、放在哪里、做什么处理。工程文件保存的不是加工后的产物,而是生成产物的指令集。

非破坏性不是锦上添花的特性,它是整个系统的设计公理——后续一切架构选择都建立在这个前提之上。因为只有这个前提成立,以下能力才能成立:任何操作可以撤销和重做,参数可以随时回溯调整,同一段素材可以在时间轴上被多次引用而不产生物理拷贝,拖动 Clip 的位置不会截断源文件。OpenTimelineIO(Pixar 发起的行业标准交换格式)的数据模型直接体现了这一理念:一个 Clip 同时持有 source_range(从源文件中取哪一段)和 trimmed_range_in_parent(在时间轴上占据哪个区间),两个坐标系完全正交,互不干扰。编辑操作只是修改这些坐标值,源文件纹丝不动。

mermaid
UTF-8|18 Lines|
flowchart TD
    subgraph RTC_TREE["RTC 设计决策树"]
        R1["根本约束\nglass-to-glass < 400ms"]
        R1 --> R2["帧来不及 --> 丢帧"]
        R1 --> R3["带宽不足 --> 降质"]
        R1 --> R4["丢包 --> NACK / FEC\n来不及就跳过"]
        R2 & R3 & R4 --> R5["不可撤销\n流过去就过去了"]
        R5 --> R6["范式:实时有损\nReal-Time Lossy"]
    end

    subgraph NLE_TREE["NLE 设计决策树"]
        N1["根本约束\n编辑精度 + 可逆性"]
        N1 --> N2["原始文件不可修改"]
        N1 --> N3["操作必须可撤销"]
        N1 --> N4["精确到帧"]
        N2 & N3 & N4 --> N5["存储编辑配方\n而非加工产物"]
        N5 --> N6["范式:非破坏性\nNon-Destructive"]
    end

这一根本分歧驱动了后文的每一条设计差异:

  • 对象模型:RTC 的对象是”实时流”,生命周期绑定在网络连接上,连接断了就消失;NLE 的对象是”编辑配方”,持久化到磁盘,随时打开、修改、另存为。
  • 时间:RTC 的时间是墙上时钟,由采集设备驱动,不可编辑;NLE 的时间是用户定义的时间轴坐标,可以随意拖拽、裁剪、变速。
  • 管线拓扑:RTC 的管线是低延迟直线,每个节点必须在毫秒内完成;NLE 的管线可以是任意复杂的 DAG,因为编辑阶段不受严格实时约束。
  • Buffer 策略:RTC 的 Jitter Buffer 吸收网络抖动;NLE 的解码队列服务于随机访问(Seek)和预览平滑。
  • 错误处理:RTC 降级——丢帧、降码率、切低分辨率;NLE 重试、回退、撤销——没有实时压力,可以花时间把事情做对。

基于这一根本差异,本文沿以下 8 个核心问题展开:

困惑一句话剧透
对象模型长什么样?Room / Stream / Track 变成了 Timeline / Track / Clip / Segment,从”实时流”到”编辑配方”
时间是什么?墙上时钟变成了用户定义的时间轴坐标,外加”素材时间 vs 全局时间”双坐标系
管线为什么不是一条直线?线性管线变成图引擎 DAG;SFU 选择转发,图引擎解码合成
没有网络了,Buffer 去哪了?Jitter Buffer 消失了,但解码队列和预览帧缓冲接替了它的位置
数据流方向为什么是反的?RTC 先编码后解码,NLE 先解码后编码——编解码器位置互换
音画同步靠什么?网络时钟协同变成了时间轴统一坐标 + 本地 A/V Sync 单元
实时性约束如何变形?“必须实时”分裂为 Preview(实时可降级)和 Export(离线不丢帧)
状态管理管什么?连接状态机变成 Git-like 编辑模型:Working / Stage / Branch

最后,以 GStreamer——一个同时支撑 RTC(webrtcbin)和 NLE(GES)的开源框架——为例,将所有差异归结到一个统一解释:同一个 DAG,不同的约束


对象模型长什么样?

RTC 的对象模型围绕”谁在和谁通信”展开:Room → User → Stream → Track → SSRC。NLE 的对象模型围绕”怎么编排素材”展开:Timeline → Track → Clip → Segment → Resource。两者都有 Track,但含义完全不同。

RTC:围绕通信会话的对象树

RTC 的对象树反映的是通信拓扑——谁在房间里、谁在发送、谁在接收。Room 是通信会话的容器;User 代表一个参会者,持有 Device(摄像头、麦克风);Device 产出 Stream;Stream 包含若干 Track(audio track / video track);Track 在 RTP 层由 SSRC 标识。这棵树的生命周期绑定在网络连接上——连接断了,对象就消失,不留痕迹。

NLE:围绕编辑工程的对象树

NLE 的对象树反映的是编辑意图——把什么素材、放在哪里、做什么处理。

Timeline(时间轴) 是整个编辑工程的根节点,定义画布宽高比(如 16

/ 9
)和帧率(如 30fps / 60fps)。所有后续对象都挂在 Timeline 下。对应 OpenTimelineIO 的 Timeline 对象。

Track(轨道) 是垂直堆叠的图层,渲染时上层覆盖下层——和 Photoshop 的图层概念一致。类型包括 Video Track、Audio Track、Sticker Track、Effect Track 等。这里有一个容易绊倒 RTC 工程师的同名陷阱:RTC 的 Track 是一路独立的媒体流,代表一个摄像头或一个麦克风;NLE 的 Track 是时间轴上的一个图层,可以在上面放置多个素材片段。同一个词,指向完全不同的抽象。

Clip / Slot(片段) 是轨道上的一段素材,有明确的入点(in point)和出点(out point),定义这段素材在时间轴上的位置——这是全局坐标系下的定位。一个 Track 上可以有多个 Clip 首尾相接或留有间隙。

Segment(素材段) 描述”从源文件截取哪一段、做什么处理”——这是素材坐标系下的描述。比如”从 source.mp4 的第 5 秒到第 15 秒,以 2 倍速播放,叠加一个高斯模糊”。Segment 有丰富的类型体系:VideoSegment、AudioSegment、StickerSegment、FilterSegment、TransitionSegment、TimeEffectSegment 等等。Clip 定义”在时间轴上的位置”,Segment 定义”用什么素材、怎么处理”。

Resource(资源节点) 指向实际的媒体文件,携带元信息:分辨率、时长、编码格式、帧率、采样率等。关键设计:多个 Segment 可以引用同一个 Resource——同一段素材在时间轴上使用多次,不需要复制文件,只需要不同的 Segment 指向同一个 Resource、截取不同的时间区间。

两棵对象树的差异反映了各自的核心关切。RTC 的对象树围绕通信拓扑组织——谁在发、谁在收、走哪条路。NLE 的对象树围绕编辑意图组织——把什么素材、放在哪里、做什么处理。生命周期差异同样深刻:RTC 的对象是瞬态的,会议结束或网络断开即消失,不会序列化;NLE 的对象是持久化的,关闭编辑器后整棵树序列化为工程文件,下次打开时完整还原。这种持久化需求反过来影响了对象模型的设计——每个节点都有唯一 ID、所有引用关系可序列化、状态变化必须支持 undo/redo。

mermaid
UTF-8|15 Lines|
flowchart TD
    subgraph RTC["RTC 对象模型"]
        RRoom[Room] --> RUser[User]
        RUser --> RDevice[Device]
        RDevice --> RStream[Stream]
        RStream --> RTrack["Track\n(= 一路媒体流)"]
        RTrack --> RSSRC[SSRC]
    end

    subgraph NLE["NLE 对象模型"]
        NTimeline[Timeline] --> NTrack["Track\n(= 一个图层)"]
        NTrack --> NClip[Clip / Slot]
        NClip --> NSegment[Segment]
        NSegment --> NResource["Resource\n(源文件引用)"]
    end
维度RTCNLE
根对象Room(通信会话)Timeline(编辑工程)
Track 含义一路独立媒体流(一个摄像头/麦克风)时间轴上的一个图层(可含多个 Clip)
最小操作单元帧 / RTP 包Clip(时间轴上的一段素材)
生命周期瞬态,绑定网络连接持久化,序列化到工程文件
多路关系Stream 之间独立传输Track 之间在画布上合成叠加

时间是什么?

RTC 的时间是真实世界的墙上时钟,以 RTP Timestamp 为载体在发送端和接收端之间同步。NLE 的时间是用户定义的时间轴坐标,且包含两层:Clip 在时间轴上的位置(全局时间),和素材文件内部的裁剪区间(素材时间)。前者不可编辑,后者可自由操控。

RTC:一切围绕墙上时钟

RTC 的时间模型可以概括为:每一帧何时被采集,就决定了它何时该被播放。时间由采集设备的时钟驱动,经过网络传输到达接收端后,系统的核心工作是在正确的时间播放正确的帧。支撑这一机制的基础设施是 RTP Timestamp。

每个 RTP 包都携带一个 timestamp,它基于一个固定频率的采样时钟——视频通常是 90kHz(即 1/90000 秒一个 tick),音频通常与采样率一致(如 48kHz)。这些时间戳是相对值,不是日历时间,也不对应某个具体的年月日时分秒。它的职责很明确:排序(乱序包按 timestamp 重排)、组帧(同一帧的多个包共享 timestamp)、音画对齐(为 A/V Sync 提供计算锚点)。Jitter Buffer 正是利用这些 timestamp 来工作——网络传输导致包的到达间隔不均匀,Jitter Buffer 按 timestamp 将它们重排回正确的顺序,然后以均匀的节奏释放给解码器。

但 RTP Timestamp 有一个局限:它是相对值,而且音频和视频各自维护独立的时钟。要让两条独立的流对齐,需要一个更高层的映射。这就是 RTCP Sender Report(SR)的角色:发送端定期发送 SR 包,内容是一组 {RTP Timestamp ↔ NTP Timestamp} 的映射关系。NTP 时间是墙上时钟——绝对时间。接收端拿到两条流各自的 SR 后,就能通过 NTP 这个公共参考系,把视频的 RTP 时间戳和音频的 RTP 时间戳对齐。这是 RTC 中跨 Track 音画同步的基础。

总结 RTC 时间的几个关键特征:时间由采集设备驱动,不可编辑;时间戳在传输中可能乱序、丢失、重复;系统的工作是尽力恢复原始的时间顺序并实时播放。整条链路上,时间只向前流动,没有回溯。

NLE:用户定义的双坐标系

NLE 中的”时间”是一个完全不同的概念。它不绑定真实世界的钟表,而是一个由用户定义、由编辑操作塑造的抽象坐标系。更关键的是,NLE 的时间模型包含两个独立的坐标系。

全局时间(Timeline 坐标)。时间轴上的位置就是全局时间:某个 Clip 从第 3 秒开始,到第 8 秒结束。时间轴的零点是工程的起点,与墙上时钟完全无关。用户可以把一个 Clip 从时间轴的第 3 秒拖到第 10 秒——这不是”回放一段录像”,而是”重新定义这段素材在作品中出现的位置”。全局时间描述的是”在最终成品中,这段内容何时出现”。在 OpenTimelineIO 的数据模型中,这个概念对应 trimmed_range_in_parent()——一个 Clip 在其父容器(通常是 Track)中经过裁剪后的时间范围。

素材时间(Source 坐标)。每一段源素材文件都有自己的内部时间线:一个 30 秒的视频,从 0 秒到 30 秒。编辑时用户选择其中一段——比如从第 5 秒到第 15 秒——这 10 秒就是在素材坐标系中的裁剪区间。素材时间与全局时间完全独立:同一段 5~15 秒的素材,今天可以放在时间轴的第 3 秒,明天可以拖到第 20 秒。在 OpenTimelineIO 中,这个概念对应 source_range

以下例子展示了双坐标系的运作方式:

用户从一段 30 秒的视频中截取第 5~15 秒(素材时间),放在时间轴的第 3 秒开始(全局时间),以 2 倍速播放。最终在时间轴上占 5 秒(10s ÷ 2x = 5s),即全局时间 3s~8s。

这里涉及三个独立操作:裁剪(素材坐标的入点和出点)、定位(全局坐标的起始时间)、变速(两个坐标系之间的映射速率)。三者互不干扰,任意修改一个不影响另外两个。这种分离让”素材是什么”和”在作品中怎么呈现”完全解耦。RTC 不需要这种分离——帧的采集时间就是它唯一的时间坐标,没有人会”把这一帧拖到另一个时间点”。

时间精度也值得一提。NLE 的时间精度通常到帧级别。在 30fps 的工程里,最小时间单位是 1/30 秒(约 33.3ms)。但内部表示通常比这更精细:常见做法是使用微秒,或者”千倍帧率”整数(如以 30000 为 fps 单位来避免浮点误差和除法精度问题)。OpenTimelineIO 使用 RationalTime(分子/分母)来精确表达时间,彻底绕开浮点精度陷阱。相比之下,RTC 的 RTP 时钟精度是 90kHz(视频)或 48kHz(音频),颗粒度更细——但这不是因为 RTC “更精确”,而是因为采样时钟本身就工作在这个频率上。两者的精度设计各自服务于自己的需求。

差异的根源在于时间的驱动者不同。RTC 的时间是被动的——由采集设备和网络共同决定,系统的职责是在正确的时间播放正确的帧,时间不可编辑、不可回溯。NLE 的时间是主动的——由用户的编辑操作定义,用户决定素材出现在哪个位置、以什么速度播放、按什么顺序排列。这个差异延伸出一系列设计选择:RTC 需要 Jitter Buffer 来对抗网络对时间顺序的破坏,NLE 需要 Seek 机制来支持跳转到任意时间点;RTC 的多路时间对齐依赖 RTCP SR 和 NTP,NLE 的多路对齐是数据模型的天然属性。

mermaid
UTF-8|15 Lines|
flowchart LR
    subgraph RTC["RTC 时间线——线性、不可逆"]
        direction LR
        R1["Capture\n采集时刻"] --> R2["RTP TS\n打上时间戳"]
        R2 --> R3["Network\n传输(可能乱序)"]
        R3 --> R4["Jitter Buffer\n按 TS 重排"]
        R4 --> R5["PTS\n播放时间"]
        R5 --> R6["Render\n上屏"]
    end

    subgraph NLE["NLE 时间线——可编辑的双坐标映射"]
        direction LR
        N1["Source File\n素材时间 5s~15s"] --> N2["Clip\n裁剪 + 变速 2x"]
        N2 --> N3["Timeline\n全局时间 3s~8s"]
    end
维度RTCNLE
时间的本质真实墙上时钟(NTP / 采样时钟)用户定义的时间轴坐标
坐标系数量一个(RTP Timestamp + NTP 映射)两个(全局时间 + 素材时间)
时间的可编辑性不可编辑,采集即确定可自由移动、裁剪、变速、倒放
时间的方向只能向前(实时流)可 Seek 到任意位置,可倒放
精度90kHz / 48kHz(采样时钟)帧级(μs / RationalTime / 千倍帧率)
时间的驱动者采集设备 + 系统时钟用户操作 + 播放器时钟
多路时间对齐RTCP SR + NTP 跨流映射统一时间轴坐标,天然对齐

管线为什么不是一条直线?

RTC 的管线是一条低延迟直线:采集→编码→传输→解码→渲染,每一步追求最快通过。NLE 的管线是一张有向无环图(DAG):多路源素材解码后,经特效处理、多轨合成,汇聚到预览或导出的输出端。面对多路输入,RTC 的 SFU 只转发不合成,NLE 的图引擎解码并合成一切。

RTC:一条必须跑通的直线

RTC 的媒体管线几乎可以画成一条没有分叉的直线。发送端:采集 → 前处理(美颜、降噪、超分等) → 编码 → RTP 打包 → 网络发送。接收端:网络接收 → Jitter Buffer → RTP 解包 → 解码 → 渲染上屏。每一步的延迟直接累加到端到端延迟(glass-to-glass latency)中,所以每一步都在追求最低延迟、最小开销。管线几乎是严格的 1-in-1-out 结构:一帧进来,经过处理,一帧出去。

这条直线上偶尔会出现微小的”分叉”,比如发送端可能同时产出多个分辨率的 Simulcast 流,但这仍然是”同一帧的多个版本”,而不是”多路不同素材的合成”。分叉的目的是让下游(SFU)可以选择转发哪个版本,本质上仍服务于低延迟传输。

多人通话场景下,SFU(Selective Forwarding Unit)的角色特别值得注意:它位于网络的中间节点,负责把发送端的流选择性地转发给不同的接收端。关键在于——SFU 不解码、不渲染、不合成。它不知道视频画面里是什么内容,它只知道”这个包应该转发给谁”。SFU 是一个”聪明的路由器”,不是媒体处理器。每个接收端各自独立解码和渲染收到的多路流,在自己的 UI 中决定画面的布局(比如宫格视图)。只有 MCU(Multipoint Control Unit)才做服务端合成——把多路流解码、合成为一路再转发——但 MCU 架构因为计算开销巨大、延迟高、灵活性差,在现代 RTC 中已经很少使用。

NLE:一张可配置的渲染图

NLE 的渲染核心通常被称为”图引擎”或”DAG 引擎”——一个有向无环图,节点是媒体处理单元,边是数据流。

节点类型可以归纳为三大类。Source 节点(0 入 n 出):从文件读取并解码,是数据的源头。Processor 节点(n 入 n 出):执行特效、颜色校正、缩放、混合等处理。Sink 节点(n 入 0 出):数据的终点,负责上屏显示(预览)或写入文件(导出)。此外还有一类特殊的 Bin / Compound 节点:它在内部封装了一个子图,但对外表现为单个节点,可以递归地嵌套。GStreamer 的 pipeline / bin / element 模型和 MLT 框架的 producer / filter / consumer 模型都是这一思想的工业实现。

为什么需要图而不是线? 因为 NLE 的核心能力是多轨合成——在同一个时刻,时间轴上可能有视频轨、音频轨、字幕轨、贴纸轨、特效轨同时活跃,它们的画面需要按从下到上的图层顺序叠加到同一帧输出上。仅这一项需求就打破了线性管线的前提。进一步考虑:

  • 转场效果(Transition):两个相邻 Clip 在时间上有重叠区域,转场效果(如交叉溶解)需要同时读取两个源的帧数据做混合运算——这是一个 2-in-1-out 的节点。
  • 画中画(Picture-in-Picture):同一时刻有多路视频源需要各自解码、各自缩放定位,再合成到同一画布上。
  • 特效链:一个 Clip 上可能挂载多个滤镜(色彩校正 → 模糊 → 锐化 → LUT),形成串联的处理链。多个 Clip 各自带着自己的特效链,汇入同一个合成节点。

这些需求叠加在一起,管线自然从”线”演变为”图”。

预览管线 vs 导出管线。两种管线共享大部分图结构——Source 层、Decode 层、Effect 层、Track Composite 层几乎一致——差异集中在最后的 Sink 端。预览管线的 Sink 是一个 Surface(GPU 纹理上屏到预览窗口),不需要编码。导出管线的 Sink 是一个 File Writer(编码 → 封装 → 写磁盘),需要编码器。GStreamer 的 GES(GStreamer Editing Services)明确支持这种设计:同一个 GESPipeline 可以在 preview 模式和 render 模式之间切换,切换的本质就是更换 Sink 端。

这种”共享图结构 + 可插拔 Sink”的设计,是 NLE 架构中常见的复用模式。

RTC 的 SFU 和 NLE 的图引擎都面对”多路输入”,但策略截然不同。SFU 的目标是最低转发延迟,图引擎的目标是最终画面质量:

维度RTC(SFU)NLE(图引擎)
解码不解码,直接转发压缩包全部解码为原始帧
合成不合成,每个接收端自行渲染GPU alpha blending 合成为一帧
目标最低转发延迟最终画面质量和编辑精度
算力消耗极低(路由级 CPU 开销)高(GPU 渲染级开销)
灵活性接收端各自决定布局编辑者精确控制每轨的位置、大小、时间和效果

NLE 图引擎的典型结构如下,多路输入汇聚为单一输出:

mermaid
UTF-8|11 Lines|
flowchart LR
    SA["Source A\nVideo"] --> DA["Decode"] --> EA["Effect Chain"]
    SB["Source B\nAudio"] --> DB["Decode"]
    SC["Source C\nSticker"] --> DC["Decode"] --> TC["Transform"]

    EA --> Comp["Track Composite\n多轨合成"]
    DB --> Comp
    TC --> Comp

    Comp --> Canvas["Canvas Overlay\n画布叠加"]
    Canvas --> Sink["Sink\nDisplay / File"]

将 RTC 的 SFU 转发与 NLE 的图引擎合成并排对比,结构差异更加直观:

mermaid
UTF-8|17 Lines|
flowchart LR
    subgraph RTC["RTC:SFU 选择转发"]
        direction LR
        RS1["Sender A"] --> SFU["SFU\n选择转发\n不解码不合成"]
        RS2["Sender B"] --> SFU
        SFU --> RR1["Receiver 1\n各自解码渲染"]
        SFU --> RR2["Receiver 2\n各自解码渲染"]
        SFU --> RR3["Receiver 3\n各自解码渲染"]
    end

    subgraph NLE["NLE:图引擎解码合成"]
        direction LR
        NS1["Source 1"] --> GE["Graph Engine\n全部解码 → 合成"]
        NS2["Source 2"] --> GE
        NS3["Source 3"] --> GE
        GE --> NO["Single Output\n一帧合成画面"]
    end

没有网络了,Buffer 去哪了?

RTC 的 Jitter Buffer 存在是因为网络抖动——包到达的间隔不均匀,需要缓冲来平滑播放。NLE 没有网络,但依然需要 buffer:解码队列保证预览播放的平滑性,而 Seek 模式下的帧消费策略与播放模式完全不同。

RTC:Jitter Buffer 的核心矛盾

RTC 的接收端面对一个本质上不可预测的数据源——网络。同一路视频流的 RTP 包,发送间隔可能是均匀的 33ms,但经过网络传输后到达接收端的间隔可能是 10ms、50ms、5ms、80ms。如果解码器按到达顺序直接消费这些包,用户看到的就是忽快忽慢的画面。

Jitter Buffer 的工作就是在解码器前面设一道缓冲:把先到的包暂存,等到”该播放的时间”再释放给解码器。它按 RTP Timestamp 重新排序,把不均匀的到达间隔拉平为均匀的输出节奏。这里有一个不可回避的 trade-off:buffer 越深,能吸收的抖动越大,播放越平滑,但端到端延迟也越高;buffer 越浅,延迟低,但网络稍有波动就会卡顿。大多数 RTC 引擎会动态调整 buffer 深度,在延迟和流畅之间持续博弈。

除了平滑抖动,Jitter Buffer 还承担几个附带职责:处理乱序包、为 NACK 重传预留等待窗口、以及丢弃已经”过期”的包——如果一个包到达时它对应的帧已经过了播放时间,留着也没用,直接丢弃。

NLE:没有抖动,但依然需要队列

NLE 没有网络,也就没有 jitter——但 buffer 并没有消失,只是换了一种形态,解决不同来源的不确定性。

解码队列

源文件存储在本地磁盘或 SSD 上,IO 延迟远低于网络,但远不是零。一段 4K HEVC 素材读取一帧的数据量可能有数百 KB,顺序读还好,随机读就有可能触发磁盘寻道延迟。更关键的是,硬件解码器本身是异步的——你送入一帧 compressed frame,不一定立即输出一帧 decoded frame,中间有几帧的 pipeline delay。因此 NLE 的播放器必须做预取(prefetch)和 decode-ahead:在当前播放位置前方预先读取并解码若干帧,确保播放引擎需要帧的时候队列里已经有帧可供消费。这和 GStreamer 中 queue / multiqueue element 的作用一致:在管线的两个异步阶段之间做缓冲。

预览帧缓冲

预览窗口需要以 30fps(或 60fps)输出画面,意味着每 33ms(或 16ms)就需要一帧合成完毕的画面。但实际的处理链——解码、特效渲染、多轨合成——每帧的耗时并不恒定。一帧可能只需 15ms,下一帧加了个复杂滤镜可能要 40ms。如果没有任何缓冲,帧率会剧烈波动。预览帧缓冲的作用类似于 Jitter Buffer:用一个小的缓冲池吸收处理时间的方差,让输出到屏幕的帧率尽可能平稳。

Play vs Seek:两种截然不同的消费策略

NLE 的 buffer 策略之所以比 RTC 更复杂,是因为它面对两种完全不同的帧消费模式。

Play 模式是时钟驱动的连续消费:播放器按帧率周期性地从 buffer 中取帧上屏。如果某一帧来不及渲染,可以丢帧——跳过这帧,继续往前走,维持播放的实时性。这一点和 RTC 非常相似,都是”宁可丢帧也不能累积延迟”。

Seek 模式则完全不同。用户拖动进度条或点击时间轴上某一位置,系统需要合成那个精确时刻的画面。这个场景下不能丢帧——用户就是要看那一帧,没有”跳过”的选项。同时也没有实时约束——花 100ms 甚至更久来渲染这一帧完全可以接受,因为用户的期望是”看到那一帧的画面”,而不是”流畅播放”。RTC 里不存在这种模式,流是单向前进的,没有”回到某一时刻重新看”的概念。

Random Access 与关键帧

Seek 模式还引入了 random access 问题。视频压缩的帧间预测意味着大部分帧(P/B 帧)依赖前面的帧才能解码。当用户 Seek 到某个时间点时,解码器不能直接从目标帧开始解码,必须先找到前方最近的关键帧(IDR / I-Frame),从那里开始解码,逐帧前进到目标帧,但中间帧全部丢弃、只显示最后那一帧。如果目标帧距离最近的关键帧有 N 帧,就需要解码 N 帧的代价换取 1 帧的显示——GOP 越长,Seek 越慢。

RTC 也依赖关键帧,但场景不同:RTC 需要关键帧来做丢包后的画面恢复。当参考帧丢失导致后续帧无法解码时,接收端通过 PLI/FIR 请求发送端发送一个新的关键帧作为新的解码起点。两者都依赖关键帧作为”解码入口”,但一个是为了随机访问,一个是为了错误恢复。

两者的根本区别在于 buffer 要应对的不确定性来源不同。RTC 的不确定性来自网络——延迟波动、抖动、丢包、乱序;NLE 的不确定性来自处理耗时的波动和随机访问带来的关键帧依赖。RTC 的 buffer 深度是实时动态博弈的结果,NLE 的 buffer 策略相对静态,核心变量不是”buffer 多深”,而是”当前是 Play 模式还是 Seek 模式”。

维度RTC Jitter BufferNLE 解码/预览队列
缓冲的原因网络抖动(包到达间隔不均匀)处理耗时波动 + 磁盘 IO 延迟
深度控制动态调整(延迟 vs 流畅实时博弈)相对固定(预取帧数预配置)
丢帧策略超时丢弃 late packetPlay 可丢帧,Seek 绝不丢
关键帧依赖丢包恢复(PLI/FIR 请求新 IDR)随机访问(Seek 到任意位置)
核心矛盾延迟 vs 流畅响应速度 vs 预览质量

数据流方向为什么是反的?

RTC 是”先编码后解码”:采集的原始帧立即编码压缩,传输到对端后解码播放。NLE 恰好相反——“先解码后编码”:源素材文件先解码成原始帧用于编辑和预览,最终导出时才编码压缩为成品文件。编解码器在管线中的位置完全互换。

RTC:编码是入口,解码是出口

RTC 管线的数据流从原始帧开始:摄像头采集画面,编码器立即将原始帧压缩为 H.264/VP8 等格式,经 RTP 打包后通过网络发送。对端收到后解包、解码、还原出原始帧、渲染上屏。编码器坐在发送端的入口位置——它是整条管线遇到的第一个重量级处理模块;解码器坐在接收端的出口位置——它是画面上屏前的最后一道工序。编码的首要目标是压缩,让数据量小到足以在有限带宽下实时传输,且必须在一帧时间(~33ms @30fps)内完成。

NLE:解码是入口,编码是出口

NLE 的管线在数据流方向上与 RTC 恰好相反。

导入阶段:解码

用户导入的素材文件——MP4、MOV、MKV——已经是编码压缩过的。要在预览窗口中显示画面、或者在图引擎中对画面施加特效,第一步就是解码:把压缩的码流还原为原始的 GPU 纹理或 CPU 像素缓冲区。解码器是 NLE 管线的”入口”,和 RTC 正好反过来。

更大的挑战在于格式多样性。RTC 的编解码生态高度集中——H.264 占据绝对主流,VP8/VP9 作为补充,AV1 正在渗透。发送端和接收端通过 SDP 协商 codec,通常只需要支持两三种。NLE 面对的世界完全不同:用户可能导入 H.264 MP4(手机拍摄)、HEVC MOV(iPhone 最新录制格式)、ProRes 422(专业摄像机素材)、DNxHR(Avid 工作流中间格式)、AV1(新一代网络视频)、甚至 MPEG-2(老旧存档素材)。NLE 的解码器矩阵远比 RTC 庞大,需要一个全面的 demuxer/decoder 库来覆盖用户可能扔进来的任何格式。FFmpeg 的 libavcodec/libavformat 几乎是行业标配。

编辑阶段:在原始帧上操作

解码之后,原始帧进入图引擎流转:颜色校正、LUT 映射、缩放裁剪、模糊锐化、关键帧动画、多轨 alpha blending 合成。这一步是 NLE 管线的核心,也是它和 RTC 管线最大的区别——RTC 的前处理(美颜、降噪)远不如 NLE 的编辑链复杂,且 RTC 前处理的目标是”让画面看起来更好再编码传输”,而 NLE 的目标是”按照用户的编辑意图生成最终画面”。

导出阶段:编码

用户完成编辑、点击”导出”时,编码器才登场。图引擎逐帧输出合成后的原始画面,编码器将其压缩并封装为最终的视频文件。编码器坐在 NLE 管线的出口——和 RTC 编码器在入口的位置再次形成镜像。

没有实时约束是 NLE 导出编码最大的自由度。RTC 编码器必须在 33ms 内完成一帧编码,这意味着几乎只能用硬编(VideoToolbox / MediaCodec / NVENC),并且 preset 必须选最快的档位。NLE 导出没有这个限制——一帧编码花 500ms 也无所谓,用户等的是进度条到 100%,不是下一帧的实时交付。这让软件编码器(x264 / x265)在 NLE 中有了用武之地:它们通常比硬编慢数倍,但在同码率下能提供显著更好的画质。码率控制策略也随之不同——RTC 使用 CBR 或动态码率自适应(跟随网络带宽波动),NLE 更倾向于 CRF(Constant Rate Factor)或 VBR(Variable Bitrate),把码率分配给最需要的帧,追求整体质量最优而非实时传输安全。

Remux 快速通道

NLE 还有一条 RTC 完全不存在的快速路径:Remux。如果用户没有对画面做任何视觉修改——只是裁剪了头尾,或者只调整了音频轨——NLE 可以跳过整个解码→编码循环,直接从源文件的容器中把压缩数据包(compressed packets)复制到新容器中。这就是 FFmpeg 的 -c copy 模式,也是 MP4Box 的 stream copy 功能。由于绕过了计算密集的编解码,Remux 的速度可以快 10 到 50 倍——一段 10 分钟的 4K 视频,全编码导出可能需要 5 分钟,Remux 可能只需 10 秒。RTC 里没有 remux 的概念——每一帧都是实时采集、实时编码、实时传输,不存在”已有的压缩数据直接搬运”的场景。

将两条管线并排放置,编解码器位置互换的对称关系一目了然:

mermaid
UTF-8|14 Lines|
flowchart LR
    subgraph RTC["RTC:编码在前,解码在后"]
        direction LR
        R1["原始帧\n(采集)"] -->|编码| R2["压缩包"]
        R2 -->|网络传输| R3["压缩包"]
        R3 -->|解码| R4["原始帧\n(渲染)"]
    end

    subgraph NLE["NLE:解码在前,编码在后"]
        direction LR
        N1["压缩文件\n(源素材)"] -->|解码| N2["原始帧"]
        N2 -->|图引擎处理| N3["原始帧"]
        N3 -->|编码| N4["压缩文件\n(导出成片)"]
    end

数据流方向的反转也导致了编解码配置上的显著差异:

维度RTC 编码(发送端)NLE 编码(导出)
时间约束必须在一帧时间内完成(~33ms)无实时约束,一帧可以编码数百毫秒
常用编码器硬编为主(VideoToolbox / MediaCodec)软编硬编均可(x264 / x265 / 硬编)
质量目标可接受质量 + 最低延迟最高质量(用户可调参数)
码率控制CBR / 动态码率自适应(跟随带宽)CRF / VBR(固定质量优先)
常用 CodecH.264 为主(兼容性优先)H.264 / HEVC / ProRes(按用途选择)
Remux 可能不可能可能(无视觉编辑时跳过编解码)

音画同步靠什么?

RTC 的音画同步是一个分布式时钟对齐问题:音频和视频通过各自独立的 RTP 流传输,经历不同的网络延迟,靠 RTCP Sender Report 映射到 NTP 墙上时钟才能在接收端对齐。NLE 的同步机制简单得多——所有轨道共享同一条时间轴坐标,同步在数据模型层面已经保证。

RTC:跨网络的分布式时钟对齐

问题的本质。音频和视频在 RTC 中是两条完全独立的 RTP 流。它们各自有独立的 SSRC、独立的 RTP 时钟(音频 48kHz,视频 90kHz),甚至可能走不同的网络路径、经历不同的排队延迟和丢包率。这意味着,即使发送端在同一瞬间采集了一帧画面和一段声音,它们到达接收端的时刻可能相差几十毫秒。如果不做任何处理,用户看到的就是”嘴型对不上声音”——这在视频通话中是非常明显的体验缺陷。

RTCP Sender Report 映射。发送端定期(通常每几秒一次)发送 RTCP Sender Report。每个 SR 包含一个核心信息:{该流的 RTP Timestamp ↔ 此刻的 NTP Timestamp} 的映射关系。NTP Timestamp 是墙上时钟——全球统一的绝对时间参考。接收端分别收到音频流和视频流的 SR 后,就拥有了两条流各自的 “RTP → NTP” 映射表。通过 NTP 这个公共参考系,接收端可以计算出:音频的某个 RTP Timestamp 和视频的某个 RTP Timestamp 在发送端其实对应同一个墙上时刻。这样,两条原本时钟体系完全独立的流就被关联起来了。

播放端的 wait / drop / render 决策。关联了时间戳之后,接收端需要在播放时做实时决策。业界的通行做法是以音频作为主时钟(master clock),原因是人耳对音频延迟和不连续性的感知远比眼睛敏锐——音频卡顿比视频丢帧更让人不适。视频渲染线程持续查询当前音频播放位置,然后决定手中的视频帧应该怎么处理:

Python
UTF-8|8 Lines|
def av_sync_decision(video_pts, audio_pts, threshold_ms=40):
    diff = video_pts - audio_pts
    if diff > threshold_ms:
        return "WAIT"    # 视频帧的时间戳比音频超前了,等音频追上来再渲染
    elif diff < -threshold_ms:
        return "DROP"    # 视频帧已经落后太多,丢弃这帧、赶紧处理下一帧
    else:
        return "RENDER"  # 差距在可接受范围内,正常渲染这一帧

这个 40ms 的阈值不是随意选取的——它大致对应人类感知音画不同步的门限。低于这个值,大多数人感觉不到唇音不对齐。这个决策函数在 RTC 接收端的播放循环中每帧都要执行一次,是一个持续不断的实时博弈。

NLE:时间轴内建的天然同步

时间轴即同步坐标。NLE 的同步机制从数据模型层面开始。所有轨道——视频轨、音频轨、字幕轨、特效轨——都排列在同一条时间轴上,共享同一个坐标系。一个视频 Clip 的起始时间是 3.0 秒,一个音频 Clip 的起始时间也是 3.0 秒,那么它们就在 3.0 秒同步——这不是”需要算出来的”,而是”数据模型保证的”。不需要任何 SR 映射,不需要 NTP,不需要跨流时钟关联。同步是编辑工程的结构性属性,而不是运行时需要解决的问题。

预览播放时的 A/V Sync。虽然数据模型保证了逻辑层面的同步,预览播放时仍然存在实际的同步挑战。原因很简单:视频的解码 + 特效处理 + 多轨合成 + GPU 渲染的耗时,和音频的解码 + PCM 送入音频设备的耗时,不可能恰好相等。如果视频处理链路偶尔卡了一下(比如遇到一个计算量大的特效),画面就会落后于声音。

处理方式与 RTC 高度相似:音频作为主时钟,视频渲染线程追踪音频的当前播放位置,用类似的 wait / drop / render 逻辑来调整帧的上屏时机。如果视频渲染来不及,可以丢帧(在 Play 模式下这是可接受的降级)。这一部分的工程实现与 RTC 接收端的 A/V Sync 逻辑高度相似。

Seek 模式下的”同步”。当用户拖动进度条 Seek 到某一时刻时,同步问题完全消失。系统只需要做一件事:合成该时刻对应的那一帧画面。没有”流”的概念,没有”音频在播、视频要追”的时间博弈。音频 Seek 通常只需要定位到对应位置并准备好 PCM 数据,不存在 wait / drop / render 的决策。Seek 是一个纯粹的”给定时间坐标,输出对应画面”的函数调用。

NLE 的同步问题比 RTC 简单一个数量级,原因可以逐条列举:没有网络延迟差异——两个轨道的数据都在本地磁盘或内存中,IO 延迟是微秒到毫秒级,而不是 RTC 面对的几十到几百毫秒的网络延迟及其抖动。没有独立的 RTP 时钟——所有轨道用同一个时间轴坐标,根本不存在”两条流的时间戳体系不同”的问题。不需要 RTCP SR 映射——因为没有需要关联的独立时钟。唯一需要处理的运行时同步问题是”预览播放时视频渲染是否跟得上音频时钟”,而这个问题的规模和复杂度比 RTC 小得多。

音画同步的工程难度差异,根源在于时间基准的统一程度。RTC 是分布式系统——音频和视频各自打上独立的 RTP 时间戳,经不同网络路径传输,到达接收端时可能相差几十毫秒,RTCP SR + NTP 本质上是事后弥补机制。NLE 是本地系统——所有轨道共享同一个时间轴坐标系,同步是数据模型的先天属性,运行时只需处理视频渲染追赶音频时钟的本地调度问题。

mermaid
UTF-8|16 Lines|
flowchart TD
    subgraph RTC["RTC:分布式时钟对齐"]
        direction TB
        RA["Audio RTP Stream\nclock_a (48kHz)"] --> SRA["RTCP SR\nRTP_a ↔ NTP"]
        RV["Video RTP Stream\nclock_v (90kHz)"] --> SRV["RTCP SR\nRTP_v ↔ NTP"]
        SRA --> Recv["Receiver\n通过 NTP 关联两条流"]
        SRV --> Recv
        Recv --> Sync["A/V Sync\nwait / drop / render"]
    end

    subgraph NLE["NLE:共享时间轴"]
        direction TB
        NA["Audio Track\ntimeline 坐标"] --> TL["Shared Timeline\n统一坐标系"]
        NV["Video Track\ntimeline 坐标"] --> TL
        TL --> PSync["Preview A/V Sync\n仅预览时调度"]
    end
维度RTCNLE
同步的挑战两条独立流经历不同网络延迟所有轨道在同一条时间轴上,天然对齐
时钟来源各流独立 RTP 时钟 + NTP 映射统一时间轴坐标
主时钟音频(人耳更敏感)音频(预览播放时)
wait / drop / render核心机制,每帧执行仅预览播放时存在,Seek 模式不需要
需要 RTCP SR?是,必须通过 SR 关联两条流否,时间轴坐标已天然统一
同步精度需求~40ms 以内可接受帧级精度(~33ms @30fps)

实时性约束如何变形?

RTC 只有一种模式:必须实时,可以丢帧。NLE 将这个单一约束分裂为两种模式:Preview(实时预览,可降级)和 Export(离线导出,绝不丢帧)。

RTC:只有一种模式——实时

RTC 的整条管线从头到尾只有一种节奏:实时。摄像头采集一帧,编码器必须在一帧时间内(~33ms @30fps)完成编码,网络传输必须在延迟预算内完成,接收端解码和渲染也必须在帧间隔内完成。任何一个环节超时,唯一的选择就是丢掉这帧继续前进——不能暂停等待,不能降低帧率让用户等,不能说”稍后再补”。用户看到的是偶尔的卡顿或画面跳变,但至少系统不会越来越延迟到无法使用。整条链路没有”等等再说”的余地,这是实时通信的基本公理。

NLE:Preview 和 Export 的分裂

NLE 把 RTC 的单一实时模式分裂成了两种截然不同的约束模式,它们共享大部分管线结构,但对时间的态度完全相反。

Preview 模式:放松版的实时

Preview 模式的目标是让用户在编辑过程中实时看到效果——按下播放键后,预览窗口以 30fps 或 60fps 渲染画面。这和 RTC 有几分相似:都需要在帧间隔内输出画面,都可以在来不及的时候丢帧。

但 Preview 模式比 RTC 有更多的降级手段。首先是分辨率降级:源素材可能是 4K 甚至 8K,但预览窗口可能只有 720p 大小,没有必要以全分辨率渲染。其次是特效简化:复杂的粒子效果、高精度色彩校正可以在预览时用简化版本替代,只在导出时启用完整计算。第三是预渲染缓存:已经渲染过且没有被编辑修改的帧可以缓存下来复用,而不是每次播放都重新计算。第四,用户可以随时暂停——RTC 的管线不能停止,NLE 的预览可以在任意时刻暂停和恢复。

Preview 模式保留了实时输出的目标,但拥有比 RTC 更丰富的降级策略和更宽松的容忍度。

Export 模式:离线逐帧

Export 模式是 RTC 世界里完全不存在的概念。用户点击”导出”后,系统把时间轴上的全部内容逐帧渲染、编码、封装为一个完整的视频文件。

这个模式的核心特征是:绝不丢帧。每一帧都必须以目标分辨率、全质量特效完整渲染并写入文件。一帧花 100ms 处理完全没问题,花 1 秒也可以接受——用户看到的是一个进度条从 0% 走到 100%,关心的是最终文件的质量,而不是处理速度。分辨率不再是”预览窗口大小”,而是用户指定的导出分辨率(1080p / 4K / 更高)。所有特效以最高精度执行,编码器使用更慢但质量更好的参数。

这种模式对用户体验的设计也完全不同于实时场景。RTC 和 Preview 的 UX 是”连续的画面流”,Export 的 UX 是”等待 + 进度反馈”——进度百分比、预估剩余时间、已处理帧数。

共享的管线,分裂的约束

Preview 管线和 Export 管线的 DAG 结构大部分相同——Source 节点读取同样的素材、同样的 Decoder 解码、同样的 Effect 链处理、同样的 Compositor 合成。差异集中在四个地方:

一是 Sink 不同——Preview 的 Sink 是屏幕 Surface,Export 的 Sink 是文件 Writer。二是分辨率不同——Preview 可以缩小到窗口尺寸,Export 使用目标导出分辨率。三是丢帧策略不同——Preview 可以丢帧维持实时性,Export 绝不丢帧。四是编码器是否存在——Preview 不需要编码(直接上屏),Export 需要完整的编码和封装流程。

同一张 DAG 图里,Preview 和 Export 只是两种不同的约束配置。

维度RTCNLE PreviewNLE Export
实时约束严格实时,帧间隔内必须完成目标实时,可降级无实时约束,逐帧处理
丢帧可以,超时即丢可以,来不及就跳绝不,每帧必须输出
分辨率动态调整(码率自适应)预览窗口大小(通常 ≤ 720p)目标导出分辨率(1080p / 4K)
编码每帧实时编码不编码(直接上屏)逐帧编码(可用慢速高质量参数)
输出RTP 网络流Surface 显示文件(MP4 / MOV 等)
用户体验实时对话 / 直播编辑预览,可暂停进度条等待

状态管理管什么?

RTC 的状态管理围绕连接生命周期展开:idle → joining → connected → reconnecting → disconnected。NLE 的状态管理围绕编辑历史展开:Working copy → Stage → Branch,支持 undo/redo 和增量 Diff 同步。两者管理的对象和可逆性要求完全不同。

RTC:管理连接的生命周期

RTC 的状态机围绕”连接”展开:idle → joining → connected → reconnecting → disconnected。状态转换由用户操作(入会/退会)、网络事件(断连/恢复)、媒体事件(首帧渲染/丢流)触发。状态机的正确性直接决定用户体验——状态错乱意味着 API 调用成功但媒体链路没起来,或者用户以为自己已经退会但实际还在占用资源。

NLE:管理编辑的历史

NLE 的”状态”不是连接状态,而是编辑状态——用户对时间轴做了什么操作、当前的模型长什么样、能不能撤销回去。

Git-like 三层缓冲区

NLE 编辑模型的核心结构可以类比 Git 的三层设计:

Working 层是可变的当前编辑状态,用户的每一次拖动、每一次参数调整都直接修改这一层——类比 Git 的 working tree。Stage 层是不可变的快照,由 commit() 操作产生——类比 Git 的 staged 状态。Branch 层是操作历史栈,由 done() 操作记入,支持 undo/redo 回溯——类比 Git 的 commit history。

commit() vs done():解耦”实时预览”和”可撤销”

这两个操作的区分是 NLE 状态管理中最精巧的设计之一。

commit() 是高频操作。比如用户拖动一个贴纸在画布上移动——每一帧(每 16ms)都会调用 commit(),产生一个新的 Stage 快照,用于驱动预览渲染引擎更新画面。这些中间状态不会记入 undo 栈,因为用户不会想”撤销到拖动过程中的第 37 帧”。

done() 是低频操作。用户松开手指、确认拖动结果时调用一次 done(),把当前状态记入 Branch(历史栈)。之后用户按 undo 会回到这次 done() 之前的状态——这才是用户心智模型中的”一次操作”。

这种分离解决了一个核心矛盾:拖动过程中需要实时更新预览(必须高频提交状态给渲染引擎),但 undo/redo 的粒度应该是”一次完整操作”而不是”拖动过程中的每一帧”。commit() 服务于预览引擎,done() 服务于用户的撤销期望。

增量 Diff 同步

每次 commit() 之后,系统需要把模型变化同步给渲染引擎。全量重建代价太高——一个复杂工程可能有数百个节点,每次拖动都全量重建渲染图会严重影响性能。

解决方案是增量 Diff:对比前后两个 Stage 快照,按 UUID 匹配同一逻辑节点,按指针(或内容 hash)判断节点是否发生了变化。输出一组增量操作:ADD(新增节点)、UPDATE(节点属性变化)、DELETE(节点被删除)。渲染引擎只需要处理这些增量操作,而不是推倒重建整张图。

这个思路和前端领域的 React Virtual DOM Diff 异曲同工——都是为了在”频繁状态变化”和”高效更新输出”之间找到平衡,用 Diff 算法把 O(n) 的全量更新降低到 O(delta) 的增量更新。

Undo/Redo

Undo 就是回到上一个 done() 快照,Redo 就是前进到下一个 done() 快照。工业实现通常基于两种经典模式:GoF 的 Command 模式(记录每一步操作的正向和逆向命令)或 Memento 模式(直接保存状态快照)。实践中 Snapshot(Memento)方式更常见,因为 Command 的逆操作在复杂编辑场景下容易出错。

为什么 RTC 不需要这些

RTC 不需要这些机制——流过去了就过去了,不存在”撤销”的概念。RTC 也不需要增量 Diff,每一帧都是独立编码传输的。RTC 的”状态恢复”是断线重连——重新建立连接、重新订阅流——而不是回退到之前的编辑状态。

mermaid
UTF-8|22 Lines|
stateDiagram-v2
    direction LR
    state "RTC 连接状态机" as RTC {
        [*] --> Idle
        Idle --> Joining
        Joining --> Connected
        Connected --> Reconnecting
        Reconnecting --> Connected
        Connected --> Leaving
        Reconnecting --> Disconnected
        Leaving --> [*]
        Disconnected --> [*]
    }

    state "NLE 编辑状态模型" as NLE {
        [*] --> Working
        Working --> Stage : commit()\n高频预览
        Stage --> Working : 继续编辑
        Stage --> Branch : done()\n记入历史
        Branch --> Working : undo()
        Working --> Branch : redo()
    }
维度RTCNLE
状态管理的对象连接/会话的生命周期编辑操作的历史
核心操作join / leave / publish / subscribecommit / done / undo / redo
可逆性不可逆(重连 ≠ 撤销)可逆(undo/redo 回溯任意操作)
持久化不持久化(连接断了状态就没了)持久化到草稿/工程文件
增量同步RTP 包级流式传输模型 Diff(类似 Virtual DOM Diff)

总结

RTC 和 NLE 使用同一套音视频技术原语——Codec、GPU Texture、Frame Buffer、A/V Sync——但因为根本约束不同,在系统设计的每个层面都做出了截然不同的选择。

RTC 的根本约束是延迟预算。为了在毫秒级完成端到端传输,管线被设计为低延迟直线,编码器在入口压缩、解码器在出口还原,Jitter Buffer 动态吸收网络抖动,音画同步靠 RTCP SR 跨网络对齐独立时钟,所有对象的生命周期绑定在连接上,帧来不及就丢,质量不够就降。

NLE 的根本约束是编辑精度和可逆性。为了支持非破坏性编辑,管线被设计为可配置的 DAG 图引擎,解码器在入口读取素材、编码器在出口导出成片,缓冲服务于处理耗时波动和随机访问,音画同步由统一的时间轴坐标天然保证,所有对象持久化到工程文件并支持 undo/redo,Preview 可以丢帧但 Export 绝不丢帧。

两者的对象模型分别反映通信拓扑和编辑意图,时间模型分别由墙上时钟驱动和由用户定义的双坐标系驱动,状态管理分别面向连接生命周期和编辑历史。GStreamer 等开源框架同时支撑 RTC(webrtcbin)和 NLE(GES)的事实也印证了这一点:差异不在底层原语,而在施加于其上的约束条件。