ALL NOTES

Remotionを使わずに動画を自動生成する:Next.js+Playwright+ffmpeg

Markdownの脚本からmp4を自動生成する仕組みを、Remotionを使わずにNext.js・Playwright・ffmpegで組みました。画面をframe番号の関数にする設計、決定性の3つの規則、同じ脚本から撮るとmp4がバイト単位で一致する理由まで、実際のコードで解説します。

はじめに

YouTube で「まどろみ研究所」という解説チャンネルをやっています。
動画は、Markdown で書いた脚本から自前の仕組みで自動生成しています。これまでに100本以上を作りました。

たとえば、この回です。図・テロップ・字幕・BGM・ナレーションまで、すべて脚本1枚から出ています。

🎥 YouTube

コードから動画を作るなら、まず名前が挙がるのは Remotion です。
ただ、今回は Remotion を使わず、Next.js・Playwright・ffmpeg の組み合わせで同じことを自前で組みました。

この記事では、その仕組みを順に見ていきます。

  • 脚本の Markdown から mp4 ができるまでの全体の流れ
  • 画面を「frame 番号の関数」にする、という中心の考え方
  • 撮るたびに同じ動画が出るようにする、決定性の3つの規則
  • Playwright で1コマずつ撮って、ffmpeg に流す部分
  • BGM もコードで合成して、場面に割り当てる部分
  • Remotion を使わないことで失ったもの・得たもの

1. 全体の流れ

先に全体を見ておきます。人間が書くのは script.md だけです。

script.md(脚本)
  │  gray-matter + remark で読む → zod で形を確かめる
  ▼
video.json(中間表現:シーン・尺・図の種類)
  │
  ▼
Next.js の撮影用ページ /render/<id>
  │  Playwright で1回だけ開き、frame を 0, 1, 2 … と進める
  ▼
1コマずつスクリーンショット(PNG)
  │  ディスクに書かず、ffmpeg の標準入力へ流す
  ▼
ffmpeg(+ 合成した audio.wav)
  ▼
video.mp4

使っている技術はこれだけです。

役割 使っているもの
画面を作る Next.js 16 / React 19
撮る Playwright(Chromium)
動画にする ffmpeg(ffmpeg-static)
脚本を読む gray-matter / remark(gfm)
中間表現を確かめる zod
文字 Noto Sans JP(npm で同梱)
BGM 自前で合成(後述)

ポイントは、Next.js を配信のために使っていないことです。
図やテロップを React のコンポーネントとして書き、それをブラウザで表示して撮る。Next.js は「撮影対象を出すためのサーバ」でしかなく、成果物は mp4 だけです。
なので、普段ブログで使う静的エクスポート(output: "export")にもしていません。


2. 脚本は Markdown の表で書く

脚本は、シーンごとに見出しと表を並べた Markdown です。さきほどの車輪の回の冒頭はこうなっています。

---
id: 020-wheel
theme: dark
subtitles: true
bgm: auto
fps: 30
size: 1920x1080
---

### 1. レールの上を走っている

| 項目 | 内容 |
| --- | --- |
| **種別** | `wheel` |
| **位相** | `run` |
| **尺** | `4.4s` |
| **テロップ** | 電車は / レールの上を走っている |
| **台詞** | 電車は、ただレールの上に乗っているだけです |
| **スクショ** | `3.7s` |

種別 がどの図を出すか、位相 がその図のどの場面か、尺 がシーンの長さです。
スクショ は、そのシーンを確認用の静止画として残すタイミングです。人間は動画を全部見る代わりに、このスクショを並べて確かめます。

これを読んで video.json という中間表現にします。形は zod で決めています(概略)。

const Scene = z.object({
  name: z.string().min(1),          // 見出し。チャプター名にもなる
  kind: z.string(),                 // 図の種別
  durationInFrames: z.number().int().positive(),
  startFrame: z.number().int().nonnegative(),
  props: z.record(z.unknown()),
  shots: z.array(z.number().int().nonnegative()), // スクショを撮るフレーム
});

尺は秒で書き、フレームに直すのは読み取る側の仕事です。30fps なら 4.4s は 132 フレーム。割り切れない値は、動画を作る前に落とします。

この「作る前に落とす」は、尺の合計にも効かせています。シーンの合計が脚本に書いた尺の目安から外れていたら、撮影を始める前にエラーにします。

Error: episodes/001-first/script.md: 尺の目安は 5秒 ですが、シーンの合計は 7秒 です(許容は ±1秒)

3. 画面を「frame 番号の関数」にする

新しく覚える考え方は1つだけです。画面は、frame 番号を入れたら見た目が決まる関数にする。

