00管线总览
整条链路由七个阶段串成,每个阶段的产物都是磁盘上的具体文件,因此可以单独重跑任意一段而不影响前后——这一点决定了返工成本。approach 逻辑改了只用重采集战斗镜头,不用重录旁白。
实机画面占比(67s / 90s)。格斗游戏的说服力来自连续对抗,切碎会摧毁打击感的可信度。
战斗镜头平均返工次数。全部由自动化驱动缺陷导致,无一是渲染问题。
外部付费素材与 CDN 依赖。画面、音频、字幕全部由本机合成。
01三条贯穿始终的原则
本次一开始就发现四处文档与实现不符(见 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 · recovery | combat.ts:13-20 |
| 连段规则 | 递减系数 max(0.48, 1-combo*0.075);仅轻击命中可取消接重击/特殊技 | combat.ts |
| 连段计数 | 两次命中间隔 <1.15s(comboTimer)即累计 —— 不依赖取消窗口 | combat.ts:213 |
| 特殊效果 | 投技不可防御;格挡削血(超必杀 3 / 伤害>10 则 1);hitstop .045/.085/.14s | combat.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?.());
- 点击页面一次 —— 浏览器的 AudioContext 需用户交互解锁,否则整段实机音效缺席。
- 静置 7–10 秒等异步资源 —— 本项目两个高精 GLB 各 10–11MB,加载未完成时画面上是程序化备用模型,一旦录进去就是穿帮。
- 确认 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) 之后的全部事件瞬间洪水执行,三个镜头全废。
不要用「绝对时间轴」调度依赖异步收敛的动作。改为按事件顺序 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=8 | ffmpeg 4.2 的 vstack 不支持 padding 选项,命令直接失败。 |
drawbox=c=0x111111:t=fill | t=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})
每段旁白落地一个 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,只做垫底。
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})'")
- 滤镜链开头没接输入标签:必须写成
"[0:v]" + ",".join(...),否则报 Filter has an unconnected output。 - 滤镜链结尾没给输出标签:视频分支要以
[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.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.py | PLAN 驱动的裁剪/拼接/混音/烧字幕 |
可继续的方向
- 30 秒竖版:重排 PLAN 表即可(裁剪.
scale=1080:1920+ 画面 crop),无需重采。 - 15 秒预告:只取钩子 + 超必杀 + 收尾三段。
- 上架平台:补
-af "alimiter=limit=0.89"压 -1dB,并按平台要求重封装。 - CI 化:把 smoke + assert HIT 做成 GitHub Action,游戏改动后自动验证演示脚本仍然有效 —— 演示脚本本身也是一种回归测试。