UIデモビデオレコーダー は何をしますか?
Playwrightの画面録画機能にカーソルオーバーレイと自然なペーシング、ストーリーテリングの流れを組み合わせて、Webアプリケーションの洗練されたデモビデオを録画します。
使用タイミング
- ユーザーが「デモ動画」「画面録画」「ウォークスルー」「チュートリアル」を依頼した場合
- ユーザーが機能やワークフローを視覚的に紹介したい場合
- ドキュメント、オンボーディング、ステークホルダー向けプレゼンテーション用の動画が必要な場合
3フェーズプロセス
すべてのデモは3つのフェーズを経ます:発見 → リハーサル → 録画。録画に直接進んではいけません。
フェーズ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>、カスタムドロップダウン、コンボボックスのいずれか? - セレクトオプション: オプションの値とテキストの両方をダンプ。プレースホルダーは
value="0"やvalue=""になることが多く、空でないように見えます。Array.from(el.options).map(o => ({ value: o.value, text: o.text }))を使用。テキストに「選択」が含まれるか、値が"0"のオプションはスキップ。 - リッチテキスト: コメントボックスは
@メンション、#タグ、マークダウン、絵文字をサポートしているか?プレースホルダーテキストを確認。 - 必須フィールド: どのフィールドがフォーム送信をブロックするか?
required、ラベルの*を確認し、空で送信してバリデーションエラーを確認。 - 動的コンテンツ: 他のフィールド入力後に表示されるフィールドはあるか?
- ボタンラベル:
「送信」、「リクエストを送信」、「送信」などの正確なテキスト。 - テーブル列ヘッダー: テーブル駆動型モーダルの場合、各
input[type="number"]を列ヘッダーにマッピングし、すべての数値入力が同じ意味を持つと想定しない。
出力
各ページのフィールドマップを作成し、スクリプト内で正しいセレクタを記述するために使用します。例:
/purchase-requests/new:
- 予算コード: <select> (ページ内最初のセレクト、4オプション)
- 希望納期: <input type="date">
- コンテキスト: <textarea> (inputではない)
- BOMテーブル: インライン編集可能セル、span.cursor-pointer → inputパターン
- 送信: <button> text="送信"
/purchase-requests/N (詳細):
- コメント: <input placeholder="メッセージを入力..."> @ユーザーと #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 : '(ロケーターオブジェクト)'}`;
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(`リハーサルOK: "${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('リハーサル成功 - すべてのセレクタを確認しました');
リハーサルが失敗した場合
- 表示要素のダンプを読み取る。
- 正しいセレクタを見つける。
- スクリプトを更新する。
- リハーサルを再実行する。
- すべてのセレクタが成功した場合のみ先に進む。
フェーズ3: 録画
発見とリハーサルが成功した後にのみ録画を作成します。
録画の原則
1. ストーリーテリングの流れ
ビデオをストーリーとして計画します。ユーザー指定の順序に従うか、以下のデフォルトを使用します:
- 開始: ログインまたは開始点に移動
- コンテキスト: 周囲を表示して視聴者が方向を把握できるようにする
- アクション: 主要なワークフローステップを実行
- バリエーション: 設定、テーマ、ローカライゼーションなどの二次機能を表示
- 結果: 結果、確認、または新しい状態を表示
2. ペーシング
- ログイン後:
4秒 - ナビゲーション後:
3秒 - ボタンクリック後:
2秒 - 主要ステップ間:
1.5〜2秒 - 最終アクション後:
3秒 - タイピング遅延: 1文字あたり
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();
}
})();
使用方法:
# フェーズ2: リハーサル
node demo-script.cjs --rehearse
# フェーズ3: 録画
node demo-script.cjs
録画前のチェックリスト
- 発見フェーズが完了している
- リハーサルが全セレクタOKで成功している
- ヘッドレスモードが有効になっている
- 解像度が
1280x720に設定されている - ナビゲーションのたびにカーソルとサブタイトルオーバーレイが再注入されている
- 主要な遷移で
showSubtitle(page, 'ステップN - ...')が使用されている - すべてのクリックに
moveAndClickが説明的なラベル付きで使用されている - 可視入力には
typeSlowlyが使用されている - サイレントキャッチがない