Communitygithub.com

monogramdesign/skills

실행 중인 웹 앱의 내레이터가 포함된 제품 데모/워크스루 비디오를 제작합니다 — Playwright가 안내 투어를 화면 녹화하고, ElevenLabs가 음성 해설을 합성하며, ffmpeg가 부드러운 배경 음악 위에 내레이션을 입힙니다 (음성 아래에서 음악을 덕킹). AI 음성 데모 비디오, 음성 해설이 포함된 스크린캐스트, 제품 워크스루/릴이 필요하거나 UI 변경 후 기존 데모를 다시 녹화하려는 경우에 사용하세요.

skills란 무엇인가요?

이 스킬은 ElevenLabs TTS(텍스트 음성 변환), Playwright 화면 녹화, ffmpeg 미디어 믹싱을 결합하여 실행 중인 웹 애플리케이션의 완전한 내레이션 데모 비디오를 생성하는 반복 가능한 Node.js 기반 파이프라인을 제공합니다. 각 데모는 단일 자체 포함 스크립트(`build-<name>.mjs`)로, 대본(`LINES` 배열)과 조율된 장면(`actions`)을 기반으로 음성 해설을 합성하고, 앱 투어를 녹화한 후, 음성 위에 음악을 덕킹한 최종 `.mp4`로 믹싱합니다. 핵심 통찰은 각 장면이 해당 내레이션 라인 길이만큼 정확히 유지된다는 점으로, 수동 동기화 없이 음성이 올바른 프레임에 정확히 맞춰집니다. 이 스킬은 로컬 Chrome을 사용하는 Playwright, `PATH`에 있는 ffmpeg/ffprobe, TTS 권한이 있는 ElevenLabs API 키를 필요로 합니다. 음성 합성을 인덱스별로 캐싱하여 재녹화 시 크레딧을 절약하고, TTS 실패 시 자동으로 음악+푸티지로 폴백합니다. 주요 사용 사례로는 AI 음성 데모 비디오 생성, 제품 워크스루, UI 변경 후 데모 재녹화 등이 있습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/monogramdesign/skills/tree/HEAD/skills/elevenlabs-demo-video

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

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

문서

ElevenLabs 기반 데모 비디오

실행 중인 웹 앱을 세련되고 내레이션된 데모 mp4로 만드는 반복 가능한 레시피입니다. 데모당 하나의 자체 포함 Node 스크립트가 모든 작업을 수행합니다: 음성 해설 합성, 앱의 조율된 투어 화면 녹화, 그리고 음성 + 음악을 푸티지 아래에 믹싱합니다. 비디오 편집기나 수동 타이밍이 필요 없습니다.

파이프라인

ElevenLabs TTS ──► 라인당 내레이션 .wav (캐시됨)
                                   │
Playwright  ──► 실행 중인 라이브 앱의 스크립트된 스크롤/클릭 투어 녹화 (.webm)
                                   │
ffmpeg      ──► 로드인 리드 트리밍, 각 내레이션 라인을 해당 장면 큐에 배치,
               앰비언트 음악 배경 생성, 음성 아래에서 음악 덕킹(사이드체인 압축),
               최종 .mp4 인코딩

핵심 아이디어: 각 장면은 내레이션 라인 길이만큼 정확히 유지되므로 수동 동기화 없이도 음성이 항상 올바른 프레임에 맞춰집니다.

사전 요구 사항

  • Node 18+ 및 npm i -D playwright (channel: "chrome"을 통해 설치된 Chrome 사용, 또는 번들된 크로미움으로 교체 가능).
  • PATH에 ffmpeg 및 ffprobe.
  • 텍스트 음성 변환 권한이 있는 ElevenLabs API 키 (저렴한 키는 종종 user_read가 없으므로 할당량 엔드포인트에 의존하지 말고 TTS를 시도해 보세요).
  • 로컬에서 실행 중인 앱 (예: :3000에서 npm run dev); BASE_URL을 전달하여 재정의.

