blog

プレビュー用のシークを書き出しに流用したら、3フレームに2回が空振りしていた

ショート動画メーカーは、手持ちの動画や画像に字幕や効果音を重ねて、9:16 の縦型動画に仕上げるツールです。編集から書き出しまでブラウザの中で完結していて、素材はサーバーに一度も送られません。

この記事では、このツールがどう作られ、どこでつまずいたかを、修正前後のコードと一緒に書きます。実装は Claude Code が書き、私は仕様を渡して、動かして、おかしいところを見つけて直してもらう、という分担で作っています。不具合の多くは、私が触って気づいたものと、Claude Code に監査を頼んで出てきたものです。

ブラウザで MP4 を作るしくみそのもの(エンコーダーの設定、コンテナへのまとめ方、ブラウザごとの差)は、ほかのサービスと共通なので「ブラウザでMP4を書き出す4通りの実装と、Safariが黙って止まる罠」に分けました。ここでは、このツールに固有の話をします。

デザインの受け渡し資料から始まった

出発点は、画面のデザインと仕様をまとめた受け渡し資料でした。README の見出しは「ショート動画メーカー(タテ動画メーカー)」で、書き出しについての指定は次の 2 か所だけです。

推奨: React + Vite、描画/書き出しは Canvas/WebCodecs or MediaRecorder

書き出し: Canvas に合成描画 → MediaRecorder(WebM)or WebCodecs(MP4/H.264)

2026 年 7 月 16 日に、この README を読ませて実装の計画を立ててもらいました。計画では、WebM を canvas.captureStream() と MediaRecorder で作るのを最初の版にし、WebCodecs と mp4-muxer による MP4 は対応ブラウザ向けの次の版にしています。音声は「仕様に記載なし」として、最初の版では扱わないことになっていました。

翌 17 日に初めて MP4 を書き出し、同じ日に音声にも対応しました。効果音 26 本と音量、パン、フェード、フィルター、速度の設定が入ったのは 18 日です。ここまでの 7 月の開発は git の管理の外で進めていて、8 月 27 日の最初のコミットに全部まとまっています。

8 月 27 日には、私が「色々バグってるんだよね」と伝えて、仕様と実装を突き合わせる監査をしてもらいました。このとき 20 件ほどを直しています。29 日に独自ドメインへ移し、名前を「ショート動画メーカー」に変えました1。

1 フレームを描く関数を、プレビューと書き出しで共用する

最初の計画で決まり、いまも全体を支えているのが「Canvas 2D で描く関数を 1 つだけ持つ」という方針です。計画には、その理由がこう書かれています。

プレビューを DOM/CSS、書き出しを Canvas にすると全演出を二重実装することになり、見た目の乖離が必ず出る。

縁取りを何重にも重ねた字幕、集中線、色の調整、画面の揺れは、どれも Canvas 2D で描けます。書き出しのときは、同じ関数を 1080×1920 のキャンバスに向けて、時刻を 1 フレームずつ進めながら呼ぶだけです。

export function renderFrame(ctx: CanvasRenderingContext2D, doc: Doc, t: number, opts: RenderOptions = {}) {
  const { W, H, title, media, safe } = canvasLayout(doc)

  // 背景
  ctx.fillStyle = '#000'
  ctx.fillRect(0, 0, W, H)

  // ─── メディア面 ───
  ctx.save()
  // ...
  drawMediaPlane(ctx, doc, t, media, opts)
  ctx.restore()

  // ─── オーバーレイ FX(メディア面にクリップ) ───
  // ...
  // ─── テキスト(タイトル+字幕、フルキャンバス自由配置) ───
  const maxTextWidth = W * 0.9
  for (const clip of [...doc.titles, ...doc.subtitles]) {
    if (t < clip.tIn || t >= clip.tOut) continue
    drawText(ctx, doc, clip, t, maxTextWidth)
  }
}

プレビューと書き出しの違いは、引数の opts に閉じ込めています。

export interface RenderOptions {
  /** 動画の currentTime を同期する(プレビュー再生で true。実時間書き出しでは false) */
  syncVideo?: boolean
  /** メディア要素の解決を差し替える(書き出し専用要素を使う場合)。null 返却でプレースホルダ */
  resolveMedia?: (m: MediaItem) => MediaEntryLike | null
}

