返回文章列表

AiVedio:前端非线性视频编辑器

从非破坏式编辑、Canonical Timeline、Project/Sequence/Track/Clip/Effect/Transition 建模、整数时间轴、编辑命令、Undo/Redo、自动保存、乐观版本、Go 后端校验、大型时间轴性能和预览导出一致性出发,设计浏览器端非线性视频编辑器。

第 14 章:前端非线性视频编辑器

本章主题:设计一个可扩展、可保存、可撤销、可渲染的浏览器非线性视频编辑器。

核心主线:原始媒体保持不可变,前端只编辑 Canonical Timeline;所有交互最终收敛为可验证、可重放的领域命令。


1. 本章定位

AI 视频平台不应只提供“生成后下载”能力。真正形成生产力闭环,还需要让用户把多个生成片段、上传素材、字幕、配音、贴图和音乐组织成一条时间轴,再完成裁剪、拼接、变速、转场、关键帧和导出。

这类编辑器不是把 FFmpeg 搬进浏览器,也不是在每次拖动后重新生成视频文件。它本质上是一个同时处理以下问题的交互式状态系统:

  • 媒体资产与编辑结果分离。
  • source time 与 timeline time 映射。
  • 多轨、层级、碰撞和转场语义。
  • 帧边界、采样边界和时间精度。
  • 高频交互与大型时间轴性能。
  • Undo/Redo、自动保存和崩溃恢复。
  • 前端状态与后端版本的一致性。
  • 时间轴与最终渲染结果的一致性。

本章重点是编辑器的数据模型与编辑事务。多轨解码、音频主时钟、WebCodecs、帧缓存和音画同步将在第 15 章展开;Timeline Compiler、Render DAG 和最终导出将在第 16 章展开。


2. 学习目标

完成本章后,应能够回答:

  1. 为什么专业视频编辑必须采用非破坏式编辑?
  2. Asset、Sequence、Track、Clip、Effect 和 Transition 应怎样建模?
  3. 为什么不能把浮点秒作为时间轴唯一事实表示?
  4. source time、clip-local time 和 timeline time 有什么区别?
  5. trim、split、move、ripple、slip、slide、roll 的精确定义是什么?
  6. 如何设计不会因拖动而产生数百条历史记录的 Undo/Redo?
  7. 如何通过操作命令、幂等键和乐观版本完成自动保存?
  8. 上千个 Clip 的时间轴如何保持流畅?
  9. 前端 TypeScript 与 Go 后端如何共享同一组时间轴不变量?
  10. 如何保证预览、保存和最终渲染使用同一个 Canonical Timeline?

3. 先确定系统边界

3.1 编辑器保存的不是视频,而是“编辑决策”

假设原始资产为:

asset_A.mp4
asset_B.mp4
music.wav

用户执行:

从 asset_A 的 3 秒开始取 5 秒
放在主视频轨 0 秒处
随后接 asset_B 的 2~8 秒
asset_B 播放速度设为 1.25 倍
两段之间增加 500ms 叠化
第 2 秒出现标题
背景音乐从第 10 秒淡出

系统不应修改原始文件,而应保存一份时间轴描述:

Immutable Assets + Timeline Decisions = Edited Project

真正的视频文件只在预览代理生成或最终导出时产生。

3.2 控制面与媒体面分离

编辑器主要处理的是小体积控制数据:

Project JSON
Operation Batch
Revision
Selection
Viewport
Playback State

媒体数据应通过对象存储和 CDN 访问:

Original Asset
Proxy Video
Audio Proxy
Thumbnail Sprite
Waveform Tile
Keyframe Index

前端时间轴只保存 asset_id、变体标识和媒体元数据引用,不应把临时签名 URL 当作长期事实写入项目。

3.3 推荐整体架构

┌──────────────────────────────── Browser ────────────────────────────────┐
│                                                                         │
│  Editor UI                                                              │
│  ├── Asset Panel                                                        │
│  ├── Timeline View                                                      │
│  ├── Inspector                                                          │
│  └── Preview Surface                                                    │
│          │                                                              │
│          ▼                                                              │
│  Command Bus ──► Canonical Editor Store ──► Query / Interval Index       │
│      │                   │                         │                    │
│      │                   ├── Undo / Redo           └── Visible Clips     │
│      │                   ├── Gesture State                               │
│      │                   └── Preview Adapter                             │
│      │                                                                  │
│      ├── IndexedDB Local Journal                                        │
│      └── Save Queue ──► Project API                                     │
│                              │                                          │
└──────────────────────────────┼──────────────────────────────────────────┘

                       Go Project Service
                       ├── Idempotency
                       ├── Optimistic Revision
                       ├── Command Validation
                       ├── Snapshot / Operation Log
                       └── PostgreSQL

最重要的边界是:

UI 不能直接任意修改项目对象;所有持久化修改都必须经过领域命令和统一校验器。


4. 核心领域模型

4.1 Project 与 Sequence

一个项目可以包含多个 Sequence,例如主片、片头模板、嵌套片段和不同宽高比版本。

Project
├── id
├── revision
├── title
├── active_sequence_id
├── sequence_ids
└── settings

Sequence
├── id
├── name
├── canvas_width
├── canvas_height
├── frame_rate
├── sample_rate
├── duration_us
├── track_ids
└── background

Project.revision 是后端并发控制版本,不等于业务版本名称。用户可以在 revision 125 时创建一个名为“客户确认版”的显式版本。

4.2 Asset

Asset 是媒体事实,不是时间轴实例。

Asset
├── id
├── kind                    video | audio | image | generated | text-template
├── checksum
├── duration_us
├── width
├── height
├── rotation
├── pixel_aspect_ratio
├── frame_rate
├── time_base
├── audio_sample_rate
├── channel_layout
├── color_metadata
├── original_object_key
├── proxy_variants
├── thumbnail_manifest
├── waveform_manifest
└── version

关键原则:

  • Asset 一旦进入可编辑状态,内容应视为不可变。
  • 若用户替换文件,应创建新 Asset,而不是原地改变旧 Asset 的含义。
  • Clip 引用 asset_id + asset_version 或不可变 checksum。
  • 项目内可保存一份必要元数据快照,但资产服务仍是媒体事实源。

4.3 Track

常见轨道类型:

video
video_overlay
image
text
subtitle
audio
effect

建议的数据结构:

Track
├── id
├── sequence_id
├── type
├── name
├── order
├── locked
├── muted
├── hidden
├── solo
├── collision_policy
├── blend_mode
├── opacity
└── effect_ids

order 决定基础层级,但最终合成顺序还要结合 Clip 的 z_index、轨道类型、混合模式和 Effect Chain。

collision_policy 不应隐含在 UI 中,可以显式定义:

exclusive    同轨 Clip 不允许重叠
stacked      允许重叠,按 z-index 合成
replace      放下新 Clip 时替换覆盖区域
ripple       编辑会推动后续 Clip

主视频轨通常采用 exclusive;叠加轨、贴图轨和标题轨通常采用 stacked

