---
name: supermovie-cut3
description: |
  動画の不要区間（無音・フィラー・脱線）を自動検出しカットするスキル。
  Silero VAD + transcript_fixed.json + Claude LLM分析の3層で高精度にカット判定。
  Remotion Sequence で必要区間のみ再生する構成を生成。
  「カット」「無音カット」「不要部分削除」「cut」と言われたときに使用。
argument-hint: [プロジェクトパス]
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent, AskUserQuestion
effort: high
---

# SuperMovie Cut — 動画自動カット

Senior video editor として、不要区間を精密に除去する。

---

## ★標準モード（確定仕様・必ずこれを使う）

**カットロジックを自分で書かない。** 検証済みの正規スクリプトを実行するだけでよい:

```bash
# 1. ドライラン（解析＋検証レポートのみ）
python3 "<このスキルのフォルダ>/scripts/cut_rms.py" "<PROJECT>" --dry-run

# 2. レポートを確認（V-cut1/2/3 すべてOKであること）して本実行
python3 "<このスキルのフォルダ>/scripts/cut_rms.py" "<PROJECT>"
```

スクリプトが自動で行うこと（リール版＝完全ジェットカット）:
- 10ms RMS解析 → noise_floor×2.5 の閾値で実音検出（ワード区間内は閾値半分で小声・息も保護）
- 実音±100ms 保護 → 保護外の真の無音だけ全部削除（残る間 ≈0.2秒 ≦0.3秒）
- ★RMSベースなので Whisperが拾わなかった声も実音として保護される
- 検証 V-cut1（削れた発話=0）/ V-cut2（全ワード実音±100msがkeep内）/ V-cut3（最大残無音≦0.3s）
  → 1つでも違反すれば書き込まず exit 1
- `src/cutData.ts`（keepSegmentsMs形式）上書き + `src/videoConfig.ts` の DURATION_FRAMES 更新

---

## ★言い直しカット（失敗テイクの強制削除）★毎回必ず実施する標準工程

噛んで言い直した時、人は同じ言葉を繰り返す。その**前半（失敗テイク）を音声ごと強制削除**する機能。
無音詰めとは別レイヤーで、`<PROJECT>/言い直しカット.json` があれば cut_rms が keep から差し引く。

> ★★ **これはオプションではなく標準工程（まさきFB 2026-06-12: 0612-reelで完璧に機能した方式）。**
> cut_rms 実行の**前に必ず**下の検出ワークフローを行う。
> **判定の核: ずっと喋っている中で語間ギャップが3秒以上空く箇所は、ほぼ間違いなく言い直し。**
> 1秒以上のギャップも候補として全て点検する（息継ぎ・意図的な間は文脈で除外）。

```json
// 言い直しカット.json = [[start_ms, end_ms], ...]（原音声の時刻・ミリ秒）
[[57650, 60900]]
```

**検出ワークフロー（Claude読解方式・推奨）:**
1. `transcript.json` をワード単位タイムスタンプ付きで読み、**不自然に長いギャップ**（語間 1秒以上）を探す。
   - large-v3 は言い直しの2回目を潰して1つに丸めることがある＝本文では気づけない。**ギャップが手がかり**。
2. 疑わしい区間（例: 56〜64秒）だけ **ffmpeg で切り出して単独で再文字起こし**すると、潰れた言い直しが露出する。
   ```bash
   ffmpeg -y -ss 56 -to 64 -i transcript_audio.wav /tmp/seg.wav
   # /tmp/seg.wav を whisper で再起こし → 「編集にかけてた…(無音)…編集にかけてた」が見える
   ```
3. RMSで「声→無音→声」の言い直し波形を確認し、**1回目テイク＋詰まりの区間**を `言い直しカット.json` に入れる。
4. `transcript_fixed.json` を**2回目テイクのタイムスタンプに張り替える**（テロップが残す側=2回目に同期するように）。
   - 強制カット区間の終端は、2回目実音の開始 − PROT_MS（100ms）に合わせると V-cut2 が通る。
5. cut_rms を再実行 → V-cut1/2/3 OK を確認（言い直し区間は in_redo で検証免除される）。
6. **カット前にユーザーへ「ここ削ります（◯◯秒の言い直し）」と提示してOKをもらう**（事故防止）。