syncVideo は、描きながら <video> の再生位置を合わせるかどうかです。resolveMedia は、WebM の書き出しで、プレビューとは別に用意した <video> を使うための差し替え口です2。

元の動画は、WebCodecs の VideoDecoder ではなく <video> 要素を目的の時刻へシークして読んでいます。最初の計画からこの形で、VideoDecoder を検討した記録はありません。<video> には「この時刻のフレームを確実にくれ」という API が無いので、このあと書くシークの問題は、この選択から来ています。

MP4 の書き出しが遅く、しかもカクついていた

MP4 の書き出しは、フレームの数だけ回るループです。その時刻へ動画をシークし、renderFrame() で描き、Canvas から VideoFrame を作ってエンコーダーに渡します。

7 月 18 日の確認で、実際の動画を置いた MP4 の書き出しが、進捗の途中で長く止まって見えました。このとき Claude Code は、裏のタブで処理が間引かれたせいだろうと推測しています。報告にも「気づいた点(バグではありません)」として、次のように書いていました。

MP4 書き出しは 1080×1920 のH.264エンコードが重く、実動画を配置していると1フレームごとのシーク待ちも入るため時間がかかります(4秒の動画で数十秒)。

本当の原因がわかったのは、8 月 27 日の監査です。書き出しのループが、プレビュー再生のために作ったシーク関数をそのまま使っていました。当時の書き出し側のコードはこうです。

async function seekVideosTo(doc: Doc, t: number) {
  const tasks: Promise<void>[] = []
  for (const m of doc.mediaItems) {
    if (m.type !== 'video' || !m.src || !m.placement) continue
    if (t < m.placement.tIn || t >= m.placement.tOut) continue
    const entry = getMediaElement(m)
    if (!entry || entry.type !== 'video') continue
    const local = t - m.placement.tIn + m.trim.in
    syncVideoTime(entry.el, local)
    tasks.push(
      new Promise<void>((res) => {
        entry.el.addEventListener('seeked', () => res(), { once: true })
        setTimeout(res, 200)
      }),
    )
  }
  await Promise.all(tasks)
}

呼んでいる syncVideoTime() は、プレビュー用にしきい値を持っていました。

/** 動画の currentTime を目標秒へ寄せる(既に近ければ何もしない) */
export function syncVideoTime(el: HTMLVideoElement, target: number) {
  if (Math.abs(el.currentTime - target) > 0.08) {
    try {
      el.currentTime = target
    } catch {
      /* 範囲外は無視 */
    }
  }
}

再生中の <video> に毎フレーム currentTime を書くと重くなるので、ずれが 0.08 秒を超えたときだけ直す、という関数です。プレビューならこれで困りません。

しかし 30fps の 1 フレームは約 0.033 秒です。書き出しで 1 フレームずつ進めると、ずれが 0.08 秒を超えるのは 3 フレームに 1 回だけになります。残りの 2 回はシークが起きないので seeked イベントも来ず、毎回 200 ミリ秒のタイムアウトまで待っていました。そのうえ動画の絵は 3 フレームに 1 回しか変わらないので、出力は 10fps 相当のカクついた映像になっていました。

3 秒(90 フレーム)で数えると、実際にシークしたのが 29 回、空振りが 61 回です。空振りだけで 61 × 200 ミリ秒、約 12.2 秒を待っていた計算になります。

直し方は、書き出し用に「しきい値なしでシークし、実際にシークしたときだけ待つ」関数を分けることでした。まず syncVideoTime() に、しきい値を引数で受け取り、実際にシークしたかを返させます。

export function syncVideoTime(el: HTMLVideoElement, target: number, threshold = 0.08): boolean {
  if (Math.abs(el.currentTime - target) <= threshold) return false
  try {
    el.currentTime = target
    return true
  } catch {
    return false // 範囲外は無視
  }
}

書き出しはこれをしきい値 0 で呼び、シークしなかったときはすぐ次へ進みます。

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)
  })
}

