Communitygithub.com

monogramdesign/skills

実行中のWebアプリケーションのナレーション付き製品デモ/ウォークスルー動画を作成します。Playwrightがガイド付きツアーを画面収録し、ElevenLabsが音声を合成し、ffmpegがナレーションをBGM(音声に合わせて音楽を自動で小さくする)に重ねます。AI音声デモ動画、ナレーション付きスクリーンキャスト、製品ウォークスルー動画、またはUI変更後の既存デモの再録音に使用します。

skills とは?

このスキルは、ElevenLabsのテキスト読み上げ(TTS)とPlaywrightのブラウザ自動操作、ffmpegの動画編集を組み合わせて、実行中のWebアプリケーションのナレーション付きデモ動画を完全自動で生成するレシピです。開発者は1つのNode.jsスクリプトを記述するだけで、ナレーションの合成、アプリ操作の画面収録、BGMとのミキシングまでを一貫して実行でき、ビデオ編集ソフトは不要です。各シーンの長さは対応するナレーションの長さに自動調整されるため、手動でタイミングを合わせる必要がありません。このスキルはClaude CodeやCursorなどのAIエージェントと連携して、UI変更後にデモ動画を素早く再生成するのに特に有用で、スクリプト内のセレクタを更新するだけで簡単に適応できます。また、TTSが失敗した場合でも、フッテージと音楽のみで動画を出力するフォールバック機能を備えており、製作プロセスの堅牢性を高めています。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/monogramdesign/skills/tree/HEAD/skills/elevenlabs-demo-video

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

ElevenLabs を活用したデモ動画の作成

実行中の Web アプリケーションから、洗練されたナレーション付きデモ mp4 を生成するための再利用可能なレシピです。1 つの自己完結型 Node スクリプトが、音声合成、アプリのガイド付きツアーの画面収録、そしてフッテージへの音声と BGM のミキシングをすべて行います。ビデオ編集ソフトも手動でのタイミング調整も必要ありません。

パイプライン

ElevenLabs TTS ──► 行ごとのナレーション .wav (キャッシュあり)
                                   │
Playwright  ──► ライブアプリのスクリプト化されたスクロール/クリックツアーを収録 (.webm)
                                   │
ffmpeg      ──► 冒頭のロード時間をトリミングし、各ナレーション行をシーンの合図に配置し、
               アンビエント BGM を生成し、音声下で音楽を自動で小さくし (サイドチェーン圧縮)、
               最終的な .mp4 にエンコード

重要な考え方: 各シーンはナレーション行の長さだけ保持されるため、手動で同期しなくても常に適切なフレームに音声が合います。

前提条件

  • Node 18 以上と npm i -D playwright (channel: "chrome" でインストール済みの Chrome を使用するか、バンドルされた chromium に切り替え可能)
  • ffmpeg と ffprobe が PATH に含まれていること
  • テキスト読み上げの権限がある ElevenLabs API キー (安価なキーには user_read がないことが多いため、クォータエンドポイントに依存せず、単に TTS を試してください)
  • アプリがローカルで実行されていること (例: :3000 で npm run dev)。BASE_URL を渡すことで上書き可能

キーは Git 管理外に保存してください (例: .gitignore された demo/.eleven.key ファイル)。生成された .mp4 ファイルは大きいため、Git 管理から除外し、共有サービス (Drive など) で配布してください。リポジトリには含めないでください。

使用方法

  1. デモごとに 1 つのスクリプト。 以下のテンプレートを demo/build-<name>.mjs にコピーしてください。各スクリプトは独立しており、スクラッチファイルを demo/<name>-work/ に書き込みます。
  2. ナレーションを LINES 配列として記述します。各行が 1 つのシーンに対応します。時間を見積もる際は、約 2.3 語/秒を目安にしてください。
  3. actions でシーンを振り付けします。各行に 1 つのエントリを、同じ順序で記述します。プレーンな非同期関数は静止画を構成します。{action, hold} オブジェクトはショットを構成し (action)、ナレーションが再生されている間にパンやクリックを行います (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 ファイルを 1 つずつずらしてください (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 }) を使用します。
  • BGM + 自動調整。 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));

// 1 line per scene, in scene order.
const LINES = [
  "Opening line — the hook, spoken over the first settled frame.",
  "Second line — narrate while the camera pans through the next section.",
  "Closing line — a calm sign-off.",
];
const TAILS = LINES.map(() => 1.4); // breathing room after each line

// ── 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()));
}

// ── Ambient music bed (Am–F–C–G pad) ────────────────────────────────────────
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]);
}

// ── Choreography helpers ────────────────────────────────────────────────────
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) synthesize narration (cached), measure durations ─────────────────────
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) record, holding each shot for its line ───────────────────────────────
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}" }); // hide blend overlays
await sleep(1500);
const contentStart = (Date.now() - t0) / 1000; // trim everything before this

const actions = [
  async () => { await sleep(300); },                                  // still hook
  { action: async () => { await scrollToText(page, "Some Heading"); await sleep(300); },
    hold:   async () => { await smoothTo(page, 1600, Math.max(4000, clips[1].dur*1000)); } }, // pan during line
  async () => { await smoothTo(page, 0, 2500); await sleep(500); },   // sign-off
];

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) mux: trim lead, place narration at cues, duck music ──────────────────
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(`DONE: ${OUT}  (${outDur.toFixed(1)}s)`);

適応方法

  • 新しい音声: voiceId() の名前フィルターを変更するか、voice_id をハードコードします。
  • インタラクティブなツアー: ラベルでブロックをスコープし、確認が表示されるまでリトライする answerChoice や clickTab などのヘルパーを追加します。
  • 動画編集: 出力 .mp4 を任意のエディターに読み込んで、トランジションやキャプションを追加します。
  • 異なるアプリ/ページ: BASE_URL を変更し、ナレーションとアクションを新しいワークフローに合わせて書き換えます。

トラブルシューティング

  • 動画ファイルが空または非常に小さい: アプリが正しい URL で実行されていること、および Playwright が Chrome を起動できることを確認してください。headless: false を試して、何が起こっているかを確認してください。
  • ナレーションが途切れる: TAILS の値を増やすか、ホールド時間を長くします。hold 関数が非同期で継続していることを確認してください。
  • 画質が悪い: ビューポートのサイズを大きくし、zoom を調整して、crf の値を下げます (例: 15)。
  • 音楽が聞こえない: sidechaincompress のパラメータを確認します。threshold を下げると、より多くの音楽がカットされます。volume=0.42 を上げます。

関連スキル