跳转至

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');

排练失败时

  1. 阅读可见元素转储。
  2. 找到正确的选择器。
  3. 更新脚本。
  4. 重新运行排练。
  5. 仅在所有选择器通过后继续。

阶段 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();
  }
})();

使用方法:

# Phase 2: Rehearse
node demo-script.cjs --rehearse

# Phase 3: Record
node demo-script.cjs

录制前检查清单

  • [ ] 探索阶段已完成
  • [ ] 预演已通过,所有选择器都能正常工作
  • [ ] 无头模式已启用
  • [ ] 分辨率已设置为 1280x720
  • [ ] 每次导航后重新注入光标和字幕覆盖层
  • [ ] 在关键过渡时使用 showSubtitle(page, 'Step N - ...')
  • [ ] 所有点击都使用带描述性标签的 moveAndClick
  • [ ] 可见的输入使用 typeSlowly
  • [ ] 没有静默 catch。辅助函数会记录警告
  • [ ] 内容展示使用平滑滚动
  • [ ] 重要的停顿对观众可见
  • [ ] 流程遵循要求的故事情节顺序
  • [ ] 脚本反映了阶段 1 中发现的实际 UI

常见陷阱

  1. 导航后光标消失——重新注入。
  2. 视频太快——添加停顿。
  3. 光标变成了圆点而不是箭头——使用 SVG 覆盖层。
  4. 光标瞬间移动——在点击前先移动到位。
  5. 下拉选择显得不连贯——先展示移动过程,再选择选项。
  6. 模态框显得太突兀——在确认前添加一段阅读停顿。
  7. 视频文件路径是随机生成的——复制为稳定的输出文件名。
  8. 选择器失败被吞掉——绝不要使用静默 catch 块。
  9. 假设字段类型——先探索。
  10. 假设功能存在——在编写脚本前先确认实际 UI。
  11. 占位符选择值看起来像真实值——注意 "0" 和 "Select..."。
  12. 弹出窗口会生成单独的视频——显式捕获弹出页面,并在需要时稍后合并。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 ev/ECC