Back to skills
SkillHub ClubShip Full StackFull Stack

talk-edit

Imported from https://github.com/openclaw/skills.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,076
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
F17.6

Install command

npx @skill-hub/cli install openclaw-skills-talk-edit

Repository

openclaw/skills

Skill path: skills/donghaozhang/qcut-toolkit/videocut/talk-edit

Imported from https://github.com/openclaw/skills.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install talk-edit into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding talk-edit to shared team environments
  • Use talk-edit for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: videocut-talk-edit
description: Talking-head video transcription and speech error detection. Generates review page and deletion task list. Triggers: edit talking head, process video, detect speech errors, 剪口播, 处理视频, 识别口误
---

<!--
input: Video file (*.mp4)
output: subtitles_words.json, auto_selected.json, review.html
pos: Transcription + detection, up to user web review

Architecture guardian: If modified, sync:
1. ../README.md skill list
2. /CLAUDE.md route table
-->

# Talk Edit v2

> Volcengine transcription + AI speech error detection + web review

## Quick Start

```
User: Help me edit this talking head video
User: Process this video
User: 帮我剪这个口播视频
User: 处理一下这个视频
```

## Output Directory Structure

```
output/
└── YYYY-MM-DD_video-name/
    ├── talk-edit/
    │   ├── 1_transcription/
    │   │   ├── audio.mp3
    │   │   ├── volcengine_result.json
    │   │   └── subtitles_words.json
    │   ├── 2_analysis/
    │   │   ├── readable.txt
    │   │   ├── auto_selected.json
    │   │   └── error_analysis.md
    │   └── 3_review/
    │       └── review.html
    └── subtitles/
        └── ...
```

**Rule**: Reuse existing folders; create new ones only if needed.

## Workflow

```
0. Create output directory
    ↓
1. Extract audio (ffmpeg)
    ↓
2. Upload for public URL (uguu.se)
    ↓
3. Volcengine API transcription
    ↓
4. Generate word-level subtitles (subtitles_words.json)
    ↓
5. AI analyzes errors/silence, generates pre-selection list (auto_selected.json)
    ↓
6. Generate review webpage (review.html)
    ↓
7. Start review server, user confirms on webpage
    ↓
[Await user confirmation] → Click "Execute Cut" on webpage or manually /cut
```

## Execution Steps

### Step 0: Create Output Directory

```bash
# Variable setup (adjust for actual video)
VIDEO_PATH="/path/to/video.mp4"
VIDEO_NAME=$(basename "$VIDEO_PATH" .mp4)
DATE=$(date +%Y-%m-%d)
BASE_DIR="output/${DATE}_${VIDEO_NAME}/talk-edit"

# Create subdirectories
mkdir -p "$BASE_DIR/1_transcription" "$BASE_DIR/2_analysis" "$BASE_DIR/3_review"
cd "$BASE_DIR"
```

### Steps 1-3: Transcription

```bash
cd 1_transcription

# 1. Extract audio (filenames with colons need file: prefix)
ffmpeg -i "file:$VIDEO_PATH" -vn -acodec libmp3lame -y audio.mp3

# 2. Upload for public URL
curl -s -F "files[][email protected]" https://uguu.se/upload
# Returns: {"success":true,"files":[{"url":"https://h.uguu.se/xxx.mp3"}]}

# 3. Call Volcengine API
SKILL_DIR="<project>/.claude/skills/qcut-toolkit/videocut/talk-edit"
"$SKILL_DIR/scripts/volcengine_transcribe.sh" "https://h.uguu.se/xxx.mp3"
# Output: volcengine_result.json
```

### Step 4: Generate Subtitles

```bash
node "$SKILL_DIR/scripts/generate_subtitles.js" volcengine_result.json
# Output: subtitles_words.json

cd ..
```

### Step 5: Analyze Errors (Script + AI)

#### 5.1 Generate Readable Format

```bash
cd 2_analysis

node -e "
const data = require('../1_transcription/subtitles_words.json');
let output = [];
data.forEach((w, i) => {
  if (w.isGap) {
    const dur = (w.end - w.start).toFixed(2);
    if (dur >= 0.5) output.push(i + '|[silence' + dur + 's]|' + w.start.toFixed(2) + '-' + w.end.toFixed(2));
  } else {
    output.push(i + '|' + w.text + '|' + w.start.toFixed(2) + '-' + w.end.toFixed(2));
  }
});
require('fs').writeFileSync('readable.txt', output.join('\\n'));
"
```

#### 5.2 Read User Habits

Read all rule files under `user-habits/` directory first.

#### 5.3 Generate Sentence List (Key Step)

**Must segment into sentences first, then analyze**. Split by silence gaps:

```bash
node -e "
const data = require('../1_transcription/subtitles_words.json');
let sentences = [];
let curr = { text: '', startIdx: -1, endIdx: -1 };

data.forEach((w, i) => {
  const isLongGap = w.isGap && (w.end - w.start) >= 0.5;
  if (isLongGap) {
    if (curr.text.length > 0) sentences.push({...curr});
    curr = { text: '', startIdx: -1, endIdx: -1 };
  } else if (!w.isGap) {
    if (curr.startIdx === -1) curr.startIdx = i;
    curr.text += w.text;
    curr.endIdx = i;
  }
});
if (curr.text.length > 0) sentences.push(curr);

sentences.forEach((s, i) => {
  console.log(i + '|' + s.startIdx + '-' + s.endIdx + '|' + s.text);
});
" > sentences.txt
```

#### 5.4 Script Auto-Mark Silence (Must Run First)

```bash
node -e "
const words = require('../1_transcription/subtitles_words.json');
const selected = [];
words.forEach((w, i) => {
  if (w.isGap && (w.end - w.start) >= 0.5) selected.push(i);
});
require('fs').writeFileSync('auto_selected.json', JSON.stringify(selected, null, 2));
console.log('Silence segments >=0.5s:', selected.length);
"
```

→ Output: `auto_selected.json` (contains only silence indices)

#### 5.5 AI Error Analysis (Append to auto_selected.json)

**Detection Rules (by priority)**:

| # | Type | Detection Method | Deletion Scope |
|---|------|-----------------|----------------|
| 1 | Repeated sentence | Adjacent sentences share >=5 char prefix | Shorter **entire sentence** |
| 2 | Skip-one repeat | Middle is fragment, compare before/after | Previous sentence + fragment |
| 3 | Incomplete sentence | Sentence cut off mid-way + silence | **Entire fragment** |
| 4 | In-sentence repeat | A + filler + A pattern | Earlier part |
| 5 | Stutter words | Repeated phrases (e.g., "that that", "so so") | Earlier part |
| 6 | Self-correction | Partial repeat / negation correction | Earlier part |
| 7 | Filler words | "um", "uh", "er", "like" | Mark but don't auto-delete |

