ui-demo

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

UI Demo Video Recorder

UI 演示视频录制工具

Record polished demo videos of web applications using Playwright's video recording with an injected cursor overlay, natural pacing, and storytelling flow.
借助 Playwright 的视频录制能力,结合注入的光标覆盖层、自然节奏和叙事流程,录制 Web 应用的精美演示视频。

When to Use

适用场景

  • User asks for a "demo video", "screen recording", "walkthrough", or "tutorial"
  • User wants to showcase a feature or workflow visually
  • User needs a video for documentation, onboarding, or stakeholder presentation
  • 用户要求提供「演示视频」、「屏幕录制」、「操作指南」或「教程」
  • 用户希望直观展示某项功能或工作流
  • 用户需要用于文档、新用户上手或向利益相关者汇报的视频

Three-Phase Process

三阶段流程

Every demo goes through three phases: Discover -> Rehearse -> Record. Never skip straight to recording.

所有演示都要经过三个阶段:调研 -> 演练 -> 录制,永远不要直接跳过前面步骤直接录制。

Phase 1: Discover

阶段1:调研

Before writing any script, explore the target pages to understand what is actually there.
在编写任何脚本之前,先探索目标页面,了解页面的实际内容。

Why

原因

You cannot script what you have not seen. Fields may be
<input>
not
<textarea>
, dropdowns may be custom components not
<select>
, and comment boxes may support
@mentions
or
#tags
. Assumptions break recordings silently.
你无法为没见过的内容写脚本。字段可能是
<input>
而非
<textarea>
,下拉框可能是自定义组件而非
<select>
,评论框可能支持
@mentions
#tags
。假设会悄无声息地毁掉录制效果。

How

操作方式

Navigate to each page in the flow and dump its interactive elements:
javascript
// 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));
导航到流程中的每个页面,导出其所有交互元素:
javascript
// 编写演示脚本前,为流程中的每个页面运行这段代码
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));

What to look for

需要确认的内容

  • Form fields: Are they
    <select>
    ,
    <input>
    , custom dropdowns, or comboboxes?
  • Select options: Dump option values AND text. Placeholders often have
    value="0"
    or
    value=""
    which looks non-empty. Use
    Array.from(el.options).map(o => ({ value: o.value, text: o.text }))
    . Skip options where text includes "Select" or value is
    "0"
    .
  • Rich text: Does the comment box support
    @mentions
    ,
    #tags
    , markdown, or emoji? Check placeholder text.
  • Required fields: Which fields block form submission? Check
    required
    ,
    *
    in labels, and try submitting empty to see validation errors.
  • Dynamic content: Do fields appear after other fields are filled?
  • Button labels: Exact text such as
    "Submit"
    ,
    "Submit Request"
    , or
    "Send"
    .
  • Table column headers: For table-driven modals, map each
    input[type="number"]
    to its column header instead of assuming all numeric inputs mean the same thing.
  • 表单字段:它们是
    <select>
    <input>
    、自定义下拉框还是组合框?
  • 下拉选项:导出选项的值AND文本。占位符通常有
    value="0"
    value=""
    ,看起来不是空值。使用
    Array.from(el.options).map(o => ({ value: o.value, text: o.text }))
    。跳过文本包含「Select」或值为
    "0"
    的选项。
  • 富文本:评论框是否支持
    @mentions
    #tags
    、markdown或表情?检查占位符文本。
  • 必填字段:哪些字段会阻止表单提交?检查
    required
    属性、标签中的
    *
    ,尝试空提交查看验证错误。
  • 动态内容:是否有字段会在其他字段填写后才出现?
  • 按钮标签:准确文本,比如
    "Submit"
    "Submit Request"
    "Send"
  • 表格列标题:对于表格驱动的模态框,将每个
    input[type="number"]
    映射到其列标题,不要假设所有数字输入的含义都相同。

Output

输出

