Best Practice · Game Promo Video Pipeline

游戏宣传视频自动化制作最佳实践 从源码事实到 1080p 成片:一条无人值守、可复现、可审计的制作管线

本文档复盘 绯夜对决 / VELVET FIGHT(Three.js 3D 格斗游戏)90 秒宣传片的完整制作过程。 它不是清单,而是一套已被验证的管线:所有实机画面由脚本自动采集、每一招都经数值与视觉双重验证、 音频与非实机素材全程可再生成。附带的命令与代码取自真实产出定向 firing,可直接复用。

90s · 1920×1080 · 30fps H.264 + AAC · 19.8MB 实机画面占比 74% 20 个片段 / 19 段旁白 / 38 条字幕

00管线总览

整条链路由七个阶段串成,每个阶段的产物都是磁盘上的具体文件,因此可以单独重跑任意一段而不影响前后——这一点决定了返工成本。approach 逻辑改了只用重采集战斗镜头,不用重录旁白。

采集层 合成层 ① 事实基线 读源码,核数值 ② 脚本 + 采集表 分镜 / 演示步骤 ③ CDP 抓帧 JPEG 序列 + meta ④ 双重验证 数值 + 看图 ⑤ 代码卡渲染 HTML → 帧序列 ⑥ 旁白合成 say + 自动提速 ⑦ 剪辑 + 混音 慢放 / 烧字幕 ⑧ 质检交付 抽帧 / 电平 / ffprobe MISS / 穿帮 → 调参数重采 → PROMO_VIDEO_SCRIPT.md → clips/<场次>/%06d.jpg → clips/F_code*/ → audio/n*.wav + manifest.json → final/nosub.mp4 → velvet-fight-promo-90s.mp4
八阶段管线。红色虚线是本次改进最多的一环——前四次失败都发生在「以为采集成功、实际是假动作」上。
74%

实机画面占比(67s / 90s)。格斗游戏的说服力来自连续对抗,切碎会摧毁打击感的可信度。

4×

战斗镜头平均返工次数。全部由自动化驱动缺陷导致,无一是渲染问题。

0

外部付费素材与 CDN 依赖。画面、音频、字幕全部由本机合成。

01三条贯穿始终的原则

原则一:脚本必须建立在源码事实上,不是 README 上

本次一开始就发现四处文档与实现不符(见 02.1)。若照 README 写宣传口径,会在片中出现错误按键提示——而演示类视频的观众最容易当场跟着试。

做法:先读完被测系统的核心模块,建立一张「事实基线表」,每个对外宣称的数字都标注出处行号。这一步花的时间会在后期避免彻底返工。

原则二:数值验证之外,必须做视觉验证

只看 debug() 返回的血量会漏掉最关键的一类错误:动作打空但碰巧也有数值变化,以及角色背对对手出招。本次 D2 镜头就是靠逐帧看图才发现「尹世琳的踢腿朝左打空」——此时 combo=2 依然成立,因为计数来自另一段命中。

做法:每个关键动作抽 4–6 帧拼成一张图,交给视觉模型或人眼判读。「看起来对」和「数据对」是两个独立条件。

原则三:每个阶段的产物落到磁盘,并且能单独重跑

帧序列、meta.json、每段 wav、manifest、nosub.mp4 全部保留。改一句旁白只需重跑 ⑥⑦⑧,不必重启浏览器;改一个按键时序只需重采那一场。

做法:拒绝「一条命令跑到底」的脚本。把管线切成带明确文件边界的步骤,用 if os.path.exists() 或显式 skip 开关控制重跑范围。

02脚本阶段:先把事实钉死

2.1 建立事实基线表

开工前通读被测系统的核心源码模块(本次是 app/src/game/ 下 17 个文件,约 1700 行),产出一张可直接被脚本、字幕、旁白引用的表。

维度核对到的事实出处
技术栈Vite + React 19 + TS + Three.js 0.180,纯前端零后端package.json
帧数据六类招式,伤害 5 / 10 / 14 / 8 / 12 / 28,每段含 startup · active · recoverycombat.ts:13-20
连段规则递减系数 max(0.48, 1-combo*0.075);仅轻击命中可取消接重击/特殊技combat.ts
连段计数两次命中间隔 <1.15s(comboTimer)即累计 —— 不依赖取消窗口combat.ts:213
特殊效果投技不可防御;格挡削血(超必杀 3 / 伤害>10 则 1);hitstop .045/.085/.14scombat.ts
输入缓冲0.2s 缓冲窗口input.ts
渲染实例化城市/雨/粒子;GLB 无骨骼 → 自动计算 12 骨骼蒙皮权重articulated-character.ts
音频Web Audio 全实时合成,零音频文件audio.ts
调试接口debug() / debugWin() / debugMeter()game.ts:198-202
本次核对出的四处「文档 ≠ 实现」
  • Docker 端口:README 写 80,Dockerfile 实际 EXPOSE 3000。
  • 冲刺键:README 只写「双击 A/D」,HUD 只写「Shift+方向」,实际两者都支持,且 README 完全没提 P2 冲刺键。
  • 触屏支持:info.md 声称支持 touch,实际 input.ts 只有键盘,无触屏也无手柄 —— 因此脚本里明确排除了移动端演示。
  • 资料可信度:app/info.md 是上游模板说明,不是本项目设计文档。判断项目能力要看源码。

