# M1〜M4 実装契約（Contract v0）

- 日付: 2026-07-13
- 状態: `edit.json スキーマ v0` は現行モノレポでも正典として有効。M1〜M4 の Tauri/IPC
  実装詳細（旧 `akari-video-tauri` シェルの契約記録）は文末の「実装ノート（legacy）」に
  隔離した — 現行実装の担当分割・IPC 相当の設計は移送先パッケージ（`packages/`）側の
  契約を参照
- 前提: `design-2026-07-13-agent-native-architecture.md`（必読）、M0 完了済み（legacy）

## edit.json スキーマ v0（セーブデータ = SSOT）

```jsonc
{
  "version": 0,
  "output": { "width": 1280, "height": 720, "fps": 30 },
  "source": {
    "path": "sample.mp4",        // edit.json からの相対 or 絶対
    "proxy": null                 // 720p プロキシ（null = 原本を使う）
  },
  "cuts": [                       // keep-range（残す区間）。source 時間・秒
    { "in": 5.0, "out": 10.0 },   // 昇順・非重複。空/欠落 = 全体
    { "in": 30.0, "out": 35.0 }
  ],
  "overlays": [
    {
      "id": "cap-a",              // 一意
      "html": "overlays/cap-a.html",  // edit.json からの相対
      "start": 1.0,               // タイムライン時間（カット連結後）・秒
      "duration": 4.0,
      "transform": { "x": 0, "y": -80, "scale": 1, "rotate": 0 },  // px / 倍 / deg
      "vars": { "--color": "#ffffff" }  // ルート要素に適用する CSS 変数の上書き
    }
  ]
}
```

- **座標系**: `transform.x/y` は **output.width/height（出力動画解像度、例 1280×720）基準の
  ピクセル座標**であり、プレビュー表示上の px ではない。`#overlay-stage` は output 解像度の
  論理サイズで舞台生成し、プレビューへは `scale()` で貼り付ける（プレビューと書き出しで
  同一座標系を共有する）。レンダラ `scripts/render-overlays.mjs` がこの座標系の実装上の SSOT
- **時間系**: `cuts` は source 秒。`overlays.start` は**タイムライン秒**（カット連結後の出力時間軸）
- **オーバーレイ HTML 断片**: ルート要素は必ず 1 つ。`<style>` を含んでよい（セレクタは
  フラグメント内で閉じる）。authoring 規約（CLAUDE.md）に従い調整値は CSS 変数・
  アニメは transform/opacity 中心・時間駆動決定的
- **幾何はコンテナ、見た目は断片**: transform と vars は**ランタイム所有のコンテナ**
  （断片を包む要素）に CSS 変数 `--x` `--y` `--scale` `--rotate` として適用し、コンテナに
  `transform: translate(var(--x), var(--y)) scale(var(--scale)) rotate(var(--rotate))` を張る。
  断片ルートには触れない（断片が自前 transform を持っていても幾何操作が常に効く =
  設計 §5 の保証ライン）。vars は CSS 継承で断片内の `var(--color)` 等にも届く

## 実装ノート（legacy — 旧 Tauri シェル実装の契約記録。参考情報）

> 以下は旧 `akari-video-tauri` シェル（Rust/Tauri v2 + `ui/` の素の JS）における
> M1〜M4 実装ラウンドの契約記録をそのまま保存したもの。ファイル所有権・IPC コマンド・
> Rust/JS の実装詳細など Tauri 固有の構造を含み、現行モノレポの実装に直接は適用されない。
> 上記 `edit.json スキーマ v0` の設計意図・座標系/時間系の根拠づけとして参考に残す。
> 完全な記録は legacy リポ `akari-video-tauri/docs/planning/contract-2026-07-13-m1-m4.md`
> に残置されている。

### ファイル所有権（並列実装の衝突防止）

| 担当 | 所有（自由に編集） | 編集禁止 |
|---|---|---|
| M1 | `src-tauri/src/video_plane/`（`edit.rs` 含む）、`src-tauri/Cargo.toml` | `ui/`、`src-tauri/src/export/` |
| M2 | `ui/overlay-runtime.js` | `src-tauri/`、`ui/interaction.js`、`ui/main.js` |
| M3 | `ui/interaction.js`、`ui/interaction.css` | `src-tauri/`、`ui/overlay-runtime.js` |
| M4 | `src-tauri/src/export/`、`scripts/`（export 関連） | `ui/`、`src-tauri/src/video_plane/`（**読み取りと `edit.rs` の型利用は可**） |

共有ファイル（`src-tauri/src/lib.rs` / `ui/main.js` / `ui/index.html` / `ui/style.css`）は
スケルトンで結線済み。**原則編集禁止**。どうしても必要な最小変更は成果報告に明記する。

### IPC コマンド契約（lib.rs にスタブ登録済み）