A field map for each page, used to write correct selectors in the script. Example:
text
/purchase-requests/new:
  - Budget Code: <select> (first select on page, 4 options)
  - Desired Delivery: <input type="date">
  - Context: <textarea> (not input)
  - BOM table: inline-editable cells with span.cursor-pointer -> input pattern
  - Submit: <button> text="Submit"

/purchase-requests/N (detail):
  - Comment: <input placeholder="Type a message..."> supports @user and #PR tags
  - Send: <button> text="Send" (disabled until input has content)

每个页面的字段映射,用于在脚本中编写正确的选择器。示例:
text
/purchase-requests/new:
  - Budget Code: <select> (页面第一个select,4个选项)
  - Desired Delivery: <input type="date">
  - Context: <textarea> (不是input)
  - BOM table: 行内可编辑单元格,带有span.cursor-pointer -> input 模式
  - Submit: <button> text="Submit"

/purchase-requests/N (详情页):
  - Comment: <input placeholder="Type a message..."> 支持@user和#PR标签
  - Send: <button> text="Send" (输入有内容前禁用)

Phase 2: Rehearse

阶段2:演练

Run through all steps without recording. Verify every selector resolves.
不录制的情况下运行所有步骤,验证每个选择器都能正常匹配。

Why

原因

Silent selector failures are the main reason demo recordings break. Rehearsal catches them before you waste a recording.
无提示的选择器匹配失败是演示录制出错的主要原因。演练可以在你浪费时间录制前发现这些问题。

How

操作方式

Use
ensureVisible
, a wrapper that logs and fails loudly:
javascript
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;
}
使用
ensureVisible
,这个封装方法会明确输出日志并报错:
javascript
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 = `演练失败:未找到「${label}」,选择器:${typeof locator === 'string' ? locator : '(定位器对象)'}`;
    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('  可见元素:\n  ' + found);
    return false;
  }
  console.log(`演练通过:「${label}`);
  return true;
}

Rehearsal script structure

演练脚本结构

javascript
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');
javascript
const steps = [
  { label: '登录邮箱输入框', selector: '#email' },
  { label: '登录提交按钮', selector: 'button[type="submit"]' },
  { label: '新建申请按钮', selector: 'button:has-text("New Request")' },
  { label: '预算代码选择框', selector: 'select' },
  { label: '交付日期', selector: 'input[type="date"]:visible' },
  { label: '描述输入框', selector: 'textarea:visible' },
  { label: '添加条目按钮', selector: 'button:has-text("Add Item")' },
  { label: '提交按钮', 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('演练失败:录制前请修复选择器');
  process.exit(1);
}
console.log('演练通过:所有选择器已验证');

When rehearsal fails

演练失败时的处理

  1. Read the visible-element dump.
  2. Find the correct selector.
  3. Update the script.
  4. Re-run rehearsal.
  5. Only proceed when every selector passes.

  1. 查看可见元素导出内容
  2. 找到正确的选择器
  3. 更新脚本
  4. 重新运行演练
  5. 所有选择器都通过后再继续下一步

Phase 3: Record

阶段3:录制

Only after discovery and rehearsal pass should you create the recording.
只有调研和演练都通过后,再开始创建录制内容。

Recording Principles

录制原则

1. Storytelling Flow

1. 叙事流程

Plan the video as a story. Follow user-specified order, or use this default:
  • Entry: Login or navigate to the starting point
  • Context: Pan the surroundings so viewers orient themselves
  • Action: Perform the main workflow steps
  • Variation: Show a secondary feature such as settings, theme, or localization
  • Result: Show the outcome, confirmation, or new state
将视频作为故事来规划。遵循用户指定的顺序,或使用默认顺序:
  • 进入:登录或导航到起始页面
  • 背景介绍:扫视页面环境,让观众了解当前位置
  • 操作:执行主要工作流步骤
  • 变体展示:展示次要功能,比如设置、主题或本地化
  • 结果:展示最终效果、确认信息或新状态

2. Pacing

2. 节奏控制

  • After login:
    4s
  • After navigation:
    3s
  • After clicking a button:
    2s
  • Between major steps:
    1.5-2s
  • After the final action:
    3s
  • Typing delay:
    25-40ms
    per character
  • 登录后:
    4秒
  • 导航后:
    3秒
  • 点击按钮后:
    2秒
  • 主要步骤之间:
    1.5-2秒
  • 最终操作后:
    3秒
  • 输入延迟:每个字符
    25-40毫秒

3. Cursor Overlay

3. 光标覆盖层

Inject an SVG arrow cursor that follows mouse movements:
javascript
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';
    });
  });
}
Call
injectCursor(page)
after every page navigation because the overlay is destroyed on navigate.
注入一个跟随鼠标移动的SVG箭头光标:
javascript
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. Mouse Movement

