ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI生成视频的真相:不是渲染像素,而是写代码让浏览器逐帧作画

AI生成视频的真相:不是渲染像素,而是写代码让浏览器逐帧作画

上周傍晚,有个朋友丢给我一个链接,说是某模型“生成的一段星空视频”。我以为是 MP4,结果打开之后页面噼里啪啦跑了一会儿 JavaScript,露出一块 canvas,上面密密麻麻的粒子正在一帧一帧往中间聚。我盯着那块画布看了十几秒,突然意识到这可能是最近这批工具里最容易被误解的一件事:大家都在喊“AI 生成视频”,但 Opus 5.5 这类模型干的根本不是那么回事——它不生成视频,它写代码,然后让浏览器逐帧把画面画出来。

这篇文章想把这个真相摊开聊一聊。后面我会用 Opus 5.5 当代号,不指向任何具体产品,纯粹为了方便讨论。文章会覆盖三条线:代码生成动画和传统视频生成到底差在哪、浏览器逐帧绘制背后那套机制是什么、以及我实际跑通一个“从提示词到动画”的完整过程,最后汇总常见问题和排查经验。如果你正在研究怎么用这类工具做动效,或者只是想知道那些漂亮的“AI 视频”链接里面到底是什么,这篇会有用。

1. 内容整体设计与思路拆解

1.1 两种“AI 视频”压根不是一回事

传统意义的 AI 视频生成,走的是像素预测路线。模型一帧一帧推算出 RGB 像素值,再把连续帧编码成视频流,最后产出一个 MP4 或 WebM。这个产物的特点是:不可编程、难以局部修改、改一个角落可能要重新生成几十秒。

Opus 5.5 这类模型走的完全是另一条路。它的输出是 HTML + CSS + JavaScript,也就是一份能在浏览器里直接运行的网页源码。画面不是预先存在的,而是浏览器在运行时通过绘图 API 一笔一笔现场画出来的。你可以理解为它没有“生成视频”,它生成了一个“能绘制视频的程序”。

我整理了一张对比表,方便你看清楚两条路线的差别:

维度传统视频生成代码生成动画
输出物MP4 / WebM 视频文件HTML/CSS/JS 源码
画面来源模型直接预测像素帧浏览器运行时逐帧绘制
是否可编辑难,改一帧可能要重新生成容易,改一行代码画面就变
运行载体播放器浏览器
文件体积相对大,取决于时长和码率极小,几 KB 到几十 KB
交互能力基本没有天然支持鼠标、键盘、触摸
画质上限取决于生成模型本身取决于前端绘制能力

最直观的区别在体积上。一个 8 秒 1080p 视频,哪怕压缩一下也有几 MB 到十几 MB。而 Opus 5.5 生成的那个星空动效,整个 HTML 文件才 12KB。它没有保存任何一帧画面,只保存了“这一帧该怎么画”的指令。

从工程角度讲,这其实是一种很有意思的抽象层提升:传统流程在“像素”这一层工作,代码生成在“指令”这一层工作。后者把控制权完全交给了你,代价是你得懂一点前端代码才能发挥它的全部价值。

1.2 为什么大家会把“代码动画”当成“视频生成”

说实话,我第一次拿到链接时也没马上反应过来。从外部看,一段连续变化的画面、一个可播放的进度感、甚至页面下方还有导出按钮,这不就是视频吗?

问题就出在这个“外部看”上。产品层面为了方便用户理解,把实现细节全藏了起来。你看到的是一个网页播放器式的交互界面,不会有人第一反应去打开开发者工具。只有当我把 console 打开、看到大段 requestAnimationFrame 调用时,才意识到整个故事的底层逻辑完全不同。

这不是骗局,更像是一种产品形态的选择:对创作者来说,把“视频”包装成熟悉的样子,降低了使用门槛。但对想要深入使用的人来说,搞清楚底层形态非常重要。你拿到的到底是什么资产,决定了你后续能怎么改、怎么扩展、怎么二次开发。

如果拿到的是一份代码,你就能做传统视频完全做不到的事:改背景色、调整粒子密度、加上鼠标交互、甚至把它嵌进自己的网页里做成实时动效。这是代码生成动画最值钱的地方。

