☰
AI生成视频的另一条路:让代码驱动浏览器逐帧绘制
2026/10/20 18:01:34 网站建设 项目流程

前阵子有个伙伴拿着一个选题来问我:让 AI 模型直接生成一段产品说明视频,但出来的东西一塌糊涂——文字歪七扭八、数字乱跳、界面对不上需求。后来他自己折腾出一个很意外的解法:不再让模型“吐视频”,而是让模型写一段能在浏览器里运行的网页代码,浏览器一帧一帧把画面画出来,再把画布流录下来。折腾完他跟我说了一句:所谓 Opus 5.5 生成视频的真相,其实不是它生成视频,而是写代码让浏览器逐帧画出来。

这句话我越想越觉得在点子上。现在很多能写代码的大模型,在“出视频”这件事上走的是另一条路:先产出逻辑完整的 HTML+JavaScript,页面自带一套时间轴驱动的动画,打开后用录制工具把整个播放过程变成视频文件。这篇文章我就把这条链路从头到尾拆开讲一遍:先理清楚两种“生成”到底差在哪,再讲浏览器逐帧绘制和录制的完整原理,最后结合一个实战案例和一堆踩坑记录,给你一套能直接照着做的方案。

1. 先分清“像素派”和“代码派”:这是两条完全不同的路子

很多人以为“生成视频”只有一种做法:给模型一句话,它直接产出一个 mp4 文件。实际上做内容的人现在手里至少有两条完全不同的路线,选错路线,后面全是眼泪。

1.1 像素派:模型直接吐出帧序列

所谓像素派,就是常见的文生视频、图生视频那套机制。模型在内部把文字描述映射到潜在空间,再一步步去噪、采样,最后输出一帧一帧的像素图,拼成视频。它的优点是画面想象力强,能生成写实场景、特殊运镜、氛围感画面,这些你用代码硬画,画到天亮都画不出来。

但它的软肋恰恰在“精度”上。文字、数字、UI 布局这种必须分毫不差的东西,它经常翻车:一个柱状图里数字从 12 涨到 45,它可能在第二帧就给你写成 57;一段中文标题,前两秒是对的,第三秒就多出一个偏旁。原因也好理解,模型生成的是“看起来像”的像素,它并不理解文本内容本身,更不理解柱状图的高度和数字之间应该保持什么数学关系。

1.2 代码派:模型交付的是一段“画画程序”

代码派的逻辑完全不同。模型这次输出的不是视频文件,而是一个完整的 HTML 文件,里面有一段 JavaScript。这段代码做的事情是:根据时间计算每一帧应该长什么样,然后在<canvas>画布上把画面画出来。你打开这个网页,它自己就会动起来;你把它录下来,它就是视频。

好处非常明显。文字是浏览器字体库渲染的,所以中文永不变形;数字是代码算出来的,12 涨到 45 就是严格递增;UI 元素是坐标和样式画出来的,比例关系完全可控。改一个参数,重新录制,又是一版新视频,不需要重新生成、碰运气。

我经常拿一个类比来解释这两者的差别:像素派是让画师直接交一幅画,代码派是让工程师交一张带标注的工程图纸。前者适合画油画,后者适合盖楼。

1.3 为什么大家总是把两者混为一谈

会混为一谈,最核心的原因是结果看起来一样——都是一个 mp4 文件躺在你的硬盘里。加上不少模型和自动化工具会在后台疯狂截图、录制浏览器画面,用户从交互角度看到的就变成“我让 AI 生成了一个视频”,完全感知不到中间还有个“代码运行-逐帧绘制-录制”的过程。

但感知不到不代表这条链路不存在。对于产品演示、数据可视化、字幕动画、动态海报这类内容,理解代码派的工作方式特别关键,因为它的成败完全取决于你能不能把动画代码写得稳定、可复现。下面我从底层原理开始,把这个过程彻底讲透。

