1. 先厘清一个被标题带偏的认知:模型不产视频,产的是"可渲染的代码"
很多人第一次看到"用大模型做视频"这个说法,脑子里浮现的画面是:输入一句话,模型吐出一个 mp4 文件。这个想象很自然,但和实际发生的事差得很远。我前后折腾过几套基于代码生成动画的流水线,踩过的第一个坑就是没搞清这件事——模型从头到尾没有"生成"过任何一帧画面,它生成的是一段可被渲染引擎执行的代码,真正把代码变成视频的是浏览器或渲染器。
这个区别不是文字游戏,它直接决定了整条流水线的设计方式。如果模型直接产视频,你要关心的是分辨率、码率、编码格式;但既然它产的是代码,你要关心的就变成了:代码能不能跑、跑出来时间轴对不对、动画节奏是不是你要的、渲染出来的帧序列怎么合成。这是两个完全不同的技术栈。
所以标题里那句"并不会生成视频"其实是在纠偏。Claude Opus 5.5 这类模型在视频这件事上的真实角色,是一个代码作者,它写的是描述动画的脚本,比如用 Canvas 画、用 SVG 动、用 CSS 过渡、或者用某个动画库编排时间线。它不碰像素,只碰逻辑。
那为什么大家还是习惯说"AI 生成视频"?因为对最终用户来说,中间过程被封装掉了,看到的就是一个视频文件。但对做工程的人来说,中间这层封装恰恰是全部难点所在。这篇文章要拆的,就是这层封装——一条从"一句话需求"到"一个能播放的视频文件"的五步流水线。
适合读这篇的人有三类:一是想自己搭一套代码生成视频工具链的开发者;二是做内容自动化、需要批量产出动画素材的团队;三是单纯好奇"AI 做视频"到底怎么落地、想搞清楚原理的技术爱好者。不管你是哪类,读完应该能自己动手跑通一条最小可用流水线。
提示:本文讨论的是"代码驱动动画"这条路线,不涉及扩散模型直接生成像素的那类方案。两条路线原理完全不同,别混着理解。
2. 五步流水线的整体骨架:从需求到成片的链路长什么样
在拆细节之前,先把整条链路摊开看一遍。我把它归纳成五步,每一步都有明确的输入和输出,边界清晰,方便单独调试。很多人搭这套东西失败,不是因为某一步做不出来,而是因为步骤之间耦合太紧,一处出错整条链就崩,根本定位不到问题。
2.1 五步各自的职责与产物
| 步骤 | 名称 | 输入 | 输出 | 核心工具类型 |
|---|---|---|---|---|
| 1 | 需求结构化 | 自然语言描述 | 结构化规格(JSON) | 大模型 |
| 2 | 代码生成 | 结构化规格 | 动画源码 | 大模型 |
| 3 | 沙箱执行 | 动画源码 | 运行中的页面 | 无头浏览器 |
| 4 | 帧捕获 | 运行中的页面 | 帧序列图片 | 截图接口 |
| 5 | 编码合成 | 帧序列图片 | 视频文件 | 编码器 |
这张表看着简单,但每一格背后都有一堆细节。比如第一步"结构化规格"到底要结构到什么程度?我的经验是:结构到能约束住动画的关键参数就行,别过度设计。时间轴总长、画布尺寸、帧率、主要元素、每个元素的入场出场时机——这些定下来,模型生成代码时就不会跑偏太远。
2.2 为什么必须拆成五步而不是一步到位
有人会问:直接让模型输出一段能录屏的代码不就行了,为什么要拆这么细?我试过一步到位的做法,问题很明显。
第一,调试困难。如果模型生成的代码跑出来效果不对,你没法判断是需求理解错了,还是代码写错了,还是渲染环境的问题。拆开之后,每一步都能单独验证:规格对不对、代码语法对不对、页面能不能加载、帧有没有丢。
第二,可复用性差。一步到位意味着每次都要重新生成全部内容。拆开之后,规格可以复用(同一套规格换不同风格的代码),代码模板可以复用(同一套动画框架换不同内容),渲染管线更是完全通用。
第三,成本不可控。大模型调用是有成本的,一步到位意味着每次微调都要重新跑一遍完整生成。拆开之后,改个颜色只需要重新生成代码那一步,前面的规格不用动。
我个人的做法是:把第 1、2 步当成"创作层",第 3、4、5 步当成"生产层"。创作层可以反复迭代,生产层一旦跑通就基本不动。这个划分让整个项目好维护很多。
2.3 一个容易忽略的前提:渲染环境必须可复现
在讲具体步骤前,必须先强调一件事:渲染环境必须锁定版本。无头浏览器的版本、字体、系统依赖库,任何一个变了,渲染出来的画面都可能不一样。我踩过一次坑,本地跑得好好的动画,换到另一台机器上字体全变了,因为那台机器没装对应字体,浏览器回退到了默认字体,整个排版全乱。
解决办法很简单:用容器把渲染环境打包,把浏览器版本、字体、依赖全部固化进去。这样不管在哪跑,出来的帧都是一样的。这一步看着不起眼,但它是整条流水线能不能稳定产出的地基。
3. 第一步与第二步:让模型先想清楚,再动手写
创作层这两步是整条流水线里最"软"的部分,也是最考验提示词设计的地方。很多人在这里翻车,是因为把两步合成一步,让模型边想边写,结果规格和代码互相打架。
3.1 需求结构化:把"做个炫酷的开场"翻译成机器能懂的参数
自然语言描述最大的问题是模糊。"做个炫酷的开场"——多炫?多长?什么风格?模型只能猜。所以第一步的任务,是把模糊描述逼成明确参数。
我的做法是给模型一个固定的输出模板,强制它填字段。比如:
{ "canvas": { "width": 1920, "height": 1080 }, "duration": 8, "fps": 30, "background": "#0a0a0a", "elements": [ { "id": "title", "type": "text", "content": "示例标题", "enterAt": 0.5, "exitAt": 7.5, "animation": "fadeUp" } ] }这个模板的好处是,模型没法含糊其辞。它必须给出具体数值,而具体数值是可以被验证的。比如duration是 8 秒、fps是 30,那总帧数就是 240 帧,这个数字后面会直接决定渲染时间。
这里有个经验:帧率别盲目往高了设。60fps 看着比 30fps 顺滑,但渲染帧数翻倍,编码时间也翻倍。对于大部分以文字、图形为主的动画,30fps 完全够用。只有涉及快速运动或者需要极致顺滑的场景,才值得上 60fps。我一般默认 30,特殊需求再调。
还有一个细节:时间轴用秒还是用帧。规格里我建议用秒,因为人理解秒更直观;但生成代码时转成帧或毫秒,因为渲染引擎通常按毫秒算。这个转换在第二步做,别在规格里混着来,否则很容易算错。
3.2 代码生成:为什么必须给模型一个"骨架模板"
直接让模型从零写动画代码,结果往往不可控。它可能用 Canvas,也可能用 SVG,还可能用一堆 CSS keyframes,风格完全不统一。更麻烦的是,它可能写出语法正确但逻辑有问题的代码,比如时间轴对不上、元素重叠。
我的解法是:给模型一个固定的代码骨架,让它只填内容,不改结构。骨架里把渲染循环、时间轴管理、元素生命周期这些通用逻辑都写好,模型只负责往里面塞具体元素的动画定义。
这样做有几个好处。一是输出稳定,不管什么需求,代码结构都一样,方便后续处理。二是调试简单,出问题大概率是模型填的内容有问题,而不是框架本身。三是可以逐步优化骨架,骨架越成熟,模型要操心的越少。
骨架的核心通常是一个基于requestAnimationFrame的渲染循环,配合一个时间轴对象。时间轴负责把"当前是第几秒"映射到"每个元素应该处于什么状态"。模型要做的,就是定义每个元素的状态函数。
// 骨架核心逻辑示意 function renderFrame(currentTime) { elements.forEach(el => { const state = el.getStateAt(currentTime); applyState(el, state); }); }模型生成的部分,就是每个元素的getStateAt实现。这样职责清晰,模型不容易跑偏。
注意:生成代码后一定要做语法检查,别直接扔进渲染器。我习惯用解析器先过一遍,语法错误在这一步就拦下来,比在浏览器里报错好定位得多。
3.3 两步之间的"契约":规格变了代码必须跟着变
这两步之间有一个隐形的契约:代码必须严格遵循规格。规格说 8 秒,代码就不能跑 10 秒;规格说 30fps,代码的时间计算就得按 30fps 来。
我见过一种失败模式:模型生成代码时"自作主张",觉得 8 秒太短,自动延长到 10 秒,结果后面按 8 秒算的帧数去截取,视频被截断了。这种问题很隐蔽,因为代码本身没报错,只是结果不对。
防范办法是在生成代码的提示词里明确写死:所有时间参数必须来自规格,不得自行调整。并且在代码生成后,加一个校验步骤,检查代码里的关键参数和规格是否一致。这个校验可以用简单的正则或者 AST 分析来做,成本很低,但能拦住一大类问题。
4. 第三步与第四步:把代码跑起来,再把画面一帧帧抠出来
生产层这两步是纯工程活,没什么"智能"成分,但恰恰是最容易出稳定性问题的地方。我在这两步上花的时间,比在创作层多得多。
4.1 沙箱执行:无头浏览器怎么选、怎么配
把生成的代码跑起来,最直接的方式是用无头浏览器。市面上主流的几个选择,各有取舍。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 无头模式浏览器 A | 生态成熟,API 稳定 | 资源占用偏高 | 通用场景 |
| 无头模式浏览器 B | 启动快,轻量 | 部分特性支持不全 | 简单动画 |
| 服务端渲染方案 | 无需完整浏览器 | 复杂动画支持差 | 静态内容 |
我的选择是优先用生态成熟的那个,因为动画里经常用到各种现代特性,轻量方案容易在某个特性上卡住。资源占用高的问题,可以通过限制并发数来缓解——同时跑太多渲染实例,机器扛不住,反而更慢。
配置上有几个关键点。一是视口尺寸必须和规格里的画布尺寸一致,否则渲染出来的画面会被缩放或裁剪。二是禁用动画的自动播放干扰,有些浏览器会对页面动画做节流,导致帧不对。三是等待页面完全就绪再开始捕获,别页面还没加载完就截图。
// 启动渲染实例的关键配置示意 const page = await browser.newPage(); await page.setViewport({ width: 1920, height: 1080 }); await page.goto(htmlPath, { waitUntil: 'networkidle0' });networkidle0这个等待条件很重要,它确保所有资源都加载完了。如果动画依赖外部字体或图片,没等加载完就截图,出来的帧会缺东西。
4.2 帧捕获:时间轴驱动还是逐帧步进
捕获帧有两种思路,差别很大。
第一种是时间轴驱动:让页面按真实时间播放,每隔 1/fps 秒截一张图。这种方式简单,但有个致命问题——截图本身耗时。如果截图花了 50ms,而帧间隔只有 33ms,那实际捕获的时间点就会漂移,动画节奏全乱。
第二种是逐帧步进:不让页面自己走时间,而是由外部控制,每次把时间设到指定值,渲染一帧,截图,再设下一个值。这种方式慢,但时间精确,每一帧都对应准确的时间点。
我强烈推荐第二种。具体做法是:在页面里暴露一个全局函数,接收时间参数,手动触发一次渲染。外部循环调用这个函数,每次传不同的时间值,然后截图。
// 逐帧步进的核心逻辑 for (let frame = 0; frame < totalFrames; frame++) { const time = frame / fps; await page.evaluate(t => window.renderAtTime(t), time); await page.screenshot({ path: `frame_${frame}.png` }); }这样出来的帧序列,时间轴绝对准确,不会漂移。代价是慢一些,但视频质量比速度重要。
4.3 帧序列的命名与存储:别小看这个细节
帧文件怎么命名、存哪里,看着是小事,但处理不当会在第五步卡住。编码器通常要求帧按顺序排列,如果命名是frame_1.png、frame_10.png、frame_2.png这种,排序就乱了。
解决办法是用固定位数的序号,比如frame_0001.png、frame_0002.png,这样字典序就是时间序。位数根据总帧数定,240 帧用 4 位够了,几千帧就用 5 位。
存储上,帧序列很占空间。1920x1080 的 PNG 一帧可能几百 KB,240 帧就是上百 MB。如果并发跑多个任务,磁盘很快就满了。我的做法是边捕获边编码,或者捕获完立刻编码然后删掉帧文件,别让帧序列长期占着磁盘。
提示:如果磁盘 IO 是瓶颈,可以考虑把帧存到内存文件系统里,编码完直接丢弃。速度会快不少,但要小心内存占用。
5. 第五步:帧序列怎么变成能播放的视频文件
最后一步是把一堆图片合成视频。这一步工具选择多,参数也多,配不好出来的视频要么体积巨大,要么画质稀烂。
5.1 编码器选型与关键参数
最常用的编码工具是 ffmpeg,几乎什么格式都能处理。关键参数有这么几个:
- 帧率:必须和捕获时的 fps 一致,否则视频速度不对。
- 编码格式:H.264 兼容性最好,H.265 体积更小但兼容性差些。
- 质量参数:控制画质和体积的平衡,值越小画质越好体积越大。
- 像素格式:影响颜色表现,一般用 yuv420p 保证兼容性。
一条典型的合成命令长这样:
ffmpeg -framerate 30 -i frame_%04d.png \ -c:v libx264 -pix_fmt yuv420p -crf 18 \ output.mp4-crf 18是质量参数,范围一般 0-51,18 左右是视觉无损,再低体积涨得厉害但肉眼看不出差别。我一般用 18 到 23 之间,看内容复杂度调。
5.2 画质与体积的平衡:几个实测经验
画质和体积永远是一对矛盾。我实测下来有几个经验。
第一,文字和图形为主的动画,crf 可以设低一点,因为这类内容边缘锐利,压缩太狠会出现明显锯齿。crf 18 是个稳妥的起点。
第二,纯色背景多的动画,体积天然就小,不用刻意优化。反倒是渐变、噪点多的画面,体积会暴涨,这时候可以适当提高 crf。
第三,别用无损编码。无损出来的文件大得离谱,而且对最终观看体验的提升微乎其微。有损编码在 crf 18 这个级别,肉眼基本看不出差别。
第四,如果视频要上传到某些平台,注意平台的二次压缩。平台通常会再压一遍,所以源文件画质留点余量,别压得太狠。
5.3 音频怎么办:大多数代码动画其实不需要
代码生成的动画通常是无声的,或者只有简单的音效。如果确实需要音频,可以在合成时加进去:
ffmpeg -framerate 30 -i frame_%04d.png -i audio.mp3 \ -c:v libx264 -pix_fmt yuv420p -crf 18 \ -c:a aac -shortest output.mp4-shortest保证视频长度以短的为准,避免音频比视频长导致尾部黑屏。但说实话,大部分这类动画加音频反而画蛇添足,除非有明确的配音需求,否则我建议保持无声。
6. 整条链路跑通之后,那些文档不会告诉你的坑
前面五步讲的是"应该怎么做",这一节讲的是"实际做的时候会怎么翻车"。这些坑我都亲自踩过,写出来能帮你省不少时间。
6.1 时间漂移:最隐蔽也最致命的问题
时间漂移是代码生成视频里最烦人的问题。表现是:动画前半段正常,后半段越来越快或越来越慢,和预期对不上。
根因通常是时间计算用了不精确的时钟。比如用Date.now()算时间差,受系统调度影响,累积误差会越来越大。正确做法是用高精度时钟,或者干脆用逐帧步进的方式,从根上避免时间累积。
我早期用真实时间播放加截图的方式,跑了 10 秒的动画,出来发现实际时长是 10.3 秒,多出来的 0.3 秒就是截图耗时累积的。换成逐帧步进后,这个问题彻底消失。
6.2 字体缺失:换台机器就变样
前面提过字体问题,这里再展开说。浏览器渲染文字时,如果指定字体不存在,会回退到默认字体。不同系统的默认字体不一样,导致同样的代码在不同机器上渲染结果不同。
解决办法有两个。一是把字体文件打包进渲染环境,用@font-face显式加载,不依赖系统字体。二是在容器里预装所有可能用到的字体。我一般两个都做,双保险。
还有个细节:字体加载是异步的。如果页面没等字体加载完就开始渲染,前几帧可能用的是回退字体。所以渲染前要显式等待字体就绪,用document.fonts.ready这类接口确认。
6.3 内存泄漏:批量渲染时的隐形杀手
单个视频渲染没问题,批量跑几十个就开始出问题——浏览器实例越来越慢,最后卡死。这通常是内存泄漏。
原因可能是页面里的定时器没清理、事件监听没解绑、或者渲染实例没正确关闭。我的做法是每个任务用独立的浏览器上下文,任务结束彻底销毁,不复用。虽然启动开销大一点,但稳定性好太多。
另外,限制并发数也很重要。我一般同时跑 2 到 4 个渲染任务,再多机器就吃不消了。宁可排队慢一点,也别把机器跑挂。
6.4 规格与代码不一致:最容易被忽略的校验
前面提过规格和代码的契约,这里说具体怎么校验。我的做法是生成代码后,提取代码里的关键参数(时长、帧率、画布尺寸),和规格逐一比对。不一致就重新生成,或者报错让人工介入。
这个校验用简单的文本匹配就能做,不需要复杂的解析。比如规格里duration是 8,就在代码里找时间相关的常量,看是不是 8。虽然粗暴,但能拦住大部分明显的不一致。
7. 这套流水线还能怎么扩展
跑通基础版本之后,我陆续加了一些扩展,让整套东西更好用。
批量生成:把规格做成列表,循环跑整条流水线,一次产出多个视频。关键是做好任务队列和失败重试,别一个失败全挂。
模板库:把常用的动画效果(淡入、滑动、缩放)做成代码模板,模型生成时直接引用,不用每次重写。这样输出更稳定,生成也更快。
预览机制:在正式渲染前,先低分辨率快速渲染一版预览,确认效果没问题再跑全分辨率。能省不少时间,尤其是迭代阶段。
参数化配置:把分辨率、帧率、编码质量这些做成配置项,不同场景用不同配置。比如预览用低配,成片用高配。
这套东西我用了挺长时间,最大的体会是:别追求一步到位,先把最小链路跑通,再逐步优化。一开始就想着做得多完美,往往卡在某一步出不来。先让一句话变成一个能播放的视频,哪怕画质差、效果糙,跑通了再慢慢打磨,这才是靠谱的路径。
另外,模型的能力在变,但流水线的骨架不会变。今天用这个模型,明天换那个模型,只要规格和代码的契约还在,整条链路照样跑。把精力花在流水线的稳定性上,比追着模型版本跑划算得多。