1.3 这条路线的适用场景和不适用场景

折腾完这个案例之后,我心里对“代码生成动画”的适用边界清晰了不少。适合的场景其实非常明确:短视频标题动效、数据可视化转场、课程动画演示、产品宣传页的 Hero 动画、还有需要用户参与交互的营销页面。这类内容对“视觉逻辑”的要求高于“物理真实感”,画面不需要以假乱真,反而需要足够快的迭代速度。

不太适合的场景也很多。比如复杂的流体模拟、电影级光照材质、真实人物面部动画,这些用纯前端绘制又慢又达不到效果。真要做这类东西,还是得回到传统视频生成或者 3D 渲染管线里。选型的时候一定要先想清楚:我要的是“能改的画面”,还是“够真的画面”?

2. 浏览器逐帧绘制的工作原理

2.1 canvas 只是一块可以反复擦写的黑板

要理解 Opus 5.5 生成动画的核心,必须先理解 canvas 的工作方式。很多人以为 canvas 里存了一张图,其实不是。canvas 本身只是页面上的一个矩形区域,它不保存任何持久化的画面内容。你调用绘图指令画上去的图形,一旦被下一轮绘图覆盖或清除,就彻底没了。

想象一块黑板。你每画一行字,拍照记录这张板书,然后擦掉重写。视频看起来是连续流动的,本质上就是“画一帧、擦掉、再画一帧”的快速循环,翻页动画也是同一个原理。canvas 里的每一帧都是从零开始重画的,不存在“这张图动了 5 个像素”这种优化,你需要清晰地把整个画面重新描述一遍。

这听起来很低效,但现代浏览器执行绘图指令的速度非常快。几百条 fillRect、arc、fillText 指令,在 16 毫秒内完成毫无压力。只要单帧绘制量控制住,60fps 完全没问题。代码生成视频能在浏览器里跑得那么流畅,靠的就是这个“简单粗暴”的重绘模型。

2.2 requestAnimationFrame 才是真正的节拍器

有了黑板,还需要有人按节奏擦写。你可能会想,用 setInterval 不行吗?可以,但效果很差。setInterval 的触发时机不跟显示器的刷新周期对齐,可能在两帧中间触发,也可能在一帧内重复触发,造成画面撕裂感和不必要的性能消耗。

浏览器专门的动画接口是 requestAnimationFrame(缩写 rAF)。它的特点是:浏览器每次准备重绘屏幕之前,会调用你注册的回调函数。也就是说,动画的每一帧和显示器的每一次刷新天然同步。显示器 60Hz 就每秒回调 60 次,显示器 120Hz 就每秒回调 120 次,完全由浏览器调度,不需要你手动算时间。

最基本的动画骨架长这样:

function draw(now) { // now 是浏览器传入的时间戳,单位毫秒 // 在这里写绘制逻辑:清空画布、更新状态、画图形 requestAnimationFrame(draw); // 注册下一帧 } requestAnimationFrame(draw);

这段代码里最关键的参数是now。它代表当前帧的时刻,通常基于页面加载后的高精度计时。所有动画进度都靠它计算,而不是靠“这已经是第几帧”。为什么?因为帧率会波动,用帧数算时间会越偏越多,用绝对时间戳算永远精确。

2.3 每一帧的 16.7 毫秒里发生了什么

以 60Hz 显示器为例,每一帧的时间预算大概是 16.7 毫秒。浏览器在这段时间里要做的事情远不止执行你的动画回调:它还要处理样式计算、布局、绘制、合成、输入事件。你的动画代码如果耗时超过预算,这一帧就会被跳过,掉帧就出现了。

正常情况下,rAF 回调内部做四件事。第一步是清空画布,通常调用ctx.clearRect(0, 0, canvas.width, canvas.height)。第二步是根据当前时间戳更新动画状态,比如粒子的位置、透明度、旋转角度。第三步是执行真正的绘制指令。第四步是再次调用 requestAnimationFrame 注册下一帧。

我一开始没太在意思考顺序,结果写出的动画总有点怪。后来才明白,clearRect 的位置必须是整个回调的开头。如果放在绘制之后,就会把所有刚画的图形全部清掉,画面闪得让人崩溃。这就是逐帧绘制世界里的基本纪律:先擦,再画,最后请下一帧。

3. 从一句自然语言到一段可播放的动画:完整实操过程

3.1 需求描述与模型输出

为了让你看清 Opus 5.5 到底怎么通过写代码完成“生成视频”,我做了一个完整的模拟项目,后面统称“模拟项目 X”。需求描述就一句话:“晚上 8 秒动画,星空背景,星尘粒子从四周螺旋汇聚到屏幕中央,变成一个放大的英文单词 HELLO,停留一秒后不需要散开,底部有渐隐字幕,整体紫色蓝色色调。”

用 Opus 5.5 试了一次,它给出的响应不是视频文件,也没有任何视频编码术语,而是一整个 HTML 文件,里面用<style>定义了页面背景,用<canvas>创建了画布,用一段 JS 构建了粒子系统。我把文件保存后拖进浏览器,动画直接跑了起来。

这里有个要点想提醒你:在提示词里把“时长”写清楚非常重要。模型不会真的去切割视频时间轴,但会在计算逻辑里用时间戳来安排动画阶段,8 秒这个数字最终会变成代码里的DURATION = 8000。你给的时长越明确,生成的动画节奏越可控。

3.2 核心代码逐段解读

下面我把那个 HTML 精简成一段能直接跑的核心代码,去掉了一些冗余样式,保留了粒子汇聚和文字浮现的关键逻辑。跑起来以后,你会看到一颗颗亮点绕着中心做螺旋运动,越来越靠近中心,随后 HELLO 出现。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>星尘汇聚 Demo</title> <style> body { margin: 0; background: #0a0e1f; overflow: hidden; } </style> </head> <body> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 按设备像素比缩放画布,避免高分屏下画面发虚 const dpr = window.devicePixelRatio || 1; 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 W = window.innerWidth; const H = window.innerHeight; // 初始化粒子:每个粒子有起始半径、角度、旋转速度和大小 const COUNT = 600; const particles = []; for (let i = 0; i < COUNT; i++) { particles.push({ radius: 40 + Math.random() * Math.min(W, H) * 0.4, angle: Math.random() * Math.PI * 2, speed: 0.2 + Math.random() * 0.8, size: 1 + Math.random() * 2.5 }); } const startTime = performance.now(); const DURATION = 8000; // 8 秒动画 function draw(now) { const t = now - startTime; const progress = Math.min(t / DURATION, 1); // 第一步:清空画布,否则上一帧内容会残留成拖影 ctx.clearRect(0, 0, W, H); // 第二步:画少量背景星点,模拟夜空 ctx.fillStyle = '#ffffff'; for (let i = 0; i < 100; i++) { const x = (i * 137.5) % W; const y = (i * 89.3) % H; ctx.globalAlpha = 0.3 + (i % 3) * 0.2; ctx.fillRect(x, y, 1.5, 1.5); } ctx.globalAlpha = 1; // 第三步:粒子从外圈螺旋汇向中央 const hue = (progress * 360) % 360; ctx.fillStyle = `hsla(${hue}, 80%, 72%, 1)`; for (let i = 0; i < COUNT; i++) { const p = particles[i]; const currentR = p.radius * (1 - easeInOut(progress)); const a = p.angle + progress * p.speed * 4; if (currentR <= 0) continue; const x = W / 2 + Math.cos(a) * currentR; const y = H / 2 + Math.sin(a) * currentR * 0.6; ctx.fillRect(x, y, p.size, p.size); } // 第四步:粒子汇聚完成后显示文字 if (progress > 0.55) { const alpha = Math.min((progress - 0.55) / 0.15, 1); ctx.globalAlpha = alpha; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 64px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('HELLO', W / 2, H / 2); ctx.globalAlpha = 1; } // 第五步:动画没结束就请求下一帧 if (progress < 1) { requestAnimationFrame(draw); } } function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; } requestAnimationFrame(draw); </script> </body> </html>

这段代码里最值得琢磨的是第三步。粒子的当前位置由两个变量决定:角度a和半径currentR。currentR从初始半径逐渐缩到 0,a随时间不断增加,两者组合就产生了螺旋下降的效果。easeInOut函数让缩小的速度先快后慢再快,看起来更像有加速度的运动,而不是机械匀速。

文字出现的时机用progress > 0.55控制,透明度则在 0.15 秒内逐渐从 0 拉到 1,形成渐显效果。所有视觉效果最后都归结为“某个数值随时间变化”,这就是逐帧动画的通用套路。

3.3 浏览器实测记录

我习惯把这类 HTML 文件直接丢进本地静态服务里跑,用 Python 一行命令python -m http.server 8080起服务,然后打开 localhost 页面。直接双击文件跑也行,但有些浏览器会有局部限制,静态服务最稳妥。

打开页面后,我做了三件验证。第一件是开 Performance 面板,观察帧率。加了 600 个粒子,整机 60Hz 下帧率稳定在 60fps 左右,没有明显掉帧。第二件是改参数,把粒子数量改成 1200,再看帧率降到 45fps 上下,明显变卡。这说明性能瓶颈和粒子数量直接相关,一帧要执行的绘制指令数量涨了一倍,代价立刻反映在帧率上。第三件是加了一个简单的 FPS 计数器,验证和面板数据一致:

let frames = 0; let lastFpsTime = performance.now(); function draw(now) { frames++; if (now - lastFpsTime >= 1000) { console.log('fps:', frames); frames = 0; lastFpsTime = now; } // 原有绘制逻辑不变 requestAnimationFrame(draw); } requestAnimationFrame(draw);

这段小工具非常实用。我在很多“看似卡顿”的动画里都靠它先确认帧率,再决定优化方向。如果你测下来帧率只有 20 到 30,那问题大概率出在单帧绘制指令太重,而不是浏览器配置问题。

4. 常见问题与排查技巧实录

4.1 最容易翻车的三个时刻:白屏、拖影和导出黑屏

白屏是最常见也最让人崩溃的现象。代码看起来没问题,页面打开却一片空白。我排查过的情况里,十有八九是 canvas 的尺寸没设置对,或者绘制代码在 canvas 尚未准备好时就开始跑。还有一次是模型生成的代码里用了某个浏览器 API,但当前浏览器版本不支持,console 里直接红色报错。白屏的排查顺序永远是:先看控制台有没有报错,再检查 canvas 元素有没有实际宽高,最后确认绘制函数有没有被正常调用。

拖影则是逐帧绘制最典型的“新人礼物”。如果你忘了在每帧开头调用 clearRect,或者 clearRect 的尺寸小于画布实际尺寸,之前画过的图形就会残留,画面看起来像发光物体拖着一条长长的尾巴。这不一定全是坏事,有些特效反而需要这种残影,比如光绘效果。但如果你想要的是干净清爽的画面,清空画布必须放在绘制循环的第一行。

导出黑屏通常发生在用 MediaRecorder 录制 canvas 动画的时候。录制启动的瞬间,如果动画还没绘制过第一帧,录制器拿到的画面就是全黑的。解决办法很粗暴:录制动作延迟 200 到 300 毫秒再启动,确保第一帧已经画出来。另外录制要针对canvas.captureStream(0)返回的流,不能直接采集页面,否则录下来的可能是空白。

4.2 画质模糊:多半是 devicePixelRatio 在捣乱

我见过不少朋友把 canvas 直接设成屏幕 CSS 宽高,跑出来在高分屏上模糊得不行。原因很简单:物理像素密度大于 CSS 像素密度,一个 CSS 像素可能对应两三个物理像素,画布没有用同样比例缩放,画面就被拉伸了。

解决方案就是代码里那两行:按照window.devicePixelRatio放大 canvas 的实际宽高,然后用ctx.scale(dpr, dpr)把绘制坐标系拉回来。这样画出来的内容清晰度和屏幕物理分辨率完全对应,颜色边缘不会发虚。

4.3 常见问题速查表

现象可能原因检查路径修复办法
页面白屏JS 报错 / canvas 无宽高打开 Console,检查 Elements修正宽高设置、清理报错
画面拖影每帧开头没有 clearRect查看绘制循环第一行调整 clearRect 为第一行指令
Web 导出黑屏录制启动早于首帧渲染检查 MediaRecorder 启动时机延迟启动或先绘制一帧
高分屏模糊未处理 devicePixelRatio对比画布物理/逻辑尺寸按 dpr 放大画布并 scale
动画卡顿粒子数过多或每帧创建对象Performance 面板记录帧耗时减少指令数量,用对象池
动画节奏漂移用帧索引而不是时间戳计算进度检查 progress 来源改用 performance.now() 差值

这张表是我在多次调试里沉淀出来的。碰到问题先对照看到底属于哪一类,而不是对着代码一行行猜,效率会高很多。

5. 掌握了底层逻辑之后再怎么看这些“AI 生成视频”

5.1 三步识别你拿到的是视频文件还是画布动画

以后你再看到任何人说“AI 生成了一段视频”,不用急着信,也不用急着不信,花十秒钟做三件事就能判断。

第一步按 F12 打开开发者工具,看看页面里有没有<canvas>元素。有 canvas 且旁边挂着密集 JS 文件的,大概率是代码生成动画。第二步打开设备模拟器,把屏幕尺寸从桌面切换到手机宽度。如果是真正的视频文件,画面世界只有一个固定画幅,缩放时整个画面被拉伸或裁切;如果是 canvas 动画,代码里通常写了窗口缩放适配,画面布局会跟着一起变。

第三步特别有决定性:拿鼠标在画面上划拉两下。如果粒子会跟着鼠标位置移动,或者画面任何部分对鼠标有反应,那它几乎不可能是传统视频文件。视频流里的像素不可能对交互产生实时响应,只有代码绘制的画面才会。

5.2 这不是降级,是另一种进化方向

很多人知道真相后第一反应是失望:我以为是模型直接“画”出来的,原来是写代码绕了一圈。我倒觉得,把这件事看成一个新的技术分支更有意思。

传统视频生成把画面固化成像素,内容完全不可改。代码生成动画从第一步开始就保留了完整的“源码”,你拿到的是一份可以改、可以拆、可以重新组合的素材。对做内容和做产品的人来说,可修改性比一劳永逸的成品珍贵得多。以前想基于一段视频改个配色、换个文字,几乎等于重做;现在拿到的是代码,改两个字符串就能全局换色。

而且这种形态更适合网页和移动端传播。短视频文件要加载、要转码、要适配播放器,一个 HTML 动效页面直接打开就能跑,跨平台能力拉满。如果把“AI 生成视频”看成下一代动态内容的一种存在形式,而不是视频压缩编码的替代品,路就会宽很多。

5.3 如果你想自己上手试:三个实用建议

第一个建议是需求描述尽量带上时间参考。时长、节奏、动画阶段的先后顺序,这些用自然语言写清楚,模型才能在生成代码时把时间戳参数定得合理。只说“好看一点”是拿不到有节奏感的动画的。

第二个建议是让模型输出单文件,并且强调不能有外部依赖。单文件最容易被拖动分享、离线打开、二次编辑。一旦依赖了外部图片或第三方 JS 库,文件换一台设备就可能满屏报错。

第三个建议是保留每一版参数的修改记录。这个建议源于我的一次翻车:我改了粒子数量后效果好很多,但后来忘了原来的数值,再想找回那个效果只能靠记忆,心态直接崩掉。用 Git 也好,简单复制几个带版本的 HTML 文件也好,至少让你能回滚。

写到最后,说点我自己的体会

我折腾完这个星尘动画之后最大的感受是:别急着否定“写代码画视频”这条路。对一个做内容或做开发的人来说,一个 AI 功能有没有价值,看的不是它是否走了传统的视频编码链路,而是它能不能稳定地输出你想要的画面变化,并且让你能继续修改。Opus 5.5 这种“写代码让浏览器画”的做法,把可修改性拉到了传统视频生成难以企及的高度。

最后分享一个我常用的判断小技巧:看到任何所谓“AI 生成视频”的链接,先别急着收藏或下载,按下 F12,如果看到满屏的 canvas 和 requestAnimationFrame,你就明白它的真相了。带着这份判断力去看所有类似的演示链接,比记住任何结论都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进