2.2 四段式脚本结构

脚本按四个正交维度组织,缺一维后期就会卡顿。这个顺序也是执行顺序。

① 脚本分镜

  • 时间码精确到帧
  • 画面描述 + 景别 + 运镜
  • 7 段落 / 20 个镜头

② 画面素材

  • 来源:实机采集 / 渲染生成
  • 获取方式(哪一场、哪个开窗)
  • 含免录屏的静态素材备选

③ 演示步骤

  • 逐镜精确到按键
  • 每个动作标注预期数值
  • 写明「怎么算成功」

④ 字幕与旁白

  • 逐句对照时间码
  • 约 4.5 字/秒语速
  • 主行 + 副行两层信息

时长定为 90 秒 / 1080p / 60fps 采集 · 30fps 交付。结构分配:

段时长内容画面来源
A 钩子5s雨夜天台,气氛先立住实机
B 简介11s一句话说清是什么、怎么玩实机
C 角色11s双姝定位与招式性格实机(选人界面)
D 战斗20s走位 / 连段 / 防御 / 对空 / 投技实机
E 必杀15s攒气 → 运镜 → K.O.实机
F 技术16s三张代码卡讲架构亮点渲染生成
G 场景12s玩法场景 + 收尾实机
受众分层决定了镜头取舍

核心:格斗 / 独立游戏玩家 → 看手感与连段深度,所以 D 段占 20s 且允许慢放。
次级:Three.js / WebGL 开发者 → 看架构可读性,所以 F 段给 16s 代码卡。
泛受众:星标猎手 / 二次元美术受众 → 看画面与能否一键跑,所以 B/G 段强调「无下载、无后端」。

03工具链体检:动手前先把环境问清楚

下面这组检查在本次省下了至少两次「跑到一半才发现不可行」。建议固化成项目里的 make check-tools。

环境体检echo "=== ffmpeg ==="      && (which ffmpeg && ffmpeg -version 2>/dev/null | head -1) || echo "NO FFMPEG"
echo "=== 中文语音 ==="    && say -v '?' 2>/dev/null | grep -iE 'zh_CN|zh-CN|Chinese'
echo "=== pw browsers ===" && ls ~/Library/Caches/ms-playwright/
echo "=== 屏幕刷新率 ==="  && system_profiler SPDisplaysDataType | grep -E 'Resolution|UI Looks'

ffmpeg 能力探测(决定字幕方案)

本机是 ffmpeg 4.2.2,没有编译 libass,且 amix 缺少较新的 normalize 选项。这两点分别影响字幕滤镜选型与混音写法,必须先探测。

能力矩阵ffmpeg -hide_banner -filters  2>/dev/null | grep -E ' (drawtext|subtitles|ass|concat) '
ffmpeg -hide_banner -encoders 2>/dev/null | grep -E ' (libx264|aac|h264_videotoolbox) '
ffmpeg -hide_banner -version  2>/dev/null | grep -oE '\-\-enable-(libass|libfreetype|libfontconfig)'
中文字体必须实测,不能想当然

macOS 上 /System/Library/Fonts/PingFang.ttc 被 ffmpeg 打开失败,可用的是 Hiragino Sans GB.ttc。逐个试:

for f in "/System/Library/Fonts/Hiragino Sans GB.ttc" \
         "/System/Library/Fonts/PingFang.ttc" \
         "/System/Library/Fonts/Supplemental/Songti.ttc"; do
  [ -f "$f" ] || continue
  ffmpeg -y -loglevel error -f lavfi -i color=c=black:s=400x100:d=0.1 \
    -vf "drawtext=fontfile='$f':text='绯夜对决':fontsize=36:fontcolor=white:x=20:y=20" \
    -frames:v 1 /tmp/ft.jpg 2>/dev/null && echo "OK   $f" || echo "FAIL $f"
done

常驻预览服务

Vite preview 用 & 挂后台会被会话回收。交给任务管理器常驻,或 nohup + 显式 pid 管理:

启动并验证cd app && npm run build
npm run preview -- --port 4173 --host 127.0.0.1     # 用后台任务方式常驻,勿用 shell &
sleep 3 && curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:4173/

04画面采集:为什么放弃 recordVideo

4.1 方案对比

