blog

ブラウザでMP4を書き出す4通りの実装と、Safariが黙って止まる罠

つやっとで公開しているもののうち 4 つは、ブラウザの中で MP4 を作っています。縦型動画の編集ツール、チャット風の動画を作るツール、ミニゲームのプレイ録画、ゲーム用エフェクトのループ動画です。どれもサーバーに素材を送らず、手元の端末でエンコードまで済ませます。

同じ「MP4 を書き出す」でも、4 つの実装はすべて違う構成になりました。使ったライブラリも、フレームの集め方も、対応していないブラウザへの逃がし方も違います。この記事では、ブラウザで MP4 を作る方法の選択肢を並べたうえで、4 つのアプリが実際にどう書き出しているか、出てきたファイル、ブラウザごとの差、そして実際に踏んだ罠をまとめます。

4 つとも Claude Code と一緒に作っていて、実装の多くは Claude Code が書いています。罠の多くは、私が触ってみたり、Claude Code に監査を頼んだりして見つけたものです。ブラウザの版は、特に断りがなければ 2026 年 9 月時点の手元の Mac(Chrome 153、Safari 27、Firefox 156)です。

ブラウザで MP4 を作る方法

ブラウザで動画ファイルを作る方法は、大きく四つあります。

  • WebCodecs + マルチプレクサ:VideoEncoder と AudioEncoder で H.264 や AAC に符号化し、出てきたデータを JavaScript のライブラリで MP4 のファイルにまとめる方法です。まとめる部分(マルチプレクサ)には mp4-muxer か Mediabunny を使います
  • MediaRecorder:canvas.captureStream() などで作った MediaStream を、ブラウザ内蔵の録画機能で録る方法です。コードは短く済みますが、出てくる形式(MP4 か WebM か)はブラウザが決めます
  • ffmpeg.wasm:FFmpeg を WebAssembly にしたものです。今回の 4 つでは、どれも候補に挙がりませんでした
  • 連番画像:動画にせず、1 フレームずつ PNG にして ZIP で渡す方法です。WebCodecs が無い環境の最後の受け皿として使っています

この中で、作り方を一番大きく分けるのは、実時間で録るか、1 フレームずつ描いて書き出すかの違いです。MediaRecorder は実時間でしか録れません。10 秒の動画を作るには 10 秒かかり、その間に描画が遅れれば、遅れたまま記録されます。WebCodecs では、フレームごとに自分でタイムスタンプを付けられます。1 フレームの描画に 200ms かかっても、出来上がる動画は 30fps のままです。

マルチプレクサのうち mp4-muxer は、作者自身が後継の Mediabunny に置き換えて非推奨にしています1。Mediabunny は MP4 と WebM の両方を書けるうえ、符号化まで引き受けてくれます。Canvas を渡せば VideoFrame の生成と解放までライブラリが持ちます。

4 つのアプリの構成

まず全体を表にします。

ショート動画メーカーチャットシアター10秒だけ!Rollshade
フレームの作り方2D Canvas に 1 フレームずつ描く2D Canvas に 1 フレームずつ描く遊んでいる WebGL の Canvas を実時間で取り込むWebGPU で描いて読み戻し、2D Canvas に置く
符号化とまとめVideoEncoder を直接使い、mp4-muxer でまとめるMediabunny の CanvasSourceMediabunny の CanvasSourceMediabunny の VideoSampleSource
映像avc1.640033、12Mbpsavc、QUALITY_HIGHavc、4Mbpsavc(MP4)か vp9(WebM)
解像度1080×1920 など、プロジェクトの設定どおり1080×1920 固定長辺 1280 まで縮め、偶数に丸める利用者が指定
キーフレーム2 秒ごと2 秒ごと2 秒ごと先頭だけ
音声OfflineAudioContext で混ぜて AAC 128kbpsOfflineAudioContext で合成して AAC 128kbpsAudioWorklet で取り出して AAC 96kbpsなし
逃がし先MediaRecorder で WebMMediaRecorder(MP4 を先に試す)MediaRecorder(音つき MP4、映像だけの MP4、WebM の順)連番 PNG の ZIP

