Playwright 视频录制实战:video 模式、动作标注与 WebM 编码底层实现
本篇基于 Playwright 官方文档 docs/src/videos.md 展开,讲解 Playwright Test 与库 API 两套视频录制机制:video 配置项的全部模式、recordVideo 参数、动作/测试信息标注,并结合 videoRecorder.ts 源码剖析 Playwright 如何把浏览器帧流编码成 25fps 的 WebM 文件。读完你可以为失败用例精准留存视频证据,理解「viewport 缩放至 800x800」「关闭 context 才落盘」等行为背后的实现原理。
一、用 video 配置项为测试录制视频
Playwright Test 通过 Playwright 配置中的 video 选项控制视频录制,默认关闭('off')。文档列出的核心模式如下:
'off'—— 不录制视频。'on'—— 为每个测试录制视频。'retain-on-failure'—— 为每次运行录制视频,但删除所有成功运行产生的视频。'on-first-retry'—— 只在测试第一次重试时录制视频。
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
video: 'on-first-retry',
},
});
视频文件会出现在测试输出目录(通常是 test-results)中。更高级的粒度控制(如按浏览器/项目覆盖)参考 TestOptions.video。
源码补充:完整的模式集合与「录制/保留」分离逻辑。 从源码结构看,当前版本的 VideoMode 已扩展到 7 种,完整定义见 test.d.ts:
video: VideoMode | /** deprecated */ 'retry-with-video' | {
mode: VideoMode,
size?: ViewportSize,
show?: {
actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' },
test?: { level?: 'file' | 'title' | 'step', position?: /* 同上 */ , fontSize?: number }
}
};
其中 VideoMode 包括 'off'、'on'、'on-first-retry'、'on-all-retries'、'retain-on-failure'、'retain-on-first-failure'、'retain-on-failure-and-retries'(旧写法 'retry-with-video' 已标记 deprecated,会被规范化为 'on-first-retry')。每种模式的行为由两个函数决定,见 index.ts:
function shouldCaptureVideo(videoMode: VideoMode, testInfo: TestInfo) {
return videoMode === 'on'
|| videoMode === 'retain-on-failure'
|| videoMode === 'retain-on-failure-and-retries'
|| (videoMode === 'on-first-retry' && testInfo.retry === 1)
|| (videoMode === 'on-all-retries' && testInfo.retry > 0)
|| (videoMode === 'retain-on-first-failure' && testInfo.retry === 0);
}
function shouldPreserveVideo(videoMode: VideoMode, testInfo: TestInfo) {
const testFailed = testInfo.status !== testInfo.expectedStatus;
switch (videoMode) {
case 'on':
case 'on-first-retry':
case 'on-all-retries':
return true;
case 'retain-on-failure':
case 'retain-on-first-failure':
return testFailed;
case 'retain-on-failure-and-retries':
return testFailed || testInfo.retry > 0;
default:
return false;
}
}
可以看到设计把「是否录制」(shouldCaptureVideo)和「是否保留」(shouldPreserveVideo)拆开了:retain-* 系列模式每次都录制,但在测试结束时按结果决定是否删除视频文件——这正是文档中 'retain-on-failure'「录制但只保留失败视频」描述的底层实现。
二、库 API 录制:recordVideo 与 context.close 的时序契约
不使用 Playwright Test 时,直接通过浏览器上下文选项录制视频,关键参数是 recordVideo: { dir, size }。视频在 browser context 关闭时保存——如果你手动创建了 browser context,必须 await context.close(),否则视频丢失。各语言写法:
const context = await browser.newContext({ recordVideo: { dir: 'videos/' } });
// 视频依赖关闭动作落盘,务必 await close
await context.close();
context = browser.newContext(new Browser.NewContextOptions().setRecordVideoDir(Paths.get("videos/")));
// 必须 close,视频才会保存
context.close();
context = await browser.new_context(record_video_dir="videos/")
# 必须 await close,视频才会保存
await context.close()
context = browser.new_context(record_video_dir="videos/")
# 必须 close,视频才会保存
context.close()
var context = await browser.NewContextAsync(new()
{
RecordVideoDir = "videos/"
});
// 必须 close,视频才会保存
await context.CloseAsync();
保存的视频文件出现在指定目录下,文件名由框架生成的唯一 ID 命名。多页面场景下,可以通过 page.video() 获取该页面对应的视频文件:
const path = await page.video().path();
path = page.video().path();
path = await page.video.path() # async
var path = await page.Video.PathAsync();
注意:视频只有在页面或 browser context 关闭后才可用,path() 在运行中调用会返回 null。
为什么必须关闭 context 才有视频
从源码看,视频录制是「边录边写」的流式过程。videoRecorder.ts 中的 VideoRecorder 在 start() 时即通过 createGuid() + '.webm' 生成输出文件路径,并把每帧写入 ffmpeg 子进程的 stdin;而 stop() 才执行真正的收尾(关闭 stdin、等待 ffmpeg 完成 muxing、artifact.reportFinished() 通知产物就绪)。自动录制的入口是 startAutomaticVideoRecording(page)(L87-L97):
export function startAutomaticVideoRecording(page: Page) {
const recordVideo = page.browserContext._options.recordVideo;
if (!recordVideo)
return;
const recorder = new VideoRecorder(page.screencast);
if (page.browserContext._options.recordVideo?.showActions)
page.screencast.showActions(page.browserContext._options.recordVideo?.showActions);
const dir = recordVideo.dir ?? page.browserContext._browser.options.artifactsDir;
const artifact = recorder.start({ size: recordVideo.size, fileName: path.join(dir, page.guid + '.webm') });
page.video = artifact;
}
三个关键事实由此得到印证:
recordVideo.dir未指定时回退到浏览器实例的 artifacts 目录(测试场景即test-results下的工件目录);- 每个页面独立创建
VideoRecorder实例并挂到page.video,这解释了page.video()的可用性与「按页面」的粒度; - 录制是挂在页面的 screencast 帧流上的,页面/上下文关闭触发 screencast 的
gracefulClose,进而调用recorder.stop()完成落盘——这就是文档强调「关闭前视频不可用」的机制根源。
三、视频尺寸:800x800 缩放规则与 pad/crop 实现
可以显式指定视频尺寸;默认尺寸是把 viewport 缩放以适配 800x800,viewport 画面被放在输出视频的左上角、必要时等比缩小。若想让视频尺寸与预期一致,可能需要把 viewport 尺寸设置成匹配的值(从类型定义看,viewport 未显式配置时视频尺寸默认为 800x450)。
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
video: {
mode: 'on-first-retry',
size: { width: 640, height: 480 },
},
},
});
const context = await browser.newContext({
recordVideo: {
dir: 'videos/',
size: { width: 640, height: 480 },
}
});
Java 示例:
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setRecordVideoDir(Paths.get("videos/"))
.setRecordVideoSize(640, 480));
Python 示例:
context = await browser.new_context(
record_video_dir="videos/",
record_video_size={"width": 640, "height": 480}
)
.NET 示例:
var context = await browser.NewContextAsync(new()
{
RecordVideoDir = "videos/",
RecordVideoSize = new RecordVideoSize() { Width = 640, Height = 480 }
});
await context.CloseAsync();
左上角放置与缩放是怎么做的
这一行为直接对应 videoRecorder.ts 中 ffmpeg 的 -vf 滤镜参数:
const w = this._size.width;
const h = this._size.height;
const videoFilterArgs = page.getFFmpegVideoFilterArgs?.({ width: w, height: h })
?? `pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`;
即对每一帧先 pad 到目标尺寸(超出部分以灰色填充,起点 0,0 保证原画面锚定在左上角),再 crop 回目标尺寸裁掉多余边——与文档「viewport 置于左上角、必要时缩小」的描述一致。另外在 start() 中有一行注释说明了尺寸优先级:“For video files only, prioritize encoding into the given size, regardless of the actual pixel data.” 即显式 size 会强制输出分辨率,与截图类产物不同。
四、视频标注:actions 高亮与测试信息水印
文档中的完整标注配置示例:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
video: {
mode: 'on-first-retry',
size: { width: 640, height: 480 },
show: {
actions: {
duration: 500,
position: 'top-right',
fontSize: 14,
},
test: {
level: 'step',
position: 'top-left',
fontSize: 12,
}
},
},
},
});
参数说明(结合 test.d.ts 的类型定义):
| 参数 | 作用 | 取值/默认 |
|---|---|---|
show.actions |
每次操作在视频中高亮:元素轮廓 + 操作标题字幕 | 开启即生效 |
show.actions.duration |
每个标注持续显示的时长 | 默认 500ms |
show.actions.position |
标注位置 | top-left / top / top-right / bottom-left / bottom / bottom-right |
show.actions.fontSize |
标注字号 | 数值(px) |
show.actions.cursor |
是否显示光标 | 'none' / 'pointer' |
show.test.level |
测试信息水印的详细级别 | 'file' / 'title' / 'step'(step 显示当前步骤) |
show.test.position / fontSize |
水印位置与字号 | 同 actions |
实现路径上,showActions 选项在 startAutomaticVideoRecording 中传给 page.screencast.showActions(...)(L92-L93),也就是说标注是渲染在 screencast 帧采集阶段的,而非后期叠加——所以录制出来的画面里天然包含高亮轮廓与字幕。
五、底层实现:从浏览器帧到 WebM 文件
FfmpegVideoRecorder(videoRecorder.ts)完整描述了编码管线,源码中的注释是理解参数设计的最佳材料:
- 帧输入:screencast 捕获的每一帧是一张 MJPEG 图。Playwright 不把裸图丢给 ffmpeg,而是用 ebml.ts 里的
writeHeader/writeClusterHeader把每帧包进一个带显式时间戳的最小 Matroska 流,从 stdin(-f matroska -i pipe:0)喂给 ffmpeg,由它负责帧时序; - 编码参数(固定 25fps,VP8 编码):
-loglevel error -f matroska -fpsprobesize 0 -probesize 32 -analyzeduration 0 -i pipe:0
-y -an -r 25 -c:v vp8 -qmin 0 -qmax 50 -crf 8
-deadline realtime -speed 8 -b:v 1M -threads 1
-vf pad=W:H:0:0:gray,crop=W:H:0:0
各参数意图(引自源码注释 L123-L163):-crf 8 恒定质量模式;-deadline realtime -speed 8 限制 CPU 占用以跟上实时帧率;-b:v 1M 提高 VP8 默认码率;-threads 1 单线程避免 CPU 超配时的卡顿;-r 25 恒定输出帧率,帧重复交给 ffmpeg 根据时间戳处理(const fps = 25 定义于 L35);
3. 优雅收尾(_stop(),L220-L239):
- 若整个会话一帧都没有收到,先写入一帧纯白图(
createWhiteImage,用 jpegjs 编码,L242-L245)——因为「ffmpeg 只有在收到非空输入时才会创建文件」,这保证零活动页面也会产出一个合法视频; - 随后在最后一帧之后追加至少 1000ms 时长(
Math.max(monotonicTime() - this._lastWriteNodeTime, 1000)),让视频结尾有可感知的停顿而不是戛然而止。
此外,ffmpeg 可执行文件本身由 Playwright 浏览器安装器随浏览器一起分发——start() 第一行就通过 registry.findExecutable('ffmpeg') 定位(L50),所以无需系统预装 ffmpeg,这也是 video 配置开箱即用的前提。
六、实用建议与验证路径
- CI 场景推荐
'retain-on-failure':每次都录,只保留失败视频,兼顾诊断价值与磁盘占用;源码中该模式对应shouldCaptureVideo恒真 +shouldPreserveVideo仅在testFailed时保留(index.ts); 'on-first-retry'与retries: 0互斥:没有重试就不会触发录制,配置video: 'on-first-retry'时记得配合retries使用;- 手动 context 一定要 close:库 API 下忘记
await context.close()会导致视频未落盘(见 videoRecorder.ts 的stop()收尾流程); page.video().path()只在关闭后可用:测试断言中若需附件化视频,应在 context 关闭后处理;- 行为回归验证可以参考仓库自带的端到端测试:tests/library/video.spec.ts、tests/library/screencast.spec.ts 与标注相关的 tests/library/screencast-overlay.spec.ts;
- 与视频相关的姊妹能力(截图、trace 的产物机制)分别见 docs/src/screenshots.md 与 docs/src/trace-viewer.md。
适用前提:本文结论基于当前仓库版本(含 show.actions/test 标注与 7 种 VideoMode),video 选项属于 TestOptions,可通过 use 全局设置并按 project/浏览器覆盖;库 API 的 recordVideo 需要浏览器安装器分发的 ffmpeg 可执行文件,使用 executablePath 手动指定非 Playwright 安装的浏览器构建时,请自行确认 ffmpeg 可用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00