1. 这个标题到底在说什么
第一次看到“Opus 5.5 生成视频”这个说法,我下意识以为是又一个文生视频模型上线了。毕竟这两年“输入一句话,输出一段视频”的产品太多了,多到大家已经形成条件反射——看到“生成视频”四个字,脑子里自动补全的就是扩散模型、时序一致性、帧间插值那一套。但把标题后半句读完,事情就变得有意思了:它不生成视频,它是写代码让浏览器逐帧画出来。
这句话拆开来看,其实包含三个层次的信息。第一层,所谓“生成视频”是一个被包装过的说法,真正的产物不是 mp4 文件,而是一段可执行的代码。第二层,这段代码的运行环境是浏览器,不是渲染农场也不是 GPU 集群。第三层,“逐帧画出来”意味着视频的每一帧都是被程序实时计算并绘制到画布上的,而不是预先编码好的像素序列。
我花了点时间把这条链路捋了一遍,越捋越觉得这个思路值得单独写一篇。因为它触及了一个很多人没想明白的问题:当我们说“AI 生成视频”的时候,我们到底在要什么?是要一个视频文件,还是要一段能动的画面?如果只是后者,那路径其实不止一条,而且有一条路径的成本低到离谱。
这篇文章适合几类人看。如果你是对 AI 生成内容好奇但没深入过技术细节的普通用户,看完你会明白为什么有些“视频生成”快得不像话。如果你是前端或者创意开发者,你会拿到一套可以直接抄的逐帧绘制方案。如果你是在做内容工具的产品或运营,你会重新思考“视频”这个交付形态是不是被过度神化了。
核心关键词我先摆在这里:代码生成视频、浏览器逐帧绘制、Canvas 动画、程序化视频、AI 写代码而非生成像素。这几个词会贯穿全文,后面每一节都会围绕它们展开。
2. 为什么是“写代码”而不是“生成像素”
2.1 两条技术路线的本质差异
要理解这个标题的价值,得先把两条路线摆在一起对比。
传统文生视频走的是像素路线。模型学习的是海量视频帧的分布,你给它一段文字,它在潜空间里一步步去噪,最后解码成一串像素帧,再编码成视频文件。这条路线的核心资产是算力和数据,产出物是一个二进制文件。它的优点是画面可以极其逼真,缺点是慢、贵、不可编辑、不可控。你想改一个细节,比如让画面里那个杯子往左挪一点,对不起,重新生成一遍,而且大概率挪不到你想要的位置。
而“写代码让浏览器逐帧画”走的是程序路线。模型输出的不是像素,是逻辑。它理解你的意图之后,写出一段 JavaScript,这段代码在浏览器里跑起来,用 Canvas 或者 WebGL 一帧一帧地把画面算出来、画出来。产出物是代码,视频只是这段代码运行时的视觉表现。
这个差异带来的连锁反应是巨大的。代码是可读的、可改的、可参数化的。你想让杯子往左挪,改一个坐标变量就行,不用重新生成。代码是轻量的,一段几百行的脚本可能只有几 KB,而同样时长的视频文件动辄几十 MB。代码是实时的,浏览器每秒画 60 帧,你看到的就是流畅动画,不需要等待渲染队列。
2.2 为什么浏览器成了最佳载体
有人可能会问,逐帧绘制为什么非得是浏览器?用 Python 加 OpenCV 不行吗?用游戏引擎不行吗?
行,但浏览器有三个别人比不了的优势。
第一是零安装分发。你写好的代码,任何人打开一个网页就能看到效果,不需要装运行时、不需要配环境、不需要下载几十 G 的依赖。这个门槛低到几乎为零,对于内容传播来说这是决定性的。
第二是Canvas 和 WebGL 的原生能力。现代浏览器内置的 2D 绘图上下文和 3D 渲染管线,性能已经足够撑起相当复杂的逐帧动画。你不需要自己写光栅化,不需要管显存分配,调用 API 就行。而且这套东西是跨平台的,手机、平板、桌面表现基本一致。
第三是时间轴天然存在。浏览器有 requestAnimationFrame 这个 API,它会在每次屏幕刷新前调用你的回调,天然就是一个帧循环。你只要在这个循环里根据当前时间计算画面状态,就实现了逐帧绘制。这个机制比很多专业工具的时间轴还要干净。
2.3 这条路线的能力边界在哪
我不想把这条路吹上天,它有明确的边界。
它擅长的是程序化、几何化、参数化的画面。比如粒子系统、波形、几何变换、数据可视化、文字动画、抽象图形、简单的物理模拟。这些东西用代码描述极其自然,几行数学公式就能生成千变万化的视觉效果。
它不擅长的是写实画面。你让它画一只毛茸茸的猫,它画不出来,因为毛发这种东西没法用简洁的代码描述。你让它还原一段真实拍摄的场景,它也做不到。所以这条路线的定位不是替代文生视频,而是覆盖文生视频覆盖不到或者覆盖得很差的那部分需求——那些本来就不需要写实、只需要“动起来好看”的场景。
想清楚这个边界,后面的所有技术选择就都顺理成章了。
3. 逐帧绘制的核心原理拆解
3.1 帧循环:视频的本质是快速切换的静态画面
先补一个最基础的知识点,懂了可以跳过。视频的本质就是一堆静态图片按顺序快速播放,利用人眼的视觉暂留形成连续运动的错觉。电影是每秒 24 帧,网络视频常见 30 帧,高刷屏能到 60 帧甚至 120 帧。所谓“逐帧画出来”,就是程序在每一帧的时间点上,重新计算并绘制一张完整的画面。
浏览器里实现这个循环的标准写法是这样的:
function render(timestamp) { // 根据 timestamp 计算当前应该画什么 drawFrame(timestamp); requestAnimationFrame(render); } requestAnimationFrame(render);requestAnimationFrame 的回调会收到一个高精度时间戳,单位是毫秒。你用它减去起始时间,就得到了动画已经播放了多久。所有的画面状态都应该由这个时间推导出来,而不是靠累加变量。这一点非常关键,后面讲避坑的时候会重点说。
3.2 状态与时间的函数关系
逐帧绘制最核心的思维转变是:不要想“这一帧画什么”,要想“时间 t 对应的画面状态是什么”。
举个最简单的例子,让一个圆从左往右移动。新手写法是每帧把 x 加 1。老手写法是 x = 起始位置 + 速度 × 已经过去的时间。这两种写法在正常情况下看起来一样,但一旦帧率波动,新手写法就会出问题——帧率高的设备上圆跑得快,帧率低的设备上圆跑得慢,因为每帧加的量是固定的。
正确的做法是把所有动画元素都表达成时间的函数。位置是时间的函数,透明度是时间的函数,颜色是时间的函数,旋转角度是时间的函数。整个画面就是这一堆函数在时刻 t 的取值集合。这个思维一旦建立起来,你就能做出任意复杂度的动画,而且天然支持暂停、快进、倒放、变速。
3.3 绘制上下文的坐标系与变换
Canvas 的坐标系原点在左上角,x 向右,y 向下。这个和数学课上学的笛卡尔坐标系不一样,刚开始会有点别扭。但 Canvas 提供了一套变换 API,可以让你在绘制前调整坐标系。
ctx.save(); ctx.translate(400, 300); // 把原点移到画布中心 ctx.rotate(angle); // 旋转坐标系 ctx.scale(1, -1); // 翻转 y 轴,变成数学坐标系 // 在这里绘制,坐标就是相对中心的 ctx.restore();save 和 restore 必须成对出现,这是新手最容易踩的坑之一。save 保存当前的变换状态,restore 恢复到上一次 save 的状态。如果你只 save 不 restore,变换会一直累积下去,画面很快就会乱掉。我见过太多人因为漏了一个 restore 调试半天。
3.4 逐帧绘制的性能账本
浏览器每秒要画 60 帧,意味着每帧只有大约 16.7 毫秒的预算。这 16.7 毫秒里,你要完成状态计算、图形绘制、合成上屏。超了这个预算,帧率就会掉,画面就会卡。
所以逐帧绘制是有性能纪律的。几个基本原则:不要在帧循环里创建对象,因为垃圾回收会打断节奏;不要在帧循环里做 DOM 操作,那是另一个线程的事;能用简单图形就别用复杂路径;能缓存的计算结果就缓存起来。
我实测过一个经验值:在普通笔记本上,Canvas 2D 每帧绘制几百个简单图形是没问题的,上千个就开始吃力。如果要做粒子系统,几千个粒子就得考虑用 WebGL 或者离屏画布预渲染。这个数字不是绝对的,跟设备性能、图形复杂度都有关,但可以作为一个起步参考。
4. 从意图到代码:AI 在这里扮演什么角色
4.1 模型输出的是逻辑不是像素
回到标题里的“Opus 5.5”。不管具体是哪个模型,它在这条链路里的角色是把自然语言意图翻译成可执行的绘制逻辑。
你说“我想要一个由彩色粒子组成的漩涡,粒子越靠近中心转得越快”,模型要做的不是去画这个漩涡,而是理解这句话里的几个要素:粒子、彩色、漩涡运动、角速度与半径的关系。然后它把这些要素翻译成代码——创建粒子数组、给每个粒子分配初始角度和半径、在每帧里根据角速度更新角度、用 HSL 颜色模型生成彩虹色、把极坐标转成直角坐标后绘制。
这个翻译过程需要的能力和写普通业务代码不太一样。它需要模型理解几何、理解运动学、理解颜色空间、理解 Canvas API。而且它得知道什么样的代码在浏览器里跑得动,什么样的写法会卡死。这其实是一个相当垂直的能力,不是所有代码模型都擅长。
4.2 提示词怎么写才能拿到好代码
我试过不少提示词写法,总结下来有几个要点。
第一,描述运动而不是描述画面。你说“一个红色的圆”模型可能给你画一个静止的圆。你说“一个红色的圆从左到右匀速移动,碰到右边界后从左边重新出现”,模型就知道要写循环逻辑了。运动是逐帧绘制的灵魂,把运动描述清楚,代码质量立刻上一个台阶。
第二,给出参数范围。说“很多粒子”不如说“大约 500 个粒子”。说“转得快一点”不如说“每秒转两圈”。具体的数字能让模型生成更确定的代码,也方便你后续调整。
第三,指定技术栈。明确说“用原生 Canvas 2D API,不要用任何第三方库”,能避免模型给你引入一堆依赖。如果你想要 3D 效果,就说“用 WebGL 或者 Three.js”。技术栈说清楚,代码的可运行性会高很多。
第四,要求可调参数。在提示词里加一句“把颜色、速度、数量这些做成顶部的常量,方便我修改”,模型就会把魔法数字提取出来。这个习惯能省掉你大量改代码的时间。
4.3 代码到手之后的第一件事
模型给的代码不要直接信。我的习惯是拿到代码先做三件事。
先看有没有明显的语法错误或者 API 误用。模型偶尔会编造不存在的 API,或者把参数顺序搞反。这个用眼睛扫一遍通常能发现。
然后看帧循环的结构对不对。重点检查状态更新是不是基于时间戳,有没有在循环里创建对象,有没有漏掉 save/restore。这几个地方是重灾区。
最后才是运行看效果。跑起来之后,打开浏览器的性能面板,看帧率稳不稳。如果掉帧,就要定位是哪个环节慢。通常问题出在绘制调用太多或者每帧计算量太大。
5. 完整实操:从零做一个逐帧绘制的粒子漩涡
光讲原理没意思,我带你走一遍完整流程。目标是一个由彩色粒子组成的漩涡动画,粒子绕中心旋转,越靠近中心转得越快,颜色随角度变化。
5.1 搭建最小可运行骨架
先建一个 HTML 文件,放一个全屏画布。
<!DOCTYPE html> <html> <head> <style> html, body { margin: 0; padding: 0; overflow: hidden; background: #000; } canvas { display: block; } </style> </head> <body> <canvas id="stage"></canvas> <script> const canvas = document.getElementById('stage'); const ctx = canvas.getContext('2d'); let W, H; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } window.addEventListener('resize', resize); resize(); const startTime = performance.now(); function render(now) { const t = (now - startTime) / 1000; // 秒 ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; ctx.fillRect(0, 0, W, H); // 后续在这里绘制粒子 requestAnimationFrame(render); } requestAnimationFrame(render); </script> </body> </html>这里有个小技巧,背景不用完全清空,而是用半透明的黑色覆盖。这样上一帧的粒子会留下淡淡的残影,形成拖尾效果,视觉上更有流动感。透明度 0.15 是我试出来的一个比较舒服的值,太高残影太重会糊,太低就没有拖尾。
5.2 粒子系统的数据结构
粒子不需要用对象数组,用平行的类型化数组性能更好,也更容易扩展。
const COUNT = 600; const radius = new Float32Array(COUNT); const angle = new Float32Array(COUNT); const speed = new Float32Array(COUNT); const hue = new Float32Array(COUNT); const cx = () => W / 2; const cy = () => H / 2; for (let i = 0; i < COUNT; i++) { radius[i] = 20 + Math.random() * Math.min(W, H) * 0.45; angle[i] = Math.random() * Math.PI * 2; // 角速度与半径成反比,越靠内转得越快 speed[i] = 1.2 / (radius[i] * 0.02 + 1); hue[i] = (radius[i] / (Math.min(W, H) * 0.45)) * 300; }角速度这里用了一个反比公式。半径越小,分母越小,速度越大。系数 1.2 和 0.02 是调出来的,目的是让内圈和外圈的速度差看起来明显但不夸张。你可以改这两个数感受一下。
5.3 每帧的状态更新与绘制
function render(now) { const t = (now - startTime) / 1000; ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; ctx.fillRect(0, 0, W, H); const centerX = cx(); const centerY = cy(); for (let i = 0; i < COUNT; i++) { const a = angle[i] + speed[i] * t; const r = radius[i]; const x = centerX + Math.cos(a) * r; const y = centerY + Math.sin(a) * r; const h = (hue[i] + t * 30) % 360; ctx.fillStyle = `hsl(${h}, 90%, 60%)`; ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fill(); } requestAnimationFrame(render); }注意这里角度是angle[i] + speed[i] * t,而不是每帧累加。这就是前面说的“状态是时间的函数”。这样写的好处是,无论帧率怎么波动,粒子在时刻 t 的位置都是确定的,动画不会因为掉帧而变慢或变快。
颜色用 HSL 模型,色相随时间缓慢偏移,整个漩涡会有一种流动的彩虹感。亮度固定在 60%,饱和度 90%,保证颜色鲜艳但不刺眼。
5.4 参数调优的实操记录
上面这套代码跑起来之后,我调了几轮参数,记录一下过程。
第一轮,粒子半径 2.5 像素,600 个,看起来有点稀疏。加到 1200 个之后密度合适了,但帧率从 60 掉到了 45 左右。说明绘制调用数量确实是瓶颈。
第二轮,把粒子半径加到 3.5,数量降回 800。视觉密度和 1200 个小粒子差不多,但帧率回到了 58 左右。这个取舍很划算,用更大的粒子换更少的绘制次数。
第三轮,试了把ctx.beginPath()和ctx.arc()换成ctx.fillRect()画方块。帧率提升明显,能到 60 满帧。但方块看起来太硬,最后还是换回了圆。如果你的设备性能吃紧,方块是一个可接受的降级方案。
第四轮,拖尾透明度从 0.15 调到 0.08,残影更重,漩涡的流动感更强了,但快速旋转时会有明显的糊。最后定在 0.12,兼顾流动感和清晰度。
这套参数不是标准答案,只是我这次调试的记录。你在自己设备上跑,可能需要微调。关键是理解每个参数影响什么,然后按自己的审美和性能预算去取舍。
6. 常见问题与排查技巧实录
6.1 画面卡顿掉帧怎么定位
掉帧是最常见的问题。排查顺序我一般是这样的。
先看是不是绘制调用太多。打开浏览器开发者工具的性能面板,录制几秒,看主线程时间花在哪里。如果大量时间在 Canvas 的绘制 API 上,那就是图形太多或者太复杂。
再看是不是每帧创建了对象。在帧循环里 new 对象、创建数组、拼接字符串,都会触发垃圾回收。垃圾回收一跑,帧率就抖。解决办法是把这些操作移到循环外面,或者用对象池复用。
最后看是不是计算量太大。有些数学运算比如三角函数、开方,单次很快,但每帧几万次就慢了。能预计算的预计算,能用近似公式的用近似公式。
6.2 不同设备表现不一致怎么办
同一个页面,在台式机上丝滑,在手机上卡成幻灯片,这个太常见了。
根本原因是设备性能差异巨大。解决办法是动态降级。检测帧率,如果连续几秒低于某个阈值,就自动减少粒子数量或者降低绘制精度。
let frameCount = 0; let lastCheck = performance.now(); let quality = 1.0; function checkPerformance(now) { frameCount++; if (now - lastCheck > 1000) { const fps = frameCount * 1000 / (now - lastCheck); if (fps < 45 && quality > 0.3) { quality -= 0.1; applyQuality(quality); } frameCount = 0; lastCheck = now; } }applyQuality 里根据 quality 值调整粒子数量、拖尾透明度这些参数。这套自适应机制能让同一份代码在高端机和低端机上都跑得比较舒服。
6.3 画面闪烁或者撕裂是怎么回事
闪烁通常是清屏和绘制之间的时序问题。如果你先清屏再绘制,中间有一瞬间是空白的,快速刷新时就会闪。解决办法是用拖尾式的半透明覆盖代替完全清屏,或者用双缓冲。
撕裂是另一回事,指的是画面上出现一条横向的错位线。这是绘制速度和屏幕刷新不同步导致的。浏览器一般会处理这个问题,但如果你在帧循环里做了耗时操作,就可能出现。把耗时操作拆分成多帧完成,或者移到 Web Worker 里,能缓解这个问题。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 帧率低 | 绘制调用过多 | 性能面板看绘制耗时 | 减少图形数量或简化图形 |
| 帧率抖动 | 帧循环内创建对象 | 看内存分配曲线 | 对象池或移出循环 |
| 画面闪烁 | 清屏与绘制不同步 | 观察闪烁频率 | 拖尾覆盖或双缓冲 |
| 画面撕裂 | 绘制耗时超预算 | 看单帧耗时 | 拆分任务或降级 |
| 设备差异大 | 性能预算不同 | 多设备实测 | 动态降级机制 |
| 动画速度不对 | 用了累加而非时间函数 | 检查状态更新逻辑 | 改为基于时间戳计算 |
6.5 几个我踩过的坑
第一个坑是忘记处理窗口大小变化。页面刚打开时画布尺寸是对的,但用户一调整窗口,画布还是老尺寸,画面就变形了。一定要监听 resize 事件,重新设置画布尺寸,并且重新计算依赖尺寸的参数。
第二个坑是时间戳单位搞混。performance.now() 返回的是毫秒,但很多动画公式用的是秒。忘了除以 1000,动画速度会差一千倍,快到你根本看不清。这个错误我犯过不止一次。
第三个坑是颜色字符串拼接太频繁。每帧给几百个粒子拼hsl(...)字符串,字符串分配的开销不小。如果颜色变化不频繁,可以预生成一批颜色缓存起来,用的时候直接取。
第四个坑是在移动端用了太高的像素比。高分屏的 devicePixelRatio 可能是 2 甚至 3,画布的实际像素数是逻辑尺寸的平方倍。如果不做限制,绘制量会暴涨。我的做法是把像素比上限设在 2,超过就按 2 算,视觉上几乎看不出差别,性能省一大截。
7. 这条路线的适用场景与延展玩法
7.1 哪些场景特别适合
程序化逐帧绘制最舒服的场景,是那些本质上是数学和逻辑的画面。
数据可视化是典型。把一组数据映射成动画,柱状图生长、折线图流动、散点图聚散,这些用代码描述比用视频描述自然得多,而且数据一变,动画自动跟着变,不需要重新生成。
抽象视觉和动态背景是另一个大类。网站首屏的动态背景、音乐可视化的波形、加载动画、过渡效果,这些需求量大,而且对写实度要求低,正好是程序化绘制的舒适区。
教育和演示场景也很有潜力。物理模拟、几何演示、算法可视化,用代码画出来可以交互、可以调参数、可以暂停讲解,比录一段视频灵活太多。
7.2 和传统视频工作流的结合点
这条路不是要取代传统视频,而是可以嵌进去。
一个思路是用程序化绘制生成素材,再导入剪辑软件做后期。比如生成一段粒子动画的序列帧,导出成图片序列,然后在剪辑软件里和其他素材合成。这样既有程序化的精确控制,又有后期软件的调色和剪辑能力。
另一个思路是实时渲染加录屏。浏览器里跑动画,用录屏工具抓下来,直接就是视频文件。这种方式适合那些不需要逐帧精确控制、只要整体效果好的场景。
还有一个思路是把代码作为交付物。客户要一个动态 logo,你交付的不是一个 mp4,而是一段可以嵌入网页的代码。这样 logo 在任何分辨率下都清晰,文件体积小,还能根据页面主题动态变色。这个思路在网页设计领域越来越常见。
7.3 还能怎么玩出花
粒子系统只是入门。同样的逐帧绘制框架,可以做的事情多得多。
用噪声函数生成有机的流动纹理,模拟烟雾、水流、火焰。用弹簧质点模型做布料和软体动画。用光线步进在片元着色器里渲染分形和体积效果。用物理引擎做碰撞和刚体运动。这些在浏览器里都有成熟的实现路径,而且性能越来越好。
再往深了走,可以把逐帧绘制和音频结合。用 Web Audio API 分析音频频谱,把频率数据映射到画面参数上,做出跟着音乐跳动的视觉效果。这个方向做出来的东西,比单纯的动画更有感染力。
甚至可以做成可交互的。鼠标位置、点击、拖拽都作为输入参数喂给绘制逻辑,画面实时响应。这种交互性是视频文件永远给不了的。
8. 我对这条路线的一些个人判断
折腾了这些之后,我越来越觉得“生成视频”这个词被用得太宽泛了。很多时候人们要的不是视频,是“会动的画面”,而视频只是实现这个需求的一种载体,而且往往是最重的那种。
代码生成画面这条路,把交付物从像素降维到了逻辑,一下子打开了很多可能性。它轻、它快、它可改、它可交互。它当然有做不到的事情,写实画面就是它的天花板。但在它擅长的领域里,它的效率是像素路线比不了的。
我个人的体会是,判断一个需求该走哪条路,问自己一个问题就够了:这个画面,用几句话能描述清楚它的运动规律吗?如果能,那大概率可以用代码画。如果不能,那可能真的需要像素路线。
最后分享一个我常用的小技巧。当你拿到一段 AI 生成的绘制代码,先别急着看效果,先把里面的数字参数全部提取到顶部,做成一个配置对象。然后花十分钟挨个改这些参数,看画面怎么变。这个过程能让你快速理解代码的逻辑,也能让你找到意想不到的视觉效果。很多时候,最好的效果不是模型一次生成的,而是你在调参过程中偶然撞见的。