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 文字。預留位置的值經常是
"0"或"",看起來非空。使用Array.from(el.options).map(o => ({ value: o.value, text: o.text }))。跳過文字包含「Select」或值為"0"的選項。 - 富文本:留言框是否支援
@提及、#標籤、Markdown 或表情符號?檢查預留位置文字。 - 必填欄位:哪些欄位會阻止表單送出?檢查
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> 文字="Submit"
/purchase-requests/N (detail):
- Comment: <input placeholder="Type a message..."> 支援 @user 和 #PR 標籤
- Send: <button> 文字="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 = `排練失敗:"${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('排練成功 - 所有選擇器已驗證');
當排練失敗時
- 讀取可見元素的傾印。
- 找到正確的選擇器。
- 更新腳本。
- 重新執行排練。
- 僅在每個選擇器都通過時才繼續前進。
階段 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);
}
每次導航後,將 injectSubtitleBar(page) 與 injectCursor(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,...