ElevenLabs 驅動的示範影片
一個可重複的配方,將正在運行的網頁應用程式轉換為精緻、附旁白的示範 mp4。每個示範對應一個自足的 Node 腳本,負責所有工作:合成旁白、錄製編排好的應用程式導覽、並將語音與音樂疊加在影片上。無需影片編輯器,也無需手動對時。
流程
ElevenLabs TTS ──► 每行旁白 .wav(已快取)
│
Playwright ──► 錄製腳本化的滾動/點擊導覽,操作正在運行的應用程式 (.webm)
│
ffmpeg ──► 修剪載入前的開頭,將每行旁白放置在其場景提示點,
生成環境背景音樂,在語音播放時降低音樂音量(側鏈壓縮),
最後編碼輸出最終的 .mp4
關鍵概念:每個場景的長度恰好等於其旁白行的長度,因此語音總是能準確落在正確的畫面上,無需手動同步。
先決條件
- Node 18+ 以及
npm i -D playwright(使用已安裝的 Chrome,透過channel: "chrome",或替換為內建的 chromium)。 ffmpeg和ffprobe需在PATH環境變數中。- 一個具有 文字轉語音 權限的 ElevenLabs API 金鑰(較便宜的金鑰通常缺少
user_read,因此不要依賴配額端點——直接嘗試 TTS 即可)。 - 應用程式需在本機運行(例如在
:3000上執行npm run dev);可透過BASE_URL覆蓋。
將金鑰存放在 Git 專案之外(例如放在已加入 .gitignore 的 demo/.eleven.key 檔案中)。渲染後的 .mp4 檔案很大——請將它們加入 .gitignore,並透過分享連結(如 Google Drive 等)來分發,而不是直接放在儲存庫中。
使用方法
- 每個示範一個腳本。 將下面的範本複製到
demo/build-<name>.mjs。每個腳本都是獨立的,並將其暫存檔案寫入demo/<name>-work/。 - 編寫旁白 作為
LINES陣列——逐字撰寫,每個場景一條。預估時間時,目標語速約為每秒 2.3 個單詞。 - 編排場景 在
actions中——每個場景對應一個條目,順序相同。一個純非同步函數構成一個靜態畫面;一個{action, hold}物件則構成畫面(action),然後在旁白播放期間進行平移/點擊(hold)。 - 在前景執行 任何需要操作應用程式的示範(點擊、填寫表單、切換身份/路由):
node demo/build-<name>.mjs。僅滾動的靜態導覽可以在背景執行,但互動式的導覽通常無法從背景沙箱存取開發伺服器或驅動受控制的輸入——因此,如果不確定,請在前景執行。 - 在 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));
// 1 line per scene, in scene order.
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, "某個標題"); 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,這些函數會根據標籤縮小到特定區塊,並重試直到出現確認定位器。