方案本次结果结论
Playwright recordVideo 失败。要求 chromium-1248,本地缓存只有 1247;且它依赖一份专用 ffmpeg 二进制(本次缺 1013,需 symlink 到已有的 1011)。即使补齐,录制逻辑本身会与渲染抢主线程。 弃用
CDP Page.startScreencast 成功。1920×1080,JPEG quality 92,实测 91–97fps。帧通过事件异步推送,Node 只负责存盘,不阻塞渲染;自带时间戳可用于精确裁剪。 采用
macOS 屏幕录制 可用但不可控:分辨率受显示器缩放影响,窗口可能被遮挡,无法做到无人值守。 兜底

核心采集器 lib.mjs—— 它同时承担 three 件事:定位项目内的 playwright 依赖、跑 screencast、提供在墙钟上精确执行动作的 Timeline。

lib.mjs · 采集核心import { createRequire } from "node:module";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";

// 关键 1:脚本放在 /tmp 时找不到项目的 playwright,用 createRequire 锚定到项目目录
export const require = createRequire("/path/to/project/app/");
export const { chromium } = require("playwright");

// 关键 2: playwright 版本与缓存中的 chromium 版本不匹配时,显式指定可执行文件
export const EXEC = "~/Library/Caches/ms-playwright/chromium-1247/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing";

export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/** 基于 CDP 的时间戳索引录屏器 */
export class Screencast {
  constructor(cdp, dir, { quality = 92 } = {}) {
    this.cdp = cdp; this.dir = dir; this.quality = quality;
    this.n = 0; this.t0 = 0; this.timestamps = [];
    mkdirSync(dir, { recursive: true });
  }
  async start() {
    this.t0 = Date.now();
    this.cdp.on("Page.screencastFrame", async ({ data, sessionId, metadata }) => {
      this.timestamps.push(Date.now() - this.t0);
      writeFileSync(join(this.dir, `${String(this.n++).padStart(6, "0")}.jpg`),
                    Buffer.from(data, "base64"));
      try { await this.cdp.send("Page.screencastFrameAck", { sessionId }); }
      catch { /* 页面被销毁时的正常竞态 */ }
    });
    await this.cdp.send("Page.startScreencast", {
      format: "jpeg", quality: this.quality,
      maxWidth: 1920, maxHeight: 1080, everyNthFrame: 1,
    });
  }
  async stop() {
    await this.cdp.send("Page.stopScreencast");
    const ms = Date.now() - this.t0;
    const fps = ms > 0 ? this.n / (ms / 1000) : 0;
    writeFileSync(join(this.dir, "meta.json"),
      JSON.stringify({ frames: this.n, ms, fps: +fps.toFixed(2) }, null, 1));
    return { frames: this.n, ms, fps };
  }
}

export async function launch() {
  return chromium.launch({
    headless: false,                 // headless 下 WebGL 表现与 headed 不同,必须 headed
    executablePath: EXEC,
    args: ["--force-device-scale-factor=1", "--hide-scrollbars", "--mute-audio"],
  });
}

export async function openPage(browser) {
  const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
  const page = await ctx.newPage();
  page.on("pageerror", (e) => console.log("  [page error]", e.message));
  await page.goto(URL, { waitUntil: "load" });
  await page.waitForFunction(() => window.__GAME_READY__ === true, null, { timeout: 30000 });
  const cdp = await ctx.newCDPSession(page);
  return { ctx, page, cdp };
}

export const debug = (page) => page.evaluate(() => window.__velvetDebug?.());
开录前必须做的三件事
  1. 点击页面一次 —— 浏览器的 AudioContext 需用户交互解锁,否则整段实机音效缺席。
  2. 静置 7–10 秒等异步资源 —— 本项目两个高精 GLB 各 10–11MB,加载未完成时画面上是程序化备用模型,一旦录进去就是穿帮。
  3. 确认 fps 余量 —— 本项目 fps < 42 持续 3 秒会自动降画质(雨粒子 380→140、像素比 1.5→1.0)。录屏本身吃性能,很容易把 fps 打下去,导致同一项目前后画质不一致。实测本环境稳定 120+,安全。

先把帧率测出来,再决定裁切策略

帧率探针(先跑 3 秒)node probe-fps.mjs   # → frames=274 ms=3002 fps=91.27

meta.json 里记录的实际 fps 是后续从「帧号」换算「秒」的唯一依据 —— scereencast 的帧率不是恒定的(本次各场次在 60–97fps 间波动),所以每个场次单独存一份 meta,裁剪时按各自 fps 换算,切勿全局假设 30 或 60。

05驱动被测对象:本次 80% 的返工都在这里

采集本身一次成功,难的是让游戏在镜头前把动作做对。以下七个缺陷按发现顺序列出,都经过复现与修复验证。

5.1 七个必修缺陷

缺陷 1 · 组合键跨游戏帧 必现