Remotion には useCurrentFrame() という、いまのフレーム番号を返すフックがあります。
これと同じことを、ただの React の state でやります(概略)。

const [frame, setFrame] = useState(0);

useEffect(() => {
  // Playwright から呼ぶ入口
  window.__seek = async (next: number) => {
    setFrame(next);
    await settled();          // 描画が落ち着くまで待つ(5章)
  };
  window.__ready = true;      // Playwright はこれを待ってから撮り始める
}, []);

const scene = sceneAt(spec, frame);       // frame からシーンを引く
const local = frame - scene.startFrame;   // シーンの中での相対フレーム

return <SceneView {...scene.props} frame={local} fps={spec.fps} />;

シーン側のコンポーネントは、受け取った frame から見た目を計算します。時刻は一切見ません。

export function Title({ text, frame, fps }: TitleProps) {
  // 最初の0.5秒で、透明から不透明へ
  const opacity = interpolate(frame, [0, fps * 0.5], [0, 1]);
  return <h1 style={{ opacity }}>{text}</h1>;
}

interpolate は、Remotion にある同名の関数を自作したものです。30行ほどで足ります。

export function interpolate(
  input: number,
  inputRange: readonly [number, number],
  outputRange: readonly [number, number],
  { clamp = true, easing = (t: number) => t } = {},
): number {
  const [inputStart, inputEnd] = inputRange;
  const [outputStart, outputEnd] = outputRange;
  if (inputEnd === inputStart) return outputStart;

  let progress = (input - inputStart) / (inputEnd - inputStart);
  if (clamp) progress = Math.min(1, Math.max(0, progress));

  return outputStart + (outputEnd - outputStart) * easing(progress);
}

フェード、スライド、棒グラフの伸び、数字のカウントアップ。動きはすべてこの関数を通して、frame 番号から計算しています。


4. 決定性の3つの規則

この仕組みでいちばん大事にしているのは、同じ脚本から撮ったら、いつ撮っても同じ動画が出ることです。
そのために、次の3つを守っています。破ると、撮るたびに少しずつ違う動画が出ます。

# 規則 破るとどうなるか
1 CSS の animation と transition を使わない 実際の時間で動くので、スクショのタイミング次第で写り方が変わる
2 Date.now() と Math.random() を使わない 毎回違う絵になる
3 フォントを読み込み終えてから撮る 最初の数フレームだけ、別のフォントで写る

規則1:CSS の時間を全部止める

規則1は、気をつけるのではなく機械的に潰しています。撮影用のページにだけ、この CSS を当てます。

*,
*::before,
*::after {
  animation: none !important;
  transition: none !important;
}

これは「アニメーションを諦める」という意味ではありません。
動きは全部 frame 番号から計算する、という宣言です。CSS に時間を持たせないことで、時間の出どころが frame 番号の1つだけになります。

規則3:フォントは同梱して、使う文字を先に読ませる

フォントはシステムのものを使わず、npm のパッケージ(@fontsource-variable/noto-sans-jp)として同梱しています。
外から取りに行ったり、マシンに入っているものを使ったりすると、環境によって結果が変わるからです。

ただ、ここに1つ落とし穴がありました。
Noto Sans JP は、文字の範囲(unicode-range)ごとに数百のファイルに分かれています。document.fonts.ready は「まだ要求されていないファイル」を待ってくれないので、そのまま撮ると、途中で初めて出てくる文字だけ別のフォントで写ります。

そこで、撮り始める前に、動画に出てくる文字を全部集めて読ませておきます。

const WEIGHTS = [500, 600, 700, 800];

async function warmFonts(text: string): Promise<void> {
  await Promise.all(
    WEIGHTS.map((weight) =>
      document.fonts.load(`${weight} 100px "Noto Sans JP Variable"`, text),
    ),
  );
  await document.fonts.ready;
}

text には、全シーンのテロップ・字幕・図の中の文字をつなげたものを渡します。画像も同じ理由で、撮り始める前に全部読み込んでおきます。


5. seek のあとの「落ち着き待ち」

Remotion を使わないことで、自分で解くことになった難しいところは、実質ここ1つでした。

setFrame(n) を呼んでから、画面に反映されるまでには、React のコミット、レイアウト、描画が挟まります。
待たずにスクショを撮ると、1フレーム前の絵が写ります。

いまの実装はこうです。

// コミットの直後に走る。ここで、待っている __seek を解放する
useLayoutEffect(() => {
  frameRef.current = frame;
  pendingSeek.current?.();
  pendingSeek.current = null;
}, [frame]);

