录制精美的产品 UI demo 视频。当用户需要录制演示视频、功能演示、操作教程或利益相关方展示视频时使用。输出带可见鼠标、自然节奏和专业字幕的 WebM 视频。
71
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
High
Do not use without reviewing
使用 patchright recordVideo + 注入的鼠标覆盖层、字幕和自然节奏,录制精美的 Web 应用演示视频。
CDP 模式:通过 patchright 连接 本机已安装 Chrome (不准自行安装无头浏览器),若本机未安装Chrome,则提示用户先进行安装,退出执行。
Patchright 1.60+ 替代方案:Screencast API(
page.screencast)提供内置录制+动作标注+章节+自定义 overlay,可替代手动注入 cursor/subtitle。详见下方「Screencast API 方案」章节。
Discover → Rehearse → Record。禁止跳过直接录制。
在写录制脚本之前,用 browser tool 逐一导航到流程中的每个页面,了解真实的页面结构。
目标:建立每个页面的字段映射表,用于 Phase 3 脚本中的选择器。
每个页面重点关注:
<input>、<textarea>、<select> 还是自定义 combobox / contenteditable?"" 或 "0")看起来非空但实际无效,跳过。"Submit"、"Submit Request"、"Save"。输出:整理每个页面的字段映射,例如:
/purchase-requests/new:
- Budget Code: <select>(4 个真实选项,第一个是 placeholder)
- Desired Delivery: <input type="date">
- Context: <textarea>(不是 input)
- Submit: <button> text="Submit Request"不录制,在 browser tool 中手动走一遍完整流程,验证每一步都能顺利完成。
Phase 2 的价值在于消灭"脚本假设"——字段顺序、选择器、等待时机,都在这里确认,不留到录制时爆。
Phase 1/2 确认后,编写录制脚本。recordVideo 必须通过 patchright Node.js 脚本完成,无法通过 browser tool 实现。
将视频规划为一个故事,默认结构:
| 时机 | 等待时长 |
|---|---|
| 登录后 | 4s |
| 导航后 | 3s |
| 点击按钮后 | 2s |
| 主要步骤之间 | 1.5-2s |
| 最后一个动作后 | 3s |
| 打字延迟 | 25-40ms / 字符 |
注入 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';
});
});
}禁止光标瞬移,点击前先平滑移动到目标:
async function moveAndClick(page, locator, label, opts = {}) {
const { postClickDelay = 800, ...clickOpts } = opts;
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
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;
}可见打字,不要瞬间填充:
async function typeSlowly(page, locator, text, label, charDelay = 35) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}重要:富文本编辑器处理
对于 contenteditable 富文本编辑器(如 Quill、TinyMCE 等):
fill() 填充内容!fill() 会导致编辑器无法识别内容,提交时可能丢失click() 聚焦,再 pressSequentially() 逐字输入fill(''),但填充内容必须用 pressSequentially()div.ql-editor、div[contenteditable="true"]// ✅ 正确:富文本编辑器输入
const editor = page.locator('div.ql-editor');
await editor.click();
await editor.fill(''); // 清空
await editor.pressSequentially('正文内容...', { delay: 35 });
// ❌ 错误:会导致编辑器无法识别内容
await editor.fill('正文内容...');在视口底部注入字幕条,每次导航后重新注入:
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;
bar.textContent = t; bar.style.opacity = t ? '1' : '0';
}, text);
if (text) await page.waitForTimeout(800);
}字幕规范:不超过 60 字符,使用 Step N - 动作 格式,UI 已能说明问题时清空。
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);'use strict';
const { chromium } = require('patchright');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const CDP_URL = process.env.CDP_URL || 'http://localhost:9222';
const VIDEO_DIR = path.join(__dirname, 'output');
const OUTPUT_NAME = 'demo-FEATURE.webm';
// 在此粘贴 injectCursor、injectSubtitleBar、showSubtitle、moveAndClick、typeSlowly 函数
(async () => {
const browser = await chromium.connectOverCDP(CDP_URL, { noDefaults: true });
// 从 openclaw 现有 context 继承登录态(避免录制视频出现登录流程)
const existingContexts = browser.contexts();
const cookies = existingContexts.length > 0
? await existingContexts[0].cookies()
: [];
const context = await browser.newContext({
recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
viewport: { width: 1280, height: 720 }
});
if (cookies.length > 0) await context.addCookies(cookies);
const page = await context.newPage();
try {
await injectCursor(page);
await injectSubtitleBar(page);
// Step 1 - 登录(cookies 已注入,通常直接跳过;若目标应用跨域或 cookie 失效则执行表单登录)
await page.goto(`${BASE_URL}/login`);
await page.waitForTimeout(2000);
const alreadyLoggedIn = await page.locator('[data-testid="user-avatar"], .user-menu, .avatar').first().isVisible().catch(() => false);
if (!alreadyLoggedIn) {
await showSubtitle(page, 'Step 1 - 登录');
await typeSlowly(page, 'input[name="email"]', 'demo@example.com', 'Email');
await typeSlowly(page, 'input[name="password"]', 'demo-password', 'Password');
await moveAndClick(page, 'button[type="submit"]', 'Login');
await page.waitForTimeout(4000);
await showSubtitle(page, '');
}
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Step 2 - 概览');
// 巡览 dashboard
await showSubtitle(page, 'Step 3 - 主要流程');
// 操作序列
await showSubtitle(page, 'Step 4 - 结果');
await page.waitForTimeout(3000);
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);
fs.copyFileSync(src, dest);
console.log('Video saved:', dest);
}
// 不调用 browser.close() — Chrome 由 xiaobei 管理
}
})();运行:
node demo-script.cjsScreencast API 是 Patchright 1.59+ 引入的原生录制能力,内置动作标注、章节标题和自定义 overlay,可替代手动注入 cursor SVG 和 subtitle bar。
| 特性 | 手动注入方案(上方) | Screencast API |
|---|---|---|
| 鼠标光标 | 需手动注入 SVG + mousemove 监听 | showActions() 内置动作标注(高亮被点击元素+动作标题) |
| 字幕 | 需手动注入 DOM + textContent | showChapter() / showOverlay() 内置 |
| 导航后重注入 | 每次导航后必须重新调用 | 自动持久,无需重注入 |
| 实时帧流 | 不支持 | onFrame 回调可流式输出 JPEG 帧(AI vision 用) |
| 视频格式 | WebM(recordVideo) | WebM(screencast.start) |
| 代码量 | 多(injectCursor + injectSubtitleBar + showSubtitle) | 少(screencast.start + showActions + showChapter) |
'use strict';
const { chromium } = require('patchright');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const CDP_URL = process.env.CDP_URL || 'http://localhost:9222';
const VIDEO_DIR = path.join(__dirname, 'output');
const OUTPUT_NAME = 'demo-FEATURE.webm';
(async () => {
// noDefaults: true — 不干扰用户浏览器状态
const browser = await chromium.connectOverCDP(CDP_URL, { noDefaults: true });
const existingContexts = browser.contexts();
const cookies = existingContexts.length > 0
? await existingContexts[0].cookies()
: [];
const context = await browser.newContext({
viewport: { width: 1280, height: 720 }
});
if (cookies.length > 0) await context.addCookies(cookies);
const page = await context.newPage();
// 开始录制 + 动作标注
await page.screencast.start({ path: path.join(VIDEO_DIR, OUTPUT_NAME) });
await page.screencast.showActions({ position: 'top-right' });
try {
// Step 1 - 登录
await page.goto(`${BASE_URL}/login`);
await page.waitForTimeout(2000);
const alreadyLoggedIn = await page.locator('[data-testid="user-avatar"], .user-menu, .avatar').first().isVisible().catch(() => false);
if (!alreadyLoggedIn) {
await page.screencast.showChapter('Step 1 - 登录', { duration: 2000 });
// 登录操作...
await page.waitForTimeout(4000);
}
// Step 2 - 概览
await page.goto(`${BASE_URL}/dashboard`);
await page.screencast.showChapter('Step 2 - 概览', { duration: 2000 });
// 巡览 dashboard...
// Step 3 - 主要流程
await page.screencast.showChapter('Step 3 - 主要流程', { duration: 1500 });
// 操作序列(showActions 会自动标注每次点击/输入)
// Step 4 - 结果
await page.screencast.showChapter('Step 4 - 结果', { duration: 3000 });
} catch (err) {
console.error('DEMO ERROR:', err.message);
} finally {
await page.screencast.stop();
await context.close();
// 不调用 browser.close() — Chrome 由 xiaobei 管理
}
})();injectCursor 和 injectSubtitleBarmoveAndClick(含描述性 label)typeSlowlyshowSubtitlemoveAndClick"" 或 "0"copyFileSync 到固定名称fill() 填充 → 会导致编辑器无法识别内容,必须用 pressSequentially()以下经验来自实际项目验证,适用于 Phase 1/2 使用 browser tool 探查页面时:
当 browser tool 或 patchright 遇到超时错误时:
若在 Phase 1/2 需要通过 browser tool 上传文件(如测试上传功能):
/tmp/openclaw/uploads/ 目录(沙箱限制)browser upload 返回超时错误不代表上传失败input.files.length === 0 判断上传是否失败snapshot 检查页面状态(进度条、缩略图、处理状态文字)在 Phase 1 建立字段映射时,特别注意:
<input> 或 <textarea>contenteditable),需要特殊处理type + slowly: true 或 pressSequentially() 进行可见输入fill() 填充内容focus())操作后检查页面状态的方式:
snapshot 而非 waitForSelector(更稳定)bcef823
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.