키를 git 외부에 저장하세요 (예: gitignored된 demo/.eleven.key). 렌더링된 .mp4 파일은 큽니다 — gitignore하고 저장소가 아닌 공유(Drive 등)를 통해 배포하세요.

사용 방법

  1. 데모당 하나의 스크립트. 아래 템플릿을 demo/build-<name>.mjs로 복사하세요. 각 스크립트는 독립적이며 스크래치 파일을 demo/<name>-work/에 씁니다.
  2. 내레이션 작성 — LINES 배열에 장면당 하나의 항목을 그대로 입력하세요. 시간을 예산할 때 약 초당 2.3단어를 목표로 하세요.
  3. 장면 조율 — actions에서 라인당 하나의 항목으로 동일한 순서로 작성하세요. 일반 async fn은 정적 샷을 구성하고, {action, hold} 객체는 샷을 구성한 다음 내레이션이 재생되는 동안 팬/클릭을 수행합니다 (hold).
  4. 앱을 구동하는 데모(클릭, 양식 작성, ID/경로 전환) 의 경우 포그라운드에서 실행하세요: node demo/build-<name>.mjs. 정적 스크롤 전용 투어는 백그라운드에서 실행할 수 있지만, 인터랙티브한 투어는 종종 백그라운드 샌드박스에서 개발 서버에 도달하거나 컨트롤된 입력을 구동할 수 없습니다 — 따라서 의심스러우면 포그라운드에서 실행하세요.
  5. UI 변경 후 재녹화. 선택자는 DOM에 연결되어 있습니다. 재설계는 이를 깨뜨릴 것입니다. 영향을 받는 스크립트를 다시 실행하고 프레임을 확인하세요 (ffmpeg -ss <t> -i out.mp4 -frames:v 1 frame.jpg).