4. 鼠标移动

Never teleport the cursor. Move to the target before clicking:
javascript
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;
}
Every call should include a descriptive
label
for debugging.
永远不要瞬移光标。点击前先移动到目标位置:
javascript
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(`警告:moveAndClick已跳过 - 「${label}」不可见`);
    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(`警告:moveAndClick在「${label}」上失败:${e.message}`);
    return false;
  }
  await page.waitForTimeout(postClickDelay);
  return true;
}
每次调用都要包含描述性的
label
以便调试。

5. Typing

5. 输入操作

Type visibly, not instant-fill:
javascript
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;
}
要让输入过程可见,不要瞬间填充:
javascript
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(`警告:typeSlowly已跳过 - 「${label}」不可见`);
    return false;
  }
  await moveAndClick(page, el, label);
  await el.fill('');
  await el.pressSequentially(text, { delay: charDelay });
  await page.waitForTimeout(500);
  return true;
}

6. Scrolling

6. 滚动操作

Use smooth scroll instead of jumps:
javascript
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
使用平滑滚动而非跳转:
javascript
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);

7. Dashboard Panning

7. 仪表盘扫视

When showing a dashboard or overview page, move the cursor across key elements:
javascript
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}`);
    }
  }
}
展示仪表盘或概览页面时,将光标移过关键元素:
javascript
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(`警告:panElements跳过了第${i}个元素(选择器:「${selector}」):${e.message}`);
    }
  }
}

8. Subtitles

8. 字幕

Inject a subtitle bar at the bottom of the viewport:
javascript
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);
}
Call
injectSubtitleBar(page)
alongside
injectCursor(page)
after every navigation.
Usage pattern:
javascript
await showSubtitle(page, 'Step 1 - Logging in');
await showSubtitle(page, 'Step 2 - Dashboard overview');
await showSubtitle(page, '');
Guidelines:
  • Keep subtitle text short, ideally under 60 characters.
  • Use
    Step N - Action
    format for consistency.
  • Clear the subtitle during long pauses where the UI can speak for itself.
在视口底部注入字幕栏:
javascript
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);
}
每次导航后都要和
injectCursor(page)
一起调用
injectSubtitleBar(page)
使用方式:
javascript
await showSubtitle(page, '步骤1 - 登录');
await showSubtitle(page, '步骤2 - 仪表盘概览');
await showSubtitle(page, '');
使用规范:
  • 字幕文本保持简短,最好不超过60个字符
  • 统一使用「步骤N - 操作」的格式
  • 长时间暂停且UI本身可以说明内容时,清空字幕

Script Template

脚本模板

javascript
'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();
  }
})();
Usage:
bash
undefined
javascript
'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');

// 在这里粘贴injectCursor、injectSubtitleBar、showSubtitle、moveAndClick、
// typeSlowly、ensureVisible和panElements的代码

(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();
    // 导航整个流程,为每个选择器运行ensureVisible
    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, '步骤1 - 登录');
    // 登录操作

    await page.goto(`${BASE_URL}/dashboard`);
    await injectCursor(page);
    await injectSubtitleBar(page);
    await showSubtitle(page, '步骤2 - 仪表盘概览');
    // 扫视仪表盘

    await showSubtitle(page, '步骤3 - 主工作流');
    // 操作序列

    await showSubtitle(page, '步骤4 - 结果展示');
    // 最终效果展示
    await showSubtitle(page, '');
  } catch (err) {
    console.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('视频已保存:', dest);
      } catch (e) {
        console.error('错误:复制视频失败:', e.message);
        console.error('  源路径:', src);
        console.error('  目标路径:', dest);
      }
    }
    await browser.close();
  }
})();
使用命令:
bash
undefined

Phase 2: Rehearse

阶段2:演练

node demo-script.cjs --rehearse
node demo-script.cjs --rehearse

Phase 3: Record

阶段3:录制

node demo-script.cjs
undefined
node demo-script.cjs
undefined

Checklist Before Recording

录制前检查清单

  • Discovery phase completed
  • Rehearsal passes with all selectors OK
  • Headless mode enabled
  • Resolution set to
    1280x720
  • Cursor and subtitle overlays re-injected after every navigation
  • showSubtitle(page, 'Step N - ...')
    used at major transitions
  • moveAndClick
    used for all clicks with descriptive labels
  • typeSlowly
    used for visible input
  • No silent catches; helpers log warnings
  • Smooth scrolling used for content reveal
  • Key pauses are visible to a human viewer
  • Flow matches the requested story order
  • Script reflects the actual UI discovered in phase 1
  • 调研阶段已完成
  • 演练通过,所有选择器正常
  • 已启用无头模式
  • 分辨率设置为
    1280x720
  • 每次导航后都重新注入了光标和字幕覆盖层
  • 主要过渡环节都使用了
    showSubtitle(page, '步骤N - ...')
  • 所有点击都使用了带描述标签的
    moveAndClick
  • 所有输入都使用了
    typeSlowly
    保证输入过程可见
  • 没有静默捕获异常,辅助方法都会输出警告日志
  • 内容展示使用了平滑滚动
  • 关键停顿对人类观看者来说清晰可见
  • 流程符合要求的故事顺序
  • 脚本符合阶段1中调研到的实际UI情况

Common Pitfalls

常见问题

  1. Cursor disappears after navigation - re-inject it.
  2. Video is too fast - add pauses.
  3. Cursor is a dot instead of an arrow - use the SVG overlay.
  4. Cursor teleports - move before clicking.
  5. Select dropdowns look wrong - show the move, then pick the option.
  6. Modals feel abrupt - add a read pause before confirming.
  7. Video file path is random - copy it to a stable output name.
  8. Selector failures are swallowed - never use silent catch blocks.
  9. Field types were assumed - discover them first.
  10. Features were assumed - inspect the actual UI before scripting.
  11. Placeholder select values look real - watch for
    "0"
    and
    "Select..."
    .
  12. Popups create separate videos - capture popup pages explicitly and merge later if needed.
  1. 导航后光标消失 - 重新注入
  2. 视频速度太快 - 添加停顿
  3. 光标是圆点而不是箭头 - 使用SVG覆盖层
  4. 光标瞬移 - 点击前先移动
  5. 下拉选择框显示异常 - 先展示移动过程,再选择选项
  6. 模态框出现太突兀 - 确认前添加阅读停顿
  7. 视频文件路径随机 - 复制到固定的输出文件名
  8. 选择器失败被静默处理 - 永远不要使用静默的catch块
  9. 字段类型是假设的 - 先调研确认
  10. 功能是假设的 - 编写脚本前先检查实际UI
  11. 下拉占位符的值看起来像真实值 - 注意
    "0"
    "Select..."
    这类选项
  12. 弹窗生成单独的视频 - 显式捕获弹窗页面,需要时后续合并