ui-demo

ui-demo

熱門

使用 Playwright 錄製精美的 UI 示範影片。當使用者要求建立網頁應用程式的示範、導覽、螢幕錄影或教學影片時使用。產出具有可見游標、自然節奏與專業感的 WebM 影片。

23萬星標
3.5萬分支
更新於 2026/7/20
SKILL.md
readonlyread-only
name
ui-demo
description

Record polished UI demo videos using Playwright. Use when the user asks to create a demo, walkthrough, screen recording, or tutorial video of a web application. Produces WebM videos with visible cursor, natural pacing, and professional feel.

UI 示範影片錄製工具

使用 Playwright 的影片錄製功能,搭配注入的游標疊加層、自然節奏與故事性流程,錄製精美的網頁應用程式示範影片。

使用時機

  • 使用者要求「示範影片」、「螢幕錄影」、「導覽」或「教學影片」
  • 使用者希望以視覺方式展示某個功能或工作流程
  • 使用者需要為文件、入門教學或利害關係人簡報準備影片

三階段流程

每個示範都需經歷三個階段:探索 → 排練 → 錄製。切勿直接跳過前兩個階段直接錄製。


階段 1:探索

在撰寫任何腳本之前,先探索目標頁面,了解實際上有哪些元素。

為什麼

你無法為沒看過的內容撰寫腳本。欄位可能是 <input> 而非 <textarea>,下拉選單可能是自訂元件而非 <select>,留言框可能支援 @mentions#tags。假設會讓錄製在無聲中失敗。

如何進行

瀏覽流程中的每個頁面,並傾印其互動元素:

// 在撰寫示範腳本之前,對流程中的每個頁面執行此程式碼
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>、自訂下拉選單還是 combobox?
  • 選項值:傾印選項的值與文字。預設選項常有 value="0"value="",看起來不為空。使用 Array.from(el.options).map(o => ({ value: o.value, text: o.text }))。跳過文字包含「Select」或值為 "0" 的選項。
  • 富文字:留言框是否支援 @mentions#tags、Markdown 或表情符號?檢查 placeholder 文字。
  • 必填欄位:哪些欄位會阻擋表單送出?檢查 required、標籤中的 *,並嘗試送出空白表單以查看驗證錯誤。
  • 動態內容:欄位是否在填寫其他欄位後才出現?
  • 按鈕標籤:確切文字,例如 "Submit""Submit Request""Send"
  • 表格欄標題:對於表格驅動的模態框,將每個 input[type="number"] 對應到其欄標題,而不是假設所有數字輸入框意義相同。

產出

每個頁面的欄位對應表,用於在腳本中撰寫正確的選擇器。範例:

/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"(輸入內容後才啟用)

階段 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. 節奏
  • 登入後: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(`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);
}

每次導航後,與 injectCursor(page) 一起呼叫 injectSubtitleBar(page)

使用模式:

await showSubtitle(page, 'Step 1 - Logging in');
await showSubtitle(page, 'Step 2 - Dashboard overview');
await showSubtitle(page, '');

指南:

  • 字幕文字保持簡短,最好在 60 個字元以內。
  • 使用 Step N - Action 格式以保持一致性。
  • 在長時間停頓且 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, 'Step 1 - Logging in');
    // 登入動作

    await page.goto(`${BASE_URL}/dashboard`);
    await injectCursor(page);
    await injectSubtitleBar(page);
    await showSubtitle(page, 'Step 2 - Dashboard overview');
    // 環視儀表板

    await showSubtitle(page, 'Step 3 - Main workflow');
    // 動作序列

    await showSubtitle(page, 'Step 4 - Result');
    // 最終展示
    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();
  }
})();

使用方式:

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

# 階段 3:錄製
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. 彈出視窗會產生獨立的影片——明確擷取彈出頁面,必要時再合併。