**Core Principle**:
- **Segment first, then compare**: Use sentences.txt to compare adjacent sentences
- **Delete entire sentences**: Fragments and repeats should delete full sentences, not just anomalous words

**Chunked Analysis (loop)**:

```
1. Read readable.txt offset=N limit=300
2. Analyze these 300 lines using sentences.txt
3. Append error indices to auto_selected.json
4. Record in error_analysis.md
5. N += 300, go to step 1
```

**Critical Warning: Line number != idx**

```
readable.txt format: idx|content|time
                     ^ use THIS value

Line 1500 → "1568|[silence1.02s]|..."  ← idx is 1568, NOT 1500!
```

**error_analysis.md format:**

```markdown
## Chunk N (line range)

| idx | Time | Type | Content | Action |
|-----|------|------|---------|--------|
| 65-75 | 15.80-17.66 | Repeated sentence | "This is an example I cut" | Delete |
```

### Steps 6-7: Review

```bash
cd ../3_review

# 6. Generate review webpage
node "$SKILL_DIR/scripts/generate_review.js" ../1_transcription/subtitles_words.json ../2_analysis/auto_selected.json ../1_transcription/audio.mp3
# Output: review.html

# 7. Start review server
node "$SKILL_DIR/scripts/review_server.js" 8899 "$VIDEO_PATH"
# Open http://localhost:8899
```

User actions on the webpage:
- Play video segments to confirm
- Check/uncheck deletion items
- Click "Execute Cut"

---

## Data Formats

### subtitles_words.json

```json
[
  {"text": "Hello", "start": 0.12, "end": 0.2, "isGap": false},
  {"text": "", "start": 6.78, "end": 7.48, "isGap": true}
]
```

### auto_selected.json

```json
[72, 85, 120]  // AI-generated pre-selected indices
```

---

## Configuration

### Volcengine API Key

```bash
cd <project>/.claude/skills
cp .env.example .env
# Edit .env and add VOLCENGINE_API_KEY=xxx
```


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### scripts/cut_video.sh

```bash
#!/bin/bash
#
# 根据删除列表剪辑视频(filter_complex 精确剪辑)
#
# 用法: ./cut_video.sh <input.mp4> <delete_segments.json> [output.mp4]
#

INPUT="$1"
DELETE_JSON="$2"
OUTPUT="${3:-output_cut.mp4}"

if [ -z "$INPUT" ] || [ -z "$DELETE_JSON" ]; then
  echo "❌ 用法: ./cut_video.sh <input.mp4> <delete_segments.json> [output.mp4]"
  exit 1
fi

if [ ! -f "$INPUT" ]; then
  echo "❌ 找不到输入文件: $INPUT"
  exit 1
fi

if [ ! -f "$DELETE_JSON" ]; then
  echo "❌ 找不到删除列表: $DELETE_JSON"
  exit 1
fi

# 获取视频时长(file: 前缀处理文件名含冒号的情况)
DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "file:$INPUT")
echo "📹 视频时长: ${DURATION}s"

# 配置参数
BUFFER_MS=50      # 删除范围前后各扩展 50ms(吃掉气口)
CROSSFADE_MS=30   # 音频淡入淡出 30ms

echo "⚙️ 优化参数: 扩展范围=${BUFFER_MS}ms, 音频crossfade=${CROSSFADE_MS}ms"

# 用 node 生成 filter_complex 命令
FILTER_CMD=$(node -e "
const fs = require('fs');
const deleteSegs = JSON.parse(fs.readFileSync('$DELETE_JSON', 'utf8'));
const duration = $DURATION;
const bufferSec = $BUFFER_MS / 1000;
const crossfadeSec = $CROSSFADE_MS / 1000;

// 按开始时间排序
deleteSegs.sort((a, b) => a.start - b.start);

// 扩展删除范围(前后各加 buffer)
const expandedSegs = deleteSegs.map(seg => ({
  start: Math.max(0, seg.start - bufferSec),
  end: Math.min(duration, seg.end + bufferSec)
}));

// 合并重叠的删除段
const mergedSegs = [];
for (const seg of expandedSegs) {
  if (mergedSegs.length === 0 || seg.start > mergedSegs[mergedSegs.length - 1].end) {
    mergedSegs.push({ ...seg });
  } else {
    mergedSegs[mergedSegs.length - 1].end = Math.max(mergedSegs[mergedSegs.length - 1].end, seg.end);
  }
}

// 计算保留片段
const keepSegs = [];
let cursor = 0;

for (const del of mergedSegs) {
  if (del.start > cursor) {
    keepSegs.push({ start: cursor, end: del.start });
  }
  cursor = del.end;
}

if (cursor < duration) {
  keepSegs.push({ start: cursor, end: duration });
}

console.error('保留片段数:', keepSegs.length);
console.error('删除片段数:', mergedSegs.length);

let deletedTime = 0;
for (const seg of mergedSegs) {
  deletedTime += seg.end - seg.start;
}
console.error('删除总时长:', deletedTime.toFixed(2) + 's');

// 生成 filter_complex(带 crossfade)
let filters = [];
let vconcat = '';
let aLabels = [];

for (let i = 0; i < keepSegs.length; i++) {
  const seg = keepSegs[i];
  filters.push('[0:v]trim=start=' + seg.start.toFixed(3) + ':end=' + seg.end.toFixed(3) + ',setpts=PTS-STARTPTS[v' + i + ']');
  filters.push('[0:a]atrim=start=' + seg.start.toFixed(3) + ':end=' + seg.end.toFixed(3) + ',asetpts=PTS-STARTPTS[a' + i + ']');
  vconcat += '[v' + i + ']';
  aLabels.push('a' + i);
}

// 视频直接 concat
filters.push(vconcat + 'concat=n=' + keepSegs.length + ':v=1:a=0[outv]');

// 音频使用 acrossfade 逐个拼接
if (keepSegs.length === 1) {
  filters.push('[a0]anull[outa]');
} else {
  let currentLabel = 'a0';
  for (let i = 1; i < keepSegs.length; i++) {
    const nextLabel = 'a' + i;
    const outLabel = (i === keepSegs.length - 1) ? 'outa' : 'amid' + i;
    filters.push('[' + currentLabel + '][' + nextLabel + ']acrossfade=d=' + crossfadeSec.toFixed(3) + ':c1=tri:c2=tri[' + outLabel + ']');
    currentLabel = outLabel;
  }
}

console.log(filters.join(';'));
")

if [ -z "$FILTER_CMD" ]; then
  echo "❌ 生成滤镜命令失败"
  exit 1
fi

echo ""
echo "✂️ 执行 FFmpeg 精确剪辑..."

ffmpeg -y -i "file:$INPUT" \
  -filter_complex "$FILTER_CMD" \
  -map "[outv]" -map "[outa]" \
  -c:v libx264 -preset fast -crf 18 \
  -c:a aac -b:a 192k \
  "file:$OUTPUT"

if [ $? -eq 0 ]; then
  echo "✅ 已保存: $OUTPUT"

  NEW_DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "file:$OUTPUT")
  echo "📹 新时长: ${NEW_DURATION}s"
else
  echo "❌ 剪辑失败"
  exit 1
fi

```