window.__seek = async (next: number) => {
  // 同じ値だと React が再描画を省くので、コミット待ちに入らない
  if (next !== frameRef.current) {
    await new Promise<void>((resolve) => {
      pendingSeek.current = resolve;
      setFrame(next);
    });
  }
  await afterPaint();          // 描画を1回待つ
  await document.fonts.ready;
  await decodeImages();
};

// 1回目の requestAnimationFrame は描画の前に走るので、2回待つ
function afterPaint(): Promise<void> {
  return new Promise((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
  });
}

useLayoutEffect で「コミットが終わった」ことを受け取り、requestAnimationFrame を2回挟んで「描画が終わった」ことを待ちます。
これで、撮るたびに同じ絵が写るようになりました。


6. Playwright で撮って、ffmpeg に流す

撮影するスクリプトの中心は、このループです(概略)。

const browser = await chromium.launch();
const page = await browser.newPage({
  viewport: { width: spec.width, height: spec.height },
  deviceScaleFactor: 1,
});
await page.goto(url, { waitUntil: "load" });
await page.waitForFunction("window.__ready === true");

for (let frame = 0; frame < spec.totalFrames; frame += 1) {
  await page.evaluate((n) => window.__seek(n), frame);
  const png = await page.screenshot();

  framesHash.update(png);                               // 決定性の確認用(7章)
  if (!ffmpeg.stdin.write(png)) await once(ffmpeg.stdin, "drain");
}
ffmpeg.stdin.end();

大事なのは、ページを開くのは1回だけというところです。
フレームごとにページを開き直すと遅いうえ、そのたびにフォントの読み込みが走って、規則3に引っかかります。

フレームはディスクに書かない

最初は、PNG を1枚ずつファイルに書き出してから、ffmpeg で連番の画像を動画にしていました。
ところが、動画5本ぶんで中間の PNG が 1.4GB になりました。できあがった mp4 は合わせて 6.3MB です。

そこで、スクリーンショットの戻り値(PNG のバイト列)を、そのまま ffmpeg の標準入力に流す形に変えました。

const ffmpeg = spawn(ffmpegPath, [
  "-y",
  "-f", "image2pipe",           // 連結した PNG を標準入力から読む
  "-framerate", String(spec.fps),
  "-i", "-",
  ...(hasAudio ? ["-i", audioPath] : []),
  "-c:v", "libx264",
  "-pix_fmt", "yuv420p",        // これが無いと再生できないプレイヤーがある
  "-crf", "18",
  ...(hasAudio ? ["-c:a", "aac", "-b:a", "192k", "-shortest"] : []),
  videoPath,
], { stdio: ["pipe", "inherit", "inherit"] });

同じ PNG のバイト列をエンコーダに渡しているので、画質は1ビットも変わりません。実際、変更の前後で mp4 のバイト列まで一致しました。
作業用のフォルダは 1.4GB から 17MB になりました。

ffmpeg は npm で入れる

ffmpeg は、マシンに入っているものではなく、npm の ffmpeg-static で入れています。
バージョンが固定されるので、マシンが変わっても同じ ffmpeg で動画になります。これも決定性のためです。

なお、pnpm v10 以降は、パッケージのインストール時のスクリプトを既定で止めます。ffmpeg-static はインストール時にバイナリを取りに行くので、pnpm-workspace.yaml で許可が要りました。

allowBuilds:
  ffmpeg-static: true

7. 同じ脚本から撮ると、mp4 がバイト単位で一致する

決定性が守れているかは、同じ脚本から2回撮って、mp4 の sha256 を比べるだけで確かめられました。

撮影のたびに、流れてきたフレームと、できあがった mp4 のハッシュを出しています。

frames sha256: (64桁のハッシュ)
mp4    sha256: (64桁のハッシュ)

最初は「mp4 の中に作成日時が入って、毎回ハッシュが変わるかもしれない」と考えていました。
実際に試すと、同じ脚本から3回撮って、mp4 のハッシュまで完全に一致しました。フレーム画像を1枚ずつ比べる必要もありませんでした。
逆に、脚本のテロップを1文字変えると、ハッシュも変わります。

もう1つ、撮影は next dev ではなく、本番のビルド(next build && next start)で行います。
開発サーバは React の StrictMode で二重に描画したり、ホットリロードが割り込んだりするからです。


8. 撮る前に検査する

撮影には、実際の動画の長さより時間がかかります。68秒の動画(2,040フレーム)で3分6秒、実際の長さの約2.7倍でした。
そこで、撮ってから気づくのではなく、撮る前に落とすようにしています。

