0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Puppeteerなしで縦動画を作る:canvas+Chrome DevTools Protocol+ffmpeg(Node.js 標準機能だけ)

0
Posted at

Instagram のリール用に、図が動く縦動画(1080×1920)をたくさん作る必要がありました。手作業では追いつかないので、台本を JSON で書く → ブラウザの canvas で1コマずつ描く → ffmpeg で MP4 にする、という仕組みを作りました。

ライブラリは入れていません。Node.js に入っている fetch と WebSocket(Node.js 22 以降はグローバルで使えます)だけで、ヘッドレスの Chrome を DevTools Protocol(CDP)で直接動かしています。この記事はその仕組みのメモです。

確認した環境:macOS、Node.js 24.20.0、Google Chrome(--headless=new)、ffmpeg 7.1。

全体の流れ

  1. reel.html:canvas に「t 秒の絵」を描くページ
  2. render.mjs:Chrome に t = 0, 1/30, 2/30 … と順に描かせて PNG で保存
  3. ffmpeg:連番 PNG を MP4 にまとめる

ポイントは、ページ側では時間を進めないことです。CSS アニメーションや requestAnimationFrame に任せると、PC の速さでコマの中身が変わってしまいます。そこで「t を渡すと、その瞬間の絵が必ず同じに描かれる」関数だけを用意し、時間は Node 側が決めます。

1. ページ側:t だけで絵が決まる関数

実際の reel.html は 4,000 行以上ありますが、外から呼ぶのは次の3つだけです。

/*
 *   window.loadScript(json) : 台本を読み込み、フォントを待つ(Promise)
 *   window.renderAt(t)      : t 秒の絵を描く(CSSアニメーションなし。t だけで決まる)
 *   window.frameData()      : 今の絵を PNG の dataURL で返す
 */

動きは、次の小さな関数の組み合わせで作っています(実際のコードから抜き出し)。

const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
const seg = (t, a, b) => clamp((t - a) / (b - a));   // a〜b 秒の進み具合(0〜1)
const easeOut = u => 1 - Math.pow(1 - u, 3);

たとえば「0.5 秒から 2 秒にかけて棒が伸びる」は、次のように書けます。

const u = easeOut(seg(t, S.bar.t0, S.bar.t1));
ctx.fillRect(140, 800, 800 * u, 80);

t が同じなら u も同じなので、何回描いても同じ絵になります。

loadScript では、Web フォントの読み込み(document.fonts.load)が終わるまで待ってから描き始めます。

台本(JSON)はこんな形です(実際の台本の一部)。

{
  "name": "01_binary_search",
  "duration": 24,
  "fps": 30,
  "title": "1,000件から1件を探す\n{c:何回で見つかる?}",
  "scenes": [
    { "start": 0, "end": 8, "chip": "① 前から順番に探す",
      "subtitles": [
        { "t0": 0, "t1": 1.8, "text": "書類が {c:1,000枚}\nこの中から1枚を探す" }
      ] }
  ]
}

24 秒 × 30fps なので、この台本は 720 コマになります。

2. Node 側:Chrome を起動して CDP につなぐ

空いているポートを探し、そのポートで DevTools を開いた Chrome を起動します。

function freePort() {
  return new Promise((res, rej) => {
    const s = net.createServer();
    s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
    s.on('error', rej);
  });
}

chrome = spawn(CHROME, [
  '--headless=new', `--user-data-dir=${profile}`, `--remote-debugging-port=${port}`,
  '--no-first-run', '--hide-scrollbars', '--mute-audio',
  '--allow-file-access-from-files', 'about:blank'
], { stdio: 'ignore' });

起動直後はまだつながらないので、http://127.0.0.1:<port>/json/list を 150ms おきに取りに行き、type が page のものが返ってきたら、その webSocketDebuggerUrl に接続します。

CDP は「id 付きの JSON を送ると、同じ id で返事が来る」仕組みなので、id ごとに Promise を覚えておけば十分です。