### scripts/generate_review.js

```javascript
#!/usr/bin/env node
/**
 * 生成审核网页(wavesurfer.js 版本)
 *
 * 用法: node generate_review.js <subtitles_words.json> [auto_selected.json] [audio_file]
 * 输出: review.html, audio.mp3(复制到当前目录)
 */

const fs = require("fs");
const path = require("path");

const subtitlesFile = process.argv[2] || "subtitles_words.json";
const autoSelectedFile = process.argv[3] || "auto_selected.json";
const audioFile = process.argv[4] || "audio.mp3";

// 复制音频文件到当前目录(避免相对路径问题)
const audioBaseName = "audio.mp3";
if (audioFile !== audioBaseName && fs.existsSync(audioFile)) {
  fs.copyFileSync(audioFile, audioBaseName);
  console.log("📁 已复制音频到当前目录:", audioBaseName);
}

if (!fs.existsSync(subtitlesFile)) {
  console.error("❌ 找不到字幕文件:", subtitlesFile);
  process.exit(1);
}

const words = JSON.parse(fs.readFileSync(subtitlesFile, "utf8"));
let autoSelected = [];

if (fs.existsSync(autoSelectedFile)) {
  autoSelected = JSON.parse(fs.readFileSync(autoSelectedFile, "utf8"));
  console.log("AI 预选:", autoSelected.length, "个元素");
}

const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>审核稿</title>
  <script src="https://unpkg.com/wavesurfer.js@7"></script>
  <style>
    * { box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      max-width: 900px;
      margin: 0 auto;
      padding: 20px;
      background: #1a1a1a;
      color: #e0e0e0;
    }
    h1 { text-align: center; margin-bottom: 20px; }

    .controls {
      position: sticky;
      top: 0;
      background: #1a1a1a;
      padding: 15px 0;
      border-bottom: 1px solid #333;
      z-index: 100;
    }

    .buttons {
      display: flex;
      gap: 10px;
      align-items: center;
      flex-wrap: wrap;
      margin-bottom: 15px;
    }

    button {
      padding: 8px 16px;
      background: #4CAF50;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
    }
    button:hover { background: #45a049; }
    button.danger { background: #f44336; }
    button.danger:hover { background: #da190b; }

    select {
      padding: 8px 12px;
      background: #333;
      color: white;
      border: none;
      border-radius: 4px;
      font-size: 14px;
      cursor: pointer;
    }
    select:hover { background: #444; }

    #time {
      font-family: monospace;
      font-size: 16px;
      color: #888;
    }

    #waveform {
      background: #252525;
      border-radius: 4px;
      margin: 10px 0;
    }

    .content {
      line-height: 2.5;
      padding: 20px 0;
    }

    .word {
      display: inline-block;
      padding: 4px 2px;
      margin: 2px;
      border-radius: 3px;
      cursor: pointer;
      transition: all 0.15s;
    }

    .word:hover { background: #333; }
    .word.current { background: #2196F3; color: white; }
    .word.selected { background: #f44336; color: white; text-decoration: line-through; }
    .word.ai-selected { background: #ff9800; color: white; }
    .word.ai-selected.selected { background: #f44336; }

    .gap {
      display: inline-block;
      background: #333;
      color: #888;
      padding: 4px 8px;
      margin: 2px;
      border-radius: 3px;
      font-size: 12px;
      cursor: pointer;
    }
    .gap:hover { background: #444; }
    .gap.selected { background: #f44336; color: white; }
    .gap.ai-selected { background: #ff9800; color: white; }
    .gap.ai-selected.selected { background: #f44336; }

    .stats {
      margin-top: 10px;
      padding: 10px;
      background: #252525;
      border-radius: 4px;
      font-size: 14px;
    }

    .help {
      font-size: 13px;
      color: #999;
      margin-top: 10px;
      background: #252525;
      padding: 12px;
      border-radius: 6px;
      line-height: 1.8;
    }
    .help b { color: #fff; }
    .help div { margin: 2px 0; }

    /* Loading 遮罩 */
    .loading-overlay {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0,0,0,0.85);
      z-index: 9999;
      justify-content: center;
      align-items: center;
      flex-direction: column;
    }
    .loading-overlay.show { display: flex; }
    .loading-spinner {
      width: 60px;
      height: 60px;
      border: 4px solid #333;
      border-top-color: #9C27B0;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
    .loading-text {
      margin-top: 20px;
      font-size: 18px;
      color: #fff;
    }
    .loading-progress-container {
      margin-top: 20px;
      width: 300px;
      height: 8px;
      background: #333;
      border-radius: 4px;
      overflow: hidden;
    }
    .loading-progress-bar {
      height: 100%;
      background: linear-gradient(90deg, #9C27B0, #E91E63);
      width: 0%;
      transition: width 0.3s ease;
    }
    .loading-time {
      margin-top: 15px;
      font-size: 14px;
      color: #888;
    }
    .loading-estimate {
      margin-top: 8px;
      font-size: 13px;
      color: #666;
    }
  </style>
</head>
<body>
  <!-- Loading 遮罩 -->
  <div class="loading-overlay" id="loadingOverlay">
    <div class="loading-spinner"></div>
    <div class="loading-text">🎬 正在剪辑中...</div>
    <div class="loading-progress-container">
      <div class="loading-progress-bar" id="loadingProgress"></div>
    </div>
    <div class="loading-time" id="loadingTime">已等待 0 秒</div>
    <div class="loading-estimate" id="loadingEstimate">预估剩余: 计算中...</div>
  </div>

  <h1>审核稿</h1>

  <div class="controls">
    <div class="buttons">
      <button onclick="wavesurfer.playPause()">▶️ 播放/暂停</button>
      <select id="speed" onchange="wavesurfer.setPlaybackRate(parseFloat(this.value))">
        <option value="0.5">0.5x</option>
        <option value="0.75">0.75x</option>
        <option value="1" selected>1x</option>
        <option value="1.25">1.25x</option>
        <option value="1.5">1.5x</option>
        <option value="2">2x</option>
      </select>
      <button onclick="copyDeleteList()">📋 复制删除列表</button>
      <button onclick="executeCut()" style="background:#9C27B0">🎬 执行剪辑</button>
      <button class="danger" onclick="clearAll()">🗑️ 清空选择</button>
      <span id="time">00:00 / 00:00</span>
    </div>
    <div id="waveform"></div>
    <div class="help">
      <div><b>🖱️ 鼠标:</b>单击 = 跳转播放 | 双击 = 选中/取消 | Shift+拖动 = 批量选中/取消</div>
      <div><b>⌨️ 键盘:</b>空格 = 播放/暂停 | ← → = 跳转1秒 | Shift+←→ = 跳转5秒</div>
      <div><b>🎨 颜色:</b><span style="color:#ff9800">橙色</span> = AI预选 | <span style="color:#f44336">红色删除线</span> = 已确认删除 | 播放时自动跳过选中片段</div>
    </div>
  </div>

  <div class="content" id="content"></div>
  <div class="stats" id="stats"></div>

  <script>
    const words = ${JSON.stringify(words)};
    const autoSelected = new Set(${JSON.stringify(autoSelected)});
    const selected = new Set(autoSelected);

    // 初始化 wavesurfer
    const wavesurfer = WaveSurfer.create({
      container: '#waveform',
      waveColor: '#4a9eff',
      progressColor: '#1976D2',
      cursorColor: '#fff',
      height: 80,
      barWidth: 2,
      barGap: 1,
      barRadius: 2,
      url: '${audioBaseName}'
    });

    const timeDisplay = document.getElementById('time');
    const content = document.getElementById('content');
    const statsDiv = document.getElementById('stats');
    let elements = [];
    let isSelecting = false;
    let selectStart = -1;
    let selectMode = 'add'; // 'add' or 'remove'

    // 格式化时间 (用于播放器显示)
    function formatTime(sec) {
      const m = Math.floor(sec / 60);
      const s = Math.floor(sec % 60);
      return \`\${m.toString().padStart(2, '0')}:\${s.toString().padStart(2, '0')}\`;
    }

    // 格式化时长 (用于剪辑结果显示,带秒数)
    function formatDuration(sec) {
      const totalSec = parseFloat(sec);
      const m = Math.floor(totalSec / 60);
      const s = (totalSec % 60).toFixed(1);
      if (m > 0) {
        return \`\${m}分\${s}秒 (\${totalSec}s)\`;
      }
      return \`\${s}秒\`;
    }

    // 渲染内容
    function render() {
      content.innerHTML = '';
      elements = [];

      words.forEach((word, i) => {
        const div = document.createElement('div');
        div.className = word.isGap ? 'gap' : 'word';

        if (selected.has(i)) div.classList.add('selected');
        else if (autoSelected.has(i)) div.classList.add('ai-selected');

        if (word.isGap) {
          const duration = (word.end - word.start).toFixed(1);
          div.textContent = \`⏸ \${duration}s\`;
        } else {
          div.textContent = word.text;
        }

        div.dataset.index = i;

        // 单击跳转播放
        div.onclick = (e) => {
          if (!isSelecting) {
            wavesurfer.setTime(word.start);
          }
        };

        // 双击选中/取消
        div.ondblclick = () => toggle(i);

        // Shift+拖动选择/取消
        div.onmousedown = (e) => {
          if (e.shiftKey) {
            isSelecting = true;
            selectStart = i;
            selectMode = selected.has(i) ? 'remove' : 'add';
            e.preventDefault();
          }
        };

        content.appendChild(div);
        elements.push(div);
      });

      updateStats();
    }

    // Shift+拖动多选/取消
    content.addEventListener('mousemove', e => {
      if (!isSelecting) return;
      const target = e.target.closest('[data-index]');
      if (!target) return;

      const i = parseInt(target.dataset.index);
      const min = Math.min(selectStart, i);
      const max = Math.max(selectStart, i);

      for (let j = min; j <= max; j++) {
        if (selectMode === 'add') {
          selected.add(j);
          elements[j].classList.add('selected');
          elements[j].classList.remove('ai-selected');
        } else {
          selected.delete(j);
          elements[j].classList.remove('selected');
          if (autoSelected.has(j)) elements[j].classList.add('ai-selected');
        }
      }
      updateStats();
    });

    document.addEventListener('mouseup', () => {
      isSelecting = false;
    });

    function toggle(i) {
      if (selected.has(i)) {
        selected.delete(i);
        elements[i].classList.remove('selected');
        if (autoSelected.has(i)) elements[i].classList.add('ai-selected');
      } else {
        selected.add(i);
        elements[i].classList.add('selected');
        elements[i].classList.remove('ai-selected');
      }
      updateStats();
    }

    function updateStats() {
      const count = selected.size;
      let totalDuration = 0;
      selected.forEach(i => {
        totalDuration += words[i].end - words[i].start;
      });
      statsDiv.textContent = \`已选择 \${count} 个元素,总时长 \${totalDuration.toFixed(2)}s\`;
    }

    // 时间更新 & 高亮当前词 & 跳过选中片段
    wavesurfer.on('timeupdate', (t) => {
      // 播放时跳过选中片段(找到连续选中的末尾)
      if (wavesurfer.isPlaying()) {
        const sortedSelected = Array.from(selected).sort((a, b) => a - b);
        for (const i of sortedSelected) {
          const w = words[i];
          if (t >= w.start && t < w.end) {
            // 找到连续选中片段的末尾
            let endTime = w.end;
            let j = sortedSelected.indexOf(i) + 1;
            while (j < sortedSelected.length) {
              const nextIdx = sortedSelected[j];
              const nextW = words[nextIdx];
              // 如果下一个紧挨着(间隔<0.1s),继续跳
              if (nextW.start - endTime < 0.1) {
                endTime = nextW.end;
                j++;
              } else {
                break;
              }
            }
            wavesurfer.setTime(endTime);
            return;
          }
        }
      }

      timeDisplay.textContent = \`\${formatTime(t)} / \${formatTime(wavesurfer.getDuration())}\`;

      // 高亮当前词
      elements.forEach((el, i) => {
        const word = words[i];
        if (t >= word.start && t < word.end) {
          if (!el.classList.contains('current')) {
            el.classList.add('current');
            el.scrollIntoView({ behavior: 'smooth', block: 'center' });
          }
        } else {
          el.classList.remove('current');
        }
      });
    });

    function copyDeleteList() {
      const segments = [];
      const sortedSelected = Array.from(selected).sort((a, b) => a - b);

      sortedSelected.forEach(i => {
        const word = words[i];
        segments.push({ start: word.start, end: word.end });
      });

      // 合并相邻片段
      const merged = [];
      for (const seg of segments) {
        if (merged.length === 0) {
          merged.push({ ...seg });
        } else {
          const last = merged[merged.length - 1];
          if (Math.abs(seg.start - last.end) < 0.05) {
            last.end = seg.end;
          } else {
            merged.push({ ...seg });
          }
        }
      }

      const json = JSON.stringify(merged, null, 2);
      navigator.clipboard.writeText(json).then(() => {
        alert('已复制 ' + merged.length + ' 个删除片段到剪贴板');
      });
    }

    function clearAll() {
      selected.clear();
      elements.forEach((el, i) => {
        el.classList.remove('selected');
        if (autoSelected.has(i)) el.classList.add('ai-selected');
      });
      updateStats();
    }

    async function executeCut() {
      // 基于视频时长预估剪辑时间
      const videoDuration = wavesurfer.getDuration();
      const videoMinutes = (videoDuration / 60).toFixed(1);
      const estimatedTime = Math.max(5, Math.ceil(videoDuration / 4)); // 经验值:约4倍速处理
      const estMin = Math.floor(estimatedTime / 60);
      const estSec = estimatedTime % 60;
      const estText = estMin > 0 ? \`\${estMin}分\${estSec}秒\` : \`\${estSec}秒\`;

      if (!confirm(\`确认执行剪辑?\\n\\n📹 视频时长: \${videoMinutes} 分钟\\n⏱️ 预计耗时: \${estText}\\n\\n点击确定开始\`)) return;

      // 直接发送原始时间戳,不做合并(和预览一致)
      const segments = [];
      const sortedSelected = Array.from(selected).sort((a, b) => a - b);
      sortedSelected.forEach(i => {
        const word = words[i];
        segments.push({ start: word.start, end: word.end });
      });

      // 显示 loading 并开始计时
      const overlay = document.getElementById('loadingOverlay');
      const loadingTimeEl = document.getElementById('loadingTime');
      const loadingProgress = document.getElementById('loadingProgress');
      const loadingEstimate = document.getElementById('loadingEstimate');
      overlay.classList.add('show');
      loadingEstimate.textContent = \`预估剩余: \${estText}\`;

      const startTime = Date.now();
      const timer = setInterval(() => {
        const elapsed = Math.floor((Date.now() - startTime) / 1000);
        loadingTimeEl.textContent = \`已等待 \${elapsed} 秒\`;

        // 更新进度条(预估进度,最多到95%等待完成)
        const progress = Math.min(95, (elapsed / estimatedTime) * 100);
        loadingProgress.style.width = progress + '%';

        // 更新预估剩余时间
        const remaining = Math.max(0, estimatedTime - elapsed);
        if (remaining > 0) {
          loadingEstimate.textContent = \`预估剩余: \${remaining} 秒\`;
        } else {
          loadingEstimate.textContent = \`即将完成...\`;
        }
      }, 500);

      try {
        const res = await fetch('/api/cut', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(segments)  // 直接发原始数据
        });
        const data = await res.json();

        // 停止计时并隐藏 loading
        clearInterval(timer);
        loadingProgress.style.width = '100%';
        await new Promise(r => setTimeout(r, 300)); // 让进度条动画完成
        overlay.classList.remove('show');
        loadingProgress.style.width = '0%'; // 重置
        const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);

        if (data.success) {
          const msg = \`✅ 剪辑完成!(耗时 \${totalTime}s)

📁 输出文件: \${data.output}

⏱️ 时间统计:
   原时长: \${formatDuration(data.originalDuration)}
   新时长: \${formatDuration(data.newDuration)}
   删减: \${formatDuration(data.deletedDuration)} (\${data.savedPercent}%)\`;
          alert(msg);
        } else {
          alert('❌ 剪辑失败: ' + data.error);
        }
      } catch (err) {
        clearInterval(timer);
        overlay.classList.remove('show');
        loadingProgress.style.width = '0%'; // 重置
        alert('❌ 请求失败: ' + err.message + '\\n\\n请确保使用 review_server.js 启动服务');
      }
    }

    // 键盘快捷键
    document.addEventListener('keydown', e => {
      if (e.code === 'Space') {
        e.preventDefault();
        wavesurfer.playPause();
      } else if (e.code === 'ArrowLeft') {
        wavesurfer.setTime(Math.max(0, wavesurfer.getCurrentTime() - (e.shiftKey ? 5 : 1)));
      } else if (e.code === 'ArrowRight') {
        wavesurfer.setTime(wavesurfer.getCurrentTime() + (e.shiftKey ? 5 : 1));
      }
    });

    render();
  </script>
</body>
</html>`;