| コマンド | 引数 → 返り値 | 担当 |
|---|---|---|
| `video_load(path)` | 単一動画ロード（M0、既存） | 済 |
| `video_play` / `video_pause` | （M0、既存） | 済 |
| `video_toggle()` | → `bool`（再生中か） | M1 |
| `video_seek(seconds)` | タイムライン秒。精密シーク（tolerance zero） | M1 |
| `video_status()` | → `{ t, duration, playing }`（タイムライン秒） | M1 |
| `edit_load(path)` | edit.json ロード → AVMutableComposition 構築 → `EditSummary` | M1 |
| `export_start(edit_path, out_path)` | 非同期開始。進捗はイベント | M4 |
| `overlay_write(edit_path, overlay_id, patch)` | edit.json / HTML の read-modify-write | M1（`edit.rs`） |
| `autoload_path()` / `debug_flags()` | 検証用 env 読み出し（既存/スケルトン） | 済 |

- `EditSummary`: `{ duration, cuts: usize, overlays: [{ id, html, html_path, start, duration, transform, vars }] }`
  （`html` = **断片の内容そのもの**。WebView は file を fetch できないため `edit_load` が
  Rust 側で読み込んで同梱する。`html_path` は書き戻し先の絶対パス）
- `OverlayPatch`: `{ transform?, vars?, start?, duration?, html? }` — `html` があれば
  オーバーレイ HTML ファイルを全置換（テキスト編集の書き戻し）。他は edit.json を更新。
  **人間の操作は必ずデータに着地**（設計 §5）の実装点
- 型定義は `src-tauri/src/video_plane/edit.rs`（スケルトンに serde 構造体を定義済み。
  これが契約のコード形。M4 も `use crate::video_plane::edit::*` で共有する）

#### イベント（Rust → JS、`app.emit`）

| イベント | payload | 発行元 |
|---|---|---|
| `export:progress` | `{ stage: string, pct: f32 }` | M4 |
| `export:done` | `{ out_path }` | M4 |
| `export:error` | `{ message }` | M4 |

再生時刻の通知は**イベントではなくポーリング**: フロント（main.js、結線済み）が
`video_status()` を 100ms 間隔で叩き、ポーリング間は `performance.now()` で内挿する。
プレビューは近似で良い（±数十 ms 許容、正確さは書き出しが持つ）。

### M1 — 映像プレーン（AVMutableComposition）

- `edit_load`: edit.json をパース → source（proxy 優先）から `AVURLAsset` →
  cuts の keep-range を `AVMutableComposition` に `insertTimeRange` で連結（映像+音声トラック）
  → 既存 AVPlayer のアイテムを差し替え。カット境界はギャップレス
- `video_seek`: `seekToTime:toleranceBefore:toleranceAfter:` で kCMTimeZero tolerance
- `video_status`: `currentTime()` / composition duration / `rate() > 0`
- M0 の `video_load` / play / pause は壊さない（単一ファイル再生も残す）
- Cargo.toml への依存追加は M1 のみ許可（`objc2-core-media`（CMTime）等は追加済み）
- 再生終端: 終端で pause 扱い（`video_status.playing = false` になれば良い。黒画面回避は
  actionAtItemEnd = pause を明示）

### M2 — オーバーレイランタイム（ui/overlay-runtime.js）

`window.akari.runtime` として公開（スケルトンに API 形だけ定義済み）:

- `mount(summary)`: `EditSummary.overlays` を DOM 化。各要素の `html`（内容同梱済み、
  fetch 不要）を `#overlay-stage` 配下に 1 オーバーレイ = 1 コンテナで注入。コンテナに
  `data-overlay-id` / `data-start` / `data-duration` を付与し `pointer-events: auto` に
  する（stage 自体は none）。transform / vars を CSS 変数としてルートに適用
- `tick(t, playing)`: タイムライン秒 t で可視状態を更新（start ≤ t < start+duration で表示）。
  フラグメント内の CSS アニメ / Web Animations は **`getAnimations()` で currentTime を
  `(t - start) * 1000` に設定**して決定的に同期（wall-clock 禁止、シークで任意時刻再現）
- 表示/非表示は `visibility` + アニメ pause/currentTime で行い、DOM は保持（layout 揺れ防止）
- `unmount()`: 全消去
- 規約: 4K 上の blur/backdrop-filter を注入側で検査はしない（authoring 側の責務）が、
  ランタイム自身のスタイル操作は transform/opacity/visibility に限る

### M3 — インタラクション層（ui/interaction.js）

`window.akari.interaction` として公開:

- ランタイムがマウントしたオーバーレイコンテナ（`[data-overlay-id]`）に対して:
  - クリックで選択（選択ハンドル表示。スタイルは `ui/interaction.css`）
  - ドラッグで移動 → **コンテナの** `--x` / `--y` を更新（ライブ）、pointerup で
    `overlay_write(edit_path, id, { transform })` により永続化
  - ダブルクリックでテキスト要素を `contenteditable` 化 → blur/Enter で確定 →
    `overlay_write(..., { html })`（フラグメント全体のシリアライズ）
  - Escape で選択解除
