# P2 — コンクールルーム同期（REST + WebSocket）

**ロードマップ:** [roadmap-p2-p3.md](./roadmap-p2-p3.md) · **要件:** [remote-singing.md](../features/remote-singing.md)

8782 Work API + 8780 Player（S1〜S6）。

| スライス | 状態 |
|----------|------|
| **S1** API / WS | 実装済 · `npm run smoke:contest-s1` |
| **S2** Player ロビー UI | 実装済 · `contestSession` Work でサイドパネル |
| **S3** `go.startAtMs` → 親 transport | 実装済 · `schedulePlayAtMs` |
| **S4** テイク upload | 実装済 · `POST .../takes` · `npm run smoke:contest-s4` |
| **S5** 分割プレビュー | 実装済 · `contest-result.html`（クライアント同期） |
| **S6** バトンリレー | 実装済 · `mode: relay` · `smoke:contest-s6` |

---

## 役割分担

| 層 | 担当 | スライス |
|------|------|----------|
| ルーム · `countdown` / `go` / `startAtMs` | **Platform WS（8782）** | S1 |
| ロビー UI · 参加 · カウントダウン表示 | **Player（8780）** | S2 |
| `startAtMs` で親 transport / Karaoke・CDS3D 追従 | **Player + embeds** | S3 |
| 録画 · コンクールテイク upload | **Karaoke → Contest API** | S4 |
| 分割プレビュー（音声パネル · canvas） | **Player / contest-result** | S5 |
| バトンリレー（順番 · ブラインドガイド · 24h） | **API + Player** | S6 |

マスタークロックの正本は **Platform Contest WS** の `go.startAtMs`。親 transport がそれに合わせて `play` し、embeds は既存の `muvic:transport` / `clockSync` で追従する。

---

## 前提

- Work に `remoteSinging.contestSession: true`（例: `work_demo_001`）
- ログイン — `POST /auth/session` → `Authorization: Bearer <token>`
- データ: `contest-sessions.json` · `contest-takes.index.json`（gitignore）· 音声は `uploads/contest/`
- Player は `?api=1`（Work API）必須

---

## REST

| Method | Path | 認証 | 説明 |
|--------|------|------|------|
| `POST` | `/contest/sessions` | 必須 | ルーム作成（ホストが participants[0]） |
| `POST` | `/contest/sessions/:roomId/join` | 必須 | ロビー参加 |
| `GET` | `/contest/sessions/:roomId` | 不要 | 状態 + `takes[]` |
| `POST` | `/contest/sessions/:roomId/countdown` | 必須 · **ホストのみ** | 3,2,1 → `go` を WS 配信 |
| `POST` | `/contest/sessions/:roomId/takes` | 必須 · 参加者 | 音声テイク（phase `recording`/`done`） |
| `GET` | `/contest/sessions/:roomId/takes` | 不要 | ルームのテイク一覧 |
| `GET` | `/works/:workId/contest-takes` | 不要 | Work 横断のコンクールテイク |
| `GET` | `/assets/contest/:roomId/:file` | 不要 | テイク音声 |

### `POST /contest/sessions` body

```json
{
  "workId": "work_demo_001",
  "mode": "parallel",
  "maxParticipants": 4
}
```

- `mode`: `parallel`（既定）\| `relay`（バトン · S6）
- **201** — `session` オブジェクト（`wsUrl` 含む）

### `POST .../takes` body（S4）

```json
{
  "audioBase64": "...",
  "mimeType": "audio/webm",
  "syncAnchorMs": 1200,
  "clientReportedStartAtMs": 1794238669628,
  "deviceClockOffsetMs": 50
}
```

- **参加者のみ** · `phase` は `recording` または `done` · `startAtMs` 必須
- 同一ユーザーは **上書き**（200）· 新規は **201**
- 全員分揃うと `phase: done`
- 動画チャンクは未対応（音声のみ · overlay と同上限 5MB）
- ワンテイク証明メタ: `sessionStartAtMs` · `clientReportedStartAtMs` · `deviceClockOffsetMs`

### エラー（代表）

| code | 意味 |
|------|------|
| `contest_not_allowed` | Work に `contestSession` なし |
| `room_full` | 定員超過 |
| `host_only` | countdown を非ホストが実行 |
| `room_not_in_lobby` | 参加は `phase: lobby` のみ |
| `not_participant` | テイク POST が非参加者 |
| `missing_start_at` | GO 前 |

---

## WebSocket

**URL:** `ws://127.0.0.1:8782/contest/ws?roomId=<id>&token=<muvic_auth_token>`

**Origin:** `http://127.0.0.1:8780` 等（ALLOWED_ORIGINS と同一）

### サーバー → クライアント

| type | フィールド | 意味 |
|------|------------|------|
| `state` | `room` | 公開セッション（`takeCount` 含む） |
| `countdown` | `seconds` | 3 \| 2 \| 1 |
| `go` | `startAtMs` | **Unix epoch ms** — 全員この時刻に再生開始 |
| `take` | `take` | テイク保存（S4） |
| `pong` | `t` | `ping` 応答 |
| `error` | `error` | 例 `host_only` |

