blog

「全角コロン対応」の正規表現が半角コロン2つだった。チャット動画を120本作って直したこと

チャットシアターは、メッセージアプリ風の会話を組み立てて、1 通ずつ届くように再生するツールです。再生した様子は 1080×1920 の縦型動画として書き出せます。作った会話はブラウザの中に保存され、サーバーには送られません。

このツールは、実装を Claude Code に任せて作りました。私が用意したのは要件とデザインのモック(ハンドオフの資料)で、あとは画面を触って直してほしいところを伝える役です。この記事では、どういう設計で作ったかと、作ったあとで自分で使い倒して見つかった不具合を、実際のコードと一緒に書きます。

「量産ツール」として始めた

最初に Claude Code へ渡した要件の 1 行目は、次のとおりです。

チャット風動画メーカー。テキストを貼るとチャット風のトーク画面が通知音つきでリアルタイム再生される縦型MP4に。「怖い話」「別れ話の再現」系はTikTok/Shortsで定番需要があり、量産ツールとして刺さる。

ほかに書いたのは、テーマを選べること、画像や動画も送れること、ブラウザ内に保存すること、全体の再生速度を 2 倍や 3 倍にできること、そして「再生画面で確認した内容がそのまま MP4 で出力できること」です。スマホで使う人が中心なので、デザインは幅 390px を基準に作ってありました。5 種類のテーマはどれもオリジナルの配色で、実在するアプリの画面を複製したものではありません。

2026-07-14 の朝に要件を渡すと、Claude Code は技術の選択肢を 2 つ聞いてきました。画面の構成は React と Vite と TypeScript、動画の書き出しは「WebCodecs を優先し、使えないブラウザは MediaRecorder で録画する」で、どちらも推奨された案をそのまま選んでいます。実装はその日の午前中にひととおりでき、午後は触りながら直す時間になりました。

公開は 07-24 です。ただ、git で管理し始めたのは 08-29 で、それまでの変更は履歴に残っていません。SEO の対応を頼んだついでに「git管理してなかったですね」と気づいて、そこで初めてコミットしました。

画面の状態は、時刻だけで決める

要件のうち設計をいちばん縛ったのは「確認した内容がそのまま MP4 になる」です。Claude Code の計画には、これを満たすために、ある時刻の画面を時刻だけから決める純粋な関数を作り、再生と書き出しの両方でそれを使う、と書かれていました。

そのために、まず会話を「時間の流れ」に変換します。会話のデータはメッセージの配列で、それぞれが「前のメッセージから何秒後に届くか」を持っています。これを「何秒の時点で届くか」の一覧(タイムライン)に直します。

  for (const m of story.messages) {
    const user = byId.get(m.userId) ?? FALLBACK_USER;
    const side = sideFor(story, user);
    const gap = Math.max(0.05, m.delaySec) / speed;
    const appearAt = t + gap;
    let typingFrom: number | undefined;
    if (side === 'other' && !m.notice) {
      const typingLen = Math.min(TYPING_MAX_SEC / speed, gap * TYPING_RATIO);
      if (typingLen > 0.15) typingFrom = appearAt - typingLen;
    }
    let skipLabel: string | undefined;
    let skipAt: number | undefined;
    if (m.skipMin && m.skipMin > 0) {
      clockMin += m.skipMin;
      skipLabel = m.skipLabel?.trim() || formatSkip(m.skipMin);
      skipAt = t + gap * SKIP_AT_RATIO;
    }
    elapsedForClock += Math.max(0.05, m.delaySec);
    const clock = formatClock(clockMin + Math.floor(elapsedForClock / 60));
    entries.push({ message: m, user, side, appearAt, typingFrom, clock, skipLabel, skipAt });
    t = appearAt;
  }

  return { entries, duration: entries.length ? t + (story.tailSec ?? TAIL_SEC) : 0 };

相手側のメッセージには、届く直前に「入力中…」の表示を出します。長さは最大 1.6 秒で、前のメッセージとの間隔の 7 割までに抑え、ちょうど届く瞬間に終わるように逆算しています。画面上部の時計は、作中の時刻に経過秒数を分に直して足したものです。時計の計算だけは再生速度で割らない値を使っているので、2 倍速で見ても作中の時間の進み方は変わりません。

タイムラインができれば、時刻 t に何を表示すべきかは t だけで決まります。