- **合成 PointerEvent で動作すること**（`isTrusted` チェック禁止）— 無人検証が
  synthetic drag を使う
- `selftest()`: 最初のオーバーレイに対し select → 60px 右へ synthetic drag → 永続化、
  を実行し `{ ok, detail }` を返す（main.js が `debug_flags().selftest == "m3"` のとき
  ロード 3 秒後に呼び、結果を `#status` に表示する — 結線済み）

### M4 — 書き出しパイプライン（src-tauri/src/export/）

`export_start(edit_path, out_path)`（非同期、std::thread + イベント発行）:

1. **カット**: ffmpeg で source（**原本**、proxy ではない）から keep-range を切り出し連結
   （filter_complex trim+concat か segment 方式。再エンコード可、H.264 High + AAC）
2. **オーバーレイレンダ**: HyperFrames（https://github.com/heygen-com/hyperframes）を
   「手」として使い、オーバーレイ HTML（data-start/data-duration 付与済みの合成シート）を
   透過連番 or 透過動画にレンダ
   - HyperFrames が導入不能/不適合なら**フォールバック**: headless Chrome
     （puppeteer-core + システム Chrome）で自前フレームキャプチャ。それも不能なら
     静的スクショ + `overlay enable=between(t,..)` の静的近似（M0 実験と同型）。
     **どの手段を使ったか・制約を成果報告に必ず書く**
3. **合成**: ffmpeg overlay で 1 に 2 を重ね `out_path` へ
4. 各段階で `export:progress` を emit、完了で `export:done`
- 中間物は `out_path` と同階層の `.akari-export-tmp/` に置き、成功時に削除
- ローカル ffmpeg 8.1.1 には **drawtext/libass が無い**（テキストは必ず HTML 経由）
- ネットワーク不可で npm 導入等ができない場合は、必要パッケージを成果報告に明記
  （統合エージェントが導入する）

### 検証資材

`scripts/make-sample.sh`（スケルトンに作成済み）が `examples/local/` に生成:
- `sample.mp4`: testsrc（タイマー内蔵）1280x720/30fps/60 秒 + 440Hz 音声
- `edit.json`: cuts `[[5,10],[30,35]]`（計 10 秒）、オーバーレイ
  cap-a（start=1, dur=4, 「字幕A」）/ cap-b（start=6, dur=4, 「字幕B」）
- 期待挙動: タイムライン 0-5 秒でソースタイマー 5→10、5-10 秒で 30→35（境界で跳ぶ）。
  前半に字幕A、後半に字幕B

検証用 env（`debug_flags()` / main.js 結線済み）:
- `AKARI_VIDEO_AUTOLOAD=<mp4>`: 単一動画自動ロード（M0）
- `AKARI_VIDEO_AUTOLOAD_EDIT=<edit.json>`: `edit_load` 自動実行 + 再生
- `AKARI_VIDEO_SELFTEST=m3`: ロード 3 秒後に `interaction.selftest()` 実行

キーボード（main.js 結線済み）: Space = toggle、←/→ = ±5 秒シーク

### 完了定義（各担当共通）

- `cargo build`（M1/M4）/ 構文エラーなし（M2/M3、`node --check`）が通る
- 自分の担当範囲の動作を可能な限りローカルで確認し、**やったこと・確認したこと・
  未確認事項・契約逸脱**を成果報告に書く
- コミットはしない（統合エージェントが行う）

### 追記（2026-07-14）— M3 拡縮ハンドル

オーナー要望（ビューワー UI ラウンド）により、選択ハンドルでの **uniform scale 変更**を
M3 の正式機能に加える。詳細仕様は
`notes-2026-07-14-viewer-ui-round.md` §3 を参照（本追記は要点のみ。当該 notes ファイルは
本移送の対象外のため legacy リポにのみ存在する）。

- 対象は `ui/interaction.js` / `ui/interaction.css`（既存の M3 所有範囲のまま。
  ファイル所有権表は変更しない）
- 選択枠の四隅ハンドル（`ui/interaction.css` に既存。現状は表示のみで
  `pointer-events: none`）をドラッグ可能にし、コンテナ中心からの距離比で
  **uniform scale**（縦横比固定）を計算する
- 書き戻し規約は既存のドラッグ移動と同一: ライブ中はコンテナの `--scale` を更新、
  `pointerup` で `overlay_write(edit_path, overlay_id, { transform })` により
  edit.json の `transform.scale` へ確定する（`--x`/`--y`/`--rotate` は拡縮中に
  変更しない）
- クランプ範囲: 0.2〜4.0（字幕/ロワーサード等の小さいオーバーレイ要素を想定した範囲。
  動画キャンバス全体の拡縮とは別ユースケースのため広すぎるレンジは取らない）
- 合成 PointerEvent（`isTrusted` チェック禁止）で動作すること。既存 `selftest()` への
  拡縮ドラッグ検証の追加は任意（必須の完了条件には含めない）