fs.writeFileSync("review.html", html);
console.log("✅ 已生成 review.html");
console.log("📌 启动服务器: python3 -m http.server 8899");
console.log("📌 打开: http://localhost:8899/review.html");

```

### scripts/generate_subtitles.js

```javascript
#!/usr/bin/env node
/**
 * 从火山引擎结果生成字级别字幕
 *
 * 用法: node generate_subtitles.js <volcengine_result.json> [delete_segments.json]
 * 输出: subtitles_words.json
 */

const fs = require("fs");

const resultFile = process.argv[2] || "volcengine_result.json";
const deleteFile = process.argv[3];

if (!fs.existsSync(resultFile)) {
  console.error("❌ 找不到文件:", resultFile);
  process.exit(1);
}

const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));

// 提取所有字
const allWords = [];
for (const utterance of result.utterances) {
  if (utterance.words) {
    for (const word of utterance.words) {
      allWords.push({
        text: word.text,
        start: word.start_time / 1000,
        end: word.end_time / 1000,
      });
    }
  }
}

console.log("原始字数:", allWords.length);

// 如果有删除片段,映射时间
let outputWords = allWords;

if (deleteFile && fs.existsSync(deleteFile)) {
  const deleteSegments = JSON.parse(fs.readFileSync(deleteFile, "utf8"));
  console.log("删除片段数:", deleteSegments.length);

  function getDeletedTimeBefore(time) {
    let deleted = 0;
    for (const seg of deleteSegments) {
      if (seg.end <= time) {
        deleted += seg.end - seg.start;
      } else if (seg.start < time) {
        deleted += time - seg.start;
      }
    }
    return deleted;
  }

  function isDeleted(start, end) {
    for (const seg of deleteSegments) {
      if (start < seg.end && end > seg.start) return true;
    }
    return false;
  }

  outputWords = [];
  for (const word of allWords) {
    if (!isDeleted(word.start, word.end)) {
      const deletedBefore = getDeletedTimeBefore(word.start);
      outputWords.push({
        text: word.text,
        start: Math.round((word.start - deletedBefore) * 100) / 100,
        end: Math.round((word.end - deletedBefore) * 100) / 100,
      });
    }
  }
  console.log("映射后字数:", outputWords.length);
}

