Intent-Driven Recording

意图驱动录制:AI 协同的自动化演示流水线

一份从"一台干净的机器"出发的完整教程:人只走一遍真实操作路径, AI 把它整理成可重放的脚本、写好解说文案、生成配音、拼好成片;改一个字不用重录, 字幕改完几十秒出新版本;一个功能点定型之后,重新录制也是全自动、无人值守的。 一条视频从发起到成稿真实耗时约 15~30 分钟,人工唯一不可省略的环节是最后的审片。

Martin Xu · 2026 年 8 月 · 全书 21 章一次看完,点卡片/条目直接跳转
全书覆盖的能力
无人值守录制引擎
ready/go 握手信号杜绝录到不该出现的画面、多屏环境自动探测录制设备、浏览器换皮去掉测试工具身份
字幕双来源判定
优先用自动化脚本自带的时间点文本(最准),没有就用本地语音识别(Whisper/SenseVoice)转写兜底
自然语速配音
按字幕分段生成配音,不做时长强制拉伸,用累计时间戳自然吸收每句的细微误差
多音字修复表
一次维护「问题词→安全替换词」,比如「命令行工具」读错音,改一次对全部功能点视频生效
配音全局偏移
一个 dub_offset 就能整体微调配音快慢,配合 tpad 兜底,正偏移也不会把配音截断
多语言一键翻译
DeepSeek 批量翻译整份字幕,操作录制完全不用为了出英文版重录第二遍
文件即状态的编排
没有数据库、没有状态机,几个约定命名的文件就是整条流水线的进度,任何一步都能单独重跑
meta.json 三级配置
内置默认 → 项目级 → 功能级层层覆盖,团队规范改一处,全部功能点视频统一生效
AI 零审阅生成脚本
record.spec.js 默认由 AI 直接产出,人工 codegen 只是 AI 猜不准页面结构时的兜底路径
AI 自愈调试循环
冒烟测试报错直接交给 AI 分析、修复选择器、重新验证,人不用打开控制台排查
AI 自动化质检
抽帧 + 多模态模型自动比对防泄露清单,产出审片报告,人只看标红的风险点
防泄露检查清单
10 类真实翻车场景(菜单栏组件、终端历史、访问限制页返回200……)总结成可执行清单
Part

准备与基础

环境怎么搭、项目怎么组织。

CHAPTER 00

00 前言与总体架构

0.1 我们要解决的问题

假设你负责一个软件产品,产品经理/研发团队每周都会交付若干"功能点"(feature point):新增一个设置项、修复一个交互、上线一个仪表盘卡片。传统的做法是:

  1. 有人手动打开产品,操作一遍功能;
  2. 用屏幕录制软件录下来;
  3. 自己配一段解说词,或者干脆不配音,只加字幕;
  4. 剪辑软件里拼接封面、加转场、导出;
  5. 上传到内部 wiki / 客户培训平台 / 销售演示库。

这个流程有几个致命问题:

  • 不可复用:功能一旦有 UI 微调,整个视频要重录、重剪。
  • 人力密集:每个功能点视频背后是 30 分钟到 2 小时的人工操作。
  • 质量不稳定:不同人录制的节奏、解说风格、封面样式都不统一。
  • 无法规模化:一个季度几十个功能点,人工根本跟不上。

我们希望达到的目标状态是:

工程师只需要手动操作一遍浏览器(这一遍会被 codegen 记录下真实的选择器), 把操作脚本和解说时间点整理成一份自动化脚本, 之后一个命令录完屏、一个命令出片;改字幕文本不满意,几十秒重出一条新片, 不需要重新录制。从发起到成稿,一条视频通常 15~30 分钟。

这就是本教程要从零构建的系统。全书称之为 "意图驱动录制流水线"(Intent-Driven Recording Pipeline,下文简称 IDRP)。

0.2 设计哲学:三个"最小化"

  1. 最小化人工描述:人不需要写一份独立的、脱离代码的"需求文档"。真正的意图直接体现在两个具体产物里:自动化脚本本身(做了什么操作)+ 一份轻量的时间点文本(每个时间点说什么),见第 03 章。
  2. 最小化人工操作:真正需要人手动做的事情只有一件——在浏览器里把这个功能点操作一遍,供 codegen 录制出真实可靠的选择器。除此之外不需要人工剪辑、不需要人工配音、不需要人工掐秒对时间轴。
  3. 最小化重复劳动:录制是唯一"慢、只做一次"的环节;字幕文案、配音、封面、合成全部是可以反复重跑、每次几十秒到几分钟就能出新结果的独立步骤,改一个字不需要从头再来。

0.3 系统总体架构

和很多人直觉设想的"先备好文案和配音,再对着配音的节奏去操作/剪辑"不同,这套系统的真实运作顺序是倒过来的:先把操作真实录下来,画面按它自己该有的节奏走,配音和字幕是"贴"上去的,而不是"领着画面走"的。

┌───────────────────┐
│  Codegen(人工一次)│  人在浏览器里走一遍真实操作路径
└─────────┬──────────┘
          ▼
┌────────────────────────────┐
│ 自动化脚本 + 时间点文本      │  操作代码 + "第几秒说什么"的文本,
│ (人工整理,或交给 AI 处理) │  两者一起维护,见03/17章
└─────────┬──────────────────┘
          ▼
┌────────────────────────────┐
│  Record:一次性连续录制      │  自动化脚本驱动浏览器操作,
│  (浏览器操作 + 屏幕录制     │  ffmpeg 同步录屏,全程一条连续
│   同时进行,无分段)         │  视频,画面按真实操作节奏走
└─────────┬──────────────────┘
          ▼
┌────────────────────────────┐
│  字幕来源(二选一,自动判断)│  优先用录制时就有的时间点文本
│  - 时间点文本直接转字幕      │  (最准,原文本非猜测);
│  - 或 ASR 转写录屏语音兜底   │  没有就转写音轨兜底
└─────────┬──────────────────┘
          ▼                                    ┌─────────────────┐
┌────────────────────────────┐                 │  三级配置合并     │
│  Dub:按字幕分段自然语速配音 │ ◄────────────── │ 内置默认 → 项目  │
│  (逐句 TTS + 累计时间戳     │                 │ meta.json → 功能 │
│   拼接,不做时长强制拉伸)   │                 │ meta.json        │
└─────────┬──────────────────┘                 └─────────────────┘
          ▼
┌────────────────────────────┐
│  Mix:合成成片                │  裁掉录制起始的不安全空档 +
│  封面/正文/封底拼接 + 配音    │  全局 dub_offset 微调对齐 +
│  混流 + 字幕烧录 + BGM(可选) │  字幕跟着同一个偏移量平移
└─────────┬──────────────────┘
          ▼
   ┌─────────────┐
   │  成片 .mp4   │
   └─────────────┘

对照到具体章节:

模块 对应章节 一句话职责
Codegen 层 04 人工走一遍真实操作路径,产出可靠选择器
自动化脚本 + 时间点文本 03 操作代码怎么组织、时间点文本怎么维护、和 meta.json 三级配置的关系
Record 层 05 浏览器操作 + 屏幕录制同步进行,一次性产出一条连续原始录像
字幕/Dub 层 06 字幕来源判定(时间点文本优先,ASR 兜底)+ 按字幕自然语速批量配音
Cover 层 07 生成封面/封底素材
Mix 层 08 裁剪、配音混流、偏移对齐、字幕烧录、BGM、拼接导出
编排器 09 命令行怎么把以上串起来,以及哪些步骤可以单独重跑

0.4 为什么是"录制优先"而不是"音频先行"

这是整个系统里最容易被想反的设计决策,值得在前言里先讲清楚。

一个符合直觉、但实践证明不可取的设计是"音频先行":先写好每一步的解说文案,用 TTS 合成音频,精确测出每段音频的时长,再把这个时长喂给自动化脚本,让它在对应步骤"等待"这么久,从而让画面节奏和配音精确对齐。这个设计理论上优雅,实际上会把系统拖入两个陷阱:

  • 文案还没定稿,录制就没法开始。解说文案往往要经过好几轮措辞调整(口语化、对齐真实按钮文案、合规审查),如果录制依赖文案先定稿,每一次改文案都要重新触发一次录制,而录制恰恰是全流程里最慢、最需要人工在场确认的一步。
  • "等待时长"和"操作实际耗时"是两回事。页面动画、网络延迟、真实渲染耗时本身就有波动,即使精确算出了配音时长,也不代表操作恰好能在这个时长内自然完成——最后还是需要额外的缓冲和微调,复杂度并没有真正降低。

真正跑得通、稳定、能做到"15~30 分钟出一条片"的做法是反过来:先把操作按它自然的节奏录下来,画面时长是多少就是多少;再让字幕和配音去适配这段已经固定下来的时间轴,而不是反过来。具体来说:

  1. 录制时,自动化脚本本身就带着"第几秒说什么"的时间点文本(人工整理,或者像 03/17 章讲的那样交给 AI 处理),录完直接转换成字幕文件——这份文本是原文,不是靠机器猜出来的,准确度最高。如果某次是人工直接手动录屏(不经过自动化脚本),就退化成用语音识别(Whisper / SenseVoice)转写录屏里的真实语音,自动生成字幕兜底。
  2. 字幕文件的时间戳一旦确定,就是后续所有环节的"事实来源"。配音生成时,每一句字幕对应的时间窗口已经定了,TTS 按自然语速把这句话读出来,多长是多长,不做时长压缩/拉伸;相邻句子之间用静音简单拼接,累计时间戳里自然消化掉每句实际时长和原定窗口之间的微小差异。
  3. 最后合成阶段,如果配音听起来和画面整体差了一点点,用一个单一的全局偏移量(dub_offset,正数整体延后、负数整体提前)微调对齐,字幕的时间戳也要跟着同一个方向、同一个量级一起平移——这是唯一需要人工感知"对不对得上"的调节点,粒度是"整条配音轨"而不是"每一句"。

这个顺序带来的最大收益是解耦:录制(慢、需要人在场)只做一次;字幕文案的措辞、配音的音色/语速、封面文案、要不要加 BGM——这些都是纯粹的后期步骤,改一处只需要重跑对应的那一步(几十秒到几分钟),完全不需要碰录制。这正是"无限重复生成"效率的来源,也是 09 章会讲到的、每个步骤都可以独立重跑的编排设计的底层原因。

0.5 技术选型一览(详细理由见各章)

能力 选型 备选
浏览器自动化/录制 Playwright(Node.js / TypeScript) Puppeteer、Selenium
屏幕级录制(浏览器+终端画面同步录制) ffmpeg + 平台原生采集设备(avfoundation/x11grab/gdigrab),硬件编码(如 videotoolbox)提速 OBS Studio + WebSocket 远程控制
字幕来源 自动化脚本自带的时间点文本(最准);无此文本时用本地语音识别(faster-whisper / SenseVoice)转写兜底 云端 ASR API
TTS 配音 edge-tts(微软神经语音,免费、自然度接近真人,作为默认方案) 云端付费 TTS(Azure/阿里云等,需要更多发音人/更强合规保障时)、macOS say(离线兜底)
字幕翻译 DeepSeek API 批量翻译(一次调用翻译整份字幕) 其他 LLM API
字幕烧录 ffmpeg + libass(真烧进画面,非外挂字幕轨) 软字幕轨(mov_text)
封面生成 Headless 浏览器截图 HTML/CSS 模板,或 ffmpeg drawtext 直接生成标题卡 Canvas/ImageMagick 直接绘制
音视频合成 ffmpeg(stream-copy 优先,编码参数不一致时才回退重编码) Remotion(React 渲染视频,适合更强的动画需求)
配置层 三级合并的 meta.json(内置默认 → 项目级 → 功能级),声明式、非代码 单一扁平配置文件
编排语言 Shell + Python(贴近 ffmpeg/命令行工具生态,改一个函数不用编译) TypeScript/Node.js(如果团队更习惯这套生态)

后续章节会逐一展开每个模块的原理、代码实现和踩坑点。建议按顺序阅读,因为 03 章定义的"自动化脚本 + 时间点文本 + meta.json"三件套会贯穿全书所有代码示例。

0.6 团队角色分工建议

即使是一个人独立搭建和使用这套系统,明确"哪个环节该由谁、以什么身份完成"也有助于理解整体设计。如果是多人团队协作,建议按下面的角色划分:

  • 需求提出方(通常是产品经理):只负责提供 00 章开篇提到的"一句话意图 + 粗粒度步骤",不需要接触 Feature Spec 的 YAML 语法,也不需要理解任何本教程后续的技术实现。
  • 录制工程师(通常是前端/测试/自动化工程师):负责把意图扩写成完整 Feature Spec(03 章)、执行 codegen 录制和脚本清洗(04 章)、首次跑通并微调节奏(09 章)、执行录制前后的检查清单(10 章)。这是本教程主要面向的角色。
  • 系统维护者(通常是团队里对这套流水线本身负责的 1~2 名工程师):负责环境和依赖的升级维护(01 章)、公共 codegen 片段库的治理(02 章 2.7 节)、CI 集成和性能优化(11 章)、以及在功能点数量增长后重新评估投入策略(14 章)。在团队规模较小时,这个角色和"录制工程师"可以是同一个人;规模扩大后建议明确分离,避免系统维护职责在多人之间"三个和尚没水吃"。
  • 审片人(可以是录制工程师本人,也可以是团队里指定的另一人):负责 10 章的最终审片环节。如果条件允许,建议审片人和录制工程师不是同一人——旁观者更容易发现录制者本人因为"看惯了"而忽略的问题(比如 10.6 节提到的菜单栏常驻组件,录制者自己每天都看到反而不会注意,换一双眼睛更容易发现)。

这个角色划分不是强制的组织架构要求,而是帮助厘清"这套系统运转起来,到底需要哪些不同性质的判断和操作",具体到某个团队,可以按实际人力灵活合并或拆分这些角色。

0.7 这不只是一个"录视频"的工具

读到这里,很容易把本书理解成"一套做功能演示视频的工具链"。这个理解不算错,但低估了这套方法论真正的价值所在。

拆开来看,这套系统解决的其实是一个更通用的问题:如何让一件需要"实际操作 + 讲解 + 产出交付物"的工作,变成一份可以反复执行、随时编辑、交给 AI 协同完成的工程流程。演示视频只是这个通用问题的一个具体应用——操作是"点浏览器",讲解是"配音字幕",交付物是"一条 mp4"。同样的骨架,换一个应用场景,"操作"可以是任何可自动化的动作,"讲解"可以是任何形式的文本产出,"交付物"可以是任何合成结果。

真正值得沉淀和复用的,是这套方法论背后的几条工程原则,它们比"怎么做视频"这件事本身更重要:

  • 工程化:不依赖任何人临场发挥的手艺("这段话我脱稿讲得挺好"),一切可复现的东西都落在文件里——代码、时间点文本、配置——而不是留在某个人的脑子里或者一次性的操作记忆里。
  • 规范化:三份文件(03 章)各自职责单一、边界清晰,任何人接手都能照着规范找到该改哪里,不需要理解一整套隐性的个人习惯。
  • 随时可编辑、随时可重新合成:字幕文案改一个字,配音和成片跟着秒级更新;这不是"重新做一遍",而是"重新跑一个已经定义好的流程",人的参与只在提出修改意图那一刻,不在执行过程里。
  • 哪怕是"重新录制",也是全自动的:这是最容易被低估的一点——很多人以为"自动化"止步于配音和合成这些后期步骤,录制本身还是要靠人工。但一旦 record.spec.js 定型,重新录制就是执行一个命令、等它跑完,不需要人重新坐到电脑前把操作走一遍。一个功能点从"定义"到"无人值守持续产出"之间,只差一次把操作路径固化成脚本的投入,这个投入是本书前 20 章讲的全部内容想要帮你压到最低的东西。

如果你所在的团队面对的不是"录功能演示视频",而是别的什么需要"人做一遍、AI 学会、之后自动重复"的工作——本书从 03 章到 20 章讲的分工方式、文件设计原则、AI 协同方法,同样适用。视频只是这次我们拿来讲清楚这套方法论的载体。

CHAPTER 01

01 环境准备

本章目标:在一台全新的机器(以 macOS 为主线,附 Linux / Windows 差异说明)上,把整套流水线所需的运行时、工具链、系统权限全部配置完成。全书后续章节的代码都假设本章已经完成。

1.1 操作系统的选择与理由

三大平台都能跑这套流水线,但难易度和稳定性不同:

  • macOS:推荐首选。系统自带 say(离线 TTS,适合本地联调)、avfoundation(ffmpeg 屏幕采集设备)、Screen Recording 权限体系成熟,Playwright 对 WebKit/Chromium 支持都很好。绝大多数"功能演示录制"场景(浏览器 + 桌面终端)在 macOS 上最省心。
  • Linux(Ubuntu 22.04+):适合放在 CI / 云端无人值守批量录制。需要 xvfb(虚拟显示器)+ x11grab。没有真实显示器时也能跑,但鼠标高亮、真实字体渲染等细节需要额外配置(见 1.7)。
  • Windows:可行,但 ffmpeg 的 gdigrab 采集在多显示器/高 DPI 下坑较多,不作为首选,本教程仅在关键步骤给出 Windows 命令,不展开。

本章后续以 macOS 命令为主线,Linux 差异用「Linux 备注」标出。

1.2 包管理器

macOS 上先装 Homebrew(如果还没有):

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Linux(Ubuntu)用 apt:

sudo apt update && sudo apt upgrade -y

1.3 Node.js 运行时

编排器、Playwright、codegen 都跑在 Node.js 上。推荐用版本管理器而不是直接装全局 Node,方便未来切换版本:

# 安装 nvm(Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.zshrc   # 或 ~/.bashrc

# 安装并使用 LTS 版本(建议 Node 20.x)
nvm install 20
nvm use 20
nvm alias default 20

node -v   # 确认版本 v20.x
npm -v

为什么是 Node 20 而不是更新的版本:Playwright、大多数 TTS SDK、以及 ffmpeg 的 Node 封装库在 20.x LTS 上生态最成熟,避免用最新的非 LTS 版本踩兼容性坑。

1.4 Python 运行时(必装,不是可选项)

和很多人的直觉相反,这套系统里 Python 不是"可选的辅助脚本语言",而是核心依赖——06 章的字幕解析/生成、语音识别兜底、多语言翻译,都是用 Python 写的小脚本(配合 shell 编排器一起跑):

brew install python@3.11
python3 -m venv ~/.venvs/idrp
source ~/.venvs/idrp/bin/activate
pip install --upgrade pip
pip install edge-tts faster-whisper

edge-tts 是 06 章配音的默认方案本体(不是可选项);faster-whisper 是字幕来源兜底用的本地语音识别引擎(06 章 6.1 节),如果更偏好 SenseVoice,换成 pip install funasr 也可以。

1.5 浏览器自动化:Playwright

mkdir -p ~/idrp && cd ~/idrp
npm init -y
npm install -D playwright @playwright/test

# 下载浏览器内核(Chromium/WebKit/Firefox)及系统依赖
npx playwright install --with-deps chromium

自动化脚本(record.spec.js)用普通 JavaScript 写就够了,不需要 TypeScript 工具链——04 章会看到,这些脚本大多是 AI 生成的一次性代码,加一层 TS 编译反而增加不必要的构建步骤。

--with-deps 会在 Linux 上自动装好 Chromium 运行所需的系统库(字体、libnss、libatk 等),macOS/Windows 上这个参数基本是空操作,但保留无害。

验证安装:

npx playwright --version
npx playwright codegen https://example.com

如果能弹出一个 Chromium 窗口和一个 "Playwright Inspector" 录制面板,说明 Playwright + codegen 已经就绪(codegen 的详细用法见第 04 章)。

1.6 音视频处理:ffmpeg

brew install ffmpeg
ffmpeg -version

Linux:

sudo apt install -y ffmpeg

务必确认版本 ≥ 5.0(本教程用到的 loudnorm、drawtext、subtitles 滤镜在旧版本上参数略有差异):

ffmpeg -version | head -1
# 期望输出类似: ffmpeg version 6.x ...

同时确认关键滤镜是否编译进当前 ffmpeg(用于封面文字绘制、字幕烧录):

ffmpeg -filters 2>/dev/null | grep -E "drawtext|subtitles|loudnorm|concat"

四项都应该能匹配到。如果 drawtext 缺失,说明你的 ffmpeg 编译时没有启用 --enable-libfreetype,需要用 brew reinstall ffmpeg 重装官方完整版(Homebrew 默认公式已经带全部这些滤镜,通常不会遇到这个问题;如果是自行编译的 ffmpeg 才需要注意)。

字幕烧录(08 章)额外需要 libass 支持,Homebrew 默认的 ffmpeg 公式不一定带这个组件,需要装完整版:

brew install ffmpeg-full

如果机器上同时装了默认版和 ffmpeg-full,08 章烧字幕的命令要显式指定完整版的路径(通常在 /usr/local/opt/ffmpeg-full/bin/ffmpeg),不要依赖 PATH 里默认解析到的那个 ffmpeg,两个版本的滤镜集不一样,混用会导致"明明装了却提示缺 libass"这种困惑。

同时装一下 07 章封面生成要用的 ImageMagick:

brew install imagemagick
magick -version

1.7 字体(封面文字 / 字幕烧录必需)

drawtext 和 subtitles 滤镜都需要指定一个真实存在的字体文件路径,中文字幕/封面尤其要确认系统里有中文字体:

macOS 自带的常用中文字体路径:

/System/Library/Fonts/PingFang.ttc
/System/Library/Fonts/STHeiti Light.ttc

Linux(Ubuntu)通常需要手动装中文字体:

sudo apt install -y fonts-noto-cjk
fc-list | grep -i noto | grep -i cjk

记下你打算使用的字体文件绝对路径,07 章(封面生成)和 08 章(字幕烧录)会直接引用这个路径。

1.8 文本转语音(TTS)相关准备

本教程的主线方案是 edge-tts 为默认(免费、无需 Key、微软神经语音、中文自然度足够好),macOS 自带的 say 只作为完全离线场景下的兜底,云端付费 API 是可选的升级路径,不是默认必需项。

默认方案:edge-tts(1.4 节已经装过,这里补充验证):

edge-tts --list-voices | grep zh-CN
edge-tts --voice zh-CN-XiaoxiaoNeural --text "你好,这是一个测试。" --write-media test.mp3

完全离线兜底:macOS 自带 say(没有网络、或者 edge-tts 服务临时不可用时用):

say -v "?"          # 列出所有可用发音人
say -o test.aiff "你好,这是一个测试。" --data-format=LEI16@22050
afconvert test.aiff test.wav -f WAVE -d LEI16

云端付费 TTS(可选,需要更多发音人/更强合规保障时再考虑):以阿里云智能语音交互 / Azure Speech 为例,开通语音合成能力、拿到 API Key 后,放进环境变量,绝不硬编码进代码或提交进仓库:

export AZURE_SPEECH_KEY=your_key_here
export AZURE_SPEECH_REGION=eastasia

06 章的配音逻辑默认调用 edge-tts,只有明确需要切换发音人/供应商时才会用到云端 API。

1.9 系统权限(macOS 尤其重要)

macOS 的隐私保护机制会拦截"屏幕录制"和"辅助功能"(Accessibility,用于模拟鼠标高亮/控制其他 App 窗口)两类操作,需要手动授权一次:

  1. 系统设置 → 隐私与安全性 → 屏幕录制 → 把你要用来跑录制脚本的终端 App(Terminal.app / iTerm2)勾选打开。
  2. 系统设置 → 隐私与安全性 → 辅助功能 → 同样勾选你的终端 App。
  3. 如果录制脚本会控制多个显示器/窗口定位,还需要在"自动化"(Automation)里允许该终端 App 控制"系统事件"(System Events)。

未授权的典型报错是 ffmpeg 报 Operation not permitted 或截屏是全黑画面——遇到这个现象第一反应是检查这三处权限,而不是查代码逻辑。

1.10 关闭干扰源(勿扰模式 / 通知)

录制过程中系统通知弹窗、消息提醒会直接穿帮,录制前务必:

# macOS:通过快捷键或控制中心开启"专注模式/勿扰模式"
# 也可以用 shortcuts CLI 自动化(macOS 12+):
shortcuts run "打开勿扰模式"   # 需要提前在"快捷指令"App 里建好这个同名快捷指令

这一项会在 10 章的"录制前检查清单"里再次出现,因为它是最容易被忽略但最容易穿帮的一步。

1.11 目录与环境变量小结

到本章结束,你的机器上应该具备:

  • Node.js 20.x(node -v 可用),Playwright + Chromium(npx playwright codegen 可弹窗)
  • Python3 + edge-tts + faster-whisper(或 funasr)已装好
  • ffmpeg ≥ 5.0 且滤镜齐全;ffmpeg-full(带 libass)另外装好,字幕烧录专用
  • ImageMagick(magick -version 可用),07 章封面生成要用
  • 至少一种可用的中文字体路径已记录
  • edge-tts 已跑通(默认配音方案),say 作为完全离线兜底
  • 屏幕录制 + 辅助功能权限已授予终端 App
  • 勿扰模式可以一键开启

1.12 Windows 环境的关键差异(非首选路径,仅供参考)

如果团队的目标机器只能是 Windows,以下是与 macOS 主线流程的关键差异点,其余步骤(Node.js/Playwright/ffmpeg 安装)基本一致,用 winget 或直接下载安装包替代 brew 即可:

