UI 데모 영상 레코더은(는) 무엇을 하나요?
Playwright의 비디오 녹화와 커서 오버레이, 자연스러운 템포, 스토리텔링 흐름을 결합하여 웹 애플리케이션의 세련된 데모 영상을 녹화합니다.
언제 사용하나요
- 사용자가 "데모 영상", "화면 녹화", "둘러보기", "튜토리얼"을 요청할 때
- 사용자가 기능이나 워크플로우를 시각적으로 보여주고 싶을 때
- 문서, 온보딩, 또는 이해관계자 프레젠테이션을 위한 비디오가 필요할 때
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 : '(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('예행연습 통과 - 모든 선택자가 확인되었습니다');
예행연습 실패 시
- 보이는 요소 덤프를 읽습니다.
- 올바른 선택자를 찾습니다.
- 스크립트를 업데이트합니다.
- 예행연습을 다시 실행합니다.
- 모든 선택자가 통과할 때만 진행합니다.
3단계: 녹화
발견과 예행연습이 통과된 후에만 녹화를 시작합니다.
녹화 원칙
1. 스토리텔링 흐름
비디오를 스토리로 계획하세요. 사용자가 지정한 순서를 따르거나, 다음 기본 순서를 사용하세요:
- 시작: 로그인하거나 시작점으로 이동
- 컨텍스트: 주변을 둘러보며 시청자가 방향을 잡을 수 있도록 함
- 작업: 주요 워크플로우 단계 수행
- 변형: 설정, 테마 또는 현지화와 같은 보조 기능 보여주기
- 결과: 결과, 확인 또는 새로운 상태 보여주기
2. 템포
- 로그인 후:
4초 - 탐색 후:
3초 - 버튼 클릭 후:
2초 - 주요 단계 사이:
1.5-2초 - 최종 동작 후:
3초 - 타이핑 지연: 문자당
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사용 - 조용한 캐치 없음; 실패는 명확하게 로그로 남기기
[생략됨 - 번역을 위해]