4.4 Clip

Clip 是 Asset 在某个时间轴位置上的一次引用。

Clip
├── id
├── asset_id
├── asset_version
├── track_id
├── timeline_start_us
├── timeline_duration_us
├── source_in_us
├── source_duration_us
├── playback_rate
├── reverse
├── transform
├── crop
├── opacity
├── volume
├── blend_mode
├── effect_ids
├── group_id
├── link_group_id
├── z_index
└── version

需要区分:

  • group_id:多个 Clip 作为一个 UI 组移动或复制。
  • link_group_id:视频与其原始音频建立联动关系,但允许用户执行“解除链接”。
  • version:Clip 自身版本,可用于细粒度冲突检测;它不替代 Project revision。

4.5 Effect 与 Transition

Effect 是对单个 Clip、Track 或 Sequence 的处理链:

Effect
├── id
├── owner_type             clip | track | sequence
├── owner_id
├── type
├── schema_version
├── enabled
├── order
├── parameters
└── keyframe_channels

Transition 是两个 Clip 之间的关系,不建议仅作为 clip.transition_out 的松散字段:

Transition
├── id
├── from_clip_id
├── to_clip_id
├── type
├── duration_us
├── alignment              before_cut | centered | after_cut
├── parameters
└── schema_version

独立实体更容易验证:

  • 两个 Clip 是否仍然相邻。
  • 是否属于兼容轨道。
  • source handle 是否足够。
  • 删除或移动任一 Clip 后如何处理 Transition。

5. 时间模型:拒绝以浮点秒作为事实源

5.1 浮点秒的问题

以下写法适合 UI 展示,不适合作为项目事实:

const start = 0.1;
const duration = 3.7;

连续执行拆分、变速、吸附和往返序列化后,可能出现:

  • 本应相邻的边界产生极小缝隙。
  • 帧吸附在前后端得到不同结果。
  • 音频累计漂移。
  • Undo 后无法回到完全相同的状态。
  • 相同时间轴在不同浏览器或语言中生成不同哈希。

5.2 本项目采用的时间表示

建议采用分层策略:

  1. Canonical Timeline:整数微秒 int64
  2. 帧率、播放速度、time base:有理数 {num, den}
  3. 媒体原始时间戳:保留 PTS/DTS 与 time base 元数据。
  4. UI 展示:需要时临时转换为浮点秒或时间码。
type TimeUs = number & { readonly __brand: "TimeUs" };

type Rational = Readonly<{
  num: number;
  den: number;
}>;

function asTimeUs(value: number): TimeUs {
  if (!Number.isSafeInteger(value)) {
    throw new RangeError("time must be a safe integer");
  }
  return value as TimeUs;
}

ECMAScript 的安全整数范围是 -(2^53-1)2^53-1;因此以微秒表示常规视频项目时可以使用 JavaScript number,但必须执行 Number.isSafeInteger 校验,并限制项目最大时长。1

若系统需要超长时间线、纳秒精度或不受 JavaScript 安全整数约束的通用协议,可以在 JSON 中使用十进制字符串,并在内部使用 BigInt。不要直接把 BigInt 当作普通 JSON 数字传输。

5.3 帧率必须是有理数

不要把 29.97 fps 固化为近似浮点数,应保存:

{
  "num": 30000,
  "den": 1001
}

同理,播放速度可以表示为:

{
  "num": 5,
  "den": 4
}

其含义是:

每经过 4 个 timeline 时间单位,消耗 5 个 source 时间单位
即 1.25 倍速

5.4 统一舍入策略

前端和后端必须共享同一种整数除法策略。例如统一使用“四舍五入到最近整数,正好一半时远离零”,或者统一向下取整。

不要出现:

TypeScript 使用 Math.round
Go 使用整数截断
Renderer 使用 av_rescale_q_rnd 的另一种模式

推荐把时间换算实现为共享测试向量,而不是只在文档中约定。

function mulDivRound(value: number, num: number, den: number): number {
  if (!Number.isSafeInteger(value) || !Number.isSafeInteger(num)) {
    throw new RangeError("unsafe integer");
  }
  if (!Number.isSafeInteger(den) || den <= 0) {
    throw new RangeError("invalid denominator");
  }

  const v = BigInt(value);
  const n = BigInt(num);
  const d = BigInt(den);
  const product = v * n;
  const half = d / 2n;
  const rounded = product >= 0n
    ? (product + half) / d
    : (product - half) / d;

  const result = Number(rounded);
  if (!Number.isSafeInteger(result)) {
    throw new RangeError("result is outside safe integer range");
  }
  return result;
}

5.5 三种时间坐标

编辑器至少存在三种时间:

Asset Source Time
Clip-local Timeline Time
Sequence Timeline Time

假设 Clip:

timeline_start = 10s
source_in = 3s
playback_rate = 1.25

当播放头位于 Sequence 的 12 秒时:

clipLocalTime = 12s - 10s = 2s
sourceTime = 3s + 2s × 1.25 = 5.5s

公式为:

sourceTime = sourceIn
           + (timelineTime - timelineStart) × playbackRate

时间轴时长为:

timelineDuration = sourceDuration / playbackRate

倒放不建议通过负时间直接扩散到所有算法。可以显式保存 reverse = true,映射时再从 source range 尾部反向计算。


6. Canonical Timeline 数据结构

以下 TypeScript 类型展示一个可落地的核心模型。真实项目应继续拆分 Effect 参数联合类型,并生成 JSON Schema。

type UUID = string;
type TimeUs = number & { readonly __brand: "TimeUs" };

type Rational = Readonly<{
  num: number;
  den: number;
}>;

type TrackType =
  | "video"
  | "video_overlay"
  | "image"
  | "text"
  | "subtitle"
  | "audio"
  | "effect";

interface TimelineProject {
  schemaVersion: number;
  projectId: UUID;
  revision: number;
  activeSequenceId: UUID;
  sequences: Record<UUID, Sequence>;
  tracks: Record<UUID, Track>;
  clips: Record<UUID, Clip>;
  effects: Record<UUID, Effect>;
  transitions: Record<UUID, Transition>;
}

interface Sequence {
  id: UUID;
  name: string;
  width: number;
  height: number;
  frameRate: Rational;
  audioSampleRate: number;
  durationUs: TimeUs;
  trackIds: UUID[];
  background: string;
}

interface Track {
  id: UUID;
  sequenceId: UUID;
  type: TrackType;
  name: string;
  order: number;
  locked: boolean;
  muted: boolean;
  hidden: boolean;
  collisionPolicy: "exclusive" | "stacked" | "replace" | "ripple";
  clipIds: UUID[];
  effectIds: UUID[];
}

