From ecc
Playwright를 사용하여 세련된 UI 데모 비디오를 녹화합니다. 사용자가 웹 애플리케이션의 데모, 연습, 화면 녹화 또는 튜토리얼 비디오를 만들고 싶을 때 사용합니다. 눈에 띄는 커서, 자연스러운 진행 속도, 전문적인 느낌이 있는 WebM 비디오를 생성합니다.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
Playwright의 비디오 녹화 기능과 주입된 커서 오버레이, 자연스러운 진행 속도, 스토리텔링 흐름을 사용하여 웹 애플리케이션의 세련된 데모 비디오를 녹화합니다.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Playwright의 비디오 녹화 기능과 주입된 커서 오버레이, 자연스러운 진행 속도, 스토리텔링 흐름을 사용하여 웹 애플리케이션의 세련된 데모 비디오를 녹화합니다.
모든 데모는 **발견(Discover) -> 리허설(Rehearse) -> 녹화(Record)**의 3단계를 거칩니다. 절대로 곧바로 녹화 단계로 건너뛰지 마세요.
스크립트를 작성하기 전에 대상 페이지를 탐색하여 실제로 무엇이 있는지 이해합니다.
보지 않은 것을 스크립팅할 수는 없습니다. 필드가 <textarea>가 아닌 <input>일 수 있고, 드롭다운이 <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>, 사용자 정의 드롭다운 또는 콤보박스 중 무엇인가요?value="0" 또는 value=""를 갖습니다. Array.from(el.options).map(o => ({ value: o.value, text: o.text }))를 사용하세요. 텍스트에 "Select"가 포함되거나 값이 "0"인 옵션은 건너뜁니다.@mentions, #tags, 마크다운 또는 이모지를 지원하나요? 자리 표시자 텍스트를 확인하세요.required, 레이블의 *를 확인하고 빈 상태로 제출하여 유효성 검사 오류를 확인하세요."Submit", "Submit Request", "Send"와 같은 정확한 텍스트.input[type="number"]를 해당 열 헤더에 매핑합니다.스크립트에 올바른 선택자(selector)를 작성하는 데 사용되는 각 페이지의 필드 맵입니다. 예시:
/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" (입력에 내용이 있을 때까지 비활성화됨)
녹화 없이 모든 단계를 실행해 봅니다. 모든 선택자가 해결되는지 확인합니다.
조용한 선택자 실패가 데모 녹화가 깨지는 주요 원인입니다. 리허설은 녹화를 낭비하기 전에 이를 잡아냅니다.
로그를 남기고 크게 실패하는 래퍼 함수인 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}" 찾을 수 없음 - 선택자: ${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(`REHEARSAL OK: "${label}"`);
return true;
}
const steps = [
{ label: '로그인 이메일 필드', selector: '#email' },
{ label: '로그인 제출', selector: 'button[type="submit"]' },
{ label: 'New Request 버튼', selector: 'button:has-text("New Request")' },
{ label: 'Budget Code 선택', selector: 'select' },
{ label: '배송일', selector: 'input[type="date"]:visible' },
{ label: '설명 필드', selector: 'textarea:visible' },
{ label: 'Add Item 버튼', selector: 'button:has-text("Add Item")' },
{ label: 'Submit 버튼', 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('리허설 통과 - 모든 선택자 확인됨');
발견 및 리허설을 모두 통과한 후에만 녹화를 생성합니다.
비디오를 하나의 이야기로 계획합니다. 사용자가 지정한 순서를 따르거나 다음 기본값을 사용합니다:
4s3s2s1.5-2s3s25-40ms마우스 움직임을 따라가는 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)를 호출하세요.
커서를 순간 이동시키지 마세요. 클릭하기 전에 대상 위치로 이동합니다:
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(`경고: "${label}"에서 moveAndClick 실패: ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
디버깅을 위해 모든 호출에는 설명적인 label이 포함되어야 합니다.
순간적으로 채우지 않고 눈에 보이게 타이핑합니다:
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;
}
건너뛰지 않고 부드러운 스크롤을 사용합니다:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
대시보드 또는 개요 페이지를 표시할 때 주요 요소 위로 커서를 이동합니다:
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}`);
}
}
}
뷰포트 하단에 자막 표시줄을 주입합니다:
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, '');
지침:
N단계 - 동작 형식을 사용합니다.'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 사용됨"0" 및 "Select..."에 주의하세요.