winget install OpenJS.NodeJS.LTS
winget install Gyan.FFmpeg

系统级录屏使用 gdigrab 而不是 avfoundation/x11grab:

ffmpeg -f gdigrab -framerate 30 -offset_x 0 -offset_y 0 -video_size 1440x900 -i desktop `
  -vcodec libx264 -pix_fmt yuv420p -preset veryfast -crf 18 output.mp4

gdigrab 在多显示器环境下需要用 -offset_x/-offset_y 手动指定要采集的显示器区域,且在高 DPI 缩放(如 150%/200% 缩放)下容易出现采集区域与实际显示不一致的问题,需要提前把目标显示器的缩放比例临时调整为 100% 再录制。鼠标指针默认可见,不需要像 macOS 那样额外传参。字体路径通常在 C:\Windows\Fonts\ 下,中文字体常见的是 msyh.ttc(微软雅黑)。权限方面 Windows 没有 macOS 那样的隐私授权弹窗机制,gdigrab 通常开箱可用,但如果系统开启了某些安全软件的屏幕保护策略,可能需要额外在安全软件里放行 ffmpeg 进程。

由于以上差异点相对繁琐、且高 DPI 缩放问题的排查成本较高,本教程后续章节的代码示例仍以 macOS/Linux 路径为主,Windows 团队建议优先考虑用 WSL2(Windows Subsystem for Linux)跑本教程的 Linux 路径,只在必须使用真实 Windows 桌面画面(比如要演示一个 Windows 专属客户端软件)时才直接用原生 Windows + gdigrab 方案。

下一章(02)开始搭建项目骨架,把这些工具组织进一个规范的代码仓库结构里。

CHAPTER 02

02 项目脚手架与目录规范

本章把 01 章装好的工具组织进一个规范的目录结构。这里要分清楚两类完全不同的目录:工具本体(你写一次、之后不常改的 CLI 脚本)和功能点数据(每次录制新功能点都会新增一个的目录)。混淆这两者是新手最常踩的坑——把某个功能点的临时调试文件提交进了工具仓库,或者反过来把工具的公共脚本误放进了某个功能点目录里,导致改一处忘了改另一处。

2.1 工具本体的目录结构

video-toolkit/                      # 工具仓库:写一次、长期维护,各功能点共用同一份
├── video-toolkit.sh                 # 主入口,vt 这个命令背后就是这个脚本(09章讲全部命令)
├── lib/
│   ├── meta.sh                      # meta.json 三级配置引擎(09章9.4节)
│   └── compose.sh                   # 封面/封底/BGM 通用合成器(07/08章)
├── rebranded-chromium/               # 换皮后的 Chromium(05章7.8节的浏览器换皮技巧)
│                                     # 机器本地产物,不提交进仓库
├── RULE.md                          # 项目级规则文件:功能点制作/录制/色调规范
│                                     # 一次写好,后面每个功能点只需要一句话描述,
│                                     # AI 自动遵守这里的规范(17章1.1节详细展开)
└── docs/                             # 工具自身的文档

2.2 每个功能点的数据目录

工具本体之外,每次录制一个新功能点,在你自己的项目里新建一个目录,跟工具仓库完全分开:

feature-07-export-report/
├── nav-draft.spec.js       ← codegen 草稿(可选,04章)
├── record.spec.js           ← 操作代码,含内嵌解说文案(03/04章)
├── timeline.json             ← 录制时自动生成,不需要手写(03章3.3节)
├── recording.mov             ← 原始录像(05章)
├── subtitles.srt              ← 字幕(06章)
├── ai_dub.wav                ← AI 配音(06章)
├── meta.json                  ← 这个功能点的展示配置(09章9.4节)
└── feature-07-export-report.mp4   ← 最终成片

这个目录里的每一份文件,本书前面章节都已经讲过它的来源和用途;本章要讲的是这些目录本身怎么组织、放在哪。

2.3 为什么这样分层

  • 工具和数据分仓库/分目录:video-toolkit/ 变化频率低(加新命令、修 bug),功能点目录变化频率高(几乎每天都有新的)。分开之后,升级工具不会影响任何一个功能点的产物,回滚某个功能点的改动也不会影响工具本身。
  • RULE.md 承担"团队规范"的角色:字体、色调、语气基调、防泄露要求这些"所有功能点都要遵守"的规则,写在这一份文件里,AI 处理任何一个功能点时都会自动带上这份上下文,不需要每次都重新强调一遍(17 章 17.1 节会展开)。
  • meta.json 只管单个功能点的个性化配置:标题、副标题这类每个功能点必然不同的字段,和 RULE.md 的"全局规则"是两个层次,不要混在一起。

2.4 全局 CLI 配置

工具本身还有一层比 RULE.md 更底层的配置——运行 vt 命令时的默认参数(用哪个发音人、用哪个语音识别引擎),存放在用户主目录下,跟具体项目无关:

# ~/.config/video-toolkit/config
VIDEO_VOICE=zh-CN-XiaoxiaoNeural
VIDEO_VOICE_EN=en-US-AvaNeural
VIDEO_ASR=faster-whisper
ADMIN_PW=你的演示账号密码(本机专用,不提交进任何仓库)

这份文件不纳入任何版本控制——它是"这台机器上运行 vt 命令的默认习惯",换一台机器可以有不同的默认值,跟项目内容无关。命令行也可以用环境变量临时覆盖(比如 VT_RECORD_SCREEN 临时指定录制屏幕序号,05 章 5.7 节提到过这个手工兜底),优先级比配置文件更高。

2.5 命名规范

  • 功能点目录名:feature-<序号>-<英文短横线slug>,例如 feature-07-export-report,跟 09 章命令行操作时的参数保持一致(vt record feature-07-export-report)。
  • 产物文件名固定用本章 2.2 节列出的这几个约定名字(record.spec.js/timeline.json/subtitles.srt/ai_dub.wav/meta.json),不要自己发明别的命名——09 章的编排器(以及 vt status)靠这几个固定文件名判断流程进度,改了名字这套"文件即状态"的机制就失效了。
  • 最终成片文件名固定是目录名本身(<功能点目录名>.mp4),方便批量归档时一眼看出对应哪个功能点。

2.6 团队协作下的目录约定补充

功能点数量上来之后(几十上百个),建议在功能点数据的存放位置上再加一层业务模块分类,比如 reports/feature-07-export-report/、settings/feature-12-notification-rule/,而不是所有功能点平铺在同一层——这条约定在项目早期(功能点个位数)看起来是多余的,但规模上升后能显著降低维护成本,建议从第一天就这样组织。

下一章正式讲清楚一个功能点由哪几份文件描述——也就是 2.2 节列出的这几个文件各自的职责边界。

Part

核心能力

三份文件、录制、字幕配音、封面合成、命令行编排。

CHAPTER 03

03 一个功能点由哪几份文件描述

00 章说过,这套系统没有一份独立于代码之外的"需求文档"。一个功能点的完整描述,落在三份具体文件里,各自职责单一、互不越界。理解这三份文件的分工,是读懂后面所有章节的前提。

3.1 三份文件,三种职责

feature-07-export-report/
├── record.spec.js     ← 操作代码:Playwright 自动化脚本,做什么、点哪里
├── timeline.json       ← 叙事文本:第几秒说什么话(配合 record.spec.js 一起维护)
└── meta.json            ← 展示配置:标题、发音人、要不要封面/BGM、字幕样式
  • record.spec.js:一段真实的 Playwright 测试代码,负责"做什么"——打开哪个页面、点哪个按钮、等哪个元素出现,解说文案也是直接写在这段代码里的(04 章会展示具体写法)。04 章会讲它怎么来——多数时候是 AI 直接生成,只有 AI 判断不出真实选择器时才需要人工 codegen 一遍。
  • timeline.json:不是一份需要单独撰写的文件,而是运行 record.spec.js 时自动记录下来的副产品——脚本执行到每一句解说文案时,顺手把"现在是第几秒"记下来,写成这份文件。全程没有人(也没有 AI)需要盯着视频掐时间点,见 3.3 节。
  • meta.json:纯展示层的配置,不涉及任何操作逻辑——标题文案、发音人选择、是否要封面/封底/BGM、字幕字号颜色、导出分辨率。09 章会讲它的三级合并机制。

这三份文件为什么要分开,而不是塞进一份大的声明式配置(比如一份大 YAML)?因为它们的变更频率和变更方式完全不同:record.spec.js 只在 UI 改版时才需要动,改动方式是"重新录一遍或者修选择器";timeline.json 会被频繁调整措辞,改动方式是"编辑文本";meta.json 基本上项目初期定好之后很少再变。合在一起会导致改一处措辞就要碰一份大文件,也让"这处改动到底会不会影响录制"变得不清晰。

3.2 record.spec.js:操作代码本身就是"意图"

// feature-07-export-report/record.spec.js
const { test } = require('@playwright/test');

test('导出报表', async ({ page }) => {
  await page.goto(process.env.ADMIN_URL);
  await page.getByRole('link', { name: '报表' }).click();
  await page.getByLabel('时间范围').click();
  await page.getByText('最近30天').click();
  await page.getByLabel('数据维度').selectOption('按渠道');

  await page.getByRole('button', { name: '导出报表' }).click();
  await page.getByText('Excel').click();
  await page.getByRole('button', { name: '确认' }).click();
  await page.getByText('导出成功').waitFor({ state: 'visible', timeout: 15000 });
});

这就是"意图"的真正载体——不需要另外写一份"这个功能点做什么"的文字说明,操作代码本身就是最精确的描述。05 章会讲这段代码怎么和屏幕录制同步执行;这里先说清楚它从哪来:默认交给 AI 直接生成(如果 AI 能读到目标页面的前端代码,通常可以直接推断出正确的选择器和操作顺序,不需要人工先演示一遍);只有页面结构 AI 判断不准的情况下,才走 04 章讲的 codegen 流程——人工在浏览器里走一遍真实操作,把生成的选择器交给 AI 整理进这份脚本。实践中真正需要人工 codegen 的场景并不多。

3.3 timeline.json:录制的自动副产品,没有人会去手动维护它

[
  { "t": 0,   "text": "接下来,我们来看一下报表导出功能。" },
  { "t": 3,   "text": "首先进入报表页面,选择需要的时间范围和数据维度。" },
  { "t": 9,   "text": "点击右上角的导出按钮,会弹出导出格式选择。" },
  { "t": 14,  "text": "选择 Excel 格式并确认,页面会显示导出进度直到完成。" },
  { "t": 20,  "text": "报表导出功能介绍完毕,感谢观看。" }
]

这里的 t(时间点)既不是人写的,也不是 AI 猜的——它是 record.spec.js 真正跑起来的那一刻,脚本自己记下来的真实经过时间。回看 04 章 4.3 节清洗后的脚本结构,每一句解说文案是直接嵌在代码里的,类似这样调用:

function step(text) {
  const elapsed = (Date.now() - t0) / 1000; // 从录制开始到现在真实过了多少秒
  timeline.push({ t: elapsed, text });
  console.log(`[${elapsed}s] ${text}`);
}

// ... 操作到这一步,顺手记一笔
await page.getByRole('button', { name: '导出报表' }).click();
step('点击右上角的导出按钮,会弹出导出格式选择。');

也就是说,timeline.json 不是一份需要单独撰写、审阅或维护的文件,它是运行 record.spec.js 这一个动作自动产出的日志。人(甚至 AI)都不需要盯着视频回放去掐每一句话该出现在第几秒——那是传统人工做字幕最费时间的部分,这套系统从设计上就绕开了它:时间戳是脚本自己在真实执行过程中记下来的,天然精确,不存在"猜得准不准"的问题。

需要人写/AI 写的只有每一句解说文案的文字内容,也就是 step() 调用里传的那个字符串——这部分是 04 章讲的 codegen/AI 整理脚本时一起写进去的,写完之后这句话该出现在第几秒,不需要任何人关心,录一遍自然就有了。

如果某次录制根本没有用自动化脚本(比如 6.1 节提到的手动屏幕录制+语音识别兜底路径),就不需要这份文件——语音识别会直接从录屏音轨生成时间戳,效果上等价于自动生成了一份 timeline.json,同样不涉及任何人工计时。

3.4 meta.json:三级合并的展示配置

{
  "title": "报表导出功能",
  "subtitle": "一键导出 Excel,进度实时可见",
  "voice": "zh-CN-XiaoxiaoNeural",
  "cover": true,
  "outro": false,
  "bgm": false,
  "dub_offset": 0
}

这份文件只回答"这条视频看起来是什么样子",不回答"这条视频里发生了什么操作"——后者永远是 record.spec.js 的职责。它的合并规则(内置默认值 → 项目级 → 功能级)在 09 章 9.4 节详细展开,这里先记住一条原则:能在项目级统一的配置项(字体、字幕样式、要不要加公司 logo)不要在每个功能级文件里重复,功能级 meta.json 只写这一个功能点真正需要个性化的几项。

3.5 从"一句话意图"到这三份文件:实际工作流程

产品经理提供的"这个功能做什么",最终会分别流入这三份文件,但不是先写一份中间态的抽象需求文档再转换过去,而是直接在对应的文件里逐步细化。默认情况下,人工只在整个流程里有一个必然的检查点,而且是在录制之后,不是之前:

  1. AI 直接生成 record.spec.js(含每一步的解说文案):能读到目标页面代码时,AI 通常可以直接推断出选择器和操作顺序,不需要人工先演示。只有 AI 判断不准的少数场景,才用 04 章的 codegen 流程——人工在浏览器里走一遍,把生成的选择器交给 AI 整理进脚本。这一步不需要人工审阅脚本内容,直接进入下一步。
  2. 跑一遍冒烟测试,正式录制:vt record 跑完,recording.mov 和 timeline.json 都有了——后者是脚本运行时自动记下来的(3.3 节),不涉及任何人工或 AI 的"猜时间点"。冒烟测试报错就把报错转述给 AI 修,不需要人工自己排查。
  3. vt all 一次跑完 srt → dub → mix,出一版成片。到这一步为止,人还没有做任何编辑动作。
  4. 这里才是真正的人工检查点:打开 subtitles.srt,读一遍文案措辞。这份文件此时已经是真实的、带精确时间戳的字幕(时间戳来自第2步的自动记录,不需要碰),人工要看的只是文字本身读起来顺不顺、有没有需要换一种说法的地方——纯粹是文案措辞的检查,不涉及任何时间对齐工作,改完直接 vt redub 重新出片,几十秒搞定。如果这一遍看下来文案本身已经没问题,这一步甚至可以跳过,不是强制动作。
  5. meta.json 通常在项目初期写一次项目级默认值,之后每个新功能点只需要补标题这一类必填字段,不需要每次都重新设计。

这个流程里,人工唯一必然要做的事情是最后过一遍成片确认能不能发布(10 章);文案措辞检查是可选的质量优化,不是必需步骤;timeline.json 从始至终没有人碰过——如果这套系统被做成了"人要根据时间点去核对字幕对不对齐",那和传统的人工做字幕、人工对轴没有本质区别,AI 真正的价值恰恰是把这部分工作彻底从人的工作清单里拿掉,而不是换个界面让人继续做。

3.6 一个真实例子

脱敏后贴一份真实项目里的 meta.json 和 timeline.json,功能点是"IP 黑白名单":

// meta.json
{
  "type": "video",
  "title": "IP 黑白名单",
  "subtitle": "精确访问控制",
  "cover": true,
  "cover_duration": 3
}
// timeline.json(节选前两条)
[
  { "t": 11.7, "text": "打开管理控制台,进入配置管理→虚拟主机→server,这里的访问控制区域能设置 IP 地址黑白名单,还支持域名黑白名单和访问时间段控制。" },
  { "t": 32.9, "text": "把这台机器的回环地址 127.0.0.1 加入黑名单,保存之后立刻生效,不需要重启进程。" }
]

可以看到 meta.json 确实非常朴素——一个真实功能点往往只需要 title/subtitle/cover 这几个字段,绝大多数配置项都吃项目级默认值(09 章 9.4 节的三级合并)。timeline.json 里的文案也不是精心雕琢的营销文案,就是把操作讲清楚的大白话,这也印证了 18 章的观点:这类文案 AI 写起来毫无难度,不需要人工反复打磨。

下一章展开讲 record.spec.js 里的选择器从哪来、怎么保证长期稳定——也就是 codegen 这一步的完整方法论。

CHAPTER 04

04 浏览器自动化与 Codegen

03 章说过,record.spec.js 默认由 AI 直接生成,只有 AI 判断不准页面结构时才需要走本章讲的 codegen 流程——这是全书里人工介入最少、而且往往可以完全跳过的一环。本章讲清楚这条兜底路径怎么走:把人手动操作浏览器一遍的过程,转成可以被程序反复、稳定重放的自动化脚本。

4.1 Playwright Codegen 的工作原理

npx playwright codegen <url> 会启动一个真实的 Chromium 实例,并注入一段监听脚本,捕获你在页面上的每一次点击、输入、选择、导航,实时生成对应的 Playwright API 调用代码,显示在旁边的 "Playwright Inspector" 窗口里。它本质上是一个"操作 → 代码"的实时翻译器,翻译规则大致是:

  • 点击一个按钮 → await page.getByRole('button', { name: '导出' }).click();
  • 输入文本 → await page.getByLabel('用户名').fill('demo');
  • 下拉选择 → await page.getByLabel('格式').selectOption('excel');
  • 页面跳转 → 自动插入 await page.waitForURL(...) 或什么都不插(取决于是否是 SPA 内部路由)

选择器优先级上,Playwright codegen 会尽量选择"面向用户可见语义"的定位方式(role、label、text),而不是脆弱的 CSS class/xpath——这一点非常重要,直接决定了脚本在下次 UI 微调后还能不能跑,4.4 节会展开讲。

4.2 录制会话的标准操作流程

# 启动 codegen,指定输出文件、初始视口,直接对着目标系统录
npx playwright codegen \
  --target javascript \
  --viewport-size=1440,900 \
  --output=feature-07-export-report/nav-draft.spec.js \
  https://staging.example.com/login

操作建议(这次会话产出的是整个功能点从头到尾的一份完整草稿,不是切成一小段一小段分别录):

  1. 一次会话走完整个功能点的操作路径:登录 → 打开报表页 → 选时间范围/维度 → 点导出 → 选 Excel → 确认。中间不要关闭 codegen 窗口再重开,保持一条连续的操作序列,因为最终 record.spec.js 也是一份连续脚本,不是拼接起来的分段文件。
  2. 如果登录这类前置操作在多个功能点之间完全一致,可以把这部分单独抽成一个共享函数(见 4.5 节),但仍然是在同一次 codegen 会话里先走一遍,产出草稿之后再手动把登录部分替换成对共享函数的调用,而不是提前单独录一份登录专用脚本。
  3. 不需要刻意保持动作"干净"——随便点、走错了退回来重试都没关系,误点、悬停、来回切换标签页这些杂质都会被原样录进草稿,但下一步整理时会被 AI 自动清理掉,人不需要在录制这一步小心翼翼。

这次会话产出的 nav-draft.spec.js 只是一份选择器草稿,下一步是把里面的选择器整理进正式的 record.spec.js——这一步交给 AI 完成(17 章会给出具体方法),AI 会自动过滤掉草稿里的误操作、补上必要的等待条件,产出可以直接使用的正式脚本,人工不需要逐行编辑。

4.3 清洗生成的脚本

Codegen 生成的原始代码是"能跑但不干净"的,直接拿去录制正式视频通常有三类问题需要处理——这些清洗工作在 sync(AI 整理草稿)这一步由 AI 完成,这里讲清楚 AI 具体在处理什么,方便你判断它做得对不对:

问题一:多余的等待/断言。Codegen 有时会插入一些调试用的 expect() 断言或不必要的 waitForTimeout,这些要么直接删掉,要么替换成基于真实页面状态的显式等待(比如等某个文案出现),写法见下面的清洗示例。

问题二:硬编码的绝对时间等待,比如 await page.waitForTimeout(1500)。这类等待在真实网络环境下可能不够(页面卡顿导致按钮还没出现就点了)也可能过多(白白拉长录制时间且不受配音节奏控制)。应该统一替换成基于状态的等待:

// 清洗前(codegen 原始产出)
await page.click('#export-btn');
await page.waitForTimeout(1500);
await page.click('text=Excel');

// 清洗后
await page.getByRole('button', { name: '导出' }).click();
await page.getByText('Excel').waitFor({ state: 'visible' });
await page.getByText('Excel').click();

问题三:脆弱选择器。如果 codegen 因为页面缺少语义化标签(没有 aria-label、按钮用 <div onclick> 实现)而退化生成了 page.locator('.css-3xk2j9') 这种基于自动生成 class 的选择器,要么推动前端加上语义化属性(长期最优解),要么在清洗脚本时替换为更稳定的替代定位方式,比如相对文本、相对父容器结构。这一步做得好坏,直接决定这套流水线在产品持续迭代下的"保质期"。

清洗后的 record.spec.js 是一份连续脚本,各个业务动作之间该停留多久,AI 会按操作的重要程度给出一个初始值(想让观众多看一眼某个界面,就多留一点 waitForTimeout),首次录出来看效果不对,直接告诉 AI"这一步停留太短/太长",让它调整脚本里的数值,不需要人工自己算:

// feature-07-export-report/record.spec.js(清洗后的片段)
await page.getByRole("link", { name: "报表" }).click();
await page.waitForURL("**/reports");
await page.getByLabel("时间范围").click();
await page.getByText("最近30天").click();
await page.getByLabel("数据维度").selectOption("按渠道");
await page.waitForTimeout(1500); // 留给观众看清页面筛选结果,纯粹凭观感决定的停留

await page.getByRole("button", { name: "导出报表" }).click();
await page.getByText("Excel").waitFor({ state: "visible" });
await page.getByText("Excel").click();

4.4 选择器稳定性策略

功能演示视频要长期维护,最大的隐性成本是"UI 一改脚本就断"。几条硬性原则:

  1. 优先级:getByRole > getByLabel > getByText > data-testid > CSS 选择器。前三种基于用户可感知的语义,UI 视觉改版(换个颜色、挪个位置)通常不影响它们;data-testid 需要研发配合埋点但非常稳定;纯 CSS 选择器/xpath 是最后手段。
  2. 如果条件允许,推动研发团队给关键交互元素加 data-testid。这是一次性投入,换来的是自动化脚本对视觉改版免疫。这一点值得写进团队的前端开发规范里,而不是每次录制前才发现选择器全挂了。
  3. 避免基于绝对位置/索引的选择器(如 nth-child(3)),列表顺序一旦变化就会点错行。
  4. 对每个清洗后的脚本片段,建立一个轻量"冒烟测试"(见 4.6 节),在正式录制前先跑一遍,快速发现选择器失效,而不是等到合成阶段才发现某一步的视频是空的。

4.5 可复用片段库

像"登录""关闭引导弹窗""切换到某个工作区"这类几乎每个功能点视频都要用到的前置操作,应该沉淀成一个共享函数,供各个 record.spec.js 直接 require:

// common/login.js
async function login(page, username, password) {
  await page.goto(process.env.ADMIN_URL);
  await page.getByLabel("用户名").fill(username);
  await page.getByLabel("密码").fill(password);
  await page.getByRole("button", { name: "登录" }).click();
  await page.waitForURL("**/dashboard");
}
module.exports = { login };
// feature-07-export-report/record.spec.js
const { login } = require("../common/login");

test("导出报表", async ({ page }) => {
  await login(page, process.env.DEMO_USERNAME, process.env.DEMO_PASSWORD);
  // ... 后续操作
});

4.6 录制前先跑一遍冒烟检查

正式录制(05 章)会同时启动屏幕录制和浏览器自动化,一旦 record.spec.js 里的选择器失效,浪费的不只是重新调试的时间,还有一段已经录废的视频。稳妥的做法是在真正触发录制之前,先用 headless 模式把整个脚本跑一遍:

npx playwright test record.spec.js --headed=false

跑通再进入 05 章的正式录制流程;跑不通就先按 17 章讲的方法把报错交给 AI 处理,不要带着还会报错的脚本去做真正的录制。

4.7 每一步该停留多久:先估算,跑过一次就用实测值锁定

00 章强调"录制优先",但这不代表脚本完全不管解说文案有多长——record.spec.js 里每一步操作完之后要停留多久,需要一个合理的初始值,否则第一次录出来要么画面一晃而过、要么傻等半天。实践中用的是一个"先估算、后锁定"的两阶段方法:

第一阶段:按字数估算。中文按每秒约 4~4.5 个字、其他字符(数字/英文/标点)按更快的语速折算,估算出这句解说词大致要读多久:

function estimateHoldMs(text) {
  const zhChars = (text.match(/[一-龥]/g) || []).length;
  const otherChars = text.length - zhChars;
  const estSec = zhChars / 4.2 + otherChars / 12;
  return Math.max(2000, estSec * 1000 + 1000); // 留1秒缓冲,最短2秒
}

这个估算值是第一次录制时用的停留时长,不需要多精确,能大致对上就行。

第二阶段:录过一次、生成过配音之后,把实测时长写死回脚本。06 章配音生成之后,ffprobe 能拿到每一句配音的精确时长——把这个真实值收集起来,做成一张"文案 → 实测毫秒数"的对照表,塞回 record.spec.js:

// 实测配音时长(edge-tts zh-CN-XiaoxiaoNeural),停留时间 = 实际配音时长 + 1s
const REAL_DUR_MS = {
  "打开管理控制台,进入配置管理→虚拟主机→server,这里的访问控制区域能设置 IP 地址黑白名单...": 13968,
  "把这台机器的回环地址 127.0.0.1 加入黑名单,保存之后立刻生效,不需要重启进程。": 9000,
  // ...
};

function narrationHoldMs(text) {
  if (REAL_DUR_MS[text] != null) return REAL_DUR_MS[text] + 1000;
  return estimateHoldMs(text); // 表里没有(新文案)时,退回估算值
}

这样处理之后,这个功能点后续每一次重新录制,停留时长都精确对应真实配音时长,不再是粗略估算;如果解说文案改了(表里查不到对应的实测值),自动退回第一阶段的估算逻辑,不会报错或者卡住。这个表随着文案迭代逐步更新,旧的条目留着也没有副作用(只是不再被命中)。

为什么这样做,而不是每次录制前都先跑一遍 TTS 拿精确时长:先合成音频再决定录制节奏,意味着录制必须等配音生成完成之后才能开始,两个环节被强耦合在一起,改一个字的文案就要重新走一遍"合成音频→量时长→再录制"的完整链路。而"估算+事后锁定"把这个耦合解开了:录制永远可以立刻开始(用估算值兜底),精确对齐是录过一次之后自然获得的副产品,不阻塞第一次录制的启动速度。

4.8 codegen 层的产物边界

到本章结束,一个功能点目录下应该有:

  • 一份清洗过的 record.spec.js(03 章已经展示过完整例子)
  • 如果有跨功能点复用的前置操作(登录等),额外一个 common/ 目录下的共享函数

这份脚本只包含操作逻辑本身,该等多久、什么时候截图、什么时候说话,都直接写在脚本里,不依赖任何外部配置驱动——03 章已经解释过为什么这三份文件(record.spec.js/timeline.json/meta.json)要保持这样的职责边界。

下一章讲这份脚本如何和屏幕录制同步执行,产出一条完整的原始录像。

CHAPTER 05

05 屏幕与浏览器录制

04 章产出了可重放的操作脚本,本章解决"重放这些操作的同时,如何把画面录成视频文件"。这里有两条技术路线,实践中通常两条都要用到,各自负责不同场景。

5.1 两条录制路线的取舍

路线一:Playwright 内置的 recordVideo。浏览器上下文(BrowserContext)创建时声明录制选项,Playwright 会在浏览器进程内部把渲染帧编码成视频文件,不依赖操作系统级的屏幕采集。

  • 优点:不需要处理系统权限(01 章 1.9 节的屏幕录制授权);分辨率/帧率完全由代码控制,不受实际显示器状态影响;可以 headless 运行(适合放到 CI/云端无人值守批量录制);天然只包含浏览器视口内容,不会误录到其他窗口/桌面通知。
  • 缺点:只能录浏览器视口内容,一个功能点如果既要演示浏览器操作又要展示终端命令行(很常见),recordVideo 覆盖不了后者;鼠标指针默认不可见(真实用户鼠标移动不会被渲染进视频),需要额外注入自定义光标。

路线二:系统级录屏(ffmpeg + 平台原生采集设备)。直接捕获物理/虚拟显示器的画面,Playwright 用有头模式(headed)正常打开真实浏览器窗口,ffmpeg 在旁边同步把整个屏幕录下来。

  • 优点:能录任何画面,浏览器和终端切换全部自然覆盖,不用区分"这一段是浏览器内容还是桌面内容";鼠标指针是真实系统指针,天然可见,不需要额外注入任何自定义光标层;不管这次操作是自动化脚本驱动的还是人工手动操作的,录制方式完全一样。
  • 缺点:依赖系统权限(01 章 1.9 节);分辨率/清晰度受实际显示器状态影响,多显示器/高 DPI 环境下配置更复杂;headless 服务器上需要虚拟显示器(Xvfb)模拟。

结论:实践中默认用路线二(系统级录屏),因为一个功能点几乎不需要提前判断"这段是不是纯浏览器内容"——不管是纯浏览器操作、纯终端操作,还是两者混合,系统级录屏用同一套机制就能覆盖,录出来是一条连续的原始录像,不需要在录制层面区分和切换路线。路线一(recordVideo)更适合另一种场景:只需要验证浏览器操作本身能不能跑通、不追求画面质感的无头批量冒烟测试(比如 04 章 4.6 节的冒烟检查),不是用来产出正式成片的。

5.2 路线一实现:Playwright recordVideo

这条路线只用于 4.6 节的无头冒烟检查,不产出正式成片,所以不需要挂进 lib/ 那套 bash 编排里,写成一段独立的 Playwright 辅助脚本即可:

// smoke/browser-recorder.ts(仅供无头冒烟检查使用,与 lib/ 下的正式编排脚本无关)
import { chromium, Browser, BrowserContext, Page } from "playwright";
import path from "path";

export interface BrowserRecordingSession {
  browser: Browser;
  context: BrowserContext;
  page: Page;
  videoDir: string;
}

export async function startBrowserRecording(opts: {
  baseUrl: string;
  viewport: { width: number; height: number };
  videoDir: string;
  headless?: boolean;
}): Promise<BrowserRecordingSession> {
  const browser = await chromium.launch({ headless: opts.headless ?? true });
  const context = await browser.newContext({
    viewport: opts.viewport,
    recordVideo: {
      dir: opts.videoDir,
      size: opts.viewport, // 录制分辨率与视口保持一致,避免缩放模糊
    },
  });

  // 注入一个可见的自定义鼠标指针层,弥补 recordVideo 不渲染真实鼠标指针的缺陷
  await context.addInitScript(() => {
    const cursor = document.createElement("div");
    cursor.id = "__idrp_cursor__";
    Object.assign(cursor.style, {
      position: "fixed",
      zIndex: "2147483647",
      width: "18px",
      height: "18px",
      borderRadius: "50%",
      background: "rgba(255, 90, 0, 0.55)",
      border: "2px solid rgba(255,255,255,0.9)",
      pointerEvents: "none",
      transform: "translate(-50%, -50%)",
      transition: "left 0.08s linear, top 0.08s linear",
      left: "0px",
      top: "0px",
    });
    document.addEventListener("DOMContentLoaded", () => {
      document.body.appendChild(cursor);
      document.addEventListener("mousemove", (e) => {
        cursor.style.left = e.clientX + "px";
        cursor.style.top = e.clientY + "px";
      });
    });
  });

  const page = await context.newPage();
  await page.goto(opts.baseUrl);

  return { browser, context, page, videoDir: opts.videoDir };
}

export async function stopBrowserRecording(session: BrowserRecordingSession): Promise<string> {
  const videoPath = await session.page.video()?.path();
  await session.context.close();
  await session.browser.close();
  if (!videoPath) throw new Error("录制未生成视频文件,检查 recordVideo 配置");
  return videoPath; // 返回生成的 .webm 文件路径
}

关键点说明:

  • recordVideo.size 显式指定和 viewport 一致,否则 Playwright 会按默认逻辑缩放,可能导致清晰度下降。
  • 自定义鼠标指针通过 addInitScript 在每个页面加载前注入,用 CSS transition 做平滑跟随,视觉上比"瞬移"更接近真实录屏效果,这是弥补 headless 无法看到真实光标的常见技巧。
  • 真实的 Playwright 鼠标移动 API 是 page.mouse.move(x, y),如果 04 章清洗后的脚本里用的是 locator.click() 这种高层 API,Playwright 内部会自动移动虚拟鼠标到目标元素再点击,mousemove 事件依然会正常触发,因此上面注入的指针跟随逻辑对通过 .click() 触发的操作同样生效。

产出的 .webm 文件在 08 章会被 ffmpeg 转码/拼接,此处不需要关心格式转换。

5.3 路线二实现:系统级录屏

macOS(avfoundation):

# 先列出可用的采集设备(屏幕/摄像头/麦克风),确认屏幕设备的索引号
ffmpeg -f avfoundation -list_devices true -i ""

# 假设屏幕是索引 1,不采集麦克风音频(配音在06章单独生成,录屏本身不需要真实拾音)
ffmpeg -f avfoundation -capture_cursor 1 -capture_mouse_clicks 1 \
  -framerate 30 -i "1:none" \
  -vcodec libx264 -pix_fmt yuv420p -preset veryfast -crf 18 \
  work/feature-07-export-report/raw/terminal-segment.mp4
  • -capture_cursor 1 显式让 ffmpeg 把系统鼠标指针渲染进画面,这是路线二相对路线一的天然优势。
  • -capture_mouse_clicks 1 会在点击处画一个短暂高亮圈,进一步提升观众可读性(对应 00 章里"意图驱动"的可理解性目标)。
  • 用 Ctrl+C 结束录制,实践中编排器会用 Node.js 的 child_process.spawn 启动这个 ffmpeg 进程,并在步骤结束时发送 SIGINT 优雅终止(直接 kill -9 会导致 mp4 文件尾部损坏无法播放)。

Linux(x11grab,含 Xvfb 虚拟显示器,用于无头服务器批量录制):

# 启动一个虚拟显示器,编号 :99,分辨率与录制脚本里设置的 viewport 一致
Xvfb :99 -screen 0 1440x900x24 &
export DISPLAY=:99

# 在这个虚拟显示器上跑浏览器/终端操作(此时路线一的 headless:false 也可以在这个虚拟显示器里运行)
ffmpeg -f x11grab -video_size 1440x900 -framerate 30 -i :99.0 \
  -vcodec libx264 -pix_fmt yuv420p -preset veryfast -crf 18 \
  work/feature-07-export-report/raw/terminal-segment.mp4
  • Xvfb 环境下真实鼠标指针默认不显示,需要额外用 unclutter -root & 保证指针不因静止而被系统隐藏,或者用合成的方式(比如驱动一个真实的 xdotool mousemove 来源)保证光标可见——这是 Linux 路线二相比 macOS 更繁琐的地方,如果条件允许优先在 macOS 上完成正式录制,Linux/Xvfb 方案留给 CI 里做"冒烟级"验证性质的录制(确认流程能跑通,不追求画面质感)。

lib/ 里对 ffmpeg 屏幕采集进程的封装:

# lib/screen_recorder.sh
start_screen_recording() {
  local output_path="$1" fps="$2" device_index="$3" platform="$4"

  if [ "$platform" = "darwin" ]; then
    ffmpeg -f avfoundation -capture_cursor 1 -capture_mouse_clicks 1 \
      -framerate "$fps" -i "${device_index}:none" \
      -vcodec libx264 -pix_fmt yuv420p -preset veryfast -crf 18 \
      "$output_path" &
  else
    ffmpeg -f x11grab -video_size "$VIEWPORT_SIZE" \
      -framerate "$fps" -i "$device_index" \
      -vcodec libx264 -pix_fmt yuv420p -preset veryfast -crf 18 \
      "$output_path" &
  fi
  echo $!  # 返回 ffmpeg 进程号,供 stop_screen_recording 使用
}

stop_screen_recording() {
  local ffmpeg_pid="$1"
  kill "$ffmpeg_pid" 2>/dev/null || true
  wait "$ffmpeg_pid" 2>/dev/null || true  # 等它把文件尾写完整,不要直接丢下不管
}

停止时用 kill 发信号给 ffmpeg 进程、再 wait 它退出,而不是直接杀掉不管——让 ffmpeg 有机会把文件尾(moov atom 等索引信息)写完整,避免产出无法播放的损坏文件。

5.4 混合场景:浏览器和终端切换,不需要特殊处理

因为路线二(系统级录屏)录的是整个屏幕,一个功能点里如果既有浏览器操作又有终端命令行演示,不需要在脚本里做任何特殊标记去区分"这一段走哪条路线"——record.spec.js 该怎么操作浏览器就怎么操作,需要展示终端的地方,脚本可以调用 child_process 切到一个提前准备好的终端窗口执行命令,ffmpeg 全程录的是同一块屏幕,画面自然跟着窗口焦点切换。05.5 节讲的终端录制注意事项(清干净历史命令、提前准备好别名)在这种混合场景下同样适用。

正因为不需要按"路线"拆分处理,00 章 0.3 节的架构图里"Record 层"只有一步,产出的也是一条连续的原始录像,不是多段视频文件——03 章已经强调过这一点:record.spec.js 是一份连续脚本,不是拼接起来的分段片段。

5.5 终端录制的特殊处理

如果某个功能点确实需要展示命令行操作过程(比如用户既有实践中"先 cd 到项目目录再执行别名命令"这种习惯的通用道理是:提前准备好终端环境和别名,录制时只敲简短命令,不要在镜头前现场调试),实践建议:

  1. 提前打开一个专用的终端窗口/标签页,设置好合适的字号(录制分辨率下要保证文字清晰可读,参考 01 章 1.6 节确认的分辨率)、配色主题(浅色/深色需要和整体视频风格统一,见 07 章品牌规范)。
  2. 提前把要用到的长命令封装成简短的 shell 别名或脚本,录制时只敲短命令,避免因为打字速度不均匀导致画面节奏被打乱。
  3. 录制这一段的窗口只保留这一个终端窗口置顶,并且提前清空历史 scrollback,避免露出之前调试时的敏感信息或者杂乱输出(这一条会在 10 章的检查清单里再次出现,是防泄露的重点场景之一)。
  4. 录制完成后,若这个终端窗口是专门为录制新建的,编排器/操作者应该在该步骤结束后关闭它,不要让它常驻,避免历史命令、环境变量残留带来后续误用或信息暴露的风险。

5.6 产物命名约定

本章的输出就是一个功能点目录下的一份文件:

feature-07-export-report/recording.mov

这是一条从开始操作到结束的完整连续录像,不做任何切分。封面/封底属于 07/08 章的静态素材合成范畴,不在这一步产出。

5.7 让系统级录屏真正做到无人值守:四个容易被忽略的工程细节

5.3 节给出的系统级录屏实现是"能跑"的最小版本,但要让它在没有人盯着的情况下稳定、安全地批量运行,还需要处理四类问题。这些问题不解决,轻则偶尔录出损坏文件需要重录,重则像下面第一条这样,真实录到不该出现的画面。

问题一:录屏启动和自动化操作之间的时序空档,是最容易被忽视的泄露入口。5.3 节的实现里,startScreenRecording 和后续开始操作之间如果直接顺序执行,中间会有一个短暂空档——ffmpeg 进程已经启动、但浏览器可能还没有导航到目标页面、还没有切到前台。这个空档期间屏幕上显示的是别的窗口(可能是你的代码编辑器、聊天软件、上一次调试留下的终端),如果这段时间恰好被 ffmpeg 录了进去,就是一次真实的敏感信息泄露,而且发生在录制的最开头,很容易被忽略着直接进入后续合成流程。

解决方法是用跨进程的双信号握手,而不是简单的顺序执行加延时等待:

  1. 自动化脚本(Playwright)在完成"打开浏览器、导航到目标页面、窗口切到前台"这一整套准备动作后,写一个 ready 标记文件(或发一个进程间信号),表示"现在开始录屏是安全的"。
  2. 外层负责启动 ffmpeg 的编排逻辑,轮询等待这个 ready 标记出现之后,才真正启动 ffmpeg 录屏进程。
  3. ffmpeg 启动后需要一小段时间才能稳定写帧(通常一秒左右),编排逻辑等待这个稳定期过后,写一个 go 标记文件。
  4. 自动化脚本轮询等到 go 标记出现,才开始执行真正的业务操作序列。
# lib/handshake.sh
write_flag() {
  echo "$(date +%s)" > "$1"
}

wait_for_flag() {
  local flag_path="$1" timeout_s="${2:-60}" waited=0
  while [ ! -f "$flag_path" ]; do
    sleep 0.2
    waited=$(echo "$waited + 0.2" | bc)
    if [ "$(echo "$waited > $timeout_s" | bc)" = "1" ]; then
      echo "等待信号文件超时: $flag_path" >&2
      return 1
    fi
  done
}

record.spec.js(Playwright 侧)和 video-toolkit.sh(编排器侧)各自轮询这两个标记文件,调用顺序变成:自动化脚本准备就绪 → 写 ready 标记 → 编排器等到 ready 出现后启动 ffmpeg → 等待约1秒让编码稳定 → 写 go 标记 → 自动化脚本等到 go 出现后才开始操作。这样"录屏开始"和"操作开始"之间不再依赖猜测的固定延时,而是由双方各自确认"我这边真的准备好了"来驱动,从工程上彻底消除了 5.3 节实现里潜在的时序空档。

问题二:多显示器环境下,录屏设备序号不是稳定的。macOS 的 avfoundation 给每块屏幕分配的采集设备序号,会在外接显示器插拔后重新编号——如果编排器把序号硬编码写死,接了外接屏之后完全可能录错屏幕(把外接屏内容录了进去,而实际操作发生在内置屏上,或者反过来)。稳妥的做法是每次录制前动态探测:枚举所有 avfoundation 采集设备,用系统显示器信息(分辨率、是否为主屏)逐一比对,找出真正对应内置/主屏的那个序号,而不是假设序号永远不变。这也是本书前言部分提到的"接外接屏后录制设备错位"这类问题的根本解法——探测一次、每次录制都重新探测,而不是配置一次就固定下来。

问题三:录制环境本身要在每次录制前主动清理,而不是假设它一直是干净的。5.5 节讲的终端窗口整洁,实践中应该做成录制流程里自动执行的一步,而不是仅仅写在人工检查清单里靠自觉执行:录制前自动关闭上一次残留的专用录制窗口、清除"下次启动恢复上次窗口"这类系统级的自动恢复行为(这类行为存在的意义是方便日常使用,但会导致录制窗口越攒越多,多个窗口边缘重叠会让画面显示错乱)、清空录制专用的历史命令文件(对应 10.6 节场景三提到的历史命令隔离)。把这些清理动作写成录制启动前自动执行的固定步骤,比指望"每个人每次都记得手动清理"可靠得多。

问题四:录制产物要做完整性校验,损坏文件不能悄悄流入后续环节。视频文件启动写入后如果录制进程被非正常终止(比如 ffmpeg 被强制杀死而不是优雅停止),常见后果是文件缺少正确的索引信息(moov atom),这样的文件在很多播放器/后续处理工具里表现为完全无法读取,但如果不主动校验,这个问题可能要等到 08 章合成阶段才会报错,那时候往往已经晚了——最好的做法是在 05 章录制结束的那一刻立刻用 ffprobe 校验一次产物完整性,发现问题立刻标记该分段需要重录,而不是让损坏文件带着"看似录制成功"的假象进入后续流程。同时,录制开始前也要检查并清理上一次异常退出可能残留的孤儿 ffmpeg 进程(比如通过进程名+目标文件路径匹配查找),避免新旧两个录制进程同时写入同一个文件导致的冲突。

这四类问题共同的特点是:它们都不是"功能实现"层面的问题,而是"长期无人值守运行的健壮性"层面的问题。5.1~5.6 节的实现能让你完成第一条视频的录制;本节这四个细节,才是让这套录制能力真正达到"完全自动化、可以放心批量跑"的关键。

5.8 三个让录制画面更专业的实践细节

细节一:把自动化用的浏览器换一个"皮"。Playwright 默认驱动的是它自己下载的 Chromium(进程名/菜单栏显示"Google Chrome for Testing"),直接拿去录制正式演示视频,任务栏和关于页面会露出这个开发测试用的身份,不够专业。做法是从 Playwright 的浏览器缓存目录里复制一份 Chromium,重命名可执行文件、改 Info.plist 里的 CFBundleExecutable/CFBundleName/CFBundleDisplayName,再重新做一次 ad-hoc 签名(改过身份信息后原签名会失效,必须重签,本地录制不需要开发者证书):

CACHE_DIR="$HOME/Library/Caches/ms-playwright"
SRC=$(find "$CACHE_DIR" -maxdepth 3 -iname "Google Chrome for Testing.app" -print -quit)
DEST="./演示助手.app"
NEW_NAME="演示助手"

ditto "$SRC" "$DEST"
mv "$DEST/Contents/MacOS/Google Chrome for Testing" "$DEST/Contents/MacOS/$NEW_NAME"
plutil -replace CFBundleExecutable -string "$NEW_NAME" "$DEST/Contents/Info.plist"
plutil -replace CFBundleName -string "$NEW_NAME" "$DEST/Contents/Info.plist"
plutil -replace CFBundleDisplayName -string "$NEW_NAME" "$DEST/Contents/Info.plist"
codesign --force --deep --sign - "$DEST"

之后在 playwright.config.js 里把 executablePath 指向这个改名后的 app,录制出来的画面里,Dock、菜单栏、Cmd+Tab 切换窗口时看到的都是自定义的名字,不会露出底层用的是 Playwright/Chromium。升级 Playwright 版本后 Chromium 会更新,重跑一遍这个脚本覆盖生成即可。

细节二:需要"双重视角"验证效果时,开两个标签页分别承担不同角色。比如演示一个访问控制类的功能——一个标签页始终停留在管理后台(负责改配置),另一个专门的标签页用来模拟真实业务请求(验证配置是否生效),两者不要来回复用同一个标签页导航切换,而是各自固定角色:

let verifyPage = null;
async function ensureVerifyPage(context) {
  if (verifyPage && !verifyPage.isClosed()) return verifyPage;
  verifyPage = await context.newPage();
  return verifyPage;
}
// 管理后台一直用 page,验证效果切到 verifyPage,互不干扰,画面切换也更容易让观众看懂
// "这是两个不同的视角",而不是同一个页面来回刷新

这样录出来的画面里,"改配置"和"看效果"是两个清晰分离的标签页,观众更容易理解因果关系,也避免了在同一个标签页里反复导航导致的画面跳动。

细节三:单个步骤操作失败不要让整条录制中断。自动化脚本跑几分钟,中间某一步偶发性地找不到元素(网络慢、动画还没播完),如果直接抛异常中断,前面几分钟的录制就全部作废。更稳妥的做法是给每个操作步骤包一层容错,失败了记录日志、跳过这一步的具体操作,但仍然继续播放对应的解说、往下走:

async function step(page, narration, action, holdMs) {
  try {
    await action();
  } catch (e) {
    console.log(`⚠️ 步骤异常,跳过具体操作: ${e.message.split("\n")[0]}`);
  }
  // 即使操作失败,解说和停留照常进行——录制不因单点故障整体作废,
  // 顶多这一步画面效果不完美,人工审片时能发现,比整条重录成本低得多
  narrate(narration);
  await sleep(holdMs);
}

这不是鼓励"忽略错误",偶发失败仍然应该被日志记录下来、纳入审片时的重点检查项;但对于一个要跑几分钟的连续录制来说,"单步降级、整体不崩"比"任何异常都中断重来"在工程上更划算。

下一章讲录制之后的字幕来源和配音生成。

CHAPTER 06

06 录制之后:字幕来源与自然语速配音

00 章 0.4 节已经把核心结论摆出来了:先有录像和字幕的时间戳,配音是后贴上去的。本章把这个结论落到具体实现:字幕从哪来、配音怎么按字幕分段生成、怎么处理生成结果和原时间轴的微小误差,以及为什么这套做法能支撑"改一个字、几十秒出新片"的迭代速度。

6.1 字幕来源:时间点文本优先,语音识别兜底

录制完成后,第一件事是拿到一份 subtitles.srt。这份文件有两个可能的来源,系统会自动判断该用哪一个:

来源一:录制脚本自带的时间点文本(首选,最准确)。如果这次录制是由自动化脚本(04/05 章)驱动的,脚本本身在执行每一步操作时,就知道"现在是第几秒、这一步该说什么"——这份时间点文本(记录成 timeline.json 这样的结构:[{ "t": 3.2, "text": "..." }, ...])在录制过程中或录制结束后,直接转换成标准 SRT 格式即可,不需要任何语音识别,因为文本本身就是原文,不是猜出来的。

# scripts/timeline_to_srt.py
import json, sys

def to_srt_time(t: float) -> str:
    h, rem = divmod(t, 3600)
    m, s = divmod(rem, 60)
    ms = int((s - int(s)) * 1000)
    return f"{int(h):02d}:{int(m):02d}:{int(s):02d},{ms:03d}"

def timeline_to_srt(timeline_path: str, srt_path: str) -> None:
    timeline = json.load(open(timeline_path))
    lines = []
    for i, item in enumerate(timeline):
        start = item["t"]
        end = timeline[i + 1]["t"] if i + 1 < len(timeline) else start + 5
        lines.append(f"{i + 1}\n{to_srt_time(start)} --> {to_srt_time(end)}\n{item['text']}\n")
    open(srt_path, "w").write("\n".join(lines))

if __name__ == "__main__":
    timeline_to_srt(sys.argv[1], sys.argv[2])

来源二:语音识别转写(兜底,用于非脚本驱动的手动录制)。如果只是打开系统自带的屏幕录制工具,人工手动操作并对着话筒讲解一遍(这是最快的起步方式,完全不需要写自动化脚本),录出来的视频音轨里有真实的人声,这时候用本地语音识别模型(faster-whisper / SenseVoice 等)转写出字幕:

# scripts/asr_to_srt.py
import sys
from faster_whisper import WhisperModel

def asr_to_srt(audio_path: str, srt_path: str) -> None:
    model = WhisperModel("small", device="cpu", compute_type="int8")
    segments, _ = model.transcribe(audio_path, language="zh")

    def fmt(t: float) -> str:
        h, m, s = int(t // 3600), int((t % 3600) // 60), int(t % 60)
        ms = int((t % 1) * 1000)
        return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"

    with open(srt_path, "w") as f:
        for i, seg in enumerate(segments, 1):
            f.write(f"{i}\n{fmt(seg.start)} --> {fmt(seg.end)}\n{seg.text.strip()}\n\n")

if __name__ == "__main__":
    asr_to_srt(sys.argv[1], sys.argv[2])

两条路径怎么选:判断逻辑很简单——如果 subtitles.srt 已经存在且比 recording.mov 新(说明是上一步从时间点文本生成的,是可信来源),就跳过语音识别,不要覆盖它;否则才跑语音识别兜底。这个先后关系很重要,因为录屏音轨往往没有真人说话(纯自动化操作是静音的),对着静音或者杂音跑语音识别只会得到一堆垃圾字幕。

if [ -f subtitles.srt ] && [ subtitles.srt -nt recording.mov ]; then
  echo "字幕已存在且比录屏新(来自时间点文本),跳过语音识别"
else
  python3 scripts/asr_to_srt.py recording.mov subtitles.srt
fi

6.2 按字幕分段生成配音:自然语速,不做时长强制拉伸

拿到 subtitles.srt 之后,逐条字幕调用 TTS 合成语音,每一段都用它自然的语速生成,不去反向拉伸或压缩音频时长去匹配字幕原定的时间窗口。这是和"音频先行"思路最大的实现差异——那种思路会强迫音频时长等于预设窗口,这里恰恰相反:音频多长就是多长,靠"下一段的起始间隔"去自然消化差异。

# scripts/srt_to_dub.py
import re, subprocess, tempfile, os, sys

# edge-tts 拿不到 SSML <phoneme> 标签的官方支持(会被转义成字面文本整段读出来),
# 遇到多音字读错,唯一可靠的办法是换一种不触发歧义读音的措辞。
# 这份"问题词 → 安全替换词"表一次维护,对所有视频生效。
POLYPHONE_FIXES = {
    "命令行工具": "命令行",  # "行工具"这个搭配下偶尔把"行"读成 xíng,去掉"工具"更保险
}

def parse_srt(srt_path: str):
    content = open(srt_path).read()
    pattern = r"(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n(.+?)(?=\n\n|\Z)"
    return re.findall(pattern, content, re.DOTALL)

def to_sec(t: str) -> float:
    h, m, rest = t.split(":")
    s, ms = rest.split(",")
    return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000

def srt_to_dub(srt_path: str, out_wav: str, voice: str = "zh-CN-XiaoxiaoNeural") -> None:
    matches = parse_srt(srt_path)
    tmpdir = tempfile.mkdtemp()
    concat_list = os.path.join(tmpdir, "concat.txt")
    prev_end = 0.0

    with open(concat_list, "w") as cl:
        for idx, t1, t2, text in matches:
            text = text.strip().replace("\n", " ")
            if not text:
                continue
            for bad, good in POLYPHONE_FIXES.items():
                text = text.replace(bad, good)

            seg_start = to_sec(t1)
            # 段间静默:只在真的有空隙时插入,保持整体时间轴大致对齐原字幕节奏
            gap = seg_start - prev_end
            if gap > 0.3:
                gap_wav = os.path.join(tmpdir, f"gap_{idx}.wav")
                subprocess.run(
                    ["ffmpeg", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
                     "-t", f"{gap:.2f}", gap_wav, "-y"],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                )
                cl.write(f"file '{gap_wav}'\n")

            mp3 = os.path.join(tmpdir, f"seg_{idx}.mp3")
            wav = os.path.join(tmpdir, f"seg_{idx}.wav")
            # 自然语速生成,不传任何速度/时长调整参数
            subprocess.run(["edge-tts", "--voice", voice, "--text", text, "--write-media", mp3],
                            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            subprocess.run(["ffmpeg", "-i", mp3, wav, "-y"],
                            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            os.remove(mp3)
            cl.write(f"file '{wav}'\n")

            # 用这一段实际生成的时长(而不是字幕原定的时长)累加,
            # 这样下一段的 gap 计算会自动吸收本段超出/不足的偏差
            actual_dur = probe_duration(wav)
            prev_end = prev_end + (gap if gap > 0.3 else 0) + actual_dur

    subprocess.run(["ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_list,
                     "-c", "copy", out_wav, "-y"],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def probe_duration(path: str) -> float:
    result = subprocess.run(
        ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", path],
        stdout=subprocess.PIPE, text=True,
    )
    return float(result.stdout.strip() or 0)

if __name__ == "__main__":
    srt_to_dub(sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else "zh-CN-XiaoxiaoNeural")

几个关键设计点:

  • 累计时间戳(prev_end)而不是每段独立对表:如果某一段 TTS 生成的音频比原字幕窗口长(比如字幕给了 2 秒的窗口,但这句话自然读出来要 2.6 秒),不做任何截断或加速处理,就让它自然占用 2.6 秒;下一段开始前的静默间隔用"这一段实际结束时间"和"下一段字幕原定开始时间"的差值计算,如果差值变负(说明已经超出,没有空隙了),就直接跳过静默、紧接着播放下一段。整条配音轨会因为个别句子偏长而整体产生轻微的时间漂移,但相邻句子之间的相对顺序和大致节奏是保持住的。
  • 为什么不用时长拉伸:TTS 音频做时长拉伸(比如用 atempo 滤镜压缩/拉长)在偏差较大时会明显听出语速不自然,反而比"多几百毫秒的漂移"更影响观感。实践证明,容忍小幅漂移、靠 8.3 节的全局偏移做最后兜底,观感优于强行对齐每一句。
  • 多音字问题表:这是一个非常具体、容易被忽略但很实用的经验——国内主流 TTS(包括 edge-tts)不提供官方渠道让你用 SSML <phoneme> 标签强制指定某个字的读音(即使传了标签,也会被转义成字面文本整段读出来)。遇到读错的多音字,最可靠的解法不是找 TTS 的 hack 参数,而是维护一份"问题词 → 安全替换词"的替换表,换一种不触发歧义读音的表达方式。这个表随着使用积累会越来越完善,一次修复对所有视频生效。

6.3 英文(或其他语言)版本:翻译字幕,而不是翻译文案脚本

11.4 章讲多语言扩展时提到"操作录制和语言无关,只有文案层要多做一份"。具体做法是批量翻译整份中文字幕,而不是分别为每种语言重新组织解说文案:

# scripts/translate_srt.py
import re, json, urllib.request, sys, os

def translate_srt(zh_srt: str, en_srt: str, api_key: str) -> None:
    content = open(zh_srt).read()
    pattern = r"(\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}\n)(.+?)(?=\n\n|\Z)"
    matches = re.findall(pattern, content, re.DOTALL)
    texts = [m[1].strip().replace("\n", " ") for m in matches]

    # 用一个不会出现在正文里的分隔符批量拼接,一次 API 调用翻译整份字幕,
    # 而不是每句话单独请求——既省调用次数,也让译文风格更一致
    combined = " ||| ".join(texts)
    body = json.dumps({
        "model": "deepseek-v4-flash",
        "messages": [
            {"role": "system", "content": "你是技术文档翻译专家。将以下中文逐句翻译成英文,"
                                           "每句用 ||| 分隔,保持原有顺序,只返回译文。"},
            {"role": "user", "content": combined},
        ],
        "temperature": 0.3,
    }).encode()

    req = urllib.request.Request(
        "https://api.deepseek.com/v1/chat/completions", data=body,
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
    )
    resp = json.loads(urllib.request.urlopen(req).read())
    translated = resp["choices"][0]["message"]["content"].strip()
    en_texts = [t.strip() for t in translated.split("|||")]

    with open(en_srt, "w") as f:
        for i, (header, _) in enumerate(matches):
            if i < len(en_texts):
                f.write(f"{header}{en_texts[i]}\n\n")

if __name__ == "__main__":
    translate_srt(sys.argv[1], sys.argv[2], os.environ["DEEPSEEK_API_KEY"])

翻译出的英文 SRT 直接复用 6.2 节的 srt_to_dub(换一个英文发音人参数),录制、Codegen、时间点文本完全不用动第二遍——这正是"录制优先"架构在多语言场景下的直接收益:语言是配音/字幕层的属性,不是录制层的属性。

6.4 为什么这套做法能支撑"改一个字,几十秒出新片"

回到 00 章的核心承诺:录制是唯一慢的一步,其余全部可以快速重跑。原因现在可以说清楚了——subtitles.srt 是一份纯文本文件,也是从这一步往后所有环节的唯一输入。发现某句话措辞别扭、某个专有名词读错了,直接编辑这个文本文件,重新跑一遍 6.2 节的配音生成 + 08 章的合成,几十秒到一分钟就能拿到新的成片,全程不需要重新打开浏览器、不需要重新录制。这也是为什么 03 章会强调:这份 SRT 文件(或者它的上游——时间点文本)是团队协作和 AI 介入的核心接口,18 章会展开讲 AI 如何直接参与撰写和微调这份文本。

下一章讲封面/封底素材怎么生成,之后 08 章会把本章产出的配音、录像、封面素材全部合成为一条成片。

CHAPTER 07

07 封面与素材生成

本章生成片头/片尾所需的静态画面。这些素材是否要用、用哪个文件,由 09 章讲的 meta.json 决定——记住 08 章 8.5 节的教训:素材文件存在不代表要启用,meta.json 里必须显式打开开关。核心工具是 ImageMagick,不是浏览器截图:白底画布上画标题、副标题、logo、公司名,直接合成一张图,再转成视频片段。

7.1 为什么用 ImageMagick 而不是浏览器截图 HTML 模板

用 HTML/CSS 写模板、拿 Playwright 截图,理论上表现力更强(渐变、阴影、Flex 布局这些 CSS 原生能力),但会给整条流水线多引入一个重量级依赖——只是为了生成一张标题卡片,代价是启动一次完整的浏览器。这套系统里 05/06/08/09 章的其余环节都是纯 shell + ffmpeg + Python,封面生成延用同一套朴素的技术栈,用 ImageMagick 的 magick 命令直接画:

  • 依赖更轻:brew install imagemagick 一行装完,不需要额外维护 Playwright 浏览器内核。
  • 足够用:功能演示视频的封面通常就是"白底 + 标题 + 副标题 + logo + 公司名"这几个元素,不需要 CSS 那种复杂布局能力。
  • 和实时预览共用一套逻辑:09 章会提到的可视化管理台,改标题/换配色能实时看到封面效果,预览和正式生成用的是同一个函数,不需要维护两套模板。

如果你的封面设计确实需要渐变、阴影、复杂排版这类 CSS 更擅长的效果,HTML 模板 + 无头浏览器截图仍然是一个合理的备选方案,只是不作为本书的默认路径。

# 生成封面静态图(不转视频)——实时预览和正式生成共用这一份逻辑,
# 避免同样的拼图代码在两处各写一遍,以后改一处忘了改另一处
gen_title_card_png() {
  local title="$1" subtitle="$2" out="$3" logo="$4" company="$5" accent="${6:-#222222}"
  [ -z "$title" ] && return 1

  # 按平台找一个能覆盖中文的字体,找不到就让 ImageMagick 用默认字体
  local font=""
  for f in "/System/Library/Fonts/Supplemental/Songti.ttc" \
           "/System/Library/Fonts/STHeiti Medium.ttc" \
           "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" \
           "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"; do
    [ -f "$f" ] && { font="$f"; break; }
  done

  # 1. 白底 + 标题(accent 默认深灰,可以传成品牌色)+ 副标题
  magick -size 1920x1080 xc:'#FFFFFF' -gravity center \
    ${font:+-font "$font"} \
    -fill "$accent" -pointsize 60 -draw "text 0,-60 '$title'" \
    -fill '#666666' -pointsize 42 -draw "text 0,30 '$subtitle'" \
    -define png:color-type=2 "$out" 2>/dev/null || return 1

  # 2. logo(缩放到高度80px,叠加到左上角,保留原色)
  if [ -n "$logo" ] && [ -f "$logo" ]; then
    local logo_small="/tmp/_cover_logo_$$.png"
    magick "$logo" -resize x80 "$logo_small" 2>/dev/null
    magick "$out" "$logo_small" -geometry +40+30 -composite -colorspace sRGB \
      -define png:color-type=2 "$out" 2>/dev/null
    rm -f "$logo_small"
  fi

  # 3. 公司名(底部居中)
  if [ -n "$company" ] && [ -n "$font" ]; then
    magick "$out" -font "$font" -gravity south -fill '#999999' -pointsize 24 \
      -draw "text 0,50 '$company'" -colorspace sRGB -define png:color-type=2 "$out" 2>/dev/null
  fi
}

三步走:先画白底+文字,再叠 logo,最后加公司名——每一步都是对上一步产物的再加工,而不是一次性拼好所有图层,这样任何一步单独出问题都容易定位(比如 logo 没显示,只要看第2步的产物对不对)。

上面代码里反复出现的 -define png:color-type=2,是一个真实踩过的坑:如果画布上当前所有像素都是无彩色的(比如默认的 accent=#222222、副标题灰色 #666666、背景纯白,三者的 R=G=B),ImageMagick 写 PNG 文件时会自动把颜色类型优化成灰阶(PNG 的 IHDR color_type=0),因为这样文件更小、看起来没有任何信息损失。

问题出在下一步叠加彩色 logo 的时候:合成操作是在这张已经被存成灰阶的 PNG 基础上进行的,-composite 会把新叠上去的彩色 logo 也一并拍扁成灰阶——观察到的现象是"logo 传的明明是彩色图片,合成出来的封面里 logo 却变成了黑白"。这个坑的诡异之处在于:同样的代码,只要标题颜色换成一个真正的彩色(比如品牌红),就完全不会复现,因为那种情况下画布从一开始就不是纯灰阶,不会触发 ImageMagick 的自动优化。也就是说默认配色(灰阶)反而是最容易触发问题的组合。

解决方法是显式强制每一步都用真彩色 RGB 编码(png:color-type=2),不管当前画布内容是不是碰巧全是灰阶,都不允许 ImageMagick 做这个"聪明"的自动优化。这类问题的规律值得记住:图像处理工具的"自动优化"经常是根据当前像素内容做的启发式判断,样例测试时用的颜色恰好绕开了触发条件,上线后遇到另一种配色才暴出问题——测试封面生成逻辑时,应该覆盖"全灰阶"和"含彩色"两种配色,而不是只用一套顺手的测试数据。

7.4 封面转视频片段

静态 PNG 需要转成一段"持续 N 秒的视频片段",才能和其他动态录制片段拼接:

gen_title_card() {
  local title="$1" subtitle="$2" duration="${3:-3}" out="$4"
  local logo="$5" company="$6" accent="${7:-#222222}"
  [ -z "$title" ] && return 1

  local png="/tmp/_cover_$$.png"
  gen_title_card_png "$title" "$subtitle" "$png" "$logo" "$company" "$accent" || return 1

  # 转视频(带静音轨,编码参数与正文对齐,方便08章合成阶段用 -c copy 无损拼接)
  ffmpeg -loop 1 -i "$png" -f lavfi -i "anullsrc=r=48000:cl=stereo" \
    -c:v h264_videotoolbox -b:v 5M -r 30 \
    -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black" \
    -pix_fmt yuv420p -t "$duration" -c:a aac -ar 48000 -ac 2 -shortest "$out" -y 2>/dev/null

  rm -f "$png"
}

时长(duration)从 meta.json 的 cover_duration 字段读取(09 章 9.4 节),默认 3 秒。编码参数(h264_videotoolbox/30fps/1920x1080/yuv420p/48kHz立体声)刻意和正文录像保持一致,这样 08 章拼接时才能用 -c copy 无损快速拼接,而不需要重新编码。

7.5 封底与外部素材:图片、视频都要能处理

封底(outro)不一定是生成出来的标题卡,也可能是一段现成的视频或者一张现成的图片(比如产品的固定结尾画面)。这里用文件扩展名简单判断走哪条处理路径,两条路径最终都对齐到同样的编码参数:

gen_cover() {
  local asset="$1" dur="$2" out="$3"
  if [[ "$asset" == *.mp4 || "$asset" == *.mov ]]; then
    ffmpeg -i "$asset" -f lavfi -i "anullsrc=r=48000:cl=stereo" \
      -c:v h264_videotoolbox -b:v 5M -r 30 \
      -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black" \
      -pix_fmt yuv420p -c:a aac -ar 48000 -ac 2 -map 0:v:0 -map 1:a:0 -shortest "$out" -y 2>/dev/null && echo "$out"
  else
    ffmpeg -loop 1 -i "$asset" -f lavfi -i "anullsrc=r=48000:cl=stereo" \
      -c:v h264_videotoolbox -b:v 5M -r 30 \
      -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black" \
      -pix_fmt yuv420p -t "$dur" -c:a aac -ar 48000 -ac 2 -shortest "$out" -y 2>/dev/null && echo "$out"
  fi
}

封底复用同一个函数(gen_outro 就是直接调用 gen_cover),因为"把一段素材统一成标准编码参数的视频片段"这件事,图片和视频只是输入格式不同,处理逻辑完全一样,没必要为封底单独写一份。

7.6 素材从哪读:还是那三份文件

回到 03 章的三份文件模型——封面用不用、标题写什么、logo 在哪,全部来自 meta.json(直接字段:title/subtitle/cover_duration/company/cover_accent_color,或者 08 章 8.5 节讲过的 resolve_asset 按约定文件名查找 logo/cover/outro)。这里不需要一份独立的"品牌规范"配置文件——meta.json 的三级合并(09 章 9.4 节:内置默认 → 项目级 → 功能级)本身就承担了"全项目统一视觉规范"的角色:项目级 meta.json 里定好 cover_accent_color、company,所有功能点的封面自动保持一致,个别功能点需要不同的标题/副标题时,功能级 meta.json 只覆盖这两个字段即可。

下一章把本章的封面片段、05 章的操作录像、06 章的配音,全部按时间轴合成为最终成片。

CHAPTER 08

08 合成成片:裁剪、配音混流、封面拼接、字幕烧录

06 章拿到了配音音频,07 章会拿到封面素材。本章是"总装车间":把原始录像、配音、封面/封底、字幕,按 00 章 0.4 节讲的"录制优先"顺序合成为一条成片。核心难点不是 ffmpeg 命令本身,而是几个容易被忽略、但真实踩过坑的细节:录制起始的不安全空档要裁掉、配音的全局偏移要和字幕的偏移保持同步、BGM/封底不能因为文件恰好存在就默默启用。

8.1 合成的整体步骤

recording.mov ──┐
                ├─▶ 裁剪起始空档 ─▶ 混流配音(+全局偏移) ─▶ 拼接封面/正文/封底
ai_dub.wav ─────┘                                              │
                                                                ▼
                                                        混入BGM(如果显式开启)
                                                                │
                                                                ▼
                                                        内嵌封面帧(缩略图)
                                                                │
                                                                ▼
                                                    烧录字幕(时间戳同步偏移)
                                                                │
                                                                ▼
                                                             成片.mp4

8.2 裁剪录制起始的不安全空档

05 章 5.7 节讲了 ready/go 双信号握手,用来避免"ffmpeg 已经启动、浏览器还没准备好"这段空档被录进去。但即使有这个握手,从"ffmpeg 进程启动"到"编码真正稳定输出"之间仍然有零点几秒的天然延迟——稳妥的做法是把这段延迟的秒数记录下来(比如写进 record-offset.txt),在合成这一步再裁一刀,双重保险:

# compose 阶段读取 record-offset.txt,裁掉开头这一小段
if [ -f "$dir/record-offset.txt" ]; then
  offset=$(cat "$dir/record-offset.txt")
  # 再加 0.3 秒缓冲,宁可多裁一点,不可让不安全画面漏进成片
  safe_offset=$(python3 -c "print(round(float('$offset') + 0.3, 2))")
  trim_args=(-ss "$safe_offset")
fi

这条原则值得单独强调:任何"理论上不会露出敏感画面"的时间窗口,只要有哪怕零点几秒的不确定性,都应该在录制和合成两个环节各设一道防线,而不是信任其中一道就够了。10 章的检查清单是最后一道人工防线,这里是自动化流程里的倒数第二道。

8.3 配音混流与全局偏移微调

把裁剪后的录像和配音混流成一条视频,同时应用 00 章 0.4 节提到的全局 dub_offset——这是唯一一个需要人工感知"听感对不对得上"的调节点,粒度是整条配音轨,而不是逐句调整:

compose() {
  local rec="$1" dub="$2" out="$3" dub_offset="$4"

  local dub_input_args=() audio_filter_args=() pad_filter=""

  if python3 -c "exit(0 if $dub_offset < 0 else 1)"; then
    # 负偏移:配音整体提前,跳过配音开头对应的秒数
    local skip=$(python3 -c "print(-$dub_offset)")
    dub_input_args=(-ss "$skip")
  elif python3 -c "exit(0 if $dub_offset > 0.001 else 1)"; then
    # 正偏移:配音整体延后,用 adelay 滤镜整体延迟
    local delay_ms=$(python3 -c "print(int($dub_offset * 1000))")
    audio_filter_args=(-af "adelay=${delay_ms}|${delay_ms}")

    # 正偏移会让"配音总时长"变成"原配音时长 + 偏移",如果这个值超过了录像本身
    # 的时长,直接 -shortest 会把超出部分的配音截掉——配音说到一半突然没声,
    # 而不是报错。用 tpad 冻结画面最后一帧,把画面垫长到能装下完整配音,
    # 不依赖 -shortest 兜底截断。
    local video_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$rec")
    local dub_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$dub")
    local needed=$(python3 -c "print($dub_dur + $dub_offset)")
    if python3 -c "exit(0 if $needed > $video_dur else 1)"; then
      local pad=$(python3 -c "print(round($needed - $video_dur + 0.3, 2))")
      pad_filter=",tpad=stop_mode=clone:stop_duration=${pad}"
    fi
  fi

  ffmpeg -i "$rec" "${dub_input_args[@]}" -i "$dub" \
    -c:v h264_videotoolbox -b:v 5M -r 30 \
    -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black${pad_filter}" \
    -pix_fmt yuv420p -c:a aac -ar 48000 -ac 2 \
    -map 0:v:0 -map 1:a:0 "${audio_filter_args[@]}" -shortest "$out" -y
}

这段代码里"正偏移会导致配音被截断"是一个真实踩过的坑:dub_offset 调大之后配合较短的录像,-shortest 会悄悄吃掉最后几句配音而不报任何错误——加 tpad 冻结最后一帧垫时长,是唯一可靠的修复方式,比事后靠人工听感发现"最后一句没声音"要主动得多。

8.4 封面、正文、封底拼接

正文视频(8.3 节产出)只是成片的一部分。完整的合成还要按需拼接标题封面(可以是一张静态图,也可以用 07 章的方法现场生成一张带标题文字的卡片)和封底:

compose_final() {
  local content="$1" out="$2"
  local parts=()

  # 1. 标题封面(可选)
  if [ -n "$title" ]; then
    gen_title_card "$title" "$subtitle" "$cover_duration" "$tmp/cover.mp4"
    parts+=("$tmp/cover.mp4")
  fi

  # 2. 正文
  parts+=("$content")

  # 3. 封底(可选,见8.5节为什么必须显式开启)
  if [ "$outro_enabled" = "true" ]; then
    gen_outro "$outro_asset" "$outro_duration" "$tmp/outro.mp4"
    parts+=("$tmp/outro.mp4")
  fi

  # 4. 拼接:各片段编码参数已在生成阶段对齐,优先用 stream-copy 无损拼接,
  #    速度极快;参数万一没对齐导致 stream-copy 失败,再回退到重新编码
  printf "file '%s'\n" "${parts[@]}" > "$tmp/concat.txt"
  if ! ffmpeg -f concat -safe 0 -i "$tmp/concat.txt" -c copy "$out" -y 2>/dev/null; then
    ffmpeg -f concat -safe 0 -i "$tmp/concat.txt" -c:v libx264 -preset fast -crf 23 -c:a aac "$out" -y
  fi

  # 5. BGM(可选,见8.5节)
  if [ -n "$bgm_asset" ]; then
    ffmpeg -i "$out" -i "$bgm_asset" \
      -filter_complex "[1:a]volume=${bgm_volume:-0.15}[bgm];[0:a][bgm]amix=inputs=2:duration=first" \
      -c:v copy "$tmp/final.mp4" -y
    mv "$tmp/final.mp4" "$out"
  fi
}

"优先 stream-copy、失败才重新编码"这个小细节值得留意:只要前面每一段素材(封面卡片、正文、封底)在生成时就统一了分辨率/帧率/像素格式/编码参数,-c copy 无损拼接几乎瞬间完成;只有素材来源不一致(比如封面是外部随手做的一张图,编码参数没对齐)时才需要重新编码兜底,这个顺序能让绝大多数场景下的合成速度快一个数量级。

8.5 显式开关:BGM 和封底不能"自动探测到文件就启用"

这是一条用真实事故换来的教训:如果系统设计成"资源目录里放了 bgm.mp3 就自动给所有视频混上背景音乐",看似省心,实际上非常危险——BGM 和封底会实打实地改变成片的听感/结构,不应该被一个文件是否存在这种隐式信号决定。真实发生过的情况是:项目的公共资源目录里一直放着一个占位用的 bgm.mp3(或者一张占位黑图当 outro),某次自动探测逻辑的 bug 修好之后,所有历史视频重新合成时全部意外多出了一段没人要的背景音乐/黑屏封底。

正确的设计原则是:素材文件"存在"和"要不要用"必须是两个独立的信号。配置里必须显式写 bgm: true(或者给出具体路径)才会真正启用,bgm 字段缺省或者显式设为 false 时,即使资源目录里确实有同名文件,也绝不自动套用:

def resolve_asset(meta: dict, dir_: str, project_dir: str, key: str) -> str:
    val = meta.get(key)
    if val in (None, "None", "null", False, "false"):
        return ""  # 显式禁用或从未设置 —— 不使用,即使文件存在
    if isinstance(val, str):
        return val  # 显式给了路径,直接用
    # val 为 True:按约定文件名,先查 feature 目录,再查项目公共 resources 目录
    for base in (dir_, f"{project_dir}/resources"):
        for name in CONVENTION_NAMES[key]:
            path = f"{base}/{name}"
            if os.path.exists(path):
                return path
    return ""

这条经验同样适用于任何"约定优于配置"的自动探测设计——约定优于配置的前提是用户明确选择了走约定路径,而不是"文件恰好在那儿"就被系统当成了信号。

8.6 字幕烧录:时间戳要跟着同一个偏移量一起平移

字幕烧录本身用 ffmpeg 的 subtitles 滤镜(需要编译进 libass 支持):

ffmpeg -i final.mp4 \
  -vf "subtitles=subtitles.srt:force_style='FontName=PingFang SC,FontSize=44,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,MarginV=45'" \
  -c:a copy final-with-subs.mp4

容易被漏掉的一点是:8.3 节的 dub_offset 真实挪动了配音在时间轴上的位置,8.4 节的标题封面也让正文整体后移了 cover_duration 秒——字幕的时间戳必须跟着同样的方向、同样的量级一起平移,否则配音已经提前/延后了,字幕却还对着画面原本(未偏移)的节奏,两者会对不上。这是配音偏移功能上线后必须同步处理的一环,很容易在第一版实现里漏掉:

def shift_srt(srt_path: str, shift_sec: float, out_path: str) -> None:
    """把字幕的每个时间戳整体平移 shift_sec 秒,正数延后、负数提前。"""
    content = open(srt_path).read()

    def shift_match(m):
        return "".join(shift_timestamp(t, shift_sec) for t in [m.group(0)])

    pattern = r"\d{2}:\d{2}:\d{2},\d{3}"
    return open(out_path, "w").write(re.sub(pattern, lambda m: shift_timestamp(m.group(0), shift_sec), content))

带封面的版本和不带封面的版本,总偏移量不一样(前者要多算上 cover_duration),实践中建议两个版本分别烧录,而不是共用一份偏移后的字幕文件。同时建议保留一份"不带封面、不烧字幕"的纯净版本(画面+配音),方便后续需要二次剪辑或者只要正文片段时直接取用,不用从带封面的成片里裁。

8.7 缩略图:把第 0 帧内嵌为封面贴图

一个容易被忽略、但影响"看起来专不专业"的细节:视频文件在 Finder / 大多数播放器里显示的默认缩略图,通常是从视频中段随机抽的一帧,不是第 0 帧——即使第 0 帧就是精心设计的标题封面,也不代表播放器会拿它当缩略图用。解决办法是把第 0 帧显式提取出来,作为"专辑封面"同款机制(attached_pic)内嵌进视频容器:

ffmpeg -i final.mp4 -vframes 1 -f image2 poster.png -y
ffmpeg -i final.mp4 -i poster.png -map 0 -map 1 -c copy \
  -c:v:1 png -disposition:v:1 attached_pic with_poster.mp4 -y

这样不管播放器内部怎么选缩略图逻辑、也不管观众有没有拖动过播放进度,Finder / 大多数播放器都会稳定显示这张内嵌的封面图作为缩略图。这是一个几行代码就能解决、但如果不知道这个机制会以为"没办法"的细节。

8.8 小结

本章的核心不是 ffmpeg 参数本身(这些命令查文档都能查到),而是四条来自真实使用中的经验:起始空档要在录制和合成两处各裁一刀、正偏移必须配合定格垫时长防止配音被截断、BGM/封底类会改变成片结构的素材必须显式开关而不能靠文件探测、字幕的时间戳必须和配音偏移保持数学上的一致。下一章把本章和前面几章的每个步骤,串成一套可以按需单独重跑的命令流程。

CHAPTER 09

09 命令行编排:让每一步都能单独重跑

前几章讲了每个环节自己的原理。本章讲这些环节怎么被组织成一套命令行工具——重点不是"用什么语言写编排器",而是一个更重要的设计决定:整条流水线的"状态"就是文件系统里那几个约定命名的文件,不是任何数据库或者内存里的状态机。这个决定直接决定了"改一个字,几十秒出新片"能不能做到。

9.1 用文件名本身表达流水线状态

每个功能点对应一个目录,目录里几个约定命名的文件,就是这个功能点当前处于流水线的哪一步:

feature-07-export-report/
├── recording.mov       ← 有这个文件,说明"录制"这一步完成了
├── timeline.json        ← (可选)自动化脚本产出的时间点文本
├── subtitles.srt        ← 有这个文件,说明"字幕"这一步完成了
├── ai_dub.wav           ← 有这个文件,说明"配音"这一步完成了
├── record-offset.txt    ← 录制起始不安全空档的秒数(08章2节用到)
├── meta.json            ← 这个功能点的配置(标题、发音人、是否要封面/BGM)
└── feature-07-export-report.mp4   ← 有这个文件,说明"合成"这一步完成了

查看一个功能点当前进度,不需要读任何数据库,直接看这几个文件存不存在、新不新:

status() {
  local dir="$1"
  [ -f "$dir/recording.mov" ]  && echo "✅ recording.mov" || echo "⬜ recording.mov 缺失"
  [ -f "$dir/subtitles.srt" ]  && echo "✅ subtitles.srt ($(grep -c '^[0-9]' "$dir/subtitles.srt") 条)" || echo "⬜ subtitles.srt 缺失"
  [ -f "$dir/ai_dub.wav" ]     && echo "✅ ai_dub.wav" || echo "⬜ ai_dub.wav 缺失"
  [ -f "$dir/$(basename "$dir").mp4" ] && echo "✅ 成片已生成" || echo "⬜ 成片缺失"
}

这个"文件即状态"的设计带来一个关键好处:任何一步都可以单独重跑,只要它依赖的上游文件还在。改了字幕文本,只需要重新生成 ai_dub.wav 和成片,recording.mov 完全不用碰——这不是靠某个"任务依赖图"框架实现的,只是因为每一步的输入输出都是磁盘上明确的文件。

9.2 命令与它们依赖/产出的文件

关于 vt 这个命令名:本书接下来会大量出现 vt record、vt all、vt redub 这样的命令。vt 不是需要单独安装的第三方软件,就是 9.1 节说的这个入口脚本(video-toolkit.sh)装好之后起的一个短别名——alias vt=/path/to/video-toolkit.sh,或者直接把这个脚本链接到 PATH 里某个目录、去掉 .sh 后缀。取这个名字纯粹是图打字方便,你自己实现时完全可以叫别的名字,本书统一用 vt只是为了后面所有命令示例整齐、便于对照阅读。

命令 依赖 产出 典型耗时
命令 依赖 产出 典型耗时
codegen 无(人工操作浏览器) nav-draft.spec.js(选择器草稿) 几分钟,人工操作为主
sync nav-draft.spec.js 更新 record.spec.js + timeline.json(交给 AI 处理,见17章) 1~2分钟
record record.spec.js recording.mov + timeline.json→subtitles.srt + record-offset.txt 1~3分钟(等于操作本身耗时)
srt recording.mov subtitles.srt(若已存在且更新则跳过,见6.1节) 几秒~1分钟(ASR兜底时较慢)
dub subtitles.srt ai_dub.wav 十几秒到1分钟
mix recording.mov + ai_dub.wav + meta.json 成片 .mp4 十几秒
redub 手改后的 subtitles.srt 重新生成 ai_dub.wav + 成片 几十秒,不碰录制
burn 成片 + subtitles.srt 烧录字幕后的最终版本 几十秒
trans subtitles.srt subtitles_en.srt(DeepSeek翻译) 十几秒
en subtitles_en.srt 英文配音 + 英文成片 几十秒
all recording.mov 一次跑完 srt→dub→mix 1~2分钟
status 无 打印当前功能点各文件的完成情况 秒级

all 是最常用的命令,把 srt → dub → mix 串起来一次跑完;但一旦某个环节要单独调整(最常见的是改字幕文案),直接用对应的单步命令(redub),不需要重新走 all。

9.3 主入口:类型判断 + 分发

编排器的主入口做两件事:判断这个功能点是"录屏类型"还是其他类型(比如 07 章会提到的截图幻灯片类型),然后分发到对应的处理流程:

main() {
  local cmd="$1"; local dir=$(resolve_dir "$2")

  case "$cmd" in
    record)  cmd_record "$dir" ;;
    codegen) cmd_codegen "$dir" ;;
    sync)    cmd_sync "$dir" ;;
    srt)     extract_srt "$dir" ;;
    dub)     srt_to_dub "$dir" ;;
    redub)   cmd_redub "$dir" ;;
    mix)     compose "$dir" ;;
    burn)    cmd_burn "$dir" ;;
    trans)   translate_srt "$dir" ;;
    en)      cmd_en "$dir" ;;
    all)     cmd_all "$dir" ;;
    status)  show_status "$dir" ;;
    *) echo "未知命令: $cmd"; exit 1 ;;
  esac
}

cmd_all 内部按类型分流,並且在真正开始 srt → dub → mix 之前,会先做一次环境自检(ffmpeg、语音识别依赖是否齐全),提前把"环境没装好"这类问题暴露出来,而不是跑到中途才报错:

cmd_all() {
  local dir="$1"
  check_env || return 1
  extract_srt "$dir" || return 1
  srt_to_dub "$dir" || return 1
  compose "$dir"
  show_status "$dir"
}

9.4 配置:三级合并的 meta.json,而不是一份扁平配置

每个功能点的展示层配置(标题、发音人、是否要封面/BGM、字幕样式、分辨率)用 meta.json 表达,采用三级合并:内置默认值 → 项目级 meta.json(放在所有功能点的共同父目录,团队统一规范放这里)→ 功能级 meta.json(只覆盖这一个功能点的个性化配置)。

def load_meta(feature_dir: str) -> dict:
    project_dir = os.path.dirname(feature_dir)
    defaults = {
        "voice": "zh-CN-XiaoxiaoNeural", "voice_en": "en-US-AvaNeural",
        "cover": None, "outro": None, "cover_duration": 3, "outro_duration": 3,
        "bgm": None, "bgm_volume": 0.15,
        "resolution": "1920x1080", "fps": 30, "dub_offset": 0,
    }
    project_cfg = read_json(f"{project_dir}/meta.json")
    feature_cfg = read_json(f"{feature_dir}/meta.json")
    merged = deep_merge(defaults, project_cfg)
    merged = deep_merge(merged, feature_cfg)
    return merged

这个三级合并解决的实际问题是:大部分配置项,一个项目里所有功能点都应该统一(品牌字体、字幕样式、要不要加公司 logo),只有少数几项需要针对某个功能点单独调整(这一条视频的标题、要不要额外加背景音乐)。项目级配置承担"统一规范"的角色,功能级配置只用来处理例外,不需要每个功能点都把全部配置项抄一遍。

resolve_asset(8.5 节提到的"素材存在不代表要用")也是这个配置系统的一部分——它同时查功能目录和项目级 resources/ 目录,但只有配置里显式打开开关才会真正采用查到的文件。

9.5 幂等性与增量跳过

vt all(或者幻灯片模式下的合成命令)在重复运行时会做增量检查:如果所有输入素材(录像、字幕、配音、meta.json)都没有比上一次的成片更新,直接跳过合成,不重复消耗时间:

should_skip_rebuild() {
  local dir="$1"
  local out="$dir/$(basename "$dir").mp4"
  [ ! -f "$out" ] && return 1  # 还没生成过,不跳过

  for src in "$dir/recording.mov" "$dir/subtitles.srt" "$dir/ai_dub.wav" "$dir/meta.json"; do
    [ -f "$src" ] && [ "$src" -nt "$out" ] && return 1  # 有输入比成片新,不跳过
  done
  return 0  # 全部没变化,跳过
}

这个检查配合 -u/--force 一类的强制重跑开关(需要的时候可以绕过跳过逻辑),让批量处理多个功能点时(比如 CI 里跑一遍全部功能点检查是否都能正常出片)不会做无意义的重复工作。

9.6 小结

本章的编排器设计非常朴素:一个 main 函数按命令分发、几个字符串约定的文件名表达状态、一个三级合并的 JSON 做配置。没有引入任务队列、没有引入状态机框架、没有引入数据库——朴素到几乎不需要文档就能读懂的实现,恰恰是它能被快速理解、快速改、快速排障的原因。11 章会讲这套朴素设计在批量处理、CI 集成场景下要做哪些补充;16 章开始会讲清楚,这些命令背后,有多少工作其实是交给 AI 完成的。

Part

质量与运维

防泄露清单、故障排查、实战案例、值不值得做。

CHAPTER 10

10 质量检查与防泄露清单

自动化程度再高,最后一道关口仍然需要人工过一遍。本章从零设计一份适用于任何"意图驱动录制流水线"的检查清单,分为录制前、录制中(编排器运行期间的人工旁站确认)、录制后三个阶段。

10.1 为什么自动化不能替代人工审片

编排器(09 章)保证的是"流程正确执行完毕",而不是"内容适合对外发布"。以下几类问题,程序天然无法自动判断:

  • 页面上恰好显示了某个真实客户的名字、真实的业务数据、内部人员的邮箱。
  • 浏览器书签栏、标签页标题、系统菜单栏(如用户既往经验中"股票组件全程可见"的教训)里出现了与本次演示无关但暴露隐私/内部信息的内容。
  • 解说文案在语义上正确,但语气/措辞不符合对外发布的标准(比如无意中用了内部黑话)。
  • 终端历史命令、环境变量打印、日志输出中残留过往调试留下的敏感字符串(密钥、内部 IP、路径)。

这些都属于"程序判断不了、必须人眼过一遍"的范畴,因此清单的存在不是可选项。

10.2 录制前检查清单(Preflight,人工执行部分)

在运行 vt record 之前,人工逐项确认:

  • 账号隔离:record.spec.js 里登录用的用户名/密码对应的是专用演示账号,不是任何真实客户账号或个人生产账号。演示账号里的数据(订单、用户列表、报表内容)本身就是脱敏/虚构的示例数据。
  • 屏幕整洁:桌面壁纸、菜单栏(尤其是像股票、日历、通知中心这类会显示动态/私人信息的菜单栏插件)、Dock 栏里没有暴露个人身份或第三方账号信息的图标/角标。
  • 浏览器整洁:书签栏隐藏或替换为通用书签;只保留本次录制需要的标签页;插件工具栏里没有暴露内部工具/客户信息的插件图标;浏览器历史/自动填充不会在录制过程中意外弹出建议框(可以用一个全新的、干净的浏览器 Profile 专门用于录制)。
  • 终端整洁:如 05 章 5.5 节所述,录制用的终端窗口是新开的、清空过 scrollback 的,命令行提示符(PS1)里不包含真实主机名/用户名(可以为录制专门配置一个简化的提示符)。
  • 通知静音:勿扰模式/专注模式已开启(01 章 1.10 节),关闭一切可能弹出系统通知的应用(IM、邮件客户端、日历提醒)。
  • 网络环境:如果演示环境是内网 staging,确认 URL、域名本身不包含内部代号信息,或提前在 Feature Spec 里换成脱敏后的展示域名。
  • 文案审阅:record.spec.js 里内嵌的解说文案通读一遍,确认语气、措辞符合对外发布标准,没有内部术语或未完成功能的"剧透"。

10.3 录制中检查(编排器运行时的旁站观察)

即使流程是自动化的,首次为一个新 Feature Spec 跑通流水线时,建议不要完全无人值守,旁站观察:

  • Preflight 阶段(04 章 4.6 节的冒烟检查)是否顺利通过,没有选择器报错。
  • 浏览器录制过程中页面是否有非预期的报错提示(Toast、Console Error 弹窗),这类内容一旦出现在成片里会显得很不专业。
  • 系统级录屏(如涉及终端演示)过程中,是否有非预期的系统通知穿帮(即使勿扰模式已开启,某些系统级弹窗仍可能例外弹出,比如软件更新提示)。
  • 每一步的停留时长观感是否合理(画面节奏由 record.spec.js 里的操作和显式等待决定,首次跑通时人工确认一遍,能及早发现某一步停留太短/太长,需要回头调整脚本里的等待时间)。

首次跑通验证无误后,同一个 Feature Spec 后续的重复运行(比如 UI 微调后重录)可以放心无人值守。

10.4 录制后审片清单(成片交付前的最后一关)

对 output/<feature-id>.mp4 完整播放一遍,逐项确认:

  • 画面完整性:首尾封面文字正确、无错别字、无乱码(尤其检查中文字体是否正确渲染,而不是显示为方块);每一步操作画面清晰、没有被截断或黑屏。
  • 音画同步:解说配音与对应画面操作时间点吻合,没有"话说完了画面还没跟上"或反过来的情况。
  • 字幕正确性:字幕文字与配音内容一致,没有因为 06 章文案和实际配音生成之间的编辑差异导致的不同步(比如后期改了 narration 文案但没有重新跑一遍配音生成)。
  • 音量一致性:全片音量听感均匀,没有忽大忽小(8.5/8.7 章的响度归一化理论上保证这一点,人工复核作为兜底)。
  • 敏感信息复核:逐帧留意(尤其是页面切换的瞬间、下拉菜单展开的瞬间)是否有一闪而过的敏感信息,比如账号切换菜单里列出的其他真实用户、报错堆栈里暴露的服务器路径/内网 IP。
  • 导出规格核对:分辨率、码率、时长与 output 配置预期一致,用 ffprobe output/<feature-id>.mp4 快速核对:
ffprobe -v error -show_entries stream=width,height,r_frame_rate,codec_name \
  -show_entries format=duration,bit_rate \
  -of default=noprint_wrappers=1 \
  output/feature-07-export-report.mp4
  • 文件命名与归档路径:成片文件名、存放路径符合团队内部约定(02 章 2.5 节的命名规范),方便后续检索。

10.5 建立"防泄露专项"检查的必要性

如果这套流水线会被多人使用、跑在同一个团队里,建议把 10.2~10.4 的清单固化成一份团队共享文档(例如追加到 docs/recording-checklist.md),并且明确要求:任何一次新 Feature Spec 的首次录制,检查清单必须由录制者本人逐项手动确认后再对外发布,不能因为"流程是自动化的"就跳过人工审片这一步。自动化解决的是"重复劳动"的问题,不是"最终把关责任"的问题,这两者不能混为一谈。

10.6 典型泄露场景与对应防范手段

把常见的"翻车"场景和对应的防范措施对照起来看,比抽象的原则更容易落地执行:

场景一:系统菜单栏常驻组件全程可见。macOS/Windows 的菜单栏/任务栏经常挂载一些个人化的常驻小组件——股票行情、日历下一个会议、剪贴板历史、VPN 连接状态、正在播放的音乐。这些组件本身对日常使用是好用的,但一旦出现在录制画面里,就可能暴露个人持仓、私人日程标题、内部系统的连接地址。防范手段是:录制前专门为录制场景准备一套"精简菜单栏",把非必需的菜单栏项目临时隐藏(macOS 可以用按住 Cmd 拖拽的方式重新排列/移出菜单栏图标,或使用系统设置里的"控制中心"逐项关闭显示)。这一条建议写成清单里独立的一项,而不是笼统地归入"屏幕整洁",因为它极容易被忽略——很多人录屏时只关注浏览器窗口内部,忘了菜单栏也在画面里。

场景二:详情/配置页停留时页面上出现了未预期的动态内容。比如一个报表详情页,除了本次演示要展示的图表,页面角落还有一个"最近登录设备"卡片,卡片里显示的是录制者本人真实的登录 IP 和设备型号。这类内容往往不是这次录制"主动"操作出来的,而是页面本身自带的、容易被忽略的模块。防范手段是:在 03 章 Feature Spec 撰写阶段,产品经理/工程师应该对照实际页面截图,明确标注这一页面上哪些区域是"本次要强调的",哪些区域是"存在但需要在录制账号下确认为脱敏状态的",把这一核对动作前置到写 Spec 的阶段,而不是等录制完成后才发现。

场景三:终端演示环节的历史命令自动补全。录制终端操作时,敲击方向键调出历史命令,如果这个终端此前被用来做过真实调试(比如连接过生产数据库、查看过真实日志),历史记录里可能残留敏感命令。防范手段除了 5.5 节提到的"新开终端窗口、清空 scrollback"之外,更彻底的做法是给录制专门配置一个独立的 shell 历史文件(比如通过 HISTFILE=/tmp/idrp-record-history 环境变量隔离),从根源上保证这个终端会话里方向键调不出任何历史真实命令。

场景四:异步接口返回的调试信息。有些系统在开发/预发环境下会默认开启更详细的接口报错信息(比如把后端异常堆栈直接展示在前端 Toast 里),一旦录制过程中恰好触发了一次接口报错(网络抖动、演示账号权限边界触发的报错),画面里可能会一闪而过地展示服务器内部路径、框架版本等信息。防范手段是录制使用的 staging 环境应该尽可能配置成与生产环境一致的错误展示级别(不显示原始堆栈),这也顺带保证了演示画面里出现的报错样式(如果确实需要演示报错处理这个功能点)也是终端用户会实际看到的样式,而不是开发调试用的样式。

10.7 出现问题后的处理流程

即使清单执行到位,仍然可能有漏网之鱼——毕竟检查清单本身也是人工执行的,人工执行就有疏漏概率。因此还需要一条兜底流程:任何时候,只要有人在已发布的成片里发现了不该出现的敏感信息,第一时间的动作是先下线/撤回该视频的对外访问权限,再排查具体是哪一步泄露的,最后针对性地修复 Feature Spec 或录制环境,重新走一遍完整清单后再重新发布。不要在没有先下线的情况下就地"打个马赛克"重新上传替换文件——很多内部协作平台/CDN 会缓存旧版本文件,简单替换不能保证所有观众看到的都是修复后的版本。

10.8 与账号权限的边界

演示账号(10.2 节第一条)除了数据要脱敏,权限范围也应该被限制到"仅能访问被录制的这个功能所需的最小权限",理由有二:一是避免录制脚本因为某个误操作(比如 04 章清洗后的脚本因选择器变化点错了元素)意外触达高权限操作产生真实副作用;二是即使录制素材后续意外泄露,暴露的账号权限影响面也是可控的。这一条原则同样适用于 04 章冒烟测试使用的账号——冒烟测试和正式录制应该用同一个受限的演示账号,不应该为了图方便临时切换成权限更高的账号去"确保脚本能跑通"。

下一章处理这套系统在长期使用中会遇到的故障模式、性能优化空间,以及可能的扩展方向。

CHAPTER 11

11 故障排查、性能优化与扩展方向

本章收尾,整理长期运行这套流水线会遇到的典型故障、值得投入的性能优化方向,以及规模化之后自然会产生的扩展需求。

11.1 常见故障排查表

现象 最可能的原因 排查方向
ffmpeg 屏幕录制画面全黑 系统权限未授予(01章1.9节) 检查"屏幕录制"权限是否授予了实际运行录制命令的那个终端 App;注意每次终端 App 更新后 macOS 有时会要求重新授权
冒烟测试选择器报错 目标页面 UI 改版,选择器失效 对照 4.4 节的选择器优先级原则,把报错交给 AI 按 17.2 节的自愈流程修复;若频繁发生,推动前端加语义化属性
视频拼接后出现绿屏/花屏 compose_final 拼接阶段各片段编码参数不完全一致 检查封面/正文/封底是否都对齐了同一套编码参数(h264_videotoolbox/30fps/1920x1080/yuv420p),尤其是像素格式
字幕烧录后中文显示为方块 subtitles 滤镜没有正确找到中文字体文件,或者用的是不带 libass 的默认 ffmpeg 核对 01 章 1.6 节确认过的 ffmpeg-full 路径,以及 07/08 章用到的字体绝对路径
配音生成没有声音或时长异常 edge-tts 调用失败但没有报错(网络问题、发音人名写错) 检查生成的 ai_dub.wav 文件大小是否为 0,命令行手动跑一遍 edge-tts --voice ... --text ... 单独验证
成片声音时大时小 混入 BGM 之后忘记做最终的响度检查 对照 08 章的合成流程,确认走完了完整的 compose → compose_final 链路,不要在中间产物阶段就当作最终交付
封面 logo 变成黑白 07 章 7.3 节的 PNG 灰阶色型问题 检查生成脚本每一步是否都带了 -define png:color-type=2
多屏环境下录到了错误的屏幕 avfoundation 设备序号在外接屏插拔后重新编号 05 章 5.7 节的动态探测逻辑;实在识别不准就用 VT_RECORD_SCREEN 环境变量手工指定
长时间批量运行后磁盘占满 每个功能点目录下的原始录像、中间产物没有清理 定期清理不再需要的 recording.mov(成片确认没问题之后);.gitignore 里确认这些大文件不会被误提交

11.2 性能优化方向

并行处理多个功能点:不同功能点目录之间互不依赖,理论上可以并行跑。实践中有两个真实的资源瓶颈需要注意:

  • 同一台机器不能同时跑两个系统级录屏:05 章讲的路线二是抓取整个屏幕,如果两个 vt record 进程同时跑,会互相抢屏幕,画面会串到一起。涉及真实屏幕录制的任务必须串行执行,这是硬约束,不是优化选项。
  • 配音生成(调用 edge-tts)可以安全并行:只要不是同一个功能点内部的多句话乱序生成,跨功能点的配音任务之间没有共享资源冲突,可以用 shell 的后台任务(& + wait)或者简单的任务队列并行跑:
for dir in feature-*/; do
  ( vt dub "${dir%/}" ) &
done
wait

跳过没有变化的重新合成:09 章 9.5 节已经讲过这个思路——检查功能点目录下的输入文件(recording.mov/subtitles.srt/ai_dub.wav/meta.json)是否比上一次的成片更新,全部没变化就跳过,不重复消耗时间,批量跑几十个功能点时能省下大量重复劳动。

ffmpeg 编码速度与质量的权衡:中间过程(比如 08 章的分段编码对齐)如果用得到软件编码,可以用更快的 -preset veryfast;正式导出的最终合成优先用硬件编码(h264_videotoolbox),在 Apple Silicon / 近年 Intel Mac 上速度和质量都能兼顾,这也是为什么这套系统的成片能做到"2分钟视频约15秒出片"(README 里提到的量级)。

11.3 接入 CI,实现批量无人值守出片

vt all(或者拆开的 vt srt/vt dub/vt mix)本身就是可以在无人值守环境下运行的命令,接入 CI 的关键是搭好运行环境:

# .github/workflows/record.yml 思路示意
name: Batch Compose
on:
  workflow_dispatch:   # 手动触发,或者按需求改成 push 触发
jobs:
  compose:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get update && sudo apt-get install -y ffmpeg imagemagick fonts-noto-cjk
      - run: pip install edge-tts faster-whisper
      - name: 批量出片
        run: |
          for dir in feature-*/; do
            ./video-toolkit.sh all "${dir%/}"
          done
      - uses: actions/upload-artifact@v4
        with:
          name: feature-videos
          path: feature-*/*.mp4

需要注意的是:CI 环境(无头 Linux)下系统级录屏这一步做不了(没有真实屏幕,Xvfb 虚拟显示器能跑 Playwright 但录出来的画面质感和真机有差距,05 章 5.3 节提到过这一点)。实际的分工是:正式录制(vt record)保留在本地有屏幕的机器上做,CI 只负责"给定已经录好的 recording.mov,批量跑 srt → dub → mix"这一段完全不需要真实屏幕的后处理流程——这恰好是整条流水线里最适合无人值守批量跑、也最能体现"文件即状态"设计(09章)价值的部分:只要 recording.mov 已经在,后面全部环节可以脱离本地机器、在任何一台装好依赖的机器上重跑。

11.4 多语言配音扩展

06 章 6.3 节已经讲过具体做法:translate_srt 把中文字幕批量翻译成英文字幕,srt_to_dub_core 换一个英文发音人跑一遍,就能拿到英文版配音。这里补充一点扩展到两种以上语言时的组织方式——每种语言的字幕/配音文件按语言后缀命名(subtitles_en.srt/ai_dub_en.wav,参照真实实现里已经在用的命名习惯),操作录制本身(recording.mov)不需要为每种语言重录,是所有语言版本共享的唯一录制产物,只有字幕翻译和配音这两层需要按语言分别生成——这是"录制优先"架构在多语言场景下的直接收益。

11.5 扩展方向小结

到本章为止,从 00 章的架构设计到 09 章的命令行编排,已经具备一套可独立运行的、意图驱动的功能点录制自动化系统。后续可能的自然演进方向包括:

  • 引入更精细的动态转场效果(超出 ffmpeg 简单拼接的能力范围,需要额外投入学习 -filter_complex 或引入专门的视频渲染框架,代价是复杂度和渲染耗时都会上升)。
  • 把"一句话意图"到 record.spec.js 的生成过程做成一个更友好的入口(比如一个简单的 Web 表单或者 Slack Bot,产品经理直接在里面写一句话,后台自动触发 AI 生成流程),17 章已经展开过这个思路的核心方法论。
  • 建立成片的自动化质检(19 章讲的抽帧+多模态模型方案),作为 10 章人工审片清单的辅助,而非替代。

这些都属于在本教程搭建的基础骨架之上的增量投入,具体投入优先级取决于团队实际的功能点录制量和对视频质感的要求,不需要在系统首次落地时就一次性做全。

CHAPTER 12

12 端到端实战演练:从一句话到一条成片

前面几章分模块讲清楚了原理和代码,本章把所有环节串起来,完整走一遍"从产品经理提出一句话需求,到最终拿到一条可以对外发布的成片"的真实工作流程。这条流水线跑顺了之后,一条视频从发起到成稿真实耗时大约 15~30 分钟,本章会具体拆解这个时间花在哪里。假设我们要做的功能点是本书反复引用的例子:"报表导出功能"。

12.1 需求提出

产品经理确认一个新功能已经上线到 staging 环境:用户在报表页面可以一键导出 Excel。产品经理不需要懂任何技术细节,只需要在共享文档里写下一句话:

"报表页面加了导出功能,用户选好时间范围和维度后点导出,能选 Excel 格式,导出的时候有进度条,做个演示视频。"

这句话加上顺手截的几张页面截图,就是本次录制任务的全部原始输入。

耗时:5 分钟

12.2 Codegen:一次性走完整个操作路径(很多时候这一步可以跳过)

如果 AI 能读到报表页面的前端代码,通常可以直接推断出选择器和操作顺序,这一步经常直接跳过,AI 直接产出 record.spec.js。本例假设页面代码 AI 读不太准(比如用了大量无语义的动态生成 class),需要走一遍 codegen 兜底:工程师打开目标页面,启动 codegen,一口气把整个操作路径走完(登录 → 报表页 → 选时间范围/维度 → 点导出 → 选 Excel → 确认),中途不关闭窗口:

npx playwright codegen --viewport-size=1440,900 \
  --output=feature-07-export-report/nav-draft.spec.js \
  https://staging.example.com/reports

Inspector 面板里实时生成的代码大致是:

await page.getByRole('link', { name: '报表' }).click();
await page.getByLabel('时间范围').click();
await page.getByText('最近30天').click();
await page.getByLabel('数据维度').selectOption('按渠道');
await page.getByRole('button', { name: '导出报表' }).click();
await page.getByText('Excel').click();
await page.getByRole('button', { name: '确认' }).click();

耗时:5 分钟(人工操作为主)

12.3 Sync:把草稿和需求一起交给 AI

工程师把 nav-draft.spec.js 和产品经理的原始描述一起交给 AI(17 章 17.1 节的方法),AI 产出正式的 record.spec.js(补上必要的等待条件,解说文案直接写成代码里的 step() 调用):

// record.spec.js(AI 整理后,解说文案直接嵌在代码里)
await page.getByLabel('时间范围').click();
await page.getByText('最近30天').click();
await page.getByLabel('数据维度').selectOption('按渠道');
step('首先进入报表页面,选择需要的时间范围和数据维度。');

await page.getByRole('button', { name: '导出报表' }).click();
step('点击右上角的导出按钮,会弹出导出格式选择。');

await page.getByText('Excel').click();
await page.getByRole('button', { name: '确认' }).click();
await page.getByText('导出成功').waitFor({ state: 'visible', timeout: 15000 });
step('选择 Excel 格式并确认,页面会显示导出进度直到完成。');

这份文件工程师没有手写一个字,AI 生成完直接进入下一步,不需要人工审阅——timeline.json 这时候还不存在,要等下一步真正录制时,step() 调用被执行到的那一刻才会自动生成,时间戳是真实经过的秒数,不是任何人猜出来的(3.3 节详细解释过这一点)。

耗时:2 分钟(AI 生成,直接进入下一步)

12.4 冒烟检查

正式录制前先跑一遍 headless 检查:

npx playwright test record.spec.js --headed=false

第一次运行报错:

Error: page.waitForSelector: Timeout 15000ms exceeded.
waiting for getByText('导出成功') to be visible

排查发现,实际页面上进度完成后的提示文案确实是"导出成功"——这次 AI 已经按真实页面文案写对了,报错原来是网络延迟导致弹窗渲染较慢,把超时时间从 15 秒调到 20 秒后重新跑通过。这类问题在正式录制前发现,成本是几十秒;如果没有这一步冒烟检查,同样的问题会在录制过程中才暴露,浪费的是一整条录像。

耗时:2 分钟

12.5 正式录制

冒烟检查通过后,运行真正的录制:

vt record feature-07-export-report

浏览器按 record.spec.js 的操作路径走一遍,ffmpeg 同步录屏,timeline.json 自动转换成 subtitles.srt。全程约 20 秒的操作,加上前后的准备/收尾,这一步实际耗时:

耗时:2 分钟

12.6 出片:srt → dub → mix 一次跑完

vt all feature-07-export-report

控制台输出大致是:

✅ 环境就绪 (ffmpeg + whisper)
✅ 字幕已存在且比录屏新(来自时间点文本),跳过语音识别
✅ AI 配音: ai_dub.wav
✅ feature-07-export-report.mp4

耗时:1 分钟

12.7 唯一的内容检查点:看一眼字幕文案(可选)

到这一步为止,工程师还没有编辑过任何一个字。subtitles.srt 现在已经是一份真实的、带精确时间戳的字幕——时间戳是第 12.5 步录制时自动记下来的,这一步完全不需要看,要看的只是文字本身:

1
00:00:00,000 --> 00:00:03,200
接下来,我们来看一下报表导出功能。

2
00:00:03,200 --> 00:00:09,100
首先进入报表页面,选择需要的时间范围和数据维度。

读一遍,发现第二句"首先进入报表页面"读起来有点生硬,改成"先打开报表页面"更口语化。改完这一处文字,跑:

vt redub feature-07-export-report

几十秒后拿到更新版的成片,全程没有碰过录制,也没有调整过任何时间戳——如果这一遍看下来文案已经没问题,这一步可以直接跳过,不是每次都要做的动作。

耗时:3 分钟(纯文字校对,可选)

12.8 审片与微调

完整播放一遍成片,发现配音和画面整体感觉配音稍微快了半拍——在 meta.json 里把 dub_offset 从 0 调成 0.4,重新走一遍最后的合成步骤(不需要重新录制、不需要重新生成配音):

vt mix feature-07-export-report

再看一遍,配音和画面对上了。按 10 章 10.4 节的清单走一遍:封面标题正确、字幕正确、音量正常、没有敏感信息(脱敏演示账号 + staging 环境)。审片通过,发布。

耗时:5 分钟

12.9 总耗时统计

阶段 耗时
需求提出 5 分钟
Codegen(人工走一遍操作路径,很多时候可跳过) 5 分钟
AI 生成 record.spec.js(不需要人工审阅) 2 分钟
冒烟检查与修正 2 分钟
正式录制 2 分钟
出片(srt→dub→mix) 1 分钟
字幕文案校对(可选) 3 分钟
审片与微调 5 分钟
合计 约 20~23 分钟

这就是 00 章开篇提到的"15~30 分钟成稿"的真实构成:人工唯一不可省略的环节是最后的审片;Codegen 只在 AI 猜不准选择器时才需要;字幕文案校对是可选的质量优化,不是流程里的固定步骤。timeline.json 从头到尾没有任何人碰过——如果这套系统被做成"人要根据时间点核对字幕对不对齐",那和传统人工做字幕、人工对轴没有本质区别,AI 真正的价值就是把这部分工作从人的清单里彻底拿掉。

12.10 维护性重录:UI 改版之后

三个月后,产品对导出按钮的文案从"导出报表"改成了"一键导出",弹窗位置也从右上角挪到了页面中间。工程师需要做的事情:

  1. 冒烟检查直接报错(选择器 getByRole('button', { name: '导出报表' }) 找不到元素),把报错交给 AI 按 17.2 节的自愈流程修一下,改成 { name: '一键导出' }——2 分钟。
  2. timeline.json 的文案本身不用改(解说词说的是"点击导出按钮",语义没变)。
  3. 重新 vt record(弹窗位置变了,画面需要重新录一遍,但操作还是自动化跑的,不需要人工重新摸索)——2 分钟。
  4. vt all 重新出片——1 分钟。
  5. 快速过一遍确认画面正确——2 分钟。

总耗时约 7~10 分钟,而且大部分是自动化在跑、人只需要间歇性确认。如果这次改动只是解说词里的某个措辞想换一种说法(画面完全不用变),流程还能进一步简化——直接编辑 subtitles.srt,跑 vt redub 重新生成配音和成片,全程不碰录制,几十秒出新版本。这正是 00 章 0.4 节"录制优先"架构要追求的收益:只有真正涉及界面变化的部分才需要重新录制,纯文案调整完全不需要碰视频。

CHAPTER 13

13 常见问题 FAQ

本章汇总从零搭建这套系统的过程中,团队成员大概率会问到的问题,按模块归类整理,每一条都给出可执行的建议而不是泛泛而谈。

13.1 关于技术选型

Q:一定要用 Playwright 吗?能不能用 Selenium 或 Puppeteer?

技术上都可以,核心思路(codegen 录制 → 清洗成脚本 → 驱动录制)与具体框架无关。选 Playwright 的实际理由是:它自带 codegen 工具且生成质量高(优先选语义化选择器)、自带 recordVideo 能力不需要额外拼接系统级录屏方案、对现代前端框架(React/Vue 的动态渲染)的等待机制(自动等待元素可交互)比 Selenium 更省心。如果团队已经有成熟的 Selenium 基础设施和大量沉淀的脚本,迁移成本需要纳入考量,不是必须换。Puppeteer 只支持 Chromium 系,没有官方 codegen 工具,作为本方案基础的适配成本更高,不推荐作为首选。

Q:为什么不直接用 OBS Studio 录屏,而要折腾 ffmpeg 命令行?

OBS 的优势是有图形界面、支持更丰富的实时特效(虚拟摄像头、场景切换、滤镜叠加),如果录制流程需要人工实时操作,OBS 体验更好。但本系统追求的是完全无人值守的自动化,ffmpeg 命令行天然适合被脚本调用、被 CI 调度,OBS 需要通过 WebSocket 插件才能被远程控制,链路更长、更脆弱。如果团队后续发现 ffmpeg 命令行拼接的画面效果(转场、字幕样式)满足不了要求,可以考虑升级到 11 章 11.5 节提到的 Remotion 方案,而不是引入需要图形界面交互的 OBS。

Q:TTS 一定要用云端付费服务吗?开源本地模型(如 Coqui TTS、VITS)行不行?

完全可以用本地开源 TTS 模型(比如 Coqui TTS),好处是没有按调用次数计费的成本、不依赖网络、数据不出本地机器(如果解说文案涉及敏感业务信息,这一点可能是硬性合规要求)。代价是:本地模型的音质、中文自然度目前普遍不如 edge-tts 这类基于商用神经语音引擎的免费方案;本地模型需要 GPU 或较强 CPU 才能达到可接受的合成速度。实践中 edge-tts 已经能覆盖绝大多数场景(免费、音质好、无需 Key),只有强合规要求必须完全离线时才需要投入本地模型——06 章的配音生成逻辑设计成一个可替换的步骤,换成本地模型只需要改 srt_to_dub_core 内部调用的命令,不影响前后其他环节。

Q:为什么解说文案直接写在 record.spec.js 代码里,不单独拆一份配置文件?

看起来"文案"和"操作代码"混在一起不够整洁,但拆开反而更麻烦——03 章 3.3 节讲过,timeline.json 的时间戳是脚本真实运行时自动记录的,如果解说文案放在另一份独立文件里,还需要额外维护"这句话对应脚本里哪一步"的映射关系,两份文件很容易改了一处忘了改另一处。文案直接写在触发对应操作的那一行代码旁边,天然保证"文案"和"操作"是绑定的,AI 生成/修改的时候也是一起改,不需要跨文件同步。

13.2 关于 codegen 与选择器稳定性

Q:如果前端团队完全不配合加 data-testid,选择器稳定性怎么办?

退而求其次,优先使用 getByRole + 可见文本的组合(这类选择器只要不改变元素的语义角色和展示文案就不会失效,UI 改版通常不会同时动这两者)。如果连语义化的 role/label 都没有(比如整个页面是用无语义的 <div> 堆出来的),那基础问题其实是这个产品前端的可访问性(accessibility)本身存在缺陷——这个反馈本身也是有价值的,值得同步给前端团队,因为可访问性和自动化测试友好性通常是一体两面的问题,解决了一个,另一个也会随之改善。

Q:codegen 录制出来的操作,如果涉及拖拽、canvas 画布、iframe 嵌套怎么处理?

拖拽操作 codegen 能录制但生成的坐标类代码通常比较脆弱(依赖具体像素位置),建议录制后手动改写为 Playwright 提供的 dragTo() 高层 API,基于元素定位而不是绝对坐标。Canvas 内部渲染的内容(比如图表)不是 DOM 元素,无法用常规选择器定位,只能通过坐标点击或者 canvas 自身暴露的事件回调触发,这类场景通常需要研发在被测系统里额外暴露一些便于自动化的钩子(比如给图表加一个隐藏的 DOM 层辅助定位)。iframe 嵌套需要先用 page.frameLocator() 定位到目标 frame 再进行后续操作,codegen 通常能自动处理这一层,生成的代码里会看到 frameLocator 调用,直接保留即可。

Q:录制环境和最终用户看到的生产环境如果有细微 UI 差异(比如 staging 环境有一条"测试环境"的顶部横幅),怎么处理?

两个方案:一是在 record.spec.js 的操作步骤里,加一段隐藏脚本在页面加载后主动隐藏这类环境标识横幅(用 Playwright 的 page.addStyleTag 注入一段 CSS 把它 display: none),这是最简单直接的方式;二是如果条件允许,为录制单独准备一个不带环境标识横幅的"演示专用"部署,长期看更干净,但需要额外的部署维护成本。多数团队采用方案一即可。

13.3 关于配音与节奏

Q:为什么不直接找真人配音,效果不是更自然吗?

真人配音的音质和情感表现力确实通常优于 TTS,但真人配音无法自动化——每次功能点微调都需要重新联系配音人员、重新录制、重新对齐时间轴,这正好是本系统要消除的瓶颈。折中方案是:对少数特别重要、面向外部客户的旗舰级演示视频,可以用真人配音(这时候本系统仍然有价值,字幕生成、时间轴计算逻辑同样适用,只是把 srt_to_dub_core 这一步换成"把 subtitles.srt 文案交给配音演员,拿到音频文件后直接替换掉 ai_dub.wav,走 08 章的合成流程");对占大多数的常规功能点视频,用 edge-tts 已经足够满足内部培训、常规客户演示的需求。

Q:多步骤的解说文案怎么保证读起来连贯,不会因为分段配音显得生硬?

分步配音确实会牺牲一部分"整体朗读的自然连贯性"(因为每一段是独立合成的,段与段之间没有真人朗读时自然的语气承接)。缓解方法有三个:一是撰写文案时刻意在每段末尾/下一段开头加入承接词("接下来""然后""完成后"),弥补语气断层;二是选择同一个发音人、同一套语速参数保持全片音色一致;三是在 08 章合成阶段,段与段之间保留几十毫秒的自然停顿而不是完全无缝拼接,模拟真人说话的换气感。这些手段不能做到和真人整体朗读完全一样自然,但对于功能演示这种以信息传达为主要目的的场景,观众的容忍度是足够的。

Q:如果 TTS 把某个专有名词/产品名读错了怎么办(比如把英文缩写按字母硬拼读,读法很怪)?

这是 06 章 6.2 节详细讲过的真实场景——edge-tts(默认方案)没有官方渠道支持 SSML <phoneme> 标签强制指定读音(传了也会被转义成字面文本整段读出来,实测确认过)。唯一可靠的解法不是找 hack 参数,而是维护一份"问题词 → 安全替换词"的表,换一种不触发歧义读音的表达方式(比如"命令行工具"读错音,换成"命令行")。如果升级到支持 SSML 的云端付费 TTS(比如 Azure Speech),才能用 <phoneme> 标签精确指定读音,但多数场景下用替换表已经够用,不需要为了这一个问题升级付费方案。

13.4 关于合成与导出

Q:视频里的背景音乐/字体是否有版权风险?

背景音乐必须使用团队拥有版权或者明确授权可商用的音轨(很多团队会采购正版商用音乐库的订阅,或使用明确标注可商用的开源音乐库),不能随意从视频网站下载音乐直接使用。字体同理,如果模板里用到了非系统自带的商用字体,需要确认该字体的授权范围是否覆盖"用于生成分发视频中的文字渲染"这种用法,不同字体厂商的授权条款差异很大,这一点建议在 07 章确定品牌视觉规范时就一并核实清楚,而不是等视频做完了才发现字体不能商用。

Q:成片文件很大,怎么压缩体积又不明显损失画质?

08 章 8.7 节给出的导出参数(-crf 16、-preset medium)偏向画质优先,正式导出这一步用的是硬件编码器 h264_videotoolbox。如果需要控制体积(比如要上传到有大小限制的平台),可以适度调高 -crf 值(数值越大压缩率越高、画质损失越明显,18~23 是常见的可接受范围)配合 -preset slower 用更多编码时间换取同等画质下更小的体积。另外,如果画面本身大部分是静态的表单/文字操作(而不是高速运动的视频内容),H.264 编码器天然对这类内容压缩效率较高,通常不需要过度担心体积问题。

Q:能不能同时导出竖屏(适合移动端/短视频平台)版本?

可以,08 章合成阶段把目标分辨率改成竖屏比例(如 1080x1920),并调整 07 章封面生成和操作录制时的取景逻辑(浏览器操作画面通常是横屏的,塞进竖屏画布需要额外做裁剪或加内容留白背景),这属于在现有架构上的一个中等工作量扩展,具体做法是在 meta.json 里加一个 orientation 字段,compose_final 读取这个字段决定用横屏还是竖屏的 scale/pad 参数。

13.5 关于团队协作与规模化

Q:多个人同时维护不同的 Feature Spec,会不会互相冲突?

因为每个功能点是独立的目录(feature-07-export-report/ 这样),天然没有文件层面的冲突。唯一需要协调的是公共代码(比如 04 章 4.5 节提到的登录共享函数)的修改——如果某人为了适配自己负责的功能点改动了公共登录函数的选择器,需要确保没有破坏其他人依赖同一个函数的功能点视频,这也是为什么 04 章 4.6 节强调每个脚本要有冒烟测试,团队应该把这些冒烟测试接入 CI,公共代码的修改一旦破坏了其他功能点的冒烟测试就会立刻被发现。

Q:功能点数量涨到几百个之后,还能靠人工审片吗?

10 章的人工审片清单在功能点数量较少时是可持续的,但规模上升后,纯人工审片确实会成为新的瓶颈。这时候合理的演进路径是:对首次录制的功能点坚持完整人工审片(因为这是内容第一次生成,风险最高);对维护性重录(12.10 节场景,只是选择器因 UI 微调而更新)可以采用抽样审片 + 自动化辅助检测(19 章讲的 AI 抽帧质检方案)的组合策略,把人工精力集中在真正的高风险变更上,而不是对每一次重录都同等对待。这个策略调整应该基于团队实际的录制量和历史泄露事件频率来决定,不是一开始就要设计到位的东西。

CHAPTER 14

14 自动化的边界与 ROI 考量

前面十几章都在讲"怎么做",本章退一步讨论一个更根本的问题:"什么时候值得做,什么时候不值得"。任何自动化系统都有建设成本和维护成本,把这套流水线无差别地套用到所有场景,反而可能得不偿失。本章帮助你判断投入的边界。

14.1 什么样的场景最适合这套系统

回顾 12 章的实战案例可以总结出几个特征,符合的特征越多,投入这套系统的 ROI(投资回报率)越高:

  • 功能点数量多、发布频率高:如果一个团队每周都要产出好几条功能演示视频,前期搭建流水线的固定成本(大约相当于本教程 00~09 章的实现工作量,一次性投入)会被后续每一条视频节省下来的人工时间快速摊薄。如果一个季度只需要做两三条视频,人工录制剪辑可能比建设自动化系统更划算。
  • 功能点会持续迭代、视频需要跟着重录:这是这套系统真正的差异化价值所在(12.10 节演示过的"维护性重录"场景)。如果视频录一次之后几乎不需要更新,自动化带来的长期复用收益就无从体现,价值会大打折扣。
  • 对视频风格一致性有要求:多人协作产出内容时,自动化流水线天然保证了封面、字幕、语速、品牌色的统一,如果团队本来就是一个人负责所有录制、风格统一不是问题,这一项收益也不明显。
  • 底层产品有较好的前端语义化基础(表单有 label、按钮有明确的可访问性角色):04 章反复强调选择器稳定性依赖前端配合,如果目标产品前端代码质量较差(大量无语义的 div 套 div),录制脚本的维护成本会显著上升,需要把这部分额外成本也计入 ROI 考量。

14.2 什么样的场景不建议投入这套系统

  • 一次性的、不会重复的演示需求(比如临时给某个客户做一次定制化的产品走查录屏),人工录制 + 简单剪辑往往比搭建/适配一个 Feature Spec 更快。
  • 内容高度依赖真人临场表达的场景(比如直播答疑、复杂业务场景的顾问式讲解,讲解逻辑会根据听众反馈实时调整),这类内容的核心价值恰恰在于"不是流程化的",自动化会削弱而不是增强其价值。
  • 目标系统是极度不稳定、UI 频繁大改的早期产品(比如仍在快速原型迭代阶段、UI 框架都可能推倒重来的项目),这时候投入选择器维护的成本会持续吃掉自动化节省下来的时间,不如等产品 UI 相对稳定后再引入这套系统。
  • 团队规模过小、没有专人负责维护:这套系统虽然运行起来是自动化的,但 04 章的选择器维护、10 章的清单执行、11 章的故障排查仍然需要有人负责,如果没有明确的责任人,久而久之这套系统会变成"没人维护的遗留系统",反而增加认知负担。

14.3 分阶段投入建议

不建议一开始就把 00~11 章的所有能力一次性建完,更务实的路径是分阶段验证价值后再逐步加码投入:

阶段一(验证阶段,投入 1~2 天):只搭建 01~05 章(环境、脚手架、Spec、codegen、录制),先解决"自动化操作 + 自动化录制"这个最核心的痛点,配音先用最简单的方式(比如直接不加解说,或者先用离线 TTS 凑合),封面先用一张静态图代替动态生成。用这个最小可用版本先给两三个真实功能点录制视频,收集团队反馈,确认"自动化录制"这个方向本身值得投入。

阶段二(提升阶段,投入 2~3 天):在验证有效的基础上,补齐 06~08 章的配音、封面、合成能力,把视频质感提升到可以正式对外发布的水准。

阶段三(规模化阶段,按需投入):当功能点数量、维护频率真正上升到需要考虑效率优化时,再投入 09 章的完整编排器自动化、11 章的并行处理/CI 集成/多语言扩展。这些能力在功能点数量少的时候投入产出比并不高,过早建设容易造成过度工程化。

这个分阶段思路本身也符合"不要为假设的未来需求设计"的工程原则——先用最小系统验证核心假设(自动化录制能不能真正节省时间、产出质量能不能被团队接受),再根据实际反馈决定下一步投入多少。

14.4 与商业化 SaaS 方案的对比

市面上也存在一些商业化的"产品演示自动化录制"SaaS 工具,通常提供更完善的图形化编辑界面、模板市场、团队协作功能。是否要用商业方案替代自建系统,可以从以下几个维度权衡:

  • 数据敏感性:自建方案的所有数据(演示账号、staging 环境地址、解说文案)都留在自己的基础设施内,商业 SaaS 通常需要把这些信息交给第三方平台处理,如果业务对数据出境/第三方托管有合规要求,自建更稳妥。
  • 定制自由度:自建方案的每一层(codegen 清洗规则、TTS 供应商、合成参数)都可以按团队实际需求深度定制,商业 SaaS 的能力边界取决于该产品是否恰好覆盖你的场景,遇到边界之外的需求往往没有办法。
  • 团队技术能力:自建方案需要团队具备本教程涉及的 Node.js/Playwright/ffmpeg 相关工程能力并持续投入维护;如果团队没有这类工程资源,采购一个"开箱即用"的商业方案可能是更现实的选择。
  • 长期成本结构:商业 SaaS 通常是按席位/用量的持续订阅费用,自建方案是前期一次性工程投入 + 较低的长期运维成本(主要是云端 TTS 的按量调用费用),录制量越大、时间跨度越长,自建方案的长期成本优势越明显。

不存在绝对正确的选择,本教程提供的是"自建"这条路径的完整技术方案,供已经决定或倾向于自建的团队参考;如果团队评估下来更适合采购商业方案,本教程里 03 章的 Feature Spec 设计思路、10 章的质量检查清单,仍然可以作为评估/使用任何商业工具时的参考标准。

14.5 一个简化的量化 ROI 估算框架

如果需要向团队/管理层证明这套系统值得投入,可以用下面这个简化框架做一次量化估算,而不是仅凭直觉判断。

第一步:估算人工录制的单位成本。 记录团队过去几次人工录制+剪辑一条功能演示视频的实际耗时(包含操作、录屏、配音、剪辑、审片全流程),取平均值,记为 T_manual(单位:小时)。假设团队人力成本折算为每小时 C 元,则单条视频的人工成本约为 T_manual × C。

第二步:估算自建系统的一次性建设成本。 参考 14.3 节的分阶段投入建议,估算完成阶段一/阶段二所需的工程师人天数,记为 D_build,折算成本为 D_build × 8 × C(假设每人天 8 小时)。

第三步:估算自动化后单条视频的边际成本。 包括首次为一个新功能点建立 Feature Spec + codegen 片段的耗时(12 章案例约 2 小时),加上后续维护性重录的平均耗时(12.10 节案例约 20 分钟,假设一条视频生命周期内平均需要维护重录 N 次)。单条视频在系统生命周期内的总边际成本约为 (2小时 + N × 20分钟) × C,再加上云端 TTS 等按量计费的直接费用(通常远小于人力成本,可以先忽略或按实际单价补充)。

第四步:计算盈亏平衡点。 设团队计划在系统生命周期内产出 V 条功能演示视频,比较两种路径的总成本:

人工路径总成本      = V × T_manual × C
自建系统路径总成本  = D_build × 8 × C  +  V × (2小时 + N × 20分钟) × C

令两者相等可以解出盈亏平衡点对应的视频条数 V*,当计划产出视频数量 V 明显超过 V* 时,自建系统在经济上是划算的;如果 V 远小于 V*,则维持人工路径或采购商业方案(14.4 节)可能是更理性的选择。

这个框架刻意做了简化(没有考虑质量提升、风格一致性等难以直接货币化的收益,也没有考虑维护这套系统本身占用的团队注意力这类隐性成本),实际决策时建议把这些定性因素作为量化结果的补充说明一并呈现,而不是仅凭一个数字下结论。

CHAPTER 15

15 术语表与延伸阅读

15.1 术语表

术语 说明 对应章节
IDRP(Intent-Driven Recording Pipeline) 本教程自定义的名称,指代全书构建的"意图驱动录制流水线"系统 00 章
Feature Spec(本书的统称,非某个具体文件名) 描述一个功能点"意图"的三份文件的合称:record.spec.js(操作代码+解说文案)、timeline.json(录制自动生成的时间戳)、meta.json(展示配置) 03 章
Codegen Playwright 提供的"操作即代码"录制工具,把人工操作转成可重放脚本 04 章
选择器(Locator/Selector) 自动化脚本定位页面元素的方式,稳定性直接决定脚本的可维护性 04 章
recordVideo Playwright BrowserContext 内置的浏览器视口录制能力 05 章
系统级录屏 通过 ffmpeg + 操作系统原生采集设备(avfoundation/x11grab/gdigrab)捕获整个屏幕画面 05 章
Xvfb Linux 下的虚拟显示器(X Virtual Framebuffer),用于无真实显示器的服务器/CI 环境模拟图形界面 05 章、11 章
TTS(Text-to-Speech) 文本转语音,本系统用于自动生成解说配音 06 章
录制优先 本系统的核心节奏设计:先把操作按自然节奏录下来,字幕/配音在录制之后再适配这段固定的时间轴,而不是反过来用配音时长驱动录制节奏 00 章、06 章
响度归一化(Loudness Normalization) 用 ffmpeg loudnorm 滤镜把音频统一调整到标准感知响度(如 -16 LUFS),保证多段拼接后音量一致 06 章、08 章
concat demuxer ffmpeg 的一种无损拼接模式,要求所有输入流编码参数完全一致 08 章
SRT 一种常见的字幕文件格式,包含序号、起止时间戳、文本三部分 08 章
硬字幕/软字幕(burned_in / soft) 硬字幕是把字幕像素直接烧录进画面(兼容性最好但不可关闭),软字幕是作为独立字幕轨封装进容器(可开关/切换语言但依赖播放器支持) 08 章
Preflight 正式录制前,用 headless 浏览器提前跑一遍所有操作脚本以确认可执行性的检查步骤 04 章 4.6 节
编排器(Orchestrator) 把配音生成、操作录制、音视频合成等各模块按顺序调用起来的总控命令,本书里就是 video-toolkit.sh 这个入口脚本(vt 命令背后的实现) 09 章
幂等性(Idempotency) 指多次对同一个功能点执行相同命令,只要输入文件不变,会得到内容一致的输出 09 章
RULE.md 项目级规则文件:解说文案的语气基调、字数区间、防泄露约束等所有功能点都要遵守的规范,一次写好,AI 处理每个新功能点时自动遵守 02 章、17 章
vt 不是需要单独安装的软件,就是本书搭出来的入口脚本(video-toolkit.sh)的一个命令行短别名——你自己实现时可以叫任何名字,书里统一用它只是为了命令示例整齐 09 章
loudnorm / drawtext / subtitles ffmpeg 中分别用于响度归一化、绘制文字(封面)、渲染字幕的滤镜 01 章、07 章、08 章
ROI(投资回报率) 本教程 14 章用来衡量"这套自动化系统是否值得为某个场景投入建设"的核心判断标准 14 章

15.2 全书能力速查

如果需要快速定位某个能力对应本书哪一段实现,可以参考下表(对应 02 章约定的工具目录结构:主入口 video-toolkit.sh + lib/ 下的辅助脚本):

能力 实现位置
操作代码 + 内嵌解说文案 每个功能点目录下的 record.spec.js(03/04章)
codegen 草稿 nav-draft.spec.js,vt codegen 命令产出(04章)
录制自动生成时间戳 timeline.json,record.spec.js 里的 step() 调用副产品(03章3.3节)
系统级录屏 + 无人值守细节 video-toolkit.sh 的 cmd_record/detect_recording_screen(05章)
字幕来源判定(时间点文本/ASR兜底) extract_srt(06章6.1节)
按字幕自然语速配音 srt_to_dub_core(06章6.2节,多音字修复表也在这里)
多语言字幕翻译 translate_srt(06章6.3节,调用 DeepSeek API)
封面生成 gen_title_card_png/gen_title_card(lib/compose.sh,ImageMagick画图+ffmpeg转视频,见07章)
合成主流程(裁剪/混流/偏移/拼接/BGM) compose/compose_final(lib/compose.sh,08章)
字幕烧录 cmd_burn(08章8.6节)
meta.json 三级配置合并 load_meta/meta_get/resolve_asset(lib/meta.sh,09章9.4节)
命令分发入口 video-toolkit.sh 的 main/命令行 case 分支(09章9.3节)

15.3 延伸阅读方向

本教程覆盖的是一条完整可用的主线方案,以下方向如果后续需要深入,可以作为独立的学习专题:

  • Playwright 官方文档中的 Auto-waiting 机制:理解 Playwright 为什么大多数场景不需要手写 waitForTimeout 也能保证操作时序正确,这对 04 章清洗脚本时判断"哪些等待是必要的、哪些是多余的"很有帮助。
  • ffmpeg 滤镜图(Filter Graph)语法:本教程用到的滤镜都是相对独立的单个调用,如果需要在一次 ffmpeg 命令里组合更复杂的多路输入输出处理(比如同时做转场+字幕+混音),需要理解 -filter_complex 里节点命名和连接的语法规则。
  • SSML(Speech Synthesis Markup Language)规范:06 章用到的 <prosody> 标签只是 SSML 能力的一小部分,深入使用可以做到更精细的停顿、重音、多语言混读控制。
  • WCAG 无障碍可访问性规范:04 章多次提到的"语义化选择器依赖前端可访问性基础",如果希望从源头推动前端团队改善这一点,WCAG 规范里关于可交互元素语义角色(role)、可访问名称(accessible name)的章节是最直接的参考依据,同时这也是一项独立于本教程之外、对产品本身有价值的工程投入。
  • 视频编码基础(H.264/H.265、码率控制、CRF 模式):如果对 08 章导出参数的选择想有更深入的理解(为什么用 CRF 而不是固定码率、不同 preset 之间具体的速度质量权衡曲线),可以专门学习视频编码的基础原理。

15.4 结语

从 00 章的架构设计到 09 章的完整实现,本教程用一台干净的机器为起点,构建了一套完整可运行的"意图驱动录制流水线",覆盖了环境搭建、意图结构化、浏览器自动化、屏幕录制、自动配音、封面生成、音视频合成、全流程编排、质量把关、故障排查与规模化扩展的全部环节。14 章特别强调了这套系统并非在所有场景下都值得投入——技术方案的价值最终要放回具体的团队规模、录制频率、产品迭代节奏中去评估。希望这份教程能帮助你判断清楚这件事是否值得做,以及如果值得做,如何用最朴素、最可维护的方式把它做出来。

Part

AI 协同实战

全书最终想分享的经验:这一切在真实团队里是怎么被 AI 承接执行的。

CHAPTER 16

16 AI 是真正的操作者:重新划分人机分工

前 15 章描述的是这套流水线的机制——需要发生什么、record.spec.js/timeline.json/meta.json 各自长什么样、ffmpeg 该怎么调、编排器该怎么串联。如果你真的按那 15 章的字面意思去实施,会得到一个能跑的系统,但会错过一个更重要的事实:在实践中,这套机制里的大部分"体力活"根本不是人手工完成的,而是交给一个 AI coding agent(比如 Claude Code)来做的。人类的实际参与被压缩到远比前文暗示的更少。这一章把这个事实摆到台面上,重新说清楚"人做什么、AI 做什么"。

16.1 一张更诚实的分工表

把 00 章 0.3 节的分工,换成实践中真实发生的样子:

环节 对应章节 字面读法(15章之前给人的印象) 实际发生的样子
record.spec.js / timeline.json 整理 03 章 人根据 codegen 草稿手写选择器和时间点文本 人只提供 codegen 草稿和一句话意图,AI agent 直接把选择器整理进脚本、草拟时间点文本,人只审阅(对应真实的 sync 环节)
Codegen 脚本清洗 04 章 人手动清洗 codegen 产出的代码 多数时候人还是要亲自操作一遍浏览器(保证操作路径符合真实业务语义),但清洗、修选择器、写冒烟测试这些都丢给 AI
Codegen 录制本身 04 章 人打开浏览器手动操作 少数场景下这一步也由 AI agent 自主完成——给它一个浏览器控制工具和一句话目标,它自己摸索出操作路径
Preflight 报错排查 04 章 人看报错、改代码 AI 读报错信息和页面结构,自己改脚本、自己重跑,形成一个自愈循环(17 章细讲)
解说文案撰写 06 章 人根据语气基调手写,或 LLM 辅助扩写后人工大改 AI 根据 Spec 和页面上下文直接产出全篇文案,人类的动作只是"读一遍、顺一顺措辞"(18 章细讲)
编排器 Shell 代码 09 章 人手写全部模块 把本书当设计文档喂给 AI,AI 直接生成大部分实现,人负责代码审查
录制后审片 10 章 人逐帧看视频、对照清单 AI 抽帧+读取截图,自动比对检查清单,产出结构化风险报告,人只看报告里标红的几条(19 章细讲)

看这张表会发现一个规律:几乎每一行"实际发生的样子"里,人类的角色都从"执行者"变成了"审阅者"。这不是偶然,而是这套系统设计上从一开始就在追求的方向(00 章 0.2 节的"三个最小化"),只是 15 章之前的写法里,"最小化"还停留在"减少人工操作步骤"的层面,而实践中已经进化到"把几乎所有需要判断力和执行力的环节都交给 AI,人只做最终把关"。

16.2 为什么 03~15 章仍然值得读——它们是喂给 AI 的设计文档

一个自然的问题是:既然 AI 能替你做这么多,为什么还要读前面 15 章讲的三份文件的职责划分、ffmpeg 参数、具体实现细节?

答案是:AI agent 不是凭空知道该怎么做这些事的,它需要一份足够精确的设计文档作为上下文,前 15 章正是这份文档。具体来说:

  • 03 章讲的 record.spec.js / timeline.json / meta.json 三份文件各自的职责边界,是你要喂给 AI 的输出格式规范——告诉它"操作代码归这份文件、时间点文本归那份文件",AI 才能产出可以直接被后续流程消费的结果,而不是一段自由散漫的文字。
  • 00 章 0.4 节"录制优先"的原理,是你要喂给 AI 的约束条件——如果不告诉它这个原理,AI 可能会想当然地假设"配音时长应该反过来决定画面节奏",按错误的方向去设计时间点文本或调整脚本等待时间。
  • 09 章的编排器架构,是你要喂给 AI 的实现蓝图——直接把这一章的代码和思路交给 AI,让它按图施工,比让它自己从零设计一套架构要可靠得多(AI 自由发挥时容易设计出过度复杂或者不符合你实际约束的方案)。
  • 10 章的检查清单,是你要喂给 AI 的质检标准——19 章会展示如何把这份清单原样交给一个多模态模型,让它照着清单去审查录制画面。

换句话说:这本书前 15 章的真实定位,不是"让人类工程师照着写代码的手册",而是"让 AI agent 理解这套系统该怎么运作的系统提示词/设计文档"。这也解释了为什么这本书值得写得这么详细——文档写得越精确,AI 执行出来的结果就越可靠,人类需要介入纠正的次数就越少。

16.3 人类不可替代的三个环节

尽管 AI 承担了大部分工作,仍然有三类判断,本书作者团队的实践中坚持由人类完成,原因各不相同:

第一,最初的业务意图判断。功能到底是什么、为什么要做、给谁看——这是 00 章"一句话意图"的来源,这句话本身仍然需要一个懂业务的人来说。AI 可以把一句话扩写成完整 Spec,但它不能替你决定"这个功能点值不值得录、该强调哪个卖点",这是业务判断,不是执行判断。

第二,真实操作路径的示范(多数情况下)。17.3 节会讨论"AI 自主 codegen"这个更激进的模式,但目前阶段它的成功率和稳定性还不如人工操作一遍来得可靠——人在操作浏览器时,会自然地按照真实用户的心智模型去点击(先看什么、犹豫在哪里、习惯性的操作顺序),这种"操作路径本身传达业务语义"的信息,目前的 AI agent 还不能稳定地凭空生成。因此人工操作一遍 + AI 负责后续所有清洗/调试,仍然是目前最可靠的组合,而不是完全交给 AI 摸索。

第三,最终发布前的签字。19 章会讲 AI 如何自动生成审片报告,但 10 章 10.7 节强调的责任归属原则依然成立:AI 报告"没发现问题"不代表可以跳过人工确认。AI 的漏检率不是零,把最终的发布责任交给一个自动化脚本承担,是不负责任的系统设计。人类审阅 AI 报告里标记的风险点,比人类从头到尾自己看一遍视频,工作量小得多,但"人在回路里做最终判断"这件事本身不能省略。

16.4 这意味着团队角色要怎么调整

对照 00 章 0.6 节最初设计的团队角色分工(需求提出方 / 录制工程师 / 系统维护者 / 审片人),加入 AI 之后需要补充一条:录制工程师的日常工作,从"写代码、清洗脚本、写文案"变成了"和 AI agent 对话、审阅它的产出、在它卡住的地方给出关键提示"。这不是角色的削弱,而是角色重心的转移——录制工程师仍然需要理解 03~15 章讲的全部原理(否则没法判断 AI 的产出对不对、没法在 AI 卡住时给出有效提示),但花在"亲手打字"上的时间大幅减少,花在"审阅和决策"上的时间占比上升。

如果团队里有人担心"AI 把活都干了,工程师是不是没用了"——恰恰相反,这套系统里工程师的价值密度更高了:不再需要花时间在重复性的选择器调试、样板代码编写上,而是把认知资源集中在业务判断、架构设计、质量把关这些机器目前还做不好的环节。

接下来三章分别展开 17(Spec 与 Codegen)、18(解说文案与字幕)、19(自动化质检)里 AI 具体是怎么工作的,包含真实可用的 prompt 模板和代码实现。20 章用一个完整案例把三者串起来,和 12 章的"传统流程"版本做对比。

CHAPTER 17

17 用 AI 设计与调试 record.spec.js / timeline.json

本章把 16 章的分工表落到具体操作:怎么和 AI agent 对话,才能让它可靠地产出 03 章讲的 record.spec.js,以及怎么让它接管 04 章里最耗时的选择器调试环节。

17.1 规则写一次,之后每个功能点真的只要一句话

一个常见的误解是:既然要让 AI 稳定产出高质量结果,是不是每次都要写一段很长的任务描述,把格式要求、字数限制、防泄露约束全部重新说一遍?不是。正确的做法分两层:

第一层,项目级规则写一次:在项目根目录放一份 RULE.md(02 章 2.1 节提到过),把所有功能点都要遵守的规范一次性写清楚——解说文案的语气基调、字数区间、命名规范、绝不能出现的内容(真实客户名称、内部代号)。这份文件放在项目根目录,多数 AI agent 工具(比如 Claude Code)会自动把仓库根目录的这类规则文件当作常驻上下文,不需要每次任务都手动附上。

第二层,每个新功能点,人只需要说一句话:

报表导出,用户选好时间范围和维度后点导出,能选 Excel 格式,导出的时候有进度条。

就这一句话,交给 AI,剩下的全部交给它:读目标页面代码理解真实交互、梳理操作步骤、按 RULE.md 里的规范写解说文案、生成 record.spec.js。人不需要在这句话里重复"字数控制在多少""不要提到客户名称"这类约束——那些已经在 RULE.md 里说过了,AI 应该自己记得。

如果 RULE.md 里还没写过、又是这次任务特有的要求(比如"这次要强调 Excel 格式这个卖点"),才需要在这一句话里补一句,而不是每次都把所有规则重新列一遍。

为什么这样分层,而不是每次都写一段完整的任务描述:把稳定不变的规则和每次都变的具体需求混在一起写,会导致两个问题——一是每次任务描述都很长,人懒得写、写起来也容易漏项;二是规则本身要调整时(比如字数区间改了),要去改所有历史任务记录里的措辞,而不是改一个地方。RULE.md 独立存在,改一次对所有后续任务生效,这和 09 章 meta.json 三级配置"改一处、全局生效"是同一个设计原则。

AI 读目标页面代码,而不是凭描述臆测,这一点仍然重要——如果只告诉 AI 一句大白话"报表页面有导出功能",它会根据训练数据里"报表导出"这个概念的常见模式去"脑补"一个页面长什么样,脑补出来的按钮文案、交互顺序和真实页面很可能对不上。所以那一句话之外,真正驱动生成质量的是给 AI 读代码的权限——它能不能访问到目标页面的真实前端代码,比任务描述写得多详细更重要。

17.2 AI 自愈式调试循环:Codegen 选择器修复

04 章 4.6 节讲过冒烟测试的价值,这里把"冒烟测试失败之后怎么办"这一步也交给 AI,形成一个闭环:

┌──────────────────┐
│ 运行冒烟测试        │
└─────────┬────────┘
          │ 失败
          ▼
┌──────────────────────────────┐
│ 把以下信息交给 AI agent:       │
│ - 完整报错堆栈(page.waitForSelector timeout 等) │
│ - 出错的脚本文件内容            │
│ - 目标页面当前的 DOM 结构        │
│   (用 page.content() 或截图辅助) │
└─────────┬─────────────────────┘
          │
          ▼
┌──────────────────────────────┐
│ AI 分析报错原因,常见几类:       │
│ - 选择器文案对不上(如"导出完成" vs "导出成功") │
│ - 元素还没渲染出来,缺少等待条件    │
│ - 页面结构变化,原选择器整体失效     │
│ 并直接修改脚本文件               │
└─────────┬─────────────────────┘
          │
          ▼
┌──────────────────┐
│ 重新运行冒烟测试     │
└─────────┬────────┘
          │
     ┌────┴────┐
   通过        仍失败(重试计数+1)
     │            │
     ▼            ▼
   结束      超过最大重试次数?
              │         │
             否        是
              │         │
        回到分析步骤   升级给人工介入

设置一个最大重试次数(实践中 3~5 次是合理值)是这个循环里唯一需要人工预先决定的参数——超过这个次数还修不好,通常说明问题不是"选择器写错了"这种表层原因,而是页面交互逻辑发生了根本性变化(比如整个操作流程被重新设计),这类情况应该交回人工判断,而不是让 AI 无限重试下去消耗时间和 API 调用成本。

把这个循环接入 09 章的编排器,作为 Preflight 失败之后的自动响应,而不是让 Preflight 直接报错中止:

# lib/self_heal.sh
MAX_RETRIES=4

self_heal_codegen_step() {
  local script_path="$1"
  local error_log="$2"
  local attempt=1

  while [ "$attempt" -le "$MAX_RETRIES" ]; do
    # 调用 AI agent CLI(以 claude 命令行为例),把报错和脚本路径作为上下文传入,
    # 要求它直接修改文件后返回;具体 CLI 调用方式取决于你使用的 agent 工具
    local prompt="以下 Playwright 脚本在冒烟测试中报错,请分析原因并直接修改文件。
文件路径:${script_path}
报错信息:
${error_log}
只修改必要的部分(通常是选择器或等待条件),不要重写整个文件结构。"

    claude -p "$prompt" --allowedTools "Edit,Read"

    # 重新跑一次冒烟测试,通过则返回,否则带着新的报错信息进入下一轮
    if error_log=$(node "$script_path" 2>&1); then
      return 0
    fi
    attempt=$((attempt + 1))
  done
  return 1  # 超过重试次数,交回人工
}

这段代码的关键不在于具体调用哪个 agent CLI(不同团队用的工具不同),而在于把"报错 → 交给 AI → 验证 → 重试"这个循环变成编排器里的标准环节,而不是让人工每次都手动复制报错信息去问 AI。一旦这个循环跑起来,04 章里"选择器维护"这项本来最琐碎的长期成本,就从人工的日常工作里基本消失了。

17.3 更激进的模式:让 AI 自己完成 Codegen

16.3 节提到,多数情况下仍然是人工操作一遍浏览器、AI 负责后续清洗调试。但存在一种更激进的模式,值得了解:给 AI agent 一个浏览器控制工具(能像人一样点击、输入、截图观察结果),只告诉它目标是什么,让它自己摸索出完整的操作路径。

任务描述大致是:

目标:在 https://staging.example.com/reports 页面上,完成"导出当前筛选条件下的
报表数据为 Excel"这个操作。

你可以使用浏览器工具截图观察当前页面状态、点击元素、输入文本。
请找到完整的操作路径,并在完成后:
1. 用 Playwright 的语义化选择器(getByRole/getByLabel/getByText)把这个路径
   写成一个可重放的 TypeScript 函数
2. 函数命名和存放路径参照 04 章约定
3. 附上一份你实际尝试过程的简要记录,说明中间是否有走错的分支

这种模式的优点是彻底不需要人工碰浏览器,理论上可以把整个 Codegen 环节也自动化掉。但实践中它目前还有两个明显局限:

  • 成功率不如人工操作稳定。AI 在探索页面时可能会走一些人类一眼就能避开的弯路(比如尝试点击一个视觉上像按钮但实际不可交互的元素),复杂的多步骤流程尤其容易出现探索失败或者路径不是最优的情况。
  • 操作路径不一定符合真实用户的心智模型。AI 摸索出来的路径可能是"技术上能达成目标"的路径,但不是"真实用户最自然会走的路径",而演示视频恰恰需要还原真实用户的操作习惯,这一点上人工操作目前仍然更可靠。

因此现阶段的实践建议是:把这种模式当作"人工操作路径的补充/交叉验证手段",而不是默认路径——比如人工操作一遍产出主路径后,让 AI 用这种模式再独立探索一次,如果两条路径不一致,正好可以发现人工操作时可能存在的非典型习惯(比如走了一条并非最短路径的操作序列),或者发现页面上确实存在一条更符合直觉的替代路径。随着底层 agent 的浏览器操作能力持续进步,这个局限会逐渐缩小,值得每隔一段时间重新评估这种模式是否已经可以作为默认路径。

17.4 小结

本章把 04 章里最耗费人工时间的两个环节——脚本清洗调试、以及(可选地)操作路径探索本身——都交给了 AI。结合上一章 16.2 节的观点:03~04 章的文字内容,此刻的作用已经从"人要执行的步骤说明"转变为"AI 应该遵守的规范和应该达成的目标描述"。下一章讲同样的思路如何应用在解说文案与字幕的生成上。

CHAPTER 18

18 用 AI 生成解说文案与字幕

06 章讲了字幕的两个来源(时间点文本、语音识别兜底)和自然语速配音的技术实现,但刻意没有深入讲"时间点文本里的话到底是怎么写出来的"。本章把这一点摊开讲清楚:实践中这份文本几乎全部由 AI 起草,人类的工作被压缩为"读一遍、顺一顺措辞",这是全流程里人类编辑强度最低、也是最应该被更多人知道的一个环节。

18.1 为什么文案生成特别适合交给 AI

对比 record.spec.js 的整理(17 章,AI 需要先读代码理解真实交互)和文案撰写,后者对 AI 来说是一个约束更少、AI 天然更擅长的任务:给定"这一步做了什么操作"和"整体语气基调",生成一句通顺、专业、长度合适的解说词,这正是语言模型最擅长的事情之一。真正决定文案质量的不是"AI 会不会写",而是你有没有把足够的上下文和约束喂给它——这也是本章的重点。

18.2 一次性生成整篇文案,而不是逐步单独请求

13.3 节提到过一个问题:分步配音会牺牲整体朗读的自然连贯性。这个问题在文案撰写阶段就可以缓解大半——秘诀是把整个功能点的全部步骤一次性交给 AI,要求它把解说文案当作一篇完整的短文来写,再按操作步骤切分成 timeline.json 的各个条目,而不是对每一步分别发起独立请求。这一步通常紧跟在 17 章的 sync(把 codegen 草稿整理进 record.spec.js)之后一起做,AI 已经读过完整的操作代码,正好顺手把时间点文本也一并起草出来。

任务描述示例:

请为下面这段 record.spec.js 里的每个操作步骤撰写解说文案,输出格式为
timeline.json(数组,每项 { "t": 秒数, "text": "..." }),
"t" 按操作在脚本里出现的大致顺序估算一个合理的时间点即可,不需要精确到毫秒
(真实时间点由06章讲的字幕生成逻辑在实际录像基础上确定,这里只是草稿)。

[附:feature-07-export-report/record.spec.js 全文]

功能点:报表导出
要求:
- 把整个操作流程当作一篇完整的解说词来构思,步骤之间要有自然的承接
  (参考 13.3 节的建议,可以用"接下来""然后""完成后"这类过渡词)
- 语气参考项目级 meta.json 里约定的调性:"专业、简洁、面向企业客户"
- 每步字数控制在 15~40 字之间(对应中文语速约每分钟280字的估算,
  超出这个区间的步骤请重新精简或拆分内容)
- 不要提及任何真实客户名称、内部系统代号(参照第10章检查清单)
- 输出前自己数一遍每步字数,超标的重写

因为是"一次性通篇构思、再切分",AI 天然会在措辞上做步骤间的呼应(比如第2步用"首先"、第4步用"最后"这种自然的行文习惯),生成结果比逐步单独请求要连贯得多,这一点在最终配音听感上有明显差别。这一步产出的 timeline.json 是草稿,真正走完 05 章的录制之后,还需要对照实际录像核对一遍时间点是否大致合理(06 章会把它转成 subtitles.srt,时间戳到那一步才是最终定稿)。

18.3 把页面截图一起喂给 AI,减少文案和实际画面对不上的风险

纯文本描述"这一步做了什么"有时会造成偏差——比如人工用一句话概括"点击导出按钮",但实际页面上那个按钮的文案是"导出报表"而不是"导出",如果 AI 严格照着人工给的文本生成文案,可能会说出和画面上按钮文字不一致的话,看起来比较"出戏"。更可靠的做法是把 04 章 codegen 阶段截取的关键帧截图,随操作脚本一起交给 AI(多数现代 agent 工具支持图文混合输入):

请看这几张页面截图(对应下面几个步骤),确认解说文案里提到的按钮名称、
字段名称和截图上显示的实际文字完全一致,如果发现文本骨架里的描述和
截图对不上,请以截图为准调整措辞。

[附:open-report-page.png, click-export.png, choose-excel-confirm.png]

这一步能显著减少"解说词说的和画面上看到的对不上"这类细节瑕疵,而这类瑕疵恰恰是 10 章审片环节最容易发现、也最影响专业观感的问题。

18.4 字幕微调:人类唯一保留的编辑动作

AI 产出的文案草稿,直接进入 06 章的 TTS 流程之前,人类要做的编辑通常只涉及三类调整:

  1. 口语化微调:AI 有时会写得偏书面语(比如"系统将会执行导出操作"),人工顺成更口语的说法("点一下就会开始导出")。
  2. 术语校正:AI 可能不知道团队内部对某个功能的习惯称呼(比如内部一直叫"一键导出"而不是"批量导出"),这类术语偏好人工一次性告诉 AI 后,可以写进项目级的常驻上下文里,后续就不用每次都改。
  3. 合规/敏感措辞把关:即使已经在任务描述里要求 AI 不要提及真实客户/内部代号(18.2 节的约束),人工过一遍仍然是必要的最后一道防线,这一点和 10 章、16.3 节强调的"人类不可替代的签字环节"是同一个道理,不能因为前置约束写了就完全省略复核。

这三类调整通常只需要几分钟,是全书里"人工编辑成本最低"的一环,这也是为什么 12.9 节的耗时统计里,"文案审阅"只占整个流程的一小部分时间。

18.5 批量场景:多个功能点一起生成

当团队积累了几十个功能点需要同时补充/更新解说文案时(比如统一调整语气风格、或者批量翻译成另一种语言,呼应 11.4 节的多语言扩展),可以把 18.2 节的任务描述改造成一个批处理脚本,让 AI agent 依次遍历各个功能点目录,对每个 record.spec.js 重复同样的生成流程:

请遍历所有 feature-* 目录,对每一个目录:
1. 读取 record.spec.js
2. 如果 timeline.json 不存在或者标记为草稿状态,按 18.2 节的方法生成一份
3. 已经是正式定稿(非草稿)的 timeline.json 不要覆盖
4. 每处理完一个目录,输出一行处理结果日志

批量场景下尤其要注意 18.4 节的人工复核环节不能省略——批量生成意味着一次性产出的文案量很大,人工复核的方式应该是抽样 + 重点复核新功能点(呼应 13.5 节"抽样审片"的思路),而不是对每一条批量生成的文案都逐字精读,那样会失去批量处理本应带来的效率收益。

18.6 小结

解说文案和字幕生成,是这套系统里 AI 参与度最高、人工介入最轻的环节:AI 负责从零构思、保证行文连贯、对齐页面实际文案;人工只负责最后的口语化微调和合规复核。这也是为什么 16.1 节的分工表里,这一行"人类的动作"写的是"读一遍、顺一顺措辞"——这几乎是整套系统里人类工作量最小的一个环节,却是团队最初重构工作流时最先感受到效率提升的地方。下一章讲录制完成后的自动化质检,这是本书要分享的最后一个、也是安全把关意义上最重要的 AI 应用环节。

CHAPTER 19

19 用 AI 做自动化质检与防泄露检测

10 章给出了一份详尽的人工检查清单,本章要做的事情是:把这份清单变成一个可以自动执行的 AI 审核步骤,插入编排器流程的末尾,让人工审片从"看完整条视频"压缩成"看 AI 标出来的几个风险点"。这是本书要分享的所有 AI 应用里,安全把关意义最重要的一环。

19.1 设计目标与边界

在动手之前先讲清楚这一章不是要做什么:这不是要用 AI 取代 10 章的人工签字环节。16.3 节和 10.7 节都强调过,最终发布责任必须由人承担。这一章要做的是把"发现风险点"这个体力活自动化,把"确认风险点、决定是否发布"这个判断权留给人。二者的关系类似代码审查工具(lint/静态扫描)和人工 code review 的关系——工具负责不知疲倦地扫一遍,人负责对工具标出来的东西做最终判断。

19.2 整体流程

成片(output/<feature-id>.mp4)
        │
        ▼
┌───────────────────────┐
│ 1. 按固定间隔抽帧        │  ffmpeg -vf fps=1
│    (或按步骤边界抽帧)     │
└──────────┬────────────┘
           ▼
┌───────────────────────┐
│ 2. 把字幕文件全文一起      │  10章清单原文作为系统提示
│    交给多模态模型批量审查  │
└──────────┬────────────┘
           ▼
┌───────────────────────┐
│ 3. 模型输出结构化 JSON    │  每条风险: 帧号/时间点/描述/严重度
└──────────┬────────────┘
           ▼
┌───────────────────────┐
│ 4. 生成人类可读的审片报告  │  Markdown/HTML,附带风险帧截图
└──────────┬────────────┘
           ▼
      人工看报告,逐条确认
           │
     ┌─────┴─────┐
   全部通过      有需要处理的风险
     │              │
     ▼              ▼
   正式发布      回到对应章节修复后重新走一遍流程

19.3 抽帧实现

// src/qa/extract-frames.ts
import { execFile } from "child_process";
import { promisify } from "util";
import path from "path";

const execFileAsync = promisify(execFile);

export async function extractFrames(opts: {
  videoPath: string;
  outputDir: string;
  fps?: number; // 默认每秒1帧,画面变化密集的片段可以调高
}): Promise<string[]> {
  const fps = opts.fps ?? 1;
  const pattern = path.join(opts.outputDir, "frame-%04d.jpg");

  await execFileAsync("ffmpeg", [
    "-y",
    "-i", opts.videoPath,
    "-vf", `fps=${fps}`,
    "-q:v", "3", // JPEG 质量,数值越小质量越高,3 已经足够识别文字细节
    pattern,
  ]);

  const fs = await import("fs");
  return fs
    .readdirSync(opts.outputDir)
    .filter((f) => f.startsWith("frame-"))
    .sort()
    .map((f) => path.join(opts.outputDir, f));
}

按固定间隔抽帧对大多数场景够用;如果想更精准地覆盖"页面切换的瞬间"这类 10.6 节场景二提到的高风险时刻(下拉菜单展开、页面跳转过渡),可以额外在 subtitles.srt 每条字幕的边界时间点前后各多抽 1~2 帧,实现上只需要读取字幕文件里已有的时间戳,对这些时间点单独跑一次 -ss <时间点> -frames:v 1 的单帧截取。

19.4 批量送审:把 10 章清单变成系统提示

// src/qa/leak-scan.ts
import fs from "fs";
import path from "path";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const CHECKLIST_PROMPT = `
你是一个视频发布前的安全审查员,请对下面这批视频截图逐张检查,参照以下清单:

1. 画面中是否出现真实客户名称、真实业务数据(而非演示/脱敏数据)
2. 系统菜单栏/任务栏是否出现个人化组件(股票、日历、剩余通知等,参考具体场景:
   菜单栏常驻组件全程可见)
3. 浏览器书签栏、标签页标题是否暴露与本次演示无关的内部信息
4. 终端窗口(如果画面中出现)是否残留历史命令、真实主机名、内网路径
5. 页面报错提示/Toast 是否泄露服务器内部路径、堆栈信息、框架版本
6. 页面上是否有一闪而过的、非本次演示主体但包含敏感信息的模块
   (如"最近登录设备"、真实用户列表等)

对每一张截图,如果发现任何一条命中,请按以下 JSON 格式输出一条记录;
如果没有发现问题,不需要为这张截图输出任何内容。

输出格式(JSON 数组,只输出 JSON,不要有其他文字):
[
  {
    "frame": "frame-0012.jpg",
    "rule": "命中的清单条目编号",
    "description": "具体描述看到了什么",
    "severity": "high | medium | low"
  }
]
`;

export async function scanFramesForLeaks(framePaths: string[]): Promise<any[]> {
  const imageBlocks = framePaths.map((p) => ({
    type: "image" as const,
    source: {
      type: "base64" as const,
      media_type: "image/jpeg" as const,
      data: fs.readFileSync(p).toString("base64"),
    },
  }));

  const response = await client.messages.create({
    model: "claude-sonnet-5",
    max_tokens: 4096,
    messages: [
      {
        role: "user",
        content: [
          { type: "text", text: CHECKLIST_PROMPT },
          ...imageBlocks.map((img, i) => ({
            ...img,
            // 在图片前插入文件名标记,方便模型在输出里正确引用 frame 字段
          })),
        ],
      },
    ],
  });

  const text = response.content
    .filter((b: any) => b.type === "text")
    .map((b: any) => b.text)
    .join("");

  try {
    return JSON.parse(text);
  } catch {
    return [{ frame: "unknown", rule: "解析失败", description: text, severity: "medium" }];
  }
}

实践中一次请求塞入的截图数量要考虑模型的单次输入体积限制和成本,一个 3~5 分钟的功能演示视频按每秒1帧抽帧可能有几百张,建议分批(比如每批 20~30 张)调用,并在批次之间保留帧文件名的连续编号,方便最后把所有批次的结果合并成一份完整报告。

19.5 文本层面的检查:字幕全文送审

除了画面,08 章生成的 SRT 字幕文件本身也需要过一遍文本层面的检查(这对应 10.2 节"文案审阅"这一条,同样可以交给 AI 打前站):

// src/qa/scan-subtitle-text.ts
export async function scanSubtitleText(srtContent: string): Promise<any[]> {
  const prompt = `