/** 時刻 t (秒) の表示状態。リアルタイム再生と書き出しの両方がこれを使う */
export function stateAt(timeline: Timeline, t: number): PlaybackState {
  let visibleCount = 0;
  let typingIndex = -1;
  let pendingSkipIndex = -1;
  for (let i = 0; i < timeline.entries.length; i++) {
    const e = timeline.entries[i];
    if (t >= e.appearAt) {
      visibleCount = i + 1;
    } else {
      if (e.typingFrom !== undefined && t >= e.typingFrom) typingIndex = i;
      if (e.skipAt !== undefined && t >= e.skipAt) pendingSkipIndex = i;
      break;
    }
  }
  return { visibleCount, typingIndex, pendingSkipIndex };
}

画面で再生するときは requestAnimationFrame で積み上げた経過時間を、書き出すときはフレーム番号を 30 で割った値を t に渡します。前回の状態を覚えていないので、どちらから呼んでも同じ時刻なら同じ画面になります。

吹き出しは Canvas に描く

再生と書き出しの画面は、HTML ではなく Canvas に直接描いています。これも計画の段階で決まっていたことで、ハンドオフの資料が「Canvas/WebCodecs」を推奨していたのと、上の stateAt() を書き出しと共有するには、描画も 1 つの関数にまとめる必要があったからです。テーマの色や余白の定義も 1 か所に置き、編集画面の HTML と Canvas の両方がそれを読みます。計画には「二重実装によるプレビューと出力のズレを防ぐ」とありました。

描くときは、デザインと同じ幅 390 の座標で配置を計算し、最後に 1080 幅まで拡大します(約 2.77 倍)。

  ctx.save();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.scale(canvas.width / LOGICAL_W, canvas.width / LOGICAL_W);

  // 背景
  ctx.fillStyle = cfg.chatBg;
  ctx.fillRect(0, 0, LOGICAL_W, LOGICAL_H);
  // ...
  const areaTop = HEADER_H;
  const areaH = LOGICAL_H - HEADER_H - COMPOSER_H;
  const totalH = rows.reduce((a, r) => a + r.height, 0) + CHAT_PAD_V * 2;
  // 新しいメッセージが常に見えるよう下端に寄せる
  const offset = Math.min(0, areaH - totalH);

  ctx.save();
  ctx.beginPath();
  ctx.rect(0, areaTop, LOGICAL_W, areaH);
  ctx.clip();
  let y = areaTop + CHAT_PAD_V + offset;
  for (const row of rows) {
    row.draw(ctx, y);
    y += row.height;
  }
  ctx.restore();

デザインの数値(吹き出しの角の丸みや余白)を変換せずにそのまま使えるのが、この座標系の利点です。メッセージが画面に収まらなくなったら、全体を上にずらして、いちばん新しいメッセージを下端に合わせます。offset を毎フレーム計算し直しているだけで、スクロールの状態は持っていません。

Canvas の fillText() は自動で改行しないので、吹き出しの幅に収まるように 1 文字ずつ足しては幅を測っています。

function wrapText(
  ctx: CanvasRenderingContext2D,
  text: string,
  maxWidth: number,
  font: string,
): string[] {
  ctx.font = font;
  const lines: string[] = [];
  for (const para of text.split('\n')) {
    let line = '';
    for (const ch of para) {
      if (line && ctx.measureText(line + ch).width > maxWidth) {
        lines.push(line);
        line = ch;
      } else {
        line += ch;
      }
    }
    lines.push(line);
  }
  return lines.length ? lines : [''];
}

日本語はどの文字の間でも基本的に改行できるので、この単純な方法で足ります。行頭に「。」が来るのを避ける処理(禁則処理)はまだ入れていません。

動画メッセージは、静止画で出す予定だった

ハンドオフの仕様では、送った動画は先頭のフレームを切り出した静止画として表示することになっていました。計画にも「インライン再生はしない」と書かれています。

実装を触ってみると、送った動画は再生されませんでした。その日の午後に伝えたのは「可能なら自動再生で自動ループにして欲しいです。無理、もしくは実装が難しいなら実装せずその旨を教えてください」です。

難しいのは書き出しのほうです。プレビューでは <video> 要素を再生させたまま毎フレーム描けば済みますが、書き出しは実時間とは無関係に 1 フレームずつ進むので、動画が勝手に進んでいては困ります。そこで書き出しの間は動画の再生を止め、フレームごとに目的の時刻へシークしてから描くようにしました。添付した動画は会話が終わるまで繰り返し再生される扱いなので、届いてからの経過時間を動画の長さで割った余りを使います。

export async function syncVideosForExport(fc: FrameContext, t: number): Promise<void> {
  const jobs: Promise<void>[] = [];
  for (const entry of fc.timeline.entries) {
    if (entry.message.kind !== 'video' || t < entry.appearAt) continue;
    const el = fc.assets.media.get(entry.message.id);
    if (!(el instanceof HTMLVideoElement) || !el.duration || !isFinite(el.duration)) continue;
    const local = (t - entry.appearAt) % el.duration;
    jobs.push(seekVideo(el, local));
  }
  await Promise.all(jobs);
}