现象:对空技应为 W + L 同时按下,实际录出来 P1 先起跳,L 在空中被判定为空中技。根因:游戏以 16.7ms 固定步长读输入,而 keyboard.down('w') 与 press('l') 是两次独立 CDP 调用,间隔可能跨过一帧。

修复 · 在 page.evaluate 内同帧派发await page.evaluate(() => {
  const ev = (type, key) => window.dispatchEvent(
    new KeyboardEvent(type, { code: key, key, bubbles: true }));
  ev("keydown", "KeyL");   // 两个键在同一个 JS 任务内到达
  ev("keydown", "KeyW");
  setTimeout(() => { ev("keyup", "KeyL"); ev("keyup", "KeyW"); }, 140);
});

验证

before=100 after=85 → 差值 15,正好等于「升月踢」的伤害值,命中。

缺陷 2 · 命中时刻被延迟演出推迟 必现

现象:超必杀按下 I 后立即查血量,p2.hp 无变化,一度误判为「脱靶」。根因:超必杀触发后有 1.05 秒运镜,期间 combat.update 完全暂停,真正的命中结算在按键后 1.4–1.8 秒。

判据应选「能量清零」而非「血量下降」

本次是靠发现 p1meter === 0(能量被消耗)反推出「技能确实触发了」,才回头把验证时点推后到 1.4s。教训:验证一个迟滞事件时,先找一个「立即变化」的中间量作为触发标志。

缺陷 3 · 击倒后的起身无敌帧 必现

倒地 0.75s + 起身无敌 0.35s。连拍多段攻击时,紧跟着的一招会被无敌帧吞掉,表现为「莫名其妙 MISS」。连续演示必须在两段攻击之间留出 >1.1s 空档。

缺陷 4 · 突进把自己送到对手背后 必现

special / super 自带 lunge(特殊技 0.9、超必杀 1.8),位移会把攻击方推过对手,此时朝向向量变负,攻击判定落空。站位距离要按最后一招的 lunge 预留,而不是按起手距离。

缺陷 5 · 绝对距离导致走反方向 必现

现象:某帧里尹世琳明明贴近了对手,踢腿却朝左打空 —— 因为 D1 的冲刺跳跃让两人换了边,而 approach 用绝对距离判断,方向丢失。

修复 · 位置闭环必须带符号 + 比例收敛async function approach(page, want) {
  for (let i = 0; i < 25; i++) {
    const d = await debug(page);
    const gap = d.p2.x - d.p1.x;                      // 带符号!用 Math.abs 会丢失方向
    if (Math.abs(gap - want) < 0.13) return gap;
    const ms = Math.min(380, Math.max(60, Math.abs(gap - want) * 130));  // 比例步长:大gap长按
    await hold(page, gap > want ? "d" : "a", ms);
  }
  return -99;                                          // 明确失败,别静默继续
}

缺陷 6 · 连段其实不必踩取消窗口 优化

代码里连段计数只要求两次命中间隔 < 1.15s,与取消窗口无关。原本脚本花大量重采去精确踩 0.1–0.28s 的取消窗口,改成「轻击 → 320ms → 重击」后一次成功。先看统计口径,再设计动作时序。

缺陷 7 · 墙钟调度被不可控耗时挤乱 必现

现象:一次采集里 approach 循环耗时失控到 36.7 秒,把 Timeline.at(6000) 之后的全部事件瞬间洪水执行,三个镜头全废。

改法:事件顺序 + 相对 sleep,用 mark 记录镜头边界

不要用「绝对时间轴」调度依赖异步收敛的动作。改为按事件顺序 await 执行,同时用 shot(name, fn) 记录每个镜头的实际起止时刻,导出到 marks.json。剪辑阶段按 mark 而非预想的时间码取窗口 —— 这样动作做慢了也不会错位。

const marks = {};
const shot = async (name, fn) => {
  marks[name] = { start: Date.now() - t0 };
  await fn();                                  // 动作按顺序执行,不等墙钟
  marks[name].end = Date.now() - t0;
  console.log(`${name}: ${(marks[name].start/1000).toFixed(1)}s - ${(marks[name].end/1000).toFixed(1)}s`);
};
await shot("D2", async () => {
  await page.keyboard.press("j"); await sleep(320); await page.keyboard.press("k");
  await sleep(700);
  const d = await debug(page);
  console.log(`  combo=${d.p1.combo} p2hp=${d.p2.hp}`);   // 每步都留证据
});

5.2 善用被测系统的调试后门

本项目在 boot.ts 把三个辅助函数挂到了 window,这是把镜头从运气里解放出来的关键:超必杀不必反复攒气,K.O. 镜头不必真的打完一局。

boot.ts 暴露的取景接口await meter(page);   // 能量灌满 → 直接进超必杀镜头
await win(page);     // 直接触发 K.O. 演出
await debug(page);   // 读取 p1/p2 的 hp / x / combo / phase,作为断言依据
给被测系统预留后门,是演示工程的一部分