**モデルの仕事は3つだけ:**
1. ドライランを実行してレポートを確認する
2. カット上位5箇所が「セクション間の沈黙・リテイク後の無音」など妥当な場所かを目視確認する
3. 本実行 → 完了報告（カット区間数・削減秒数・検証結果・上位5箇所）

微小カット（数十ms）が多数含まれていても正常。CutPlayerは問題なく連結し、知覚されない。
空テキストのセグメント（リテイクの痕跡）は、該当区間に実音があれば自動で保護されるので個別対応不要。

**以下の Phase 1〜4（VAD / LLM内容分析 / ヒアリング）は、ユーザーが「脱線もカットして」
「内容のカットもして」と明示した場合だけ使うオプション。** 標準レシピ（画面収録解説）では使わない。

## ワークフロー概要（オプションモード用）

```
┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
│ 1. VAD   │→│ 2. transcript│→│ 3. LLM   │→│ 4. カット  │→│ 5. 適用   │
│ 音声区間  │  │ ギャップ検出 │  │ 内容分析  │  │ リスト統合 │  │ + 検証   │
│ Silero   │  │ words間の間 │  │ 不要判定  │  │ ヒアリング │  │ Remotion │
└──────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘
```

---

## 前提条件チェックリスト

- [ ] `/supermovie-transcribe3` で文字起こし済み
- [ ] `/supermovie-transcript-fix3` で誤字修正済み
- [ ] `transcript_fixed.json` が存在し `words` 配列がある
- [ ] `transcript_audio.wav` が存在
- [ ] Python 3.9+ がインストール済み

---

## Phase 1: Silero VAD 音声区間検出

### 1-1. インストール（初回のみ）

```bash
# プロジェクトのvenvを使用（transcribeで作成済み）
source "<PROJECT>/.venv/bin/activate"
pip install silero-vad torchaudio
```

### 1-2. VAD実行スクリプト

`<PROJECT>/vad_runner.py` を生成して実行:

```python
#!/usr/bin/env python3
"""SuperMovie VAD Runner (Silero VAD)"""
import torch
import torchaudio
import json
import sys

audio_path = sys.argv[1]
output_path = sys.argv[2]
min_silence = float(sys.argv[3]) if len(sys.argv) > 3 else 0.5  # 秒

print(f"🔍 VAD解析開始: {audio_path}")

# Silero VADモデルをロード
model, utils = torch.hub.load(
    repo_or_dir='snakers4/silero-vad',
    model='silero_vad',
    force_reload=False,
)
(get_speech_timestamps, _, read_audio, _, _) = utils

# 音声読み込み（16kHz）
wav = read_audio(audio_path, sampling_rate=16000)

# 音声区間検出
speech_timestamps = get_speech_timestamps(
    wav,
    model,
    sampling_rate=16000,
    min_silence_duration_ms=int(min_silence * 1000),
    speech_pad_ms=100,       # 発話前後に100msの余白
    min_speech_duration_ms=250,  # 250ms未満の音声は無視
)

# ミリ秒に変換
speech_segments = []
for ts in speech_timestamps:
    speech_segments.append({
        'start': round(ts['start'] / 16000 * 1000),
        'end': round(ts['end'] / 16000 * 1000),
    })

# 無音区間を算出
silence_segments = []
audio_duration_ms = round(len(wav) / 16000 * 1000)

for i in range(len(speech_segments)):
    if i == 0 and speech_segments[0]['start'] > 0:
        silence_segments.append({
            'start': 0,
            'end': speech_segments[0]['start'],
            'duration': speech_segments[0]['start'],
        })
    if i < len(speech_segments) - 1:
        gap_start = speech_segments[i]['end']
        gap_end = speech_segments[i + 1]['start']
        gap_duration = gap_end - gap_start
        if gap_duration >= min_silence * 1000:
            silence_segments.append({
                'start': gap_start,
                'end': gap_end,
                'duration': gap_duration,
            })

output = {
    'audio_duration_ms': audio_duration_ms,
    'speech_segments': speech_segments,
    'silence_segments': silence_segments,
    'stats': {
        'speech_count': len(speech_segments),
        'silence_count': len(silence_segments),
        'total_speech_ms': sum(s['end'] - s['start'] for s in speech_segments),
        'total_silence_ms': sum(s['duration'] for s in silence_segments),
    }
}

with open(output_path, 'w', encoding='utf-8') as f:
    json.dump(output, f, ensure_ascii=False, indent=2)

speech_pct = output['stats']['total_speech_ms'] / audio_duration_ms * 100
print(f"✅ 完了: 発話 {len(speech_segments)}区間 ({speech_pct:.1f}%) / 無音 {len(silence_segments)}区間")
```

