mcpField
G
generate-video

generate-video

github.com/kernelクリエイティブ・メディア1361 回インストール2 週間前に更新
リポジトリ

generate-video について

generate-videoは、ウェブページのシーンから決定論的なMP4クリップを作るスキルです。通常はNext.jsまたはReactでシーンを構築し、仮想クロックで等間隔のフレームを取得してffmpegでエンコードするため、滑らかなデモや解説動画の制作に使えます。

主な機能

  1. 01仮想クロックによるアニメーション制御
  2. 02Chromiumによる決定論的なフレーム取得
  3. 03PNG連番のffmpegエンコード
  4. 04本録画前の単一フレームプレビュー
  5. 05GIF出力にも対応

活用シーン

  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 に変換します。重要なのは、ブラウザーが描画した内容をそのまま画面録画するのではなく、注入した仮想時計に基づいてアニメーションを動かし、1 秒あたり正確に N 枚の等間隔なフレームをロスレス PNG としてレンダリングしてから、ffmpeg でエンコードすることです。各フレームは時間上の正確な時点に対応するため、カクつきは一切ありません。

これは、Kernel のブログやローンチ向けアニメーションクリップ(タイムラインの競争、ファンアウト、スループットカウンター、ライブブラウザーグリッド)を支えるパイプラインです。

使用する場面

  • 「これの短い動画を作って」「このアニメーションを録画して」「デモ/ローンチ/解説用のクリップが必要」
  • アニメーション付きのビジュアライゼーション:統計カウンター、タイムライン/競争バー、アーキテクチャ図、ビフォー/アフター、ライブグリッド
  • ユーザーが反復して調整する場合:「もっと滑らかに」「もっとキビキビと」「いくつかバージョンを試して」「X を使って再編集して」
  • 以前の画面録画がカクついたり、引っかかったりした場合

動画がブランドアセットの場合

アニメーションを作る前に、デザインシステムのトークン(色、フォント構成、枠線の処理)を使ってシーンを作成します。ブランドに沿ったシーンなら、動画もブランドに沿ったものになります。

シーンの構成

以下のパターンは、動画ならではの構成上のポイントです。解説アニメーションを「クリップ」として見やすくするための工夫です。

伝えたい内容に合ったパターンを選びます:

伝えたい内容パターン
「XはYよりずっと速い」共通のアイデアを表す2つまたは3つのバーやレース用チップ
「この数値は大きい/小さい」数値を大きく表示する、単一のカウントアップ
「一度に多数/大規模に」徐々に埋まるグリッド(ライブタイルまたはアニメーションするセル)
「一連のステップからなるパイプライン」ステージにラベルを付けた左から右へのフロー

レース/タイムラインの構成(上から下へ):左揃えのアイブロウ(キッカー)→ 名前+読み取り値の行(カウントアップ中のms)→ トラック(アニメーションする塗りつぶし、または動くチップ)→ トラックの下にあるステージのサブタイトル → 注意事項を記した脚注を1つ。アイブロウはビューポートではなく、コンテンツ列に対して左揃えにします。中央揃えのコンテンツブロックに対してアイブロウがビューポートの左端にあると、見た目が崩れます。