ここから、それぞれのコードを見ていきます。

ショート動画メーカーは WebCodecs を直接使う

ショート動画メーカーは、4 つの中で唯一、VideoEncoder を直接呼んでいます。エンコーダーの出力を、そのまま mp4-muxer に流し込む形です。

const target = new ArrayBufferTarget()
const muxer = new Muxer({
  target,
  video: { codec: 'avc', width: w, height: h },
  ...(withAudio
    ? { audio: { codec: 'aac' as const, sampleRate: MIX_SAMPLE_RATE, numberOfChannels: MIX_CHANNELS } }
    : {}),
  fastStart: 'in-memory',
})

const encoder = new VideoEncoder({
  output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
  error: (e) => console.error(e),
})
encoder.configure({
  codec: 'avc1.640033', // H.264 High
  width: w,
  height: h,
  bitrate: 12_000_000,
  framerate: fps,
})

avc1.640033 は H.264 の High プロファイル、レベル 5.1 を表す文字列です。フレームのループでは、タイムスタンプをマイクロ秒で自分で付け、キーフレームを 2 秒ごとに指定します。

for (let frame = 0; frame < totalFrames; frame++) {
  if (opts.signal?.cancelled) break
  const t = frame / fps
  await seekVideosTo(doc, t)
  renderFrame(ctx, doc, t, { syncVideo: false })
  const vf = new VideoFrame(canvas, {
    timestamp: (frame * 1_000_000) / fps,
    duration: 1_000_000 / fps,
  })
  encoder.encode(vf, { keyFrame: frame % (fps * 2) === 0 })
  vf.close()
  opts.onProgress?.(frame / totalFrames)
  if (encoder.encodeQueueSize > 8) {
    await new Promise((res) => setTimeout(res, 8))
  }
}
await encoder.flush()

VideoFrame は使い終わったらすぐ close() します。閉じ忘れると、GPU 側のメモリが解放されないまま溜まっていきます。encodeQueueSize が 8 を超えたら少し待つのは、描画がエンコーダーを追い越して、キューにフレームが積み上がるのを防ぐためです。

デザインの受け渡し資料が指定していたのは「Canvas/WebCodecs」までで、mp4-muxer は最初の実装計画で Claude Code が選んだものです。Mediabunny と比べた跡はありません。

チャットシアターは Mediabunny に任せる

チャットシアターも、2D Canvas に 1 フレームずつ描く点は同じです。違いは、符号化を Mediabunny の CanvasSource に任せていることです。

const output = new Output({
  format: new Mp4OutputFormat(),
  target: new BufferTarget(),
});
const videoSource = new CanvasSource(canvas, {
  codec: 'avc',
  bitrate: QUALITY_HIGH,
  keyFrameInterval: 2,
});
output.addVideoTrack(videoSource, { frameRate: FPS });

フレームを足す側は、時刻と長さを秒で渡すだけです。

for (let frame = 0; frame < totalFrames; frame++) {
  if (signal.aborted) throw new DOMException('aborted', 'AbortError');
  const t = frame / FPS;
  await syncVideosForExport(frameContext, t);
  renderFrame(canvas, frameContext, t);
  await videoSource.add(t, 1 / FPS);
  // ...
}
videoSource.close();
await output.finalize();

add() が返す Promise を待つと、エンコーダーが詰まっているあいだは先へ進みません。ショート動画メーカーで自前で書いていた待ちと close() が、ここでは要らなくなっています。Mediabunny は書き出すときだけ動的に読み込んでいて、これで最初に読む JS が 501kB から 311kB に減りました。

10秒だけ!は遊んでいる画面をそのまま録る

10秒だけ!は、ほかの 3 つと違って、遊んでいる最中の WebGL の Canvas を実時間で録っています。1 回の録画はカウントダウンから演出まで約 16.5 秒です。

もともとの仕様書では mp4-muxer を使う予定でした。計画を見直した段階で、Claude Code が mp4-muxer は作者自身が非推奨にしていると指摘し、Mediabunny に差し替えています。理由はもう一つあり、CanvasSource なら VideoFrame の生成と解放をライブラリが持つので、閉じ忘れでメモリが尽きる事故が起きません。

