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。
全体の流れ
-
reel.html:canvas に「t 秒の絵」を描くページ -
render.mjs:Chrome に t = 0, 1/30, 2/30 … と順に描かせて PNG で保存 - 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)を活用して作成し、記事中のコードは最小版を実際に実行して確認しています。