vid-motion-graphics
根據內容摘要生成多場景動態圖形 MP4 影片。使用 Playwright 在無頭 Chromium 中渲染 HTML/CSS 動畫(Web Animations API 尋幀),並以 FFmpeg 組裝 PNG 幀。不需要 React、AI API 或 Python——除了 graphic-gif 家族之外,無需任何新的依賴項。
僅使用 CDN 字型。HTML 中不使用外部函式庫。
關鍵規則(每次生成前務必閱讀)
- 使用
window.renderFrame(t)——不要使用 CSS@keyframes處理場景轉場。 在 Chromium 中,CSS 動畫currentTime的向後搜尋會被靜默忽略。renderFrame 方法:一個純 JS 函數直接根據毫秒計算opacity/transform。Playwright 每幀呼叫一次。確定性高、無競爭條件。 - 任何元素上均不得使用
animation-delay。 使用 renderFrame 時不需要。如果你發現自己在寫animation-delay,請停止——你正在使用錯誤的架構。 window.__videoReady = true只能放在document.fonts.ready.then(...)內部。 絕對不要同步設定——Playwright 捕捉第 1 幀之前必須載入字型,否則文字會以後備字型渲染。- 必須暴露
window.__stopPreview()。 瀏覽器的 rAF 預覽迴圈會與 Playwright 的 evaluate/screenshot 呼叫產生競爭。capture-frames.mjs會在幀迴圈之前呼叫__stopPreview()。務必包含它。 - 場景邊界檢查使用
t < startMs(而不是<=)。 在幀 0 時使用t <= 0會導致場景 1 顯示為黑色。正確的防護條件是if (t < startMs || t >= endMs) return hidden。 - Body 必須使用精確的像素尺寸。 寬度和高度必須是整數(
1080px、1920px)。不要使用%、vw/vh或響應式單位。 - 同一時間不得有兩個場景同時可見(10% 的進入重疊除外)。所有場景在其 renderFrame 視窗外都必須是
opacity: 0。 - 轉場僅使用
opacity。 不要使用display切換或visibility——GPU 合成的不透明度可以達到幀完美。 - 不要在聊天中傾印 HTML。 將其儲存到檔案,僅顯示摘要。
- 標題應說明訊息,而非主題。 例如 "3 Reasons Q4 Crushed Targets",而不是 "Q4 2024 Performance Video"。
- 在生成任何 HTML 之前,請閱讀
references/scene-library.md。 使用該檔案中的精確 HTML 結構和 CSS 類別名稱。
步驟 1:接收
必要參數: content_brief
可選參數與預設值:
| 參數 | 預設值 | 說明 |
|---|---|---|
| content_brief | — | 描述影片要傳達內容的文字(必要) |
| scenes | auto | 場景數量(1–6)。Auto 會根據內容摘要推斷。 |
| duration_per_scene | 3s | 每個場景的持續時間(秒)(1–8s) |
| style | kinetic-dark | kinetic-dark / editorial-light / data-pulse / bold-type / minimal-clean |
| aspect_ratio | 1:1 | 1:1 (1080×1080) / 16:9 (1920×1080) / 9:16 (1080×1920) |
| fps | 30 | 每秒幀數(24、30 或 60) |
| music | none | 背景音軌的檔案路徑(mp3/m4a/wav) |
| source | — | 在最後一幀頁尾顯示的來源歸屬 |
如果缺少 content_brief,請準確詢問:
"要建立影片,我需要一個內容摘要——影片應該傳達什麼?
範例:'顯示 Q4 營收成長 85% 的三個原因:新的企業交易、客戶流失減少、價格調漲。使用粗體數字。風格:data-pulse。'
可選:風格(預設:kinetic-dark)、寬高比(預設:1:1)、每個場景的秒數(預設:3 秒)"
如果提供了 content_brief → 立即進行到步驟 2。
步驟 2:內部架構(永遠不向用戶顯示)
1. 將內容摘要解析為場景(最多 6 個):
- 場景 1 = 鉤子或標題(始終)
- 場景 2 到 N-1 = 支援論點、指標或故事節奏
- 場景 N = CTA 或結束總結(如果場景多於一個則始終存在)
- 每個場景一個關鍵想法——如果摘要包含 7 個以上的想法,則合併最弱的部分
2. 閱讀 references/scene-library.md——為每個場景選擇場景類型:
- 鉤子/開場 →
title-card - 單一指標 →
stat-reveal - 2–4 個項目的列表 →
bullet-list - 前後對比 / 兩個數值 →
split-screen - 推薦 / 引言 →
quote-card - 結尾 / CTA →
cta-card
3. 閱讀 references/style-presets.md——為所選風格載入 CSS 權杖和動畫個性。
4. 計算時間:
totalDuration = sceneCount × duration_per_scene (秒)
totalFrames = totalDuration × fps
每個場景佔據 @keyframes 總範圍的 (100 / sceneCount)%。
| 場景 | 起始 % | 結束 % |
|---|---|---|
| 1 | 0% | (100/N)% |
| 2 | (100/N)% | (200/N)% |
| … | … | … |
| N | ((N-1)×100/N)% | 100% |
在每個場景的範圍內:
- 進入:場景範圍的前 10%
- 停留:場景範圍的 10% 到 85%
- 退出:場景範圍的 85% 到 100%
5. 決定像素尺寸:
1:1→ W=1080, H=108016:9→ W=1920, H=10809:16→ W=1080, H=1920
步驟 3:HTML 生成
在編寫任何程式碼之前,請閱讀 references/scene-library.md 和 references/style-presets.md。
必要的 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: 960px;
}
/* 來自 scene-library.md 的場景類型 CSS */
[在此貼上場景類型 CSS]
</style>
</head>
<body>
<div class="scene scene-1">
<div class="scene-inner">
[來自 scene-library.md 模板的場景 1 HTML]
</div>
</div>
[為每個場景重複]
<script>
window.__videoReady = false;
window.TOTAL_DURATION_MS = [totalDuration * 1000];
// ── 動畫輔助函式 ─────────────────────────────────────────────────────────
function lerp(a, b, p) { return a + (b - a) * p; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3); }
function sceneState(t, startMs, endMs) {
if (t < startMs || t >= endMs) return { opacity: 0, ty: 0 };
const prog = (t - startMs) / (endMs - startMs);
if (prog < 0.10) {
const p = easeOutCubic(prog / 0.10);
return { opacity: p, ty: lerp(24, 0, p) };
}
if (prog < 0.85) return { opacity: 1, ty: 0 };
const p = (prog - 0.85) / 0.15;
return { opacity: 1 - p, ty: lerp(0, -12, p) };
}
function applySceneState(el, state) {
el.style.opacity = state.opacity;
el.style.transform = state.ty !== 0 ? `translateY(${state.ty.toFixed(2)}px)` : '';
}
// ── 主要渲染函式——由 Playwright 每幀呼叫一次 ────────────────────────────────
window.renderFrame = function(t) {
// 場景 1:0ms – [D]ms
applySceneState(document.querySelector('.scene-1'), sceneState(t, 0, [D]));
// 場景 2:[D]ms – [2D]ms
applySceneState(document.querySelector('.scene-2'), sceneState(t, [D], [2D]));
// ... 為每個場景重複 ...
};
// ── 預覽迴圈——由 Playwright 在幀捕捉前停止 ─────────────────────────────────
let __previewActive = false;
let __previewRafId = null;
window.__stopPreview = function() {
__previewActive = false;
if (__previewRafId !== null) { cancelAnimationFrame(__previewRafId); __previewRafId = null; }
};
document.fonts.ready.then(() => {
window.renderFrame(0);
window.__videoReady = true;
__previewActive = true;
const startTime = performance.now();
function previewTick() {
if (!__previewActive) return;
const elapsed = performance.now() - startTime;
if (elapsed < window.TOTAL_DURATION_MS) {
window.renderFrame(elapsed);
__previewRafId = requestAnimationFrame(previewTick);
} else {
window.renderFrame(window.TOTAL_DURATION_MS - 1);
__previewActive = false;
}
}
__previewRafId = requestAnimationFrame(previewTick);
});
</script>
</body>
</html>
設計品質規則:
- 標題字型大小:永遠不小於 60px——文字必須在手機縮圖大小下清晰可讀
- 數字和統計數據:始終是螢幕上最大的元素(120–200px)
- 顯示字型使用緊密字距:
-0.02em到-0.04em - 每個場景使用一個強調色——不要將強調色分散到多個元素上
- 深色預設:分隔線
rgba(255,255,255,0.10),絕不使用實色 - 淺色預設:分隔線
rgba(0,0,0,0.10),絕不使用實色 - 每個使用
transform的元素都要設定transform-origin: center center .scene內部的內距:至少 80px——絕不讓文字觸及視窗邊緣
步驟 4:自我品質驗證(在步驟 5 之前修正所有失敗)
renderFrame 正確性:
-
window.renderFrame(t)已定義——純函數,除樣式寫入外無副作用 - 場景邊界使用
t < startMs(而不是t <= startMs)——避免第 0 幀出現黑色 - 場景轉場中零個
animation-delay或@keyframes實例 - 沒有兩個場景具有重疊的
opacity: 1視窗(10% 進入重疊除外) - 已暴露
window.__stopPreview()且預覽 rAF 迴圈會檢查__previewActive
就緒訊號:
-
window.__videoReady = false在document.fonts.ready之前宣告 -
window.__videoReady = true僅在document.fonts.ready.then(...)內部設定 -
window.renderFrame(0)在document.fonts.ready.then(...)內部、__videoReady = true之前呼叫
版面配置:
-
html, body使用精確像素尺寸([W]px、[H]px) -
body的寬度/高度上沒有%、vw、vh、rem單位 -
html, body設定overflow: hidden - 所有場景
position: absolute; inset: 0
設計:
- 所有顏色來自風格預設權杖——沒有無關的十六進位值
- 所有字型來自風格預設——沒有無關的 font-family 字串
- 標題字型大小 ≥ 60px
- 如果提供了
source參數,最終場景中需有來源
步驟 5:匯出
根據內容摘要確定 slug(kebab-case,≤30 個字元):
mkdir -p chart/[slug]
儲存 HTML:chart/[slug]/video.html
瀏覽器預覽:
open chart/[slug]/video.html
執行匯出(將 [skill-root] 替換為此技能的目錄路徑):
bash [skill-root]/scripts/export-video.sh \
chart/[slug]/video.html \
chart/[slug]/video.mp4 \
--duration [totalDuration] \
--fps [fps] \
--width [W] \
--height [H] \
[--music path/to/audio.mp3]
該腳本會在首次執行時安裝 Playwright(約 200MB Chromium),捕捉所有幀,然後使用 FFmpeg 組裝 MP4。
步驟 6:輸出摘要
## 影片:[來自內容摘要的標題]
日期:[YYYY-MM-DD] | 場景數:[N] | 風格:[style] | 比例:[ratio]
時長:[N] 秒 | FPS:[fps] | 幀數:[N]
檔案
原始檔: chart/[slug]/video.html
輸出檔: chart/[slug]/video.mp4
大小: [X] MB
檢查清單
- [ ] 所有場景按順序出現,無空白幀
- [ ] 文字在手機縮圖大小下清晰可讀
- [ ] 場景轉場流暢(無跳接剪輯)
- [ ] 最終場景包含 CTA 或結尾訊息
- [ ] 最終幀中包含來源歸屬(如果已提供)
提示(當用戶尋求指導時顯示)
"提供一個內容摘要——每個場景應該說什麼的條列要點。"
"直接說出洞察。'3 reasons Q4 grew 85%' 能為影片提供主軸。"
"如果你有偏好,請提及風格:kinetic-dark(預設)、editorial-light、data-pulse、bold-type、minimal-clean。"
"根據平台指定寬高比:LinkedIn/Instagram 動態消息使用 1:1,Stories/Reels 使用 9:16,YouTube/簡報使用 16:9。"
✅ 良好範例:"Create a 9-second video. Q4 revenue hit $4.2M (85% growth). Drivers: enterprise deals, churn 1.2%, price increase. CTA: acme.com/q4. Style: data-pulse. Aspect: 1:1."
❌ 不良範例:"make a video about our company"