書き出しのループには、このシークを 1 行足すだけで済みました。

+  // 動画は frame ごとにシークして合わせるため自然再生は止める
+  pauseVideos(frameContext.assets);
 ...
-      renderFrame(canvas, frameContext, frame / FPS);
-      await videoSource.add(frame / FPS, 1 / FPS);
+      const t = frame / FPS;
+      await syncVideosForExport(frameContext, t);
+      renderFrame(canvas, frameContext, t);
+      await videoSource.add(t, 1 / FPS);

確認のために、Claude Code はブラウザの中で赤一色の短い動画を作って添付し、書き出した MP4 をデコードして、動画の枠の位置にその赤が出ていることを調べていました。同じ日に、画像や動画の枠が 190×122 の固定で切り抜かれていたのも、素材の縦横比に合わせるように変えています。

通知音と、MP4 への書き出し

メッセージが届いたときの「ポコッ」という音は、音声ファイルを使わず Web Audio のオシレーターで合成しています。

  if (preset === 'poko') {
    // 短い水滴風: サイン波 660→330Hz
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = 'sine';
    osc.frequency.setValueAtTime(660, t0);
    osc.frequency.exponentialRampToValueAtTime(330, t0 + 0.09);
    gain.gain.setValueAtTime(0.0001, t0);
    gain.gain.exponentialRampToValueAtTime(0.35, t0 + 0.008);
    gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.13);
    osc.connect(gain).connect(dest);
    osc.start(t0);
    osc.stop(t0 + 0.15);

音の高さを 0.09 秒で 660Hz から 330Hz まで下げ、音量は 8 ミリ秒で立ち上げて 0.13 秒で消しています。exponentialRampToValueAtTime() は 0 を目標にできないので、0 の代わりに 0.0001 を使います。

この関数は引数に BaseAudioContext を取るので、AudioContext でも OfflineAudioContext でも同じ音が作れます。ただし使い方は、再生と書き出しで違います。画面での再生は、描画のループが新しく見えたメッセージを見つけた瞬間に、その場で鳴らしています。書き出しでは、タイムラインのすべての時刻に通知音を先に並べ、OfflineAudioContext で 1 本の音声トラックにしてから動画と合わせます。

/** 通知音をタイムラインどおりに敷いた AudioBuffer を合成する */
async function renderAudioTrack(story: Story, timeline: Timeline): Promise<AudioBuffer> {
  const sampleRate = 48000;
  const length = Math.max(1, Math.ceil(timeline.duration * sampleRate));
  const ctx = new OfflineAudioContext(2, length, sampleRate);
  if (story.sound.enabled) {
    for (const entry of timeline.entries) {
      if (entry.message.notice) continue; // お知らせは通知音なし
      scheduleSound(ctx, story.sound.preset, entry.appearAt);
    }
  }
  return ctx.startRendering();
}

プレビューと書き出しで同じ音の関数を通す作りは、ほかのサービスでも繰り返し使っています。音まわりの共通の話は「録画の音が45ms遅れても直さなかった理由と、ブラウザの音の罠」にまとめました。

映像は WebCodecs で H.264 にエンコードし、Mediabunny で MP4 にしています。フレーム番号から時刻を決めて 1 枚ずつ描くので、端末が遅くても、できあがる動画の動きは同じです。

    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);
      if (frame % 3 === 0 || frame === totalFrames - 1) {
        onProgress({ frame: frame + 1, totalFrames, ratio: (frame + 1) / totalFrames });
        // UIを固まらせない
        await new Promise((r) => setTimeout(r, 0));
      }
    }

ただし、これは WebCodecs が使えるブラウザの話です。使えないブラウザでは、Canvas を captureStream() で MediaRecorder に流し、実際の時間で 1 回再生しながら録画します。

    const tick = () => {
      // ...
      const elapsed = (performance.now() - startedAt) / 1000;
      // ...
      renderFrame(canvas, frameContext, elapsed);
      const frame = Math.min(totalFrames, Math.floor(elapsed * FPS) + 1);
      onProgress({ frame, totalFrames, ratio: frame / totalFrames });
      requestAnimationFrame(tick);
    };

こちらは時刻を performance.now() から取るので、端末が描画に追いつかなければフレームが落ちます。「確認した内容がそのまま MP4 になる」が厳密に成り立つのは WebCodecs の経路だけです。形式もブラウザ次第で、MP4 を録れなければ WebM になり、画面にその旨を出しています。

