# ガイド: OBS・自作のオーバーレイ

あなたは配信の字幕や、話者ごとの吹き出しを自分で作れます。文字おこしくんは Subscribe の WebSocket につなぐページのオリジンを制限していないので、あなたのページはどこに置いても（OBS のブラウザソースからも）Subscribe につなげます。

## 方法を選ぶ

| 方法 | 手間 | できること |
|---|---|---|
| Discord の `/link` の返答のボタンで開く、公式の配信用オーバーレイのリンク（`…#token=…&overlay=1`）を、そのまま OBS に入れる | なし | 公式のデザインで字幕を出せる。URL パラメータと OBS の「カスタム CSS」で表示・色・大きさを変えられる（[表示ページの URL パラメータと CSS](../viewer.md)）。`view=youtube` では、文字おこしくんが YouTube のライブチャットと同じ要素の形で発言を出すので、YouTube のコメント欄向けに公開されている CSS をそのまま使える |
| **自作の HTML + 参照実装**（このページ） | HTML 1 枚 | レイアウト・演出を自由に作れる |

## 自作の HTML（話者ごとに最後の一言を出す字幕）

次の 2 ファイルを同じ場所に置きます。

1. [mojiokoshi-subscribe.js](../examples/mojiokoshi-subscribe.js)（参照実装。中身を変えずに置く）
2. `overlay.html`（このページのコード）

