はじめに
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 でコードだけで撮っている話
作った動画は「まどろみ研究所」で公開しています。よければ見てみてください。