エンコードする解像度は、Canvas の実寸から決めています。

/** SNS に上げるぶんには十分で、モバイルでも詰まらない長辺の上限。 */
export const MAX_LONG_SIDE = 1280;

/** H.264 は偶数の幅・高さしか受け付けない。 */
const toEven = (v) => Math.max(2, Math.round(v / 2) * 2);

// ...
export function encodeSize(width, height, maxLongSide = MAX_LONG_SIDE) {
  const longSide = Math.max(width, height);
  const scale = longSide > maxLongSide ? maxLongSide / longSide : 1;
  return {
    width: toEven(width * scale),
    height: toEven(height * scale),
  };
}

高 DPI のスマホでは、Canvas の実ピクセルが 1200×2000 級になります。そのまま流すとモバイルのエンコーダーが追いつかないので、長辺を 1280 に抑えています。H.264 の 4:2:0 では色の情報を縦横 2 画素ずつまとめて持つため、幅と高さは偶数に丸めます。

実時間で録るので、エンコーダーが詰まったときの扱いもほかと逆です。待つのではなく、そのフレームを捨てます。

if (this._busy) {
  this.droppedFrames += 1;
  return;
}

this._busy = true;
this.frameCount += 1;
this._pending = this.source
  .add(timestampSeconds)
  // ...

タイムスタンプは実時間で付けているので、間引かれたフレームがあっても、可変フレームレートの動画として正しい長さに収まります。

Rollshade はループの継ぎ目を守る

Rollshade が書き出すのは、ゲーム用エフェクトのループ動画です。ループなので、1 フレームでも欠けると継ぎ目で絵が飛びます。そのため、エンコーダーが出したフレームを数えて、欠けていたら作り直しています。

const encode = async (quality: typeof QUALITY_HIGH) => {
  const output = new Output({ format: mp4 ? new Mp4OutputFormat() : new WebMOutputFormat(), target: new BufferTarget() });
  const seen = new Set<number>();
  const source = new VideoSampleSource({
    codec: mp4 ? 'avc' : 'vp9',
    quality,
    alpha: background ? 'discard' : 'keep',
    keyFrameInterval: total / fps + 1,
    ...(mp4 ? { latencyMode: 'realtime' as const } : {}),
    onEncodedPacket: (packet) => void seen.add(Math.round(packet.timestamp * fps)),
  });
  // ...
  return { buffer: output.target.buffer!, complete: seen.size === total };
};
let result = await encode(settings.quality === 'low' ? QUALITY_LOW : QUALITY_HIGH);
if (!result.complete) result = await encode(QUALITY_VERY_HIGH);
if (!result.complete) throw new Error('the encoder dropped frames, so the loop would not be seamless');

keyFrameInterval: total / fps + 1 は、動画の長さより長い間隔を指定して、キーフレームを先頭の 1 枚だけにする書き方です。latencyMode: 'realtime' と合わせて、どちらも Safari の罠への対処です(後で書きます)。背景を透過にしたときは VP9 の WebM で書き出し、WebCodecs が無いブラウザでは連番 PNG を無圧縮の ZIP にまとめます。

実際に出てくるファイル

書き出したファイルの大きさは、ビットレートの設定でほぼ決まります。記録に残っている実例を並べます。

アプリ中身結果
ショート動画メーカー40.2 秒、1080×1920、12Mbps約 61MB
ショート動画メーカー89.9 秒、同じ設定約 136MB
チャットシアター1080×1920 の会話動画 20 本合計 42MB
10秒だけ!約 16.5 秒、長辺 1280、4Mbps10MB 弱を目安に設定
Rollshade(Chrome)4 秒、1920×1080、H.264551KB、書き出し 2.4 秒
Rollshade(Safari 27)4 秒、1920×1080、H.2641,148KB、書き出し 1.2 秒

ショート動画メーカーは 12Mbps 固定なので、長さに比例してほぼ 1.5MB/秒ずつ増えます。Rollshade は大部分が黒い背景のエフェクトなので、同じ 1080p でもずっと小さく収まります。同じ設定でも Safari のほうが 2 倍ほど大きくなったのは、エンコーダーの実装が違うためと考えられます。

