Communitygithub.com

monogramdesign/skills

构建一个带有语音旁白的产品演示/导览视频——Playwright 屏幕录制引导式导览,ElevenLabs 合成语音旁白,ffmpeg 将旁白叠加在柔和的背景音乐上(音乐在语音期间自动降低音量)。适用于需要 AI 语音演示视频、带旁白的录屏、产品导览短片,或在 UI 更新后重新录制现有演示的场景。

skills 是什么?

该技能提供了一套可重复使用的流程,用于将正在运行的 Web 应用转化为带有 AI 语音旁白的高质量演示视频。整个流程通过一个自包含的 Node.js 脚本完成:利用 ElevenLabs 的文本转语音(TTS)为每一段旁白生成 WAV 文件,使用 Playwright 自动化控制浏览器并录制脚本化的滚动、点击导览(WebM 格式),最后通过 ffmpeg 将所有元素整合——修剪片头加载时间,按场景提示对齐旁白,生成循环的轻音乐背景,并采用侧链压缩(sidechain compression)技术在语音播放时自动压低音乐音量,确保语音清晰。核心设计理念是“每个场景的时长严格等于其对应旁白的时长”,从而无需手动同步语音和画面。该技能支持 Node 18+、Playwright、ffmpeg 和 ElevenLabs API 密钥,适合需要为产品变化快速重新生成演示视频的开发者和产品团队。脚本可独立运行,旁白按索引缓存以节省 API 配额,并在 TTS 失败时自动降级为纯画面+音乐模式。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/monogramdesign/skills/tree/HEAD/skills/elevenlabs-demo-video

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

ElevenLabs 驱动的演示视频

一个可复用的配方,用于将正在运行的 Web 应用转化为精良的、带有旁白的演示 mp4 视频。每个演示对应一个自包含的 Node 脚本,负责所有工作:合成 语音旁白、屏幕录制一个编排好的应用导览,以及将语音 + 音乐 混合到视频画面下方。无需视频编辑器,无需手动计时。

流程

ElevenLabs TTS ──► 每行旁白的 .wav 文件 (已缓存)
                                   │
Playwright ──► 录制一个脚本化的滚动/点击导览,对象是正在运行的 Web 应用 (.webm)
                                   │
ffmpeg     ──► 裁剪掉加载时长的开头部分,将每行旁白放置在其场景提示处
               生成环境背景音乐,在语音下方对音乐进行闪避处理 (侧链压缩),编码最终的 .mp4

核心思路:每个场景的时长严格等于其旁白行的时长,因此语音 总能落在正确的帧上,无需手动同步。

先决条件

  • Node 18+ 和 npm i -D playwright (使用已安装的 Chrome,通过 channel: "chrome",或替换为捆绑的 chromium 即可)。
  • ffmpeg 和 ffprobe 在 PATH 环境变量中。
  • 一个具有文本转语音权限的 ElevenLabs API 密钥 (价格较低的密钥通常 缺少 user_read 权限,因此不要依赖配额端点 — 直接尝试 TTS 即可)。
  • 应用在本地运行 (例如 npm run dev 在 :3000 端口);传递 BASE_URL 可覆盖。

将密钥存储在 git 之外 (例如 .gitignore 忽略的 demo/.eleven.key 文件中)。渲染后的 .mp4 文件很大 — 将它们加入 .gitignore 并通过共享链接 (例如 Google Drive 等) 分发, 而不是通过仓库。

使用方法

  1. 每个演示一个脚本。 将下面的模板复制到 demo/build-<name>.mjs。 每个脚本独立,并将其临时文件写入 demo/<name>-work/。
  2. 编写旁白 作为 LINES 数组 — 逐字编写,每个场景一个条目。 在预算时长时,目标语速约为每秒 2.3 个单词。
  3. 编排场景 在 actions 中 — 每个旁白行对应一个条目,顺序相同。 一个普通的异步函数构成一个静态镜头;一个 {action, hold} 对象构成镜头 (action),然后在旁白播放期间进行平移/点击 (hold)。
  4. 在前台运行 任何需要驱动应用 (点击、表单 填写、身份/路由切换) 的演示: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 进行偏移。
  • Hold = max(旁白时长, 平移时长)。 对于主动的 hold,至少填充到行时长 以确保语音不会被切断;如果交互运行时间更长,则让它继续。
  • 录制清晰。 使用大视口 (例如 1920×1200) 并通过初始化脚本应用 document.documentElement.style.zoom (例如 1.5),以便逻辑布局 计算保持在你的设计尺寸,但帧渲染得更清晰。
  • 消除卡顿滚动。 具有 mix-blend-* 效果的固定、全视口覆盖层 (例如胶片颗粒、渐变) 会强制每个滚动帧进行完整的重新合成。在录制时隐藏它们: page.addStyleTag({content: ".grain{display:none!important}"})。 同时隐藏任何你不想出现在画面中的常驻 CTA/页脚。
  • 稳健地驱动受控输入。 React 控制的 <select> 会忽略简单的 selectOption。使用原生 setter 加上一个冒泡的 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(`DONE: ${OUT}  (${outDur.toFixed(1)}s)`);

调整

  • 新语音: 更改 voiceId() 中的名称过滤器,或硬编码一个 voice_id。
  • 交互式导览: 添加像 answerChoice/clickTab 这样的辅助函数,它们通过标签限定范围到某个块, 重试直到出现确认定位器为止。

相关技能