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. 学习目标
完成本章后,应能够回答:
- 为什么专业视频编辑必须采用非破坏式编辑?
- Asset、Sequence、Track、Clip、Effect 和 Transition 应怎样建模?
- 为什么不能把浮点秒作为时间轴唯一事实表示?
- source time、clip-local time 和 timeline time 有什么区别?
- trim、split、move、ripple、slip、slide、roll 的精确定义是什么?
- 如何设计不会因拖动而产生数百条历史记录的 Undo/Redo?
- 如何通过操作命令、幂等键和乐观版本完成自动保存?
- 上千个 Clip 的时间轴如何保持流畅?
- 前端 TypeScript 与 Go 后端如何共享同一组时间轴不变量?
- 如何保证预览、保存和最终渲染使用同一个 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 本项目采用的时间表示
建议采用分层策略:
- Canonical Timeline:整数微秒
int64。 - 帧率、播放速度、time base:有理数
{num, den}。 - 媒体原始时间戳:保留 PTS/DTS 与 time base 元数据。
- 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 时应定义统一策略:
- 删除被裁掉区间内的关键帧。
- 保留区间内关键帧并减去 trim delta。
- 必要时在新边界插入一个求值后的关键帧,保持视觉连续。
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 Project | Clip、Track、Effect、Transition | 是 |
| Confirmed Server State | server 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
某批失败后:
- 回到最近 confirmed base。
- 移除失败命令。
- 重新 replay 其后仍有效的 pending commands。
- 展示可定位到实体的错误。
不要简单地“整页刷新并丢弃所有未保存编辑”。
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_id、operation_id 和 base_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. 自测题
题目
- 为什么 Clip 不能直接保存对象存储签名 URL?
group_id与link_group_id有什么区别?- 1.25 倍速时,2 秒 timeline duration 消耗多少 source duration?
- Slip 为什么不改变 Clip 的 timeline range?
- Slide 与 Roll 的主要差异是什么?
- 为什么吸附阈值应以像素定义?
- 为什么一次拖动只应生成一个 Undo Command?
base_revision解决什么问题,operation_id又解决什么问题?- 为什么 Transition 需要检查 source handle?
- 为什么前端和 Go 后端必须共享时间换算黄金测试?
答案
- 签名 URL 会过期且不是资产身份;Clip 应引用不可变 Asset ID,由资产服务按权限生成访问地址。
group_id表示多个 Clip 作为 UI 组操作;link_group_id表示视频与音频等保持同步编辑关系,并可解除链接。2 × 1.25 = 2.5秒 source duration。- Slip 的目的就是保持成片位置和时长不变,只改变原素材取段。
- Roll 移动两个相邻 Clip 的公共切点;Slide 移动中间 Clip 整体,同时调整两侧邻居,整体区间长度不变。
- 像素阈值能在不同缩放级别保持一致的手感。
- 高频 Pointer Move 是手势临时状态,不是独立业务意图;逐帧记录会污染历史并放大保存压力。
base_revision检测并发修改;operation_id/batch_id防止超时重试导致同一操作重复应用。- 转场需要在切点附近同时读取两侧素材;没有额外 handle 就无法构造完整过渡区间。
- 否则相同 Timeline 在不同语言中可能因整数除法和舍入方式不同而产生帧级偏差。
23. 本章总结
本章最重要的结论是:
- 非破坏式编辑:原始 Asset 不变,项目保存编辑决策。
- Canonical Timeline:所有模块共享同一份确定性事实模型。
- 整数时间 + 有理数:避免浮点秒成为持久化事实。
- 明确编辑语义:Move、Trim、Split、Ripple、Slip、Slide、Roll 都必须有可验证公式和不变量。
- 领域命令:UI 不直接任意修改项目,持久化修改统一走 Command Bus。
- 手势事务:拖动是临时状态,结束后只提交一次原子操作。
- Undo 与自动保存统一:逆命令、幂等批次、本地 Journal 和乐观 revision 构成恢复链路。
- 大型时间轴虚拟化:只渲染可视范围,媒体可视化使用多级 Tile,重计算移出主线程。
- 后端仍然重要:Go 服务负责权限、幂等、版本、命令校验、操作日志和快照。
- 预览与渲染同源:不能让 CSS 预览、后端渲染和项目保存各自解释时间轴。
一句面试总结:
前端视频编辑器的难点不是拖拽组件,而是把复杂编辑行为收敛成确定、可重放、可冲突检测、可编译的时间轴语义。
24. 下一章衔接
第 15 章将继续回答:浏览器如何低延迟预览这份 Canonical Timeline。
重点包括:
- HTMLVideoElement、Canvas 与 Web Audio 的能力边界。
- WebCodecs、OffscreenCanvas 和 WebGL/WebGPU。
- 为什么音频时钟通常应作为主时钟。
requestAnimationFrame为什么不能作为媒体事实时钟。- GOP、关键帧、VFR 与精确 seek。
- 帧缓存、预加载、淘汰和资源释放。
- 多轨音频混音与漂移校正。
- Proxy、原始素材与预览质量切换。