如果你同时是开发者:把这类 debug 钩子做成正式能力(用 import.meta.env.DEV 或 URL 参数开关)。本次若无这三个接口,超必杀与 K.O. 两段的取景需要反复重试数十轮。

06双重验证:让「假动作」无处藏身

本次最有价值的实践。每个关键动作同时过两道关:

① 数值验证(机器)

调用 debug() 读前后血量,差值必须精确等于该招式的表定伤害。不是「变少了」就算过。

  • 轻击 + 重击 = 14
  • 对空 · 升月踢 = 15
  • 投技 = 12
  • 超必杀 = 28

② 视觉验证(看图)

抽 4–6 帧拼成一张图判读:角色朝向、特效是否出现、字幕位置、有没有缺失模型。数值对但画面错的情况,只有这一步能发现。

抽帧拼图 —— 低成本视觉验证的标准手法rm -rf seq && mkdir -p seq && I=0
for f in 690 700 710 720 730 745; do
  I=$((I+1)); cp clips/B_battle/$(printf %06d $f).jpg seq/$(printf %03d $I).jpg
done
ffmpeg -y -loglevel error -i seq/%03d.jpg \
  -filter_complex "tile=3x2:padding=6:margin=6:color=0x111111" \
  -frames:v 1 shots/sheet-d2.jpg
视觉验证抓到的三个「数值合格」假动作
  • D2 连段:combo=2 数值达标,但帧 690 显示踢腿朝左打空(两人换边导致朝向反了)。
  • D4 对空:血量差值 15 完全正确,画面却显示跳起来的是 P1 自己。
  • E2 超必杀:因 lunge 把 P1 推过对手,目标其实不在攻击范围内,只是当时误读了数值时机。

另外两个 ffmpeg 预览操作曾经翻车,记下来避免重复踩:

操作结果
vstack=2:padding=8ffmpeg 4.2 的 vstack 不支持 padding 选项,命令直接失败。
drawbox=c=0x111111:t=fillt=fill 会把整张图填满颜色 —— 预览一度全黑,误以为是素材坏了。

07非实机素材:把代码卡也变成「可重跑的产物」

技术段的三张代码卡不是图片文件,而是一个 HTML 页面被 screencast 录下来。好处是:代码一改重录即可,动效(逐行高亮)免费获得,且风格与实机画面可由同一套 CSS 变量协调。

codecard.mjs · 要点// 同一套 Screencast 录制任意 URL —— 采集层与渲染目标是解耦的
await page.goto("file:///tmp/mf-promo/code/f1.html");
const sc = new Screencast(cdp, join(CLIPS, "F_code1"));
await sc.start();
await sleep(6000);           // 让高亮动画走完一轮
await sc.stop();
代码卡文字溢出:这块返工了两次

第一次长行被裁切(damage 切成 da),缩小字号后仍有两行溢出。最终解法:不要用 CSS 缩再去迁就长行,而是把代码按显示宽度重新拆行 —— 既保住了「这是真实代码」的观感,又彻底消除溢出。

调整项:font-size 23→19px、line-height 1.75→1.85、行号宽度 34→28px、注释条 21→20px。

代码卡设计约束

  • 内容取自真实源码,不要为了好看编 API —— 技术受众会照着搜。
  • 每张卡一个主题(帧数据 / 自动蒙皮 / 合成音频 + Docker),底部一条蓝色中文注释条点明价值。
  • 交付前逐张单独看图检查:文字清晰度、行尾截断、中文是否出现豆腐块。

08旁白与音效:让每句话都卡进它的窗口

8.1 自动生成 + 自动配速

共 19 段旁白,用 macOS 内置的婷婷中文语音。核心技巧是循环重试:先合成 → 测时长 → 超窗口就按比例提速再合成,全程无需人工试听。

build_audio.py · 自动配速循环VOICE, BASE_RATE = "Tingting", 188      # 中文约 4 字/秒

for i, (start, wend, text, subs) in enumerate(LINES):
    window = wend - start
    rate = BASE_RATE
    for _ in range(4):
        subprocess.run(["say", "-v", VOICE, "-r", str(rate), "-o", f"{DIR}/n{i:02d}.aiff", text], check=True)
        subprocess.run(["ffmpeg","-y","-loglevel","error","-i", f"{DIR}/n{i:02d}.aiff",
                        "-ar","44100","-ac","2", f"{DIR}/n{i:02d}.wav"], check=True)
        dur = float(subprocess.run(["ffprobe","-v","error","-show_entries","format=duration",
                                    "-of","csv=p=0", f"{DIR}/n{i:02d}.wav"],
                                    capture_output=True, text=True).stdout.strip())
        if dur <= window - 0.35:
            break
        rate = int(rate * dur / (window - 0.45)) + 4     # 按缺口比例提速
    manifest.append({"idx": i, "start": start, "dur": round(dur,3), "wav": out_wav, "subs": subs})