200 ミリ秒のタイムアウトは、待ちきれなかったときの保険として残しています。直したあとの実測では、エフェクト無しの 90 フレームが全体で約 6.7 秒(1 フレームあたり約 75 ミリ秒)でした。1080×1920 でエフェクトを重ねると、1 フレームに 100〜200 ミリ秒ほどかかります。

エンコードが重いのは事実なので、7 月の「重いから遅い」という説明は筋が通って見えました。「4 秒で数十秒」という数字がエンコードの重さだけで説明できるかを確かめていれば、もっと早く気づけたはずです。

クリップが無い時刻に、1 本目の動画が映っていた

同じ監査で見つかったもう 1 つの不具合は、描画の側にありました。その時刻に映すクリップを探すコードが、見つからなかったときに先頭のクリップを返していたのです。

const current = placed.find((m) => t >= m.placement!.tIn && t < m.placement!.tOut) ?? placed[0] ?? null

?? placed[0] のせいで、1 本目のクリップより前、最後のクリップより後、クリップどうしの隙間で、1 本目の素材が表示されていました。しかも素材のどの位置を映すかを、範囲外の時刻のまま計算していたので、トリミングで切ったはずの部分までシークしていました。

いまは、見つからなければ黒で塗ります。クリップが 1 本も無いときだけ、案内の表示を出します。

  const current = placed.find((m) => t >= m.placement!.tIn && t < m.placement!.tOut) ?? null

  if (!current) {
    // クリップが 1 本も無ければ案内、あるなら「その時刻には何も無い」ので黒。
    // (ここで先頭クリップにフォールバックすると、ギャップや末尾で 1 本目が復活する)
    if (placed.length === 0) {
      drawMediaPlaceholder(ctx, media, 'メディア未配置')
    } else {
      ctx.fillStyle = '#000'
      ctx.fillRect(media.x, media.y, media.w, media.h)
    }
    return
  }

素材の中の再生位置も、トリミングの範囲に収める関数を 1 つ作り、プレビューと書き出しの両方から使うようにしました。

export function mediaLocalTime(m: MediaItem, t: number): number {
  if (!m.placement) return m.trim.in
  const raw = t - m.placement.tIn + m.trim.in
  const hi = m.trim.out > m.trim.in ? m.trim.out : raw
  return Math.min(Math.max(m.trim.in, raw), hi)
}

動画編集では「その時刻には何も無い」こと自体が正しい状態です。何か映しておこうとして先頭のクリップを返すと、編集した内容と違う動画になります。

まだ読み込まれていない画像とフォントが、そのまま録られる

9 月 30 日に、このツールで雑学の動画を 5 本作ってみました。台本を書き、読み上げ音声と画像を用意し、ヘッドレスの Chrome でツールを自動操作して書き出す、という作業を Claude Code にやってもらいました。その 1 本目で見つかったのが、画像クリップの先頭の数フレームが、読み込み中を示す縞模様のまま録画されている不具合です。

原因は、画像の要素を「表示するときに初めて作る」作りにありました。プレビューで一度も映していない画像は、書き出しのループがその時刻に来たときに読み込みが始まります。読み込みが終わるまでの数フレームは、仮の表示が録られていました。

直し方は、書き出しを始める前に、配置済みの画像の読み込みを待つことです。MP4 と WebM の両方の書き出しに、同じ 2 行を足しました。

   await prepareVideos(doc)
+  await preloadImages(doc.mediaItems)
+  await preloadFonts(doc)
export function preloadImages(items: MediaItem[], timeoutMs = 5000): Promise<void> {
  const tasks: Promise<void>[] = []
  for (const m of items) {
    if (m.type !== 'image' || !m.src || !m.placement) continue
    const entry = getMediaElement(m)
    if (!entry || entry.ready) continue
    tasks.push(
      new Promise<void>((res) => {
        entry.el.addEventListener('load', () => res(), { once: true })
        entry.el.addEventListener('error', () => res(), { once: true })
        setTimeout(res, timeoutMs)
      }),
    )
  }
  return Promise.all(tasks).then(() => undefined)
}

読み込みに失敗したときも resolve しているので、壊れた画像が 1 枚あっても書き出しは止まりません。直したあとは、修正前後の書き出しのフレームを見比べて、縞模様が消えたことを確かめています。