请检查以下字幕全文,找出:
1. 任何真实客户名称、公司名称、内部代号
2. 语气/措辞不符合对外发布标准的表达(内部黑话、未完成功能的"剧透")
3. 与画面描述明显不符的表述(如果你能从上下文判断)

字幕内容:
${srtContent}

按 JSON 数组格式输出问题列表,字段包括 line(对应字幕序号)、issue(问题描述)、
severity。没有问题则输出空数组。`;

  // 调用方式与19.4节相同,此处省略重复代码
  return [];
}

19.6 生成人类可读的审片报告

把画面风险和文本风险的检查结果合并,生成一份带截图缩略图的 Markdown 报告,方便人工快速浏览:

// src/qa/generate-report.ts
import fs from "fs";
import path from "path";

export function generateQaReport(opts: {
  featureId: string;
  frameFindings: any[];
  textFindings: any[];
  frameDir: string;
  outputPath: string;
}): void {
  const lines: string[] = [`# ${opts.featureId} 审片报告`, ""];

  if (opts.frameFindings.length === 0 && opts.textFindings.length === 0) {
    lines.push("✅ AI 自动检查未发现风险点,仍需人工完整审阅一次后再发布。");
  } else {
    lines.push(`⚠️ 发现 ${opts.frameFindings.length + opts.textFindings.length} 个待人工确认的风险点:\n`);

    for (const f of opts.frameFindings) {
      lines.push(`## 画面风险:${f.frame}(严重度:${f.severity})`);
      lines.push(`- 命中规则:${f.rule}`);
      lines.push(`- 描述:${f.description}`);
      lines.push(`- 截图:![${f.frame}](${path.join(opts.frameDir, f.frame)})`);
      lines.push("");
    }

    for (const t of opts.textFindings) {
      lines.push(`## 文本风险:字幕第 ${t.line} 条(严重度:${t.severity})`);
      lines.push(`- 描述:${t.issue}`);
      lines.push("");
    }
  }

  fs.writeFileSync(opts.outputPath, lines.join("\n"), "utf-8");
}