interface Clip {
  id: UUID;
  assetId: UUID;
  assetVersion: number;
  trackId: UUID;
  timelineStartUs: TimeUs;
  timelineDurationUs: TimeUs;
  sourceInUs: TimeUs;
  sourceDurationUs: TimeUs;
  playbackRate: Rational;
  reverse: boolean;
  transform: Transform;
  crop: Crop;
  opacity: number;
  volume: number;
  blendMode: string;
  effectIds: UUID[];
  groupId?: UUID;
  linkGroupId?: UUID;
  zIndex: number;
  version: number;
}

interface Transform {
  positionX: number;
  positionY: number;
  scaleX: number;
  scaleY: number;
  rotationDeg: number;
  anchorX: number;
  anchorY: number;
}

interface Crop {
  left: number;
  top: number;
  right: number;
  bottom: number;
}

interface Effect {
  id: UUID;
  ownerType: "clip" | "track" | "sequence";
  ownerId: UUID;
  type: string;
  schemaVersion: number;
  enabled: boolean;
  order: number;
  parameters: Record<string, unknown>;
  keyframeChannels: Record<string, KeyframeChannel>;
}

interface KeyframeChannel {
  valueType: "number" | "vec2" | "vec4" | "color" | "boolean";
  timeDomain: "clip_timeline" | "source" | "sequence";
  keyframes: Keyframe[];
}

interface Keyframe {
  offsetUs: TimeUs;
  value: unknown;
  interpolation: "hold" | "linear" | "bezier";
  easing?: {
    x1: number;
    y1: number;
    x2: number;
    y2: number;
  };
}

interface Transition {
  id: UUID;
  fromClipId: UUID;
  toClipId: UUID;
  type: string;
  durationUs: TimeUs;
  alignment: "before_cut" | "centered" | "after_cut";
  schemaVersion: number;
  parameters: Record<string, unknown>;
}

6.1 为什么使用归一化 Store

不要在 Track 内嵌完整 Clip,再在 Selection 和 Inspector 中复制一份 Clip。

推荐:

clipsById
tracksById
sequencesById
selectedClipIds
visibleClipIds

优点:

  • 单一事实源。
  • O(1) 按 ID 更新。
  • 减少深层对象复制。
  • 便于局部订阅和 memoized selector。
  • 便于构建操作命令和差异。

6.2 Canonical 与派生状态分离

以下信息不应写入项目快照:

当前鼠标位置
拖动中的临时偏移
当前缩放倍数
滚动位置
当前选择框
hover 状态
播放器缓冲状态
临时签名 URL

这些属于 Session/UI State。

以下信息应进入项目快照:

Clip 位置和源范围
Track 顺序
关键帧
效果参数
转场
字幕内容与样式
项目画布设置

7. 编辑操作的精确定义

约定一个 Clip 的时间范围均为左闭右开区间:

Timeline Range = [T, T + D)
Source Range   = [S, S + L)

其中:

T = timeline_start
D = timeline_duration
S = source_in
L = source_duration
r = playback_rate = p / q

必须满足:

L ≈ D × p / q

允许的误差不应由各模块自由决定,应统一为 0 或不超过一个媒体 tick。

7.1 Move

Move 只改变时间轴位置:

T' = T + Δ
D' = D
S' = S
L' = L

校验:

  • T' >= 0
  • 目标 Track 类型兼容。
  • Track 未锁定。
  • 不违反碰撞策略。
  • linked/grouped Clip 是否应一起移动。

7.2 Trim In

拖动左边界 Δt

T' = T + Δt
D' = D - Δt
S' = S + Δt × r
L' = L - Δt × r

向左扩展时 Δt < 0,必须确认 Asset 在 source_in 之前存在足够 handle。

7.3 Trim Out

拖动右边界 Δt

T' = T
D' = D + Δt
S' = S
L' = L + Δt × r

向右扩展时需要确认 source range 不超过 Asset 边界。

7.4 Split

K 处拆分,要求:

T < K < T + D

计算:

leftTimelineDuration  = K - T
leftSourceDuration    = leftTimelineDuration × r
rightTimelineStart    = K
rightSourceIn         = S + leftSourceDuration

拆分后必须处理:

  • 新 Clip ID。
  • Effect 是复制、拆分还是只保留一侧。
  • Keyframe 是否按区间分配并重新基准化。
  • Transition 是否失效。
  • linked audio 是否同步拆分。
  • group/link 关系是否继承。

7.5 Ripple Trim 与 Ripple Delete

Ripple 的核心不是“改变当前 Clip”,而是把时长变化传播给后续编辑点。

若当前 Clip 时长变化为:

ΔD = D' - D

则作用域内所有后续 Clip:

timelineStart' = timelineStart + ΔD

Ripple Delete 删除一个区间 [A, B) 后,将后续 Clip 左移 B-A

必须明确 ripple scope:

current_track
selected_tracks
all_unlocked_tracks
sync_group

没有明确作用域的 Ripple 很容易破坏字幕、配音和音效同步。

7.6 Slip

Slip 改变 Clip 使用的源素材区间,但不改变其时间轴位置和时长:

T' = T
D' = D
S' = S + Δs
L' = L

适合“保持镜头在成片中的位置不变,只换用素材中的另一段内容”。

校验:

0 <= S'
S' + L <= assetDuration

默认情况下,基于 clip timeline 的关键帧不移动;基于 source time 的分析标记会随 source range 改变。

7.7 Slide

假设 A、B、C 三个 Clip 首尾相接。Slide 移动 B,但保持:

  • B 的 source range 不变。
  • B 的时长不变。
  • 整个 A+B+C 的总时长不变。
  • 不产生空隙。

B 向右移动 Δ

A 的 Out 向右扩展 Δ
B 的 Start 和 End 向右移动 Δ
C 的 In 向右裁剪 Δ,C 的 End 保持不变

因此 Slide 需要同时检查 A 的尾部 handle 和 C 的头部可裁剪空间。

7.8 Roll

Roll 移动两个相邻 Clip 的公共切点,但不改变总时长:

A.duration' = A.duration + Δ
B.start'    = B.start + Δ
B.duration' = B.duration - Δ

同时改变 A 的 source out 和 B 的 source in。

Roll 与 Slide 的面试区分:

  • Roll 移动一个切点,改变左右两个 Clip 的可见时长。
  • Slide 移动中间 Clip 的整体位置,改变两侧邻居的边界。

7.9 Replace 与 Insert

把新 Clip 放入 [A, B) 时至少有两种语义:

Overwrite/Replace

覆盖目标区间
必要时拆分或裁剪被覆盖 Clip
后续时间不移动

Insert

在 A 处插入新 Clip
将后续 Clip 整体右移新 Clip 时长

UI 必须显式区分,否则用户无法预测结果。


8. 拖动、吸附与编辑手势事务

8.1 时间轴坐标换算

x = (time - viewportStart) × pixelsPerSecond

反向换算:

time = viewportStart + x / pixelsPerSecond

为了避免浮点值进入 Canonical Timeline,应在最终写入前转换为整数微秒,并执行帧、采样点或编辑网格量化。

8.2 吸附阈值必须以像素定义

错误做法:

始终在 50ms 内吸附