うまくいったパターン:

  • 色で意味を表します。 ベースラインや遅いものにはニュートラルなグレーを、製品や高速な経路にはブランドのアクセントカラーを使います。行番号で色分けしないでください。
  • レースは距離ではなく、時間で表します。 2つのものを「競走」させる場合は、各レーンのゴールを同じ位置に揃え、到着する「時間」を変えます(速い方は到着して待機します)。速い方のトラックを短くしないでください。
  • 遅い動きは遅く、速い動きは瞬時に感じさせます。 遅い経路にはイージングしたカーブを使い、速い経路には直線的で短い動き(約150ms)を使うことで、滑らかに流れるのではなく、スナップするように見せます。
  • ステージラベルはセグメントの開始位置に絶対配置します。固定幅のフレックスセルにはしません。 幅の狭いセグメント(例:6,900msのバーにある300msのステージ)では、ラベルをセグメントに合わせてサイズ設定すると「identity bind」が「ident」のように切り詰められます。各ラベルを開始位置のleft%に配置し、white-space: nowrapを指定して、短いラベルが後ろの空きスペースにはみ出せるようにします。
  • 数値は一気に切り替えるのではなく、カウントアップさせます。数値が刻々と変わるms表示により、ほかは静止したバーでも動きが伝わります。ライブ表示の読み取り値には、font-variant-numeric: tabular-nums(または等幅フォント)を設定します。プロポーショナル数字はフレームごとに幅が変わり、周囲のレイアウトが揺れてしまいます。
  • 一度に表示するのは1つずつです。 独立した要素を2つ同時にアニメーションさせないでください。視線で両方を追うことはできません。1つを表示し、間を置いてから、次を表示します。
  • 冒頭は動き出す前の状態を保つ時間(約400ms)を設け、視聴者が動き出す前にラベルを読めるようにします。また、クリップの終了前に最終フレームを1秒以上保持します。

デスクトップのビューポートではなく、動画での読みやすさを優先します: 通常のウェブページよりもはるかに大きな文字を使います(小さく表示されたり、モバイルで再生されたり、ミュートで自動再生されたりします)。ゆったりとした間隔と高いコントラストを確保します。1600×900または1920×1080でレンダリングし、ファイルサイズに問題がなければdeviceScaleFactorを2にして、Retinaでも文字がくっきり表示されるようにします。

数値は実態と一致させます。 バーやカーブが実際の指標を示す場合は、元資料(ブログ、ベンチマーク)と一致させ、複数のバーを同じ条件で比較できるようにします(対象範囲や除外項目を揃えます。例:すべて接続時間を除外)。再構成したものや例示用のものにはラベルを付け、アニメーションがリアルタイムの測定値であるかのように示さないでください。

使用技術

  • シーン:実際のウェブページです。シーンごとに独自のルート(/race、/fanout、…)を持つ、小規模なNext.js(App Router)+React+TypeScriptアプリが最も手軽です。CSSとrequestAnimationFrameによるランプでアニメーションさせます。アニメーションライブラリは不要です。
  • 制御:ヘッドレスのChromiumをCDP経由で制御します。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 と計算すると、クロックが400msのときに go が切り替わった瞬間、進行度が 400/dur まで一気に跳び、バーが約⅓まで突然伸びて見えます。startRef(go が初めて true になったときのクロック値)を基準点にすれば解決します。同じ理由から、ランプが開始したときに再購読するのではなく、useClock は常に購読しておく必要があります。

これを使って、バーの幅、カウントアップ、フェーズの切り替えなど、時間ベースの状態をすべて動かしてください(setTimeout の代わりに clock >= 4800 を使います)。setTimeout/実際の rAF を使ったままの処理は、録画中に停止します。

ライブ(録画しない)モードでの注意点が1つあります。各 useClock インスタンスはそれぞれのマウント時刻を起点に独自の rAF ループを実行するため、異なる時刻にマウントされたコンポーネント内のフックは別々のタイムラインを参照し、後からマウントされたコンポーネントは0から開始します。シーン全体を最初にマウントする(ここで紹介するパターンはすべてそうなっています)か、シーンのルートで useClock() を1回呼び出し、その値を基にランプを導出してください。録画時はどちらの方法でも問題ありません。すべてのインスタンスが同じグローバルな window.__vt を参照するためです。

ヒント:ゆっくり動く要素にはイージングカーブ(power 2)を維持し、それに対して瞬時に見せたい要素は、短い線形の動き(power 1、約150ms)にしてください。

2. 決定論的に録画する(レコーダー)

依存関係のない、CDP を直接使った自己完結型の Node スクリプトです。