### クライアント → サーバー

| type | 意味 |
|------|------|
| `ping` | キープアライブ |
| `startCountdown` | ホストのみ（REST `.../countdown` と同等） |

---

## フェーズ

`lobby` → `countdown` → `recording`（`startAtMs` 設定）→ `done`（参加者全員のテイク揃い）

カウントダウンは **1 秒間隔 × 3**、直後に `go`（`startAtMs = now + 400ms` バッファ）。

---

## Player / Karaoke（S2〜S4）

| ファイル | 役割 |
|----------|------|
| `apps/web/js/contest-client.js` | REST + WS URL · takes |
| `apps/web/js/contest-lobby-ui.js` | サイドパネル · `onContestGo` |
| `apps/web/js/player-transport.js` | `prepareContestStart` · `schedulePlayAtMs` |
| `apps/web/player.html` | `#contestPanel` · `muvic:session` に `contestRoomId` |
| Karaoke `app.js` | ルーム中は `POST .../takes`（重ね録り overlays ではなく） |

### S3 動作

1. `countdown`（seconds=3）→ 親 transport を **0 秒へ seek + pause**
2. 同時に Player → Karaoke `muvic:contestRecord` **start**（双方自動録画・カメラ ON 試行）
3. `go.startAtMs` → 待機してから `playFrom`（遅延時は経過秒から追従）
4. 曲終端（親 `pause` / 尺到達）→ Karaoke **自動で録画停止** → テイク upload

### S4 動作

1. Player が `muvic:session` で `contestRoomId` / `contestStartAtMs` を Karaoke へ
2. 自動（または手動）録画停止 → Karaoke が Contest takes API へ音声 POST + 動画チャンク
3. WS `take` · Work 詳細「コンクールテイク」に一覧

**手動確認:**

1. 2 人でルーム参加 → ホストが開始（手動録画ボタンは不要）  
2. カウントダウンで双方「コンクール自動録画を開始」→ GO で親再生  
3. 曲終了で自動停止 → トースト「コンクールテイク／動画を保存」  
4. `contest-result` で同期再生（映像 unmute）  

```powershell
npm run smoke:contest-s1
npm run smoke:contest-s4
```

（要 **8782 再起動**後の `npm run dev:api` · Karaoke は **8790 ハードリロード**）

---

## 分割プレビュー（S5）

| ファイル | 役割 |
|----------|------|
| `apps/web/contest-result.html` | 専用ページ |
| `apps/web/js/contest-split-preview.js` | ルーム選択 · 最大 4 パネル · 同期再生 · canvas メーター |
| Work 詳細 / Player ロビー | 「分割プレビュー」リンク |

**URL:** `contest-result.html?work=work_demo_001&room=<roomId>&api=1`

※ 音声のみのときは名前 + レベルメーター。`videoUrl` があるテイクは実写パネル（同期再生で unmute）。2 本以上で「分割合成して保存」。

**手動確認:** Work 詳細のコンクールテイク上のリンク、または  
http://127.0.0.1:8780/apps/web/contest-result.html?work=work_demo_001&api=1  
→ **▶ 同期再生**

---

## バトンリレー（S6）

| 項目 | 内容 |
|------|------|
| 作成 | `mode: "relay"` · 参加順 = 番順 |
| 期限 | `relayExpiresAt` = 作成 + **24h**（join / countdown / take 拒否） |
| 進行 | 現番のみ take · アップロード後 `relayTurnIndex++` · ロビーに戻る · 全員完了で `done` |
| 開始 | ホスト **または** 現番が countdown |
| ブラインド | `GET .../relay-guide` は **現番のみ** · 他者の `audioUrl` は `done` まで隠す |
| ガイド | 直前テイク URL → Player `muvic:session.overlayAudioUrl` → Karaoke |

```powershell
npm run smoke:contest-s6
```

**手動:** Player モード「バトン」→ 2 人参加 → A 開始・録画 → B にガイドトースト → B 開始・録画 → 分割プレビュー

---

## デモ

ステークホルダー通し脚本: [demos/stakeholder-p2-contest.md](../demos/stakeholder-p2-contest.md)

## 動画チャンク / 合成（S7 PoC）

| 項目 | 内容 |
|------|------|
| チャンク | `POST .../takes/:takeId/video-chunks`（256KB）→ `.../video-complete` |
| Karaoke | 音声テイク後、実写 blob をチャンク送信（`contestRoomId` 時） |
| 公開 | take に `videoUrl` · `hasVideo` |
| サーバー合成 | `POST .../composition` — **ffmpeg があれば** side-by-side、なければ **501** |
| クライアント合成 | `contest-result`「分割合成して保存」→ Canvas MediaRecorder → `composition/chunks` |
| 取得 | `GET .../composition` |

```powershell
npm run smoke:contest-video
```

（要 **8782 再起動** · Karaoke **8790 ハードリロード**）

## P2 完了後

- 署名 URL / R2 TTL · ffmpeg 本番パイプライン · ワンテイク証明強化