19.7 接入编排器

在 09 章命令行编排的最后一步(vt mix 或者 vt all 跑完)之后追加这个质检环节,作为交付前的最后一步自动化:

// 追加在最终成片生成之后(可以包装成一个新的 vt 命令,比如 vt qa)
const frameDir = path.join(workDir, "qa-frames");
fs.mkdirSync(frameDir, { recursive: true });
const frames = await extractFrames({ videoPath: finalPath, outputDir: frameDir });
const frameFindings = await scanFramesForLeaks(frames);
const textFindings = await scanSubtitleText(fs.readFileSync(path.join(workDir, "subtitle.srt"), "utf-8"));

generateQaReport({
  featureId: spec.id,
  frameFindings,
  textFindings,
  frameDir,
  outputPath: path.join("output", `${spec.id}.qa-report.md`),
});

logger.info(`[${spec.id}] 审片报告已生成,请人工确认后再发布`);

这样每次运行编排器,output/ 目录下除了成片本身,还会多一份同名的 .qa-report.md,人工审片时直接打开这份报告,如果报告是"未发现风险点",仍然按 10 章的建议做一次完整人工审阅(AI 检查不能替代首次发布的完整人工审阅,参考 19.1 节的边界说明);如果报告标出了具体风险点,人工只需要针对性地跳转到视频对应时间点确认,而不是从头看到尾。