どのアプリも、出来上がった動画をすべてメモリに持ってから Blob にしています。90 秒で 136MB なので、長い動画を扱うなら、ファイルへ直接書き出す方式を考えることになります。いまのところ、このメモリの使い方が問題になった記録はありません。

書き出せるかの判定

書き出せるかどうかを typeof VideoEncoder !== 'undefined' だけで判定すると、外れます。WebCodecs があっても、目的のコーデックと解像度で符号化できるとは限らないからです。

実際、手元の Firefox 156 は WebCodecs を持っていて VP9 の WebM は作れましたが、H.264 では「エンコード設定に対応していない」と返しました。Rollshade では、コーデックと解像度とフレームレートを全部渡して聞いています。

export async function videoSupport(width: number, height: number, fps: number): Promise<VideoSupport> {
  if (typeof VideoEncoder === 'undefined') return { webcodecs: false, webm: false, webmAlpha: false, mp4: false };
  const [webm, webmAlpha, mp4] = await Promise.all([
    canEncodeVideo('vp9', { width, height, frameRate: fps, quality: QUALITY_HIGH }),
    canEncodeVideo('vp9', { width, height, frameRate: fps, quality: QUALITY_HIGH, alpha: 'keep' }),
    canEncodeVideo('avc', { width, height, frameRate: fps, quality: QUALITY_HIGH, latencyMode: 'realtime' }),
  ]);
  return { webcodecs: true, webm, webmAlpha, mp4 };
}

逆向きのずれもあります。Chrome の VideoEncoder.isConfigSupported({ alpha: 'keep' }) は false を返しますが、Mediabunny はアルファを VP9 の別データとして書くので、透過の WebM を作れます。ブラウザの判定 API に直接聞くと「できない」と答え、実際にはできる組み合わせです。Mediabunny を使うなら、判定も Mediabunny の canEncodeVideo に揃えたほうが、実際の挙動と食い違いません。

音声も別に判定します。WebCodecs の AAC エンコーダーが Safari に入ったのは Safari 26 からです2。10秒だけ!とチャットシアターは、AAC を書けない端末では音声トラックを付けずに映像だけで書き出します。10秒だけ!のコードには、Opus に落とさない理由がコメントで残っています。

// iOS は Safari 26 未満で AAC を書けない。Opus には落とさない(MP4 の Opus は SNS で鳴らないことがある)
if (!encodable) {
  this._closeTap();
  return;
}

一方、ショート動画メーカーは今も typeof VideoEncoder だけで映像の可否を決めています。H.264 を持たない WebCodecs の環境で何が起きるかは、まだ確かめていません。

ブラウザごとの差

自分たちで観測したことと、資料に書かれていることを分けて並べます。観測は 2026 年 9 月時点の手元の環境です。

事象根拠
Safari 27 の H.264 は、1080p の High と Main で既定の latencyMode: 'quality' だと、出力もエラーも返さずに止まる。'realtime' なら動く観測(Rollshade の検証)
Safari 27 の H.264(realtime)は、2 秒ごとのキーフレームの直後のフレームを落とすことがある(120 枚中 1 枚)観測(Rollshade)
Safari 27 で WebGPU の Canvas から VideoFrame を作ると、前回表示した絵が返る。先頭の 2〜3 フレームは黒観測(Rollshade)
Chrome は realtime にしても、大きさも画質も変わらない観測(Rollshade)
Chrome の <video> は透過 WebM を透過で再生し、Safari の <video> は不透明で再生する観測(Rollshade)
Firefox 156 は WebCodecs があるが、H.264 で符号化できない観測(Rollshade)
Firefox 127 には WebCodecs が無い観測(Rollshade)
WebCodecs は Chrome 94 以降、Firefox 130 以降(Android 版は未対応)、Safari は 26 から全面対応資料2
MediaRecorder は iOS Safari なら MP4、Android Chrome では WebM になることが多い10秒だけ!のコメント(実機の記録なし)