OBS のブラウザソースに `https://<置いた場所>/overlay.html#token=<トークン>&server=wss://<host>/v1/subscribe` を入れます。トークンの取り方は[クイックスタート](../quickstart.md#1-接続先とトークンを手に入れる)を見てください。

```html
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<title>captions</title>
<style>
  html, body { margin: 0; background: transparent; }
  body { font: 700 36px/1.4 "Noto Sans JP", sans-serif; color: #fff; text-shadow: 0 0 6px #000, 0 0 2px #000; }
  #captions { position: fixed; left: 40px; right: 40px; bottom: 40px; display: grid; gap: 8px; }
  .line { transition: opacity .3s; }
  .line.partial .text { opacity: .7; }
  .line.speaking .name::after { content: " ●"; color: #6f6; }
  .name { color: #ffd54f; margin-right: .5em; }
  .status { position: fixed; top: 8px; right: 8px; font-size: 14px; opacity: .6; }
</style>
<div id="captions"></div>
<div class="status" id="status"></div>
<script type="module">
  import { subscribe, speakerName } from "./mojiokoshi-subscribe.js";

  // トークンは URL のフラグメント（#）に入れる。フラグメントは、ページを置いたサーバーのログや Referer に残らない。
  const params = new URLSearchParams(location.hash.slice(1));
  const token = params.get("token");
  const server = params.get("server");

  let frame = 0;
  const render = (state) => {
    // 描画は 1 フレームに 1 回にまとめる（partial は数百 ms ごとに届く）
    if (frame) return;
    frame = requestAnimationFrame(() => {
      frame = 0;
      const lastByKey = new Map(); // 話者（不明ならトラック）→ 最後の発話
      for (const u of state.utterances.values()) {
        const key = u.speaker.kind === "unattributed" ? `track:${u.trackId}` : u.speaker.speakerId;
        const last = lastByKey.get(key);
        if (!last || last.startedAt <= u.startedAt) lastByKey.set(key, u);
      }
      const lines = [...lastByKey.values()]
        .sort((a, b) => a.startedAt - b.startedAt)
        .slice(-3) // 最近話した 3 人まで
        .map((u) => {
          const line = document.createElement("div");
          line.className = `line${u.stage === "partial" ? " partial" : ""}${state.speaking.has(u.trackId) ? " speaking" : ""}`;
          const name = document.createElement("span");
          name.className = "name";
          name.textContent = speakerName(state, u); // HTML として解釈させない
          const text = document.createElement("span");
          text.className = "text";
          text.textContent = u.text;
          line.append(name, text);
          return line;
        });
      document.getElementById("captions").replaceChildren(...lines);
    });
  };

  if (!token || !server) {
    document.getElementById("status").textContent = "URL に #token=…&server=… を付けてください";
  } else {
    subscribe({
      url: server,
      token,
      onChange: render,
      onStatus: (status, detail) => {
        const labels = { connecting: "接続中…", reconnecting: "再接続中…", ended: "終了しました", failed: `接続できません（${detail?.code}）` };
        document.getElementById("status").textContent = labels[status] ?? "";
      },
    });
  }
</script>
</html>
```

- **`file://` で開くと、ブラウザが ES モジュールの読み込みを拒むことがあります。** 2 つのファイルは、GitHub Pages・Cloudflare Pages などの静的ホスティングか、ローカルの静的サーバー（`npx serve` など）に置いてください。1 ファイルにしたいときは、参照実装の中身を `<script type="module">` に貼り付けて、`import` の行と `export` を消します。
- `overlay.html` の背景は透明です。OBS のブラウザソースの幅・高さは配信の解像度に合わせ、「カスタム CSS」は空にしておきます。
- `overlay.html` は、`partial` を少し薄く表示し、発話が確定したら普通の濃さにしています。同じ発話は `utteranceId` で置き換わるので、行が増え続けることはありません（[改訂モデル](../revisions.md)）。
- `overlay.html` は、話している表示（●）を `speech_started` / `speech_ended`（参照実装では `state.speaking`）から作っています。

## セキュリティ

- **トークンはフラグメント（`#token=`）に入れる。** クエリ（`?token=`）に入れたトークンは、ページを置いたサーバーのアクセスログや `Referer` に残ります。
- **API キーをページに埋め込まない。** 配信画面は他人も見るので、ページには `transcript:read` のセッショントークンだけを渡します。
- 表示名と発言は `textContent` で入れる（`innerHTML` を使わない）。
- 文字おこしくんには、トークンを 1 つずつ取り消す仕組みが無いので、自分で発行するトークンの有効期間は、必要な長さより長くしない（[セッショントークンの有効期間](../limits.md#セッショントークンの有効期間)）。
- Discord のリンクを配信に映してしまったときは、使い方の[表示ページと OBS](../../help/obs.md#get-link)の手順で、文字起こしを止めて始め直します。

## よくある作り方

| 作りたいもの | ヒント |
|---|---|
| 1 行の字幕（テロップ） | 全話者の中で `startedAt` が最新の発話を 1 つだけ出す |
| 話者ごとの定位置の吹き出し | 話者の登場順（`state.speakers` の挿入順）で位置を決める。`unattributed` の発話は、トラック単位で出す |
| 過去ログのスクロール | `sortedUtterances(state)` を下から積む。`partial` は最後に来る |
| アバター付き | `welcome.speakers[].speaker.avatarUrl` と `speaker_upserted` の `speaker.avatarUrl` を画像に使う（http / https の URL だけが届く） |

## コーディングエージェント向けのプロンプト

```text
OBS のブラウザソースで使う、文字おこしくんのリアルタイム字幕オーバーレイ（HTML 1 枚 + 参照実装 1 ファイル）を作ってください。
仕様: https://moji.kaitoranma.com/docs/llms.txt
参照実装: https://moji.kaitoranma.com/docs/examples/mojiokoshi-subscribe.js（そのまま同じフォルダに置いて import する）

- 接続先とトークンは location.hash の server= と token= から読む（クエリには入れない）。API キーは使わない。
- 背景は透明。画面下に、話者ごとに最後の発話を最大 3 行、話者名つきで出す。partial は薄く、final / refined は普通に。
- 発話は utteranceId で置き換わる（参照実装の state.utterances は Map）。行を追記しない。
- 表示名・発言は textContent で入れる。描画は requestAnimationFrame で 1 フレーム 1 回にまとめる。
- 接続状態（再接続中・終了・失敗）を右上に小さく出す。
- 見た目は CSS 変数（文字色・影・大きさ）で変えられるようにする。
```