manifest.json 是整个音频层的唯一真相

每段旁白落地一个 nNN.wav,并在 manifest 里记录 idx / start / dur / subs。下游的合成、字幕烧录全部读它 —— 改一句台词只影响那一行,不必同步维护三份时间码。

8.2 雨声底噪:零素材生成

粉红噪声 + 带通 + 颤音 = 雨声ffmpeg -y -loglevel error -f lavfi \
  -i "anoisesrc=color=pink:r=44100:a=0.55:d=91" \
  -af "highpass=f=500,lowpass=f=2000,volume=0.16,tremolo=f=0.31:d=0.42" \
  audio/rain.wav

关键点:雨声要与画面上的雨同调(画面是雨夜),同时用 tremolo 制造起伏避免听感疲劳;音量压到 0.16,只做垫底。

ffmpeg 4.2 的 amix 没有 normalize

19 路旁白用 amix 合并时,默认是按输入数归一化的,结果音量被压得极低。老版本的替代写法是叠加 volume=N(N = 输入路数)补偿,不要去找 normalize=0(本机不支持,会直接报错)。

# 错误写法(本机 ffmpeg 4.2.2 不支持):  amix=inputs=19:normalize=0
# 正确写法(见 §9.2 完整命令):           amix=inputs=19:... ,volume=19.0,volume=1.6

09合成:裁剪 → 拼接 → 混音 → 烧字幕

9.1 用 PLA表 描述镜头而不是写命令

把 20 个片段的来源、入出点、慢放倍数、目标时长集中成一张表。它是剪辑意图的可读版本,微调时改表不改代码。

PLAN · (名称, 源场次, 入点, 出点, 慢放, 目标时长)PLAN = [
    ("A1", "T_title",    0.0,  3.0, 1.0, 3.0),
    ("D1", "B_battle",   3.2,  5.7, 1.6, 4.0),   # 走位:轻慢放,保持流畅
    ("D2", "B_battle",   8.0, 10.0, 2.0, 4.0),   # 连段:2x 慢放看清取消
    ("D3", "B_battle",   9.8, 11.8, 2.0, 4.0),   # 格挡特效极短,必须慢放
    ("D4", "D4_antiair", 2.2,  6.2, 1.0, 4.0),   # 单独采集的一场
    ("F1", "F_code1",    0.5,  5.5, 1.0, 5.0),
    ...
]

逐片段裁切(注意每场用自己的 fps)

关键:fps_of(src) 从各场次自己的 meta.json 取帧率fps = json.load(open(f"{CLIPS}/{src}/meta.json"))["fps"]
f0   = round(s * fps)          # 入点帧号
vf   = f"setpts=PTS*{slow},fps=30,scale=1920:1080,setsar=1"
ffmpeg -y -loglevel error \
  -start_number $f0 -i "$CLIPS/$src/%06d.jpg" \
  -vf "$vf" -frames:v $n_out \
  -c:v libx264 -preset fast -crf 18 -pix_fmt yuv420p "$SEG/${i}_${name}.mp4"

setpts=PTS*2 实现慢放 —— 先慢放再统一 fps=30,可以让不同慢放倍数的片段拥有一致的时间基,为下一步 concat -c copy 创造条件。

统一规格后可无损快拼ffmpeg -y -loglevel error -f concat -safe 0 -i seg/list.txt -c copy final/nosub.mp4
ffprobe -v error -show_entries format=duration -of csv=p=0 final/nosub.mp4   # → 90.03

9.2 音频汇聚

19 段旁白按 manifest 的时间码延迟摆放ins, chains, labels = [], [], []
for m in manifest:
    ins    += ["-i", m["wav"]]
    ms      = int(m["start"] * 1000)
    chains.append(f"[{m['idx']}:a]adelay={ms}|{ms}[a{m['idx']}]")
    labels .append(f"[a{m['idx']}]")
chains.append("".join(labels) +
    f"amix=inputs={len(manifest)}:duration=longest:dropout_transition=0,"
    f"volume={len(manifest)}.0,volume=1.6[nar]")   # 双 volume:先补 amix 衰减,再抬增益
ffmpeg -y -loglevel error *ins -filter_complex ";".join(chains) -map "[nar]" audio/narration.wav

9.3 烧字幕(drawtext)

本机无 libass,所以用 drawtext。两个要害:文本写进临时文件(避免命令行转义地狱),以及显示窗口用下一句的起点夹逼(避免相邻两句字幕重叠)。