// 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 は、冒頭の静止時間+アニメーション+最終フレームの静止時間(1秒以上)です。

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 フレームから2パスでパレットを作成してください。グローバルパレットを使うとブランドカラーをきれいに保ち、ファイルサイズも小さくできます。

# 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未満に抑えられます。1パスの gif エンコーダーは使わないでください。フレームごとにディザリングされ、くすんだ見た目になります。

4. 繰り返し調整する(全編を録画せずに1フレームをプレビューする)

録画する前に、指定した仮想時刻のフレームを1枚プレビューして、見た目を確認してください。フラグを注入してページを読み込み、クロックを進め(window.__vt=<t>; window.dispatchEvent(new Event('vt')) — このディスパッチは重要です。useClock は vt イベントが発生したときだけ更新されます)、rAF を2回待ってから、captureScreenshot で PNG を取得し、表示します。コピー、タイミング、カーブを調整して、同じ手順を繰り返してください。静止画の見た目が決まってから60fpsで全編を録画します。調整のたびに数分を節約できます。

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"))。URL は localhost で返されます。同じ理由で、localhost の解決に失敗して cloudflared がクラッシュしないよう、--metrics 127.0.0.1:<port> を指定する必要があります。
  • ブラウザーのエンドポイントではなく、PAGE ターゲットに接続してください。 /json/version のソケットには Target/Browser ドメインしかなく、Page.*/Runtime.* は「見つかりませんでした」というエラーになります。/json/list から type === "page" のエントリーを取得してください。
  • 実際の setTimeout/rAF を使うものは、録画中にフリーズします。 __vt では進みません。すべてのアニメーション、フェーズの切り替え、カウンターをクロックフック経由にしてください。(実際のネットワーク通信やライブブラウザーの読み込みなど、真に非同期のコンテンツがあるシーンは、完全な決定論的動作にはできません。その場合はリアルタイムで録画し、ある程度のばらつきを許容してください。)
  • 読み込み前にステップ実行を始めると、最初のフレームでフォントがちらつきます。 navigate の後に document.fonts.ready を待機し、さらにバンドル用に実時間の sleep を入れてください。
  • pkill -f cloudflared/pkill -f "next start" は、独自のコマンドラインにも一致してシェルを終了させることがあります(終了コード 144)。 代わりにポートを指定して終了する(fuser -k 8088/tcp)か、正確な PID を使ってください。
  • クイックトンネルはアイドル状態の後に切断されます。 *.trycloudflare.com のホスト名が変わるか、エッジの登録が切断されます。リンクが 404/000 になる場合は、cloudflared を再起動して新しい URL を共有してください。ディスク上のファイルに問題はありません。
  • 実際のブラウザーセッションは後片付けしてください。 実際のクラウドブラウザー(例:Kernel のライブグリッド)を起動するシーンでは、請求が続かないよう、今回作成したものだけを対象にして、後から削除してください。

ガイドライン

  • クリップは短く(数秒から約 30 秒)してください。1 つのクリップにつきアイデアは 1 つにし、必要に応じてクリップをつなげてください。
  • デフォルトでは 60 fps、1600×900(または 1920×1080)が適しています。ファイルサイズが許容できる場合は、deviceScaleFactor を 2 にして、Retina ディスプレイで文字をくっきり表示してください。
  • タイミングは正確にしてください。数値や曲線が実際の指標を示す場合は、出典(ブログ、ベンチマーク)に合わせてください。再構成または例示の要素にはその旨を明記し、アニメーションを実測値のように見せないでください。
  • 本番の録画を行う前に、必ず静止画をプレビューしてください。
  • 共有する前に、PNG フレームだけでなく、エンコード済みの MP4 も検証してください。ffprobe -show_entries stream=nb_frames,duration out.mp4 の値が durationMs × fps と一致することを確認し、最初・中間・最後のフレームを抽出して表示してください(ffmpeg -ss <t> -i out.mp4 -frames:v 1 check.png)。これにより、フォントのちらつき、アセットの欠落、レンダリングの途中終了を検出できます。

コメント