// 添加空白标记(>0.5秒的静音按1秒拆分,便于精细控制)
const wordsWithGaps = [];
let lastEnd = 0;

for (const word of outputWords) {
  const gapDuration = word.start - lastEnd;

  if (gapDuration > 0.1) {
    // 如果静音 >0.5秒,按1秒拆分
    if (gapDuration > 0.5) {
      let gapStart = lastEnd;
      while (gapStart < word.start) {
        const gapEnd = Math.min(gapStart + 1, word.start);
        wordsWithGaps.push({
          text: "",
          start: Math.round(gapStart * 100) / 100,
          end: Math.round(gapEnd * 100) / 100,
          isGap: true,
        });
        gapStart = gapEnd;
      }
    } else {
      // <1秒的静音保持原样
      wordsWithGaps.push({
        text: "",
        start: Math.round(lastEnd * 100) / 100,
        end: Math.round(word.start * 100) / 100,
        isGap: true,
      });
    }
  }

  wordsWithGaps.push({
    text: word.text,
    start: word.start,
    end: word.end,
    isGap: false,
  });
  lastEnd = word.end;
}

const gaps = wordsWithGaps.filter((w) => w.isGap);
console.log("总元素数:", wordsWithGaps.length);
console.log("空白段数:", gaps.length);