### 1-3. 実行

```bash
"<PROJECT>/.venv/bin/python3" "<PROJECT>/vad_runner.py" \
  "<PROJECT>/transcript_audio.wav" \
  "<PROJECT>/vad_result.json" \
  0.5
```

### 1-4. 出力: vad_result.json

```json
{
  "audio_duration_ms": 325000,
  "speech_segments": [
    { "start": 500, "end": 3200 },
    { "start": 4100, "end": 8500 }
  ],
  "silence_segments": [
    { "start": 3200, "end": 4100, "duration": 900 }
  ],
  "stats": {
    "speech_count": 45,
    "silence_count": 12,
    "total_speech_ms": 290000,
    "total_silence_ms": 35000
  }
}
```

---

## Phase 2: transcript ギャップ検出

transcript_fixed.json の words 間のギャップからも無音を検出。
VADとは別の観点（Whisperが認識しなかった区間）。

```
words: [...{end: 3200}, {start: 4500}...]
              ↑ gap = 1300ms（Whisperが何も認識しなかった）
```

### 2-1. ギャップ抽出ルール（★リール＝完全ジェットカット・発話は絶対切らない）

**まさきFBの経緯（重要・失敗から学習）:**
- ❌ 全ギャップを0.1秒まで詰める（word基準ジェットカット）→ **「詰めすぎて発話の語尾まで切れ、ぶつ切りで精度が下がった」**
- ✅ **正解：RMS実音±100msの外（真の無音）だけ削除。前後に余裕を残して発話を絶対に切らない。**
  リール版は残る無音 ≈0.2秒・上限0.3秒(MAX_SILENCE_MS)で詰める。

> ⚠️ 横型(無印 supermovie)の旧FB「長い間（0.5秒超）だけ詰める／約0.37秒に短縮」はリール版では使わない。

**なぜ詰めすぎると切れるか:** Whisperの word.end は実音声より内側（早め）に寄る。
語尾の余韻・息継ぎが word 境界の外にあるため、境界ギリギリで切ると発話が削れてぶつ切りになる。
だからリール版も「word基準」ではなく「RMS実音基準」で削る（cut_rms.py が正本）。

**★リール版の処理（cut_rms.py の確定仕様）:** 実音±PROT_MS(100ms)の外にある真の無音は**全部削除**する（完全ジェットカット）。
カット点に残る無音 = PROT+PROT ≈ **0.2秒（上限 MAX_SILENCE_MS=0.3秒）**。

| ギャップ長 | 処理（リール＝完全ジェットカット） |
|-----------|------|
| 実音±100ms 内 | 残す（保護が重なる短い間は自然に残る） |
| 実音±100ms の外 | 全部削除 → 残る無音は約0.2秒（≦0.3秒） |

> ⚠️ 旧・横型(無印)用の値「≤0.5秒はそのまま / >0.5秒→約0.37秒に短縮」は**リール版では使わない**（横型 supermovie 専用）。
> リールは MAX_SILENCE_MS=300ms で詰める。

**★★★ まさきFB（最重要・二度と発話を切らない）: word境界ではなく「音声波形(RMS)」で切る。**
whisperのword.endは実音声より内側に寄るため、word基準だと語尾を削る。
**transcript_audio.wav の RMS エネルギーで実測の無音区間を求め、その無音だけを削除する。**

> ★この実装そのものが `scripts/cut_rms.py`。**標準モードでは再実装不要** — スクリプトを実行するだけでよい（手実装禁止）。
> 以下はスクリプトが内部で行う処理の説明（理解用）。

