同事问我:你们的动效短片,画面是一帧一帧画的,还是套模板?
严格说都不是。画面有两个来源:一个是我们自己写的图形组件(标题卡、要点卡、图表),负责把信息讲清楚;另一个是移植进来的开源项目 video-shotcraft 的镜头卡,负责让片子看上去不像 PPT。
这篇文章主要讲后者——它是什么、我们怎么接进来、四天里踩了哪些坑、为什么到现在也只用了其中一部分。只想看产品那部分的,直接翻到第五节。
一、它是什么
video-shotcraft 是 Vincentwei1021 放在 GitHub 上的开源项目,Apache-2.0 协议。我们考察时把它钉在 commit 5ddbf521(2026-10-05),作者还挂了一个 Gallery 站点,效果能直接看。
它的形态比较少见:一个仓库里同时放着配方卡和调校好的实现。
配方卡是 Markdown,一共 35 张,按用途分七类:开场 8、字体 8、数据 6、转场 5、结尾 4、节奏 3、交互 1。每张卡固定写六件事——意图、动效核心、参数表、声音、已知坑、参考实现。
举一张真实的卡(paper-title-card)为例。它的参数表列的是「逐词入场 / 重点词 / 下划线 / 时长 / 版式 / 副行」,而"声音"那一节直接钉死了音效位置:
字卡出场统一钉 swoosh-quick(模板片四张字卡钉在 f220/565/725/885 同音)
这句话里最值钱的其实是 f220/565/725/885 这串数字。它说明作者是在一支配好了音的成片里逐帧量出来的。这种东西写不进提示词,只能来自真做过一遍——从卡里反复出现的"模板片"、"钉帧"这些说法看,它大概率出自一位常年做广告片/模板片的人之手。
卡背后是能跑的代码:一套 Remotion 实现(35 个镜头组件 + 12 个共享件),跑在 Remotion 4.0.482 上。我们只把 import 路径改到自己的目录树(外加一条过严格模式编译的补丁),静帧就渲出来了——上游的调校没白做。另有 7 份参考文档,讲审美规则、流程、声音设计、节拍同步这些"卡与卡之间"的规矩。
有一点必须先说清:卡和代码必须成对移植。我们试过只搬文档,结果是模型能选卡、渲染侧没有实现,路走不通。
二、接进来:四天,三个池子
引入靠一个脚本 vendor-shotcraft.mjs <上游语料目录>,它按一份人工维护的清单 manifest.json 取件。清单是唯一事实来源,脚本本身不做审美判断:
- 清单把卡分成三个池子:
core27 张(主力)、url5 张(要真实页面截图,属于运镜类)、deferred3 张(依赖页面纹理和 3D,先不动); - 只注册 core 池,落成 35 个可用的镜头变体——因为有的卡自带多个变体(
chart-live-moves3 个、trailer-grammar-moves3 个); - 脚本生成两份产物:
registry.generated.ts给渲染侧(组件在不在、天然多长、依赖谁),catalog.json+catalog.prompt.md给方案层的模型(能挑哪些卡、每张卡装几个字)。
最后这份目录有个绕不过去的细节:它塞不进工具表——工具描述的字符预算是 6500,已经用掉 6263。所以选卡不是模型常规的工具调用,而是方案生成之外单独一次调用,目录随方案 payload 下发,不签进后端代码。
Apache-2.0 的 §4(b) 要求改过的文件必须标注"已修改"。我们如实记在 THIRD_PARTY/video-shotcraft/ATTRIBUTION.md 里,代码与音频分成两节,谁改了什么一条条列着。
下面五个坑,是按踩到的顺序写的。
坑一:上游根本没有 props 契约
拆开第一张卡就发现问题:这些组件全是零 prop 的。
形如 export const BlurSlide: React.FC = () => {
,文案、节奏、时长全都写死在模块级常量里。卡片正文里那句"参数化:words/sub/subDigits 可换文案",指的是去改常量,不是组件暴露了接口。
于是"移植一张卡"不等于"搬一个文件",而是给每个组件手写一层 props 契约。我们在 types.ts 里定下的是 ShotProps = { copy, theme, durationInFrames }:copy 拆到 title / lines / accentWords / sub / value / label / wordmark 这些位,theme 是一套配色令牌。
坑二:时长是约束,不是自由量
这条更隐蔽。上游的动效有两族驱动方式:一族按绝对帧号调(interpolate(frame, [16, 34], …),或者 const START = 16 这种命名常量),一族走 useT()(frame / (durationInFrames - 1),天生自由)。
出事的是绝对帧那一族。有一张卡的 useT() 读到的是整条片子的时长——片子越长,这张卡的动效越慢。30 秒的片子里它是惊鸿一瞥,3 分钟的片子里它像在慢慢憋气。
修法是把时长当约束用:要么按卡自己的自然时长原样跑,要么在上游给的区间里做均匀时间缩放,出了区间就换卡。至于按绝对帧调校的卡,归一化会直接毁掉调校——4 帧错峰、18 帧下划线窗口,都是 30fps 下的绝对量,等比例拉长就不是原来那支片子了。
坑三:音频不是 Apache-2.0
这个坑差点让我们上线一堆违规文件。
上游 assets/audio/ 下的音效与 BGM 不在 Apache-2.0 覆盖范围内,各自带着 Mixkit 的独立许可。Mixkit 的免费许可允许商用、无需署名,但有一条:不得把素材本身作为独立文件分发——不得放进模板、不得随源文件分发,必须并入一个"范围更大、性质不同"的成片。
而 Remotion 的 public/ 目录会被打包、可以被直接拉取。把 mp3 放进去,正好是许可禁止的那种形态。
比许可更麻烦的是授权链。上游自己的 ATTRIBUTION 里记着:首批音效是批量下载的,metadata 被抹掉了。现存 11 个音效里仍有 5 个无法反查(keyboard、pop、riser-cine、sparkle、whoosh-big),原文标着"商用前须确认"。这 5 个我们一律不用。
最后的落地是:音效一个都没进仓库(v1 干脆不出音效),BGM 5 首入库到 remotion/audio/bgm/。BGM 能入库有个前提——这个仓库是私有的,不算对外分发;哪天转公开,第一件事就是把那几个 mp3 摘出去。这一点连同代价一起写进了文档,没有含糊过去。
坑四:目录结构不能靠卡名猜
上游在 README 里警告过"不要凭卡名假设目录结构",我们还是差点照猜。清单里 brand-ink-open 归在 opening 类,文件却躺在 demos/typography/。
所以生成 registry 时,路径取自卡里「## 参考实现」那一行加上实际文件树,绝不从 category 推。
还有一批卡是我们主动放下的:url 池那 5 张要有真实页面截图才成立;deferred 池那 3 张依赖 @remotion/motion-blur 或页面纹理。而依赖真实页面纹理的 demo,随附的纹理里带着上游自己产品的界面截图——直接拿进商用片子会带出别人的内部信息。这几张先不碰。
坑五:那个 vendor 脚本是破坏性的
脚本每次先清空产物目录再拷。而产物目录里的文件在上次 vendor 之后被逐个参数化过——重跑一次,人工改动全没。
这事真发生过:33 张卡的改动一夜回到解放前(git 里还在,git checkout 救了回来)。现在脚本默认拒绝重跑——发现"已参数化"标记就退出,要重来必须显式加 --force。
三、把 5 个变体扩到 25 个
第一版只有 5 张卡能用,其余 30 张只是目录里的背景信息。原因还是零 prop:校验环节对没参数化的镜头一律剔除。
扩池子是一件件手工做的事。四天里的五个时点:
| 时点 | 可用镜头变体 |
|---|---|
| 10-08 移植当天 | 5 / 35 |
| 10-11 第一批 | 10 / 35 |
| 10-11 第二批 | 13 / 35 |
| 10-11 第三批 | 18 / 35 |
| 10-11 第四批 | 25 / 35 |
每张卡参数化之后都要过一道验收:默认路径(不给 props)渲出来,必须与上游逐像素一致。参数化是"给同一张卡换文案和配色",不是重画这张卡——默认观感漂了,等于偷改用户已经发出去的片子。
验法是渲两张静帧做差分:同帧数、同参数,一张是改完的,一张是 git stash 回上游版本渲的,再用 PIL 量化差异。逐像素零差异是常态;有差,就得说清差来自哪一层、差几个灰阶。
这里踩过一个坑:由配色令牌混出来的描边和网格线必须是实色。半透明会让线条颜色取决于它压在什么背景上——同一根边框压在浅底和深底上能差 16 个灰阶。混合比得按上游的实色反推。
四、它值不值
先把账摊开。
| 上游给的 | 我们补的 | |
|---|---|---|
| 画面 | 35 张卡的动效实现,逐帧调过 | 25 个变体的 props 契约(文案/配色/时长) |
| 语义 | 配方卡写清每张卡的适用与已知坑 | 生成模型能读的目录,单独一次调用选卡 |
| 时长 | 卡片给参考区间 | 自然时长常量、可用区间、越界降级 |
| 配色 | 4 套预设主题令牌 | 主题注入,与真实品牌色共存 |
| 声音 | 音效与钉帧位置写在卡里 | 音效暂不出;BGM 5 首入库 |
| 素材 | 灰阶占位假 UI | 真实图片、图表、用户上传的图 |
最大的收获其实不是"35 张卡",而是它把"画面该怎么动"写成了可检索的文档。我们方案层的模型挑卡时读的就是这批卡——它读到的不是"一张好看的镜头",而是"这张卡适合什么、装几个字、有哪些已知的坑"。
真要挑刺,它的问题也很具体。
一,能用的不到七成。 35 个变体里 25 个可用,剩下 10 个要么还得手工参数化,要么依赖纹理和 3D。这部分没有捷径。
二,卡再好看也装不下一次完整讲解。 一镜一张卡,片子最多 16 镜;一张卡通常只装 8~22 个字。想用镜头卡讲完一份两百多字的解说,只有两条路:压缩内容,或者重复用卡——后者会让片子看着重复。
三,它承载不了数据。 这是最要紧的一条。图表类的卡(chart-live-moves、gauge-readout-moves)虽然已经参数化,但数字位是装饰性的;整个池子里唯一带真实数据位的卡是 counter-confetti,只能安放一个数。一张 20 行的表,它画不出来。
四,审美是一个人的审美。 35 张卡、4 套主题出自同一位作者。风格统一是优点,也是上限。
五,它需要人维护。 上游语料更新了跟不跟、哪张卡进哪个池子、哪些依赖有问题,都要人一条条过,没有自动化。
五、那数据类的片子怎么办
这正是我们的产品要解决的问题,也是它跟"套模板"最不一样的地方。
数字从表里搬,不让模型碰。 用户传一张表进来,模型只做一件事:指出表头在哪、每列是什么。行和数字由程序搬——模型从头到尾没有机会"编"一个数出来。表最多收 20 行,这是全链路唯一的上限,画面和结算都以它为准。
引擎固定,不让模型挑。 表格源的成片固定走我们自己的图表组件:柱状图、横条榜、洞察卡直读表里的数。镜头卡不画图,它只被安排在屏与屏之间,做不承载数据的排版屏(一句金句、一个转场、一个收尾)。这两种画面的时长口径完全不同——图表屏的长度由配音决定,镜头卡的长度是它自己的帧数——所以是两套排程,共存于一条片子。
核验延伸到卡上。 摆在镜头卡上的数字同样必须原样出自这张表,核不上就把那一屏降级成经典画面,旁白一个字不动。少一屏画面是小事,片子讲错数是大事。
配音不只是念稿。 旁白可以用一段语音样本复刻音色;字幕按标点权重分配到帧上——标点代表停顿,逗号算两拍、句号算三拍——字幕跟着语气走,不是整屏弹一次。需要出镜的可以挂图片数字人。
顺带说一个我们自己的坑,跟上面那些一样真实:有次一份不到两分钟的片子,渲了 1000 秒的空白帧——片长是按占位的 30000 帧算的。改成显式传帧区间之后,同一条记录从 1231 秒掉到 119 秒;再把传给渲染子进程的线程数环境变量摘掉,又掉到 35 秒。渲染时长这种事,看日志看不出来,只有去看它到底渲了多少帧。
跟"一键成片"比
| 维度 | 通用一键成片 | 我们这条路 |
|---|---|---|
| 画面从哪来 | 模板与素材库拼贴 | 图表和动效组件按内容现排,逐帧调过 |
| 数字从哪来 | 模型生成,或你自己填 | 程序从你的表里搬,模型碰不到 |
| 你能改什么 | 换个模板、换首音乐 | 方案逐条改、逐镜换图、音色与画幅 |
| 什么时候花钱 | 多数是生成即扣费 | 先看方案,确认了才冻结 |
| 数据类内容 | 基本只能出静态图卡 | 20 行以内的表一屏画完,横竖屏都行 |
| 配音 | 通用 TTS | 音色复刻 + 按语气切字幕 |
六、接下来
两件事。
一是把剩下 10 个变体参数化。池子满了,片子才不容易看着重复。
二是给图表类的镜头卡接上真实数据位。现在整池只有一个数据位,而表里的数不止一个。让镜头卡也能画出真表,是两条路真正合上的一步。