跳到正文
zenn · 游戏开发社区· 来源· 超新星ボードゲーム開発部·· 14 小时前AI 评分60

超新星:无服务器将浏览器 canvas 录为 MP4 的 10 个踩坑点

サーバーなしで canvas を mp4 に。詰まった10の点

正文 · 原文

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

動画の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