《炊想》要发一条抖音宣传片。手边没有相机,没有剪辑软件,也没有音效库,最后整条片子都是代码写出来的:一个 HTML 页面画画面,无头 Chrome 逐帧截图,numpy 合成声音,ffmpeg 合成 mp4。
前后做了五版,前四版都不满意。第五版的成片是这样的:
这篇记录这套做法的几个关键环节:怎么让每一帧都可复现,静态图片怎么做出镜头感,渲染为什么慢、慢在哪里,没有素材怎么合成做菜的声音。最后一节是前四版为什么不行:比起技术,这一节的教训更重要。
一、把视频写成一个 render(t)
最直接的想法是写一页 CSS 动画,然后录屏。这条路走不通:录屏是实时的,机器一卡就掉帧;CSS 动画、requestAnimationFrame 都跟着真实时间走,同一段动画录两遍,每一帧都不一样。
换个思路:页面里不放任何会自己动的东西,只暴露一个函数 render(t)。给它一个时间(秒),它把页面上每个元素的位置、透明度、文字都按这个时间算好、写死。页面本身是静止的,时间由外面的脚本一格一格地推:
const FPS = 60;
await page.setViewport({ width: 1080, height: 1920, deviceScaleFactor: 1 });
await page.goto('file://' + path.join(dir, 'scene.html'), { waitUntil: 'networkidle0' });
const total = Math.round(await page.evaluate(() => window.DURATION) * FPS);
for (let i = from; i < to; i++) {
await page.evaluate(t => window.render(t), i / FPS);
await page.screenshot({ path: `frames/${String(i).padStart(5, '0')}.jpg`, type: 'jpeg', quality: 94 });
}
一台机器慢一点,只是渲染得慢,不会掉帧。为了保证「同一个 t 永远画出同一帧」,页面里有三条规矩:
- 不用 CSS 动画和过渡,也不用
Date.now();所有运动都是 t 的函数。 - 随机数自己种。蒸汽团块的位置、大小用的是固定种子的线性同余:
seed = seed * 16807 % 2147483647,每次打开页面都一样。 - 截图前等字体和图片。
networkidle0不保证字体已经可用,所以再等document.fonts.ready和每张图片的onload,并用document.fonts.check('900 60px "Songti SC"')打一行日志确认。否则头几帧可能是用回退字体画的。
有了这几条,好处马上就来了:
- 可以只渲染一段。第 18 秒的标题字号太大、伸出了屏幕,我改了 CSS,只重渲第 1040~1145 帧,再整体重新编码,不用从头来。
- 可以并行。整片 1440 帧拆成两半,两个进程各跑 720 帧,最后按帧号拼起来。(拼起来是不是逐像素一致,第三节末尾有个意外。)
- 可以先看关键帧。正式渲染前传几个时间点,比如
node render.js 3.6,8.55,18.5,只出这几张图,把排版问题先看掉。
动画曲线只用几个小函数,所有动效都由它们拼出来:
function clamp(x) { return Math.max(0, Math.min(1, x)); }
function prog(t, a, b) { return clamp((t - a) / (b - a)); } // t 在 [a, b] 里走到了几成
function expo(x) { x = clamp(x); return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); } // 快出慢收
function sine(x) { x = clamp(x); return -(Math.cos(Math.PI * x) - 1) / 2; } // 两头缓
function eio(x) { x = clamp(x); return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; } // 三次缓入缓出
// 一个元素在 [a, b] 之间可见:i 秒淡入、o 秒淡出
function win(t, a, b, i, o) {
if (t < a || t > b) return 0;
const x = i ? expo((t - a) / i) : 1, y = o ? eio((b - t) / o) : 1;
return Math.min(x, y);
}
比如字幕的三行依次从遮罩里滑上来:第 k 行的位移是 (1 - expo(prog(t, a + 0.08k, a + 0.5 + 0.08k))) × 110%,每行比上一行晚 0.08 秒出发。
二、静态图片怎么有「镜头感」
素材是 11 张竖版静态图(下一节会说它们从哪来),要让它们看起来像一组镜头。每个镜头写成一行配置:
var SH = [
{ img: 's06_toss', a: 0.0, b: 1.0, s0: 1.16, s1: 1.05, y0: -20, y1: 10, in: 'cut', fl: 1 },
{ img: 's01_slice', a: 3.5, b: 5.0, s0: 1.04, s1: 1.12, x0: 40, x1: -40, in: 'whip' },
{ img: 's05_drop', a: 8.5, b: 10.0, s0: 1.0, s1: 1.1, in: 'flash', fl: 1 },
{ img: 's09_hero', a: 12.0, b: 15.0, s0: 1.0, s1: 1.1, in: 'cut', st: 1 },
// …
];
a、b 是起止时间,s0→s1 是缩放,x、y、r 是平移和旋转,in 是入场方式,fl 是火光闪烁强度,st 是蒸汽强度。render(t) 找到当前镜头,按 sine(prog(t, a, b)) 插值出变换。在这个基础上加了四种手法:
- 切入时轻推。硬切的镜头开头 0.35 秒从 1.05 倍缩回 1 倍。切点因此有一下「落地」的感觉,不像幻灯片换页。
- 甩镜。新镜头用 0.16 秒从右侧 420px 滑进来,上一镜在最后 0.1 秒往左甩出。同时给整个舞台套一个只在横向模糊的 SVG 滤镜:
<feGaussianBlur stdDeviation="60 0">,模糊量跟着速度走。CSS 的filter: blur()只能四个方向一起糊,做不出这种运动模糊。 - 闪白和火光。牛肉下锅那一刻,叠一层暖白色、
mix-blend-mode: screen的图层,0.25 秒内从 0.75 淡到 0。有火的镜头,亮度按1 + 0.07 * noise1(t, 23)抖动;noise1是三个不同频率正弦的叠加,看起来像火苗一跳一跳。 - 蒸汽。28 个径向渐变的椭圆,按各自的速度往上飘、变大、淡出。整组挂一个
feTurbulence+feDisplacementMap+ 模糊的滤镜,把圆团扭成一缕一缕的烟。
标签要跟着图走
一桌饭的镜头上有「主菜」「汤品」「配菜」「主食」四个标签,要贴在对应的碗上。可镜头一直在缩放,写死屏幕坐标,标签就会慢慢滑离碗。
做法是标签坐标写在图上(0~1 的比例),每帧用和镜头一样的变换换算成屏幕坐标:
// 图是 1440×2160,中心对齐屏幕中心(540, 960)
var sx = 540 + (g.x - 0.5) * 1440 * T.sc + T.x;
var sy = 960 + (g.y - 0.5) * 2160 * T.sc + T.y;
图上的比例坐标怎么量?用 sharp 给图叠一张带刻度的网格,打开看一眼,碗在 (0.30, 0.15) 就写 0.30、0.15。
先放大,别让浏览器放大
生成的图是 1024×1536。镜头层是 1440×2160,比屏幕大一圈,给平移和推近留余量;再乘上最多 1.16 倍的推近,原图实际要放大 1.4~1.6 倍。直接交给浏览器放大,用什么插值、要不要锐化都由不得自己,推近的镜头会发软。
所以渲染前先用 sharp 把每张图放大到 1440×2160,用 Lanczos 插值再轻微锐化。浏览器拿到的图和镜头层一样大,渲染时只剩 1.0~1.16 倍的推近:
await sharp(src).resize(1440, 2160, { kernel: 'lanczos3' })
.sharpen({ sigma: 0.9, m1: 0.5, m2: 2 })
.jpeg({ quality: 93, mozjpeg: true }).toFile(dst);
第四版用的是 1024×1024 的方图,要铺满竖屏得放大到 2 倍左右,预放大也救不回来。素材的分辨率和画幅,最好在源头就选对。
三、渲染为什么慢,慢在哪里
这台机器是 2013 年的 i7-4770HQ(4 核 8 线程)。24 秒、60fps 一共 1440 帧,两个进程各跑 720 帧,各用了 906 秒和 876 秒,差不多 15 分钟,平均每个进程 1.2 秒一帧。
先看时间花在哪:render(t) 本身几毫秒,几乎所有时间都花在 page.screenshot 上,也就是 Chrome 把这一帧真正画出来、编码成图片的过程。按镜头分别量:
| 位置 | 每帧 | 说明 |
|---|---|---|
| 3.6 秒 切牛肉 | 1156ms | 没有蒸汽 |
| 5.95 秒 甩镜中 | 1625ms | 整个舞台套着横向模糊 |
| 12.6 秒 成品 | 1275ms | 有蒸汽 |
| 22 秒 片尾 | 983ms | 纯色背景 + 图标 |
| 12.6 秒 改存 PNG | 3269ms | 同一帧 |
甩镜最慢,这个合理,整屏模糊本来就贵。PNG 慢了将近三倍,这个也合理,1080×1920 的无损压缩很重,逐帧中间文件用 JPEG 质量 94 足够,最后还要再被 H.264 压一遍。
不合理的是第一行:切牛肉的镜头里根本没有蒸汽,为什么和有蒸汽的成品镜头差不多慢?
逐层关掉试:
| 3.6 秒这一帧 | 每帧 |
|---|---|
| 原样 | 1217ms |
蒸汽层 display: none |
509ms |
| 再去掉胶片颗粒层 | 376ms |
蒸汽层一层就占了 700 多毫秒。没有蒸汽的镜头里,28 个椭圆的透明度全是 0,可滤镜挂在整组上。从耗时看,只要这一层还在渲染树里,Chrome 就照样在整片滤镜区域上算一遍湍流噪声、做一遍位移、再模糊一遍,算出来的是一片透明。
改法只有一行:没有蒸汽的镜头,把整层藏起来。
var stOn = (cur >= 0 && SH[cur].st) || 0;
// 没有蒸汽的镜头整层隐藏:滤镜挂在整层上,团块全透明时也照样逐像素计算
$('steam').style.display = stOn ? '' : 'none';
改完要证明画面一个像素都没变。每隔 48 帧取一帧(共 30 帧),改前改后各截一张 PNG,比较 MD5:30 帧全部相同。整片重新跑一遍,两个进程分别用了 403 秒和 384 秒,原来是 906 秒和 876 秒,少了一半多;整片从 15 分钟降到不到 7 分钟。
24 秒的片子里,有蒸汽的镜头只有 7.5 秒,剩下 16.5 秒白白多算了这一层。这和前端页面上的老问题是一回事:opacity: 0 不等于不渲染,滤镜、backdrop-filter、混合模式这些代价按面积算的东西,不用的时候要 display: none。
整片比对,67 帧对不上
改完之后,我又把新渲染的 1440 帧和交付版逐帧比较字节:1373 帧完全相同,67 帧不同,集中在第 1072~1138 帧,也就是一桌饭的标签停稳之后。把两张图相减再放大 8 倍,差异只在标签的文字边缘,最大差 90 个色阶,放大才看得出。
同一份代码、同一个 t,为什么画出来不一样?交付版的这一段是改了标题字号之后单独补渲的,起点和整片渲染不同。于是换几种起点画同一帧:
| 第 1100 帧怎么画的 | 截图的 MD5 |
|---|---|
| 新开页面,直接画 1100 | 2d8c54af |
| 从 1040 一帧帧画到 1100 | b94e8950 |
| 从 1090 一帧帧画到 1100 | b94e8950 |
一帧的像素,和这个页面之前画过什么有关。
出问题的标签同时满足两个条件:带 backdrop-filter,会被单独提成一个合成层;入场动画的 transform 里有 scale,从 0.9 放大到 1。去掉任何一个,直接画和连续画就一致了。合成层是先画成位图再缩放、叠加的,Chrome 在层的缩放不断变化时怎么复用位图、怎么抗锯齿,和之前的状态有关。具体是哪一条规则,我没有再往下追。
不改效果的修法也很简单:入场动画走完后,把 transform 设成 none,不要停在 scale(1):
S(e, on, pp < 1 ? 'translateY(' + (1 - pp) * 24 + 'px) scale(' + lerp(0.9, 1, pp) + ')' : 'none');
改完,新开页面直接画和连续画出来的 PNG 一模一样。
这点差异肉眼几乎看不见,可它说明「同一个 t 永远画出同一帧」在像素级上并不总是成立。要拿 MD5 做回归比对,两边的渲染历史得一样;凡是用到合成层缩放的元素,动画结束时最好回到 none。
四、没有音效库,就自己合成
美食广告里最抓人的声音往往不是音乐,而是做菜本身的声音:刀落砧板、油锅滋啦、铲子刮锅。这些声音同时还是剪辑的节拍,切一刀换一个镜头。
我没有音效素材库,不想用来路不明的素材,就用 numpy 一个一个合成。所有声音都放进一个简单的混音器:
SR = 48000
L = np.zeros(N); R = np.zeros(N); RvL = np.zeros(N); RvR = np.zeros(N)
def put(sig, at, g=1.0, pan=0.0, rev=0.0):
"""把一段声音放到第 at 秒:g 是音量,pan 是左右(-1~1),rev 是送进混响的比例"""
s = int(round(at * SR)); e = min(N, s + len(sig)); seg = sig[:e - s] * g
gl, gr = np.cos((pan + 1) * np.pi / 4) * 1.414, np.sin((pan + 1) * np.pi / 4) * 1.414 # 等功率声像
L[s:e] += seg * gl; R[s:e] += seg * gr
if rev: RvL[s:e] += seg * gl * rev; RvR[s:e] += seg * gr * rev
滤波统一用一个 FFT 函数:变换到频域,乘一条四阶的高通、低通幅度曲线,再变回来。离线处理,不在乎相位和实时性,比写 IIR 简单得多:
def filt(x, lo=None, hi=None):
X = np.fft.rfft(x); f = np.fft.rfftfreq(len(x), 1 / SR); g = np.ones_like(f)
if hi: g *= 1 / np.sqrt(1 + (f / hi) ** 4)
if lo: g *= 1 / np.sqrt(1 + (lo / np.maximum(f, 1e-3)) ** 4)
return np.fft.irfft(X * g, len(x))
油锅的滋啦声
滋啦声的本质是成千上万个油点在随机的时刻炸开。所以用泊松过程撒脉冲:每秒约 2500 个,位置随机,幅度服从指数分布、正负随机。再滤成 1.8k~9kHz 的「噼啪」,叠一层 3.5k~12kHz 的「嘶」:
def sizzle(dur, intensity=1.0, attack=0.02, decay=1.2):
n = int(dur * SR); tt = np.arange(n) / SR
k = rng.poisson(2500 * intensity * dur) # 这段时间里炸开多少个油点
imp = np.zeros(n)
np.add.at(imp, rng.integers(0, n, k), rng.exponential(1.0, k) * rng.choice([-1, 1], k))
crack = filt(imp, lo=1800, hi=9000) * 0.12 # 噼啪
hiss = filt(rng.standard_normal(n), lo=3500, hi=12000) * 0.05 # 嘶
env = np.minimum(1, tt / attack) * np.exp(-tt / decay) * 0.7 + 0.3 * np.exp(-tt / (decay * 2.5))
return (crack + hiss) * env
intensity 控制密度。牛肉下锅那一下是 1.6,最响最密;成品镜头里的余温是 0.25,只剩零星几声。
刀落砧板
一刀下去是三个声音的叠加:
- 刀和板子撞击的低频闷响,是一个从 155Hz 快速掉到 95Hz 的正弦;
- 刀刃的「咔」,是 1.5k~5kHz 的噪声,几毫秒就衰减完;
- 木板自己的短共振,是 410Hz 和 690Hz 两个衰减很快的正弦。
def chop(g=1.0):
n = int(0.25 * SR); tt = np.arange(n) / SR
thump = np.sin(2 * np.pi * (95 + 60 * np.exp(-tt * 60)) * tt) * np.exp(-tt * 38)
click = filt(rng.standard_normal(n), lo=1500, hi=5000) * np.exp(-tt * 180) * 0.6
res = np.sin(2 * np.pi * 410 * tt) * np.exp(-tt * 55) * 0.25 + np.sin(2 * np.pi * 690 * tt) * np.exp(-tt * 70) * 0.15
return (thump * 0.9 + click + res) * 0.8 * g
其余几个声音也是同样的思路:
- 点火:三声极短的高频「咔」,接一团 60~700Hz 的低频噪声快速涌起、慢慢退下,再叠 11Hz 的颤动;
- 淋汁:低通噪声乘一条慢速随机的包络,听起来一股一股的;
- 刮盘:带通噪声乘一个 34Hz 的方波做颗粒感,再加几个金属共振频率;
- 收尾的拨弦:Karplus-Strong 算法,一小段随机噪声在延迟线里反复取平均,高频越磨越少,就成了琴弦的音色。
混响也是合成的:用一段按指数衰减的滤波噪声当冲激响应,和需要混响的声音做 FFT 卷积。
声音对着镜头放
声音的时间线和镜头表是同一套时间:3.5 秒切牛肉三刀(3.5、4.0、4.5),5 秒切辣椒四刀快刀,7.5 秒点火,8.5 秒下锅,10 秒淋汁,11 秒刮盘,12.5 秒计时器到 15:00 时「叮」一声。3.5~12 秒底下垫一层很轻的 120 BPM 底鼓和沙锤,镜头都切在拍点上。
![]()
频谱上能直接看出结构:前半段一根根竖线是刀声,大片的高频是滋啦,12 秒以后低处出现横向的条纹,是和弦铺底。
最后做响度归一化,目标是流媒体常用的 -14 LUFS。ffmpeg 的 loudnorm 用两遍:第一遍只测量,第二遍带上测量值、加 linear=true,整条只乘一个增益,不做动态压缩,刀声和滋啦的冲击感就不会被压扁(前提是峰值有余量,否则它会自动退回动态模式):
out = run([F, '-i', 'audio_raw.wav', '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-'])
m = json.loads(out[out.rindex('{'):out.rindex('}') + 1])
af = (f"loudnorm=I=-14:TP=-1.5:LRA=11:measured_I={m['input_i']}:measured_TP={m['input_tp']}"
f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}:offset={m['target_offset']}:linear=true")
run([F, '-y', '-i', 'audio_raw.wav', '-af', af, '-ar', '48000', 'audio.wav'])
成品是 -14.6 LUFS,峰值 -1.3 dBTP。整条声音合成只要 4.6 秒。
合成声音没法边听边调,每改一个参数都要重跑一遍。所以检查分两步:先看频谱图、ebur128 测出来的响度和峰值,以及各个声音之间的电平比例,确认结构和音量没问题;最后再整条听。听感这件事没法靠数字保证,所以另外出了一条静音音轨的版本,不满意可以直接在抖音曲库里换配乐。
五、编码
ffmpeg -framerate 60 -i frames/%05d.jpg -i audio.wav \
-c:v libx264 -preset slow -crf 16 -pix_fmt yuv420p -profile:v high -level 4.2 \
-g 120 -bf 2 -r 60 \
-c:a aac -b:a 192k -ar 48000 -shortest -movflags +faststart out.mp4
yuv420p:不指定的话 x264 会沿用输入的像素格式:JPEG 序列是全范围的yuvj420p,PNG 序列会编成 4:4:4,都有手机播放器处理不好。-crf 16:平台还会再转码一遍,上传的片子质量留得高一点,二次压缩后不至于糊成一片。成片 24 秒 36.5MB,约 12Mbps。-g 120:每 2 秒一个关键帧,拖动进度条时定位快。+faststart:把索引挪到文件开头,边下边播。
静音版不用重新编码画面,直接拷贝视频流,配一条空音轨:
ffmpeg -i out.mp4 -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \
-map 0:v -map 1:a -c:v copy -c:a aac -b:a 128k -shortest -movflags +faststart out_silent.mp4
六、前四版为什么不行
上面这些技术,前四版大部分都用上了:
| 版本 | 时长 | 素材 | 做法 | 问题 |
|---|---|---|---|---|
| 一 | 30 秒 | 界面截图 | 截图依次滑过 + 字幕 | 没有声音,像翻截图 |
| 二 | 66 秒 | 菜品成品图 | 纪录片式旁白、金色宋体 | 太长,节奏慢 |
| 三 | 21 秒 | 界面 + 成品图 | 产品演示、60fps | 画面不精致,开头不抓人 |
| 四 | 21 秒 | 1024 方形成品图 | 匹配剪辑、视差、蒸汽 | 还是照片在动 |
| 五 | 24 秒 | 生成的过程镜头 | 美食广告结构 + 做菜声 | — |
第四版的动效其实最复杂:八道菜按盘子的圆心和半径对齐,在同一个圆窗里一拍一切;同一张图拆成两层做 2.5D 视差;整套合成配乐。看下来还是很一般。
回头看,问题不在动效,在素材:前四版只有一张张成品图,菜已经做好了,摆在盘子里。不管怎么推、怎么切、怎么加蒸汽,观众看到的都是「几张菜的照片在动」。
于是去查美食广告到底怎么拍。通行的做法可以归成三条:
- 过程镜头:切、下锅、颠锅、淋汁,让人看到菜是怎么变好吃的;
- 微距质感:切开的辣椒籽、挂着汁的肉片,近到能看见油光;
- 声音做节奏:刀声、滋啦声本身就是节拍,剪辑点卡在声音上。
前四版一条都没做到,因为手里根本没有过程镜头。所以第五版先补素材:用炊想后端本来就在用的生图接口,按菜谱里的真实步骤生成了 11 张竖版过程镜头。
![]()
几个经验:
- 统一的风格后缀。每条提示词前半句写镜头内容,后半句完全一样:电影感美食广告摄影、竖构图、暗调暖光、浅景深、高细节,不要文字、水印和人脸。11 张图的光线和色调因此能接得上。
- 菜要和菜谱对得上。最后一张「一桌饭」,配的是菜谱里真实的搭配:凉拌木耳、冬瓜虾皮汤、糙米饭。
- 接口有尺寸上限。请求 1440×2560,返回的还是 1024×1536,所以才有第二节的预放大。
- 每张 25~40 秒,最慢的一张 132 秒,11 张一共约 7 分半。
有了过程镜头,片子的结构就照美食广告来:
| 时间 | 画面 |
|---|---|
| 0~2 秒 | 颠锅的火焰「今晚,」→ 成品「吃这个。」 |
| 2~3.5 秒 | 片名:小炒黄牛肉,从切肉到上桌,十五分钟 |
| 3.5~12 秒 | 七个步骤,一步一个镜头,计时器 00:00 走到 15:00 |
| 12~16.5 秒 | 成品「香辣鲜嫩,十五分钟端上桌。」→ 夹起「趁热。」 |
| 16.5~20.5 秒 | 一桌饭的标注,搜索框打出「小炒黄牛肉」 |
| 20.5~24 秒 | 炊想图标和标语,停留 3 秒以上 |
![]()
开头第一帧就是火焰,不放图标、不放片名,前两秒先把人留住。字幕用的是菜谱里的原文步骤,比如「大火滑炒 40 秒,肉片七成熟立刻盛出」,静音看也能看懂这道菜怎么做。
画面是生成的,不是实拍。片尾写了「菜谱仅供参考 · 画面为示意」,发布时在抖音的「作品声明」里也要如实勾选。
小结
- 视频写成
render(t):页面里不放任何会自己动的东西,随机数用固定种子,截图前等字体。这样每一帧都能单独重画,可以只重渲一段,也可以拆开并行。 - 先放大,别让浏览器放大:素材预先用 Lanczos 放大到镜头层的尺寸,渲染时只剩少量推近。
- 按面积算钱的效果,不用就
display: none:透明的东西照样会渲染。这一行让无蒸汽镜头每帧从 1.2 秒降到 0.5 秒,画面一个像素都没变。 - 逐帧渲染也不是绝对确定的:带
backdrop-filter又在做缩放动画的元素,同一帧的像素和之前画过什么有关;动画结束时把transform设回none。 - 声音可以合成:油锅是泊松随机脉冲加高频噪声,刀声是下滑正弦加噪声加共振;响度用两遍
loudnorm加linear=true归到 -14 LUFS。 - 动效救不了素材:先想清楚观众要看什么(过程、质感、声音),再去补素材;镜头和动效是最后一步。

太牛了