function cdp(wsUrl) {
  const ws = new WebSocket(wsUrl); let id = 0; const pend = new Map(); const listeners = [];
  ws.onmessage = ev => {
    const m = JSON.parse(ev.data);
    if (m.id && pend.has(m.id)) {
      const { res, rej } = pend.get(m.id); pend.delete(m.id);
      m.error ? rej(new Error(m.error.message)) : res(m.result);
    } else if (m.method) listeners.forEach(f => f(m));   // Page.loadEventFired など
  };
  const opened = new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
  return {
    opened,
    send: (method, params = {}) => new Promise((res, rej) => {
      const i = ++id; pend.set(i, { res, rej });
      ws.send(JSON.stringify({ id: i, method, params }));
    }),
    on: f => listeners.push(f),
  };
}

3. 1コマずつ描いて PNG にする

ページの関数は Runtime.evaluate で呼びます。awaitPromise: true にすると loadScript の Promise も待てて、returnByValue: true で dataURL の文字列をそのまま受け取れます。

async function evaluate(c, expr) {
  const r = await c.send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
  if (r.exceptionDetails) throw new Error('page error: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
  return r.result.value;
}
const b64png = d => Buffer.from(d.replace(/^data:image\/png;base64,/, ''), 'base64');

await evaluate(c, `window.loadScript(${JSON.stringify(JSON.stringify(script))})`);
for (let i = 0; i < frames; i++) {
  const d = await evaluate(c, `(window.renderAt(${i / fps}), window.frameData())`);
  fs.writeFileSync(path.join(fdir, String(i).padStart(5, '0') + '.png'), b64png(d));
}

JSON.stringify を2回かけているのは、台本を「JS の文字列リテラル」としてページに渡すためです。1回だと、JS のコードとして埋め込まれてしまいます。

スクリーンショット(Page.captureScreenshot)ではなく canvas.toDataURL() なので、ウィンドウの大きさに関係なく 1080×1920 の PNG が取れます。

4. ffmpeg で MP4 にする

await run(FFMPEG, ['-y', '-framerate', String(fps), '-i', path.join(fdir, '%05d.png'),
  '-c:v', 'libx264', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-crf', '18',
  '-r', String(fps), '-an', '-movflags', '+faststart', outMp4]);
  • -pix_fmt yuv420p:PNG から作ると色の形式が変わり、再生できない環境があるのでそろえます。
  • -an は音なし、-movflags +faststart はネットで再生を始めやすくする指定です。

5. 確認用のコンタクトシート

できた MP4 から1秒ごとのコマを取り出して一覧画像にし、全体を1枚で確認しています。

ffmpeg -y -i out.mp4 -vf "select='not(mod(n\,30))'" -fps_mode vfr sheet/%03d.png

実際のスクリプトでは -vsync vfr と書いていましたが、ffmpeg 7.1 では「-vsync is deprecated. Use -fps_mode」と表示されました。新しく書くなら -fps_mode vfr のほうがよさそうです。

動くか確かめた最小版

記事用に、棒が伸びて字幕が切り替わるだけの 3 秒の台本で、上のコードを1つにまとめた最小版を作って実行しました。

$ node render.mjs script.json
90 コマ → demo.mp4

$ ffmpeg -i demo.mp4
  Duration: 00:00:03.00
  Stream #0:0: Video: h264 (High), yuv420p, 1080x1920, 30 fps

同じ台本で2回描き、90 枚の PNG のハッシュがすべて一致することも確かめました。

つまずいたところ

  • Chrome が残る:エラーで止まっても残らないよう、process.on('exit') と SIGINT で kill しています。
  • プロフィールの場所:普段使いの Chrome と混ざらないよう、--user-data-dir に作業用のフォルダを渡しています。
  • 速さ:1コマごとに PNG を base64 でやりとりするので速くはありません。最小版(90 コマ)は手元で約15秒でした。そのぶん、ずれのない動画になります。

まとめ

  • ページ側は renderAt(t) で「t 秒の絵」を描くだけにして、時間は Node 側で進める
  • CDP は id 付き JSON のやりとりなので、Node 標準の WebSocket で十分
  • toDataURL() で取った PNG を ffmpeg で yuv420p の MP4 にする

作ったツール類は https://tools.equaglobal.com にまとめています。


※この記事は、AI(Claude)を活用して作成し、記事中のコードは最小版を実際に実行して確認しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?