Mediabunny は 350kB ほどあり、最初は起動時に読み込んでいました。公開前に、書き出しを押したときに import() で読み込むように変え、最初に読み込むファイルを 501kB から 311kB に減らしています。

      // 重いエンコーダ(mediabunny)は書き出し時のみ読み込む(初回表示を軽くする)
      const { exportVideo } = await import('../export/exportVideo');

方式の選び方やブラウザごとの違いは、4 つのサービスを比べた「ブラウザでMP4を書き出す4通りの実装と、Safariが黙って止まる罠」に詳しく書いています。

画像と動画は IndexedDB にしまう

会話の文字は Zustand の persist で localStorage に保存していますが、添付した画像や動画はそこに入りません。画像と動画は IndexedDB に Blob のまま保存し、メッセージには鍵だけを持たせています。

// メディア(画像/動画のBlob)は LocalStorage に入らないため IndexedDB に保存する。
// Message.mediaRef がここのキー。
const store = createStore('chat-theater-media', 'blobs');

export async function putMedia(blob: Blob): Promise<string> {
  const ref = newId();
  await set(ref, blob, store);
  return ref;
}

会話を複製したときは画像を複製せず、同じ鍵を共有します。そのため会話を消すときは、ほかの会話から参照されていない画像だけを IndexedDB から消しています。

自分で 120 本作って、やっと直ったバグ

公開から 2 か月ほどたった 09-27 に、このツールで実際に動画を量産してみることにしました。Claude Code に「意味が分かると怖い話」の台本を 20 本書いてもらい、続けてこう頼みました。

このアプリと先程の台本を使って、20本分動画を作成してください。…作ってる最中にバグを見つけた場合は修正してください

Claude Code は Playwright でヘッドレスの Chrome を動かし、実際の画面に台本を貼り付けて、1 本ずつ書き出しボタンを押していきました。翌日にはジャンルを 5 つ増やし、最終的に 120 本を書き出しています。この過程で、貼り付け機能の不具合がまとめて直りました。

全角コロンが区切りとして認識されない

貼り付ける台本は 名前: セリフ の形で、名前とセリフをコロンで区切ります。日本語で台本を書くと、コロンはたいてい全角の : になります。ところが、全角で書いた行が分割されませんでした。

不思議なのは、該当するコードのコメントに、全角にも半角にも対応すると書いてあったことです。

    // 「名前: セリフ」形式(全角/半角コロン対応)。形式外の行は直前の発言者の続きとして無視せず名前なし扱い
    const m = line.match(/^(.+?)[::]\s*(.+)$/);

文字クラス [::] は、ぱっと見では全角と半角のコロンが 1 つずつ並んでいるように読めます。Claude Code が od -c でこの行のバイト列を表示させたところ、中身は半角のコロンが 2 つでした1。

0000040    +   ?   )   [   :   :   ]   \   s   *   (   .   +   )   $   /

全角の : は UTF-8 では 3 バイトなので、od -c で見れば 1 文字ではなく 3 つのバイトとして出てきます。ここにあるのは 1 バイトの : が 2 つだけです。コメントは意図を正しく書いていて、コードだけがその意図からずれていました。パーサーを別のファイルに切り出すときに、正しい文字クラスに直しています。

    const m = line.match(/^(.+?)[::]\s*(.+)$/);

あわせて、[開始 21:30] の時刻の区切りや、角括弧そのものも全角で書けるようにしました(/^[[[](.+)[\]]]$/)。

貼り付けた全員が相手側になる

貼り付けた台本に新しい名前があると、ユーザーとして自動で登録します。最初に登場した名前を自分側(右)、残りを相手側(左)にするつもりでしたが、実際には全員が左に並びました。当時の判定はこうです。

        defaultSide: byName.size === 0 && createdCount === 0 ? 'self' : 'other',

この条件は「登録済みのユーザーが 1 人もいないとき」だけ真になります。初期データのチュートリアルに 2 人のユーザーがいるので、ほとんどの環境では最初から偽でした。

実は、この不具合は 09-02 の時点で見えていました。宣伝用の台本を 3 本作ってもらったとき、Claude Code は「すでにユーザーがいる環境だと全員が相手側(左)になるので、主役だけ『ユーザー』画面で自分側に直してください」と、手作業で回避する方法を案内していました。待ち時間についても「貼り付け直後は全メッセージが1.5秒間隔です。各話の『間の調整』だけは必ず手で直してください」と書いています。3 本なら手で直せますが、20 本ではそうはいきません。量産を始めて、ようやくコードを直す理由ができたことになります。