当时间轴缩放变化时,50ms 可能对应 1px,也可能对应 100px。

正确做法:

snapThresholdTime = snapThresholdPixels / pixelsPerSecond

常见吸附候选:

  • 播放头。
  • Clip 起点和终点。
  • Marker。
  • Transition 边界。
  • 关键帧。
  • 字幕边界。
  • 节拍点。
  • Sequence 起点和终点。

吸附系统应返回结构化结果:

interface SnapResult {
  snappedTimeUs: TimeUs;
  deltaUs: number;
  targetType: "playhead" | "clip_edge" | "marker" | "keyframe";
  targetId?: string;
  priority: number;
}

不要让不同 UI 组件各自实现一套吸附算法。

8.3 Gesture State 与 Canonical State 分离

一次拖动可能产生数百个 Pointer Move 事件,但只应形成一次业务操作。

pointerdown
→ 创建 Gesture Session
→ pointermove 只更新 transient preview
→ requestAnimationFrame 合并视觉刷新
→ pointerup 生成一个 MoveClip Command
→ 校验并提交 Canonical Store

拖动取消时直接丢弃 Gesture State,无须执行 Undo。

8.4 多选与联动编辑

移动多个 Clip 时,不能逐个提交命令,否则中间状态可能违反碰撞约束。

应提交一个原子批次:

MoveClipsBatch
├── clip_ids
├── delta_us
├── target_track_mapping
└── snap_context

后端要么全部应用,要么全部拒绝。


9. Keyframe、Effect 与 Transition

9.1 Keyframe 的时间域

关键帧必须声明时间域:

clip_timeline
source
sequence

常见默认值:

  • 位置、缩放、透明度:clip_timeline
  • 与原始素材分析结果绑定的框或姿态:source
  • 全局滤镜或镜头运动:sequence

若不声明时间域,Slip、Trim 和变速后会出现歧义。

9.2 Clip-local Keyframe

Clip-local keyframe 使用相对 Clip 起点的 offset_us

0 <= offset_us <= clip.timeline_duration_us

优点:

  • Move Clip 时无需移动所有关键帧。
  • 复制 Clip 时关键帧自然复制。
  • 嵌套 Sequence 更容易复用。

Trim In 时应定义统一策略:

  1. 删除被裁掉区间内的关键帧。
  2. 保留区间内关键帧并减去 trim delta。
  3. 必要时在新边界插入一个求值后的关键帧,保持视觉连续。

9.3 Effect Schema 必须版本化

不要保存任意 JavaScript 表达式或未约束对象:

{
  "type": "blur",
  "schemaVersion": 2,
  "parameters": {
    "radius": 12.0,
    "quality": "medium"
  }
}

服务端维护:

Effect Type Registry
├── 参数 JSON Schema
├── 默认值
├── 允许范围
├── 前端 Inspector 描述
├── Preview 实现标识
└── Renderer 实现标识

这样才能保证旧项目可迁移、未知参数可拒绝、预览与渲染可对齐。

9.4 Transition 与媒体 Handle

若两个 Clip 在切点处做 500ms 居中叠化,则通常需要切点两侧各有额外媒体内容。

需要验证:

outgoing tail handle >= required duration
incoming head handle >= required duration

本项目推荐“边界 Transition + 虚拟重叠”模型:

  • 时间轴上的两个 Clip 仍然首尾相接。
  • Transition 保存切点关系和持续时间。
  • Preview/Render Compiler 在执行模型中构造重叠区间。
  • 主轨碰撞规则仍然保持简单。

显式 Clip 重叠模型也可以成立,但全系统只能选择一种 Canonical 语义,不能预览使用一种、渲染使用另一种。


10. 操作命令模型

10.1 为什么不直接保存任意 JSON Patch

JSON Patch 很通用,但缺少业务语义:

replace /clips/123/timelineStartUs

服务端很难判断这是 Move、Ripple、Slide,还是恶意绕过约束。

领域命令更适合编辑器:

MoveClips
TrimClip
SplitClip
RippleDelete
SlipClip
SlideClip
RollEdit
AddTransition
SetEffectParameter
SetKeyframes
ReorderTracks

10.2 命令结构

interface EditorCommand<TPayload = unknown> {
  operationId: string;
  clientSessionId: string;
  localSequence: number;
  type: string;
  payload: TPayload;
  createdAt: string;
}

interface OperationBatch {
  batchId: string;
  projectId: string;
  baseRevision: number;
  commands: EditorCommand[];
}

关键字段:

  • operationId:命令幂等键。
  • batchId:批次幂等键。
  • clientSessionId + localSequence:排查客户端乱序和重复提交。
  • baseRevision:乐观并发控制。

10.3 Command Handler

interface CommandContext {
  project: TimelineProject;
  assets: ReadonlyMap<string, AssetMetadata>;
}

interface CommandResult {
  nextProject: TimelineProject;
  inverseCommand: EditorCommand;
  touchedEntityIds: string[];
}

function applyCommand(
  context: CommandContext,
  command: EditorCommand,
): CommandResult {
  switch (command.type) {
    case "MoveClips":
      return applyMoveClips(context, command);
    case "TrimClip":
      return applyTrimClip(context, command);
    case "SplitClip":
      return applySplitClip(context, command);
    default:
      throw new Error(`unsupported command: ${command.type}`);
  }
}

同一套命令语义至少应存在三类实现或消费者:

Frontend optimistic apply
Go backend authoritative validation/apply
Timeline Compiler

它们不能通过复制粘贴后各自演化。推荐维护:

  • 语言无关命令协议。
  • JSON Schema。
  • 黄金测试向量。
  • Canonical project hash。
  • 跨语言一致性测试。

11. Undo/Redo

11.1 基本模型

每个已提交命令都产生一个逆命令:

MoveClip(+5s)       ↔ MoveClip(-5s)
SetVolume(0.8)      ↔ SetVolume(previousValue)
DeleteClip          ↔ RestoreClip(snapshot)
SplitClip           ↔ MergeSplitResult

前端维护:

undoStack
redoStack

执行新命令后:

push undoStack
clear redoStack

11.2 不要把每次 Pointer Move 放入历史

一次拖动只生成一条历史记录:

before: 10s
after:  17s

不要生成:

10.01s
10.03s
10.08s
...
17s

11.3 命令合并

连续输入可按规则合并:

SetText("A")
SetText("AB")
SetText("ABC")

在同一编辑会话和短时间窗口内,可压缩成一条:

SetText(before="", after="ABC")

合并规则必须由命令类型定义,而不是全局随意合并。

11.4 服务端规范化后的 Undo

服务端可能执行:

  • 帧吸附。
  • 参数裁剪。
  • 默认值补全。
  • Transition 自动删除。
  • ID 生成。

因此 Undo 不能只依赖客户端提交前的猜测。服务端响应应返回实际应用后的 Canonical Command 或 Entity Delta,前端据此修正历史。

11.5 协作场景的区别

单用户 Undo 是回退本地历史。

