Communitygithub.com

UI 데모 영상 녹화기

Playwright를 사용하여 세련된 UI 데모 비디오를 녹화합니다. 사용자가 웹 애플리케이션의 데모, 워크스루, 화면 녹화 또는 튜토리얼 비디오 제작을 요청할 때 사용하세요. 보이는 커서, 자연스러운 페이싱, 전문적인 느낌의 WebM 비디오를 생성합니다.

UI 데모 영상 녹화기란 무엇인가요?

UI 데모 영상 녹화기는 Playwright의 비디오 녹화 기능을 활용하여 웹 애플리케이션의 전문적인 데모 비디오를 제작하는 AI 에이전트 스킬입니다. 이 스킬은 커서 오버레이, 자연스러운 페이싱, 스토리텔링 흐름을 결합하여 매끄러운 시연 영상을 만듭니다. 세 단계(발견 - 리허설 - 녹화)로 작동하며, 첫 번째 단계인 '발견'에서는 대상 페이지의 실제 상호작용 요소(입력 필드, 선택 상자, 버튼 등)를 탐색하여 정확한 선택자를 파악합니다. 두 번째 단계인 '리허설'에서는 모든 선택자를 검증하여 녹화 중 오류를 방지합니다. 마지막 '녹화' 단계에서는 스토리텔링 흐름에 따라 자막 바, 커서 움직임, 자연스러운 타이핑 속도 등을 적용하여 완성도 높은 비디오를 생성합니다. 이 스킬은 Claude Code, Cursor, Codex와 같은 Playwright를 지원하는 모든 AI 에이전트 플랫폼에서 사용할 수 있습니다.

지원 대상~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/alhurwitz/alberts-agentic-arsenal/tree/HEAD/skills/ui-demo

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

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 }))를 사용하세요. 텍스트에 "Select"가 포함되거나 값이 "0"인 옵션은 건너뛰십시오.
  • 리치 텍스트: 댓글 상자가 @멘션, #태그, 마크다운, 또는 이모지를 지원하나요? 플레이스홀더 텍스트를 확인하세요.
  • 필수 필드: 어떤 필드가 양식 제출을 막나요? required, 레이블의 *를 확인하고, 빈 상태로 제출하여 유효성 검사 오류를 확인하세요.
  • 동적 콘텐츠: 다른 필드가 채워진 후에 필드가 나타나나요?
  • 버튼 레이블: "Submit", "Submit Request", 또는 "Send"와 같은 정확한 텍스트.
  • 테이블 컬럼 헤더: 테이블 기반 모달의 경우, 각 input[type="number"]를 컬럼 헤더에 매핑하고 모든 숫자 입력이 동일한 의미를 갖는다고 가정하지 마십시오.

출력

각 페이지에 대한 필드 맵으로, 스크립트에서 올바른 선택자를 작성하는 데 사용됩니다. 예시:

/purchase-requests/new:
  - 예산 코드: <select> (페이지의 첫 번째 select, 4개 옵션)
  - 희망 납기일: <input type="date">
  - 컨텍스트: <textarea> (입력 필드 아님)
  - BOM 테이블: 인라인 편집 가능한 셀 (span.cursor-pointer -> input 패턴)
  - 제출: <button> text="Submit"

/purchase-requests/N (상세):
  - 댓글: <input placeholder="Type a message..."> @사용자 및 #PR 태그 지원
  - 전송: <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: '로그인 이메일 필드', 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('REHEARSAL FAILED - fix selectors before recording');
  process.exit(1);
}
console.log('REHEARSAL PASSED - all selectors verified');

리허설 실패 시

  1. 표시된 요소 덤프를 읽습니다.
  2. 올바른 선택자를 찾습니다.
  3. 스크립트를 업데이트합니다.
  4. 리허설을 다시 실행합니다.
  5. 모든 선택자가 통과할 때만 진행합니다.

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(`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, '1단계 - 로그인 중');
await showSubtitle(page, '2단계 - 대시보드 개요');
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, '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('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

녹화 전 체크리스트

  • 발견 단계 완료
  • 리허설 통과 (모든 선택자 OK)
  • 헤드리스 모드 활성화됨
  • 해상도가 1280x720으로 설정됨
  • 모든 탐색 후 커서 및 자막 오버레이 재주입됨
  • 주요 전환에서 showSubtitle(page, 'Step N - ...') 사용됨
  • 모든 클릭에 설명적인 레이블과 함께 moveAndClick 사용됨
  • 눈에 보이는 입력에 typeSlowly 사용됨
  • 조용한 캐치 없음; 오류가 콘솔에 명확히 기록됨
  • 모든 page.waitForTimeout 지연이 페이싱 지침과 일치함

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

관련 스킬