19.8 关于误报与漏报

多模态模型的检查结果不会是完美的,需要接受两类错误同时存在:

误报(把正常内容标记为风险):比如把演示账号里明显是脱敏占位数据的用户名误判为"真实客户信息"。这类误报的代价是人工多花几秒钟确认一下,成本很低,不需要特别优化。

漏报(没发现真实存在的风险):这是需要认真对待的一类错误。缓解手段包括:定期把已知的历史真实泄露案例(10.6 节列出的四个典型场景)整理成测试用例,人为在测试视频里制造这些场景,验证当前的检查 prompt 是否能可靠识别出来,并根据结果持续迭代 19.4 节的清单 prompt 描述精度。这本质上是把"防泄露检查"当作一个需要持续验证、持续迭代的质量系统来对待,而不是写一次 prompt 就一劳永逸。

19.9 小结

本章展示了如何把 10 章的人工检查清单转换成一个自动化的 AI 审核步骤:抽帧 + 多模态模型批量审查 + 文本层面审查 + 生成结构化报告,接入编排器成为发布前的标准环节。这是全书对"AI 到底能替代人类做多少事"这个问题给出的最终答案:AI 可以把发现风险的体力活全部接管,但确认风险、承担发布责任的判断权,仍然、也应该、留在人类手里。下一章做一个反事实推演:同一个功能点,如果完全不用 AI,纯人工要花多久——用真实的耗时对比说明 AI 协同到底省在哪。