多人协作中的 Undo 不应把整个项目 revision 回滚,而应生成一个新的“反向业务操作”,并考虑其他用户在其后做出的修改。这属于 selective undo、OT 或 CRDT 范畴,不能直接复用单机栈模型。


12. 自动保存、离线日志与乐观版本

12.1 保存链路

用户操作
→ 前端立即 optimistic apply
→ 命令写入本地 Journal
→ 300~1000ms debounce
→ 合并成 Operation Batch
→ POST /operations
→ 服务端幂等校验
→ revision CAS
→ 应用并验证命令
→ 返回 new_revision
→ 本地标记已确认

12.2 为什么使用 IndexedDB

自动保存队列不能只存在内存中。浏览器崩溃、刷新或网络断开后,用户未确认的编辑操作仍应能够恢复。

IndexedDB 提供按 key 保存结构化记录、索引和事务的浏览器数据库能力,适合保存项目缓存、待提交命令和恢复元数据。2

建议对象仓库:

project_snapshots
pending_batches
confirmed_operations
asset_metadata_cache
editor_preferences

不要把大型视频 Blob 长期塞入项目状态数据库;媒体仍应走对象存储、Cache Storage 或浏览器媒体缓存策略。

12.3 API 示例

POST /v1/projects/{project_id}/operations
Content-Type: application/json
Idempotency-Key: <batch_id>
{
  "baseRevision": 125,
  "clientSessionId": "01J...",
  "batchId": "01J...",
  "commands": [
    {
      "operationId": "01J...",
      "localSequence": 48,
      "type": "MoveClips",
      "payload": {
        "clipIds": ["clip-1", "clip-2"],
        "deltaUs": 2000000
      },
      "createdAt": "2026-06-24T12:00:00+09:00"
    }
  ]
}

成功:

{
  "projectId": "project-1",
  "previousRevision": 125,
  "newRevision": 126,
  "appliedBatchId": "01J...",
  "canonicalCommands": [],
  "warnings": []
}

冲突:

409 Conflict
{
  "code": "REVISION_CONFLICT",
  "expectedRevision": 125,
  "currentRevision": 128,
  "operationsSinceBase": [
    {
      "revision": 126,
      "type": "TrimClip",
      "touchedEntityIds": ["clip-9"]
    }
  ]
}

12.4 冲突处理策略

按复杂度分层:

MVP

发现 409
→ 停止继续提交
→ 拉取最新快照
→ 提示用户重新应用本地修改

单用户多端

获取 base_revision 之后的操作
→ 判断 touched entity 是否相交
→ 不相交则自动 rebase
→ 相交则进入冲突界面

多人实时协作

领域 OT / CRDT
Presence
Selective Undo
Server Sequencing

不要为了“看起来高级”直接把整个 Timeline JSON 做成通用 CRDT。视频编辑操作含有大量跨实体不变量,领域命令和服务端定序通常更可控。

12.5 幂等与重复响应

同一个 batch_id 重试时,服务端应返回第一次成功应用的结果,而不是再次移动 Clip。

数据库至少需要:

UNIQUE(project_id, batch_id)
UNIQUE(project_id, operation_id)

13. Go 后端设计

13.1 API 模型

package editor

import (
    "context"
    "encoding/json"
    "time"
)

type TimeUS int64

type Rational struct {
	Num int64 `json:"num"`
	Den int64 `json:"den"`
}

type Command struct {
	OperationID    string          `json:"operationId"`
	LocalSequence int64           `json:"localSequence"`
	Type          string          `json:"type"`
	Payload       json.RawMessage `json:"payload"`
	CreatedAt     time.Time       `json:"createdAt"`
}

type ApplyBatchRequest struct {
	BaseRevision   int64     `json:"baseRevision"`
	ClientSessionID string   `json:"clientSessionId"`
	BatchID        string    `json:"batchId"`
	Commands       []Command `json:"commands"`
}

type Warning struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

type ApplyBatchResponse struct {
	ProjectID        string    `json:"projectId"`
	PreviousRevision int64     `json:"previousRevision"`
	NewRevision      int64     `json:"newRevision"`
	AppliedBatchID   string    `json:"appliedBatchId"`
	Warnings         []Warning `json:"warnings"`
}

API 层还应对以下内容做上限校验:

  • 命令数量。
  • Payload 字节数。
  • Clip 数量。
  • 项目最大时长。
  • Effect 参数深度。
  • Keyframe 数量。
  • 所有时间值是否能在前端安全整数范围内表达。

13.2 服务层伪代码

func (s *Service) ApplyBatch(
	ctx context.Context,
	projectID string,
	userID string,
	req ApplyBatchRequest,
) (ApplyBatchResponse, error) {
	if err := s.authorizer.CanEdit(ctx, userID, projectID); err != nil {
		return ApplyBatchResponse{}, err
	}
	if err := validateEnvelope(req); err != nil {
		return ApplyBatchResponse{}, err
	}

	var out ApplyBatchResponse
	err := s.db.WithTx(ctx, func(tx Tx) error {
		// 1. 幂等命中时直接返回旧结果。
		if saved, ok, err := tx.FindAppliedBatch(ctx, projectID, req.BatchID); err != nil {
			return err
		} else if ok {
			out = saved
			return nil
		}

		// 2. 锁定项目 revision,避免并发批次同时通过。
		project, err := tx.GetProjectForUpdate(ctx, projectID)
		if err != nil {
			return err
		}
		if project.Revision != req.BaseRevision {
			return NewRevisionConflict(req.BaseRevision, project.Revision)
		}

		// 3. 加载 Canonical State,并按顺序原子应用整个批次。
		state, err := s.stateLoader.Load(ctx, tx, project)
		if err != nil {
			return err
		}

		for _, command := range req.Commands {
			if err := s.commandValidator.Validate(ctx, state, command); err != nil {
				return err
			}
			if err := s.commandApplier.Apply(state, command); err != nil {
				return err
			}
		}

		// 4. 批次结束后执行全局不变量校验。
		if err := s.timelineValidator.Validate(state); err != nil {
			return err
		}

		newRevision := project.Revision + 1
		if err := tx.AppendOperations(ctx, projectID, newRevision, req); err != nil {
			return err
		}
		if err := tx.UpdateProjectRevision(ctx, projectID, project.Revision, newRevision); err != nil {
			return err
		}

		out = ApplyBatchResponse{
			ProjectID:        projectID,
			PreviousRevision: project.Revision,
			NewRevision:      newRevision,
			AppliedBatchID:   req.BatchID,
		}
		return tx.SaveAppliedBatch(ctx, projectID, req.BatchID, out)
	})
	return out, err
}

13.3 PostgreSQL 表设计

