mcpField
G
generate-video

generate-video

github.com/kernel创意与媒体1361 次安装2 周前更新
代码仓库

关于 generate-video

generate-video 是一项技能,可将网页场景制作成确定性 MP4 视频,场景通常使用 Next.js 或 React 构建。它通过虚拟时钟捕获等间隔帧,再用 ffmpeg 编码,适合制作流畅的演示和说明视频。

主要功能

  1. 01由虚拟时钟控制动画
  2. 02通过 Chromium 确定性捕获帧
  3. 03使用 ffmpeg 编码 PNG 序列
  4. 04完整录制前预览单帧
  5. 05可选生成 GIF

适用场景

  1. 01制作流畅的产品演示或发布视频
  2. 02为指标、时间线或架构图制作动画
  3. 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),以检查字体闪烁、资源缺失或渲染截断。

评论

支持 Markdown