fs.writeFileSync(
  "subtitles_words.json",
  JSON.stringify(wordsWithGaps, null, 2)
);
console.log("✅ 已保存 subtitles_words.json");

```

### scripts/review_server.js

```javascript
#!/usr/bin/env node
/**
 * 审核服务器
 *
 * 功能:
 * 1. 提供静态文件服务(review.html, audio.mp3)
 * 2. POST /api/cut - 接收删除列表,执行剪辑
 *
 * 用法: node review_server.js [port] [video_file]
 * 默认: port=8899, video_file=自动检测目录下的 .mp4
 */

const http = require("http");
const fs = require("fs");
const path = require("path");
const { execSync, execFileSync } = require("child_process");

const PORT = process.argv[2] ? Number(process.argv[2]) : 8899;
if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
  throw new Error(`Invalid port: ${process.argv[2]}`);
}
const VIDEO_FILE = process.argv[3] || findVideoFile();

function findVideoFile() {
  const files = fs.readdirSync(".").filter((f) => f.endsWith(".mp4"));
  return files[0] || "source.mp4";
}

const MIME_TYPES = {
  ".html": "text/html",
  ".js": "application/javascript",
  ".css": "text/css",
  ".json": "application/json",
  ".mp3": "audio/mpeg",
  ".mp4": "video/mp4",
};

const server = http.createServer((req, res) => {
  // CORS
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");

  if (req.method === "OPTIONS") {
    res.writeHead(200);
    res.end();
    return;
  }

  // API: 执行剪辑
  if (req.method === "POST" && req.url === "/api/cut") {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      try {
        const deleteList = JSON.parse(body);

        // 保存删除列表到当前目录
        fs.writeFileSync(
          "delete_segments.json",
          JSON.stringify(deleteList, null, 2)
        );
        console.log(`📝 保存 ${deleteList.length} 个删除片段`);

        // 生成输出文件名
        const baseName = path.basename(VIDEO_FILE, ".mp4");
        const outputFile = `${baseName}_cut.mp4`;

        // 执行剪辑
        const scriptPath = path.join(__dirname, "cut_video.sh");

        if (fs.existsSync(scriptPath)) {
          console.log("🎬 调用 cut_video.sh...");
          execFileSync(
            "bash",
            [scriptPath, VIDEO_FILE, "delete_segments.json", outputFile],
            { stdio: "inherit" }
          );
        } else {
          // 如果没有 cut_video.sh,用内置的 ffmpeg 命令
          console.log("🎬 执行剪辑...");
          executeFFmpegCut(VIDEO_FILE, deleteList, outputFile);
        }

        // 获取剪辑前后的时长信息
        const originalDuration = parseFloat(
          execFileSync(
            "ffprobe",
            ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", `file:${VIDEO_FILE}`],
            { encoding: "utf8" }
          ).trim()
        );
        const newDuration = parseFloat(
          execFileSync(
            "ffprobe",
            ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", `file:${outputFile}`],
            { encoding: "utf8" }
          ).trim()
        );
        const deletedDuration = originalDuration - newDuration;
        const savedPercent = (
          (deletedDuration / originalDuration) *
          100
        ).toFixed(1);

        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(
          JSON.stringify({
            success: true,
            output: outputFile,
            originalDuration: originalDuration.toFixed(2),
            newDuration: newDuration.toFixed(2),
            deletedDuration: deletedDuration.toFixed(2),
            savedPercent,
            message: `剪辑完成: ${outputFile}`,
          })
        );
      } catch (err) {
        console.error("❌ 剪辑失败:", err.message);
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ success: false, error: err.message }));
      }
    });
    return;
  }

  // 静态文件服务(从当前目录读取)
  const baseDir = path.resolve(".");
  let filePath = req.url === "/" ? "/review.html" : req.url;
  filePath = path.resolve("." + filePath);

  // Block path traversal
  if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
    res.writeHead(403);
    res.end("Forbidden");
    return;
  }

  const ext = path.extname(filePath);
  const contentType = MIME_TYPES[ext] || "application/octet-stream";

  try {
    if (!fs.existsSync(filePath)) {
      res.writeHead(404);
      res.end("Not Found");
      return;
    }

    const stat = fs.statSync(filePath);

    // 支持 Range 请求(音频/视频拖动)
    if (req.headers.range && (ext === ".mp3" || ext === ".mp4")) {
      const range = req.headers.range.replace("bytes=", "").split("-");
      const start = parseInt(range[0], 10);
      const end = range[1] ? parseInt(range[1], 10) : stat.size - 1;

      res.writeHead(206, {
        "Content-Type": contentType,
        "Content-Range": `bytes ${start}-${end}/${stat.size}`,
        "Accept-Ranges": "bytes",
        "Content-Length": end - start + 1,
      });

      const stream = fs.createReadStream(filePath, { start, end });
      stream.on("error", () => { res.end(); });
      stream.pipe(res);
      return;
    }

    // 普通请求
    res.writeHead(200, {
      "Content-Type": contentType,
      "Content-Length": stat.size,
      "Accept-Ranges": "bytes",
    });
    const stream = fs.createReadStream(filePath);
    stream.on("error", () => { res.end(); });
    stream.pipe(res);
  } catch {
    res.writeHead(500);
    res.end("Internal Server Error");
  }
});