実装（★音声エネルギーベース＝確定。cut_rms.py の中身）:
**目標: 残る無音は約0.2秒・上限0.3秒(MAX_SILENCE_MS)。ただし音声(RMS≥閾値)は絶対に削らない（両立）。**
**★ワード基準ではなく「実際の音(RMS)」基準にするのが要。** whisperは無音に幻の語を置く/語尾を内側に取るため、ワード基準だと破綻する。
```
1. wav(16kHz mono) を 10ms フレームで RMS 計算
2. noise_floor=RMSの10%点。threshold = max(noise_floor*2.5, max_rms*0.03)  # 小声も「音」として拾う低め単一閾値
   ★ワード区間内は threshold/2 で判定（小声・息継ぎも「音」として保護）
3. RMS≥threshold の区間 = 「実音(loud)」。200ms以内(LOUD_MERGE_MS)のloudは1つにマージ（語内の小さな谷で割らない）
4. 保護区間 prot = 各loudを ±PROT_MS(100ms) 拡張してマージ ＝ これが keep 区間
5. remove = prot 外の残り（=音から100ms以上離れた真の無音だけ）を全部削除
   → カット点の残り無音 = PROT+PROT ≈ 0.2秒（≦0.3秒）。0.2秒未満の間は prot が重なり削られない（自然）。
6. 先頭は最初の実音の150ms前(INTRO_LEAD_MS)、末尾は最後の実音の300ms後(OUTRO_TAIL_MS)まで残す
※ 旧・横型用の値（PROT 230ms / 残0.46秒 / GAP 900ms / TAIL 350ms / LEAD 300ms）はリール版では使わない。
```
**なぜ両立するか:**
- 削除対象は「RMS<閾値 かつ 実音から100ms以上離れた」区間のみ → **音声は1フレームも削られない（構造的保証）**。
- 残る無音は最大約0.2秒（上限0.3秒）→ 「間が長すぎる」も完全に解消（リールは無音ゼロが理想）。
- ★必須検証（cut_rms.py が自動実行）:
  - V-cut1: 各削除区間に RMS≥閾値 のフレームが1つも無いこと（=音声を削っていない）。違反で exit 1。
  - V-cut2: 全ワードの実音±100msが keep 内であること。
  - V-cut3: 再生に残る最大の連続無音(RMS<閾値) ≤ 0.3秒(MAX_SILENCE_MS) であること。
- ❌ ワード全長を保護すると、無音に伸びた幻の語の尻尾まで残り「1.7秒の間」が消えない（失敗済み）。実音基準を厳守。

> ★以上の V-cut1/2/3 はすべて `scripts/cut_rms.py` が自動検証する。
> 1つでも違反すれば書き込まず exit 1 になるので、モデルが手でチェックを再実装する必要はない。
> ★発話が切れるのは「間が長い」より遥かに重大。迷ったら詰めない。

---

## Phase 3: Claude LLM 内容分析

transcript_fixed.json のテキストを分析し、**内容的に不要な区間**を判定。

### 3-1. LLMプロンプト

```
あなたは動画編集の専門家です。
以下の文字起こしテキストを分析し、カットすべき区間を判定してください。

## カット対象
1. 言い直し・言い淀み（「あ、違う、えっと〜」）
2. 話の脱線（本題から逸れた雑談が30秒以上続く場合）
3. 繰り返し（同じ内容を2回以上言っている場合、2回目以降）
4. 技術トラブル（「あ、画面見えてます？」「ちょっと待ってください」）

## カットしてはいけないもの
1. 本題の説明
2. 重要なポイントの強調（繰り返しに見えても意図的な強調）
3. 質問への回答
4. 具体的な例え話

## 入力
<segments JSON>

## 出力形式
{
  "cuts": [
    {
      "segmentIndex": 5,
      "reason": "言い直し",
      "severity": "high",
      "text": "あ、違う、えっと..."
    }
  ],
  "keep_note": "セグメント12の繰り返しは意図的な強調のため保持"
}
```

### 3-2. severity判定

| severity | 処理 |
|----------|------|
| `high` | 自動カット推奨（言い直し、技術トラブル） |
| `medium` | ユーザーに確認（脱線、繰り返し） |
| `low` | 情報提供のみ（カットしなくてもOK） |

---

## Phase 4: カットリスト統合 + ヒアリング

### 4-1. 3層の結果を統合

```
VAD無音区間 + transcriptギャップ + LLM内容分析
    ↓ マージ（重複除去）
    ↓
統合カットリスト
```

