blog

画面に映らない文字が録画を壊した、10秒ゲーム集36本の舞台裏

10秒だけ!は、スマホのブラウザで遊べるミニゲーム集です。どのゲームも 10 秒で終わり、遊んでいる様子は自動で MP4 に録画されます。終わったら、その動画をそのまま SNS に投稿できます。

実装は Claude Code に任せ、私は仕様書を書き、手元の Android の端末で遊んで、面白いかどうかと不具合を判断しました。この記事では、最初の仕様書から 36 本を出すまでの経緯と、録画、音、物理、配信で踏んだ罠を順に書きます。録画の部品(WebCodecs、Mediabunny、MediaRecorder)そのものの比較はブラウザで MP4 を書き出す方法をまとめた記事に、音のしくみの横断的な話はブラウザの音の記事に分けました。

最初の仕様書と、そこから直した 7 か所

このプロジェクトは、私が書いた仕様書だけがある状態から始まりました。2026 年 8 月 10 日に、Claude Code に「仕様書を読み込んでチェックして、実装へのプランを立てて」と頼んだのが最初です。

仕様書の目的は今と変わりません。プレイ中の 10 秒とスコア表示の数秒を自動で MP4 に録画し、SNS で共有してもらう。その動画が拡散されて、勝手に遊ぶ人が増える状態を目指す、というものです。

ただ、手段の書き方にはいくつか無理がありました。

  • スコアの文字やスタートボタン、シェアボタンまで、HTML ではなくすべて Three.js の 3D 空間に描く
  • MP4 は WebCodecs と mp4-muxer で作る
  • Cloudflare Pages に GitHub 連携で自動デプロイする
  • 音については何も書いていない

最初のチェックで、Claude Code はこれらを含めて 7 か所の修正を提案し、判断を求められた点はどれも提案どおりにしました。

  1. mp4-muxer は作者が開発終了を告知していたので、後継の Mediabunny に替える
  2. スタートボタンは録画の前、シェアボタンは録画の後にしか出ないので、Three.js で描いても動画には映らない。HTML で作る
  3. 録画の長さの記述が食い違っている(「10 秒と数秒」と書きながら、カウントダウンから録る)
  4. WebCodecs が使えない端末への代替が無いので、MediaRecorder の経路を足す
  5. 音の方針が無い。iOS で AudioEncoder が使えるのは Safari 26 からなので、最初は音なしの MP4 にする
  6. エンコードが追いつかないときの扱いと、フレーム間隔が揺れることへの考慮が無い
  7. 動画を見た人がサイトにたどり着く導線が無いので、URL を動画に焼き込む

2 番目の指摘は、仕様の目的から見ても筋が通っています。録画されるのは Canvas の中身だけなので、映らないものを Canvas に描く理由がありません。HTML にしておけば、タップの判定も日本語の表示も読み上げも、ブラウザに任せられます。

デプロイも、その日のうちに GitHub 連携をやめて、手元から wrangler で直接上げる形に変えました。配信まわりの構成は、ほかのサービスと共通なのでCloudflare Pages の記事にまとめています。

45 本を企画して、36 本を出した

最初のゲームは、数字を順番にタップしていくだけの「ナンバーラッシュ」でした。その後、私が 24 本ぶんのアイデアをテキストファイルに書き、Claude Code に渡しました。24 本を一度に作るのは無理なので、ゲームごとの仕様書と、別のチャットに移っても続きから進められる進捗表(ROADMAP)を作ってもらいました。

24 本を作り終えたあと、それまでのボツの理由を踏まえて、さらに 20 本の案を出してもらっています。合わせて 45 枠のうち、作ったのが 36 本、ボツにしたのが 8 本、仕様だけで止めたのが 1 本です。

ボツにしたゲームには、どれも理由を書き残しています。

ゲームボツにした理由
02 ドミノ簡単すぎてゲーム性がない
10 カオス・テトリス3 通り設計しても、プレイヤーの操作が結果を動かさなかった
19 マグネットオンとオフしか操作が無く、初期配置で結果が決まってしまう

