vid-product-launch
從產品描述和發布情境生成敘事性的 MP4 產品發布影片。 流程:HTML/CSS 動畫 → 無頭 Chromium(Playwright,逐幀擷取)→ FFmpeg(H.264 MP4)。 不需要 React。不需要 AI 影片 API。不需要 Python。除了 Playwright 和 FFmpeg 之外,零執行成本。
關鍵規則(每次生成前請閱讀)
-
標語(tagline)不可省略。請勿跳過它。 它是產品在 4-6 個字內的核心承諾。如果使用者未提供,請從描述中推導出一個——一個精準、使用主動語態的濃縮。切勿寫「[標語在此]」或留空。
-
揭露時刻(reveal moment)必須讓人感覺是應得的。 「鋪陳」(tease)和「建構」(build)章節存在的目的是為了營造張力。如果產品名稱出現在影片的前 20%,敘事就會崩潰。在「揭露」(Reveal)章節之前,絕對不能透露產品名稱。
-
product_name 的字型大小:16:9 最小 120px,9:16 最小 80px。 在揭露時刻,產品名稱必須是最大的元素。如果它不突出,揭露就失敗了。
-
一個證明數據(proof stat)。不是一個列表。 如果使用者提供多個數據,選最強的一個。列出 5 個數字會破壞衝擊力。一個超大的數字才能創造它。
-
使用
window.renderFrame(t)— 場景轉場不要用 CSS@keyframes。 CSS 的currentTime查找對於 Chromium 中的反向查找會被無聲地忽略。renderFrame 函式直接從毫秒計算元素樣式。Playwright 每幀呼叫一次。 -
任何元素上都不使用
animation-delay。 有了 renderFrame 就不需要它。如果你寫了animation-delay,停下來——你正在使用錯誤的架構。 -
window.__videoReady = true只能在document.fonts.ready.then(...)內部設定。 絕不能同步設定。 -
暴露
window.__stopPreview()。 rAF 預覽迴圈會與 Playwright 的 evaluate/screenshot 呼叫產生競爭。擷取腳本會在幀迴圈之前呼叫__stopPreview()。 -
在場景邊界檢查中使用
t < startMs(而不是t <= startMs)。t <= 0在第 0 幀會導致場景 1 變成黑色。 -
Body = 精確的像素尺寸。 寬度和高度是整數。不能使用
%、vw/vh、rem。 -
在生成任何 HTML 之前,請閱讀
references/scene-library.md和references/style-presets.md。 -
切勿在聊天中傾倒 HTML。 儲存到檔案。只顯示摘要。
-
膠片顆粒畫布(Film grain canvas)必須是 240×135,而不是 W×H。 在 canvas 元素上設定
width="240" height="135",然後用style="width:[W]px;height:[H]px"拉伸。在 1920×1080 解析度下的全尺寸顆粒處理速度會慢 64 倍——每幀 8MB 的 ImageData——這會讓 1800 幀的匯出花費數小時。 -
鋪陳章節必須是暗色(#000 或接近黑色),無論調性為何。 鋪陳章節的白色背景看起來像示範幻燈片,而不是產品發布影片。從暗到亮的敘事弧線(暗色鋪陳 → 暗色建構 → 產品揭露)是發布影片製造戲劇張力的方式。即使是
minimal和energetic預設,也應該在鋪陳-問題和張力-建構場景中使用background: #000。 -
每個鋪陳詞(tease word)都必須是它自己的節拍(beat),字型大小 200px 以上。 逐詞顯示在同一行衝擊力不夠。每個問題詞(例如「研究。」/「寫作。」/「推廣。」/「重複。」)獲得 1500–1800ms 的螢幕時間,使用
font-size: 200px; font-weight: 900置中,一次一個。請參閱 scene-library.md 中的tease-words模式。 -
建構章節必須展示敘述問題的內容——而不僅僅是粒子。 使用
terminal-card模式:3 張卡片依序出現,並帶有打字機動畫,顯示正在進行的手動工作。卡片具有border: 1px solid rgba(255,255,255,0.07)、藍色圓點強調、等寬字型。僅靠粒子持續 20 秒是空白的螢幕時間。 -
暗色場景上的點陣 CSS 背景(Dot-grid CSS background)。 在建構、揭露、證明和 CTA 場景中,加入
background-image: radial-gradient(circle, rgba(255,255,255,0.035) 1px, transparent 1px); background-size: 60px 60px;。它不需要計算成本,還能增加深度。
步驟 1:輸入
必要:
product_name— 正在發布的產品或功能的名稱product_description— 2–3 句話:它做什麼、為誰而做、主要益處
可選參數和預設值:
| 參數 | 預設值 | 描述 |
|---|---|---|
| tagline | 自動 | 4–6 個字 — 產品的核心承諾 |
| problem_statement | 自動推斷 | 用於鋪陳章節的 1 句話(產品解決的痛苦) |
| proof_stat | 自動推斷 | 單一指標(例如「500+ 團隊」、「10 倍速度」、「節省 $200 萬」) |
| cta | 自動 | URL 或行動短語(例如「opendirectory.dev」、「加入候補名單」) |
| launch_date | — | ISO 日期字串(啟用倒數計時卡場景) |
| tone | cinematic | cinematic / energetic / minimal / emotional |
| duration | 60 | 30 / 60 / 90(秒) |
| aspect_ratio | 16:9 | 16:9 (1920×1080) / 9:16 (1080×1920) |
| letterbox | false | 電影級 2.35:1 黑邊 — 僅限 16:9 |
| music | — | 音訊檔案路徑 (mp3/m4a/wav) |
| fps | 30 | 每秒幀數 (24、30 或 60) |
如果缺少 product_name 或 product_description,請確切詢問:
「要建立發布影片,我需要兩件事:
- 產品名稱 — 這個產品叫什麼?
- 產品描述 — 2–3 句話:它做什麼、為誰而做、主要益處是什麼?
可選:標語(4–6 個字)、證明數據(一個數字)、CTA(URL 或行動短語)、調性(cinematic / energetic / minimal / emotional)、長度(30 / 60 / 90 秒)。」
如果兩者都存在 → 立即進行步驟 2。
步驟 2:內部架構(永遠不向使用者顯示)
1. 推導缺失的參數:
- tagline(如果未提供):根據 product_description 撰寫一個。4–6 個字。主動語態。沒有贅詞(「未來是…」、「隆重介紹…」)。推導出最精準的承諾。
- problem_statement(如果未提供):從 product_description 推斷。1 句話陳述痛苦。絕不提及產品名稱。撰寫讓觀眾點頭認同的內容。
- proof_stat(如果未提供):從 product_description 推斷。如果沒有可用的數據,請使用可信的規模指標(「獲得 500+ 團隊信賴」、「從數天縮短到數分鐘」)。
- cta(如果未提供):如果可從 product_name 推斷,則使用產品域名,否則使用「了解更多」作為行動短語。
2. 從 duration 計算章節時間:
| 章節 | 30 秒 | 60 秒 | 90 秒 | 起始公式 |
|---|---|---|---|---|
| 鋪陳 | 0–5s | 0–10s | 0–15s | 0 毫秒 |
| 建構 | 5–12s | 10–30s | 15–40s | 鋪陳結束 |
| 揭露 | 12–20s | 30–45s | 40–60s | 建構結束 |
| 證明 | 20–25s | 45–55s | 60–75s | 揭露結束 |
| CTA | 25–30s | 55–60s | 75–90s | 證明結束 |
將每個邊界轉換為毫秒。指派給常數:
TEASE_START_MS, TEASE_END_MS
BUILD_START_MS, BUILD_END_MS
REVEAL_START_MS, REVEAL_END_MS
PROOF_START_MS, PROOF_END_MS
CTA_START_MS, CTA_END_MS
3. 為每個章節選擇場景:
- 鋪陳: 總是以
blackout-opener(1500 毫秒)開始。然後是tease-words(每個問題關鍵字作為自己的 1600–1800 毫秒節拍,200px 以上)。如果提供了launch_date,可選擇在鋪陳結尾加入countdown-card。 - 建構:
terminal-card序列(3 張卡片顯示正在輸入的手動任務)+ 可選的結尾強調行。長度 ≥ 30 秒可獲得全部 3 張卡片。長度 = 30 秒則使用 2 張卡片。切勿讓僅有粒子的螢幕持續超過 5 秒——螢幕必須顯示敘述問題的內容。 - 揭露:
reveal-hero— 始終在這個章節的第一個。如果長度 ≥ 60 秒,可選擇接著顯示tagline-card。 - 證明: 如果有數字型數據,則使用
proof-stat。否則使用feature-bullet。 - CTA:
cta-card— 始終、始終放在最後。
典型場景數量:30 秒 = 4 個場景,60 秒 = 6–7 個場景,90 秒 = 7–8 個場景。
4. 計算每個場景的時間:
在每個章節內,將時間平均分配給該章節中的各個場景。
例外:blackout-opener 總是準確獲得鋪陳章節的前 1500 毫秒。
5. 決定像素尺寸:
16:9→ W=1920, H=10809:16→ W=1080, H=1920
6. 信箱效果計算(如果啟用,僅限 16:9):
- 2.35:1 內容高度 = W / 2.35 = 1920 / 2.35 ≈ 817px
- 條形高度 = (H - 817) / 2 = (1080 - 817) / 2 ≈ 132px
- 頂部條:
top: 0; height: 132px - 底部條:
bottom: 0; height: 132px
7. 電影效果標誌(按預設):
cinematic:膠片顆粒=開啟(畫布 240×135,透明度 0.025),暗角=開啟,漏光=開啟(暖金色),暗色場景上的點陣=開啟energetic:膠片顆粒=關閉,暗角=關閉,點陣=開啟,揭露時有白色閃光minimal:膠片顆粒=開啟(畫布 240×135,透明度 0.022),暗角=開啟(輕微,0.5 徑向),點陣=開啟,無漏光 — 強調色 = #4B9FFF(電光藍)emotional:膠片顆粒=開啟(畫布 240×135,透明度 0.018,暖色調混合),暗角=開啟(柔和),漏光=開啟(銅暖色),點陣=關閉
所有預設:鋪陳和建構場景一律使用暗色背景(#000 或接近黑色),無論預設為何。
8. 在 HTML 中嵌入 window.__sfxTimeline(始終存在——即使未提供音樂時也是如此):
匯出腳本會讀取這個陣列,使用 FFmpeg 的 aevalsrc/anoisesrc 原生合成每種 SFX 類型,並使用 adelay 將事件放置在精確的毫秒偏移處。不需要外部音訊檔案。
SFX 類型參考:
| 類型 | 聲音 | 持續時間 | 備註 |
|---|---|---|---|
word-hit | 低頻衝擊(50Hz)+ 瞬態點擊(2.2kHz)+ 噪音爆發 | 180 毫秒 | 每個鋪陳詞一個 — 透過 amix 合成 3 層 |
type-sequence | 15Hz 機械鍵盤點擊聲(sin^30 脈衝包絡) | 1.9 秒 | 每張卡片一個 — 持續整個 TYPE_DUR |
whoosh | 雙頻噪音掃掠(1.1kHz 主體 + 4–8kHz 空氣聲) | 700 毫秒 | 在 BUILD_CLOSE 轉場處 |
tension-riser | 低沉隆隆聲(250Hz)+ 隨 t/2.8 斜坡增長的次低音 | 2.9 秒 | 從 BUILD_CLOSE + 700ms 開始 — 在 REVEAL_START 處達到峰值 |
reveal-boom | 次低音(45Hz)+ 主體(90Hz)+ 閃爍聲(4.5–11kHz)+ 85 毫秒迴響 | 900 毫秒 | 精確在 REVEAL_START 處 — 最重的打擊 |
counter-tick | 諧波點擊聲(880Hz + 440Hz + 1760Hz + 2640Hz) | 80 毫秒 | 3 個節拍 — 漸弱 0.32 → 0.26 → 0.20 |
cta-chime | A 大三和弦(440 + 554 + 659 + 880Hz)+ 帶有迴響的鐘聲閃爍 | 1.2 秒 | 精確在 CTA_START 處 |
SFX 時間映射 — 從章節常數計算 ms 值,切勿硬編碼:
window.__sfxTimeline = [
// ── 鋪陳:每個詞節拍一個 word-hit,最後一個更大聲 ───────────────────────
{ ms: WORD_BEATS[0].start, sfx: 'word-hit', vol: 0.55 },
{ ms: WORD_BEATS[1].start, sfx: 'word-hit', vol: 0.55 },
{ ms: WORD_BEATS[2].start, sfx: 'word-hit', vol: 0.55 },
{ ms: WORD_BEATS[N].start, sfx: 'word-hit', vol: 0.65 }, // 最後一個詞更大聲
// ── 建構:每張卡片一個 type-sequence(1.9 秒,匹配 TYPE_DUR 1800ms) ────────
{ ms: CARDS[0].start + 180, sfx: 'type-sequence', vol: 0.28 },
{ ms: CARDS[1].start + 180, sfx: 'type-sequence', vol: 0.28 },
{ ms: CARDS[2].start + 180, sfx: 'type-sequence', vol: 0.28 },
// ── 轉場:whoosh 接著 2.9 秒的 riser,在 REVEAL_START 達到峰值 ───────────
{ ms: BUILD_CLOSE, sfx: 'whoosh', vol: 0.50 },
{ ms: BUILD_CLOSE + 700, sfx: 'tension-riser', vol: 0.35 },
// ── 揭露:電影級重擊 ─────────────────────────────────────────────
{ ms: REVEAL_START, sfx: 'reveal-boom', vol: 0.88 },
// ── 證明:諧波滴答聲,漸弱 ───────────────────────────────────
{ ms: PROOF_START, sfx: 'counter-tick', vol: 0.32 },
{ ms: PROOF_START + 2200, sfx: 'counter-tick', vol: 0.26 },
{ ms: PROOF_START + 4400, sfx: 'counter-tick', vol: 0.20 },
// ── CTA:A 大三和弦降落 ────────────────────────────────────────────
{ ms: CTA_START, sfx: 'cta-chime', vol: 0.62 },
];
將此區塊放在預覽迴圈之前,window.renderFrame 之後。
步驟 3:HTML 生成
在撰寫任何程式碼之前,請閱讀 references/scene-library.md 和 references/style-presets.md。
使用這些檔案中的確切 CSS 類別名稱、HTML 結構和 renderFrame 模式。
必要的 HTML 骨架:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
[字型 CDN 連結,來自樣式預設]
<style>
:root {
[來自樣式預設的所有 CSS 權杖]
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: [W]px; height: [H]px;
overflow: hidden;
background: var(--bg);
font-family: var(--font-body);
position: relative;
}
.scene {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px;
opacity: 0;
will-change: opacity, transform;
}
.scene-inner {
width: 100%;
max-width: 1200px;
}
[貼上 scene-library.md 中每種場景類型的 CSS]
</style>
</head>
<body>
<!-- 電影覆蓋層元素 — 渲染在所有場景之上 -->
[如果膠片顆粒:開啟]
<canvas id="grain-overlay"
width="[W]" height="[H]"
style="position:fixed;inset:0;pointer-events:none;opacity:0.025;mix-blend-mode:overlay;z-index:50"></canvas>
[結束如果]
[如果暗角:開啟]
<div id="vignette-overlay"
style="position:fixed;inset:0;background:radial-gradient(ellipse at center,transparent 35%,rgba(0,0,0,0.65) 100%);pointer-events:none;z-index:51"></div>
[結束如果]
[如果漏光:開啟]
<div id="light-leak"
style="position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,220,140,0.5) 0%,rgba(255,255,255,0.3) 45%,transparent 70%);opacity:0;pointer-events:none;z-index:52"></div>
[結束如果]
[如果信箱效果:開啟]
<div id="lbox-top" style="position:fixed;top:0;left:0;width:[W]px;height:132px;background:#000;z-index:100"></div>
<div id="lbox-bot" style="position:fixed;bottom:0;left:0;width:[W]px;height:132px;background:#000;z-index:100"></div>
[結束如果]
<!-- 場景 -->
[來自 scene-library.md 模板的場景 HTML,每個選定的場景一個]
<script>
window.__videoReady = false;
window.TOTAL_DURATION_MS = [duration * 1000];
// ── 章節時間常數 ──────────────────────────────────────────────────
const TEASE_START_MS = [N];
const TEASE_END_MS = [N];
const BUILD_START_MS = [N];
const BUILD_END_MS = [N];
const REVEAL_START_MS = [N];
const REVEAL_END_MS = [N];
const PROOF_START_MS = [N];
const PROOF_END_MS = [N];
const CTA_START_MS = [N];
const CTA_END_MS = [N];
// ── 場景節拍定義 ──────────────────────────────────────────────────
const WORD_BEATS = [
{ start: TEASE_START_MS + 1500, end: TEASE_START_MS + 3200 },
{ start: TEASE_START_MS + 3200, end: TEASE_START_MS + 4900 },
{ start: TEASE_START_MS + 4900, end: TEASE_START_MS + 6600 },
];
if (TEASE_END_MS - TEASE_START_MS > 6600) {
WORD_BEATS.push({ start: TEASE_START_MS + 6600, end: TEASE_START_MS + 8300 });
}
const TYPE_DUR = 1800;
const BUILD_CLOSE = BUILD_END_MS - 800;
const CARDS = [
{ start: BUILD_START_MS + 400, end: BUILD_START_MS + 400 + TYPE_DUR },
{ start: BUILD_START_MS + 400 + (TEASE_END_MS - TEASE_START_MS < 8000 ? 3000 : 6000), end: BUILD_START_MS + 400 + TYPE_DUR + (TEASE_END_MS - TEASE_START_MS < 8000 ? 3000 : 6000) },
{ start: BUILD_START_MS + 400 + (TEASE_END_MS - TEASE_START_MS < 8000 ? 6000 : 12000), end: BUILD_START_MS + 400 + TYPE_DUR + (TEASE_END_MS - TEASE_START_MS < 8000 ? 6000 : 12000) },
];
// ── 核心渲染引擎 ──────────────────────────────────────────────────
function renderFrame(t) {
// 場景透明度計算
const scenes = document.querySelectorAll('.scene');
scenes.forEach(scene => {
const start = parseInt(scene.dataset.start);
const end = parseInt(scene.dataset.end);
const fadeIn = parseInt(scene.dataset.fadeIn) || 0;
const fadeOut = parseInt(scene.dataset.fadeOut) || 0;
if (t < start || t >= end) {
scene.style.opacity = '0';
scene.style.pointerEvents = 'none';
return;
}
// 淡入
if (t < start + fadeIn) {
const progress = (t - start) / fadeIn;
scene.style.opacity = Math.min(1, progress);
}
// 淡出
else if (t > end - fadeOut) {
const progress = (t - (end - fadeOut)) / fadeOut;
scene.style.opacity = Math.max(0, 1 - progress);
} else {
scene.style.opacity = '1';
}
scene.style.pointerEvents = 'auto';
});
// ── 場景特定的 renderFrame 邏輯 ─────────────────────────────
// blackout-opener:不需要特殊邏輯
// tease-words:根據 t 控制每個詞的不透明度
WORD_BEATS.forEach((beat, idx) => {
const word = document.getElementById(`tease-word-${idx}`);
if (!word) return;
if (t >= beat.start && t < beat.end) {
word.style.opacity = '1';
} else {
word.style.opacity = '0';
}
});
// terminal-card:根據卡片時間設定打字機進度
CARDS.forEach((card, idx) => {
const cardEl = document.getElementById(`terminal-card-${idx}`);
if (!cardEl) return;
if (t >= card.start && t < card.end) {
cardEl.style.opacity = '1';
const typeEl = cardEl.querySelector('.typewriter');
if (typeEl) {
const progress = Math.min(1, (t - card.start) / TYPE_DUR);
typeEl.style.setProperty('--type-progress', progress);
}
} else {
cardEl.style.opacity = '0';
}
});
// reveal-hero:在 REVEAL_START 處顯示產品名稱
const revealHero = document.getElementById('reveal-hero');
if (revealHero) {
if (t >= REVEAL_START_MS && t < REVEAL_END_MS) {
revealHero.style.opacity = '1';
// 可選的縮放動畫
const progress = Math.min(1, (t - REVEAL_START_MS) / 500);
revealHero.style.transform = `scale(${0.8 + 0.2 * progress})`;
} else {
revealHero.style.opacity = '0';
}
}
// proof-stat:在 PROOF_START 處的數字動畫
const proofStat = document.getElementById('proof-stat');
if (proofStat) {
if (t >= PROOF_START_MS && t < PROOF_END_MS) {
proofStat.style.opacity = '1';
} else {
proofStat.style.opacity = '0';
}
}
// cta-card:在 CTA_START 處顯示
const ctaCard = document.getElementById('cta-card');
if (ctaCard) {
if (t >= CTA_START_MS && t < CTA_END_MS) {
ctaCard.style.opacity = '1';
} else {
ctaCard.style.opacity = '0';
}
}
// 覆蓋層:如果使用,根據需要控制光洩漏和膠片顆粒
const lightLeak = document.getElementById('light-leak');
if (lightLeak) {
if (t >= REVEAL_START_MS && t < REVEAL_START_MS + 2000) {
const progress = (t - REVEAL_START_MS) / 2000;
lightLeak.style.opacity = Math.min(0.6, progress * 0.6);
} else {
lightLeak.style.opacity = '0';
}
}
}
// ── 膠片顆粒引擎 ──────────────────────────────────────────────────
const grainCanvas = document.getElementById('grain-overlay');
if (grainCanvas) {
const ctx = grainCanvas.getContext('2d');
const grainW = 240, grainH = 135;
const imageData = ctx.createImageData(grainW, grainH);
function updateGrain() {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const val = Math.random() * 256;
data[i] = val; // R
data[i+1] = val; // G
data[i+2] = val; // B
data[i+3] = 255; // A
}
ctx.putImageData(imageData, 0, 0);
requestAnimationFrame(updateGrain);
}
updateGrain();
}
// ── SFX 時間軸 ───────────────────────────────────────────────────
window.__sfxTimeline = [
// ── 鋪陳:每個詞節拍一個 word-hit,最後一個更大聲 ───────────────────────
{ ms: WORD_BEATS[0].start, sfx: 'word-hit', vol: 0.55 },
{ ms: WORD_BEATS[1].start, sfx: 'word-hit', vol: 0.55 },
{ ms: WORD_BEATS[2].start, sfx: 'word-hit', vol: 0.55 },
{ ms: WORD_BEATS[WORD_BEATS.length-1].start, sfx: 'word-hit', vol: 0.65 },
// ── 建構:每張卡片一個 type-sequence(1.9 秒,匹配 TYPE_DUR 1800ms) ────────
{ ms: CARDS[0].start + 180, sfx: 'type-sequence', vol: 0.28 },
{ ms: CARDS[1].start + 180, sfx: 'type-sequence', vol: 0.28 },
{ ms: CARDS[2].start + 180, sfx: 'type-sequence', vol: 0.28 },
// ── 轉場:whoosh 接著 2.9 秒的 riser,在 REVEAL_START 達到峰值 ───────────
{ ms: BUILD_CLOSE, sfx: 'whoosh', vol: 0.50 },
{ ms: BUILD_CLOSE + 700, sfx: 'tension-riser', vol: 0.35 },
// ── 揭露:電影級重擊 ─────────────────────────────────────────────
{ ms: REVEAL_START, sfx: 'reveal-boom', vol: 0.88 },
// ── 證明:諧波滴答聲,漸弱 ───────────────────────────────────
{ ms: PROOF_START, sfx: 'counter-tick', vol: 0.32 },
{ ms: PROOF_START + 2200, sfx: 'counter-tick', vol: 0.26 },
{ ms: PROOF_START + 4400, sfx: 'counter-tick', vol: 0.20 },
// ── CTA:A 大三和弦降落 ────────────────────────────────────────────
{ ms: CTA_START, sfx: 'cta-chime', vol: 0.62 },
];
// ── 預覽迴圈 ───────────────────────────────────────────────────────
let __previewRAF = null;
let __previewStart = null;
window.__stopPreview = function() {
if (__previewRAF) {
cancelAnimationFrame(__previewRAF);
__previewRAF = null;
}
};
function __previewLoop(timestamp) {
if (!__previewStart) __previewStart = timestamp;
const elapsed = timestamp - __previewStart;
const t = Math.min(elapsed, window.TOTAL_DURATION_MS);
renderFrame(t);
if (t < window.TOTAL_DURATION_MS) {
__previewRAF = requestAnimationFrame(__previewLoop);
}
}
// ── 字型就緒後啟動 ────────────────────────────────────────────────
document.fonts.ready.then(() => {
window.__videoReady = true;
__previewLoop(performance.now());
});
</script>
</body>
</html>
步驟 4:匯出
- 將 HTML 儲存到
output.html - 使用 Playwright 腳本在所需幀率下逐幀擷取
- 使用 FFmpeg 將幀合併成 H.264 MP4
- 如果提供了音樂,則使用 FFmpeg 混合音訊
匯出腳本範例會先在 __videoReady 上等待,然後逐幀呼叫 renderFrame(t)。
參考資料
references/scene-library.md:場景模板、CSS、HTML 結構references/style-presets.md:字型、顏色、背景、動畫曲線