UI 演示视频录制器¶
使用 Playwright 的视频录制功能,录制带有注入式光标覆盖层、自然节奏和叙事流程的精美 Web 应用演示视频。
使用场景¶
- 当用户要求“演示视频”“屏幕录制”“操作演示”或“教程”时
- 当用户希望直观展示功能或工作流时
- 当用户需要视频用于文档、新手引导或向利益相关者演示时
三阶段流程¶
所有演示都会经过 探索 -> 排练 -> 录制 三个阶段。不要直接跳到录制阶段。
阶段 1:探索¶
在编写脚本之前,先探索目标页面,了解实际内容。
为什么¶
你无法为未见过的内容编写脚本。字段可能是 <input> 而不是 <textarea>,下拉框可能是自定义组件而不是 <select>,评论框可能支持 @mentions 或 #tags。假设会悄悄破坏录制。
方法¶
访问流程中的每个页面,并转储可交互元素:
// Run this for each page in the flow BEFORE writing the demo script
const fields = await page.evaluate(() => {
const els = [];
document.querySelectorAll('input, select, textarea, button, [contenteditable]').forEach(el => {
if (el.offsetParent !== null) {
els.push({
tag: el.tagName,
type: el.type || '',
name: el.name || '',
placeholder: el.placeholder || '',
text: el.textContent?.trim().substring(0, 40) || '',
contentEditable: el.contentEditable === 'true',
role: el.getAttribute('role') || '',
});
}
});
return els;
});
console.log(JSON.stringify(fields, null, 2));
需要确认的内容¶
- 表单字段:是
<select>、<input>、自定义下拉框还是组合框? - 选择选项:转储选项的值和文本。占位符可能包含
value="0"或value="",看起来非空。使用Array.from(el.options).map(o => ({ value: o.value, text: o.text }))。跳过文本中包含“选择”的选项或值为"0"的选项。 - 富文本:评论框是否支持
@mentions、#tags、Markdown 或表情符号?检查占位符文本。 - 必填字段:哪些字段会阻止表单提交?检查标签上的
required、*,并提交空表单以确认验证错误。 - 动态内容:输入其他字段后是否会显示字段?
- 按钮标签:准确文本(如
"Submit"、"Submit Request"、"Send")。 - 表格列标题:对于表格驱动的模态框,将每个
input[type="number"]映射到其列标题。不要假设所有数值输入具有相同含义。
输出¶
用于在脚本中编写正确选择器的逐页字段映射。示例:
/purchase-requests/new:
- 予算コード: <select>(ページの最初のドロップダウン、4オプション)
- 希望納期: <input type="date">
- 背景説明: <textarea>(inputではない)
- BOMテーブル: インライン編集可能なセル、span.cursor-pointer -> inputパターン
- 送信: <button> テキスト="送信"
/purchase-requests/N(詳細):
- コメント: <input placeholder="メッセージを入力...">、@ユーザーと#PRタグに対応
- 送信: <button> テキスト="送信"(入力前は無効)
阶段 2:排练¶
在不录制的情况下执行所有步骤。确认每个选择器都能解析。
为什么¶
选择器失败是演示录制损坏的最大原因。排练可以在浪费录制之前发现问题。
方法¶
使用 ensureVisible——一个记录日志并醒目报告错误的包装器:
async function ensureVisible(page, locator, label) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
const msg = `REHEARSAL FAIL: "${label}" not found - selector: ${typeof locator === 'string' ? locator : '(locator object)'}`;
console.error(msg);
const found = await page.evaluate(() => {
return Array.from(document.querySelectorAll('button, input, select, textarea, a'))
.filter(el => el.offsetParent !== null)
.map(el => `${el.tagName}[${el.type || ''}] "${el.textContent?.trim().substring(0, 30)}"`)
.join('\n ');
});
console.error(' Visible elements:\n ' + found);
return false;
}
console.log(`REHEARSAL OK: "${label}"`);
return true;
}
排练脚本结构¶
const steps = [
{ label: 'Login email field', selector: '#email' },
{ label: 'Login submit', selector: 'button[type="submit"]' },
{ label: 'New Request button', selector: 'button:has-text("New Request")' },
{ label: 'Budget Code select', selector: 'select' },
{ label: 'Delivery date', selector: 'input[type="date"]:visible' },
{ label: 'Description field', selector: 'textarea:visible' },
{ label: 'Add Item button', selector: 'button:has-text("Add Item")' },
{ label: 'Submit button', selector: 'button:has-text("Submit")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('REHEARSAL FAILED - fix selectors before recording');
process.exit(1);
}
console.log('REHEARSAL PASSED - all selectors verified');
排练失败时¶
- 阅读可见元素转储。
- 找到正确的选择器。
- 更新脚本。
- 重新运行排练。
- 仅在所有选择器通过后继续。
阶段 3:录制¶
仅在探索和排练通过后,再创建录制。
录制原则¶
1. 叙事流程¶
将视频作为故事来规划。遵循用户指定的顺序,或使用以下默认顺序:
- 入口:导航到登录或起始点
- 上下文:查看周围环境,让观众理解当前所在位置
- 操作:执行主要工作流步骤
- 变体:展示设置、主题、本地化等辅助功能
- 结果:展示结果、确认或新状态
2. 节奏¶
- 登录后:
4秒 - 导航后:
3秒 - 点击按钮后:
2秒 - 主要步骤之间:
1.5〜2秒 - 最终操作后:
3秒 - 输入延迟:每个字符
25〜40ms
3. 光标覆盖层¶
注入一个跟随鼠标移动的 SVG 箭头光标:
async function injectCursor(page) {
await page.evaluate(() => {
if (document.getElementById('demo-cursor')) return;
const cursor = document.createElement('div');
cursor.id = 'demo-cursor';
cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`;
cursor.style.cssText = `
position: fixed; z-index: 999999; pointer-events: none;
width: 24px; height: 24px;
transition: left 0.1s, top 0.1s;
filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
`;
cursor.style.left = '0px';
cursor.style.top = '0px';
document.body.appendChild(cursor);
document.addEventListener('mousemove', (e) => {
cursor.style.left = e.clientX + 'px';
cursor.style.top = e.clientY + 'px';
});
});
}
由于覆盖层会在导航时被丢弃,请在每次页面导航时调用 injectCursor(page)。
4. 鼠标移动¶
不要瞬移光标。在点击前移动到目标位置:
async function moveAndClick(page, locator, label, opts = {}) {
const { postClickDelay = 800, ...clickOpts } = opts;
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
console.error(`WARNING: moveAndClick skipped - "${label}" not visible`);
return false;
}
try {
await el.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const box = await el.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
await page.waitForTimeout(400);
}
await el.click(clickOpts);
} catch (e) {
console.error(`WARNING: moveAndClick failed on "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
为了便于调试,为每个调用包含一个描述性的 label。
5. 输入¶
不要让输入瞬间完成,而是让输入过程清晰可见:
async function typeSlowly(page, locator, text, label, charDelay = 35) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
console.error(`WARNING: typeSlowly skipped - "${label}" not visible`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. 滚动¶
使用平滑滚动,而不是跳跃式滚动:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. 仪表盘扫视¶
在显示仪表盘或概览页面时,将光标移动到主要元素上方:
async function panElements(page, selector, maxCount = 6) {
const elements = await page.locator(selector).all();
for (let i = 0; i < Math.min(elements.length, maxCount); i++) {
try {
const box = await elements[i].boundingBox();
if (box && box.y < 700) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
await page.waitForTimeout(600);
}
} catch (e) {
console.warn(`WARNING: panElements skipped element ${i} (selector: "${selector}"): ${e.message}`);
}
}
}
8. 字幕¶
在视口底部注入字幕栏:
async function injectSubtitleBar(page) {
await page.evaluate(() => {
if (document.getElementById('demo-subtitle')) return;
const bar = document.createElement('div');
bar.id = 'demo-subtitle';
bar.style.cssText = `
position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998;
text-align: center; padding: 12px 24px;
background: rgba(0, 0, 0, 0.75);
color: white; font-family: -apple-system, "Segoe UI", sans-serif;
font-size: 16px; font-weight: 500; letter-spacing: 0.3px;
transition: opacity 0.3s;
pointer-events: none;
`;
bar.textContent = '';
bar.style.opacity = '0';
document.body.appendChild(bar);
});
}
async function showSubtitle(page, text) {
await page.evaluate((t) => {
const bar = document.getElementById('demo-subtitle');
if (!bar) return;
if (t) {
bar.textContent = t;
bar.style.opacity = '1';
} else {
bar.style.opacity = '0';
}
}, text);
if (text) await page.waitForTimeout(800);
}
每次导航后,将 injectSubtitleBar(page) 与 injectCursor(page) 一起调用。
使用模式:
await showSubtitle(page, 'Step 1 - Logging in');
await showSubtitle(page, 'Step 2 - Dashboard overview');
await showSubtitle(page, '');
指南:
- 字幕文本应简短,最好控制在 60 个字符以内。
- 为保持一致性,使用
Step N - Action格式。 - 如果界面在较长时间停顿时具有自解释性,请清除字幕。
脚本模板¶
'use strict';
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:3000';
const VIDEO_DIR = path.join(__dirname, 'screenshots');
const OUTPUT_NAME = 'demo-FEATURE.webm';
const REHEARSAL = process.argv.includes('--rehearse');
// Paste injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible, and panElements here.
(async () => {
const browser = await chromium.launch({ headless: true });
if (REHEARSAL) {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
// Navigate through the flow and run ensureVisible for each selector.
await browser.close();
return;
}
const context = await browser.newContext({
recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
viewport: { width: 1280, height: 720 }
});
const page = await context.newPage();
try {
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Step 1 - Logging in');
// login actions
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Step 2 - Dashboard overview');
// pan dashboard
await showSubtitle(page, 'Step 3 - Main workflow');
// action sequence
await showSubtitle(page, 'Step 4 - Result');
// final reveal
await showSubtitle(page, '');
} catch (err) {
console.error('DEMO ERROR:', err.message);
} finally {
await context.close();
const video = page.video();
if (video) {
const src = await video.path();
const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
try {
fs.copyFileSync(src, dest);
console.log('Video saved:', dest);
} catch (e) {
console.error('ERROR: Failed to copy video:', e.message);
console.error(' Source:', src);
console.error(' Destination:', dest);
}
}
await browser.close();
}
})();
使用方法:
录制前检查清单¶
- [ ] 探索阶段已完成
- [ ] 预演已通过,所有选择器都能正常工作
- [ ] 无头模式已启用
- [ ] 分辨率已设置为
1280x720 - [ ] 每次导航后重新注入光标和字幕覆盖层
- [ ] 在关键过渡时使用
showSubtitle(page, 'Step N - ...') - [ ] 所有点击都使用带描述性标签的
moveAndClick - [ ] 可见的输入使用
typeSlowly - [ ] 没有静默 catch。辅助函数会记录警告
- [ ] 内容展示使用平滑滚动
- [ ] 重要的停顿对观众可见
- [ ] 流程遵循要求的故事情节顺序
- [ ] 脚本反映了阶段 1 中发现的实际 UI
常见陷阱¶
- 导航后光标消失——重新注入。
- 视频太快——添加停顿。
- 光标变成了圆点而不是箭头——使用 SVG 覆盖层。
- 光标瞬间移动——在点击前先移动到位。
- 下拉选择显得不连贯——先展示移动过程,再选择选项。
- 模态框显得太突兀——在确认前添加一段阅读停顿。
- 视频文件路径是随机生成的——复制为稳定的输出文件名。
- 选择器失败被吞掉——绝不要使用静默 catch 块。
- 假设字段类型——先探索。
- 假设功能存在——在编写脚本前先确认实际 UI。
- 占位符选择值看起来像真实值——注意
"0"和"Select..."。 - 弹出窗口会生成单独的视频——显式捕获弹出页面,并在需要时稍后合并。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 ev/ECC