CHAPTER 20

20 反过来看:如果完全不用 AI,同一条视频要多久

12 章完整走过一遍"报表导出功能"的真实流程,全程约 20~23 分钟——但那个流程里,sync(把 codegen 草稿整理成正式脚本、解说文案直接嵌进去)、选择器报错的排查,全部是交给 AI 完成的。本章做一个反事实推演:同一个功能点,如果这几步全部改成人工手写,要花多久。这个对比能直接回答"AI 到底帮省了多少事"这个问题,而不是泛泛地说"AI 提升效率"。

20.1 需求提出(不变)

和 12.1 节完全一样——产品经理一句话 + 截图。这一步无论有没有 AI 都需要人来做,不受影响。

耗时:5 分钟

20.2 手写 record.spec.js(含解说文案)

没有 AI 帮忙整理 codegen 草稿的话,工程师需要自己把 nav-draft.spec.js 里啰嗦、可能包含误操作的原始代码,逐行改写成干净的 record.spec.js:核对每个选择器是不是用了语义化定位方式、补上必要的等待条件、删掉多余的断言,再给每一步插入 step() 调用、斟酌每一句解说词的措辞和承接——timeline.json 本身不用手写(3.3 节说过它是运行脚本时自动记录的),但脚本里嵌的解说文案是要人一句一句想的。