// 检测可用的硬件编码器
function detectEncoder() {
  const platform = process.platform;
  const encoders = [];

  // 根据平台确定候选编码器
  if (platform === "darwin") {
    encoders.push({
      name: "h264_videotoolbox",
      args: "-q:v 60",
      label: "VideoToolbox (macOS)",
    });
  } else if (platform === "win32") {
    encoders.push({
      name: "h264_nvenc",
      args: "-preset p4 -cq 20",
      label: "NVENC (NVIDIA)",
    });
    encoders.push({
      name: "h264_qsv",
      args: "-global_quality 20",
      label: "QSV (Intel)",
    });
    encoders.push({
      name: "h264_amf",
      args: "-quality balanced",
      label: "AMF (AMD)",
    });
  } else {
    // Linux
    encoders.push({
      name: "h264_nvenc",
      args: "-preset p4 -cq 20",
      label: "NVENC (NVIDIA)",
    });
    encoders.push({
      name: "h264_vaapi",
      args: "-qp 20",
      label: "VAAPI (Linux)",
    });
  }

  // 软件编码兜底
  encoders.push({
    name: "libx264",
    args: "-preset fast -crf 18",
    label: "x264 (软件)",
  });

  // 检测哪个可用
  for (const enc of encoders) {
    try {
      execSync(`ffmpeg -hide_banner -encoders 2>/dev/null | grep ${enc.name}`, {
        stdio: "pipe",
      });
      console.log(`🎯 检测到编码器: ${enc.label}`);
      return enc;
    } catch (e) {
      // 该编码器不可用,继续检测下一个
    }
  }

  // 默认返回软件编码
  return {
    name: "libx264",
    args: "-preset fast -crf 18",
    label: "x264 (软件)",
  };
}

// 缓存编码器检测结果
let cachedEncoder = null;
function getEncoder() {
  if (!cachedEncoder) {
    cachedEncoder = detectEncoder();
  }
  return cachedEncoder;
}

// 内置 FFmpeg 剪辑逻辑(filter_complex 精确剪辑 + buffer + crossfade)
function executeFFmpegCut(input, deleteList, output) {
  // 配置参数
  const BUFFER_MS = 50; // 删除范围前后各扩展 50ms(吃掉气口和残音)
  const CROSSFADE_MS = 30; // 音频淡入淡出 30ms

  console.log(
    `⚙️ 优化参数: 扩展范围=${BUFFER_MS}ms, 音频crossfade=${CROSSFADE_MS}ms`
  );

  // 检测音频偏移量(audio.mp3 的 start_time)
  let audioOffset = 0;
  try {
    const offsetCmd =
      "ffprobe -v error -show_entries format=start_time -of csv=p=0 audio.mp3";
    audioOffset = parseFloat(execSync(offsetCmd).toString().trim()) || 0;
    if (audioOffset > 0) {
      console.log(`🔧 检测到音频偏移: ${audioOffset.toFixed(3)}s,自动补偿`);
    }
  } catch (e) {
    // 忽略,使用默认 0
  }

  // 获取视频总时长
  const probeCmd = `ffprobe -v error -show_entries format=duration -of csv=p=0 "file:${input}"`;
  const duration = parseFloat(execSync(probeCmd).toString().trim());

  const bufferSec = BUFFER_MS / 1000;
  const crossfadeSec = CROSSFADE_MS / 1000;

  // 补偿偏移 + 扩展删除范围(前后各加 buffer)
  const expandedDelete = deleteList
    .map((seg) => ({
      start: Math.max(0, seg.start - audioOffset - bufferSec),
      end: Math.min(duration, seg.end - audioOffset + bufferSec),
    }))
    .sort((a, b) => a.start - b.start);

  // 合并重叠的删除段
  const mergedDelete = [];
  for (const seg of expandedDelete) {
    if (
      mergedDelete.length === 0 ||
      seg.start > mergedDelete[mergedDelete.length - 1].end
    ) {
      mergedDelete.push({ ...seg });
    } else {
      mergedDelete[mergedDelete.length - 1].end = Math.max(
        mergedDelete[mergedDelete.length - 1].end,
        seg.end
      );
    }
  }

  // 计算保留片段
  const keepSegments = [];
  let cursor = 0;

  for (const del of mergedDelete) {
    if (del.start > cursor) {
      keepSegments.push({ start: cursor, end: del.start });
    }
    cursor = del.end;
  }
  if (cursor < duration) {
    keepSegments.push({ start: cursor, end: duration });
  }

  console.log(
    `保留 ${keepSegments.length} 个片段,删除 ${mergedDelete.length} 个片段`
  );

  // 生成 filter_complex(带 crossfade)
  const filters = [];
  let vconcat = "";

  for (let i = 0; i < keepSegments.length; i++) {
    const seg = keepSegments[i];
    filters.push(
      `[0:v]trim=start=${seg.start.toFixed(3)}:end=${seg.end.toFixed(3)},setpts=PTS-STARTPTS[v${i}]`
    );
    filters.push(
      `[0:a]atrim=start=${seg.start.toFixed(3)}:end=${seg.end.toFixed(3)},asetpts=PTS-STARTPTS[a${i}]`
    );
    vconcat += `[v${i}]`;
  }

  // 视频直接 concat
  filters.push(`${vconcat}concat=n=${keepSegments.length}:v=1:a=0[outv]`);

  // 音频使用 acrossfade 逐个拼接(消除接缝咔声)
  if (keepSegments.length === 1) {
    filters.push("[a0]anull[outa]");
  } else {
    let currentLabel = "a0";
    for (let i = 1; i < keepSegments.length; i++) {
      const nextLabel = `a${i}`;
      const outLabel = i === keepSegments.length - 1 ? "outa" : `amid${i}`;
      filters.push(
        `[${currentLabel}][${nextLabel}]acrossfade=d=${crossfadeSec.toFixed(3)}:c1=tri:c2=tri[${outLabel}]`
      );
      currentLabel = outLabel;
    }
  }

  const filterComplex = filters.join(";");

  const encoder = getEncoder();
  console.log(`✂️ 执行 FFmpeg 精确剪辑(${encoder.label})...`);

  const cmd = `ffmpeg -y -i "file:${input}" -filter_complex "${filterComplex}" -map "[outv]" -map "[outa]" -c:v ${encoder.name} ${encoder.args} -c:a aac -b:a 192k "file:${output}"`;

  try {
    execSync(cmd, { stdio: "pipe" });
    console.log(`✅ 输出: ${output}`);

    const newDuration = parseFloat(
      execSync(
        `ffprobe -v error -show_entries format=duration -of csv=p=0 "file:${output}"`
      )
        .toString()
        .trim()
    );
    console.log(`📹 新时长: ${newDuration.toFixed(2)}s`);
  } catch (err) {
    console.error("FFmpeg 执行失败,尝试分段方案...");
    executeFFmpegCutFallback(input, keepSegments, output);
  }
}

