Communitygithub.com

UI 示範影片錄製器

使用 Playwright 錄製專業的 UI 示範影片,包含可見的滑鼠游標、自然的節奏與專業感,適用於使用者要求建立示範、逐步解說、螢幕錄製或教學影片的情境,輸出 WebM 格式。

UI 示範影片錄製器 是什麼?

這個技能提供了一個框架,用於使用 Playwright 錄製高品質的網頁應用程式示範影片。技能強制執行一個嚴格的三階段流程:**探索 (Discover)**、**排練 (Rehearse)** 和 **錄製 (Record)**,以確保可靠性。探索階段會建立頁面上互動元素的詳細地圖,避免腳本因選擇器對應到錯誤的元件而出錯。排練階段則透過快速檢查所有選擇器的可見性,防止耗時的錄製失敗。然後,錄製階段會產生具有敘事性、流暢節奏和視覺提示(例如 SVG 游標覆蓋和動態字幕)的專業影片。強烈建議使用內建的 `moveAndClick` 和 `typeSlowly` 輔助函式,讓互動感覺更自然。該技能整合了 Playwright 的錄影功能,並提供可複製的腳本模板,用於開發環境的本機測試、持續整合管道或本地端執行。

相容平台~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 的影片錄製功能,並注入游標覆蓋、自然的節奏和敘事流程,來錄製網頁應用程式的專業示範影片。

使用時機

  • 使用者要求製作「示範影片」、「螢幕錄製」、「逐步解說」或「教學影片」
  • 使用者想要以視覺方式展示某個功能或工作流程
  • 使用者需要一份影片用於文件、入門教學或利害關係人簡報

三階段流程

每個示範都必須經過三個階段:探索 (Discover) -> 排練 (Rehearse) -> 錄製 (Record)。絕不能直接跳到錄製階段。


第一階段:探索

在撰寫任何腳本之前,先探索目標頁面以了解實際情況。

原因

你無法為你從未見過的畫面編寫腳本。表單欄位可能是 <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>、自訂下拉選單還是複合框?
  • 選項值:傾印選項的值和文字。預留位置通常有 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="發送" (在輸入內容之前為禁用狀態)

第二階段:排練

在不錄製的情況下執行所有步驟。驗證每個選擇器都能解析到元素。

原因

無聲的選擇器失敗是示範錄製中斷的主要原因。排練可以在您浪費一次錄製之前就發現這些問題。

方法

使用 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("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('排練通過 - 所有選擇器已驗證');

當排練失敗時

  1. 讀取可見元素的傾印資訊。
  2. 找到正確的選擇器。
  3. 更新腳本。
  4. 重新執行排練。
  5. 只有在每個選擇器都通過時才繼續。

第三階段:錄製

只有在探索和排練都通過之後,才能開始建立錄製。

錄製原則

1. 敘事流程

將影片規劃成一個故事。遵循使用者指定的順序,或使用此預設順序:

  • 進入點:登入或導航到起點
  • 背景介紹:環顧四周,讓觀眾了解環境
  • 行動:執行主要工作流程步驟
  • 變化:展示次要功能,例如設定、主題或在地化
  • 結果:顯示結果、確認訊息或新狀態

2. 節奏

  • 登入後:4 秒
  • 導航後:3 秒
  • 點擊按鈕後:2 秒
  • 主要步驟之間:1.5-2 秒
  • 最終動作後:3 秒
  • 打字延遲:每個字元 25-40 毫秒

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

使用方式:

# 第二階段:排練
node demo-script.cjs --rehearse

# 第三階段:錄製
node demo-script.cjs

錄製前的檢查清單

  • 探索階段已完成
  • 排練通過,所有選擇器正常
  • 已啟用無頭模式
  • 解析度設定為 1280x720
  • 每次導航後重新注入游標和字幕覆蓋層
  • 在主要轉場時使用 showSubtitle(page, '步驟 N - ...')
  • 所有點擊都使用 moveAndClick 並附帶描述性標籤
  • 可見的輸入使用 typeSlowly
  • 無靜默捕捉

Individual skills in this repo

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

相關技能