19 番は、数字の上では成立していました。描画なしで自動プレイを回すと、放置した場合は 10.0 点、見ながら切り替えた場合は 16.6 点で、操作の効果ははっきり出ていたのです。それでも実際に遊ぶと面白くありませんでした。「数字が動いても、遊びとして薄いことがある」というのが、このときの学びです。

自動プレイで測ってから作るやり方そのものは、その後も育てていきました。「何もしない」「でたらめに連打する」「狙って操作する」の 3 通りを描画なしで回し、スコアに差が出なければ、そのゲームは操作が効いていません。後半の 20 本では、描画を書く前に物理だけを試作して、この差を測ってから進めています。

途中で、私から方針も足しました。09 インベーダーを触ったとき、弾が 2 発までしか出ないせいで動画が地味だったので、「敵がいるなら多めにし、タップや操作を制限しない」を仕様書に書き加えてもらいました。指を動かしているのに何も起きない動画は、共有されないからです。

年表

日付できごと
8 月 10 日仕様書のチェック。ナンバーラッシュと、録画と共有を同じ日に実装
8 月 12 日この日までに最初の本番デプロイ(git の最初のコミットより前)。デザインを刷新
8 月 13 日透視投影のカメラと物理(cannon-es)を入れる。動画の先頭が黒い不具合を直す
8 月 19 日録画が途中で壊れる不具合を直す。SNS 向けに各ページを事前に HTML 化
8 月 20 日広告を入れる。iframe をやめてページ本体に貼り直す
8 月 27 日後半 20 本の案を出す
9 月 9 日公開の支度(サムネイル、OG 画像、pages.dev からの転送)
9 月 15 日効果音と、録画への音の取り込み
9 月 18 日36 本目(31 レール・スイッチ)

録画と共有は、最初の日から入っています。音が入ったのは、公開から 1 か月近く経った 9 月 15 日です。

録画のしくみ

録画は、使えるほうを選ぶ二段構えです。本命は WebCodecs で、ゲームの Canvas を 1 フレームずつ H.264 にして、Mediabunny で MP4 に詰めます。WebCodecs が使えない端末では、MediaRecorder で Canvas を録ります。

export async function createRecorder(canvas, { audio = null } = {}) {
  const size = sizeForCanvas(canvas);

  if (await webCodecsSupport(size.width, size.height)) {
    return new WebCodecsRecorder(canvas, size, { audio });
  }

  if (MediaRecorderFallback.isSupported()) {
    return new MediaRecorderFallback(canvas, { audio });
  }

  return null;
}

どちらも使えなければ null を返し、録画せずにゲームだけ遊べるようにしています。録画の大きさは長い辺 1280px までに縮め、縦横とも偶数にそろえます(H.264 の制約です)。

フレームの取り込みは、ゲームを描いた直後に、同じ requestAnimationFrame の中で行います。

      this.stage.render(dt);
      // 取り込みは必ず描画のあと。しかも同じ rAF コールバックの中で行う
      // (WebGL の描画バッファはこのタスクを抜けると失われるため)。
      this._captureFrame();

WebGL の Canvas は、画面に出したあと次の描画のために中身を消してしまいます1。別のタイミングで読むと、黒いフレームが撮れることがあります。

Mediabunny の側は CanvasSource に任せています。

    this.source = new CanvasSource(this.canvas, {
      codec: 'avc',
      bitrate: BITRATE,
      keyFrameInterval: KEY_FRAME_INTERVAL,
      // 出力サイズを固定する。録画中にキャンバスの大きさが変わっても
      // (iOS のアドレスバー伸縮など)例外にせず、レターボックスで吸収する。
      sizeChangeBehavior: 'contain',
      transform: { width: this.size.width, height: this.size.height },
    });

VideoEncoder を直接使わなかったのは、VideoFrame の生成と解放をライブラリが持ってくれるからです。close() を忘れてメモリが尽きる、という事故が起きません。add() が返す Promise をそのまま「エンコードが追いついたか」の合図に使い、追いついていなければ新しいフレームを捨てます。

