最近有个事挺值得说道:某个大模型更新到 5.5 版本后,很多人拿它“生成视频”的案例到处刷屏。但仔细扒开看,那个案例根本不是传统意义上的“生成视频”——模型没有一帧一帧去预测像素,而是输出了一段代码,最后由浏览器根据这段代码把画面逐帧画出来。这个区别很大,大到你理解了它,就能理解接下来很长一段时间里 AI 内容生成的走向。
先说结论:这不是偷懒,反而是一种更聪明的路线。像素级视频生成对算力、数据、可控性的要求都很高,而代码生成视频的本质是“用逻辑描述变化”,它绕开了模型逐帧绘制时的不可控,也让“改视频”这件事从重新生成变成改参数。这篇文章我就从原理、实操、性能优化到踩坑排查,把这条技术路线完整拆一遍。
1. “生成视频”与“写出视频”是两条完全不同的路
1.1 像素预测路线:扩散模型怎么“画”视频
绝大多数人理解的 AI 生成视频,是输入一句提示词,然后模型直接输出一个 MP4。这个 MP4 里每一帧都是模型算出来的像素。
这条路线通常基于扩散模型。模型在训练时学会“从噪声里还原图像”的能力,到了视频任务,它要做的就是把这种还原能力扩展到时间维度:给定第一帧,或者给定一段文字描述,模型预测下一帧的像素分布,再下一帧,再下下一帧。听起来很顺,但实际跑起来有几个硬伤。
第一是成本。每生成一帧都是一次完整的扩散采样,一个 5 秒、30 帧每秒、1080p 的视频,等于要做 150 次全图生成,算力需求按秒烧钱。第二是一致性。逐帧预测很容易出现“主角的衣服一会儿红一会儿蓝”“人脸到第三秒就变形了”这种问题,因为模型在预测第 68 帧时,并不知道第 1 帧长什么样,只能靠隐空间里的向量去勉强维持记忆。第三是不可编辑性。哪怕你只是想改画面里一个数字、一行文字,也必须重新生成整个视频,或者局部重绘,成本极高。
所以这条路不是不好,而是它更像“摄影”——你按下快门,得到的是已经定型的画面,想后期改一点东西,就得回到现场重拍。
1.2 代码生成路线:用指令代替渲染
另一条路线,就是这次引发热议的“代码生成视频”:大模型不输出视频文件,而是输出一段可执行的代码——通常是 HTML、JavaScript、Canvas 操作、CSS 动画,或者 WebGL 渲染脚本。你把这套代码丢给浏览器,浏览器里的渲染引擎会一帧一帧地把画面画出来,最终在你眼前形成一段流畅的动画。
我拿一个最简单的例子来类比。你要做一个“小球从左上角滚到右下角”的视频。
传统视频生成模型会直接给你一段图像序列,球在每一帧里的位置都是通过采样得到的,位置可能忽左忽右,球的形状可能会变形。而代码生成路线是:模型理解你的需求,然后写一段逻辑,比如“每过 16 毫秒,球的 x 坐标加 3,y 坐标加 2,碰到边界就反弹”,浏览器执行这段逻辑,每一帧根据同一个规则推进状态,然后绘制。
结果是,这个动画的每一帧都不是“猜”出来的,而是从同一个确定性规则里算出来的。球永远不会凭空消失,永远不会突然变色,除非你的代码里写明了要变色。
这才是“真相”里最有价值的部分:模型在生成一段关于变化的描述,而不是生成变化本身。
1.3 为什么说代码生成在可控性上占优
我们来做个对比,把两条路线放在同一张表里看会更加清楚:
| 对比维度 | 像素级视频生成 | 代码生成动画 |
|---|---|---|
| 生成成本 | 每帧一次模型推理,成本极高 | 一次代码生成,运行代码的算力极小 |
| 可编辑性 | 改一处要重新生成 | 改一个参数即可,拿代码即改 |
| 一致性 | 靠隐空间约束,长视频易漂移 | 靠确定性规则,帧间天然一致 |
| 白屏可控 | 较难,只能通过 prompt 控制 | 可直接改代码或数据源 |
| 专业门槛 | 低,提示词即可 | 需要一定的前端或编程基础 |
| 适用场景 | 实拍感强的镜头、人物动作 | 数据可视化、UI 动效、示意动画 |
当然,代码生成也不是万能。你让它生成一段“下雨时街道上的真实人流”,它大概率写出一段很简陋的动画,完全达不到像素级视频的质感。它的优势集中在“抽象表达、图形化、结构化动态内容”这个象限里。
换句话说,如果你想做的是镜头感很强的短片,走像素预测;如果你想做的是图表动效、产品演示、交互原型,代码生成这条路线是降维打击。
2. 浏览器逐帧绘制背后的渲染机制
2.1 一帧画面是怎么被画出来的
要理解“浏览器逐帧画出来”,得先知道浏览器渲染一帧到底发生了什么。
当浏览器打开一个页面,渲染主线程会执行 JavaScript、计算样式、布局、绘制、合成。完整的流程是:
- JavaScript 脚本执行:这里可以更新 DOM、修改 CSS 变量、计算粒子位置。
- Style 计算:浏览器根据 CSS 算出每个元素最终样式。
- Layout 布局:算出每个元素在页面上的几何位置。
- Paint 绘制:把元素的基本视觉信息画到图层上。
- Composite 合成:把多个图层交给 GPU 合成,最终显示在屏幕上。
显示器一般每秒刷新 60 次,所以正常情况下浏览器会尽可能在刷新前完成这一整套流程。如果每一帧都能在 16.67 毫秒内完成,用户看到的就是流畅动画。如果某一帧的主线程被一个特别重的任务卡住了,比如执行了一段耗时 80 毫秒的同步脚本,那这一帧就画不出来,动画就会掉帧、卡顿。
“逐帧画出来”本质上是循环执行上面这套流程。我们不需要每帧都做完整的全页面布局,只需要更新变化的部分。这也是为什么代码生成路线偏爱 Canvas 或 WebGL——它们跳过 DOM 和 Layout 阶段,直接在画布上做绘制,控制粒度和效率都高得多。
2.2 requestAnimationFrame:驱动逐帧动画的心脏
浏览器提供了一个专门逐帧调用的 API,叫requestAnimationFrame。它和setInterval最大的区别是,它跟随显示器的刷新频率触发,而且只在页面可见时触发。
可以这样理解:setInterval是电话闹钟,每隔固定时间去执行一次,哪怕没人听它也会响;requestAnimationFrame是人体生物钟,跟着心跳走,该夺一下才夺一下。用requestAnimationFrame驱动动画,好处有三个:
- 自动匹配显示器刷新率,避免一帧画两次或者两帧画一次。
- 页面切到后台时自动暂停,不浪费 CPU。
- 每一帧的触发时机都在浏览器准备绘制之前,能让代码修改和页面渲染保持同步。
核心循环长这样:
let lastTime = 0; function animate(now) { // 计算距离上一帧的时间差,单位毫秒 const delta = now - lastTime; lastTime = now; // 根据时间差更新状态,重绘画布 update(delta / 1000); // 将毫秒转为秒 render(); // 请求下一帧 requestAnimationFrame(animate); } requestAnimationFrame(animate);这里有个小细节:now参数是页面加载后经过的高精度时间,类型是DOMHighResTimeStamp,它比Date.now()更适合做动画计时,因为不会受到系统时间调整的干扰。
2.3 三种绘制实现:DOM/CSS、Canvas、WebGL 的取舍
“写代码让浏览器画视频”并不只有一种写法,选择对应的渲染方案直接影响效果和性能。
第一种是 DOM + CSS 动画。适合做相对简单的位移动效、透明度渐变、形状变化。优点是代码可读性强,浏览器帮你处理插值,修改样式方便;缺点是动画一复杂,DOM 节点变多,Layout 和 Paint 开销会迅速上升,60 个以上节点同时变化往往就开始掉帧。
第二种是 Canvas 2D。这是目前代码生成视频案例里最常用的方案。它把整个画面当成一块画布,开发者把每个粒子、每条线、每个图形都手动画上去。没有 DOM 节点,没有 Layout 阶段,绘制指令直接进入渲染管线。几千个粒子同时运动通常也能保持流畅。
第三种是 WebGL / Three.js。适合三维场景、大量粒子、Shader 特效。WebGL 把绘制任务交给 GPU,能处理十万级以上的粒子。但开发和调试成本高,让大模型自己从零写一个 WebGL 渲染器,出错概率也更高。
选哪种不是越高级越好,而是看内容形态。我的经验是:点线面、图表、粒子系统走 Canvas 2D,性价比最高;需要交互式 3D 场景,适合 Three.js;纯 LOGO 动效、文字入场这类轻量内容,DOM 动画足够。
3. 实操:从提示词到可交互的逐帧动画
3.1 先想清楚动画结构
很多教程直接给你一段代码跑起来,但实际让 AI 模型写动画时,第一步不是提需求,而是先拆结构。一个完整的动画,至少有这四部分:
- 场景结构:背景是什么,主体是什么,有哪些图层。
- 状态变量:位置、速度、透明度、颜色等随时间变化的量。
- 每帧更新逻辑:状态变量如何随
delta变化。 - 绘制逻辑:如何用当前状态画出画面。
我给一个具体例子:让某个 5.5 版本的模型生成“一群发光的粒子在暗色空间里流动,鼠标靠近时粒子会被吸引过去”。正常的人类需求是不会直接写成“给我写一段 canvas 粒子动画代码”的,但模型的输出往往包含这类结构,我们拆开来看。
3.2 用 Canvas 2D 实现粒子轨迹动画的核心代码
先搭最基础的一版,不涉及交互,只是一些粒子沿随机方向缓慢运动。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>粒子动画 Demo</title> <style> body { margin: 0; background: #0a0e14; overflow: hidden; } #canvas { display: block; } </style> </head> <body> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 适配设备像素比,避免高分屏上画面发虚 const dpr = Math.max(window.devicePixelRatio || 1, 2); canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; ctx.scale(dpr, dpr); const width = window.innerWidth; const height = window.innerHeight; // 粒子数量根据屏幕面积动态调整 const count = Math.floor((width * height) / 12000); const particles = []; for (let i = 0; i < count; i++) { particles.push({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4, r: Math.random() * 2 + 0.5, alpha: Math.random() * 0.7 + 0.3, hue: Math.random() * 60 + 180 }); } function update() { for (const p of particles) { p.x += p.vx; p.y += p.vy; // 边界回弹 if (p.x < 0 || p.x > width) p.vx *= -1; if (p.y < 0 || p.y > height) p.vy *= -1; } } function render() { // 用半透明黑色清屏,产生拖尾效果 ctx.fillStyle = 'rgba(10, 14, 20, 0.15)'; ctx.fillRect(0, 0, width, height); for (const p of particles) { ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${p.hue}, 80%, 70%, ${p.alpha})`; ctx.fill(); } } function frame() { update(); render(); requestAnimationFrame(frame); } frame(); </script> </body> </html>如果你之前没写过 Canvas,我解释一下这段代码在做什么。
getContext('2d')拿到的是画布上的绘图上下文,所有绘制指令都通过它发出。粒子用简单对象存位置、速度、半径、透明度和色相。update函数里,每个粒子的位置加上速度,碰到边界就把对应轴速度反向,相当于反弹。render函数第一行用一个半透明黑色矩形填充整个画布,覆盖上一次的画面,但因为透明度只有 0.15,旧的画面会残留一丁点,形成拖尾轨迹。后面再把每个粒子画成一个小圆点,色相值集中在蓝绿色系,所以粒子会整体呈现冷光感。
这个拖尾技巧在动画里非常实用,视觉效果立刻提升一个档次,而且完全不需要做额外的历史轨迹存储。
3.3 参数选择与数学计算:位置、速度、颜色如何定
实际操作中,模型的输出不会一次到位,关键是你要会调参数。
粒子数量count不是随便写的。如果屏幕是 1920x1080,总面积约 207 万像素,除以 12000,大概是 173 个粒子。这个数值在大部分设备上都能流畅跑。如果屏幕是手机竖屏 390x844,面积约 33 万像素,粒子数就自动降到约 27 个。用面积比例动态计算,比写死一个常量科学得多。
速度vx/vy决定粒子整体运动速度。我在这里给的是 ±0.2,单位是“像素/帧”。如果跑在 60FPS 的环境里,一个粒子每秒大概运动 12 像素,属于缓慢流动状态。想让动画更激烈,可以把速度提到 ±0.8 到 ±1.5,但一定要注意,速度越快,帧间隔波动对视觉效果的影响越大,后面我会专门讲时间步长的问题。
颜色这里用了hsla,hue 取 180 到 240 之间的值,也就是青色到蓝色,这种色域比较适合“科技感、数字空间”的主题。你完全可以根据场景改:暖色调火焰就把 hue 范围改成 0 到 40,赛博霓虹就是 280 到 320。
3.4 加入交互与动态分支
接下来做鼠标交互。监听鼠标位置,在更新粒子状态时,计算鼠标与粒子的距离,把鼠标当作一个“吸引点”。
核心逻辑就加一段:
let mouseX = width / 2; let mouseY = height / 2; canvas.addEventListener('mousemove', (e) => { mouseX = e.clientX; mouseY = e.clientY; }); function update() { for (const p of particles) { const dx = mouseX - p.x; const dy = mouseY - p.y; const dist = Math.sqrt(dx * dx + dy * dy); // 距离小于 200 时施加吸引 if (dist < 200) { const force = (1 - dist / 200) * 0.08; p.vx += dx * force; p.vy += dy * force; } // 限制最大速度,防止粒子飞出去 const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy); const maxSpeed = 1.2; if (speed > maxSpeed) { p.vx = (p.vx / speed) * maxSpeed; p.vy = (p.vy / speed) * maxSpeed; } p.x += p.vx; p.y += p.vy; // 边界处理省略 } }这段逻辑里有个容易被忽略的地方:施加力之后必须限速。否则粒子在鼠标附近持续受力,速度会无限叠加,画面会突然闪一下,一堆粒子像爆炸一样飞出去。模型很大概率会漏写这一步,你在调代码时多留意。
到这里,已经从一个静态的粒子集合变成了一个可交互、动态演化的系统。整个动画没有一帧是预先渲染好的,全部是实时算出来的。这就是“让浏览器逐帧画出来”的真实质感。
4. 从动画到成片:录制导出与性能优化
4.1 用 MediaRecorder 把 Canvas 录成 WebM
动画做好了,但如果你的目标是发布到短视频平台,或者发给同事确认,总不能让人家打开浏览器看。这时就需要把 Canvas 画面录制成视频文件。
浏览器内置了canvas.captureStream()和MediaRecorder,不需要装任何软件就能录制画布上的内容。代码非常简单:
const stream = canvas.captureStream(60); // 60 是目标帧率 const recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 8_000_000 }); const chunks = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) 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 = 'particle-animation.webm'; a.click(); }; recorder.start(); // 动画想要录制 5 秒,就在 5 秒后停止 setTimeout(() => recorder.stop(), 5000);需要注意,MediaRecorder录制的是“真实播放的画面”,它的帧率取决于你的动画实际渲染帧率。如果你的动画本身只有 30FPS,录制设置成 60 也只是重复帧,不会让动画更流畅。
另一个坑是兼容性。video/webm;codecs=vp9在某些浏览器上不支持,这时可以加一层降级判断:
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';实在想要 MP4,建议录成 WebM 后用 FFmpeg 转一下,比让浏览器现场转码省心太多。
4.2 固定时间步长与状态漂移问题
很多动画代码为了省事,直接用“每帧 +1”来更新状态,也就是帧数驱动。问题在于,浏览器并不是永远稳定在 60FPS。后台切回来的一瞬间,可能一下子跳到 20FPS,再恢复 60FPS。如果你每帧加 1,那么低帧率时段物体的移动速度看起来就慢了,高帧率时段又快了,整体动画节奏会忽快忽慢。
正确做法是使用时间驱动,我在前面requestAnimationFrame的代码里写过delta参数。把状态变化量乘以时间差,就能让动画在不同帧率下保持统一速度。
进阶一点,你还会遇到“时间步长太大”的问题。假设某一帧卡了 200 毫秒才恢复,粒子在一帧里要推进过去 200 毫秒的位移,可能直接穿透边界,或者碰撞检测失效。解决办法是固定步长拆解:
const STEP = 1 / 60; // 假设每次模拟 1/60 秒 let accumulator = 0; let lastTime = performance.now(); function frame(now) { let frameTime = (now - lastTime) / 1000; lastTime = now; // 避免大步长导致碰撞穿透 frameTime = Math.min(frameTime, 0.1); accumulator += frameTime; while (accumulator >= STEP) { update(STEP); accumulator -= STEP; } render(); requestAnimationFrame(frame); }这种“固定时间步长 + 累积器”的思路,是从游戏引擎里借来的。它保证了无论浏览器帧率怎么波动,物理模拟和动画更新始终以恒定步长推进,不会因为某一个掉帧而出现状态跳跃。
4.3 性能诊断与优化实战
如果动画跑起来卡顿,先不要盲猜,打开 DevTools 看 Performance 面板。按下 Record,录 3 到 5 秒,重点看三块:Scripting 时间有没有超过 8 毫秒、Rendering 是不是有大量重绘、合成器线程有没有大量掉帧。
我整理过一份常见排查表,实战里按这个顺序查基本能解决 90% 的问题:
| 症状 | 大概率原因 | 对应方案 |
|---|---|---|
| 动画整体卡顿 | 每帧绘制指令过多或 fillStyle 频繁切换 | 合并指令、减少状态切换、用整数坐标 |
| 掉帧集中在一段区域 | 大量粒子群聚后重叠绘制 | 改用更小的圆形、增加半透明混合的复杂度上限 |
| 高分屏下画面模糊但流畅 | 未处理 devicePixelRatio | 前面写的 DPR 适配代码补上 |
| CPU 占用极高 | 阴影、滤镜效果用太多 | 去掉 shadowBlur,改用预绘制纹理或渐变填充 |
| 长时间运行后变慢 | 内存中垃圾对象过多 | 避免在 update 里 new 对象,复用粒子对象池 |
粒子系统优化还有个大招:离屏 Canvas。如果背景里有一块完全不动的静态图形,比如一个淡淡的网格,不要每帧都画一次,把它提前画在一个离屏 Canvas 上,然后每帧用drawImage贴一次,耗时几乎为 0。静态图形越复杂,这个方案收益越大。
5. 常见问题排查:动画空白、闪烁、导出黑屏
5.1 Canvas 一片空白
新手最容易犯的错,是设置了 CSS 尺寸,但没有设置 canvas 元素的width和height属性。你需要理解,Canvas 的宽高属性决定绘图缓冲区的像素数量,而 CSS 宽高只决定元素在页面上的显示大小。两者不一致,要么空白,要么模糊。
正确写法是:
canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; ctx.scale(dpr, dpr);另外,如果你是在模型生成的代码基础上改,还要看它是不是把绘制逻辑写在了requestAnimationFrame里。如果写成了同步执行但绘制函数没有被调用,自然也是白屏。
5.2 拖尾效果出现残影不消失
我在 Demo 里用半透明背景做拖尾,这里有个副作用:如果fillRect使用的透明度不够低,或者动画逻辑没有每次全屏覆盖,画面会逐渐积累坏像素,最后整个背景变得脏乱。
想解决,要么提高背景透明度到 0.3 左右让拖尾更快消失;要么不用残留原理,而是手动保存最近 N 帧的位置数据,每次重新绘制所有历史轨迹。后者更可控,代价是内存和绘制量增加。
5.3 录制出来的视频是黑屏
MediaRecorder 录制黑屏,最常见的原因是captureStream调用时 Canvas 没有真正在进行绘制。比如你把recorder.start()放在了动画启动之前,或者 Canvas 本身是离屏不可见的,录制器捕获不到画面。
正确顺序是:
- 确保 Canvas 已经挂载到 DOM,且可见。
- 启动动画循环,让画面已经在刷新。
- 调用
canvas.captureStream()。 - 创建 MediaRecorder 并启动。
也可以先调试一下stream.getVideoTracks()[0].getSettings(),看能不能拿到画布分辨率信息。如果拿不到,多半就是 Canvas 没在渲染。
5.4 动画在不同设备上速度不一样
这通常是帧率驱动代码导致的。两台设备,一台 60Hz,一台 120Hz,同样每帧加 1 的代码,120Hz 设备上的运动速度会快一倍。改用时间驱动之后,两台设备的动画时间表现就会一致。这也是我在 4.2 里反复强调固定时间步长的原因。
6. 这个方向能用在哪:场景边界与下一步思考
6.1 真正适合代码生成视频的五个场景
代码生成视频不是用来替代电影摄影的,它的优势在信息密度高、结构清晰、需要迭代修改的场景。
第一个是数据动态化。把一套静态图表变成动态演示,条形图逐个弹跳、折线图随着时间延伸、大屏上的数据流动,这些都是代码生成的强项。
第二个是产品 UI 演示。App 的页面转场、按钮点击反馈、下拉刷新的交互示意,与其录屏,不如让模型生成一段带高亮框和注视点的交互动画。
第三个是教学演示动画。物理课上讲牛顿第一定律,与其找现成视频,不如生成一个带小球和受力箭头的 Canvas 动效,改一个参数就能换个演示场景。
第四个是营销素材中的抽象背景。我见过很多动态海报、Logo 演变、发布会开场动画,本质上都是代码生成。
第五个是程序化内容生产。比如根据接口实时数据生成视频摘要,数据变了,视频自动跟着变,这种动态个性化能力是传统视频制作做不到的。
6.2 不适合的方向也要心里有数
代码生成视频也有明显的天花板。人物动作、表情、物理世界的真实光影、复杂的布料模拟,这些让浏览器去逐帧画,画出来的效果大概率是“扁平图形拼接”,达不到真实感。还有镜头语言:推拉摇移、景深虚化、运动模糊,Canvas 2D 做起来成本极高,WebGL 能做但模型生成的 shader 经常出错。
我建议的判断标准很简单:如果你的需求可以抽象成“图形 + 时间变化 + 规则”,那就适合代码生成;如果需求是“还原一段真实世界发生的事情”,请走传统视频或像素级视频生成。
6.3 给未来做这个方向的人几个建议
大胆让模型生成动画代码,但不要直接拿来跑线上。先做三件事:确定动画时长和帧率、固定渲染分辨率、约定颜色主题变量。把这三个问题想清楚,再让模型写代码,一次通过的几率大幅提高。
另外,学会给模型“喂结构”。不要只写“做一个粒子动画”,而是写“做 200 个粒子沿正弦曲线移动的动画,鼠标悬浮时粒子聚集到光标周围,背景是暗色,用 Canvas 2D 实现”。结构越清晰,模型输出的代码越接近可运行状态。
还有一个小技巧是让模型把动画参数集中放在文件顶部,比如粒子数量、颜色、速度上限,这样后续调整不用满文件找数字。
我个人在实际操作中的体会是:把代码生成动画看成“编写一个有生命的系统”,而不是“画一帧又一帧图片”。关注状态、时间、交互这三者的关系,比关注具体画面更重要。而这类能力,也正在成为内容生产领域里相当稀缺的技能——既懂视觉表达,又懂代码逻辑的人,做出来的东西会越来越有优势。以后当你再看到“某个大模型生成视频”的消息,可以先停下来想一想:它是给你一个 MP4,还是给你一段代码?这两者背后,是完全不同的未来。