CREATE TABLE edit_projects (
    id                  uuid PRIMARY KEY,
    owner_id            uuid NOT NULL,
    title               text NOT NULL,
    schema_version      integer NOT NULL,
    revision            bigint NOT NULL DEFAULT 0,
    latest_snapshot_id  uuid,
    created_at          timestamptz NOT NULL DEFAULT now(),
    updated_at          timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE edit_project_operations (
    project_id          uuid NOT NULL,
    operation_id        uuid NOT NULL,
    batch_id            uuid NOT NULL,
    applied_revision    bigint NOT NULL,
    command_index       integer NOT NULL,
    command_type        text NOT NULL,
    payload             jsonb NOT NULL,
    client_session_id   text NOT NULL,
    local_sequence      bigint NOT NULL,
    created_at          timestamptz NOT NULL,
    applied_at          timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (project_id, operation_id),
    UNIQUE (project_id, applied_revision, command_index)
);

CREATE TABLE edit_project_batches (
    project_id          uuid NOT NULL,
    batch_id            uuid NOT NULL,
    base_revision       bigint NOT NULL,
    applied_revision    bigint NOT NULL,
    response_body       jsonb NOT NULL,
    applied_at          timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (project_id, batch_id)
);

CREATE TABLE edit_project_snapshots (
    id                  uuid PRIMARY KEY,
    project_id          uuid NOT NULL,
    revision            bigint NOT NULL,
    schema_version      integer NOT NULL,
    snapshot            jsonb NOT NULL,
    content_hash        text NOT NULL,
    created_at          timestamptz NOT NULL DEFAULT now(),
    UNIQUE (project_id, revision)
);

13.4 Snapshot 与 Operation Log

两种常见方案:

方案 A:每次直接覆盖完整 JSONB

优点:实现简单。

缺点:大型项目写放大明显,冲突和审计能力弱。

方案 B:Append-only Operation Log + 周期性 Snapshot

优点:

  • 命令可审计。
  • Undo、冲突分析和恢复更容易。
  • 不必每次重写整个大 JSON。

代价:

  • 读取需要 Snapshot + Replay。
  • 需要 Compaction。
  • 命令协议必须长期兼容。

生产系统推荐 B,但可在早期采用混合方式:每批保存操作,同时异步或每 N 个 revision 生成快照。

13.5 不要把 Redis 当作项目事实源

Redis 可以缓存:

project:{id}:revision
project:{id}:snapshot-cache
project:{id}:presence
project:{id}:edit-lease

但以下内容仍应以 PostgreSQL 和对象存储快照为事实源:

Canonical Timeline
Operation Log
Revision
Named Version
Render Input

14. 大型时间轴性能设计

14.1 不要渲染全部 Clip DOM

当项目包含数千个 Clip 时,只渲染:

可视轨道
可视时间范围
左右预加载缓冲区
当前选择和拖动对象

时间范围查询可使用:

  • timeline_start 排序数组 + 二分查找。
  • 每轨 Interval Tree。
  • 分桶索引。
  • 大项目中的分段页。

14.2 时间轴视图虚拟化

viewportStartUs
viewportEndUs
pixelsPerSecond
visibleTrackRange
overscanUs

计算可视 Clip:

clip.end > viewportStart - overscan
AND
clip.start < viewportEnd + overscan

轨道纵向也要虚拟化,尤其是字幕、音效和自动生成镜头很多时。

14.3 分层渲染 UI

推荐混合:

DOM            Clip 交互框、可访问性、选中状态
Canvas         时间标尺、波形、缩略图 tile、节拍网格
Overlay DOM    拖动辅助线、吸附提示、上下文菜单

不必为了“性能”把所有内容画进一个巨大 Canvas。纯 Canvas 会增加命中测试、文本编辑和无障碍实现难度。

14.4 LOD:不同缩放级别展示不同细节

极低缩放:只显示 Clip 色块和标题
低缩放:稀疏缩略图
中缩放:缩略图 + 简化波形
高缩放:密集缩略图、采样级波形、关键帧

缩略图和波形应由服务端预生成多级 Tile,不要在每次缩放时重新下载整个大图。

14.5 主线程职责最小化

可放入 Dedicated Worker 的任务:

  • 大型时间轴索引重建。
  • 波形 tile 解码和几何计算。
  • 缩略图布局。
  • Canonical hash。
  • 批量校验。
  • 操作日志压缩。

HTML 标准定义了 Dedicated Worker 和 Shared Worker,Worker 可通过消息通道接收结构化数据;二进制缓冲还可以使用 transferable 避免额外复制。3

不要假定“使用 Worker 就一定是另一个物理线程”;设计上只应依赖其独立执行环境和消息边界。

14.6 高频交互优化

拖动中:

  • Pointer Move 只写 transient store。
  • 使用 requestAnimationFrame 合并视觉更新。
  • 不完整序列化项目。
  • 不触发网络保存。
  • 不重建全轨索引。
  • 不让所有 Inspector 组件重新渲染。

拖动结束后:

  • 生成一个领域命令。
  • 更新受影响实体。
  • 增量更新索引。
  • 进入 Undo 和 Save Queue。

14.7 媒体代理与索引

原始素材可能是:

4K/8K
H.265/AV1
高码率
VFR
长 GOP
HDR
手机旋转元数据
多音轨

编辑器应优先使用:

低分辨率 Proxy
音频 Proxy
缩略图 Sprite/Tile
Waveform Tile
关键帧索引
媒体探测元数据

MSE 提供了由 JavaScript 构造并向媒体元素追加分段媒体的缓冲模型,其设计目标包含拼接、时移和视频编辑等场景;但它不是完整 NLE 引擎,轨道编排、解码策略和同步仍需应用自行设计。4

WebCodecs 则提供更底层的音视频编解码接口,但规范不要求浏览器支持任何特定 codec,因此编辑器必须执行能力检测并保留 <video>、代理媒体或服务端转码回退路径。5


15. Canonical Timeline 校验器

保存前和渲染前都必须校验,但两者目标不同:

  • 编辑保存校验:快速、可解释、适合实时交互。
  • 渲染编译校验:完整、严格、保证可执行。

15.1 基础不变量

所有 ID 唯一
所有引用存在
所有时间为安全整数
所有 duration > 0
所有 timeline_start >= 0
source_in >= 0
source_in + source_duration <= asset_duration
playback_rate.num > 0
playback_rate.den > 0
Track 属于正确 Sequence
Clip 类型与 Track 类型兼容
Track 内 clip_ids 与 clipsById 一致
Keyframe 有序且位于允许范围
Effect 参数符合 schema
Transition 引用两个存在且兼容的 Clip

15.2 Exclusive Track 不变量

按 start 排序后,对相邻 Clip:

previous.end <= next.start

Transition 使用虚拟重叠模型时,不应通过实际 Clip 重叠破坏该约束。

15.3 Sequence Duration

Sequence duration 应由内容派生,或至少与派生值一致:

max(clip.timeline_start + clip.timeline_duration)

空 Sequence 可为 0。

15.4 安全与资源上限

必须限制:

单项目 Track 数
Clip 数
Keyframe 数
Effect 数
字幕长度
文本字体大小和变换范围
嵌套 Sequence 深度
项目 JSON 字节数
单批命令数
命令 Payload 深度

禁止:

  • 在 Text Clip 中保存任意 HTML 并直接 innerHTML 渲染。
  • 在 Effect 中保存可执行 JavaScript。
  • 接受任意外部媒体 URL 作为 Renderer 输入。
  • 让用户通过极端数值制造巨大画布或指数级渲染开销。

16. 前端状态分层

推荐把状态明确分成五层:

示例是否持久化
Canonical ProjectClip、Track、Effect、Transition
Confirmed Server Stateserver revision、已确认 batch是/缓存
Pending Operation State待提交命令、重试信息本地持久化
Session State选中、工具、面板、播放头通常本地
Gesture State拖动偏移、框选、吸附候选

错误设计通常是把所有内容放入一个全局对象,然后:

鼠标每移动 1px
→ 整个项目对象变化
→ 全部组件重新计算
→ 自动保存序列化完整项目

正确数据流:

Pointer Event
→ Gesture Reducer
→ Visible Geometry Selector
→ UI Update
→ Gesture Commit
→ Domain Command
→ Canonical Reducer
→ Undo + Journal + Save Queue

17. 失败场景与恢复

17.1 保存超时,但服务端实际成功

客户端不能直接再次生成新 batch_id

正确处理:

使用相同 batch_id 重试
→ 服务端命中幂等记录
→ 返回第一次结果

17.2 两个标签页同时编辑

可能发生:

Tab A base_revision=10
Tab B base_revision=10
A 保存成功,revision=11
B 保存得到 409

最小策略:B 停止提交并要求刷新。

更好的策略:

  • 本地广播 active editor 状态。
  • 默认只允许一个写入标签页。
  • 仍以服务端 revision 为最终保护,不能只依赖浏览器本地锁。

17.3 本地已应用,服务端拒绝

前端应保留:

confirmed base
pending commands
optimistic projection

某批失败后:

  1. 回到最近 confirmed base。
  2. 移除失败命令。
  3. 重新 replay 其后仍有效的 pending commands。
  4. 展示可定位到实体的错误。

不要简单地“整页刷新并丢弃所有未保存编辑”。

17.4 Asset 在编辑期间不可用

Timeline 不应因此损坏。

UI 可显示:

Missing Media
Proxy Pending
Asset Quarantined
Permission Lost

Clip 仍保留 asset_id 和编辑决策,待资产恢复或用户执行 Relink。

17.5 Schema 升级

项目必须包含:

schema_version

读取流程:

load old snapshot
→ sequential migrations
→ validate
→ produce current canonical model

迁移必须可测试、可重复执行,并尽量保留未知字段或明确拒绝不支持的新版本。


18. 测试策略

18.1 单元测试

重点测试每个领域命令:

Move
Trim In/Out
Split
Ripple Delete
Slip
Slide
Roll
Add/Remove Transition
Set Keyframe
Undo/Redo

每个测试同时断言:

  • 目标实体变化。
  • 非目标实体不变。
  • 时间不变量。
  • source boundary。
  • collision policy。
  • inverse command。

18.2 属性测试

比固定案例更重要的性质:

apply(command) + apply(inverse) == original canonical hash
Split 后两个片段总时长 == 原片段时长
Roll 不改变两个相邻 Clip 的总时长
Slide 不改变 A+B+C 总时长
Slip 不改变 timeline range
Move 不改变 source range
任何成功命令后 Timeline Validator 均通过
序列化再反序列化后 Canonical Hash 不变

18.3 跨语言黄金测试

维护一组:

initial_project.json
commands.json
expected_project.json
expected_errors.json

TypeScript、Go 和 Timeline Compiler 都运行同一组测试向量。

这是防止“前端显示 01:00:00:00,后端保存成 00:59:59:29”的关键措施。

18.4 Fuzz 与恶意输入

重点 Fuzz:

  • 极大和负时间。
  • 分母为 0。
  • 重复 ID。
  • 循环嵌套 Sequence。
  • 超深 JSON。
  • Transition 引用自身。
  • NaN、Infinity。
  • 大量零时长 Clip。
  • 乱序和重复 Keyframe。

18.5 性能测试

构造:

100 Tracks
10,000 Clips
100,000 Keyframes
多级缩略图和波形
持续缩放、滚动和拖动

测量:

  • Pointer 到视觉反馈延迟。
  • 可视 Clip 查询耗时。
  • 单命令 apply 耗时。
  • Canonical 序列化耗时。
  • Worker 消息体大小。
  • 内存占用和释放。
  • 自动保存队列积压。

18.6 E2E 测试

至少覆盖:

拖动并吸附
多选移动
拆分后撤销
刷新后恢复未确认命令
网络断开后继续编辑并重连
revision 冲突
Asset Missing
浏览器后退/关闭保护
键盘精确移动一帧

19. 常见错误设计

错误 1:把 <video>.currentTime 当作时间轴事实

currentTime 是播放器状态,不是项目编辑模型。项目时间必须由 Canonical Timeline 决定。

错误 2:所有时间都用浮点秒

短 Demo 看不出问题,复杂编辑后会出现边界、吸附和跨语言不一致。

错误 3:Clip 保存签名 URL

签名 URL 会过期,也不能代表资产身份。Clip 应引用 Asset ID。

错误 4:拖动时持续请求后端

会制造大量写入、乱序响应和历史污染。拖动过程是 transient state,结束后只提交一次。

错误 5:Undo 保存完整项目深拷贝

小项目可用,大项目会造成内存和 GC 压力。优先使用领域命令和逆命令;必要时配合周期性本地快照。

错误 6:只保存最终 Snapshot,不保留操作幂等键

保存超时后无法判断是否已成功,重复提交可能重复移动或删除。

错误 7:Effect 参数不做版本化

旧项目无法稳定打开,Preview 和 Renderer 也会各自解释参数。

错误 8:预览模型和渲染模型各写一套

最终会出现:预览看起来正确,导出错位、转场长度不同或字幕位置变化。

错误 9:大型时间轴仍渲染全部 DOM

缩放和滚动时会触发布局、绘制和组件更新风暴。

错误 10:把多人协作等同于“给 JSON 加 WebSocket”

实时连接只解决传输,不解决并发编辑语义、顺序、冲突和 Undo。


20. 面试高频问题与参考答案

问题 1:浏览器视频编辑器的核心数据是什么?

参考答案:

核心不是视频 Blob,而是 Canonical Timeline。原始 Asset 保持不可变,项目保存 Track、Clip、source range、timeline range、Effect、Transition 和 Keyframe。预览与最终渲染都消费同一份时间轴事实模型。

问题 2:为什么不用浮点秒?

参考答案:

浮点秒在多次拆分、变速和跨语言计算中会产生边界误差。我的做法是 Canonical Timeline 使用整数微秒,帧率、播放速度和媒体 time base 使用有理数,并在 TypeScript、Go 和 Renderer 中共享统一舍入测试向量。

问题 3:source time 与 timeline time 有什么区别?

参考答案:

source time 表示 Clip 在原始 Asset 中取哪一段;timeline time 表示这段内容在成片中何时出现。变速后两者不是一比一关系,映射为 sourceIn + (timelineTime-timelineStart) × playbackRate

问题 4:Undo/Redo 怎样设计?

参考答案:

我使用领域命令和逆命令,而不是每次保存完整项目。拖动过程只更新 transient state,pointerup 时生成一个命令。服务端规范化后,前端用实际应用结果更新 Undo 历史。多人协作时则使用新的反向操作,而不是全局回滚 revision。

问题 5:自动保存怎样避免冲突?

参考答案:

每批操作携带 batch_idoperation_idbase_revision。服务端通过唯一约束实现幂等,通过乐观版本实现 CAS。冲突返回当前 revision 和 base 之后的操作;不相交修改可 rebase,相交修改需要提示或进入协作算法。

问题 6:上万个 Clip 怎样优化?

参考答案:

数据层归一化,每轨维护按时间排序或 Interval Index;UI 只渲染可视时间区间和可视轨道,缩略图、波形采用分级 Tile;拖动使用 transient store 和 rAF 合并更新;索引重建与几何计算放入 Worker。

问题 7:Transition 为什么独立建模?

参考答案:

Transition 是两个 Clip 的关系,需验证相邻性、轨道兼容性和媒体 handle。独立实体比散落在 Clip 字段中更容易处理删除、移动、版本迁移和渲染编译。

问题 8:怎样保证预览和导出一致?

参考答案:

两者必须读取同一个 Canonical Timeline 和同一套 Effect/Transition Schema。前端不能保存仅供 CSS 使用的隐式状态;第 16 章的 Timeline Compiler 会把 Canonical Timeline 编译成 Render DAG,预览端也应尽量复用同样的时间映射与参数求值测试向量。

问题 9:为什么不直接把所有项目状态放 Redis?

参考答案:

项目时间轴、revision 和操作日志是核心事实,需要事务、审计和可靠恢复,应放 PostgreSQL/对象存储快照。Redis 只适合缓存、presence、临时 lease 和通知加速。

问题 10:前端非线性编辑器最难的地方是什么?

参考答案:

不是画一个时间轴,而是把每种编辑操作定义成确定的业务语义,并让前端交互、后端保存、Undo、冲突处理和最终 Renderer 对这些语义得出相同结果。


21. 60 秒面试口述模板

我们的前端编辑器采用非破坏式编辑,原始视频和音频都作为不可变 Asset 保存,时间轴只记录 Track、Clip、source range、timeline range、播放速度、关键帧、效果和转场。Canonical Timeline 使用整数微秒,帧率和播放速度使用有理数,避免浮点累计误差。所有 trim、split、move、ripple、slip、slide 和 roll 操作都被定义为领域命令,拖动期间只更新临时状态,结束时提交一个原子命令,因此 Undo、自动保存和审计都比较清晰。保存接口携带 batch 幂等键和 base revision,服务端用唯一约束防重复,用乐观锁处理并发。大型项目中,时间轴使用归一化 Store、每轨时间索引、横纵双向虚拟化、缩略图和波形 Tile,并把重计算放到 Worker。预览和最终渲染都消费同一份 Canonical Timeline,后续再由 Timeline Compiler 编译为 Render DAG。


22. 自测题

题目

  1. 为什么 Clip 不能直接保存对象存储签名 URL?
  2. group_idlink_group_id 有什么区别?
  3. 1.25 倍速时,2 秒 timeline duration 消耗多少 source duration?
  4. Slip 为什么不改变 Clip 的 timeline range?
  5. Slide 与 Roll 的主要差异是什么?
  6. 为什么吸附阈值应以像素定义?
  7. 为什么一次拖动只应生成一个 Undo Command?
  8. base_revision 解决什么问题,operation_id 又解决什么问题?
  9. 为什么 Transition 需要检查 source handle?
  10. 为什么前端和 Go 后端必须共享时间换算黄金测试?

答案

  1. 签名 URL 会过期且不是资产身份;Clip 应引用不可变 Asset ID,由资产服务按权限生成访问地址。
  2. group_id 表示多个 Clip 作为 UI 组操作;link_group_id 表示视频与音频等保持同步编辑关系,并可解除链接。
  3. 2 × 1.25 = 2.5 秒 source duration。
  4. Slip 的目的就是保持成片位置和时长不变,只改变原素材取段。
  5. Roll 移动两个相邻 Clip 的公共切点;Slide 移动中间 Clip 整体,同时调整两侧邻居,整体区间长度不变。
  6. 像素阈值能在不同缩放级别保持一致的手感。
  7. 高频 Pointer Move 是手势临时状态,不是独立业务意图;逐帧记录会污染历史并放大保存压力。
  8. base_revision 检测并发修改;operation_id/batch_id 防止超时重试导致同一操作重复应用。
  9. 转场需要在切点附近同时读取两侧素材;没有额外 handle 就无法构造完整过渡区间。
  10. 否则相同 Timeline 在不同语言中可能因整数除法和舍入方式不同而产生帧级偏差。

23. 本章总结

本章最重要的结论是:

  1. 非破坏式编辑:原始 Asset 不变,项目保存编辑决策。
  2. Canonical Timeline:所有模块共享同一份确定性事实模型。
  3. 整数时间 + 有理数:避免浮点秒成为持久化事实。
  4. 明确编辑语义:Move、Trim、Split、Ripple、Slip、Slide、Roll 都必须有可验证公式和不变量。
  5. 领域命令:UI 不直接任意修改项目,持久化修改统一走 Command Bus。
  6. 手势事务:拖动是临时状态,结束后只提交一次原子操作。
  7. Undo 与自动保存统一:逆命令、幂等批次、本地 Journal 和乐观 revision 构成恢复链路。
  8. 大型时间轴虚拟化:只渲染可视范围,媒体可视化使用多级 Tile,重计算移出主线程。
  9. 后端仍然重要:Go 服务负责权限、幂等、版本、命令校验、操作日志和快照。
  10. 预览与渲染同源:不能让 CSS 预览、后端渲染和项目保存各自解释时间轴。

一句面试总结:

前端视频编辑器的难点不是拖拽组件,而是把复杂编辑行为收敛成确定、可重放、可冲突检测、可编译的时间轴语义。


24. 下一章衔接

第 15 章将继续回答:浏览器如何低延迟预览这份 Canonical Timeline。

重点包括:

  • HTMLVideoElement、Canvas 与 Web Audio 的能力边界。
  • WebCodecs、OffscreenCanvas 和 WebGL/WebGPU。
  • 为什么音频时钟通常应作为主时钟。
  • requestAnimationFrame 为什么不能作为媒体事实时钟。
  • GOP、关键帧、VFR 与精确 seek。
  • 帧缓存、预加载、淘汰和资源释放。
  • 多轨音频混音与漂移校正。
  • Proxy、原始素材与预览质量切换。

25. 官方资料