### 4-2. ユーザーに確認

```
カット分析が完了しました。

📊 分析結果:
  動画長: 5分25秒
  発話率: 89%（VAD検出）
  カット候補: 15箇所

🔴 自動カット推奨（8箇所）:
  [0:32-0:35] 無音 3.0秒 → 0.5秒に短縮
  [1:15-1:18] 言い直し「あ、違う、えっと...」
  [2:40-2:43] 無音 2.8秒 → 0.5秒に短縮
  ...

🟡 確認が必要（4箇所）:
  [3:20-3:55] 脱線？「昨日のニュースで...」(35秒)
  → カットする / 残す / 短縮する？
  ...

🟢 カットしなくてOK（3箇所）:
  [1:50-1:52] 短い間 0.8秒（自然な間）

どう進めますか？
→ 全て推奨通り / 個別に判断 / 無音カットのみ
```

### 4-3. カットモード選択

| モード | 内容 |
|--------|------|
| `auto` | 全推奨カット適用 + medium は保持 |
| `aggressive` | high + medium 全てカット |
| `silence-only` | 無音カットのみ（内容はカットしない） |
| `manual` | 1件ずつ確認 |

---

## Phase 5: Remotion への適用

> ★標準モードでは scripts/cut_rms.py がこのPhase全体（cutData.ts生成・videoConfig更新・検証）を
> 自動で行う。以下は手動編集やオプションモードで必要になった場合の参考。
> 生成形式は `keepSegmentsMs`（残す区間のms配列）+ playback自動算出が正本。
> subtitles の整列スクリプトはこの keepSegmentsMs を正規表現で読むため、形式を変えないこと。

### 5-1. cutData.ts 生成

カットではなく**残す区間（keep segments）**をリスト化:

**⚠️ 致命的ルール: 全数値は必ず `toFrame()` でラップすること ⚠️**
ms値を直接書くと、Remotionが「フレーム番号59020」と解釈し、
18071フレームの動画でフレーム59020を参照→映像が表示されない。
**ms値のベタ書きは絶対禁止。必ず `toFrame(ms値)` を使う。**

```typescript
// src/cutData.ts
export interface CutSegment {
  id: number;
  originalStart: number;  // 元動画のフレーム（⚠️ ms ではない）
  originalEnd: number;    // 元動画のフレーム（⚠️ ms ではない）
  playbackStart: number;  // カット後の再生フレーム（⚠️ ms ではない）
  playbackEnd: number;    // カット後の再生フレーム（⚠️ ms ではない）
}

export const FPS = 30;
const toFrame = (ms: number) => Math.round(ms / 1000 * FPS);

// ⚠️ 全値に toFrame() を適用。ベタ数値禁止。
export const cutData: CutSegment[] = [
  { id: 1, originalStart: toFrame(0), originalEnd: toFrame(32000), playbackStart: 0, playbackEnd: toFrame(32000) },
  { id: 2, originalStart: toFrame(35000), originalEnd: toFrame(75000), playbackStart: toFrame(32500), playbackEnd: toFrame(72500) },
];

export const ORIGINAL_DURATION_FRAMES = toFrame(325000);
export const CUT_DURATION_FRAMES = toFrame(298000);

// ★ 他スキル（subtitles, se）が使うリマッピング関数
export function mapOriginalToPlayback(originalFrame: number): number {
  for (const seg of cutData) {
    if (originalFrame >= seg.originalStart && originalFrame <= seg.originalEnd) {
      return seg.playbackStart + (originalFrame - seg.originalStart);
    }
  }
  for (let i = cutData.length - 1; i >= 0; i--) {
    if (originalFrame > cutData[i].originalEnd) {
      return cutData[i].playbackEnd;
    }
  }
  return 0;
}
```

### 5-1b. cutData バリデーション（必須）

```
書き出し後、以下を必ず確認:
1. 全数値が toFrame() でラップされていること（ベタ数値がないこと）
2. 全 originalStart/End がフレーム値（< ORIGINAL_DURATION_FRAMES）であること
3. 全 playbackStart/End がフレーム値（< CUT_DURATION_FRAMES + 余裕10f）であること
4. CUT_DURATION_FRAMES == videoConfig.ts の DURATION_FRAMES であること
```