Chrome は 126 から MediaRecorder で MP4 を録れるようになったとされています3。10秒だけ!のコメントにある「Android Chrome では WebM」は、それより前の知識の可能性があります。ただ、WebCodecs が使える Chrome では、そもそも MediaRecorder の経路に来ません。

実際に踏んだ罠

ここからは、4 つのアプリで実際に起きた問題を、症状、原因、直し方の順に書きます。

シークのしきい値で、書き出しが遅くカクつく

ショート動画メーカーで、手持ちの動画を入れた MP4 の書き出しが極端に遅く、出来上がった動画が 10fps くらいにカクついていました。最初に遅さに気づいたとき、Claude Code は 1080×1920 の H.264 のエンコードが重いためと説明し、バグではないと報告していました。1 か月あまり後に監査を頼んで、本当の原因が分かりました。

原因は、プレビュー用のシーク関数をそのまま書き出しに使っていたことです。プレビューでは、再生中の <video> を毎フレームいじると重いので、0.08 秒以上ずれたときだけ currentTime を動かしていました。30fps の 1 フレームは 0.033 秒なので、書き出しでは 3 フレームに 1 回しかシークが起きません。しかもシークしなかったフレームでも seeked イベントを待ち、そのたびに 200ms のタイムアウトまで待っていました。3 秒の動画で試算すると、待ち時間だけで約 12 秒になります。

直し方は、書き出し用に「しきい値なしでシークし、実際にシークしたときだけ待つ」関数を分けることでした。

export function seekVideoExact(el: HTMLVideoElement, target: number, timeoutMs = 200): Promise<void> {
  if (!syncVideoTime(el, target, 0)) return Promise.resolve()
  return new Promise<void>((resolve) => {
    let settled = false
    const finish = () => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      el.removeEventListener('seeked', finish)
      resolve()
    }
    const timer = setTimeout(finish, timeoutMs)
    el.addEventListener('seeked', finish)
  })
}

直したあと、3 秒の動画の書き出し全体が 6.7 秒になりました。<video> を素材にして 1 フレームずつ書き出すなら、プレビュー用の間引きを持ち込まないことと、シークしていないのに seeked を待たないことの二つが要ります。

画像とフォントが、読み込まれる前の状態で録画される

ショート動画メーカーで、プレビューでまだ表示していない画像クリップが、その先頭の数フレームだけ読み込み中の縞模様のまま録画されていました。Claude Code に雑学動画を何本か作らせていた途中で見つかった問題です。

画像の要素は「初めて描かれるとき」に作られるので、プレビューで一度も映していない画像は、書き出しの途中で初めて読み込みが始まります。書き出しのループは読み込みを待たずに進むので、読み込みが終わるまでのフレームがプレースホルダのまま記録されました。字幕のフォントも同じ構造で、Web フォントの読み込みが終わる前に描けば、その文字は代替フォントで録られるはずです。こちらは読み込みの状態を確かめただけで、代替フォントで録られたフレームそのものは見ていません。

直し方は、書き出しを始める前に、使う画像とフォントをすべて読み込んでおくことです。

export async function preloadFonts(doc: Doc, timeoutMs = 8000): Promise<void> {
  if (typeof document === 'undefined' || !document.fonts) return
  const tasks: Promise<unknown>[] = []
  for (const clip of [...doc.titles, ...doc.subtitles]) {
    if (!clip.text) continue
    const font = `${fontWeightOf(clip.style.font)} ${clip.style.size}px "${clip.style.font}"`
    tasks.push(document.fonts.load(font, clip.text))
  }
  // ...
  // フォントが取れなくても書き出しは止めない(オフライン時は代替フォントで続行)
  await Promise.race([
    Promise.allSettled(tasks),
    new Promise((res) => setTimeout(res, timeoutMs)),
  ])
}

document.fonts.load() の第 2 引数に実際の文字列を渡しているのは、日本語の Web フォントが文字の範囲ごとに分割配信されているためです。フォント名だけで読み込むと、字幕に使う文字を含むファイルが読み込まれるとは限りません。チャットシアターは、計画の段階でこの問題を予想していて、書き出しの最初にフォントの読み込みを待つ処理を入れていました。

動画の先頭が黒く、SNS のサムネイルになる