いまの設定は次のとおりです。

項目値
録画の長さ16.5 秒(カウントダウン 1.5 秒、プレイ 10 秒、お祝い 5 秒)
映像H.264、4Mbps、キーフレームは 2 秒ごと
解像度長い辺 1280px まで、縦横とも偶数
音声AAC、96kbps(16.5 秒で約 200KB)
大きさ16.5 秒で 8MB 前後

16.5 秒という長さは、テストで固定しています。お祝いの時間を 3 秒から 5 秒に延ばしたとき、スコアと評価の一言と内訳が出そろうまでに 0.8 秒ほどかかり、3 秒では見せ場が足りなかったためです。尺が変わると仕様書まで直すことになるので、数字が勝手に動かないようにしました。

録画で踏んだ不具合

動画の先頭が真っ黒になる

8 月 13 日、録った動画の最初の数フレームが真っ黒になっているのに気づきました。SNS は動画の最初のフレームをサムネイルに使うことが多いので、ここが黒いと投稿したときの見た目が台無しです。

原因は、録画を始めてから最初のフレームを取り込むまでの隙間でした。エンコーダーの準備を待つあいだの時間が、そのまま動画の先頭の空白になっていたのです。

直し方は二つです。一つは、最初に取り込んだフレームを必ず 0 秒に置くことです。

    const now = performance.now();
    // **最初のフレームは必ず 0 秒に置く。**
    // 録画開始から最初の取り込みまでの隙間(エンコーダの初期化ぶん)が
    // そのまま動画の先頭の空白になり、SNS はそこをサムネイルに拾ってしまう。
    // 実測でここが数フレームぶん空き、投稿時に真っ暗な絵が出ていた。
    if (this._firstFrame) {
      this._recordStartedAt = now;
      this._firstFrame = false;
    }
    this._recorder.captureFrame((now - this._recordStartedAt) / 1000);

もう一つは、録画を始める前に盤面を一度描いておくことです。

    // 録画を始める前に盤面を 1 枚描いておく。キャンバスが空のまま
    // エンコーダが回り始めると、先頭に何も写っていないフレームが残る。
    this.stage.render(0);
    await this._startRecording();

フレームの時刻には実際の経過時間を使っているので、できあがる動画はフレームの間隔が揺れます(可変フレームレート)。その代わり、端末が重くなってフレームが減っても、動画の長さは遊んだ時間と一致します。

録画に映らない文字が、録画を壊した

公開後の 8 月 19 日、結果画面の動画が途中で止まるようになりました。コンソールには、次の警告が出ていました。

When using "avc1" for mp4 encoding, the codec description is not supposed to change during the entire recording. Normally, a change in the encoding resolution may lead to this situation. Consider switching to "avc3" instead to resolve this problem

録画中に解像度が変わった、という警告です。Canvas の大きさを測ると、録画の途中で 678×1490 から 678×1516 に変わっていました。

犯人は、録画には映らない HTML の表示でした。お祝いの時間に入ると、残り時間の文字を 22px から 10px に小さくしていました。その行が 13px 縮み、残りの高さいっぱいに広がる盤面が、そのぶん大きくなっていたのです。

不思議なのは、解像度の変化には最初から備えていたことです。初日の時点で、CanvasSource に sizeChangeBehavior: 'contain' と固定の transform を渡していました。既定の 'deny' だと、Canvas の大きさが変わった瞬間に例外になるためです。contain なら、大きさが変わったフレームも最初の大きさに収めて渡すはずで、エンコーダーに届く解像度は変わらないと考えていました。それでも警告が出た理由は、まだ調べきれていません。警告文が勧めている avc32 も試していません。