### 5-2. MainVideo.tsx は編集不要（★CutPlayer常設）

テンプレートには `CutPlayer.tsx` が常設され、MainVideo は既に `<CutPlayer />` を使用している。
CutPlayer は `cutData.ts` を読んで `OffthreadVideo` の trimBefore/trimAfter で
各keepセグメントを連結再生する。**このスキルは cutData.ts を上書きするだけでよい。**
MainVideo.tsx / CutPlayer.tsx には触らない。

### 5-3. videoConfig.ts の更新（★Root.tsxではない）

```typescript
// src/videoConfig.ts の DURATION_FRAMES をカット後の尺に更新
export const DURATION_FRAMES = <CUT_DURATION_FRAMES>;
```
Root.tsx / telopData.ts / titleData.ts は videoConfig を import しているため自動追従する。
※ cutData.ts 内の CUT_DURATION_FRAMES と videoConfig の DURATION_FRAMES を一致させる。

### 5-4. telopData.ts のフレーム再計算

**カットによってフレーム番号がずれるため、全テロップのstartFrame/endFrameを再計算。**

```
カット前: テロップA startFrame=1000
カット区間: frame 800-900 が削除された
カット後: テロップA startFrame=900（100フレーム前にずれる）
```

この再計算はcutData.tsのマッピングから機械的に算出可能。

---

## Phase 6: 検証

### 6-1. バリデーション

| チェック項目 | 条件 | 失敗時の対応 |
|-------------|------|------------|
| カット後の動画長 | 元の50%以上残っている | 50%未満は警告（カットしすぎ） |
| テロップフレーム | カット後のフレーム範囲内 | 範囲外テロップは削除 |
| 音声ジャンプ | カット境界で不自然な音声ジャンプがないか | crossfade 0.1秒を挿入 |
| 映像ジャンプ | カット境界で映像が不自然に飛ばないか | 情報提供（手動確認を促す） |

### 6-2. 一時ファイル削除

```bash
rm -f "<PROJECT>/vad_runner.py" "<PROJECT>/vad_result.json"
```

---

## 完了時の報告フォーマット

```
✅ カット完了

✂️ カット結果:
  元の動画: <X>分<Y>秒
  カット後: <X>分<Y>秒（<Z>秒短縮 = <P>%削減）
  カット箇所: <N>箇所
    - 無音短縮: <n>箇所
    - 内容カット: <n>箇所
    - フィラー除去: <n>箇所

📄 生成ファイル:
  cutData.ts（カット区間定義）
  telopData.ts（フレーム再計算済み）

次のステップ:
→ npm run dev でカット結果をプレビュー
→ /supermovie-subtitles3 でテロップ再生成（カット後のタイミングで）
```

---

## 実行タイミングの注意

カットは subtitles の前に実行するのが推奨。
subtitles の後にカットすると全テロップのフレーム再計算が必要になるため、カット前にやるのが効率的。

---

## エラーハンドリング

| エラー | 対応 |
|--------|------|
| Silero VAD インストール失敗 | transcriptギャップのみで無音検出（VADスキップ） |
| torch がない | `pip install torch torchaudio` を案内 |
| transcript_fixed.json がない | `/supermovie-transcript-fix3` を促す |
| カット後の動画が50%未満 | 警告 + カットモードを `silence-only` に変更提案 |
| テロップのフレーム再計算でずれ | cutData.ts のマッピングを再検証 |
| 音声ファイルが16kHzでない | ffmpegで再変換 |

---

## 連携マップ

```
/supermovie-init3              ← ヒアリング → プロジェクト作成
    ↓
/supermovie-transcribe3        ← 文字起こし（ローカル無料）
    ↓ transcript.json
/supermovie-transcript-fix3    ← 誤字修正（辞書 + Claude LLM）
    ↓ transcript_fixed.json
/supermovie-cut3               ← ★ここ: 不要区間カット（VAD + LLM分析）
    ↓ cutData.ts
/supermovie-subtitles3         ← テロップ＆タイトル生成
    ↓ telopData.ts + titleData.ts
/supermovie-image-gen3         ← 画像生成 + 配置データ
    ↓ insertImageData.ts
/supermovie-se3                ← SE自動配置
    ↓
npm run dev                   ← プレビュー
```