字幕窗口夹逼 + textfile 传参for m in manifest:
    start, dur_n, subs = m["start"], m["dur"], m["subs"]
    st = start + 0.15
    en = min(start + dur_n + 0.75,
             (manifest[m["idx"]+1]["start"] - 0.15) if m["idx"]+1 < len(manifest) else 90.0)
    main_txt, sub_txt = (subs + ["", ""])[:2]
    for tag, txt, size, color, y in (("m", main_txt, 44, "white",    "h-192"),
                                     ("s", sub_txt,  29, "0xC7D2F0", "h-124")):
        if not txt: continue
        p = f"{AUDIO}/s{m['idx']:02d}{tag}.txt"
        open(p, "w").write(txt)                      # 关键:文本落文件,绕开转义
        sub_filters.append(
            f"drawtext=fontfile={FONT}:textfile={p}:fontsize={size}:fontcolor={color}"
            f":borderw=3:bordercolor=black@0.55:x=(w-text_w)/2:y={y}"
            f":enable='between(t,{st:.2f},{en:.2f})'")
filtergraph 最容易犯的两个错(都踩过)
  1. 滤镜链开头没接输入标签:必须写成 "[0:v]" + ",".join(...),否则报 Filter has an unconnected output。
  2. 滤镜链结尾没给输出标签:视频分支要以 [vout] 收尾,然后 -map "[vout]";写 -map "[v]" 而链里没定义会直接失败。
最终成片(正确版完整命令)fc="[0:v]"+",".join(sub_filters)+"[vout];"\
   "[1:a]volume=0.82[n1];[2:a]volume=0.85[n2];"\
   "[n1][n2]amix=inputs=2:duration=longest:dropout_transition=0,"\
   "volume=1.85,alimiter=limit=0.94:level=false[aout]"

ffmpeg -y -loglevel error \
  -i final/nosub.mp4 -i audio/narration.wav -i audio/rain.wav \
  -filter_complex "$fc" -map "[vout]" -map "[aout]" \
  -c:v libx264 -preset medium -crf 19 -pix_fmt yuv420p \
  -c:a aac -b:a 192k -movflags +faststart \
  final/velvet-fight-promo-90s.mp4

-movflags +faststart 把 moov box 前移,网页端可边下边播 —— 宣传视频默认要加。alimiter=limit=0.94 留住安全边际。

10成品质检:上线前必过的六项

① 规格核对ffprobe -v error -show_entries format=duration,size,bit_rate \
  -show_entries stream=codec_name,width,height,r_frame_rate,channels \
  -of default=noprint_wrappers=1 velvet-fight-promo-90s.mp4
② 电平检查(避免削波 / 过小)ffmpeg -i velvet-fight-promo-90s.mp4 -map 0:a -af volumedetect -f null - 2>&1 \
  | grep -E 'mean_volume|max_volume'
# mean_volume: -20.6 dB   max_volume: 0.0 dB
0.0 dB 意味着安全边际为零

第一版混音峰值顶到 0.0dB。虽然 AAC 编码的 max_volume 有一定过冲成分、听感未必削波,但交付前应留出余量。修复:旁白 volume 1.0→0.82、雨声 1.0→0.85、总线 2.0→1.85,末端加 alimiter=limit=0.94。若目标是 B 站 / YouTube,建议再压 -1dB。

③ 抽帧人眼/模型复核(抽 8 个时间点,注意覆盖每段)V=final/velvet-fight-promo-90s.mp4; rm -rf seq && mkdir -p seq && I=0
for t in 1.5 12 24 33 41 55 76 88; do
  I=$((I+1)); ffmpeg -y -loglevel error -ss $t -i $V -frames:v 1 -vf scale=880:495 seq/$(printf %02d $I).jpg
done
ffmpeg -y -loglevel error -i seq/%02d.jpg \
  -filter_complex "tile=4x2:padding=5:margin=5:color=0x222222" -frames:v 1 shots/final-check.jpg
  • 字幕可读性:无乱码、无豆腐块、深色体系下对比度足够(本片主行白色 + 3px 半黑描边)
  • 时间对齐:旁白首字与字幕出现基本同步,相邻字幕不叠压
  • 画面连续性:段落交界处无黑场、无跳帧、无分辨率突变
  • 音量一致性:mean 落在 -18 ~ -23 dB,max 留出 ≥ 0.5dB 余量
  • 数值真实性:片中每个宣称的数字都能回溯到源码或被验证过的运行结果
  • 播放兼容:faststart 已开,浏览器拖动进度条能立即起播

11陷阱速查表

按阶段归类,左列为症状,右列为处置。带 ● 的在本次真实发生过。

