朋友给我发来一段很特别的视频,说这是某个叫 Opus 5.5 的AI工具“生成”的。我盯了几秒钟,第一反应是不对劲——画面太干净了。没有常见视频生成模型那种隐隐约约的噪点,边缘稳定,线条利落,几何结构严丝合缝,甚至每一帧之间的过渡都带着数学上的精确。我问他这工具怎么调的,他说不知道,反正是AI生成的。我跑去翻了下输出日志和工程目录,这才看到真相:Opus 5.5 压根不是在生成视频,而是在写代码,然后让浏览器把成百上千帧画面一帧一帧地画出来。
说白了,它走的不是“神经网络直接推理画面像素”的路子,而是“程序化生成 + 浏览器实时渲染”的路子。视频文件只是录制了浏览器绘制过程的结果,真正的核心,是一段可以反复修改、重新执行的代码。
这篇文章我想把这件事彻底拆开。从两种视频生成路线的本质差异,到浏览器逐帧绘制依赖的基础技术,再到一个可以直接上手复现的最小工程,以及我在实际调试中踩过的性能坑和录制坑。如果你也好奇“AI工具生成视频”背后到底是什么,或者想用代码完全掌控每一帧画面,这篇文章应该能给你一个相对完整的答案。
1. 先搞清楚“真相”:视频不是算出来的,是画出来的
1.1 两种“AI生成视频”的路线差异
市面上绝大多数视频生成工具,走的是端到端的内容生成路线。你把一段文字描述丢进去,模型在内部把语义映射成视觉特征,然后推理出一批连续的像素帧。这个过程本质上是在“算视频”——模型从头到尾负责所有帧的内容,你几乎无法干预某一帧的像素到底怎么排布,最多改改提示词。
但 Opus 5.5 这类工具走的是另一条路。它接收你的需求之后,先输出一段JavaScript代码,代码里定义了整个视频的视觉元素、运动规则和时间轴逻辑。浏览器拿到这段代码,开始解析执行,在Canvas画布上按每秒几十帧的频率重复绘制。每一帧画什么,取决于一个共享的时间变量。视频成品,只是把这段实时绘制过程录制下来的结果。
这两种路线有一个本质区别:一个是“结果导向的直接生成”,另一个是“过程导向的规则构建”。前者擅长轻松产出照片级、写实风格的片段,但不可控;后者画出的内容可能更抽象、更几何化,但每一帧的逻辑都在你的掌控之中,改一个参数就相当于重画一个世界。
1.2 为什么程序化渲染路线容易被当成“AI视频”
我猜很多人第一次看到 Opus 5.5 的作品,都会和我一样先入为主地认为它是传统视频生成模型。原因很好理解:用户界面里输入的是自然语言描述,输出的是一个视频文件,表面上和主流AI视频工具没有区别。至于内部到底是扩散模型逐帧去噪,还是浏览器执行循环绘制,界面层根本看不出来。
再加上这类工具输出的画面往往有很强的设计感,比如流动的粒子、渐变的几何体、持续变化的图案,观感上很“未来”,很“AI”。但实际上,这种视觉风格恰恰是程序化生成最擅长的事——它不需要理解真实世界的物理规律,只需要用数学函数描述运动即可。一个低频正弦波就能让整个画面产生呼吸感,几条贝塞尔曲线就能演化出复杂的路径轨迹。这些效果写起来不难,跑起来也不贵,但视觉效果非常唬人。
我后来把工程目录里的代码文件打开,发现整个视频的逻辑从一开始就是高度结构化的。它生成了脚本,脚本创建画布,设置帧循环,注册动画函数,然后通过媒体录制接口把画面导出为视频文件。整个过程里“AI生成”的部分只发生在最前端的需求理解与代码生成阶段,后面的视频合成完全是典型的浏览器前端工程。这就是标题里“真相”二字的由来——它不生成视频,它生成的是画出视频的代码。
1.3 为什么有人愿意选择这条路线
传统视频生成模型有一个很麻烦的问题:不可重复性。同一个提示词跑两次,得到的内容大概率不一样,你想微调某一帧的构图几乎不可能。而代码驱动的逐帧绘制完全绕开了这个问题。
首先,它非常确定。同一段代码,不管运行多少次,只要时间轴设置一致,得到的视频帧序列就是一致的,不存在“提示词相同但结果不同”的情况。其次,它支持精细控制。你可以只让某个粒子在某一秒改变方向,可以在第三秒突然切换配色方案——这些都是对代码的修改,而不是对成千上万帧像素的推测。最后,它可以嵌入更大的系统。因为输出本身是代码,可以被动态拼接、批量执行、参数化调用,这是传统生成模型做不到的。
这个特性让它在数据可视化、动态图形设计、自动化内容生产这些领域特别有优势。我见过有人用它批量生成科普动画里用于演示数学原理的抽象视频,也见过有人把这类代码封装成接口,让用户在前端页面上调参数、点按钮,实时预览并导出不同的动态效果。这些场景的共同点是:内容必须精确、可变、可控。而传统的AI视频生成在这些方面,目前还很难做到。
2. 代码驱动逐帧绘制的三个底层支柱
2.1 Canvas 是画布,requestAnimationFrame 是节拍器
想要让浏览器逐帧画出视频,最核心的两个底层能力是 Canvas 和 requestAnimationFrame。Canvas 是一块可编程绘制的画布,你可以在上面画出矩形、圆弧、路径、图像,也可以直接操作像素缓冲区。它的本质是一个位图绘制环境,每一帧画面最终都会被绘制到这个位图之上。
requestAnimationFrame 则是浏览器的帧同步机制。它告诉浏览器:“在下次屏幕刷新之前,请执行我传给你的回调函数。”浏览器会尽量把回调的执行频率和显示器的刷新率对齐,通常是 60 次每秒。这个机制比 setTimeout 或 setInterval 精准得多,因为它由浏览器统一调度,不会在后台标签页里白白消耗资源,也不会因为事件循环阻塞而跳帧。
把这两样东西放在一起,就构成了逐帧视频的完整循环:浏览器每次刷新屏幕前,调用一次绘制函数;绘制函数先清空上一次的画面,再根据当前的时间或帧序号计算位置、颜色等状态,最后把整个画面画到 Canvas 上。如此反复,视觉上就形成了连续的动画。每一帧单独拿出来都是静止图片,但它们的连续播放就是视频。
我第一次自己写这个循环时,最大的认知转变是:画面上的一切“动”,本质上都不存在。没有东西在真正移动,只是每一帧画的位置和上一帧略有不同而已。视频的流畅感来自帧与帧之间的微小差异,而帧率决定了这些差异在时间上的密度。
2.2 从绘制循环到视频文件:录制导出是关键一跳
只是让浏览器实时播放动画,离“生成一个视频文件”还差得远。要得到一个可分发、可上传、可剪辑的 mp4 或 webm 文件,必须把浏览器绘制的过程录制下来。这一步依赖的是 MediaRecorder 接口,以及 Canvas 的 captureStream 能力。
captureStream 的作用是给 Canvas 画布创建一个实时媒体流,这个流里的每一帧都对应画布上刚刚绘制的内容。MediaRecorder 接收这个流,按照你指定的编码格式和时间间隔,把流里的画面编码为一个视频容器文件。录制结束之后,你会得到一个 Blob 对象,可以直接通过 URL.createObjectURL 生成下载链接,也可以把它转成 ArrayBuffer 做进一步处理。
这里有一个很容易被忽略的细节:MediaRecorder 录制的是流,流的帧率由 captureStream 的参数决定,而不是由你的绘制循环决定的。如果你的绘制循环跑 60fps,但 captureStream 设置的是 30fps,那么录制结果就是 30fps,每两帧绘制画面里只有一帧会被捕获。反过来,如果你的绘制能力跟不上,实际帧率只有 20fps,但 captureStream 要求 60fps,录制工具也会尽量把录制时长拉长以匹配请求的帧率。这就可能导致视频画面时长和实际过程时长不一致,后期还需要调整时间轴。所以录制参数与绘制帧率保持一致,是最省心的做法。
2.3 时间驱动还是帧驱动,决定了视频的稳定性
写逐帧动画时,最大的分水岭是动画状态的管理方式。新手通常用帧驱动:每绘制一帧,就给某个变量加 1,然后根据这个变量计算画面内容。这套逻辑简单直观,但有个致命缺陷,一旦浏览器因为性能波动或页面卡顿跳过了某些帧,动画的时间感就会错乱。
更好的方案是时间驱动:给每一帧传入一个精确的时间戳,所有动画状态都由这个时间戳计算得出。比如“第 0.5 秒时粒子出现在屏幕左上角,第 2.3 秒时运动到中心”,这些都是时间的函数,和帧序号无关。好处在于,即使某一帧延迟了几十毫秒,它依然会绘制在正确的时间点上;就算录制过程中跳了几帧,视频的整体节奏也不会漂移。
我在实践里基本只用时间驱动。尤其是当视频要录制导出时,时间一致性直接决定成品质量。如果用帧驱动,一旦主线程繁忙或浏览器休眠,整个动画的速度就会忽快忽慢,对后期合成来说简直是灾难。而时间驱动的代码写起来也不复杂,只需要在绘制函数的开头读取 performance.now() 或时间戳参数,再用它做各种计算就行。
3. 动手复现一个最小逐帧视频工程
3.1 搭建场景与绘制函数
理论说再多都不如直接跑一个工程。我来带你从零搭一个最简单的逐帧视频生成器,效果是:暗色背景上有若干光点沿着正弦曲线运动,光点颜色随时间平滑变化。整个工程只有一个 HTML 文件,不需要任何依赖库,用浏览器打开就能看到动画。
先看 HTML 结构:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>逐帧动画录制示例</title> <style> body { margin: 0; background: #111; display: flex; align-items: center; justify-content: center; height: 100vh; } canvas { background: #0a0a0a; border-radius: 8px; } </style> </head> <body> <canvas id="scene" width="1280" height="720"></canvas> <script> // 核心逻辑写在这里 </script> </body> </html>画布宽度 1280,高度 720,这是 16:9 的标准视频尺寸。如果不改动视频比例,录制出来的文件在大多数播放器里都能正常显示,不会出现上下黑边的问题。
接下来是绘制的核心逻辑。我定义了一个粒子数组,每个粒子有初始相位、速度系数和偏移量。在每一帧里,我根据当前时间计算出每个粒子的 x、y 坐标,以及它的颜色值,然后把它们画出来:
const canvas = document.getElementById('scene'); const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height; // 粒子总数越多,画面越密,但对性能要求也越高 const PARTICLE_COUNT = 120; // 每个粒子的初始参数 const particles = []; for (let i = 0; i < PARTICLE_COUNT; i++) { particles.push({ phase: Math.random() * Math.PI * 2, // 初始相位,避免所有粒子同步运动 speed: 0.6 + Math.random() * 1.8, // 运动速度系数 offsetY: Math.random() * H, // 垂直方向的基础偏移 size: 1.5 + Math.random() * 3, // 光点半径 }); } // 创建离屏 canvas,用来预绘制渐变背景,避免每帧重复计算 const bgCanvas = document.createElement('canvas'); bgCanvas.width = W; bgCanvas.height = H; const bgCtx = bgCanvas.getContext('2d'); const gradient = bgCtx.createRadialGradient(W / 2, H / 2, 80, W / 2, H / 2, W); gradient.addColorStop(0, '#10222a'); gradient.addColorStop(1, '#050a0e'); bgCtx.fillStyle = gradient; bgCtx.fillRect(0, 0, W, H); function drawFrame(t) { // t 是从录制开始到当前帧的毫秒数 // 先画背景 ctx.drawImage(bgCanvas, 0, 0); // 再画粒子 particles.forEach((p, index) => { const progress = (t / 1000) * p.speed + p.phase; const x = W * (0.5 + 0.4 * Math.sin(progress)); const y = p.offsetY + 40 * Math.sin(progress * 0.8); // 颜色随时间变化,用 HSL 模型最方便 const hue = (index * 360 / PARTICLE_COUNT + t * 0.02) % 360; ctx.beginPath(); ctx.arc(x, y, p.size, 0, Math.PI * 2); ctx.fillStyle = `hsl(${hue}, 80%, 70%)`; ctx.fill(); }); } let startTime = null; function loop(now) { if (startTime === null) { startTime = now; } const elapsed = now - startTime; drawFrame(elapsed); requestAnimationFrame(loop); } requestAnimationFrame(loop);这里有几个可以立刻上手玩起来的调参点。第一个是粒子数量 PARTICLE_COUNT,数量越多画面越饱满,但超过一定阈值后,绘制耗时就会上升,可能导致帧率掉下 60fps。第二个是 width 和 height,分辨率越高,单帧绘制越慢,录制约 10 秒的 4K 视频,性能开销比 1080p 高出数倍。第三个是颜色公式里的时间系数 0.02,把它调大,颜色变化就会更剧烈,画面更花哨,但也可能显得刺眼。
3.2 用 MediaRecorder 把绘制过程录成视频文件
动画已经可以跑了,现在把它变成真正的视频文件。最简单的录制方式是给 Canvas 创建媒体流,用 MediaRecorder 接收并保存。我通常把录制逻辑和绘制逻辑分开,避免绘制的循环逻辑被录制状态干扰。
继续在脚本里追加代码:
// 录制参数 const fps = 60; const durationSec = 10; // 录制时长 const stream = canvas.captureStream(fps); const mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 8_000_000, }); const chunks = []; mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) { chunks.push(e.data); } }; mediaRecorder.onstop = () => { const blob = new Blob(chunks, { type: 'video/webm' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `opus-demo-${Date.now()}.webm`; a.click(); URL.revokeObjectURL(url); }; // 点击页面任意位置开始录制,录制结束自动下载 document.addEventListener('click', () => { mediaRecorder.start(); setTimeout(() => { mediaRecorder.stop(); }, durationSec * 1000); }, { once: true });注意,mimeType 我用了 webm 和 vp9 编码。为什么不用 mp4?因为浏览器对 mp4 录制的支持目前仍然不如 webm 稳定,尤其是在录制 Canvas 流时,mp4 容器对编码器的要求更高。如果录制过程中浏览器发现编码器不支持,MediaRecorder 会直接进入错误状态。
如果想得到 mp4,我的习惯是先用 webm 录制,再用 FFmpeg 之类的工具转封装。因为逐帧绘制阶段,webm 是浏览器中最稳妥的格式,而转码可以放到录制结束之后,不依赖浏览器环境。
录制时长是怎么算的?durationSec 乘以 fps,就是期望的帧数。比如 10 秒乘以 60fps,就是 600 帧。每次绘制循环会尝试以 60fps 的频率运行,但如果某些帧因为性能原因没有及时绘制,captureStream 只能捕获它实际收到的帧,视频时长可能比预期短,也可能出现重复帧来补足帧数。对程序化动画来说,只要坚持时间驱动而不是帧驱动,即使偶尔丢帧,导出视频的运动节奏依然是对的。
3.3 让画面更加复杂:加入交互参数与批量输出
录制一次动画只是起点。真正把代码驱动视频变得强大的,是“参数化”和“批量生成”。我在做实际项目时,很少只生成一段视频,而是会写一个参数列表,循环跑出几十个不同版本,再从中挑效果最好的。这个思路在 Opus 5.5 这类工具里体现得很明显:它写出的代码,本质上是模板,用户更换关键词,它就在模板里换参数。
给上面那个工程加上参数化也很容易。比如把粒子的运动速度、数量、颜色变化速度都从顶部变量里抽出来,组成一个配置对象。每次生成视频前,随机或按规则改变配置对象,然后重新启动录制。代码的骨架不变,变化的只有参数。这样你很快就能发现,同一段逻辑可以衍生出完全不同的视觉风格。
我还试过在录制过程中动态改变参数,让画面从第一秒到第十秒逐渐从稀疏变得密集,或让颜色从冷色调渐变为暖色调。这种随时间渐变的逻辑,在传统视频生成模型里很难用一句提示词控制得精准,但在代码驱动渲染里,只是一个关于时间的 if 判断或插值函数。
4. 性能瓶颈与常见问题的排查实录
4.1 帧率上不去时先查这三处
逐帧绘制这个方案最大的敌人是性能。你想象一下,一秒要画 60 帧,每一帧可能要画上百个粒子、几十个图形,还要做颜色计算和坐标转换。一帧处理时间超过 16.7 毫秒,帧率就会掉到 60fps 以下。我实测下来,最常见的性能瓶颈有三个。
第一是每帧重复创建对象。JS 里在循环内部用数组字面量、对象字面量或模板字符串创建新对象,都会触发内存分配和垃圾回收。垃圾回收一旦在主线程上发生,就会造成明显的卡顿。所以绘制循环里尽量复用对象,比如把颜色值预先算好放进数组,坐标也尽量用简单变量而不是临时对象。
第二是不小心使用了引起整页布局的 API。Canvas 本身是位图绘制,不受 CSS 布局影响,但如果你在绘制循环里读取某些需要计算布局的属性,比如 offsetWidth、clientHeight,浏览器会强制同步执行布局计算,从而打断绘制流水线。这是非常隐蔽的卡顿来源,我只遇到过一次,排查了很久才发现是循环里读了一次画布的父元素宽度。
第三是绘制指令过多。Canvas 的每次 beginPath、arc、fill 都是有开销的,绘制指令数量越多,单帧耗时越长。优化的思路是把固定不变的内容预绘制到离屏 Canvas 上,每帧只需要画动态部分。我的背景渐变就是这样做预绘制的,否则每一帧都要重新计算一次放射性渐变,性能至少下降 20%。
4.2 录制黑屏、时长偏移与画质损失的坑
录制黑屏是我见过最频繁的问题。有时候画面在浏览器里明明正常播放,但录出来的视频却全是黑屏或只有背景没有粒子。排查方向通常是 captureStream 和 MediaRecorder 之间的兼容性问题。某些浏览器版本对 Canvas 流的捕获存在已知限制,如果你在录制前没有让画布至少绘制过一帧,流里可能没有有效画面,录制出来的自然就是黑的。
还一种可能是你调用了 captureStream 时传入的帧率高于浏览器实际支持的帧率。某些浏览器在帧率过高时会自动丢帧,导致黑屏或者花屏。我现在的做法是先调用一次绘制函数,确保画布上有内容,再启动 recorder,然后把 captureStream 的帧率和绘制目标帧率都设为一致。
时长偏移这个问题也常见。录制 10 秒的视频,结果文件播放出来只有 8 秒,或者长度超出 10 秒。原因通常在于绘制帧率和录制流帧率不一致,或者浏览器后台节流。解决思路还是时间驱动:绘制函数里不要依赖自增计数,而是依赖时间戳。如果花了 10 秒录制,但实际捕获的帧数少于 600 帧,MediaRecorder 有时会用重复帧来补足,画面看起来停顿,但总时长不会出错。
画质损失则和编码参数有关。videoBitsPerSecond 设置得太低,画面会出现明显的块状压缩噪声。逐帧绘制生成的画面通常以几何图形和渐变为主,高频细节不多,但过度压缩依然会让边缘变得模糊。我的经验是,1080p 视频建议至少给 8Mbps,4K 视频建议给 20Mbps 以上,否则粒子边缘会出现大面积马赛克。
4.3 一个隐藏的浏览器限制:后台标签页节流
浏览器为了节省资源,会自动降低后台标签页的定时器频率。requestAnimationFrame 在页面不可见时也会被挂起,这意味着如果你切到别的窗口,录制进程瞬间就会断掉画面供应。
这个问题在录制长视频时特别致命。我有一次录 3 分钟的数据可视化视频,录到一半切去查资料,回来发现视频后半段完全静止。原因就是标签页进入后台后,requestAnimationFrame 不再触发,但 MediaRecorder 还在等待画面数据,最后得到的视频时长虽然接近,但内容缺失。
解决办法有两个。第一是录制动画期间,保持标签页在前台,并把电脑设置为不熄屏。第二种是用 OffscreenCanvas 配合 Web Worker 在后台线程里执行绘制,可以绕过标签页节流,但实现复杂度明显更高,而且需要确认目标浏览器对 OffscreenCanvas 的支持情况。对第一次尝试的人来说,直接保证前台运行是最省力的方案。
5. 什么场景适合让浏览器逐帧画视频
5.1 值得用的场景
代码驱动的逐帧渲染有一个明确的优势区间:当内容需要精确运动、确定结果、批量生产时,它完胜传统视频生成模型。我在实际项目里最常用它做三类内容。
第一类是数据可视化动画。数据本身是结构化的,画面中的位置、大小、颜色都要对应具体的数值。用代码驱动渲染,可以直接把数据读进来,让数值驱动图形,整个动画就是一个直观的数据图表。传统视频生成模型很难做到“某个数字精确决定柱状图高度”这种需求。
第二类是动态图形设计。从品牌动画、标题动画到抽象的视觉实验,这类内容不追求写实,追求形式感和节奏感。程序化渲染天然适合生成几何图形、渐变、粒子和循环动画。而且因为是参数化的,可以快速尝试不同配色、不同运动曲线、不同排版,改几行代码就能生成一个新版本。
第三类是可交互的预览工具。因为整个动画本质上是网页应用,用户可以实时调整参数并立即看到效果。这在广告投放、动态海报、直播背景之类的场景里特别实用。用户需要一个能即时反馈的可视化界面,浏览器逐帧绘制是目前最方便的载体。
5.2 不如交给传统视频生成模型的场景
不是说所有视频都适合代码驱动渲染。如果你需要有真实物理规则的人脸表演、复杂的街景、动物动作、真实的镜头光晕,代码驱动渲染的工作量会变得非常巨大。你可以写出很漂亮的正弦波粒子,但你很难用 Canvas 写出一段自然行走的人。这种时候,传统视频生成模型仍然是更合理的起点。
另一个不如传统模型的场景是快速创意探索。如果脑子里只有一个模糊的画面概念,比如“一座漂浮在云端的城市”,用代码驱动渲染,要定义光线、建筑、云层、摄像机运动等一大堆规则,效率很低。但传统视频生成模型只需要一句话就能给你一个可看的初步效果。所以在创作流程里,这两种技术不是谁替代谁,而是谁出现在哪个阶段。
还有一些场景本质上就不该用代码驱动渲染,比如超短视频需要匹配真实角色形象,或者需要声音和画面的物理同步。浏览器逐帧绘制虽然也可以做音频,但要精确到帧级别非常麻烦,远不如专业视频编辑工具顺手。
5.3 后续扩展方向:从单视频到动态内容系统
代码驱动渲染最有想象力的地方,是它把视频从一个静态文件变成了一个动态系统。视频能做的事情,代码都能做。代码可以接收用户输入、读取实时接口数据、根据条件分支输出不同画面。这意味着同样的视频还能变成可交互的网页,变成一个根据数据实时变化的看板,变成用户调整参数即可改变输出的工具。
我有一个明显的感受是,这类工具未来会越来越多地嵌入到内容生产工作流里,而不是作为一个独立的视频生成器存在。它会更像一个“可视化代码生成器”,你描述一个动态效果,它生成一个模块,这个模块可以被你的代码继续调用。从这个角度看,花时间理解浏览器逐帧绘制原理,比只会使用某个视频生成产品,要值得多。
6. 一轮完整调试后的个人心得
如果你打算自己去写一个逐帧绘制视频工程,我建议从很小的目标开始。不要一上来就想生成一部完整的短片,先把一个粒子在一个黑底 Canvas 上移动给跑通。然后加上录制,导出文件,确认视频能正常播放。最后再逐渐加入更多粒子、渐变、色彩变化和参数控制。每一个阶段都先确保它能稳定运行,再扩展功能。
一个让我受益很大的习惯是:永远保留一份可重复复现的配置记录。因为代码驱动视频的产出往往很容易让人满意,但你会很快忘记某个好看效果的参数组合。我现在会在录制时自动把参数 JSON 一起输出到控制台,或者嵌入到视频文件名里。这样每次生成的作品都可以追溯到它的源头配置,想做系列化内容时非常有用。
最后想说,看到“生成视频”这个词,不要下意识认为一定是扩散模型。视频的本质是连续图像序列,只要你有一个确定的过程能一帧一帧画出图像,你就拥有了一条视频生成链路。至于这个过程是神经网络推理,还是 JavaScript 循环加 Canvas,都不重要。重要的是你能理解它、控制它,并且知道什么时候该选哪条路。