直し方は、原因と保険の二重にしました。原因のほうは、その行に min-height: 30px を付けて、文字が小さくなっても縮まないようにしました。保険のほうは、録画している間は Canvas の解像度そのものを固定することです。iOS ではアドレスバーの出し入れや画面の回転でも表示領域が変わるので、行の高さを直すだけでは足りません。

   resize() {
     // レイアウトを起こさない clientWidth/Height を使う。
     const w = this.canvas.clientWidth;
     const h = this.canvas.clientHeight;
 
+    // 録画中は一度決めた解像度を動かさない(→ setSizeLocked)。
+    if (this._sizeLocked && this.hasSize) return;

解像度を固定したまま CSS 上の大きさが変わると、Canvas は少し伸び縮みして表示されます。タップの位置と盤面の座標がずれるので、表示と描画の大きさの比で戻しています。

   pointerToField(event) {
     const rect = this.canvas.getBoundingClientRect();
-    return { x: event.clientX - rect.left, y: event.clientY - rect.top };
+    // 録画中に CSS の大きさだけ変わったときは、キャンバスが伸びた状態になる。
+    // 盤面の座標は据え置いた解像度のままなので、その比で戻す。
+    const sx = rect.width > 0 ? this.width / rect.width : 1;
+    const sy = rect.height > 0 ? this.height / rect.height : 1;
+    return { x: (event.clientX - rect.left) * sx, y: (event.clientY - rect.top) * sy };
   }

修正後は、録画中ずっと 678×1480 のまま動かず、警告も消えました。録画に映らない部分でも、レイアウトを通じて録画を壊せます。この教訓は README の「覚えておくべき制約」に残し、のちにミュートボタンを足したときも、HUD の行に入れずに済ませました。

引っ張ってリロードすると、撮った動画が消える

8 月 22 日の不満は、逆向きでした。スマホでページの一番上から下に引っ張っても、リロードできなかったのです。

録画中にうっかりリロードされると撮っていた 16.5 秒が消えるので、html, body に overscroll-behavior: none を常に掛けていました。これを、録画している間だけ <html> に付くクラスで掛けるように変えています。body ではなく <html> にしたのは、body の値をルート要素へ伝えるかどうかがブラウザによって当てにならないためです。

状態の切り替わりと、非同期の隙間

似た種類の不具合が二つありました。どちらも、await で止まっている間に描画のループが回り続けることが原因です。

  • お祝いの時間にボタンで録画を捨てたのに、残り時間が尽きて結果画面へ進んでしまう。捨てる処理の先頭で状態を戻して直した
  • 「もう一度」を連打すると 10 秒の開始が二重に走り、盤面とライトが二重に組まれて画面が真っ白になる。開始の処理に錠を付けて直した

MediaRecorder の経路で、音が縮む

音を入れたあと(9 月 15 日)、MediaRecorder の経路で、76 秒の映像に 18 秒ぶんの音しか入らない現象を見つけました。開発中のプレビューのペインが隠れていて、requestAnimationFrame が約 0.25fps まで落ちていたためです。30fps で描けば、1 秒おきに鳴らした音は 0.05、1.015、2.01 秒と正しい位置に並びます。WebCodecs の経路は時刻を自分で付けるので、この現象は起きません。開発環境で起きたことで、遊んでいる人の端末で起きた記録はありません。

もっとも、この「隠れていると rAF が止まる」性質は、検証のたびに足を引っ張りました。初日の録画の確認からして、ペインが隠れていたので 1 フレームずつ手で回して確かめています。1 回の確認に 80 秒ほどかかり、途中でソースを編集すると Vite がページを読み直してその回は消えます。WebCodecs と Web Share API は HTTPS でしか動かないので、共有まわりの確認はプレビュー環境にデプロイしてから行っています。

音は公開から 1 か月後に足した

効果音を足したのは 9 月 15 日です。私の注文は「BGMは不要」「ゲーム1つ1つに別の音はいらないけど、系統の違うとこは違う音にしたいです」でした。音は Web Audio ですべて合成していて、音声ファイルは 1 つもありません。最初の読み込みを重くしないためです。

音の経路と、動画に入れる口の作り方は音の記事に詳しく書きました。ここでは、この作品に固有の判断だけを挙げます。

ライブラリの中を読んで、録音の方法を変えた

仕様書の最初の案では、Mediabunny の MediaStreamAudioTrackSource で音を録るつもりでした。ところが Mediabunny の中を読むと、MediaStreamTrackProcessor の無い Safari では、ライブラリが AudioContext をもう 1 つ作り、resume() を待つ作りになっていました。iOS では、利用者の操作の外で作った AudioContext は動き出さないことがあるので、録画の開始そのものが止まるおそれがあります。さらに、音の 0 秒が「最初に届いた音」になり、動画の 0 秒(最初のフレーム)と別の瞬間になります。

そこで、AudioWorklet で音を 1024 サンプルずつ取り出し、最初の映像フレームを撮った瞬間の AudioContext.currentTime を音の 0 秒にして、AudioSampleSource に渡す形にしました。

    // **音の 0 秒は、最初のフレームを取り込んだ瞬間の AudioContext の秒。**
    // 同じ rAF でカウントダウンの音を currentTime に予約しているので、そこが 0 秒に乗る。
    if (this.hasAudio && this._audioZero === null) {
      this._audioZero = this._audio.currentTime - timestampSeconds;
    }

これは iOS で実際に止まったのを見て直したのではなく、コードを読んで避けたものです。

ボタンの音は動画に入れない

お祝いの時間に HTML のボタン(花火とハートの切り替え)を押すと、「コッ」という音が鳴ります。ボタンは動画に映らないので、その音だけが動画に残ると、何の音かわかりません。ボタンの音は録画の口を通らない別の経路で鳴らし、録った動画の同じ時刻に音が無いこと(RMS 0.0002 以下)を確かめました。

  // HTML のボタンの音。**ボタンは動画に映らないので、音も動画に入れない**
  // (演出タイムに「花火/ハート」を切り替えたときの「コッ」が動画に残っていた)
  's.press': {
    gain: 0.3,
    speakerOnly: true,

反対に、画面のミュートで止まるのはスピーカーだけで、動画には音が入ります。ミュートは「いまこの場で鳴らしたくない」ためのもので、動画を見るのは別の人だからです。

AAC の 45 ミリ秒を、あえて直さない

録った MP4 を読み直して測ると、音が映像より 45〜52 ミリ秒遅れていました。AAC のエンコーダーが冒頭に入れる準備用のサンプル(priming)のぶんで、それを打ち消す情報を MP4 に書いていないためです。測ったのはデスクトップの Chrome だけで、priming の長さはエンコーダー(つまり端末)ごとに違います。

ここは補正しないと決めました。人は音が遅れることには気づきにくく、早まることには敏感だとされています3。補正の情報を読まないプレーヤーで音が先に鳴るより、少し遅れたままのほうが安全です。

ほかにも、iOS で AudioContext を起こすタイミング、navigator.audioSession の設定、Vite が AudioWorklet のファイルを data: URL に埋め込まないようにする指定などを入れています。どれも問題が出てから直したのではなく、設計の段階で先回りしたものです。iPhone では確かめておらず、録った動画を SNS に上げて音が鳴ることを確かめたのは Android です。

共有ボタンは、押した瞬間に navigator.share を呼ぶ

録画が終わると、Web Share API で動画ファイルを共有できます。iOS の Safari は、タップの中でしか共有シートを開かせてくれないので、共有の関数はわざと async にせず、呼び出しまでを同期的に済ませています。

export function shareVideo(file, { text } = {}) {
  // navigator.share の呼び出し自体をこの関数の同期部分で済ませるため、
  // async 関数にせず Promise を組み立てて返している。
  let promise;
  try {
    // iOS ではファイルと text/url を同時に渡すと text が落ちることがある。
    // 導線は動画へのウォーターマークで担保しているので、ここは落ちても問題ない。
    promise = navigator.share({ files: [file], text });
  } catch (error) {
    return Promise.resolve(error?.name === 'AbortError' ? 'canceled' : 'failed');
  }
  // ...

動画のファイルは録画が終わった時点で作っておき、ファイルを共有できる端末かどうかも、1 バイトの仮のファイルで先に聞いておきます。どれも初日の設計で先回りしたもので、iOS で共有が止まって直した記録はありません。ボタンが「シェア」と「保存」のどちらになるかがエンコードの前に決まるので、終わった瞬間にボタンが入れ替わって配置が跳ねることもありません。

物理で踏んだ罠

8 月 13 日に cannon-es を入れてから、物理のゲームが増えるにつれて罠も増えました。代表的な二つは、どちらも「cannon が 1 刻みごとに何かを捨てている」ことに由来します。

フレームレートで難易度が変わる

13 番の大玉ヒルクライムでは、60fps と 120fps で、10 秒に登れる距離が 10.7m と 15.0m まで開きました。cannon の world.step(fixedDt, dt, maxSubSteps) は内部で何刻みも進めますが、速度を触る処理(推進や転がり)はフレームに 1 回しか掛かりません。刻みが増えるほど、その処理の効きが薄まります。

刻みを自分で回し、刻みごとに速度の処理を掛けるように変えました。

  // **刻みは自分で回す。**
  // `world.step(fixedDt, dt, maxSubSteps)` に任せると、速度を触る処理
  // (推進・転がり・頭打ち)が**フレームに 1 回**しか掛からない。
  // 60fps と 120fps で押し戻されかたが変わり、実測で 10.7m と 15.0m まで開いた。
  // 刻みごとに掛ければフレームレートに依らない。
  world._accum = Math.min(world._accum + dt, FIXED_DT * MAX_SUB_STEPS);
  while (world._accum >= FIXED_DT) {
    world._accum -= FIXED_DT;
    _drive(world, FIXED_DT);
    _roll(world, FIXED_DT);
    world.physics.world.step(FIXED_DT);

これで 30fps と 120fps の差が 1 割以内に収まりました。メッシュへ位置を写す処理は sync() として切り出し、最後に 1 回だけ呼びます。固定刻みの考え方は、Aim Training の記事にも別の形で出てきます。

摩擦が 60 倍効く

28 番のおはじき相撲では、7.5m/s で弾いた駒が 0.21m で止まりました。cannon は摩擦の上限を「摩擦係数 × 重力 × 質量」で作りますが、その値を力ではなく 1 刻みぶんの力積として使っています。そのままだと 1/dt 倍、60Hz なら 60 倍効きすぎます。

  // ⚠️ **摩擦の上限を「力積」に直します。**
  // cannon は摩擦の上限を `μ × |重力| × 質量` で作りますが、これは**力ではなく
  // 1 ステップぶんの力積**として使われるので、そのままだと 1/dt 倍(= 60 倍)
  // 効きすぎます(μ = 0.84 で 7.5m/s の駒が **0.21m** で止まった)。
  // `frictionGravity` に `重力 × dt` を渡すと上限が `μ·m·g·dt` になり、
  // 減速度がクーロン摩擦どおりの μg になります。**ここが §6.1 の目標の土台です。**
  physics.world.frictionGravity = new CANNON.Vec3(0, GRAVITY * FIXED_DT, 0);

ほかにも、物理の罠はゲームの数だけありました。

症状原因と直し方
22 番で、組まれたアーチを押しても崩れない詰まった剛体に 3.7m/s を与えても、次の刻みでソルバーが吸収して 0.02m しか動かない。操作を「押す」から「砕く(剛体を消す)」に変えた
28 番で、円柱の駒が震える平らな面どうしの接触が不安定。剛体は球にして、見た目だけ円柱にした
04 番で、薄い床を突き抜ける深くめり込むと接触の法線が反転する。床の外側に厚みを足した
12 番で、ぴったり積むと吹き飛ぶ剛体を格子より 2% 小さくし、梁は 1 本の剛体にした
15 番で、玉が 4 割しか下まで届かない重力を 9.82 から 12 に上げた

22 番の件からは、「物理で押して崩すことを遊びの中心に据えない」という決まりを作り、後半の 20 本の企画に使いました。

カメラと描画

8 月 13 日には、演出の時間に花火が消えて画面が真っ暗になる不具合も出ました。03 番が、ラウンドが終わったあとも画面サイズの変化に合わせてカメラをトンネルの奥へ動かしていたためです。スマホのブラウザはアドレスバーの出し入れで画面サイズが頻繁に変わるので、これが起きやすくなります。ラウンドが終わったらカメラを固定するようにしました。

同じ日には、依頼の取り違えもありました。私は「カメラが揺れる演出だけ消してほしい」と頼んだのに、花火ごと消えていたのです。git の履歴をさかのぼって、消すのはカメラを揺らす 1 行だけに戻しました。

24 番の色違い探しでは、前の問題の色が数 % だけ次の問題に残っていました。最後のフレームで描き直していなかったためで、私がカラーピッカーで色を拾って見つけました。

SNS のクローラと、事前に作る HTML

8 月 19 日には、各ゲームのページを事前に HTML として書き出す処理(prerender)を入れました。X や LINE、Facebook のクローラは JavaScript を実行しないので、ゲームごとの OGP を出すには、その URL が返す生の HTML に入っている必要があるからです。それまでは、11 本すべてのゲームが同じタイトルで、存在しない URL まで 200 を返していました。

それでも、9 月 9 日の公開の支度で、OG 画像が 35 本とも同じになっていることに気づきました。prerender が og:image を書き換えていなかったためです。共有されることが目的なのに、どのゲームを貼っても同じ絵が出ていました。

同じ日に、10secgames.pages.dev 側の URL から独自ドメインへの転送も入れています。このあたりの Cloudflare Pages の罠は、Cloudflare Pages の記事にまとめました。

広告タグを iframe に入れたら、審査が進まなかった

8 月 20 日に広告(忍者AdMax)を入れました。渡されたタグは、読み込まれると document.write で自分を書き出す古い形でした。1 ページで画面を切り替える作りでこれを走らせると、ページ全体が消えてしまいます。

そこで最初は、タグを iframe に閉じ込めました。ところが本番で見ると、広告が出ないのは審査中だからではなく、タグが正しく貼られていないと判定されて審査が進まないためでした。配信のスクリプトを読むと、iframe の中かどうかを送っていて、iframe の中では何もせずに戻る経路もありました。

直し方は、タグを走らせている間だけ document.write を横取りすることです。

function runTag(src) {
  return new Promise((resolve) => {
    const write = document.write;
    const writeln = document.writeln;
    /** @type {string[]} */
    const captured = [];
    document.write = document.writeln = (...args) => captured.push(args.join(''));

    const restore = (value) => {
      document.write = write;
      document.writeln = writeln;
      resolve(value);
    };

    const script = document.createElement('script');
    script.src = src;
    script.async = true;
    script.onload = () => restore(captured.join(''));
    // 広告ブロッカーや配信停止のときはここに来る。枠は空のままにする。
    script.onerror = () => restore(null);
    document.head.append(script);
  });
}

書き出されるはずだった HTML を受け取り、自分で枠に入れます。タグはグローバル変数を 1 つ共有しているので、1 枠ずつ順番に走らせます。隠れている画面の枠に貼ると、見えない表示が数えられてしまうので、見えている枠だけに貼っています。

いまの状態

9 月 9 日の時点で 35 本を公開し、その後 36 本目(31 レール・スイッチ)と音の入った録画も本番に出しました。初回の JavaScript は gzip で約 214KB あり、一覧しか見ない人にも three.js が届いてしまう点は、直していない課題として残っています。

  1. Canvas を作るときに preserveDrawingBuffer: true を指定すれば中身は残りますが、描画が遅くなることがあります。 ↩
  2. avc1 は H.264 の設定情報(SPS と PPS)をファイルの先頭にまとめて持ち、avc3 はストリームの中に持ちます。avc3 なら途中で設定が変わっても書けますが、対応していない再生環境もあります。 ↩
  3. 放送の音と映像のずれについての ITU-R の勧告 BT.1359 では、音の遅れは約 125 ミリ秒、音の先行は約 45 ミリ秒で気づかれるとしています。この数字は 10秒だけ!の設計資料に書いたもので、勧告の原文は確かめていません。 ↩

← 記事一覧へ