Communitygithub.com

UI 演示视频录制器

使用 Playwright 录制精美的 UI 演示视频。当用户要求创建 Web 应用的演示、导览、屏幕录制或教程视频时使用。生成带有可见光标、自然节奏和专业感的 WebM 格式视频。

UI 演示视频录制器 是什么?

该技能用于通过 Playwright 录制高质量的应用演示视频。它提供了一套完整的三阶段流程:发现 (Discover)、排练 (Rehearse) 和录制 (Record),确保最终视频流畅且专业。此技能对于需要为文档、入职培训或向利益相关者展示功能而制作视频的开发者和产品经理特别有用。 该技能会自动注入一个 SVG 光标覆盖层,模拟自然的鼠标移动,并提供脚本模板来管理诸如缓慢输入、平滑滚动和仪表板平移等操作。它还包含一个字幕栏,用于向观众描述步骤,并以脚本模板结束,该模板可以处理排练和录制模式,从而防止因选择器故障而浪费时间。 该技能主要设计为与 Playwright (Node.js) 配合使用,支持任何 Web 应用程序。它简化了视频生成过程,使其可重复且可靠。

兼容平台~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/alhurwitz/alberts-agentic-arsenal/tree/HEAD/skills/ui-demo

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

UI 演示视频录制器 是做什么的?

使用 Playwright 的视频录制功能录制精美的应用演示视频,并注入光标覆盖层、自然节奏和叙事流程。

使用时机

  • 用户要求“演示视频”、“屏幕录制”、“导览”或“教程”
  • 用户希望以视觉方式展示功能或工作流程
  • 用户需要用于文档、入职培训或利益相关者演示的视频

三阶段流程

每个演示都需经历三个阶段:发现 -> 排练 -> 录制。切勿直接跳到录制阶段。


阶段 1:发现

在编写任何脚本之前,先探索目标页面以了解实际内容。

原因

你无法为未亲眼所见的内容编写脚本。字段可能是 <input> 而非 <textarea>,下拉菜单可能是自定义组件而非 <select>,评论框可能支持 @提及 或 #标签。假设会静默地破坏录制。

方法

导航到流程中的每个页面,并转储其交互元素:

// 在编写演示脚本之前,为流程中的每个页面运行此代码
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>、自定义下拉菜单还是组合框?
  • 选择选项:转储选项值 AND 文本。占位符通常具有 value="0" 或 value="",看起来非空。使用 Array.from(el.options).map(o => ({ value: o.value, text: o.text }))。跳过文本包含“选择”或值为 "0" 的选项。
  • 富文本:评论框是否支持 @提及、#标签、Markdown 或表情符号?检查占位符文本。
  • 必填字段:哪些字段会阻止表单提交?检查 required、标签中的 *,并尝试提交空值以查看验证错误。
  • 动态内容:字段是否在填写其他字段后出现?
  • 按钮标签:确切的文本,例如 "提交"、"提交请求" 或 "发送"。
  • 表格列标题:对于表格驱动的模态框,将每个 input[type="number"] 映射到其列标题,而不是假设所有数字输入的含义相同。

输出

为每个页面生成字段映射,用于在脚本中编写正确的选择器。示例:

/purchase-requests/new:
  - 预算代码:<select>(页面上第一个 select,4 个选项)
  - 期望交付日期:<input type="date">
  - 上下文:<textarea>(不是 input)
  - BOM 表格:带有 span.cursor-pointer -> input 模式的内联可编辑单元格
  - 提交按钮:<button> text="提交"

/purchase-requests/N(详情):
  - 评论:<input placeholder="输入消息..."> 支持 @user 和 #PR 标签
  - 发送按钮:<button> text="发送"(在输入有内容之前禁用)

阶段 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 = `排练失败:"${label}" 未找到 - 选择器:${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('  可见元素:\n  ' + found);
    return false;
  }
  console.log(`排练通过:"${label}"`);
  return true;
}

排练脚本结构

const steps = [
  { label: '登录邮箱字段', selector: '#email' },
  { label: '登录提交按钮', selector: 'button[type="submit"]' },
  { label: '新建请求按钮', selector: 'button:has-text("新建请求")' },
  { label: '预算代码选择器', selector: 'select' },
  { label: '交付日期', selector: 'input[type="date"]:visible' },
  { label: '描述字段', selector: 'textarea:visible' },
  { label: '添加项目按钮', selector: 'button:has-text("添加项目")' },
  { label: '提交按钮', selector: 'button:has-text("提交")' },
];

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('排练通过 - 所有选择器已验证');

当排练失败时

  1. 读取可见元素转储。
  2. 找到正确的选择器。
  3. 更新脚本。
  4. 重新运行排练。
  5. 只有当每个选择器都通过时,才能继续。

阶段 3:录制

只有在发现和排练都通过后,才应创建录制。

录制原则

1. 叙事流程

将视频规划为一个故事。遵循用户指定的顺序,或使用以下默认顺序:

  • 入口:登录或导航到起始点
  • 环境介绍:环顾四周,让观众了解环境
  • 操作:执行主要工作流程步骤
  • 变化:展示次要功能,例如设置、主题或本地化
  • 结果:展示结果、确认或新状态

2. 节奏

  • 登录后:4s
  • 导航后:3s
  • 点击按钮后:2s
  • 主要步骤之间:1.5-2s
  • 最后操作后:3s
  • 输入延迟: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(`警告: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. 输入

以可见方式输入,而不是即时填充:

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. 滚动

使用平滑滚动而非跳跃:

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(`警告:panElements 跳过了元素 ${i}(选择器:"${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);
}

在每次导航后,与 injectCursor(page) 一起调用 injectSubtitleBar(page)。

使用模式:

await showSubtitle(page, '步骤 1 - 登录');
await showSubtitle(page, '步骤 2 - 仪表板概览');
await showSubtitle(page, '');

指南:

  • 保持字幕文本简短,最好不超过 60 个字符。
  • 使用 步骤 N - 操作 格式以保持一致性。
  • 在较长的暂停期间清除字幕,让 UI 本身来说话。

脚本模板

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

使用方式:

# 阶段 2:排练
node demo-script.cjs --rehearse

# 阶段 3:录制
node demo-script.cjs

录制前检查清单

  • 发现阶段已完成
  • 排练通过,所有选择器正常
  • 已启用无头模式
  • 分辨率设置为 1280x720
  • 每次导航后重新注入光标和字幕覆盖层
  • 在主要转换处使用 showSubtitle(page, '步骤 N - ...')
  • 所有点击均使用 moveAndClick,并带有描述性标签
  • 可见输入使用 typeSlowly
  • 没有静默的 catch;

…

[已截断以进行翻译]

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相关技能