vid-motion-graphics
生成多场景动态图形视频(MP4)。通过 Playwright(Web 动画 API 帧定位)在无头 Chromium 中渲染 HTML/CSS 动画,然后用 FFmpeg 组装 PNG 帧。无需 React、无需 AI API、无需 Python——除了 graphic-gif 家族外,零新依赖。
仅使用 CDN 字体。HTML 中不引入外部库。
重要规则(每次生成前必读)
- 使用
window.renderFrame(t)——不要使用 CSS@keyframes进行场景过渡。 CSS 动画的currentTime定位在 Chromium 中向后搜索时会被静默忽略。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(而不是<=)。t <= 0会使第 0 帧的场景 1 变黑。正确的守卫是if (t < startMs || t >= endMs) return hidden。 - Body 必须为精确像素尺寸。 宽度和高度是整数(如
1080px、1920px)。不得使用%、vw/vh或响应式单位。 - 任何时候不得有两个场景同时可见(除了 10% 的入场重叠)。所有场景在其
renderFrame窗口之外必须opacity: 0。 - 过渡仅使用
opacity。 不要使用display切换,不要使用visibility——GPU 合成的不透明度可以做到帧精确。 - 切勿在聊天中转储 HTML。 保存到文件,仅显示摘要。
- 标题应陈述信息,而非主题。 如“Q4 达成目标的 3 个原因”,而不是“Q4 2024 绩效视频”。
- 生成任何 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 = 行动号召或结束总结(如果多于 1 个场景,则始终如此)
- 每个场景一个核心观点——如果简报有 7 个以上观点,合并最弱的那些
2. 阅读 references/scene-library.md——为每个场景选择场景类型:
- 钩子/开场 →
title-card - 单个指标 →
stat-reveal - 2–4 个要点的列表 →
bullet-list - 前后对比 / 两个值 →
split-screen - 推荐 / 引用 →
quote-card - 结尾 / 行动号召 →
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
检查清单
- [ ] 所有场景按顺序出现,无空白帧
- [ ] 文本在移动端缩略图尺寸下清晰可读
- [ ] 场景过渡流畅(无跳切)
- [ ] 最后一帧包含行动号召或结束信息
- [ ] 最后一帧包含来源归属(如果提供)
提示技巧(当用户请求指导时显示)
“提供一个内容简报——用要点列出每个场景应说的内容。”
“直接命名洞察。例如‘Q4 增长 85% 的 3 个理由’能让视频有主线。”
“如果你有偏好,可以提及风格:kinetic-dark(默认)、editorial-light、data-pulse、bold-type、minimal-clean。”
“指定针对平台的宽高比:1:1 用于 LinkedIn/Instagram 信息流,9:16 用于 Stories/Reels,16:9 用于 YouTube/演示。”
✅ 好的示例:“创建一个 9 秒的视频。Q4 收入达到 420 万美元(增长 85%)。驱动因素:企业交易、客户流失率 1.2%、价格上涨。行动号召:acme.com/q4。风格:data-pulse。宽高比:1:1。”
❌ 差的示例:“做一个关于我们公司的视频”