UI Demo
Documentsui-demoUI Demo is an Agent Skill for documents published in the TeamWiseFlow/xiaobei repository on GitHub: a SKILL.md instruction file an AI agent can load to do this task.
录制精美的产品 UI demo 视频。当用户需要录制演示视频、功能演示、操作教程或利益相关方展示视频时使用。输出带可见鼠标、自然节奏和专业字幕的 WebM 视频。
How to use this skill
Bring this guide into your coding agent with a prompt tailored to the tool you use.
- Open your project in Codex.
- Copy and paste the prompt below.
- Review files and risks before approving any action.
Inspect the Agent Skill at https://github.com/TeamWiseFlow/xiaobei/blob/HEAD/crews/content-producer/skills/ui-demo/SKILL.md and all supporting files. Report provenance, permissions, scripts, dependencies, and security risks. Propose an exact installation into .agents/skills/ui-demo, but wait for my approval before writing files or running scripts.
Copying this prompt does not install or run the skill. Review third-party files before use.
UI Demo Video Recorder
使用 patchright recordVideo + 注入的鼠标覆盖层、字幕和自然节奏,录制精美的 Web 应用演示视频。
CDP 模式:通过 patchright 连接 本机已安装 Chrome (不准自行安装无头浏览器),若本机未安装Chrome,则提示用户先进行安装,退出执行。
Patchright 1.60+ 替代方案:Screencast API(
page.screencast)提供内置录制+动作标注+章节+自定义 overlay,可替代手动注入 cursor/subtitle。详见下方「Screencast API 方案」章节。
When to Use
- 用户需要"演示视频"、"产品录屏"、"功能演示"或"操作教程"
- 需要制作用于文档、用户引导或投资人/客户展示的视频
Three-Phase Process
Discover → Rehearse → Record。禁止跳过直接录制。
Phase 1: Discover(browser tool)
在写录制脚本之前,用 browser tool 逐一导航到流程中的每个页面,了解真实的页面结构。
目标:建立每个页面的字段映射表,用于 Phase 3 脚本中的选择器。
每个页面重点关注:
- 表单字段类型:是
<input>、<textarea>、<select>还是自定义 combobox / contenteditable? - Select 选项:确认实际选项值。Placeholder 选项(通常 value 为
""或"0")看起来非空但实际无效,跳过。 - 按钮精确文本:如
"Submit"、"Submit Request"、"Save"。 - 必填字段:尝试提交空表单,观察验证报错。
- 动态字段:填写某字段后,确认是否有新字段出现。
- 登录态:如需登录,先通过 browser-guide 完成登录,再进行 Discovery。
输出:整理每个页面的字段映射,例如:
/purchase-requests/new:
- Budget Code: <select>(4 个真实选项,第一个是 placeholder)
- Desired Delivery: <input type="date">
- Context: <textarea>(不是 input)
- Submit: <button> text="Submit Request"
Phase 2: Rehearse(browser tool)
不录制,在 browser tool 中手动走一遍完整流程,验证每一步都能顺利完成。
- 按照 Phase 1 的字段映射,逐步导航、填写、点击
- 每个操作后确认页面状态符合预期
- 发现不符时,修正字段映射再重试
- 全流程无误后,才进入 Phase 3 写录制脚本
Phase 2 的价值在于消灭"脚本假设"——字段顺序、选择器、等待时机,都在这里确认,不留到录制时爆。
Phase 3: Record(Node.js 脚本)
Phase 1/2 确认后,编写录制脚本。recordVideo 必须通过 patchright Node.js 脚本完成,无法通过 browser tool 实现。
Recording Principles
1. Storytelling Flow
将视频规划为一个故事,默认结构:
- Entry:登录或导航到起始点
- Context:浏览周围环境让观众先定向
- Action:执行主要工作流步骤
- Variation:展示次要功能(可选)
- Result:展示结果或最终状态
2. Pacing(节奏)
| 时机 | 等待时长 |
|---|---|
| 登录后 | 4s |
| 导航后 | 3s |
| 点击按钮后 | 2s |
| 主要步骤之间 | 1.5-2s |
| 最后一个动作后 | 3s |
| 打字延迟 | 25-40ms / 字符 |
3. Cursor Overlay(鼠标覆盖层)
注入 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';
});
});
}
4. Mouse Movement(鼠标移动)
禁止光标瞬移,点击前先平滑移动到目标:
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;
}
5. Typing(打字)
可见打字,不要瞬间填充:
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('正文内容...');
6. Subtitles(字幕)
在视口底部注入字幕条,每次导航后重新注入:
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 已能说明问题时清空。
7. Smooth Scroll
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
Script Template
'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"]', '[email protected]', '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.cjs
Screencast API 方案(Patchright 1.60+)
Screencast 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) |
Screencast 脚本模板
'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 管理
}
})();
何时选 Screencast vs 手动注入
- Screencast:需要动作标注、章节标题、代码简洁、或需要实时帧流(AI vision)时
- 手动注入:需要完全自定义光标样式、非标准 overlay 位置、或需要兼容 Patchright < 1.59 时
- 可混合使用:Screencast 录制 + 手动注入自定义 overlay(Screencast 不排斥页面上的 DOM 元素)
Checklist Before Recording
- Phase 1 完成,每个页面字段映射已确认
- Phase 2 完成,全流程手动走通无报错
- 脚本选择器来自 Phase 1/2 的实际观察,无假设
- 每次导航后重新调用
injectCursor和injectSubtitleBar - 所有点击使用
moveAndClick(含描述性 label) - 可见输入使用
typeSlowly - 滚动使用 smooth 模式
- 关键过渡点有
showSubtitle
Common Pitfalls
- 导航后光标消失 → 重新注入
- 视频速度太快 → 增加停顿
- 光标瞬移 → 点击前先
moveAndClick - Select placeholder 看起来非空 → Phase 1 时确认 value 是否为
""或"0" - 弹窗感觉突兀 → 确认前增加阅读停顿
- 视频文件路径随机 →
copyFileSync到固定名称 - 富文本编辑器用
fill()填充 → 会导致编辑器无法识别内容,必须用pressSequentially() - 混淆标题和正文输入框 → Phase 1 必须明确区分,标题和正文通常是独立的元素
Browser Operation Best Practices(浏览器操作最佳实践)
以下经验来自实际项目验证,适用于 Phase 1/2 使用 browser tool 探查页面时:
1. 超时错误处理
当 browser tool 或 patchright 遇到超时错误时:
- 不要立即重启浏览器或放弃任务
- 等待 30 秒后在原页面继续操作
- 若仍无法操作,再等待 30 秒
- 只有在等待 60 秒后仍报错,才考虑关闭浏览器重开
- 关闭重开后仍报错才是真的出错,需停止并反馈用户
2. 文件上传处理(browser tool)
若在 Phase 1/2 需要通过 browser tool 上传文件(如测试上传功能):
- 文件必须先复制到
/tmp/openclaw/uploads/目录(沙箱限制) browser upload返回超时错误不代表上传失败- 禁止通过
input.files.length === 0判断上传是否失败 - 正确做法:上传后用
snapshot检查页面状态(进度条、缩略图、处理状态文字) - 等待策略:30s → snapshot → 失败再等 60s → snapshot → 最多 3 次
3. 表单字段映射
在 Phase 1 建立字段映射时,特别注意:
- 标题和正文通常是独立的输入框,必须分别定位和操作
- 标题通常是
<input>或<textarea> - 正文可能是富文本编辑器(
contenteditable),需要特殊处理 - Select 组件要区分 placeholder 选项和真实选项
- 提交按钮可能有多个(如"保存"、"提交"、"发布"),通过精确文本匹配
4. 内容输入最佳实践
- 使用
type+slowly: true或pressSequentially()进行可见输入 - 对于富文本编辑器,禁止使用
fill()填充内容 - 输入前确认元素已聚焦(可通过点击或
focus()) - 长文本分段输入,避免一次性输入过多内容
5. 页面状态检查
操作后检查页面状态的方式:
- 使用
snapshot而非waitForSelector(更稳定) - 检查关键元素是否存在、可见、可点击
- 检查是否有错误提示或验证消息
- 检查 URL 是否变化(导航成功)