10秒だけ!で録った動画を SNS に投稿すると、真っ黒なサムネイルが出ていました。録画を開始してから最初のフレームを取り込むまでに、エンコーダーの初期化ぶんの隙間がありました。タイムスタンプを「録画開始からの経過時間」で付けていたので、その隙間がそのまま動画の先頭の空白になり、SNS はそこをサムネイルに拾っていました。

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

_captureFrame() {
  if (!this._recording || !this._recorder) return;

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

WebGL の Canvas を取り込むときは、もう一つ決まりがあります。WebGL の描画バッファは、描いたタスクを抜けると中身が失われることがあるので、取り込みは描画の直後、同じ requestAnimationFrame のコールバックの中で行います。

録画中に解像度が変わり、動画が途中で止まる

10秒だけ!で、プレイが終わるあたりで次の警告が出て、出来上がった動画が途中で止まる不具合がありました。

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

H.264 の MP4 では、デコードに必要な設定(codec description)を、ファイルの先頭に 1 回だけ書きます。録画の途中で解像度が変わると、この設定が変わってしまい、後半が再生できなくなります。

原因は、録画には映らない HTML の表示でした。残り時間の表示は、演出に入ると文字が 22px から 10px に切り替わります。その分 HUD の高さが 13px 縮み、flex: 1 で残りを埋めていたゲームの Canvas が伸びて、録画の途中で 678×1490 から 678×1516 に変わっていました。

直し方は二段にしました。一つ目は、HUD の行に最小の高さを入れて、文字が小さくなっても高さが変わらないようにすることです。二つ目は、録画を始めたら Canvas の描画バッファの大きさを固定することです。

resize() {
  // レイアウトを起こさない clientWidth/Height を使う。
  const w = this.canvas.clientWidth;
  const h = this.canvas.clientHeight;

  // 録画中は一度決めた解像度を動かさない(→ setSizeLocked)。
  if (this._sizeLocked && this.hasSize) return;
  // ...
}

一つ目だけでは足りません。iOS のアドレスバーが伸び縮みしたり、画面を回転したりしても、同じように Canvas の大きさが変わるからです。

ちなみに、この録画では最初から Mediabunny の sizeChangeBehavior: 'contain' を指定し、出力の大きさを固定していました。それでもこの警告は出ています。なぜ防げなかったのかは、まだ調べていません。警告文が勧める avc3(設定をフレームごとに埋め込む形式)への切り替えも、試していません。

Safari の H.264 が、エラーも出さずに止まる

Rollshade の書き出しを Safari 27 で試したところ、H.264 のエンコーダーが 1 フレーム目から何も出力しなくなりました。エラーのコールバックも呼ばれず、flush() が終わらないまま待ち続けます。

WebCodecs を直接使って条件を切り分けると、次のようになりました。

設定結果
avc1.640028(High)1080p、既定の設定8 秒でタイムアウト、出力 0
avc1.4d0028(Main)1080p、既定の設定止まる
High 1080p、latencyMode: 'realtime'60 フレームすべて出力、469ms
avc1.42001f(Baseline)1280×72060 フレームすべて出力
VP9 1080p60 フレームすべて出力

latencyMode の既定は 'quality' で、これを 'realtime' にすると動きます。Chrome では realtime にしても大きさも画質も変わらなかったので、MP4 は全ブラウザで realtime にしました。

realtime にすると、今度は別の問題が出ました。2 秒ごとのキーフレームの直後のフレームを、120 枚中 1 枚落とすことがあります。エラーは出ないので、出来上がった動画を見ないと気づけません。単純な絵で再現を試したときは欠けなかったので、欠けるのは複雑な絵のときだけのようです。

Rollshade では、キーフレームを先頭だけにしたうえで、先ほどのコードのようにエンコーダーの出力を数えています。欠けていたら画質を上げて 1 回だけ作り直し、それでも欠けたら失敗として知らせます。エラーが出ない失敗は、出力を数えないと検出できません。

前のフレームの絵が繰り返される

Rollshade では、Safari 以外にも、three.js 側の事情で絵が正しく入らない問題が二つありました。

一つ目は、Chrome で書き出した動画に、同じ絵が 2 フレーム続く箇所があったことです。three.js の WebGPU の描画パイプラインは、内部のフレーム番号が変わったときだけシーンを描き直します。その番号は three.js 自身の requestAnimationFrame のループでしか進みません。書き出しで rAF を待たずに続けて描くと、番号が変わらないので、前の絵が使い回されていました。直し方は、1 フレームごとに内部の番号を進めることで、非公開の API を呼んでいます。

(renderer as unknown as { _nodes: { nodeFrame: { update(): void } } })._nodes.nodeFrame.update();

非公開の API なので、three.js を上げるたびに書き出しの検証を回すことにしています。

二つ目は、先ほどの表にある Safari の問題です。WebGPU の Canvas から VideoFrame を作ると前回表示した絵が返り、先頭が黒く、その後も 1 フレーム遅れました。rAF を待ってから取り込むと、重複と飛びがかえって増えました。最終的に、Canvas から取り込むのをやめ、描画先のテクスチャから画素を読み戻して 2D Canvas に置き、それを符号化する形にしました。読み戻した画素から直接 VideoFrame を作る方法も試しましたが、VP9 で色がずれたので、2D Canvas を経由しています。

読み戻しには、さらに罠がありました。WebGPU では、テクスチャの 1 行が 256 バイトの倍数に切り上げられて返ってきます。幅が 64 画素の倍数でないとき(360 や 1080)、そのまま並べると画像が縞模様に崩れました。

export function unpad(raw: Uint8Array, width: number, height: number): Uint8Array {
  const row = width * 4;
  if (raw.length === row * height || height < 2) return raw;
  const stride = (raw.length - row) / (height - 1);
  const out = new Uint8Array(row * height);
  for (let y = 0; y < height; y++) out.set(raw.subarray(y * stride, y * stride + row), y * row);
  return out;
}

縦型の 1080×1920 は、ちょうどこの条件に当たります。

音と映像の 0 秒がずれる

音声付きの動画では、音の 0 秒と映像の 0 秒を同じ瞬間に決める必要があります。ショート動画メーカーの WebM 書き出し(実時間で録る経路)では、ここがずれていました。音の基準時刻を取ってから、全クリップの予約と録画の開始を挟んで、映像の基準時刻を取っていたためです。そのあいだに流れた時間の分だけ、音が映像より先に進みます。

直し方は、録画開始、映像の基準、音の基準を、間に何も挟まずに続けて取ることです。

recorder.start()

const duration = doc.project.duration
const t0 = performance.now()

// 映像の時間原点(t0)と同じ瞬間を音声の原点にする
if (audioCtx && dest) {
  const origin = audioCtx.currentTime
  for (const { clip, buf } of decodedClips) {
    scheduleAudioClip(audioCtx, clip, buf, { destination: dest, timeOrigin: origin, startAt: 0 })
  }
}

この不具合は、Claude Code にコードを監査してもらって見つけたもので、実際の動画で音ずれを確かめたわけではありません。

基準を揃えても、AAC にはもう一つのずれがあります。AAC のエンコーダーは、先頭に 2112 サンプル(48kHz で約 44ms)の「助走」の無音を入れます4。本来は MP4 の edit list でこの分を飛ばしますが、Mediabunny は edit list を書きません。10秒だけ!で録った MP4 を解析すると、7 つの音すべてが鳴らした時刻から 45〜52ms 遅れていました(デスクトップの Chrome で測定)。

10秒だけ!では、このずれを補正しないと決めました。音が映像より遅れる向きのずれは、先に鳴る向きより気づかれにくいとされています5。時刻を前へずらして補正すると、edit list を読まない再生環境では、逆に音が先に鳴る側へ外れます。助走の長さも端末のエンコーダーごとに違うので、決め打ちの補正値も使えません。詳しくはブラウザの音の記事に書きました。

MediaRecorder で、音だけが縮む

10秒だけ!の MediaRecorder の経路を検証したとき、映像が 76 秒あるのに、音が 18 秒に縮んでいました。検証用のプレビュー画面が隠れていて、requestAnimationFrame が約 0.25fps まで落ち、Canvas から新しいフレームがほとんど届いていなかったためです。

MediaRecorder は、映像と音声を実時間のストリームとして受け取ります。Canvas の更新が止まると、映像のフレームが来ないだけでなく、音の記録も詰まりました。WebCodecs の経路は時刻を自分で付けるので、この問題は起きません。MediaRecorder を使う場合は、録画中のタブを前面に出しておくことが前提になります。ショート動画メーカーの WebM 書き出しも、同じ理由で、終わるまでタブを前面に置いておく必要があります。

共有ボタンが反応しない

10秒だけ!は、録った動画を navigator.share() で SNS のアプリに直接渡しています。このとき、iOS Safari の決まりに気をつける必要があります。navigator.share() は、タップなどのユーザー操作の中から呼ばないと拒否されます。しかも、イベントハンドラの中で await を 1 回挟むだけで、ユーザー操作の扱いが外れることがあります。

/**
 * できあがった動画を端末の共有シートへ渡す。
 *
 * ⚠️ いちばんの落とし穴:
 * navigator.share はユーザージェスチャーの中から呼ばなければ弾かれる。
 * しかも「ハンドラの中で await を挟んでから呼ぶ」だけでもジェスチャー扱いが外れる。
 * そのため動画の Blob は録画停止の直後に作っておき、
 * ボタンのハンドラでは何も待たずに shareVideo() を呼ぶこと。
 */

このコメントは設計の段階で入れた予防で、このプロジェクトで実際に共有が止まった記録はありません。ほかに、MP4 の moov(目次にあたる部分)をファイルの先頭に置く fastStart を明示しています。SNS やメッセージアプリに渡したとき、全体を読み込む前に再生を始められるかどうかに関わるためです。

どれを選ぶか

4 つを作った経験から、選び方を表にまとめます。

やりたいこと選ぶもの理由
編集した内容を正確な fps で書き出すWebCodecs + Mediabunny1 フレームずつ描いて時刻を自分で付けられる。描画が遅い端末でも動画は崩れない
遊んでいる画面をそのまま録るWebCodecs + Mediabunny(実時間の時刻を付ける)詰まったらフレームを捨て、可変フレームレートとして収める
とにかく短いコードで録るMediaRecorder実時間でしか録れず、形式はブラウザ次第。逃がし先として使う
透過の動画を作るMediabunny の VP9 WebMブラウザの判定 API より Mediabunny の判定のほうが実際に合う
WebCodecs が無い環境でも何か渡す連番 PNG の ZIP、または MediaRecorder動画にこだわらなければ確実に作れる
新しく mp4-muxer を入れる選ばない作者が非推奨にしている。同じ作者の Mediabunny がある

どの方式でも共通して効いたのは、次の三つでした。判定はコーデックと解像度を含めて聞くこと。エラーが出ない失敗に備えて、出来上がったものを数えたり測ったりすること。そして、Chrome だけでなく Safari でも試すことです。Safari の H.264 が黙って止まる件も、キーフレームの直後を落とす件も、Chrome だけで作っていたら気づけませんでした。

ショート動画メーカーは、いまも非推奨の mp4-muxer のままです。

  1. mp4-muxer の README の冒頭に「mp4-muxer has been deprecated in favor of Mediabunny, which entirely supersedes it.」とあります。Vanilagy/mp4-muxer ↩
  2. Can I use: WebCodecs API と MDN の browser-compat-data(AudioEncoder) によります。Safari は 16.4 から映像の一部に対応し、26 で音声を含めて揃いました。Firefox で AAC を書けるかは、今回の調査では確かめられていません。 ↩
  3. Chrome 126 での MediaRecorder の MP4 対応は、blink-dev の Intent to Ship で告知されています。 ↩
  4. AAC のエンコーダー遅延と edit list については、Apple の Technical Note TN2258 に説明があります。 ↩
  5. 10秒だけ!の設計資料では、ITU-R BT.1359 の値として「音が遅れる向きは 125ms まで、先に鳴る向きは 45ms で気づかれる」を根拠にしています。この数値は一次資料では確かめていません。 ↩

← 記事一覧へ