시간을 절약하는 교훈

  • 인덱스별로 내레이션 캐싱. 각 라인은 l<i>.wav입니다. 존재하면 재사용하여 재녹화 시 크레딧을 다시 사용하지 않습니다. 라인을 변경하려면 LINES[i]를 편집하고 해당 l<i>.wav를 삭제하세요. 목록 중간에 라인을 삽입하면 캐시된 wav를 하나씩 위로 이동하세요 (mv l7.wav l8.wav; mv l6.wav l7.wav; …). 그러면 새 라인만 합성됩니다 — 루프는 인덱스별로 캐시하며 텍스트를 확인하지 않습니다.
  • 할당량 탄력성. TTS 호출이 실패하면 푸티지 + 음악만으로 폴백하고 각 라인의 길이를 단어 수로 추정하여 빌드가 항상 mp4를 생성하도록 합니다.
  • 로드인 리드 트리밍. goto/seed/hydrate를 녹화하고, 첫 번째 샷이 안정되면 contentStart를 표시한 다음 -ss contentStart로 앞부분을 잘라내어 비디오가 차분한 프레임에서 열리도록 합니다. 내레이션 큐는 믹스 시점에 contentStart만큼 이동됩니다.
  • 유지 시간 = max(내레이션, 팬). 활성 유지의 경우, 음성이 잘리지 않도록 최소 라인 길이 이상으로 패딩하세요. 상호 작용이 더 오래 걸리면 그대로 두세요.
  • 선명하게 녹화. 큰 뷰포트(예: 1920×1200)를 사용하고 초기화 스크립트를 통해 document.documentElement.style.zoom (예: 1.5)을 적용하여 논리적 레이아웃 수학이 디자인 크기로 유지되지만 프레임이 더 선명하게 렌더링되도록 하세요.
  • 끊김 없는 스크롤 처리. mix-blend-* (필름 그레인, 그라디언트)가 있는 고정된 전체 뷰포트 오버레이는 모든 스크롤 프레임마다 전체 재합성을 강제합니다. 녹화 중에 숨기세요: page.addStyleTag({content: ".grain{display:none!important}"}). 또한 프레임에 포함하고 싶지 않은 항상 표시되는 CTA/푸터도 숨기세요.
  • 컨트롤된 입력을 견고하게 구동. React 컨트롤 <select>는 일반 selectOption을 무시합니다. 네이티브 세터 + 버블링 change 이벤트로 설정하고, 확인 로케이터가 나타날 때까지 재시도하세요:
    const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set;
    setter.call(el, value); el.dispatchEvent(new Event("change", { bubbles: true }));
    
  • 선택자에서 인용 부호 일치. 제목이 곱은 따옴표 (', ', ")로 렌더링되면, 일반 '를 사용한 정규식이 일치하지 않습니다. 아포스트로피에 .를 사용하세요: /Tell us who you.re looking for/i.
  • 레이블로 질문/필드를 찾은 다음 블록 내에서 작동. 레이아웃 변경에 견고함: page.locator("label", { hasText: q }).first().locator("xpath=..") 그런 다음 그 안에서 .getByRole("radio"/"checkbox", { name }).
  • 음악 배경 + 덕킹. ffmpeg aevalsrc로 코드 사인파에서 루핑 앰비언트 패드를 합성한 다음 sidechaincompress로 음성 아래에서 음악을 덕킹하여 음성이 항상 명확하게 읽히도록 합니다. 음악을 ~0.4로 유지하고 압축기가 딥하도록 놔두세요.

템플릿 (demo/build-<name>.mjs)

import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";

const DIR = path.resolve("demo");
const WORK = path.join(DIR, "name-work");
fs.mkdirSync(WORK, { recursive: true });
const KEY = fs.readFileSync(path.join(DIR, ".eleven.key"), "utf8").trim();
const BASE = process.env.BASE_URL || "http://localhost:3000";
const OUT = path.join(DIR, "name.mp4");
const W = 1920, H = 1200, ZOOM = 1.5;

const sh = (c, a) => execFileSync(c, a, { stdio: ["ignore", "pipe", "pipe"] }).toString();
const probe = (f) => parseFloat(sh("ffprobe", ["-v","error","-show_entries","format=duration","-of","default=noprint_wrappers=1:nokey=1", f]).trim());
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 장면 순서대로 라인당 하나.
const LINES = [
  "오프닝 라인 — 첫 번째 안정된 프레임에서 말하는 훅.",
  "두 번째 라인 — 카메라가 다음 섹션을 통해 팬하는 동안 내레이션.",
  "클로징 라인 — 차분한 마무리.",
];
const TAILS = LINES.map(() => 1.4); // 각 라인 후 여유 시간

// ── ElevenLabs ──────────────────────────────────────────────────────────────
async function voiceId() {
  const r = await fetch("https://api.elevenlabs.io/v1/voices", { headers: { "xi-api-key": KEY } });
  const { voices } = await r.json();
  return (voices.find((v) => v.name.toLowerCase().startsWith("jessica")) || voices[0]).voice_id;
}
async function tts(id, text, out) {
  const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${id}?output_format=mp3_44100_128`, {
    method: "POST", headers: { "xi-api-key": KEY, "content-type": "application/json" },
    body: JSON.stringify({ text, model_id: "eleven_multilingual_v2",
      voice_settings: { stability: 0.55, similarity_boost: 0.8, style: 0, use_speaker_boost: true } }),
  });
  if (!r.ok) throw new Error(`tts ${r.status} ${await r.text()}`);
  fs.writeFileSync(out, Buffer.from(await r.arrayBuffer()));
}

// ── 앰비언트 음악 배경 (Am–F–C–G 패드) ────────────────────────────────────────
function buildMusic(total, out) {
  const chords = [[220,261.63,329.63],[174.61,220,261.63],[261.63,329.63,392],[196,246.94,293.66]];
  const CH = 6;
  const files = chords.map((freqs, i) => {
    const expr = freqs.map((f) => `0.17*sin(2*PI*${f}*t)`).join("+");
    const f = path.join(WORK, `chord${i}.wav`);
    sh("ffmpeg", ["-y","-f","lavfi","-i",`aevalsrc=${expr}:duration=${CH}:sample_rate=44100`,
      "-af", `afade=t=in:d=1.4,afade=t=out:st=${CH-1.4}:d=1.4`, f]);
    return f;
  });
  const list = path.join(WORK, "chords.txt");
  fs.writeFileSync(list, files.map((f) => `file '${f}'`).join("\n"));
  const loop = path.join(WORK, "loop.wav");
  sh("ffmpeg", ["-y","-f","concat","-safe","0","-i",list,"-c","copy", loop]);
  const loops = Math.ceil(total / (chords.length * CH)) + 1;
  sh("ffmpeg", ["-y","-stream_loop",String(loops),"-i",loop,"-af",
    ["aecho=0.8:0.88:70|130:0.25|0.18","lowpass=f=2200","tremolo=f=0.12:d=0.25",
     "aformat=channel_layouts=stereo","afade=t=in:d=0.5",
     `afade=t=out:st=${Math.max(0,total-3)}:d=3`,`atrim=0:${total}`].join(","), out]);
}

// ── 조율 헬퍼 함수 ────────────────────────────────────────────────────
async function smoothTo(page, y, ms) {
  await page.evaluate(({ y, ms }) => new Promise((res) => {
    const s = window.scrollY, d = y - s, t0 = performance.now();
    const e = (t) => (t < 0.5 ? 2*t*t : -1 + (4-2*t)*t);
    const f = (n) => { const p = Math.min(1, (n-t0)/ms); window.scrollTo(0, s + d*e(p)); p < 1 ? requestAnimationFrame(f) : res(); };
    requestAnimationFrame(f);
  }), { y, ms });
}
async function scrollToText(page, text, ms = 1500, offset = 150) {
  const y = await page.evaluate(({ text, offset }) => {
    const all = [...document.querySelectorAll("h1,h2,h3,h4,p,span,button,a,li")].filter((e) => e.getClientRects().length);
    let el = all.find((e) => (e.textContent || "").trim() === text)
          || all.filter((e) => (e.textContent || "").includes(text)).sort((a,b)=>a.textContent.length-b.textContent.length)[0];
    return el ? el.getBoundingClientRect().top + window.scrollY - offset : null;
  }, { text, offset });
  if (y != null) await smoothTo(page, Math.max(0, y), ms);
}

// ── 1) 내레이션 합성 (캐시됨), 지속 시간 측정 ────────────────────────────────
const clips = []; let vid = null, narrationOk = true;
for (let i = 0; i < LINES.length; i++) {
  const mp3 = path.join(WORK, `l${i}.mp3`), wav = path.join(WORK, `l${i}.wav`);
  if (fs.existsSync(wav)) { clips.push({ wav, dur: probe(wav) }); continue; }
  try {
    if (!vid) vid = await voiceId();
    await tts(vid, LINES[i], mp3);
    sh("ffmpeg", ["-y","-i",mp3,"-ar","44100","-ac","2", wav]);
    clips.push({ wav, dur: probe(wav) });
  } catch (e) {
    narrationOk = false;
    clips.push({ wav: null, dur: LINES[i].trim().split(/\s+/).length / 2.3 });
  }
}

// ── 2) 각 샷을 해당 라인 길이만큼 유지하며 녹화 ─────────────────────────────────
const browser = await chromium.launch({ channel: "chrome", headless: true });
const context = await browser.newContext({ viewport: { width: W, height: H }, recordVideo: { dir: WORK, size: { width: W, height: H } } });
await context.addInitScript((z) => {
  const apply = () => { try { document.documentElement.style.zoom = String(z); } catch {} };
  apply(); document.addEventListener("DOMContentLoaded", apply);
}, ZOOM);
const page = await context.newPage();
page.setDefaultTimeout(20000);
const video = page.video();
const t0 = Date.now();

await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" });
await page.evaluate(() => (document.fonts ? document.fonts.ready : null));
await page.addStyleTag({ content: ".grain{display:none !important}" }); // 블렌드 오버레이 숨기기
await sleep(1500);
const contentStart = (Date.now() - t0) / 1000; // 이전 모든 것을 트리밍

const actions = [
  async () => { await sleep(300); },                                  // 정적 훅
  { action: async () => { await scrollToText(page, "Some Heading"); await sleep(300); },
    hold:   async () => { await smoothTo(page, 1600, Math.max(4000, clips[1].dur*1000)); } }, // 라인 동안 팬
  async () => { await smoothTo(page, 0, 2500); await sleep(500); },   // 마무리
];

const cues = [];
for (let i = 0; i < actions.length; i++) {
  const sc = actions[i];
  const act = typeof sc === "function" ? sc : sc.action;
  const hold = typeof sc === "function" ? null : sc.hold;
  await act();
  cues.push((Date.now() - t0) / 1000);
  if (hold) {
    const start = Date.now();
    await hold();
    const min = clips[i].dur + TAILS[i], elapsed = (Date.now() - start) / 1000;
    if (elapsed < min) await sleep((min - elapsed) * 1000);
  } else {
    await sleep((clips[i].dur + TAILS[i]) * 1000);
  }
}
await sleep(400);
await context.close();
await browser.close();

// ── 3) 믹스: 리드 트리밍, 큐에 내레이션 배치, 음악 덕킹 ──────────────────────────
const raw = await video.path();
const outDur = probe(raw) - contentStart;
const music = path.join(WORK, "music.wav");
buildMusic(outDur, music);

if (!narrationOk) {
  sh("ffmpeg", ["-y","-ss",String(contentStart),"-i",raw,"-i",music,
    "-filter_complex","[1:a]volume=0.7[a]","-map","0:v","-map","[a]",
    "-c:v","libx264","-preset","medium","-crf","18","-pix_fmt","yuv420p",
    "-c:a","aac","-b:a","192k","-movflags","+faststart","-t",String(outDur), OUT]);
} else {
  const lead = 0.35, narr = path.join(WORK, "narration.wav");
  const inputs = clips.flatMap((c) => ["-i", c.wav]);
  const fc =
    clips.map((c, i) => { const at = Math.max(0, Math.round((cues[i]-contentStart+lead)*1000)); return `[${i}:a]adelay=${at}|${at}[d${i}]`; }).join(";") +
    ";" + clips.map((_, i) => `[d${i}]`).join("") +
    `amix=inputs=${clips.length}:normalize=0:dropout_transition=0[mix];[mix]apad=whole_dur=${outDur},volume=1.25[a]`;
  sh("ffmpeg", ["-y", ...inputs, "-filter_complex", fc, "-map","[a]", narr]);
  sh("ffmpeg", ["-y","-ss",String(contentStart),"-i",raw,"-i",music,"-i",narr,
    "-filter_complex",[
      "[1:a]volume=0.42[m]","[2:a]asplit=2[k][n]",
      "[m][k]sidechaincompress=threshold=0.035:ratio=9:attack=15:release=350[md]",
      "[md][n]amix=inputs=2:normalize=0:duration=longest[a]",
    ].join(";"),
    "-map","0:v","-map","[a]",
    "-c:v","libx264","-preset","medium","-crf","18","-pix_fmt","yuv420p",
    "-c:a","aac","-b:a","192k","-movflags","+faststart","-t",String(outDur), OUT]);
}
console.log(`완료: ${OUT}  (${outDur.toFixed(1)}s)`);

적응

  • 새 음성: voiceId() 이름 필터를 변경하거나 voice_id를 하드코드하세요.
  • 인터랙티브 투어: answerChoice/clickTab과 같은 헬퍼를 추가하여 레이블로 블록을 범위 지정하고, 확인 로케이터가 나타날 때까지 재시도하세요.

관련 스킬