検査 何を見るか
尺 シーンの合計が、脚本の尺の目安から外れていないか(2章)
文字の重なり スクショを撮るフレームで、テロップや図の文字どうしが重なっていないか
はみ出し 図が、文字を置いてよい範囲からはみ出していないか

はみ出しの検査は、スクショのフレームだけでは足りませんでした。
弾ける・跳ねる・輪が広がるといった動きは一瞬なので、スクショとスクショの間に来ると素通りします。実際、検査を通ったのに、広がる輪がテロップを突き抜けていた回がありました。
いまは、全体を 3フレーム(0.1秒)ごとに刻んで見ています。


9. BGM もコードで作る

BGM も、外部のサービスを使わずに自前で合成しています。
「秒を入れたら音の値が返る式」で作るので、frame 番号と同じく、何度作っても同じ音になります。料金も、規約の確認も要りません。

曲を書かない。ループを1つ作って並べる

最初は4小節のコード進行を書きましたが、うまくいきませんでした。

版 したこと 結果
1 4小節のコード進行を書いた 不安になる音になった(増三和音が混ざっていた)
2 和声を直した 不安ではなくなったが、ダサい
3 3秒のループ1つだけにした 急かされる感じがする
4 同じ形のまま8秒に伸ばし、音の立ち上がりを遅くした 採用

コードで和声を書くということは、和声のミスがそのまま音になるということです。しかも、絵と違って画面には出ません。
展開させようとせず、短いループを並べるほうが、外す余地そのものが小さくなりました。

指摘を「制約」として固定する

その後、「1曲だけだと聴き続けるのが辛い」という指摘を受けて20曲に増やしました。
このとき、それまでの作り直しの理由を、すべての曲が守る制約として固定しました。

守るもの 出どころ
増三和音を作らない 1版目が不安になる音だった
音の立ち上がりを速くしない 打楽器のように聞こえると、急かされる
音を詰めない 3版目が急かされる音だった
音量を上げない(最大 −27〜−25dBFS) ナレーションの下で、意外と邪魔だった

耳で聴く前に、機械で測る

書き出す前に、ピーク・音量・ループの継ぎ目・拍の整合を表で出しています。
この検査が実際に欠陥を見つけました。ある曲の継ぎ目の段差が、普段の5.5倍ありました。音の余韻を途中で急に0にしていたのが原因で、9秒に1回「プツッ」と鳴っていたはずです。耳で気づける保証がない欠陥でした。

場面に合わせて自動で割り当てる

脚本に bgm: auto と書くと、シーンの種別から曲を自動で選びます。

シーン 選ぶ曲の系統
問いかけ 「問」の曲
図で説明する 図の動きの量で決める(ほぼ静止なら静かな曲、3.5秒以上動くなら拍のある曲)
話をひっくり返す 音を減らす
まとめ 「結」の曲

張り詰めさせたい場面は、音を足すのではなく、減らして作ります。足して作ると、1版目と同じ失敗をするからです。


10. Remotion を使わないことで失ったもの・得たもの

最後に、正直に比べておきます。

失ったもの どうしたか
実績のある seek と落ち着き待ち 自分で解いた(5章)。実質ここが唯一の難所だった
スクラブできるプレビュー画面 要らなかった。人間はスクショを並べて見る
interpolate やイージング 自作した。30行ほど
クラウドでの並列レンダリング 今のところ要らない。遅ければフレームを分けて並べればよい
得たもの 中身
新しく覚えるものが無い Next.js・React・zod・remark と、普段ブログで使っている道具だけで済んだ
ライセンスを気にしなくてよい Remotion は個人や小さな組織は無料だが、規模によっては有償になる
部品ごとに差し替えられる 脚本を読む・声と BGM を作る・撮る、がそれぞれ別の部品になっている

いちばん効いたのは、最後の「部品ごとに差し替えられる」でした。
実際、この後に3Dの回を作るときは、撮る部分を Godot に替えました。声と BGM は、ここで作ったものをそのまま使っています。


おわりに

Remotion がやっていることは、分解すると「画面を frame 番号の関数にする」「1コマずつ撮る」「動画にまとめる」の3つでした。
React と Playwright と ffmpeg があれば、それぞれは自分で組めます。自分で組むと、同じ入力から同じ動画が出ることを、自分の手で保証できるのが気持ちいいところです。

この仕組みのうち、次の部分は別の記事で書いていきます。

  • ナレーションを、自分の声を学習させた音声合成に替えた話(Style-Bert-VITS2)
  • 脚本を書くところから撮影までを、Claude Code に任せている話
  • 3Dの回を、Godot でコードだけで撮っている話

作った動画は「まどろみ研究所」で公開しています。よければ見てみてください。