1. 当“生成视频”这四个字开始骗人
第一次看到“Opus 5.5 生成视频”这个说法,我差点直接划走。原因很简单:一个以文本推理见长的模型,凭什么突然就能出视频了?市面上真正做视频生成的模型,背后要么是扩散模型在潜空间里反复去噪,要么是时序一致性网络在帧与帧之间做约束,算力开销和训练数据量都是另一个量级的东西。一个语言模型如果真能端到端吐出一段 MP4,那才是新闻。
但把标题后半句读完,事情就有意思了——“它不生成视频,是写代码让浏览器逐帧画出来”。这句话一下子把整件事从“模型能力神话”拉回到了“工程实现路径”上。它说的其实是:模型输出的不是视频文件,而是一段可执行的代码,这段代码在浏览器里跑起来,通过逐帧渲染的方式,把动画一帧一帧画到画布上,最终形成一段可以播放、可以录制的动态画面。
这个区别非常关键。前者是“模型直接生产像素”,后者是“模型生产生产像素的程序”。打个比方,前者像是让一个画家直接画出一部动画片,后者像是让一个导演写出分镜脚本和拍摄指令,然后由浏览器这个“摄影棚”按指令逐帧拍出来。两者都能得到视频,但底层逻辑、可控性、可编辑性完全不是一回事。
这篇文章适合几类人看:一是对“AI生成视频”这个说法保持警惕、想搞清楚背后到底怎么回事的技术人;二是想自己动手复现类似效果的前端或全栈开发者;三是做内容创作、想理解这类工具能力边界的产品和运营同学。我会把这条路径拆开讲清楚:它到底怎么运作、为什么选择浏览器逐帧渲染、代码长什么样、实测中会遇到哪些坑、以及这条路子的天花板在哪里。
2. 拆开“写代码画视频”这条链路:从一句提示到一帧画面
2.1 模型输出的到底是什么
先明确一个事实:在这条路径里,模型产出的核心产物是代码,通常是 HTML、CSS 加 JavaScript 的组合,或者是一个基于 Canvas API 的渲染脚本。它不会给你一个二进制视频流,也不会给你一堆图片序列。你拿到手的,是一段可以被浏览器解析执行的程序。
这段程序里通常包含几个关键部分。第一是画布初始化,也就是在页面上创建一个 canvas 元素,设定宽高和渲染上下文。第二是时间轴逻辑,用一个循环或者 requestAnimationFrame 来驱动帧的推进,每一帧对应一个时间点。第三是绘制指令,根据当前时间点计算出图形的位置、颜色、透明度、旋转角度等属性,然后调用绘制 API 把它们画出来。第四是录制或导出逻辑,有些实现会配合 MediaRecorder API 把画布内容录成视频文件。
为什么是代码而不是直接出视频?因为代码有几个天然优势。一是体量小,一段几百行的脚本远比一段几秒的视频文件轻量。二是可解释,你能读懂每一行在干什么,能改参数、换颜色、调速度。三是可复用,同一套渲染逻辑换个输入就能生成不同内容。四是浏览器原生支持,不需要额外安装渲染引擎或编解码工具。
2.2 浏览器逐帧渲染的基本原理
浏览器渲染动画的核心机制,说白了就是“在正确的时间点画正确的画面”。最常用的两个驱动方式是 setInterval 和 requestAnimationFrame。前者按固定毫秒间隔触发,简单但容易和屏幕刷新率不同步,导致卡顿或丢帧。后者由浏览器统一调度,通常跟随显示器刷新率,一般是每秒 60 次,也就是 60fps,画面更顺滑。
逐帧渲染的关键在于“时间归一化”。假设你要做一段 5 秒的动画,总共 300 帧(60fps 下),那么每一帧对应的时间进度就是当前帧号除以总帧数,得到一个 0 到 1 之间的值。所有的动画属性——位置、缩放、透明度、颜色插值——都基于这个进度值来计算。这样做的好处是,无论实际渲染帧率是多少,动画的逻辑时间轴是稳定的,不会因为机器性能差异导致动画快慢不一。
Canvas 2D 上下文提供了丰富的绘制方法,比如 fillRect 画矩形、arc 画圆、drawImage 贴图、createLinearGradient 做渐变。对于更复杂的场景,还可以用 WebGL 做 GPU 加速渲染,但那又是另一个复杂度层级了。大多数“代码画视频”的场景,Canvas 2D 已经够用。
2.3 为什么这条路子值得关注
有人可能会问:既然有现成的视频生成模型,为什么还要绕这么大一圈用代码画?答案在于可控性和可编辑性。视频生成模型输出的是一个黑盒结果,你想改其中某一帧的某个元素,基本只能重新生成,而且很难保证其他部分不变。但代码渲染不一样,你想让某个方块在第 3 秒变成红色,直接改一行参数就行,其他部分纹丝不动。
另一个价值在于“生成过程即教学”。模型写出的渲染代码,本身就是一份可读的动画教程。你看着它怎么组织时间轴、怎么计算插值、怎么分层绘制,能学到不少前端动画的实战思路。这对于想提升图形编程能力的人来说,比直接拿一个视频文件有价值得多。
还有一点是资源消耗。视频生成模型跑一次推理,GPU 占用和耗时都不低。而代码渲染在浏览器里跑,对本地算力要求低得多,生成代码的文本推理也比生成视频的像素推理轻量。在需要快速迭代、频繁调整的场景下,这条路径的效率优势很明显。
3. 一段可跑的渲染脚本长什么样
3.1 最小可运行骨架
先看一个最简版本,理解整体结构。下面这段代码会在画布上画一个从左向右移动的圆,持续 3 秒。
<!DOCTYPE html> <html> <head> <style> body { margin: 0; background: #111; display: flex; justify-content: center; align-items: center; height: 100vh; } canvas { border: 1px solid #333; } </style> </head> <body> <canvas id="stage" width="640" height="360"></canvas> <script> const canvas = document.getElementById('stage'); const ctx = canvas.getContext('2d'); const duration = 3000; // 总时长 3 秒 const startTime = performance.now(); function render(now) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // 0 到 1 // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 计算圆的水平位置 const x = 50 + progress * (canvas.width - 100); const y = canvas.height / 2; // 绘制 ctx.beginPath(); ctx.arc(x, y, 30, 0, Math.PI * 2); ctx.fillStyle = '#4af'; ctx.fill(); if (progress < 1) { requestAnimationFrame(render); } } requestAnimationFrame(render); </script> </body> </html>这段代码的结构很清晰:初始化画布、定义总时长、在每一帧里计算进度、根据进度算出圆的坐标、清屏后重绘、如果没结束就继续请求下一帧。这就是逐帧渲染的最小闭环。
3.2 时间轴与缓动函数
上面用的是线性插值,圆是匀速移动的。但真实动画往往需要缓动,比如开头慢、中间快、结尾慢。这时候就需要引入缓动函数。最常见的是 easeInOutQuad,实现如下:
function easeInOutQuad(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }把 progress 传进去,得到的就是一个经过缓动映射的值。用这个值去算位置,动画就会自然很多。缓动函数的选择直接影响观感,线性显得机械,easeInOut 显得柔和,easeOutBack 会带一点回弹。做代码渲染视频时,缓动是提升质感最廉价的手段。
3.3 多元素分层与组合
真实场景很少只有一个元素。通常需要把画面拆成背景层、中景层、前景层,分别绘制。Canvas 的绘制顺序就是图层顺序,后画的盖在先画的上面。所以一般流程是:先画背景渐变或底图,再画中景的装饰元素,最后画前景的主体和文字。
如果元素很多,建议把每个元素的绘制逻辑封装成独立函数,接收 progress 和 ctx 作为参数。这样主循环里只需要按顺序调用各个绘制函数,代码可读性和可维护性都会好很多。比如:
function drawBackground(ctx, progress) { /* ... */ } function drawParticles(ctx, progress) { /* ... */ } function drawTitle(ctx, progress) { /* ... */ } function render(now) { const progress = computeProgress(now); ctx.clearRect(0, 0, canvas.width, canvas.height); drawBackground(ctx, progress); drawParticles(ctx, progress); drawTitle(ctx, progress); if (progress < 1) requestAnimationFrame(render); }这种分层思路,和传统动画制作里的“赛璐璐分层”是一个道理。每一层独立控制,互不干扰,修改起来也方便。
3.4 把画布内容录成视频文件
如果最终需要一个视频文件,可以用 MediaRecorder API 配合 canvas.captureStream()。基本用法是:
const stream = canvas.captureStream(60); // 60fps const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' }); const chunks = []; recorder.ondataavailable = (e) => chunks.push(e.data); recorder.onstop = () => { const blob = new Blob(chunks, { type: 'video/webm' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'output.webm'; a.click(); }; recorder.start(); // 动画结束后调用 recorder.stop()这里有个细节要注意:captureStream 的帧率参数要和实际渲染帧率匹配,否则录出来的视频可能掉帧。另外 MediaRecorder 在不同浏览器里的支持程度有差异,WebM 格式在部分场景下需要转码成 MP4 才能通用。如果只是本地预览,WebM 足够;如果要发布到某些平台,可能还需要一步转码。
4. 实测中那些让人抓头发的瞬间
4.1 第一帧总是闪一下
这个问题很常见。原因是 requestAnimationFrame 第一次触发时,performance.now() 的时间戳和 startTime 之间可能已经有一个不小的时间差,导致第一帧的 progress 不是 0 而是一个跳变值。解决办法是在初始化时先手动调用一次 render,传入 startTime 本身,让第一帧的 progress 确定为 0。或者把 startTime 的赋值放到第一次 requestAnimationFrame 回调里,用回调传入的时间戳作为起点。
4.2 高刷新率屏幕上的速度异常
在 120Hz 或 144Hz 屏幕上,requestAnimationFrame 每秒触发次数翻倍。如果代码里是按“每帧固定位移”来算的,动画就会在高端屏幕上跑得飞快。正确的做法始终是基于时间差来计算进度,而不是基于帧计数。上面代码里用 elapsed / duration 就是基于时间的,天然免疫刷新率差异。如果你看到别人的实现里用 frameCount++ 来驱动,那基本可以判断它在高刷屏上会出问题。
4.3 录制时画布变黑或丢帧
用 captureStream 录制时,如果画布在录制开始前没有内容,或者录制过程中清屏和绘制之间有间隙,录出来的视频可能出现黑帧。稳妥的做法是:先渲染一帧静态画面,再启动录制,然后开始动画循环。另外,录制期间避免做太重的同步计算,否则会阻塞渲染线程,导致丢帧。如果动画复杂,可以考虑用 OffscreenCanvas 把渲染放到 worker 里,但那就更复杂了。
4.4 文字渲染的模糊问题
Canvas 里画文字,如果坐标不是整数,容易出现模糊。解决办法是把绘制坐标用 Math.round 取整,或者用 ctx.translate 做半像素偏移。另外,高分屏下 canvas 的物理像素和 CSS 像素不一致,需要按 devicePixelRatio 放大画布尺寸再缩放上下文,否则文字和图形都会发虚。这个坑在移动端尤其明显。
4.5 模型生成的代码不一定能直接跑
这是最需要提醒的一点。模型写出的渲染代码,逻辑上通常没问题,但细节上经常有疏漏。比如变量名拼写不一致、某个 API 参数顺序写反、忘记处理边界情况、缓动函数在 t=1 时返回 NaN 等。实测下来,大概有二三成的概率需要手动修一两个小地方才能跑通。所以拿到代码后,先通读一遍,重点检查变量声明、循环边界、API 调用这三块,能省不少调试时间。
5. 这条路子的能力边界与适用场景
5.1 它擅长什么
代码逐帧渲染最擅长的,是那些几何感强、规律性强、参数化程度高的动画。比如数据可视化动画、Logo 演绎、加载动画、粒子效果、文字排版动画、简单的 2D 场景切换。这些场景的共同点是:元素可以用数学公式描述,运动可以用时间轴驱动,不需要复杂的纹理和光影。
另一个优势是批量生成。如果你需要生成一百个结构相似但参数不同的动画,用代码渲染就非常高效。改几个变量就能出一批,而视频生成模型很难做到这种精细的批量控制。
5.2 它不擅长什么
写实风格的三维场景、复杂的人物动作、真实感的光影和材质,这些基本不是代码逐帧渲染能搞定的。Canvas 2D 的能力上限就在那里,WebGL 虽然能做一些三维效果,但让模型直接写出可用的 WebGL 着色器代码,难度和不确定性都大得多。
另外,长时间、高帧率、高分辨率的视频,用浏览器渲染和录制也会遇到性能和内存瓶颈。几分钟以上的内容,逐帧渲染的耗时和录制稳定性都会成为问题。
5.3 和传统视频生成模型的关系
这两条路径不是替代关系,而是互补关系。视频生成模型适合做“内容本身复杂、难以用规则描述”的画面,比如真实场景、人物表情、自然现象。代码渲染适合做“结构清晰、需要精确控制”的画面,比如信息图、UI 动画、参数化设计。实际工作中,完全可以把两者结合:用代码渲染生成基础动画和图形元素,再用其他方式合成或增强。
5.4 一个容易被忽略的价值:可访问性和可搜索性
代码渲染出来的内容,本质上是 DOM 和 Canvas 的组合,这意味着它对屏幕阅读器、搜索引擎、自动化测试都更友好。视频文件是黑盒,而代码是白盒。对于需要做无障碍适配或自动化验证的场景,这条路径有天然优势。这一点在标题里没提,但实际用起来会发现很实用。
6. 如果你想自己试一把,我的几条实操建议
第一条建议是先把渲染循环写稳,再考虑画面复杂度。很多人一上来就想做很炫的效果,结果时间轴没理顺,后面越改越乱。正确的顺序是:先跑通一个移动的方块,确认时间轴和帧循环没问题,再逐步加元素、加缓动、加分层。
第二条建议是善用参数化。把颜色、尺寸、速度、缓动类型都抽成变量或配置对象,放在代码顶部。这样调整的时候不用满篇找数字,也方便后续做批量变体。模型生成的代码往往把这些值硬编码在绘制逻辑里,拿到后第一件事就是把它抽出来。
第三条建议是录制前先做性能检查。打开浏览器的性能面板,看渲染一帧的耗时。如果单帧超过 16 毫秒,60fps 就保不住,录出来会卡。这时候要么降低画面复杂度,要么降低目标帧率,要么把部分计算缓存起来。
第四条建议是保留中间产物。渲染过程中的每一帧,如果可以的话,用 toDataURL 存下来。这样即使录制环节出问题,你还有帧序列可以后期合成。而且帧序列方便你逐帧检查哪里画错了。
第五条建议是对模型生成的代码保持“半信半疑”。信它的结构思路,疑它的细节实现。它给的骨架通常是对的,但具体参数和边界处理要自己过一遍。把它当成一个能快速出草稿的助手,而不是能直接交付的成品。
最后说一个我自己的体会:这条路径最大的乐趣,不在于最终那个视频文件,而在于你能看着代码一行行把画面“长”出来。那种从抽象逻辑到具体视觉的转化过程,比直接拿一个生成结果有意思得多。而且每修一个 bug、每调一次参数,你对图形编程的理解就深一层。这大概就是“写代码画视频”这件事,比“生成视频”更值得玩的地方。