阶段症状与处置
环境● 后台任务被回收:shell & 起的 preview 服务会被会话回收 → 交给任务管理器常驻,启动后 curl 验证 HTTP 200。
环境● Playwright 版本不匹配:报 Executable doesn't exist at .../chromium-1248 → 显式传 executablePath 指向缓存里已有的版本。
环境● 缺专用 ffmpeg 二进制:Playwright 录屏依赖 ffmpeg-1013 → symlink 到已有版本可绕过,但更推荐直接改用 CDP screencast。
环境● 字体打不开:PingFang.ttc 在 ffmpeg 中失败 → 逐个实测,本机可用 Hiragino Sans GB.ttc。
渲染● 自动降画质:fps 阈值以下持续数秒会偷偷降粒子/像素比 → 录前确认 fps 余量(目标 >2× 交付帧率)。
渲染● 资源未加载完就开录:高精模型异步加载中,前几秒是低模备用 → 开录前静置并断言资源到位标志。
渲染● AudioContext 未解锁:无用户交互则无声音 → 录制前 page.mouse.click() 一次。
驱动● 组合键跨帧 → 在单个 page.evaluate 内同帧派发 KeyboardEvent。
驱动● 迟滞事件提前判定 → 找一个立即变化的中间量(如能量清零)作为触发标志,再推迟验证时点。
驱动● 位移推过目标 → 站位按最后一招的位移量预留。
驱动● 绝对距离走反方向 → 位置闭环用带符号差值。
驱动● 墙钟调度被异步耗时挤乱 → 事件顺序执行 + marks.json 记录实际边界。
驱动● 无敌帧吞攻击 → 两段攻击间留足空档(本项目 >1.1s)。
验证● 数值对但画面错(打空 / 朝向反)→ 必须抽帧拼图做视觉验证。
合成● filtergraph 缺输入/输出标签 → 链首 [0:v]、链尾 [vout] + -map "[vout]"。
合成● amix 缺 normalize(ffmpeg 4.2)→ 用 volume=N 补偿。
合成● vstack 无 padding / drawbox t=fill 填满整图 → 预览造图时注意版本差异。
输出● 峰值顶到 0.0dB → 降各路增益 + 末端 alimiter。

12命令速查

完整复现顺序cd app && npm run build
npm run preview -- --port 4173 --host 127.0.0.1      # 常驻

node smoke.mjs          # ① 渲染冒烟:断言模型加载 / fps / 画质档位
node probe-fps.mjs      # ② 帧率探针:→ fps(60~97)、分辨率正常才可继续
node capture-game.mjs   # ③ 全场次采集(可拆成单场脚本单独重跑)
node capture-b3.mjs     #    战斗镜头:事件驱动版,含 combo/HIT 断言
node capture-d4.mjs     #    对空:同帧按键版
node codecard.mjs       # ④ 代码卡渲染并录制

python3 build_audio.py  # ⑤ 旁白生成 + 自动配速 → audio/manifest.json
python3 build_video.py  # ⑥ 裁剪 → 拼接 → 雨声 → 混音 → 烧字幕 → 成片
python3 qc.py           # ⑦ 抽 8 帧拼图 + volumedetect + ffprobe
高频诊断# 某场次到底录到了多少帧 / 实际帧率     → cat clips/<场次>/meta.json
# 这一段 “\ 每镜实际起止时刻 ” 从哪来        → cat clips/_marksB.json
# 抽单帧                                → ffmpeg -ss T -i v.mp4 -frames:v 1 -vf scale=880:495 f.jpg
# 拼图(给模型 / 人眼看)                → ffmpeg -i seq/%03d.jpg -filter_complex "tile=3x2:padding=6:margin=6:color=0x111111" -frames:v 1 sheet.jpg
# 音量                                  → ffmpeg -i v.mp4 -map 0:a -af volumedetect -f null -
# 读取时长 / 体积 / 码率                 → ffprobe -v error -show_entries format=duration,size -of csv=p=0 v.mp4

交付物清单

文件说明
PROMO_VIDEO_SCRIPT.md制作脚本:分镜 / 素材 / 演示步骤 / 字幕旁白四段
promo/velvet-fight-promo-90s.mp4成片 · 90s · 1920×1080 · 30fps · H.264+AAC · 19.8MB
promo/VIDEO_PRODUCTION_GUIDE.html本文档
lib.mjs采集核心:Screencast / Timeline / launch / openPage
capture-*.mjs各场次驱动脚本,内含 HIT/MISS 断言
codecard.mjs代码卡 HTML 生成 + 录制
build_audio.py旁白生成、自动配速、manifest 输出
build_video.pyPLAN 驱动的裁剪/拼接/混音/烧字幕

可继续的方向

  • 30 秒竖版:重排 PLAN 表即可(裁剪.scale=1080:1920 + 画面 crop),无需重采。
  • 15 秒预告:只取钩子 + 超必杀 + 收尾三段。
  • 上架平台:补 -af "alimiter=limit=0.89" 压 -1dB,并按平台要求重封装。
  • CI 化:把 smoke + assert HIT 做成 GitHub Action,游戏改动后自动验证演示脚本仍然有效 —— 演示脚本本身也是一种回归测试。