フォントも同じ構造の問題を抱えていました。Google Fonts の日本語フォントは、文字の範囲ごとに小さなファイルに分けて配信され、ブラウザはその文字を初めて描くときにファイルを取りに行きます。このときの調査では、プロジェクトを開いた直後、一部の字幕の文字がまだ読み込まれていないことがわかりました。そのまま書き出せば、その文字は代わりの書体で録られるはずです。

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))
  }
  for (const e of doc.effectClips) {
    // 漫符は drawManpu が Noto Sans JP 900 固定で描く
    if (e.kind === 'manpu') tasks.push(document.fonts.load('900 100px "Noto Sans JP"', e.params.text ?? '!?'))
  }
  // フォントが取れなくても書き出しは止めない(オフライン時は代替フォントで続行)
  await Promise.race([
    Promise.allSettled(tasks),
    new Promise((res) => setTimeout(res, timeoutMs)),
  ])
}

document.fonts.load() の 2 つめの引数に字幕の文字列を渡すと、その文字を含むファイルだけを取りに行きます。字幕だけでなく、漫符(「!?」のような記号のエフェクト)の文字も対象です。

ただし、フォントのほうは「読み込みが終わったか」を確かめただけで、代わりの書体で録られたフレームを実際に見たわけではありません。7 月には、フォントが届いたらプレビューを描き直す対策を入れていました。プレビューは後から描き直せば済みますが、書き出しはその瞬間の絵がそのまま残ります。同じ「あとから届く」問題でも、プレビューと書き出しでは直し方が違っていました。

WebM の書き出しで、音の時間原点が早すぎた

WebM の書き出しは、MP4 とはしくみが違います。専用の <video> を実際に再生し、Canvas を captureStream() で、音を Web Audio の MediaStreamDestination で録る、実時間の録画です。

8 月 27 日の監査では、この経路の音と映像の時刻の基準がずれていることも見つかりました。修正前のコードはこうです。

  await audioCtx?.resume()

  // 音声クリップは実時間で進むので、録画開始時点を基準に一括スケジュールする
  if (audioCtx && dest && audioClips.length) {
    const buffers = await Promise.all(
      audioClips.map((c) => getDecodedAudio(c.src).catch(() => null)),
    )
    const origin = audioCtx.currentTime
    audioClips.forEach((c, i) => {
      const buf = buffers[i]
      if (buf) scheduleAudioClip(audioCtx, c, buf, { destination: dest, timeOrigin: origin, startAt: 0 })
    })
  }

  recorder.start()

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

音の基準 origin を取ってから、全クリップを予約するループと recorder.start() を挟んで、映像の基準 t0 を取っています。その間に流れた時間のぶん、音が映像より先に進みます。

修正では、recorder.start() の直後に、映像と音の基準を続けて取るようにしました。

  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 })
    }
  }

t0 と origin のあいだに await が 1 つも無いことが、この修正の要点です。ただ、これはコードを読んで見つけた不具合で、実際に書き出した WebM で音のずれを測ったわけではありません。修正の報告でも「コード上の修正のみで、実挙動までは追えていないもの」に入れていました。

音と映像の時間原点をどこで揃えるかは、録画をするほかのサービスでも同じように問題になりました。音まわりの共通の話は「録画の音が45ms遅れても直さなかった理由と、ブラウザの音の罠」にまとめています。

長さが縮まない、プロジェクトを切り替えると素材が消える

7 月 17 日に、触ってみた私から報告した不具合が 2 つあります。

1 つめは、動画の長さが伸びたまま戻らないことです。クリップを長くしてから短く戻しても、動画全体の長さは長いままでした。長さを更新する 8 か所がすべて Math.max で、伸ばす方向にしか動かなかったのが原因です。いまは、一番最後のクリップの終わりに自動で合わせる方式と、手で決める方式を切り替えられます。手で決めた長さは、自動で計算した長さとは別の値として持っています。同じ値を共有していたら、手動の状態でも自動計算に上書きされてしまうからです。

2 つめは、プロジェクトを切り替えると、配置した動画や画像が消えることです。素材を URL.createObjectURL() のメモリ上の URL で持っていて、プロジェクトを保存するときにその URL を捨てていました。素材の実体を IndexedDB に入れ、どのプロジェクトからも参照されなくなったものだけを消す形に直しました。

