关于 generate-video
generate-video 是一项技能,可将网页场景制作成确定性 MP4 视频,场景通常使用 Next.js 或 React 构建。它通过虚拟时钟捕获等间隔帧,再用 ffmpeg 编码,适合制作流畅的演示和说明视频。
主要功能
- 01由虚拟时钟控制动画
- 02通过 Chromium 确定性捕获帧
- 03使用 ffmpeg 编码 PNG 序列
- 04完整录制前预览单帧
- 05可选生成 GIF
适用场景
- 01制作流畅的产品演示或发布视频
- 02为指标、时间线或架构图制作动画
- 03预览画面后调整动画时序
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kernel/ skills --skill generate-video安装后在 Claude Code 中输入/generate-video确认已加载。
详细介绍
生成视频
将网页(通常是你构建的小型 Next.js/React 场景)转换为流畅、确定性的 MP4。核心技巧:不要截取浏览器恰好渲染出的画面,而是使用注入的虚拟时钟驱动动画,以每秒精确 N 帧的速率渲染为无损 PNG,然后用 ffmpeg 编码。每一帧都对应一个精确的时间点,因此完全不会抖动。
Kernel 博客和发布动画短片(时间线竞速、扇出、吞吐量计数器、实时浏览器网格)背后的流程就是如此。
适用场景
- “把这个做成一段短视频”“录下这个动画”“我需要一段演示/发布/讲解视频”
- 动画可视化:统计计数器、时间线/竞速条、架构图、前后对比、实时网格
- 用户反复调整:“做得更流畅些”“节奏更紧凑些”“试几个版本”“用 X 重新剪辑”
- 之前的屏幕录制画面卡顿或出现停顿时
如果视频是品牌素材
在制作动画之前,先使用设计系统中的设计令牌(颜色、字体组合、边框样式)构建场景。品牌风格的场景,才能制作出符合品牌风格的视频。
场景构图
以下模式是视频特有的构图经验——让这些动画解释视频内容读起来更好的关键。
选择与要表达的观点相匹配的模式:
| 要表达的观点 | 模式 |
|---|---|
| “X 比 Y 快得多” | 围绕同一个概念设置两条或三条柱形条或竞速筹码 |
| “这个数字很大/很小” | 单个放大的递增数字统计值 |
| “同时发生很多次/大规模发生” | 逐渐填满的网格(实时图块或动画单元格) |
| “一连串步骤组成的流水线” | 带有标注阶段的从左到右的流程 |
竞速/时间线的结构(从上到下):左对齐的眉题(引导语)→ 名称 + 读数一行(实时递增的 ms)→ 轨道(动画填充或移动的筹码)→ 轨道下方的阶段副标题→ 一条说明例外情况的脚注。眉题要与内容栏左对齐,而不是与视口左侧对齐——居中的内容块配上视口左侧的眉题会显得很不协调。
行之有效的惯例:
- 颜色代表含义。 基线/较慢的一项用中性灰色;产品/较快路径用品牌强调色。不要按行索引着色。
- 竞速比的是时间,不是距离。 如果两个项目在“竞速”,就让每条轨道都到达相同的终点,在不同的时间抵达(较快的一项迅速到达后等待)——不要让较快的一项跑更短的轨道。
- 让慢速显得缓慢,快速显得瞬间完成。 慢速路径保留缓动曲线;快速路径采用线性动画并缩短时长(约 150ms),让它读起来像瞬间跳到位,而不是滑过去。
- 阶段标签:锚定在区段起点,使用绝对定位——不要使用固定宽度的弹性布局单元格。 狭窄区段(例如 6,900ms 条中的 300ms 阶段)如果按区段宽度设置标签宽度,标签就会被截断(“identity bind” → “ident”)。将每个标签定位在其起点的
left%处,设置white-space: nowrap,并让较短的标签溢出到后方的空白区域。 - 让数字递增,不要直接跳变——即使其他部分静止,跳动的
ms数值也能带来动感。对任何实时读数设置font-variant-numeric: tabular-nums(或使用等宽字体)——比例字体中的数字每帧宽度都会变化,导致周围布局抖动。 - 一次只展示一个元素。 不要同时为两个互不相关的元素添加动画——观众无法同时跟上两者。揭示一个,停顿一下,再揭示下一个。
- 开场先停留一拍(构图完成但动画尚未开始的状态约保持 400ms),让观众先读标签,再开始运动;并且在片段结束前让最后一帧至少停留 1 秒。
视频的易读性,而非桌面视口的易读性: 字体要比普通网页大得多(视频会以小尺寸观看/在手机上观看/静音自动播放),间距要充足,对比度要高。以 1600×900 或 1920×1080 渲染;如果文件大小允许,将 deviceScaleFactor 提高到 2,以获得视网膜屏幕般清晰的文字。
确保数字符合实际情况。 如果柱形条/曲线暗示了实际指标,就要与来源(博客、基准测试)一致,并确保多条柱形条的口径相同(纳入/排除项一致——例如都不包括连接时间)。任何重建或示意数据都要标注清楚;绝不能让人误以为动画展示的是实时测量结果。
技术栈
- 场景:真实网页。最简单的做法是创建一个小型 Next.js(App Router)+ React + TypeScript 应用,并为每个场景单独设置路由(
/race、/fanout等)。使用 CSS +requestAnimationFrame渐变动画;无需动画库。 - 驱动程序:通过 CDP 控制的无头 Chromium——使用 Node 的原始 WebSocket(Node ≥ 18 提供全局
WebSocket)。无需 puppeteer/playwright。 - 编码:ffmpeg(PNG 图像序列 → H.264)。
- 交付:当用户不在渲染机器旁时,使用
python3 -m http.server+ cloudflared 快速隧道。
预检:检查工具是否已安装
别等到录制了 500 帧才发现缺少 ffmpeg。请提前验证:
for c in chromium ffmpeg ffprobe node cloudflared; do command -v "$c" >/dev/null && echo "ok $c" || echo "MISSING $c"; done
通常会有 chromium、node 和 cloudflared;但经常没有 ffmpeg(以及 ffprobe)。录制前请先安装:
sudo apt-get update && sudo apt-get install -y ffmpeg # ffprobe ships in the same package
apt-get update 不是可选步骤——在软件包列表过期的 VM 上(新建机器时很常见),直接运行 apt-get install -y ffmpeg 会失败,并提示 E: Unable to locate package ffmpeg。先更新,再安装。
工作流
1. 使用可注入时钟构建场景
流畅效果的关键在于,动画读取的是一个你可以设置的时钟,而不是实际的墙上时间。添加一个钩子:正常情况下使用真实的 rAF,但当设置了 window.__REC__ 时,改为读取 window.__vt(虚拟时间,毫秒):
// useClock(): elapsed ms. Recorder sets window.__REC__ + window.__vt and
// fires a "vt" event each frame, so every captured frame is an exact instant.
// Note: it ALWAYS subscribes — do not gate the subscription on an `active`
// flag, or a ramp that starts mid-clip will resubscribe late and skip frames.
export function useClock(): number {
const [t, setT] = useState(0);
useEffect(() => {
const w = window as unknown as { __REC__?: boolean; __vt?: number };
if (w.__REC__) {
const h = () => setT(w.__vt || 0);
h(); window.addEventListener("vt", h);
return () => window.removeEventListener("vt", h);
}
let raf = 0; const start = performance.now();
const tick = (n: number) => { setT(n - start); raf = requestAnimationFrame(tick); };
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return t;
}
// derive everything from the clock — eased ramps, phase switches, counters
export const useStart = (delay = 400) => useClock() >= delay;
// Eased 0..target ramp that begins the instant `go` flips true. It ANCHORS to
// the clock value at that moment (startRef) so it always starts from 0.
// power 1 = linear (no curve).
export function useRamp(target: number, dur: number, go: boolean, power = 2): number {
const clock = useClock();
const startRef = useRef<number | null>(null);
if (go && startRef.current === null) startRef.current = clock;
if (!go || startRef.current === null) return 0;
const x = Math.min(1, (clock - startRef.current) / dur);
return target * (1 - Math.pow(1 - x, power));
}
**锚点为什么重要(这个错误会让你头疼):**时钟报告的是绝对经过时间/虚拟时间。如果渐变计算 x = clock / dur,那么 go 在时钟为 400ms 时变为 true 的那一刻,进度就会直接跳到 400/dur——进度条会明显地跳到约 ⅓ 处,而不是从 0 开始增长。将时钟值锚定到 go 首次变为 true 时的 startRef 即可解决问题。这也是 useClock 必须始终订阅,而不是在渐变激活时重新订阅的原因。
所有基于时间的状态都从这个时钟驱动——包括进度条宽度、数字递增和阶段切换(使用 clock >= 4800,而不是 setTimeout)。任何仍依赖 setTimeout/真实 rAF 的内容,在录制期间都会冻结。
实时(非录制)模式有一个注意事项:每个 useClock 实例都会运行自己的 rAF 循环,并以各自的挂载时间为起点,因此挂载时间不同的组件会读取不同的时间线,后挂载的组件会从 0 开始。请一次性挂载整个场景(这里的做法都是如此),或在场景根组件中调用一次 useClock(),并从该单一值派生渐变。无论采用哪种方式,录制都不受影响——每个实例都会读取相同的全局 window.__vt。
提示:较慢的动画应保留缓动曲线(power 2);要让某个效果相对它看起来瞬间完成,请使用线性且较短的动画(power 1,约 150ms)。
2. 确定性录制(录制器)
一个独立的 Node 脚本——使用原始 CDP,无依赖:
// node recsmooth.mjs <url> <durationMs> <framesDir> <cdpPort>
import fs from "node:fs";
const [,, URL, MS, DIR, PORT] = process.argv;
const FPS = 60, N = Math.round((Number(MS) / 1000) * FPS), step = 1000 / FPS;
fs.rmSync(DIR, { recursive: true, force: true }); fs.mkdirSync(DIR, { recursive: true });
// connect to a PAGE target (not the browser endpoint — it lacks Page/Runtime)
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const page = list.find((t) => t.type === "page");
if (!page) throw new Error("no page target in /json/list — is Chromium running with a tab open?");
const ws = new WebSocket(page.webSocketDebuggerUrl.replace("localhost", "127.0.0.1"));
let id = 0; const pend = new Map();
const send = (m, p = {}) => new Promise((r) => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p })); });
await new Promise((r) => (ws.onopen = r));
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } };
const ev = (x, awaitPromise = false) => send("Runtime.evaluate", { expression: x, awaitPromise });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send("Page.enable"); await send("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 1, mobile: false });
// install recording flags BEFORE any page script runs
await send("Page.addScriptToEvaluateOnNewDocument", { source: "window.__REC__=true; window.__vt=0;" });
await send("Page.navigate", { url: URL });
await sleep(1500); // let bundle + fonts load (clock stays at 0)
await ev("document.fonts && document.fonts.ready", true).catch(() => {});
for (let i = 0; i < N; i++) {
await ev(`window.__vt=${Math.round(i * step)}; window.dispatchEvent(new Event('vt'));`);
await ev("new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))", true); // commit+paint
const { data } = await send("Page.captureScreenshot", { format: "png" });
fs.writeFileSync(`${DIR}/f${String(i).padStart(4, "0")}.png`, Buffer.from(data, "base64"));
}
ws.close();
先以无头模式启动 Chromium:
/usr/bin/chromium --headless=new --no-sandbox --disable-gpu --hide-scrollbars \
--force-device-scale-factor=1 --window-size=1600,900 \
--remote-debugging-port=9460 about:blank >/tmp/chrome.log 2>&1 &
设置片段时长:durationMs = 开场停留时间 + 动画时长 + 最后一帧的停留时间(≥1s)。
3. 使用 ffmpeg 编码
ffmpeg -y -framerate 60 -i frames/f%04d.png -vf "format=yuv420p" -r 60 \
-c:v libx264 -preset slow -crf 18 -movflags +faststart out.mp4
yuv420p + +faststart = 可在各种播放器和浏览器中播放。PNG 源文件 + crf 18 = 画面清晰。使用 ffprobe -show_entries stream=r_frame_rate,nb_frames,duration out.mp4 进行检查。
还想要 GIF?(适用于不会自动播放 mp4 的 Slack/文档。)使用同一批 PNG 帧进行两遍调色板编码——全局调色板能保持品牌色干净,并让文件更小:
# 1) build an optimized palette
ffmpeg -y -framerate 60 -i frames/f%04d.png \
-vf "fps=30,scale=1000:-1:flags=lanczos,palettegen=stats_mode=full" pal.png
# 2) encode the gif using it
ffmpeg -y -framerate 60 -i frames/f%04d.png -i pal.png \
-lavfi "fps=30,scale=1000:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3" out.gif
30fps + 约 1000px 宽度,可将几秒钟的片段控制在约 250KB 以下。不要使用单遍 gif 编码器——它会逐帧抖动,画面会显得浑浊。
4. 迭代(预览单帧,无需完整录制)
录制前,在选定的虚拟时间预览一帧,检查画面效果:注入标志后加载页面,推进时钟(window.__vt=<t>; window.dispatchEvent(new Event('vt'))——分发事件很重要,useClock 只会在 vt 事件触发时更新),等待 2 次 rAF,调用 captureScreenshot 截取 PNG,然后查看。调整文案、时间或曲线,再重复操作。静帧效果确认无误后,再进行完整的 60 fps 录制——每轮迭代都能节省几分钟。
5. 交付
- 本地用户:直接提供
file://路径或http://localhost链接。 - 远程用户(你在远程/无头机器上渲染):提供输出目录服务,并建立隧道。
mkdir -p /tmp/share && cp out.mp4 /tmp/share/
( cd /tmp/share && nohup python3 -m http.server 8088 --bind 127.0.0.1 >/tmp/fs.log 2>&1 & )
nohup cloudflared tunnel --url http://127.0.0.1:8088 --protocol http2 \
--metrics 127.0.0.1:45088 --no-autoupdate >/tmp/cf.log 2>&1 &
sleep 9; grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' /tmp/cf.log | head -1
分享 <tunnel>/out.mp4。等对方下载后再关闭服务。
注意事项(这些问题会让你措手不及)
- 在精简型/沙盒化 VM 中,
localhost可能无法解析。 所有地方都使用127.0.0.1,并且重写 CDP WebSocket URL(webSocketDebuggerUrl.replace("localhost","127.0.0.1"))——它返回的地址中会带有localhost。出于同样的原因,cloudflared 需要--metrics 127.0.0.1:<port>,否则解析localhost时会崩溃。 - 连接到页面目标,而不是浏览器端点。
/json/version的 socket 只有Target/Browser域——Page.*/Runtime.*会抛出 "wasn't found"。从/json/list中提取type === "page"的条目。 - 录制期间,所有基于真实
setTimeout/rAF的内容都会冻结。 它们不会随着__vt推进。将所有动画、阶段切换和计数器都接入时钟钩子。(包含真正异步内容的场景——例如加载实时网络/浏览器内容——无法做到完全确定性;请实时录制并接受一定偏差。) - 如果在字体加载前就开始逐帧推进,前几帧的字体会闪烁。 导航后等待
document.fonts.ready(并为 bundle 留出一段真实时间的sleep)。 pkill -f cloudflared/pkill -f "next start"可能匹配到自身命令行并杀掉 shell(退出码为 144)。请按端口(fuser -k 8088/tcp)或精确 PID 结束进程。- 快速隧道空闲后会失效。
*.trycloudflare.com主机名会轮换,或边缘注册会断开。如果链接返回 404/000,请重启 cloudflared 并提供新的 URL——磁盘上的文件没有问题。 - 清理真实浏览器会话。 如果场景启动了真实的云浏览器(例如由 Kernel 提供的实时浏览器网格),请在之后删除它们,并仅删除你刚创建的实例,以免持续产生费用。
指南
- 保持片段简短(几秒到约 30s)。每个片段只讲一个想法;如有需要,可串联多个片段。
- 60 fps、1600×900(或 1920×1080)是不错的默认设置。如果文件大小允许,可将
deviceScaleFactor提高到 2,以获得清晰的 Retina 文本。 - 确保时长真实可信。如果数字/曲线暗示了真实指标,就应与来源(博客、基准测试)一致。将任何重构/示意元素标注为此类内容——不要把动画伪装成实时测量结果。
- 完整录制前,务必预览一张静帧。
- 分享前,请验证编码后的 MP4,而不只是 PNG 帧:
ffprobe -show_entries stream=nb_frames,duration out.mp4的结果应与durationMs× fps 相符,并提取和查看第一帧、中间帧及最后一帧(ffmpeg -ss <t> -i out.mp4 -frames:v 1 check.png),以检查字体闪烁、资源缺失或渲染截断。