普通视频是一条固定的时间线,播放器只要管 seek 和缓冲。互动视频不是------它在特定时刻停住,给观众几个选项,选完才知道下一段播什么。
这个区别一旦落到实现上,就不只是「多几个按钮」那么简单。本文记录把剧情建模成有向图之后,真正需要解决的三个工程问题:预加载该预取哪几条分支、选择界面的背景帧从哪来,以及为什么结构选错会让制作量指数爆炸。
一、数据结构:从数组到有向图
线性视频的模型是 Clip[],互动视频的模型是一张有向图:节点是场景,边是选择。

最小可用的类型定义大致长这样:
ts
type NodeId = string;
interface StoryNode {
id: NodeId;
video: string;
next?: NodeId; // 线性续接
choices?: Choice[]; // 或者分叉,二选一
effects?: Record<string, number>; // 走到这个节点时写变量
}
interface Choice {
label: string;
target: NodeId;
if?: string; // 条件表达式,不满足则不展示这个选项
}
next 和 choices 互斥:有 next 就是线性节点,有 choices 就是选择点。这个约束值得在校验层强制,否则运行时要处理一堆歧义状态。
二、状态机:让第一集的决定影响第三集
只有跳转的话,这就是个流程图。真正让它变成状态机的是变量------effects 写,if 读。

条件求值不要直接上 eval。互动剧的条件表达式复杂度很低,手写一个受限求值器就够,而且能避免把任意代码执行权交给内容文件:
ts
type Vars = Record<string, number>;
const OPS: Record<string, (a: number, b: number) => boolean> = {
'>=': (a, b) => a >= b,
'<=': (a, b) => a <= b,
'>': (a, b) => a > b,
'<': (a, b) => a < b,
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
};
// 只支持 "varName OP number" 这一种形式,故意不支持更多
function evalCondition(expr: string, vars: Vars): boolean {
const m = expr.trim().match(/^(\w+)\s*(>=|<=|==|!=|>|<)\s*(-?\d+)$/);
if (!m) throw new Error(`bad condition: ${expr}`);
const [, name, op, num] = m;
return OPS[op]((vars[name] ?? 0), Number(num));
}
未定义的变量默认取 0,这样内容作者不必在开头声明一堆初始值,写 trust >= 1 就能直接用。
三、工程问题一:预加载该预取哪几条分支
这是互动视频最实际的性能问题。线性视频只要往前缓冲就行;到了选择点,你不知道观众会选哪条,但任何一条的首帧卡顿都会毁掉体验------观众刚做完决定,最不能接受的就是转圈。
全预取显然不行:一个选择点两条分支就是双倍带宽,连着几个选择点会把流量打爆。实际可用的策略是按需分层预取:
ts
const PREFETCH_LEAD_MS = 8000; // 选择点前 8 秒开始预取
function prefetchPlan(node: StoryNode, vars: Vars) {
if (!node.choices) return node.next ? [node.next] : [];
// 只预取当前条件下真正可见的分支
return node.choices
.filter(c => !c.if || evalCondition(c.if, vars))
.map(c => c.target);
}
// 预取首片段而非整段:够撑到正式请求接上即可
async function warmUp(ids: NodeId[], graph: Map<NodeId, StoryNode>) {
await Promise.all(ids.map(id => {
const v = graph.get(id)?.video;
return v && fetch(v, { headers: { Range: "bytes=0-524287" } });
}));
}
两个关键点。第一,用 if 过滤后再预取 ------条件不满足的分支根本不会展示,预取它纯属浪费。第二,只取前几百 KB,够无缝接上正式请求就行,不必整段拉下来。
实测下来,2 个可见分支各预取 512KB,比全量预加载省掉约七成带宽,而切换时的首帧延迟基本感知不到。
四、工程问题二:选择界面的背景帧

很多实现在选择点直接切黑底卡片,然后放按钮。体验会立刻从「看剧」掉回「填表」。
正确做法是用刚播完那一段的尾帧作为背景,选项叠在观众正在看的画面上。两种取法:
客户端抓帧 ------video 播到最后一帧时用 canvas 抓:
ts
function captureLastFrame(video: HTMLVideoElement): string {
const c = document.createElement("canvas");
c.width = video.videoWidth;
c.height = video.videoHeight;
c.getContext("2d")!.drawImage(video, 0, 0);
return c.toDataURL("image/jpeg", 0.85);
}
零成本、零存储,但要求视频同源或服务端带 CORS 头,否则 canvas 会被污染,toDataURL 直接抛 SecurityError。
服务端抽帧 ------转码时顺手 ffmpeg -sseof -0.1 -i in.mp4 -frames:v 1 last.jpg,把尾帧当静态资源存下来。多一份存储,但能提前预加载,且不受 CORS 限制。生产环境更推荐这个。
五、工程问题三:结构决定成本
最后这个不是代码问题,但它决定项目做不做得完。

设 n 个选择点、每个 2 个选项:
- 发散结构 (分支永不汇合):结局数
2^n,需要制作的场景数量级O(2^n)。n=3 就是 8 个结局、最多 14 个独立场景。 - 汇合结构 (分支走几步就回主线):结局数固定,场景数
O(n)。同样 n=3,大约 8 个场景。
翻一倍的制作量换来的并不是翻一倍的体验------大多数观众只会走其中一条路径,剩下的产能对他们不存在。

所以成熟做法是混合:中段用汇合控制成本,结尾用发散做出真正不同的结局。观众最在乎的是结局不同,而不是中间每一幕都不同。
小结
把互动视频当成有向图之后,剩下的工作就很具体了:一个受限的条件求值器、一套按可见分支过滤的分层预取、一个尾帧管线,加上结构上的成本约束。
这几块拆开看都不复杂,难的是一开始就选对模型------如果还在用「片段数组 + 一堆 if」的思路硬做,后面每一步都会别扭。
本文首发于 ReelFork 官方博客,原文(含更多结构设计细节):www.reelfork.com/blog/what-i...