超新星:无服务器将浏览器 canvas 录为 MP4 的 10 个踩坑点
サーバーなしで canvas を mp4 に。詰まった10の点
ブラウザゲームの演出を、サーバーを使わずに mp4 にして、スマホの共有メニューから X に載せられるようにしました。canvas に描いて MediaRecorder で録るだけですが、「最初のコマが真っ黒」「字体が勝手に変わる」「webm だと X に載らない」など、エラーの出ない落とし穴が10個ありました。この記事では、仕組みを短く説明したあと、その10個を直し方つきで並べ、最後に1枚のチェックリストにまとめます。

作っているのは、星が4段になると爆発して、まわりの星を自分の色に変えるボードゲーム「超新星」です。爆発が次の爆発を呼ぶ「連鎖」を、対局後に数秒の動画にできます。22連鎖(約7秒)で 1.1MB ほどです。
仕組み
1コマを描く関数は、時刻 t(秒)だけで絵が決まる関数 にしました。
/** 時刻 t(秒)の1枚を描く */
export function drawFrame(ctx: CanvasRenderingContext2D, c: Clip, t: number) { ... }
Clip は「手を指す前の盤」と「指したあとの盤」と盤の大きさだけです。爆発の順番はゲームのエンジンが出す結果に入っているので、t が決まれば、どのマスがどの姿かが決まります。
録る側は、これを requestAnimationFrame で呼び続けるだけです。
const canvas = Object.assign(document.createElement('canvas'), { width: 720, height: 900 });
const ctx = canvas.getContext('2d')!;
drawFrame(ctx, c, 0);
const stream = canvas.captureStream(30);
const rec = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: 4_000_000 });
const chunks: Blob[] = [];
rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
const stopped = new Promise<void>(ok => { rec.onstop = () => ok(); });
rec.start(250);
const t0 = performance.now();
await new Promise<void>(ok => {
const step = () => {
const t = (performance.now() - t0) / 1000;
drawFrame(ctx, c, Math.min(t, len));
if (t >= len + .1) ok(); else requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
rec.stop();
await stopped;
stream.getTracks().forEach(tr => tr.stop());
const blob = new Blob(chunks, { type: type.split(';')[0] });
ここからが、詰まった10の点です。
録る前
1. 画面の演出は、そのままでは録れない
盤は DOM の部品と CSS のアニメーションで動いています。ページの一部の DOM を動画にする API はありません。getDisplayMedia は画面共有の許可を求めるダイアログが出るので、「動画にする」ボタンには向きません。
直し方:同じ流れを canvas に描き直す。二重に描くことになるので、爆発の間隔(NOVA_STEP = 0.14秒)や演出の長さを出す関数は、画面の盤と同じものを読み込んで使います。画面の演出を直すと、動画も一緒に変わります。
2. 描く関数が「前のコマ」に頼ると、確かめられない
前のコマの状態を引き継いで描く作りだと、途中の1コマだけを見ることができません。
直し方:drawFrame(ctx, clip, t) を時刻だけの関数にする。drawFrame(ctx, clip, 1.3) のあと canvas.toDataURL() で、その瞬間の PNG がとれます。Playwright で何枚か書き出して目で確かめられ、宣伝用の動画も同じ関数で作れました。
3. フォントを読み込まないと、別の字体で描かれる
ctx.font に Web フォントの名前を書いても、そのフォントがまだ読み込まれていなければ、代わりの字体で描かれます。エラーは出ません。画面で使っていない字体や太さは読み込まれていません。
さらに、Google Fonts の日本語フォントは、字の範囲(unicode-range)ごとに細かく分けて配信されます。フォントの名前だけで読み込んでも、使う字が入っている分が読み込まれているとは限りません。
直し方:描く前に、使う字そのものを渡して読み込む。
await Promise.all(["64px 'Dela Gothic One'", "500 24px 'Zen Kaku Gothic New'", "700 24px 'Zen Kaku Gothic New'"]
.map(f => document.fonts.load(f, '超新星連鎖!ブラウザで無料遊べるボードゲーム0123456789' + extra)));
document.fonts.load() の2つ目の引数に渡した字を含む分だけが読み込まれます。中身が変わる文字列は extra として足しています。
録るとき
4. 書ける形式はブラウザごとに違う。webm は X に載らない
直し方:mp4 から順に MediaRecorder.isTypeSupported で試す。
function mimeType(): string | null {
if (typeof MediaRecorder === 'undefined' || typeof HTMLCanvasElement.prototype.captureStream !== 'function') return null;
return ['video/mp4;codecs=avc1.42E01E', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm']
.find(t => MediaRecorder.isTypeSupported(t)) ?? null;
}
webm になったときは「X などに載せられないときは、Chrome か Safari で作り直してください」と出します。どの形式も書けない端末では、ボタン自体を出しません。
5. 最初のコマが真っ黒になる
captureStream() を呼んだ時点で canvas に何も描かれていないと、動画の頭が空のコマになります。
直し方:captureStream() の前に drawFrame(ctx, c, 0) で1枚目を描いておく。
6. 録るのは実時間。コマ番号で進めると、動画の時間がずれる
MediaRecorder は、実際に流れた時間で録ります。7秒の動画には7秒かかります。「1回呼ぶごとに 1/30 秒進める」作りにすると、描画が遅れてコマが飛んだときに、演出がゆっくり再生されてしまいます。
直し方:t を performance.now() から出す(上のコード)。待つあいだは、ボタンに「動画を作っています 40%」のように進み具合を出します。
7. タブを裏に回すと、絵が止まる
裏に回したタブでは requestAnimationFrame が止まるので、その間は動画の絵も止まります。今の実装では対策していません。数秒で終わるので、作っている間は画面を見ていてもらう前提にしています。
録ったあと
8. 録り終わりを待たずに Blob を作ると、最後が欠ける
rec.stop() を呼んだ直後は、最後の dataavailable がまだ届いていません。
直し方:onstop を Promise にして待ってから Blob を作る。そのあと stream.getTracks().forEach(t => t.stop()) で映像の流れも止めます。Blob の種類は video/mp4;codecs=... から codecs を外した video/mp4 にして、ファイル名の拡張子と合わせています。
9. 共有できるかは、ファイルを渡して確かめる
navigator.share があっても、ファイルを共有できるとは限りません。
直し方:navigator.canShare({ files: [file] }) で確かめ、だめならダウンロードのリンクにする。
const file = new File([blob], `choushinsei-${chain}chain.mp4`, { type: blob.type });
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], text }); // スマホなら、X や LINE を選べる
} else {
a.href = URL.createObjectURL(blob); a.download = file.name; // パソコンなど
}
10. 見せる <video> が自動で再生されない
自動再生は、消音でないと止められます。JavaScript で後から setAttribute('muted', '') しても、video.muted プロパティは false のままです(属性は初期値の defaultMuted を表すだけ)。
直し方:muted と playsinline を属性で付け、さらに video.muted = true も入れる。閉じたら URL.revokeObjectURL() で後始末をします。
チェックリスト
| # | 確かめること | 直し方 |
|---|---|---|
| 1 | DOM の演出を録ろうとしていないか | canvas に描き直す。時間の定数は画面と共有 |
| 2 | 好きな時刻の1コマを描けるか |
drawFrame(ctx, clip, t) を時刻だけの関数に |
| 3 | 動画にだけ出る字体・字を読み込んだか | document.fonts.load(font, 使う字) |
| 4 | mp4 を先に試しているか |
isTypeSupported を mp4 → webm の順に。webm なら注意を出す |
| 5 | 最初のコマが空でないか |
captureStream の前に1枚描く |
| 6 | 時間を実時間で進めているか |
performance.now() から t を出す。進み具合を出す |
| 7 | タブを裏に回したときどうなるか | 止まることを前提にするか、対策する |
| 8 | 録り終わりを待ったか |
onstop を待ってから Blob。トラックを止める |
| 9 | ファイルを共有できるか確かめたか |
canShare({ files })、だめならダウンロード |
| 10 | プレビューが自動再生されるか |
muted を属性とプロパティの両方で |
もう一つ、技術とは別に決めたことがあります。動画に入れるのは、盤と連鎖の数とサイトの住所だけで、プレーヤーの名前や対局の番号は入れていません。オンライン対戦の相手の名前が、知らないうちに SNS に載ってしまうのを避けるためです。
まとめ
- DOM の演出は録れないので、canvas に描き直して
captureStream+MediaRecorderで録る - 1コマを描く関数を「時刻
tの関数」にすると、画面と時間をそろえられ、確かめるのも楽 - mp4 を優先し、最初のコマ・フォント・後始末・共有の可否を確かめる
ほかのやり方(WebCodecs で1コマずつ書き出す、など)を試した方がいたら、コメントで教えてください。前回は「メールアドレスを受け取らない Google ログイン」について書きました。
ゲームはブラウザで基本無料で、連鎖を作ると結果の画面から動画にできます。
来源:zenn · 游戏开发社区 · zenn.dev