---
name: supermovie-cut
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 として、不要区間を精密に除去する。

---

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

**カットロジックを自分で書かない。** 検証済みの正規スクリプトを実行するだけでよい。
★**標準は完全ジェットカット版 `supermovie-cut3/scripts/cut_rms.py`（PROT=100・残り無音約0.20秒）**を使う
（まさきFB 2026-06-13。この `supermovie-cut/scripts/cut_rms.py`＝PROT=230・残り0.46秒は「甘すぎ」とNG）:

```bash
# 1. ドライラン（解析＋検証レポートのみ）★cut3版を使う
python3 "/Users/noguchimasatomo/.claude/skills/supermovie-cut3/scripts/cut_rms.py" "<PROJECT>" --dry-run

# 2. レポートを確認（V-cut1/2/3 すべてOKであること）して本実行
python3 "/Users/noguchimasatomo/.claude/skills/supermovie-cut3/scripts/cut_rms.py" "<PROJECT>"
```
※既にカット済みを再カットする時は videoConfig.ts の DURATION_FRAMES を元尺(round(原尺秒×30))に戻してから実行。

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

**モデルの仕事は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-transcribe` で文字起こし済み
- [ ] `/supermovie-transcript-fix` で誤字修正済み
- [ ] `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：長い間だけ詰める・発話は絶対切らない）

**まさきFBの経緯（重要・両方の失敗から学習）:**
- ❌ 1.0秒超だけ短縮 → 「間が長すぎる所がある」
- ❌ 全ギャップを0.1秒まで詰める（ジェットカット）→ **「詰めすぎて発話の語尾まで切れ、ぶつ切りで精度が下がった」**
- ✅ **正解：長い間（0.5秒超）だけ詰める。前後に余裕を残して発話を絶対に切らない。**

**なぜ詰めすぎると切れるか:** Whisperの word.end は実音声より内側（早め）に寄る。
語尾の余韻・息継ぎが word 境界の外にあるため、境界ギリギリで切ると発話が削れてぶつ切りになる。

| ギャップ長 | 処理 |
|-----------|------|
| ≤ 0.5秒 | そのまま（自然な間・触らない） |
| > 0.5秒 | 約0.37秒に短縮（無音の中央だけ抜く） |

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

実装（★音声エネルギーベース・両立版＝確定）:
**目標: 残る無音は最大0.5秒。ただし音声(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)」。250ms以内のloudは1つにマージ（語内の小さな谷で割らない）
4. 保護区間 prot = 各loudを ±PROT(230ms) 拡張してマージ
5. remove = [intro,outro] から prot を引いた残り（=音から230ms以上離れた真の無音だけ）を全部削除
   → カット点の残り無音 = PROT+PROT ≈ 0.46秒（≦0.5秒）。0.46秒未満の間は prot が重なり削られない（自然）。
6. 先頭は最初の発話の200ms前、末尾は最後の発話の350ms後まで残す
※ この実装そのものが scripts/cut_rms.py。再実装せずスクリプトを使う。
```
**なぜ両立するか:**
- 削除対象は「RMS<閾値 かつ 実音から220ms以上離れた」区間のみ → **音声は1フレームも削られない（構造的保証）**。
- 残る無音は最大約0.46秒 → 「間が長すぎる」も解消。
- ★必須検証（出力前）:
  - V-cut: 各削除区間に RMS≥閾値 のフレームが1つも無いこと（=音声を削っていない）。1つでもあれば PROT を上げて再計算。
  - 再生に残る最大の連続無音(RMS<閾値) ≤ 0.5秒 であること。
- ❌ ワード全長を保護すると、無音に伸びた幻の語の尻尾まで残り「1.7秒の間」が消えない（失敗済み）。実音基準を厳守。

実装（フォールバック・wordギャップ方式。バッファを大きめに）:
```
GAP=900ms        # ★これ超だけ詰める（短い間は残す）
TAIL=350ms       # 語尾を必ず残す（大きめ）
LEAD=300ms       # 次の語の頭・息継ぎを残す（大きめ）
gap>GAP のとき w[i].end+TAIL で閉じ w[i+1].start-LEAD で再開（残り≈0.65秒）
```

**★カット後の必須検証（チェックを増やしても書き出し時間は変わらない）:**
- V-cut1: 全ワードの [start-100ms, end+250ms] が kept 区間に入るか（入らなければ語尾を削っている＝NG）
- V-cut2: 各カット境界の前後150msに RMS>threshold（音）が無いか（あれば発話を切っている＝その区間はカットしない）
- 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-subtitles でテロップ再生成（カット後のタイミングで）
```

---

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

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

---

## エラーハンドリング

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

---

## 連携マップ

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