左右の判定は、まず「このストーリーにまだ自分側がいなければ、最初に登場した新しい名前を自分側にする」に直しました。翌日、考察ホラーのシリーズを作るときに、これでも足りないことが分かります。同じ人物を、ある話では語り手(右)に、別の話では相手(左)に出したくなったのです。ユーザーの設定を書き換える方式では、それができません。そこで、左右の指定をユーザーではなくストーリーに持たせました。

/**
 * このストーリーでの表示側。story.selfIds があればそれが優先(ストーリーごとの指定)、
 * 無ければユーザーのデフォルト。同じ人物が話によって語り手(右)にも相手(左)にもなれるようにする
 */
export function sideFor(story: Pick<Story, 'selfIds'>, user: Pick<User, 'id' | 'defaultSide'>): UserSide {
  if (story.selfIds) return story.selfIds.includes(user.id) ? 'self' : 'other';
  return user.defaultSide;
}

台本に [自分 名前] と書けば、その話だけの指定になります。

待ち時間がすべて 1.5 秒

貼り付けたメッセージには、すべて既定の 1.5 秒が入っていました。短い相づちも長い独白も同じ間隔で届くので、長いメッセージは読み終わる前に次が来ます。

そこで、待ち時間を直前のメッセージの長さから決めるようにしました。

/** 直前のメッセージの長さから待ち時間を決める(読む時間 + 打つ間) */
export function autoDelay(prevText: string | undefined, sameSender: boolean): number {
  if (prevText === undefined) return 1.0;
  const len = [...prevText].length;
  const base = sameSender ? 0.6 : 0.9;
  const sec = base + len * 0.07;
  return Math.round(Math.min(4.5, Math.max(1.0, sec)) * 10) / 10;
}

次のメッセージが届く前に、見ている人はいま届いたメッセージを読み終えている必要があります。だから基準にするのは「次の」ではなく「直前の」メッセージです。文字数を [...prevText].length で数えているのは、絵文字のような 2 つの符号単位でできた文字を 1 文字と数えるためです。間を自分で決めたいところには [間 3] と書けます。

オチを読む前に動画が終わる

20 本の書き出しを走らせ始めてから、Claude Code は動画の終わり方がおかしいことに気づきました。最後のメッセージが届いてから 2 秒で動画が終わるので、オチの一文を読み切る前に切れてしまいます。怖い話は最後の一文が肝なので、これは致命的です。

書き出しをいったん止め、末尾で止まる時間をストーリーごとに持てるようにしました。

-  return { entries, duration: entries.length ? t + TAIL_SEC : 0 };
+  return { entries, duration: entries.length ? t + (story.tailSec ?? TAIL_SEC) : 0 };

台本の最後の行のあとに [間 5] と書くと、それが末尾の静止時間になります。20 本はすべてこの設定で書き出し直しました。

同じときに、作中の時刻も直しています。画面の時計はデザインのモックに合わせて 23:02 から始まる固定の作りで、09-02 の台本は話のほうを深夜の設定にして合わせていました。[開始 21:30] で開始時刻を、[30分後] や [翌朝] で時間の経過を書けるようにして、台本の時刻と画面の時計が食い違わないようにしています。

音の数を数えて確かめる

120 本も作ると、1 本ずつ再生して確かめるわけにはいきません。Claude Code は、書き出した MP4 の音声をヘッドレスの Chrome で decodeAudioData() し、波形のピークが立ち上がる回数を数えて、メッセージの数(お知らせを除く)と照合していました。最後にまとめて書き出した 100 本は、すべて一致しています。

公開と、pages.dev からの転送

公開は Cloudflare Pages で、chat-theater.tsuyatt.com という独自ドメインを付けています。最初の URL である chat-theater.pages.dev も残るので、そちらに来たアクセスを独自ドメインへ転送する必要がありました。

Claude Code が最初に入れたのは、index.html の中の JavaScript による転送です。私がほしかったのは HTTP の 301 で、別のサービス(入稿前チェッカー)ではそうしていたはずだと伝えました。そちらのコードを参照して、Pages Functions のミドルウェアで 301 を返す形に直しています。同じ形はその後ほかのサービスにも広げていて、その経緯は「Cloudflare Pagesで9サイトを無料で回して踏んだ罠」に書きました。

まだ確かめていないこと

iPhone の Safari では確かめていません。120 本の書き出しもすべてヘッドレスの Chrome で行ったもので、ほかのブラウザで MP4 の書き出しがどう振る舞うかは、記録がありません。

  1. od -c はファイルの中身を 1 バイトずつ文字として表示するコマンドです。見た目の似た文字の取り違えを調べるのに向いています。 ↩

← 記事一覧へ