对比项像素派(文生视频/图生视频)代码派(生成程序逐帧绘制)
最终产物视频文件(像素序列)可运行网页代码 + 录制的视频文件
文字精度弱,中文、数字容易乱码强,等同浏览器渲染文本
帧间一致性风格可能漂移、物体形态抖动同一份代码,每一帧都严格一致
修改成本重新生成,随机性强改参数或代码,结果可预期
硬件依赖通常需要较强显卡任意现代浏览器即可
擅长场景写实风光、创意氛围、特殊运镜UI 演示、数据叙事、字幕动画、精确排版

2. 逐帧绘制的底层引擎:画布、刷新回调与纯时间推导

要理解“写代码让浏览器逐帧画出来”,得先理解浏览器里一帧画面到底是怎么被画出来的。这一节我把它拆成三个层次:画布操作、刷新回调、状态推导。

2.1 Canvas 2D:每一帧都是“清屏后重画”

<canvas>本质上就是一张位图。你要画一个新画面,通常的做法是先把上一帧的内容擦掉,再按当前时间把这一帧的内容重新画一遍。所以整个动画过程可以抽象成:不断重复“清空画布→计算状态→绘制元素”这个循环。

一个最简单的逐帧绘制骨架长这样:

<canvas id="scene" width="1280" height="720"></canvas> <script> const canvas = document.getElementById('scene'); const ctx = canvas.getContext('2d'); function renderFrame(t) { // 清屏 ctx.clearRect(0, 0, canvas.width, canvas.height); // 画背景 ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 根据时间 t 计算一个圆的位置并绘制 const x = (t / 20) % canvas.width; ctx.beginPath(); ctx.arc(x, 360, 40, 0, Math.PI * 2); ctx.fillStyle = '#22d3ee'; ctx.fill(); } // 每帧调用一次 renderFrame,t 是当前时间 function loop(t) { renderFrame(t); requestAnimationFrame(loop); } requestAnimationFrame(loop); </script>

你一旦理解了这个循环,就会发现所谓“动画视频”其实就是一段时间的画面记录。代码不直接产像素视频,它只负责在时间轴上不断往画布上图,浏览器负责把这些图呈现在屏幕上。

2.2 requestAnimationFrame:跟显示器刷新率同步的回调

控制绘制节奏的核心 API 是requestAnimationFrame,通常简写为 rAF。很多人会拿它跟setInterval比,但它俩根本不是一回事。

setInterval(fn, 16)是“没头脑式”的定时器,它到点就执行,不管页面是否可见、显示器是否准备好了。屏幕刷新率不同、浏览器标签页切到后台,时间都会乱掉,动画容易卡顿或加速。而 rAF 是浏览器在每次重绘前回调你一次,浏览器知道显示器什么时候刷新,所以它能做到和屏幕刷新率天然同步——通常是每秒 60 次,高刷屏上可能是 120 次。

更关键的是,rAF 的回调函数会带一个时间戳参数,单位是毫秒。这个时间戳是“页面从加载开始到现在经过的时间”,它非常精确,是我们做时间轴动画的基础。

常见的循环写法是:

const duration = 30000; // 动画总时长 30 秒 let startTime = null; function loop(ts) { if (startTime === null) startTime = ts; const elapsed = ts - startTime; renderFrame(elapsed); if (elapsed < duration) { requestAnimationFrame(loop); } else { renderFrame(duration); finishAndSave(); // 动画结束,触发保存视频的逻辑 } } requestAnimationFrame(loop);

注意这里elapsed是我们自己维护的“动画时间轴”,它只从启动瞬间开始计时,而不是用ts原始值。这样即使页面加载了一段时间后才点击“开始录制”,动画也能从 0 秒跑起。

2.3 画面状态必须由时间推导,不看上一帧

写浏览器动画最容易犯的错误,是用“累加上一帧的结果”来计算当前状态,比如x = x + 5。这种写法在帧率稳定时看着还行,一旦某一帧因为绘制太重、页面被切后台而变慢,累积误差就会让动画突然加速或者跳跃。代码派做视频最忌讳的就是这个,因为录制时每一帧都要稳定、可控、可复现。

正确做法是“纯函数式渲染”:画面里每个元素的状态,只由当前时间推导出来。同一个时间点,得到的画面永远一模一样。这个特性对视频制作极其重要,因为它意味着你可以随时重新录制同一段动画,前后两版逐帧一致;如果某一帧出了问题,你也能定位到具体时间点,去改那段状态计算逻辑。

一个标准的时间推导演示:

function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } // 把全局时间 t 映射到 [0, 1] 区间的进度 function progressAt(t, start, end) { return clamp((t - start) / (end - start), 0, 1); } // 缓动函数:让进度变化更自然 function easeInOutCubic(p) { return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; } // 某元素从第 1000ms 开始,用 2000ms 从 x=0 移到 x=600 const p = easeInOutCubic(progressAt(elapsed, 1000, 3000)); const x = 0 + (600 - 0) * p;

