先说个我自己的观察。过去大半年,身边做内容的朋友几乎都在试同一件事:让模型帮忙出视频。试完之后大多数人得出同一个结论——像抽卡。同一段提示词,今天出来的画面能看,明天出来的字幕压边、配音和画面错位、关键信息被裁掉一半。改一处,全盘重来。这不是模型不行,是这条路本身缺少可复现的骨架。我后来换了个思路,用Remotion做渲染引擎,把整个"怎么出片"的流程封装成Skills交给 AI 助手去调用,前后跑了二十多条成片之后,AI 生成视频这件事才算真正从玄学落到了工程层面。这篇就讲讲我是怎么搭的、每一步为什么这么选、以及踩过的那些坑。适合两类人看:一类是做过短视频但没碰过代码的内容创作者,另一类是写过前端但没做过视频渲染的开发者,两边都能对上号。
1. 三分钟理清:Remotion 与 Skills 到底是什么货色
1.1 Remotion:视频不是"画"出来的,是"算"出来的
大部分人理解的视频制作是时间轴加素材,一帧一帧拖。Remotion 走的是完全相反的路子:它把视频当成一个 React 组件,你给它一个frame数字,它返回这一帧应该长什么样。整个视频就是f(0)到f(N-1)的一次遍历,渲染器逐帧截图,再用 ffmpeg 编码成 mp4。
这个模型一旦建立,很多原本玄学的事情立刻有了确定性。因为画面是纯函数输出,只要输入的 props 一样、代码版本一样,出来的每一帧就完全一样,一个像素都不会飘。你想改标题字号,改一个 CSS 值重新渲染就行;你想批量做一百条,只要换一百份 props JSON。这就是"工程"和"玄学"最本质的分野——可复现。
代价也很清楚:你得会写一点 JSX,得理解帧率、时长、插值这些概念。所以我从一开始就没打算让模型"自由发挥"去写代码,而是把这些知识固化下来,让它在固定的轨道上跑。
1.2 Skills:把老师傅的手感写成可加载的说明书
Skills 这个概念说白了不复杂,它是一个文件夹,里面至少有一个SKILL.md,头部用 YAML 写两三个字段:名字、一句话描述(什么时候该用我)。Agent 启动的时候,上下文里只挂这些元数据,几十个 token 而已;等到用户的请求真的匹配上了,它才会把正文加载进来,正文里再用相对路径指向references/里的详细文档和scripts/里的可执行脚本。
这个"渐进式披露"的设计对我特别关键。Remotion 的坑不少:字体没加载完就渲染会得到空白文字、staticFile()的路径规则和普通 import 不一样、durationInFrames的单位是帧不是秒、并发数开太高会 OOM。这些东西如果每次对话都从头讲一遍,上下文早就爆了。写成 Skill 之后,元数据常驻,详细规则按需加载,脚本直接跑——相当于给 AI 配了一本随身的施工手册,还带工具箱。
1.3 拼起来之后,"玄学"到底被治好了哪几处
我把这套组合的价值归成四条。第一是确定性:同输入同输出,客户要改一个字,我重渲染一遍,其余画面零变化,不用重新抽卡。第二是可控粒度:模型生成的是结构化 JSON(标题、分镜、每句文案、时长、配色),不是像素,任何一层都能单独替换。第三是可版本管理:整个项目就是一个 git 仓库,改动有 diff,回滚有历史。第四是可批量化:一个模板配 N 份数据,出 N 条片子,边际成本接近零。
这四条听着像宣传语,但你真跑过一版就知道差别有多大。以前改一句配音文案,等于推翻重来;现在改 JSON 里一个字段,渲染三分钟,收工。
2. 方案选型:我为什么放弃"抽卡式出片",改走编程渲染这条路
2.1 三条路线的横向对比
在动手之前我把能走的路都捋了一遍,大致三类。下面是当时做的对比表,字段是我自己关心的几个维度,不是通用评测。
| 维度 | 纯模型端到端生成 | 模板拖拽工具 | 编程式渲染(Remotion) |
|---|---|---|---|
| 画面可控性 | 低,提示词决定一切 | 中,受模板约束 | 高,代码级控制 |
| 可复现性 | 差,同提示词结果不同 | 较好 | 完全确定 |
| 改单点成本 | 极高,基本等于重做 | 中,受模板限制 | 低,改字段重渲染 |
| 批量能力 | 弱 | 中,需手工复制 | 强,数据驱动 |
| 学习门槛 | 最低 | 低 | 中,需要 JSX 基础 |
| 长尾定制 | 几乎为零 | 低 | 无上限 |
| 稳定出片周期 | 不可预估 | 小时级 | 分钟级 |
看这张表就明白我为什么选第三条。端到端生成适合做氛围素材、概念片、几秒钟的视觉冲击,一旦涉及"第 8 秒必须出现产品名、第 12 秒必须切到价格"这种硬约束,它就交不出活。模板工具能解决一部分,但你的所有片子会长得一模一样,而且模板不给你改底层布局的自由。
Remotion 的位置刚好卡在中间偏工程:门槛比拖拽高,比从零写 Canvas 低得多;自由度接近写代码,但常用能力(转场、字幕、音频、静态资源)都有现成包。
2.2 为什么技能层不是锦上添花,而是必需品
有人会问:既然代码可控,为什么不直接让 AI 写 Remotion 代码?我试过,答案是它能写,但写不稳。
不稳在哪?它默认会按 Web 前端的习惯写动画,用 CSStransition和animation——这在 Remotion 里是死路,因为渲染器是逐帧截图的,CSS 动画的时间基准和渲染帧不同步,出来要么不动,要么跳帧。它也会忽略delayRender,字体没加载完就开始渲染,结果标题变成一片空白。它还会把durationInFrames写成秒数,导致视频只有 30 帧。
这些错误的共同点是:不是能力问题,是知识缺口。而知识缺口正好是 Skill 该补的地方。我把 Remotion 的核心约束、常见误写、模板代码、渲染命令全部写进 Skill,模型每次动手前会先读到这些,出错率肉眼可见地降下来。这就是我说的"技能层是必需品"——它不是让 AI 变聪明,是让它别在已知的坑里再摔一次。
2.3 工程骨架长什么样
我最终落地的目录结构大概是这样,你可以直接照着建:
video-project/ ├── public/ │ ├── fonts/ │ └── audio/ ├── src/ │ ├── index.ts # 注册入口 │ ├── Root.tsx # Composition 注册 │ ├── Main.tsx # 主视频组件 │ ├── components/ # 可复用视觉组件 │ └── schema.ts # props 类型定义 ├── data/ │ └── ep001.json # 单条视频数据 ├── skills/ │ └── remotion-video/ │ ├── SKILL.md │ ├── references/ │ │ ├── animation.md │ │ └── rendering.md │ └── scripts/ │ └── render.sh ├── remotion.config.ts └── package.json这个结构有个好处:数据、模板、技能三层彻底分离。换内容只动data/,换风格只动src/components/,换流程只动skills/。三者互不干扰,也让 AI 每次只需要读它该读的那一层。
3. 核心细节拆解:把"大片感"拆成可以填参数的东西
3.1 参数化的边界:哪些进配置,哪些必须写在代码里
这是整个项目里我修改次数最多的一处设计。一开始我把所有东西都塞进 JSON——字体、颜色、字号、间距、动效曲线,结果 JSON 比代码还长,改起来更痛苦。
后来定了一条线:内容进配置,视觉进代码,只有需要批量变化的视觉才进配置。具体划分是:
- 进配置:标题文案、副标题、分镜列表、每段时长、主色、强调色、背景图路径、音频路径、片尾署名。
- 进代码:字号层级、字重、行高、间距节奏、动效曲线、入场延迟、转场类型。
理由很直白:文案每条视频都不同,必须外置;字号行高这类东西一旦定下来就是一整套视觉语言,每条视频都改反而会破坏一致性。主色算例外,因为系列里可能按主题换色,所以放配置。
判断标准可以简化成一句话:这条数据变了,画面应该变;那条数据变了,画面不该变。想清楚这个,参数边界自然就出来了。
3.2 帧、秒、时长:把所有时间换算成帧数
这是我见过最多人翻车的地方。Remotion 里所有时间单位都是帧,不是秒,不是毫秒。人脑习惯秒,所以中间必须有一次换算,而且这个换算必须在写代码之前完成,不能边写边算。
公式很简单:
总帧数 = 时长(秒) × 帧率 起始帧 = 起始时间(秒) × 帧率举个实际例子。我做一条 45 秒的竖版短视频,30fps,那么durationInFrames = 45 × 30 = 1350。分镜拆成 4 段,时长分别是 6 秒、14 秒、18 秒、7 秒,换算成帧就是 180、420、540、210,加起来正好 1350。每段的起始帧是前面所有段累加:0、180、600、1140。
字幕的时长我另外有一套估算法。中文口播的舒适语速大概每秒 4 到 5 个字,按 4.5 算,一句 18 个字的文案大约 4 秒,也就是 120 帧;留 6 帧的呼吸间隙,实际占 126 帧。这套估算肯定不精确,但它保证了一件事:没有人声的时候画面不会长时间空着,有人的时候字幕不会一闪而过。真要精确,就把配音的时长直接量出来填进 JSON,这是最稳的做法。
注意:
fps一旦改了,所有按帧写死的数值全部失效。所以我在项目初期就把帧率定死为 30,写进 Skill 的硬性约束里,任何情况下不允许改。
3.3 动效的三板斧:插值、弹簧、序列
Remotion 的动效生态其实就三个核心 API,把这三个吃透,90% 的画面都能做出来。
interpolate是线性映射,给定输入区间和输出区间,自动算出当前值。基础用法是这样:
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', });这里clamp必须加。不加的话,第 16 帧开始透明度会继续往上跑到 2、3、4,浏览器会把它当 1 处理,看起来没问题,但一旦你把这个值拿去算位移或者缩放,画面就会炸。我第一版就是因为这个,标题飞出了画布。
spring是物理弹簧,输出 0 到 1 的自然缓动,做元素入场特别合适:
const enter = spring({ frame, fps, config: {damping: 200, stiffness: 100, mass: 1}, });damping越大越"稳",越小越"弹"。我的经验是文字用 200 左右,接近临界阻尼,不会有回弹;图形元素用 12 到 20,能出一点俏皮的过冲。这个参数没有标准答案,得自己拖进度条看。
Sequence负责把时间切片。它接受from和durationInFrames,内部的子组件会看到一个从 0 重新开始的frame。这个"时间归零"的机制非常关键——它意味着每个分镜组件都可以按"我从第 0 帧开始入场"来写,不用关心自己在整条视频里的绝对位置。分镜一多,这个抽象能省掉大量心智负担。
3.4 SKILL.md 的写法:结构、字段与措辞
Skill 的正文写得好不好,直接决定 AI 的产出质量。我改了四五版才摸到点门道。
文件头部是 YAML frontmatter,只有两个字段值得认真对待:name和description。description是唯一常驻上下文的部分,它决定了技能什么时候被唤起,所以要写得具体、带触发词。我一开始写的是"帮助制作视频",太泛,模型经常在该用的时候不用。改成"用 Remotion 生成或修改代码驱动的视频,涉及分镜、字幕、动画、渲染命令时使用"之后,命中率明显上来了。
正文部分我按"约束 → 模板 → 检查"三段来组织。约束段全是硬性规则,语气要强硬,比如"所有时间单位必须是帧"、"禁止使用 CSS transition 和 animation"、"字体必须通过 loadFont 加载并在顶层调用"。模板段给一段能直接跑的最小代码,模型照着改比从零写靠谱得多。检查段是一份清单,渲染前逐条过。
references/里放细节文档。animation.md讲插值和弹簧的参数调法,rendering.md讲渲染命令和性能优化。这些内容很长,不适合常驻,按需加载就好。
scripts/render.sh是最实用的部分,把命令封装成脚本,模型只要调用就行:
#!/usr/bin/env bash set -euo pipefail npx remotion render src/index.ts Main "out/$1.mp4" \ --props="data/$1.json" \ --concurrency=4 \ --codec=h264 \ --crf=18set -euo pipefail这三件套别省。渲染失败的时候,没有它脚本会继续往下跑,最后你拿到一个半截的文件还不知道错在哪。
4. 实操:从空目录到一条成片的完整流程
4.1 环境搭建与最小可渲染工程
先说版本要求:Node 18 以上,建议 20 LTS。低于 16 会缺一些 API,报错信息还特别难懂。
初始化可以直接用官方脚手架,也可以手动搭。手动搭更透明,我贴一下核心几个文件。
src/index.ts是入口,只做一件事:
import { registerRoot } from 'remotion'; import { RemotionRoot } from './Root'; registerRoot(RemotionRoot);src/Root.tsx注册 Composition,也就是告诉渲染器"有这么一个视频,它多长、多大、用什么组件":
import { Composition } from 'remotion'; import { Main } from './Main'; import { defaultProps } from './schema'; export const RemotionRoot: React.FC = () => ( <Composition id="Main" component={Main} durationInFrames={1350} fps={30} width={1080} height={1920} defaultProps={defaultProps} /> );注意width和height。竖版短视频是 1080×1920,横版是 1920×1080,方版 1080×1080。分辨率一旦定了就别改,因为所有字体大小、间距、安全边距都是按这个尺寸调的,换一个尺寸等于重新排版。
package.json里加两条脚本,日常就够用了:
{ "scripts": { "dev": "remotion studio src/index.ts", "render": "remotion render src/index.ts Main out/main.mp4 --props=data/ep001.json" } }npm run dev会起一个本地预览器,带时间轴、带参数面板,改代码热更新。这个预览器是我认为 Remotion 最值回票价的地方——你能像调网页一样调视频,还能逐帧步进。以前做动画只能渲染完看成品,现在改一像素立刻看到效果。
4.2 素材、字体与静态资源的处理
这一块是新手最容易卡住的地方,我单独拎出来讲。
Remotion 里引用本地文件,必须用staticFile(),文件放在public/目录下,路径参数是相对于public/的:
import { Img, Audio, staticFile } from 'remotion'; <Img src={staticFile('images/bg-01.jpg')} /> <Audio src={staticFile('audio/vo-ep001.mp3')} />不能写成../public/images/bg-01.jpg,也不能用import直接导入。原因在渲染机制:渲染是在一个无头浏览器里跑的,相对路径的基准和你本地不一样,staticFile()会把它拼成渲染器能识别的 URL。我第一次不知道这个,图全裂了,还以为是路径写错了,查了半小时。
字体更麻烦,有个时序问题。浏览器加载自定义字体是异步的,而渲染器截帧是同步推进的。如果不做等待,渲染器可能在第 0 帧就截图,那时候字体还没到位,结果就是标题用默认字体渲染出来,或者直接空白。
正确做法是在模块顶层调用loadFont,Remotion 会自动接管延迟渲染:
import { loadFont } from '@remotion/fonts'; import { staticFile } from 'remotion'; loadFont({ family: 'TitleFont', url: staticFile('fonts/title-bold.woff2'), weight: '700', });然后组件里用fontFamily: 'TitleFont'。这套写法比手动delayRender/continueRender省心得多,除非你有非常特殊的加载逻辑,否则不用碰那两个 API。
素材规格我总结了一个经验值,供参考:
| 素材类型 | 建议规格 | 说明 |
|---|---|---|
| 背景图 | 尺寸等于画布 1.2 倍,JPEG 质量 80 | 留出推拉空间,体积可控 |
| 图标 | SVG 优先 | 任意缩放不糊 |
| 配音 | 44.1kHz 单声道 mp3 | 人声够用,体积小 |
| 背景音乐 | 44.1kHz 立体声 mp3 | 需要空间感 |
| 字体 | woff2,只打包用到的字重 | 字重越多渲染越慢 |
4.3 从 JSON 脚本驱动整条视频
数据驱动的核心是一份结构化的 JSON。我的结构大概长这样:
{ "title": "一个具体的话题标题", "subtitle": "副标题最多十二个字", "theme": { "primary": "#1F6FEB", "accent": "#F0883E" }, "segments": [ { "type": "cover", "durationInFrames": 180, "text": "开场" }, { "type": "bullet", "durationInFrames": 420, "heading": "第一层逻辑", "points": ["要点一", "要点二"] }, { "type": "quote", "durationInFrames": 540, "text": "一段引述内容" }, { "type": "outro", "durationInFrames": 210, "text": "片尾" } ], "audio": "audio/vo-ep001.mp3" }组件侧做两件事:把segments的时长累加算出总帧数,用Series或手写Sequence依次排布。Series的好处是会自动累加offset,不用自己记起点:
import { Series } from 'remotion'; <Series> {data.segments.map((seg, i) => ( <Series.Sequence key={i} durationInFrames={seg.durationInFrames}> <SegmentRenderer segment={seg} theme={data.theme} /> </Series.Sequence> ))} </Series>SegmentRenderer按seg.type分发到不同的分镜组件。这个分发层是整套设计的关键抽象——新增一种分镜,只要加一个组件和一个case,其余代码一行不动。
有个细节值得说:durationInFrames的总和必须和Composition上的值对齐,否则最后一段会被截断。我的做法是在Root.tsx里算,不写死:
const total = data.segments.reduce((s, seg) => s + seg.durationInFrames, 0); <Composition durationInFrames={total} ... />这样数据一改,时长自动跟着走,永远不会错位。
4.4 预览、渲染与批量出片
日常流程分三步:预览、单条渲染、批量渲染。
预览用npm run dev,本地起一个 Studio,可以拖时间轴、改参数实时看。这一步不要跳,我所有的排版问题都是在这一步发现的——尤其是文字压到安全区外、元素出画、转场重叠。
单条渲染:
npx remotion render src/index.ts Main out/ep001.mp4 \ --props=data/ep001.json \ --concurrency=4 \ --codec=h264 \ --crf=18参数解释一下。--concurrency是并行渲染的标签页数量,默认是 CPU 核数的一半左右,我的机器是 8 核,开到 4 最稳,开到 6 反而因为内存压力变慢。--codec=h264兼容性最好,平台通吃。--crf控制质量,范围 0 到 51,数字越小质量越高体积越大,18 是视觉无损附近,我用这个值出成片;如果只是内部审片,用默认值或者 23 就行,快不少。
批量的话,我在 Skill 的scripts/里放了一个循环脚本:
for f in data/*.json; do name=$(basename "$f" .json) npx remotion render src/index.ts Main "out/${name}.mp4" --props="$f" --concurrency=4 done渲染耗时我实测过一个数据:1080×1920、30fps、1350 帧的片子,单帧平均 70 到 90 毫秒,理论总时长 1350 × 0.08 ≈ 108 秒,4 并发实际跑下来 45 到 60 秒,因为编码阶段是瓶颈,并发收益会打折扣。也就是说一条 45 秒的片子,从按下回车到成片落地,差不多一分钟。这个速度意味着你可以一天迭代几十次,这在拖拽工具里是不可想象的。
配套还有一个静帧命令,用来出封面特别好用:
npx remotion still src/index.ts Main out/cover.png --frame=45 --props=data/ep001.json挑一帧画面最饱满的当封面,比另外做封面省事得多。
5. 常见问题与排查实录
5.1 高频故障速查表
下面这张表里的每一条,我都至少踩过一次,有些踩过三次以上。
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 成片从头到尾一片黑 | durationInFrames写成了秒数 | 改成秒数 × fps |
| 字体变成默认黑体或空白 | 字体未加载完成就渲染 | 顶层loadFont,不放组件内 |
| 图片全部裂开 | 用了相对路径或import | 改staticFile(),文件放public/ |
| 动画完全不动 | 用了 CSStransition/animation | 改interpolate+useCurrentFrame |
| 元素飞到画布外 | interpolate未加clamp | 两端都加extrapolate: 'clamp' |
| 最后一段没播完就结束 | 总帧数与分段之和不一致 | 用reduce动态计算总帧数 |
| 渲染到一半进程被杀 | 并发过高导致内存不足 | 降到 2 到 4,关掉其他重负载程序 |
| 音画不同步 | 音频起点未设置startFrom | 用Sequence或startFrom对齐 |
| 中文断行位置很怪 | 缺wordBreak或容器宽度过窄 | 设wordBreak: 'break-all'并留足边距 |
| 渲染出来的颜色偏暗 | 色域或压缩参数问题 | 检查--crf,必要时换--codec=prores |
5.2 几个不常见但致命的坑
第一个坑来自动画的物理参数没有归一化。spring的输出依赖fps,如果你在 30fps 下调好了参数,某天换到 60fps 渲染,所有动画的速度会翻倍。这不是 bug,是设计如此。我的处理办法是把fps从useVideoConfig()里取,永远不硬编码,同时在 Skill 里明确写"帧率是全局常量,任何情况下不修改"。
第二个坑是中文字体的字重缺失。很多开源中文字体只提供 Regular 和 Bold 两档,你在代码里写fontWeight: 900,浏览器会做"伪粗体",就是给字形描边加粗。这在网页上勉强能看,但在视频里一旦放大到 1920 宽,描边会糊成一坨,边缘发毛,非常明显。解决办法只有两个:要么找到真正提供黑体字重的字体,要么在设计上避开 900 这个档位,用 Bold 加字号来拉开层级。
第三个坑是音频的淡入淡出没做。背景音乐直接从第 0 帧以满音量开始,第一秒会有一个非常突兀的爆音。这个在预览里不仔细听会漏掉,但发布出去之后评论区一定有人提。做法是用interpolate算音量曲线,喂给<Audio volume={...}>:
const volume = interpolate( frame, [0, 30, total - 60, total], [0, 0.6, 0.6, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } ); <Audio src={staticFile('audio/bgm.mp3')} volume={volume} />30 帧淡入、60 帧淡出,按 30fps 算就是 1 秒进、2 秒出,听感很自然。
第四个坑偏工程:public/目录里堆了几百兆素材之后,渲染会明显变慢。原因是渲染器启动时会把整个目录挂载进无头浏览器。我后来把素材按项目分子目录,一个项目只留自己用的,速度立刻回来了。
5.3 我的日常检查清单
每次渲染正式成片之前,我会走一遍这份清单,十分钟能省下两小时返工。
画面层面:所有文字是否都在安全区内,竖版视频底部要留出约 250 像素给平台 UI 遮挡;字号最小的一行在手机上看是否还能读;所有元素是否有出画的情况;转场处是否有重叠或黑帧。
时间层面:总帧数是否等于分段之和;每段时长是否和配音对得上;片头片尾是否有足够的停留时间让观众看清信息。
资源层面:字体是否只有一个来源、没有回退;音频是否有淡入淡出;图片是否都走了staticFile()。
工程层面:props是否来自外部 JSON,没有硬编码在组件里;fps是否只在一处定义;渲染命令是否带了--props。
这份清单我直接写进了 Skill 的正文末尾,让 AI 每次渲染前自己过一遍。效果比我想象的好——它真的会逐条核对,还会主动指出"第 3 段的时长和累积起始帧对不上"。
6. 一点延伸:这套东西还能怎么用
跑顺之后我发现它的适用面比预想的宽。除了短视频,我用它做过产品更新的版本播报——每周从变更日志里拉数据,渲染一条 30 秒的横版视频,全自动。也做过数据可视化的动态图表,把一个 CSV 喂进去,条形图按数据逐帧生长,比静态图直观得多。还有人用它批量生成带学员名字的结课证书视频,一人一条,几百条跑一晚上。
我个人在实际操作中的体会是:真正省时间的不是"AI 帮我写代码",而是"AI 在一个被约束好的轨道上写代码"。轨道是 Skill 定的,约束是 Remotion 定的,AI 在这两者之间做填充和变体。缺了任何一环,效率都会掉回去。所以如果你也想走这条路,我的建议是先把最小可渲染工程跑通,把一条片子从头到尾渲出来,然后再去写 Skill——没有跑通过的项目,写出来的技能文档全是纸上谈兵。
最后分享一个小技巧:把你每次踩坑的排查过程,顺手记进references/troubleshooting.md。三个月之后你会发现,这份文档比任何教程都值钱,因为它记的全是你自己环境里真实发生过的事。