ウィンドウが低いと、タイムラインと再生ボタンが切れる

8 月 27 日、Mac のブラウザで見ていて、タイムラインと再生ボタンが画面の下にはみ出しているのに気づきました。私は「動画エリアの幅を小さくすれば解決するかな?」と聞きましたが、これは縦方向のあふれなので、幅を縮めても直りません。

原因は、受け渡し資料の画面構成にあった min-height: 860px を、そのまま .app と #root の両方に入れていたことです。ブラウザの表示領域が 860px より低い Mac では、レイアウト全体が画面より高くなっていました。.app だけ直しても、#root 側の 860px が残っていて、まだはみ出しました。

最小の高さを 600px に下げ、高さが足りないときは中央の領域(左のパネルとプロパティ)だけが縮んで、中でスクロールするようにしました。タイムラインは高さを固定して、6 本のトラックが常に見えるようにしています。1512×880、1512×800、1440×760、1280×720 のウィンドウで、ページ全体がスクロールしないことを確かめました。

MP4 のエンコードと、配信について

MP4 は、VideoEncoder で H.264(avc1.640033、12Mbps)、AudioEncoder で AAC(128kbps)にし、mp4-muxer で 1 つのファイルにまとめています。VideoEncoder が無いブラウザでは WebM で書き出します。9 月 30 日に作った雑学動画は、40 秒で約 61MB、約 90 秒で約 136MB でした。

mp4-muxer は、作者が後継の Mediabunny への移行を勧めている、開発の終わったライブラリです。ほかのサービス(チャットシアター、10秒だけ!、Rollshade)は Mediabunny を使っていて、mp4-muxer のままなのはこのツールだけです。コーデックの文字列の意味、ブラウザごとの対応、ほかのアプリとの比較は「ブラウザでMP4を書き出す4通りの実装と、Safariが黙って止まる罠」に書きました。

音声は、OfflineAudioContext で全体を先にミックスしてから AAC にしています。効果音のノードのつなぎ方は、プレビュー再生と書き出しで同じ関数を通しています。理由はコードのコメントに書いてあるとおりです。

// 音声クリップ 1 本のノードチェーンを組む。
// プレビュー再生(AudioContext)と書き出し(OfflineAudioContext)の両方から呼ぶ。
// ここを共用しないとプレビューと書き出しで音が食い違うため、必ずこの関数を通すこと。

映像で renderFrame() を共用しているのと同じ考え方です。

配信は Cloudflare Pages で、tate-douga-maker.pages.dev から独自ドメインへの 301 を Pages Functions で書いています。効果音のファイルが 26 本あるので、静的ファイルに Function を通さない設定も入れました。このあたりは、ほかのサービスとまとめて「Cloudflare Pagesで9サイトを無料で回して踏んだ罠」に書いています。

残っている課題

  • 映像のエンコード設定が使えるかを VideoEncoder.isConfigSupported() で事前に確かめていない(音声は確かめている)
  • mp4-muxer から Mediabunny への移行
  • 完成した MP4 をいったん丸ごとメモリに持ってからダウンロードさせている3
  • Safari と Firefox で書き出しを試した記録が無い

振り返ると、エンコードそのものでつまずいたことはほとんどありません。つまずいたのは、プレビュー用に作ったものを書き出しに流用したところと、ブラウザが「必要になってから用意する」ものを、書き出しが待たずに撮っていたところでした。

  1. 受け渡し資料の README の見出しには、最初から「ショート動画メーカー」の名前がありました。サブドメインを short-video-maker に決めたのは改名の約 1 時間前ですが、改名の理由は記録に残っていません。 ↩
  2. Web Audio の MediaElementAudioSourceNode は 1 つの要素に 1 回しか作れないので、音を録るための <video> をプレビューと共有できません。計画の段階でそう書かれていて、WebM の書き出し専用の要素を用意しています。 ↩
  3. mp4-muxer の fastStart: 'in-memory' を使っています。動画の目次にあたる情報をファイルの先頭に置くための設定で、そのぶん全体をメモリ上で組み立てます。 ↩

← 記事一覧へ