耗时:35 分钟(对比 12.3 节 AI 完成同样的工作只用了 2 分钟,而且不需要人工审阅)

20.3 手动排查选择器报错

冒烟测试报错"等待 导出完成 超时"。没有 AI 可以直接把报错甩给它处理,工程师需要自己:打开浏览器手动复现问题 → 打开开发者工具查看实际 DOM → 定位到真实文案是"导出成功" → 回到编辑器修改选择器 → 重新跑冒烟测试确认。

耗时:15 分钟(对比 12.4 节 AI 自愈流程只用了 2 分钟)

20.4 录制、出片(不变)

vt record 和 vt all 这两步是纯自动化命令,跟有没有 AI 参与 sync/调试无关,耗时和 12 章一样。

耗时:3 分钟

20.5 审片与微调(不变)

这一步在 00 章 0.2 节和 16.3 节都强调过,是刻意保留给人类的环节,AI 介入与否不影响这一步的耗时。

耗时:5 分钟

20.6 总耗时对比

阶段 12 章(AI 协同)耗时 本章(纯人工)耗时
需求提出 5 分钟 5 分钟
record.spec.js 生成(含解说文案) 2 分钟(AI 生成,不需要人工审阅) 35 分钟(人工逐行改写+撰写文案)
选择器报错排查 2 分钟(AI 自愈) 15 分钟(人工打开控制台排查)
Codegen(人工操作路径) 5 分钟 5 分钟
冒烟检查 2 分钟 2 分钟(此处未计入20.3的报错排查时间)
录制+出片 3 分钟 3 分钟
审片与微调 5 分钟 5 分钟
合计 约 23 分钟 约 70 分钟

差距集中在两处:脚本/文案的整理撰写和选择器报错排查,这两项加起来纯人工要 50 分钟,AI 协同只要 5 分钟。这不是偶然——这两项工作的共同特点是"需要读代码/读报错、做模式匹配、产出结构化文本",正是当前语言模型最擅长、也最容易通过一段清晰的上下文(本书 03、04、06 章讲的三份文件的职责边界)让它稳定做对的工作。

20.7 维护性重录场景下差距更明显

12.10 节的"按钮文案改了、弹窗位置挪了"场景,AI 协同耗时约 7~10 分钟。同样的场景纯人工处理:

  1. 冒烟测试报错,人工手动排查定位到新的按钮文案——10 分钟。
  2. 修改选择器,确认弹窗新位置不影响其他选择器——5 分钟。
  3. 重新录制——2 分钟(自动化命令,与是否用 AI 无关)。
  4. 重新出片——1 分钟。
  5. 审片确认——2 分钟。

纯人工约 20 分钟,AI 协同约 7~10 分钟,差距没有首次制作时那么悬殊(因为维护性变更本身改动范围小),但更重要的是认知负荷的差异:纯人工排查选择器需要工程师持续专注在"打开控制台、逐个尝试选择器"这类需要盯着屏幕的工作上;AI 协同流程里,工程师把报错转述给 AI 之后大部分时间在等待,可以同时处理别的事情。团队功能点数量上来之后,这种"能不能并行处理多件事"的差异,比单条视频节省几分钟更能决定整体吞吐量。

20.8 全书总结

从 00 章的架构设计到 09 章的命令行编排,本书前半部分讲清楚了这套系统的机制:录制优先、字幕来自时间点文本或语音识别、配音按字幕自然语速生成、全局偏移做最后微调。16~20 章讲清楚了这套机制在真实团队里是怎么被使用的:AI 承担了脚本整理、选择器调试、解说文案撰写、录制后质检里的大部分体力活,人类的角色收缩为提出意图、走一遍真实操作路径、以及最终审片签字。如果只能记住一句话:这本书前面讲的三份文件(record.spec.js/timeline.json/meta.json)和这套命令行流程,既是你要搭建的系统,也是你要喂给 AI 的设计文档——这是本书作者团队从"手工录制"到"命令行流水线"再到"AI 协同"这个演进过程中,最值得传递给其他团队的经验。