把“起止时间”“起止值”做成可配置的变量,你就能像搭积木一样排布整个时间轴。想要某个元素晚两秒再出现,改一个数字就行,不用动绘制逻辑。

3. 从画布变 MP4:录流和导帧两条可信链路

代码把画面画到浏览器里之后,下一步就是怎么把它变成真正的视频文件。这里我实践下来认为最靠谱的有两条路:一条是直接录制画布流,另一条是先导出帧序列再用命令行工具合成。两条各有适用场景,我分别讲。

3.1 路线一:captureStream 抓流,MediaRecorder 直接录像

浏览器有一条现成的录制链路:canvas.captureStream()能把画布内容变成一个媒体流,MediaRecorder能把媒体流录制成文件。整个过程全部在前端完成,不需要额外软件。

核心代码如下:

const stream = canvas.captureStream(30); // 按 30fps 采样画布 const mimeType = pickSupportedMime(); const recorder = new MediaRecorder(stream, { mimeType }); const chunks = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; recorder.onstop = () => { const blob = new Blob(chunks, { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'output.webm'; a.click(); URL.revokeObjectURL(url); }; recorder.start();

不同浏览器对编码格式的支持差异很大。我这个项目里就遇到过 Chrome 默认录出来是video/webm;codecs=vp9,而 Safari 根本不支持 webm,得用video/mp4;codecs=avc1。所以录制前最好做一次能力探测,选第一个被支持的格式:

function pickSupportedMime() { const candidates = [ 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/mp4;codecs=avc1', ]; return candidates.find((m) => MediaRecorder.isTypeSupported(m)) || ''; }

这条路最大的优点是简单,动画结束的时候调用recorder.stop(),浏览器就自动把这段时间的所有画面拼成一个视频文件。适合做快速交付、预览版本。

3.2 路线二:逐帧导出 PNG,交给 ffmpeg 压 MP4

如果你非要 MP4、H.264 编码,或者你的浏览器根本不支持把 canvas 流录成指定格式,那就走帧序列路线:一帧一帧导出图片,最后用 ffmpeg 合成。

在浏览器里逐帧导出,最简单的做法是用canvas.toBlob()拿到 PNG 数据,再通过下载帮你把它们存下来。更稳的方案是用OffscreenCanvas,因为它可以在后台渲染并直接convertToBlob(),不太影响主线程动画。

拿到一组连续的帧图片后,合成命令长这样:

ffmpeg -framerate 30 -start_number 1 -i frame_%04d.png \ -c:v libx264 -pix_fmt yuv420p -crf 18 -preset slower output.mp4

这里的几个参数值得解释一下。-framerate 30告诉 ffmpeg 每秒放 30 帧;-pix_fmt yuv420p是兼容性关键,很多播放器只认这种像素格式,不加它视频在部分设备上会黑屏;-crf 18控制质量,数字越小画质越高,文件越大;-preset slower用更多编码时间来换更高压缩效率。

帧序列方案适合对画质、编码、后期处理有强要求的场景,比如你要在合成前用其他工具对帧做裁剪、加滤镜,或者要接入公司内部的视频流水线。缺点是磁盘占用大、流程更繁琐。

3.3 声音怎么办:录流时混合音轨,或后期合并

很多“视频生成”类需求根本不需要声音,但如果你的动画要配解说词、背景音乐,声音就得单独处理。这里有两个常见做法。

第一种是录制前就把音轨混进媒体流,让 MediaRecorder 一并录下来。做法是用 Web Audio API 创建一个“媒体流目的地”节点,把音频源接进去,再把它的音轨和画布的音轨合并到同一个 MediaStream:

const audioCtx = new AudioContext(); const dest = audioCtx.createMediaStreamDestination(); const gainNode = audioCtx.createGain(); gainNode.connect(dest); // 把画布视频轨和音频轨合并 const combined = new MediaStream(); canvasStream.getVideoTracks().forEach((t) => combined.addTrack(t)); dest.stream.getAudioTracks().forEach((t) => combined.addTrack(t)); const recorder = new MediaRecorder(combined, { mimeType });

第二种是画面、声音分开录,最后用 ffmpeg 合成。我在录制 UI 演示时经常用这个方法,因为解说词往往要单独剪辑:

ffmpeg -i video.webm -i audio.m4a \ -c:v copy -c:a aac -shortest final.mp4

-c:v copy表示视频轨直接复制不重编码,速度快;-shortest表示输出长度取两个输入里较短的那个,避免画面或声音拖尾。

4. 实战:200 行代码做一段 30 秒的数据报告动画

理论讲完了,我用一个完整的实战案例把这些东西串起来。这个例子我做过很多次,适合作为入门模板:做一段 30 秒的“季度营收报告”动画,包含标题、折线图、KPI 数字滚动三个部分,最后自动录制并下载。

4.1 先把 30 秒拆成故事板

动笔写代码前,我强烈建议先画一张时间轴表,把每个时间段要发生什么列清楚。代码派最大的优势就是节奏可控,而节奏可控的前提是你先想清楚节奏。

下面这个表就是我自己常用的“故事板”:

时间段镜头内容画面元素
0~4s标题滑入主标题、副标题
4~16s折线图逐步绘制坐标轴、折线、数据点
16~26sKPI 卡片上浮、数字滚动三张卡片、百分比、金额
26~30s收尾定格LOGO 区块、结束语

每一段其实就是一个“进度区间”。我把这些区间定义为配置数据,渲染函数只负责根据配置画当前时刻的画面:

const timeline = [ { start: 0, end: 4000, name: 'title' }, { start: 4000, end: 16000, name: 'chart' }, { start: 16000, end: 26000, name: 'kpi' }, { start: 26000, end: 30000, name: 'ending' }, ];

这样写的好处是:以后要换内容、改时长,只需要改表格数据,渲染代码基本不用动。

4.2 画板、图表、数字滚动三段核心渲染逻辑

画布分辨率我固定为 1920x1080,这是标准 1080p 尺寸。注意千万不要用window.innerWidth这种动态值,否则录出来的视频分辨率会跟着窗口大小变,交付出去没法统一。

绘制折线图的核心逻辑,是按进度算出“应该画到哪个数据点”,然后把前 n 个点用线连起来。以 7 个数据点为例:

function drawChart(progress) { const data = [12, 18, 21, 26, 32, 38, 45]; const x0 = 240, y0 = 760; const stepX = 220, scaleY = 12; // 坐标轴固定 ctx.strokeStyle = '#334155'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x0 + stepX * 6, y0); ctx.stroke(); // 根据进度决定画到第几个点 const pointCount = Math.round(progress * (data.length - 1)); ctx.beginPath(); ctx.moveTo(x0, y0 - data[0] * scaleY); for (let i = 1; i <= pointCount; i++) { ctx.lineTo(x0 + i * stepX, y0 - data[i] * scaleY); } ctx.strokeStyle = '#22d3ee'; ctx.lineWidth = 5; ctx.lineCap = 'round'; ctx.stroke(); }

数字滚动的关键点和很多人想的不一样:不要在每一帧对同一个数字做“加减法累积”,而是用“目标值 × 当前进度”来算当前显示值。因为进度是从时间推导出来的,所以数字的变化永远是平滑的,而且重新录制时完全一致:

function drawKPI(progress) { const target = 45; // 最终值 const current = Math.round(target * easeOutCubic(progress)); ctx.font = '700 72px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.fillStyle = '#f8fafc'; ctx.textAlign = 'center'; ctx.fillText(current + '%', 640, 400); }

4.3 动画结束后自动停录并下载

把动画循环和录制状态绑在一起,是这段代码里比较容易绕晕的部分。我的建议是做一个简单的状态机:录制开始后记录startTime,动画循环里一旦elapsed >= duration,就调用recorder.stop(),并在onstop回调里生成下载链接。

一个值得注意的细节是:recorder.start()之后,最好等一下下再启动动画,或者干脆在recorder.onstart之后调用requestAnimationFrame(loop)。不然录制器还没准备好,动画已经跑了好几十毫秒,成片头几帧会丢内容。

在线预览和录制可以共用同一个渲染函数renderFrame(t)。预览时就正常跑 rAF,录制定稿时再走“带动画结束逻辑”的循环。我通常还会在页面上放一个大大的“开始录制”按钮,因为浏览器对自动播放音频、自动启动录制这类操作有用户手势限制,点一下按钮能躲开很多权限问题。

5. 复现过程中踩过的四个坑:模糊、卡顿、掉帧、不同步

这套方案看着简单,真正跑起来坑不少。我把四次明显踩坑的经历写出来,你能避开就避开。

5.1 成品糊:多半是 HiDPI 适配少做了一步

第一次用这套流程录出来的视频,画质让我非常失望,明明代码里线的颜色是清晰的,成片却像蒙了一层纱。排查了半天,问题出在显示器的物理像素密度上。

现在的显示器普遍是 2 倍物理像素,浏览器里的 CSS 像素和物理像素不是一对一。如果我只把 canvas 的 CSS 宽高设成 960x540,内部画布宽高也设成 960x540,系统会自动把它放大到物理分辨率,放大过程就是糊的过程。

解决办法极其简单:录制用视频的分辨率直接写进 canvas 的width和height,不考虑窗口大小。代码里所有坐标也直接用 1920x1080 这套坐标系,不让系统做任何缩放:

canvas.width = 1920; canvas.height = 1080;

如果你在页面上要兼顾预览美观,可以把 CSS 里的style.width设置成100%,让它按比例缩放显示,但内部画布尺寸保持绝对固定。这样录出来的东西永远清晰。

5.2 走着走着就卡:用时间戳算进度,不要用固定步长

我最早写心动效用的还是x += 5这种累积式写法,结果一个很复杂的图表页一画,帧率掉到 45fps 左右,动画播放速度就肉眼可见地变快了,因为一秒钟里执行的帧数和期望的不一样。

后来我把所有状态全部改成时间推导:进度 =(当前时间戳 - 开始时间戳) / 总时长,再也不依赖“上一帧长什么样”。这样做的直接好处是,画面帧率低一点、高一点,整段动画的节奏都不会变,变的只是画面流畅度。哪怕某几帧因为性能问题被浏览器扔掉,时间轴也依然准确,录出来的视频不会快进或慢放。

配合这个改动,我还会做一层性能优化:把静态背景、网格线、复杂阴影等不随时间变化的元素,预先画到一个离屏 canvas 上,每帧先用drawImage把背景贴上去,再画动态元素。这一步能把很多重绘制从“每帧重复计算”降级为“每帧只拷贝”,对 60fps 录制很关键。

5.3 录出来掉帧感明显:captureStream 的帧率别乱设

录制输出的帧率,跟captureStream的参数有直接关系。我一开始图省事写的是canvas.captureStream(0),文档说 0 表示“画布每次发生变化就采集一帧”。听起来很智能,结果录出来的视频帧率极不稳定,有的片段一秒钟才 8 帧,卡顿感非常明显。

原因是这个模式下,浏览器会在每次 draw 操作结束时立即采样画布,而我的动画每帧内部会有很多次绘制调用,采样点就被打得很乱。后来我改成显式指定帧率,并且让渲染和录制都按这个帧率对齐:

const fps = 30; const stream = canvas.captureStream(fps);

如果你目标是发布到视频平台,常见的帧率选 30 或 60 都行。但请记住,帧率定得越高,每帧绘制时间预算越短,复杂动画越容易丢帧。宁可 30fps 画质稳定,也不要 60fps 频繁掉帧。

5.4 音画对不上:动画时钟和音频时钟要用同一根

给一段动画配旁白时,我遇到过成片声音比画面慢 0.3 秒的问题。根因是动画时间轴用的是 rAF 时间戳,而声音用的是AudioContext.currentTime,两根时钟虽然都以毫秒计,但一个从“页面加载”起算,一个从“音频上下文启动”起算,中间一旦有用户点击、系统延迟,就对不齐了。

解决思路是把这两者放到同一个启动事件里:点击“开始录制”按钮后,先await audioCtx.resume(),再recorder.start(),同时把startTime赋值为当前时间戳。这样动画从 0 开始的同时,音频也从 0 开始播放,两条时间轴对齐。

如果前期没注意混流,录完了才发现对不上,还有一条补救路径:用同步工具拖动音频轨。比如音轨比画面慢 0.2 秒,可以用:

ffmpeg -i video.mp4 -i audio.m4a -itsoffset -0.2 \ -map 0:v -map 1:a -c:v copy -c:a aac synced.mp4

但补救总归不如一开始对齐来得省事,建议把“同一事件启动音频和录制”写成一个固定流程,每次都用。

6. 到底什么内容适合这招:一张选型清单

写代码让浏览器逐帧画视频,不是万能的,但它在一类内容上优势特别明显。我的选型经验可以归纳成三个问题。

6.1 适合:一切“必须准”的画面

如果你的视频里包含大量必须准确的文字、数字、界面元素,那代码派基本是首选。比如产品功能演示,要精确展示某个按钮的位置、弹窗的文字;再比如数据报告动画,折线要和数字严格对应,坐标轴刻度不能错;还有字幕动画、资讯条、歌词视频、教学讲解,这类内容的共同点是“内容本身比画面氛围更重要”。

浏览器渲染文本的能力是像素生成模型给不了的。你让模型画一个 24 号字的中文标题,它能给你画出一个大约 80% 相似但笔画粘连的“错别字”,而代码派的视频里每一个字都是标准字体字形,放大缩小都不变形。这个优势在中文场景里尤其值钱。

6.2 不适合:一切“要好看”的实拍感画面

反过来,如果你要的是写实人物、自然风景、流体、布料、电影感运镜、复杂光影,那代码派就该主动退场。你让模型写代码去模拟一段瀑布,大概率得到的是几个半透明蓝色矩形叠在一起,毫无水流的灵动感。这种画面是像素派的舒适区,它“画得像”的能力远超代码。

我带过的一个项目就吃过这个亏:明明要的是产品实拍加光影氛围,偏要让代码派去硬画,改来改去都不对劲,最后换回像素生成,一次通过。所以不要因为代码派在精度上有优势,就什么都往篮子里装。

6.3 选出路线后还建议做的事

无论你最终走哪条路,我有三个小习惯值得推荐。

第一,固定一个“成片规格”:分辨率、帧率、时长、编码格式,每次录制前都确认一遍,不要让一次成功的视频因为忘了改分辨率而全部重录。第二,动画代码要预留一个“总时长”常量,时长一变,所有基于时间的进度区间自动按比例缩放,不用一个个改数字。第三,给每个版本编号,我在实际项目里经常同时存在 v3 的数据、v5 的文案,版本管理跟不上,最后导出的可能还是旧版。

如果你主要做数据可视化、报告动画、产品演示这类内容,我甚至建议把整套流程沉淀成一个可复用的模板项目:故事板配置数组、渲染函数、录制逻辑、下载逻辑全部抽成独立模块,新项目只要换数据和图片资源,半小时就能出一版初稿。

我自己现在的习惯是:需要精确内容优先写代码,需要氛围画面才动像素生成工具,两个方案组合着用。开头那个伙伴后来也沿用了这套思路,他最新的视频甚至直接让模型生成了背景氛围视频,再用代码在上面叠精确的数据层,两阶段合成出片,效率和画质都很理想。这种组合方式,比纠结“到底哪种才是真生成”有意义得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询