// 备用方案:分段切割 + concat(当 filter_complex 失败时使用)
function executeFFmpegCutFallback(input, keepSegments, output) {
  const tmpDir = `tmp_cut_${Date.now()}`;
  fs.mkdirSync(tmpDir, { recursive: true });

  try {
    const partFiles = [];
    keepSegments.forEach((seg, i) => {
      const partFile = path.join(
        tmpDir,
        `part${i.toString().padStart(4, "0")}.mp4`
      );
      const segDuration = seg.end - seg.start;

      const encoder = getEncoder();
      const cmd = `ffmpeg -y -ss ${seg.start.toFixed(3)} -i "file:${input}" -t ${segDuration.toFixed(3)} -c:v ${encoder.name} ${encoder.args} -c:a aac -b:a 128k -avoid_negative_ts make_zero "${partFile}"`;

      console.log(
        `切割片段 ${i + 1}/${keepSegments.length}: ${seg.start.toFixed(2)}s - ${seg.end.toFixed(2)}s`
      );
      execSync(cmd, { stdio: "pipe" });
      partFiles.push(partFile);
    });

    const listFile = path.join(tmpDir, "list.txt");
    const listContent = partFiles
      .map((f) => `file '${path.resolve(f)}'`)
      .join("\n");
    fs.writeFileSync(listFile, listContent);

    const concatCmd = `ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${output}"`;
    console.log("合并片段...");
    execSync(concatCmd, { stdio: "pipe" });

    console.log(`✅ 输出: ${output}`);
  } finally {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  }
}

server.listen(PORT, () => {
  console.log(`
🎬 审核服务器已启动
📍 地址: http://localhost:${PORT}
📹 视频: ${VIDEO_FILE}

操作说明:
1. 在网页中审核选择要删除的片段
2. 点击「🎬 执行剪辑」按钮
3. 等待剪辑完成
  `);
});

```

### scripts/volcengine_transcribe.sh

```bash
#!/bin/bash
#
# 火山引擎语音识别(异步模式)
#
# 用法: ./volcengine_transcribe.sh <audio_url>
# 输出: volcengine_result.json
#

AUDIO_URL="$1"

if [ -z "$AUDIO_URL" ]; then
  echo "❌ 用法: ./volcengine_transcribe.sh <audio_url>"
  exit 1
fi

# 获取 API Key
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$(dirname "$(dirname "$SCRIPT_DIR")")/.env"

if [ ! -f "$ENV_FILE" ]; then
  echo "❌ 找不到 $ENV_FILE"
  echo "请创建: cp .env.example .env 并填入 VOLCENGINE_API_KEY"
  exit 1
fi

API_KEY=$(grep -m1 '^VOLCENGINE_API_KEY=' "$ENV_FILE" | cut -d'=' -f2- | tr -d '\r')
if [ -z "$API_KEY" ]; then
  echo "❌ VOLCENGINE_API_KEY not set or empty in $ENV_FILE"
  exit 1
fi

echo "🎤 提交火山引擎转录任务..."
echo "音频 URL: $AUDIO_URL"

# 读取热词词典
DICT_FILE="$(dirname "$SCRIPT_DIR")/subtitles/dictionary.txt"
HOT_WORDS=""
if [ -f "$DICT_FILE" ]; then
  # 把词典转换成 JSON 数组格式
  HOT_WORDS=$(cat "$DICT_FILE" | grep -v '^$' | while read word; do echo "\"$word\""; done | tr '\n' ',' | sed 's/,$//')
  echo "📖 加载热词: $(cat "$DICT_FILE" | grep -v '^$' | wc -l | tr -d ' ') 个"
fi

# Escape special characters for JSON
escape_json() {
  printf '%s\n' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr -d '\n'
}

ESCAPED_URL=$(escape_json "$AUDIO_URL")

# 构建请求体
if [ -n "$HOT_WORDS" ]; then
  REQUEST_BODY="{\"url\": \"$ESCAPED_URL\", \"hot_words\": [$HOT_WORDS]}"
else
  REQUEST_BODY="{\"url\": \"$ESCAPED_URL\"}"
fi

# 步骤1: 提交任务
SUBMIT_RESPONSE=$(curl -s -L -X POST "https://openspeech.bytedance.com/api/v1/vc/submit?language=zh-CN&use_itn=True&use_capitalize=True&max_lines=1&words_per_line=15" \
  -H "Accept: */*" \
  -H "x-api-key: $API_KEY" \
  -H "Connection: keep-alive" \
  -H "content-type: application/json" \
  -d "$REQUEST_BODY")

# 提取任务 ID
TASK_ID=$(echo "$SUBMIT_RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)

if [ -z "$TASK_ID" ]; then
  echo "❌ 提交失败,响应:"
  echo "$SUBMIT_RESPONSE"
  exit 1
fi

echo "✅ 任务已提交,ID: $TASK_ID"
echo "⏳ 等待转录完成..."

# 步骤2: 轮询结果
MAX_ATTEMPTS=120  # 最多等待 10 分钟(每 5 秒查一次)
ATTEMPT=0

while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
  sleep 5
  ATTEMPT=$((ATTEMPT + 1))

  QUERY_RESPONSE=$(curl -s -L -X GET "https://openspeech.bytedance.com/api/v1/vc/query?id=$TASK_ID" \
    -H "Accept: */*" \
    -H "x-api-key: $API_KEY" \
    -H "Connection: keep-alive")

  # 检查状态
  STATUS=$(echo "$QUERY_RESPONSE" | grep -o '"code":[0-9]*' | head -1 | cut -d':' -f2)

  if [ "$STATUS" = "0" ]; then
    # 成功完成
    echo "$QUERY_RESPONSE" > volcengine_result.json
    echo "✅ 转录完成,已保存 volcengine_result.json"

    # 显示统计
    UTTERANCES=$(echo "$QUERY_RESPONSE" | grep -o '"text"' | wc -l)
    echo "📝 识别到 $UTTERANCES 段语音"
    exit 0
  elif [ "$STATUS" = "1000" ]; then
    # 处理中
    echo -n "."
  else
    # 其他错误
    echo ""
    echo "❌ 转录失败,响应:"
    echo "$QUERY_RESPONSE"
    exit 1
  fi
done

echo ""
echo "❌ 超时,任务未完成"
exit 1

```