Skill: abill6688/video-slicer (version 1.2.0)

--- name: video-slicer version: "1.2.0" description: > 视频智能切片工具。从长视频中按主题切割出3-5分钟（最长8分钟）的独立完整短视频片段， 适用于自媒体内容制作、演讲精华提取、课程视频剪辑、会议录像精剪等场景。 完整工作流：关键帧采样→视觉定位→Whisper音频转写→内容分析→ffmpeg精准切割。 支持中文语音识别（Whisper + MPS加速）。 当用户提到"视频切割""视频切片""切短视频""演讲精华""截取视频片段""视频剪辑" "自媒体视频""提取演讲片段""会议精剪"时触发此技能。 trigger: - 视频切割 - 视频切片 - 切短视频 - 演讲精华 - 自媒体视频 - 截取视频片段 - 会议录像精剪 - 课程视频剪辑 author: Bill (米赋教育 / @bill-mifu) homepage: https://clawhub.ai/bill-mifu/video-slicer license: MIT-0 metadata: openclaw: requires: bins: - ffmpeg - python3 pip: - openai-whisper - opencc-python-reimplemented env: [] compatible_models: - glm-5v-turbo - gpt-4o - claude-3.5-sonnet - any-multimodal-agent tags: - video - audio - whisper - ffmpeg - content-creation - chinese - social-media agent_created: true --- # Video Slicer — 视频智能切片 Skill > **从长视频中自动定位目标演讲/段落，切割出适合自媒体发布的独立短视频（3-5min，最长8min）** > > **推荐模型**: `glm-5v-turbo` | **兼容**: 所有多模态 Agent --- ## 核心能力 | 能力 | 说明 | |------|------| | 关键帧采样 | 均匀/精细两种模式，快速定位目标时间段 | | Whisper 转写 | 本地中文语音识别，MPS GPU 加速，支持繁简转换 | | 内容智能分析 | 基于转写文本自动设计切片方案（主题+时间戳） | | 精准切割 | ffmpeg H.264 重编码，句子边界对齐，高质量输出 | | 批量处理 | 支持一次规划多个切片，一键批量输出 | ## 适用场景 - 自媒体账号运营：从长演讲/直播中截取金句片段 - 教育内容制作：课程视频分章节剪辑 - 会议纪要精剪：从3h+会议录像中提取核心发言 - 播客/访谈剪辑：按话题分段发布 - 个人知识管理：录制内容归档整理 ## 工作流程（4个 Phase） ### Phase 1: 快速预览（~2min）— 定位目标区域 **Step 1 — 获取视频信息** ```bash # 获取时长、分辨率、编码格式 ffmpeg -i <video.mp4> 2>&1 | grep -E "Duration|Stream|Video" ``` **Step 2 — 粗粒度关键帧采样**（每5分钟一帧，把握整体结构） ```bash VIDEO="path/to/video.mp4" OUT_DIR="./frames" mkdir -p "$OUT_DIR" for t in $(seq 0 5 200); do ts=$(printf "%02d:%02d:00" $((t/60)) $((t%60))) ffmpeg -y -ss "$ts" -i "$VIDEO" -frames:v 1 -q:v 2 "${OUT_DIR}/frame_${ts}.jpg" 2>/dev/null done ``` **Step 3 — 目视检查关键帧** → 用 Read 工具查看每帧图片 → 定位目标演讲者的大致时间段 ### Phase 2: 精细定位（~3-5min）— 精确起止点 **Step 4 — 过渡区间精细采样**（每2分钟一帧） ```bash # 在疑似起始/结束区间前后精细采样 for m in 38 40 42 44 46; do ffmpeg -y -ss "00:${m}:00" -i "$VIDEO" -frames:v 1 -q:v 2 "./frames/fine_00_${m}.jpg" 2>/dev/null done ``` **Step 5 — 提取音频 + Whisper 转写** 这是**最关键的步骤**。对于长视频（>30min），**必须先提取音频片段再转写**： ```bash # 5A: 先用 ffmpeg 提取目标区间的音频（比直接喂视频快10倍） ffmpeg -y -i "$VIDEO" -ss 00:40:00 -to 01:07:00 \ -vn -acodec copy ./transcript/segment.aac ``` ```python # 5B: Whisper 转写音频（Python） import os, ssl, certifi, json, torch, whisper from opencc import OpenCC # macOS SSL 必需修复 ssl._create_default_https_context = lambda: ssl.create_default_context(cafile=certifi.where()) os.environ['SSL_CERT_FILE'] = certifi.where() AUDIO = "./transcript/segment.aac" START_OFFSET = 2400 # 音频起点对应的全视频偏移量(秒) device = "mps" if torch.backends.mps.is_available() else "cpu" model = whisper.load_model("base", device=device) result = model.transcribe(AUDIO, language="zh", verbose=False, initial_prompt="根据视频主题设置关键词提示") # 繁简转换 + 时间戳校正 cc = OpenCC('t2s') segments = [] for seg in result["segments"]: segments.append({ "start": round(seg["start"] + START_OFFSET, 1), "end": round(seg["end"] + START_OFFSET, 1), "text": cc.convert(seg["text"].strip()) }) # 保存结构化结果 with open("./transcript/speech.json", "w", encoding="utf-8") as f: json.dump(segments, f, ensure_ascii=False, indent=2) print(f"[OK] 转写完成! {len(segments)} 个片段") ``` ### Phase 3: 内容分析与切片规划 **Step 6 — 分析转写文本，设计切片方案** 基于 `speech.json` 中的带时间戳文本，Agent 需要设计合理的切片方案： #### 切片设计原则 | 原则 | 说明 | |------|------| | 时长控制 | 每段 **3-5 分钟**，最长 **不超过 8 分钟** | | 单一主题 | 每段聚焦一个明确的核心论点/概念 | | 开头 Hook | 以金句/故事/数据冲击开头，前3秒抓住注意力 | | 结尾完整 | 在句子自然结束处切断，不截断语义 | | 可选过渡 | 段间允许 5-10 秒重叠，保证衔接自然 | #### 常见切片模式参考 | 类型 | 时长 | 适用场景 | |------|------|----------| | 开场震撼篇 | 4-5min | 背景、问题引入、数据冲击 | | 核心概念篇 | 5-6min | 主要论点、定义解释、框架介绍 | | 高观点/新观点篇 | 4-5min | 哲学思考、范式转换、认知升级 | | 实践指南篇 | 4-5min | 原则、方法论、操作步骤、案例 | | 展望总结篇 | 3-4min | 未来趋势、行动呼吁、价值升华 | #### 切片方案输出格式 将方案保存为 JSON 供切割脚本使用： ```json [ { "clip_id": 1, "start": "00:40:00", "end": "00:45:00", "title": "教育为什么必须变", "theme": "开场震撼", "hook": "工业时代的教育体系已经过时了...", "duration_sec": 300 } ] ``` 同时生成人类可读的 `clips_plan.md` 文档。 ### Phase 4: 执行切割 **Step 7 — ffmpeg 逐段精准切割** ```bash V="/path/to/source.mp4" O="./output" mkdir -p "$O" # 单段切割命令模板（H.264重编码确保精确切割点） ffmpeg -y -ss <START_TIME> -to <END_TIME> -i "$V" \ -c:v libx264 -preset fast -crf 18 \ -c:a aac -b:a 128k \ "${O}/clipN_<标题>.mp4" ``` **参数说明：** | 参数 | 值 | 含义 | |------|-----|------| | `-c:v libx264` | H.264 | 兼容性最好的视频编码 | | `-preset fast` | fast | 编码速度与质量平衡点 | | `-crf 18` | 18 | 高质量（越小越好，0-51，推荐 17-23） | | `-c:a aac` | AAC | 通用音频编码 | | `-b:a 128k` | 128kbps | 音频质量足够好 | **批量切割脚本：** ```bash #!/bin/bash V="$HOME/Downloads/source_video.mp4" OUT="./output" mkdir -p "$OUT" # 从 clips_plan.json 读取并切割 while IFS=',' read -r start end filename title; do echo ">>> 切割: $title ($start -> $end)" ffmpeg -y -ss "$start" -to "$end" -i "$V" \ -c:v libx264 -preset fast -crf 18 \ -c:a aac -b:a 128k \ "${OUT}/${filename}.mp4" 2>/dev/null done < clips.csv echo "=== 全部完成 ===" ls -lh "$OUT"/*.mp4 ``` ## 一键脚本用法 Skill 附带 Python 一键脚本，支持基础自动化： ```bash # 完整工作流（采样帧 + 转写短视频 + 输出结构化数据） python3 {baseDir}/scripts/video_slicer.py <视频文件路径> [输出目录] # 示例 python3 {baseDir}/scripts/video_slicer.py ~/Downloads/演讲视频.mp4 ./my_output ``` > **注意**: 对于超过 30 分钟的视频，脚本会跳过全量转写（避免超时），需要 Agent 手动指定时间范围后调用 `transcribe_segment()` 函数。 ## 环境依赖 | 工具 | 版本要求 | 用途 | 安装方式 | |------|----------|------|----------| | **ffmpeg** | >= 5.0 | 视频/音频处理核心引擎 | `brew install ffmpeg` | | **Python** | >= 3.10 | Whisper 运行环境 | 系统 / conda | | **openai-whisper** | >= 20231117 | OpenAI 本地语音转文字模型 | `pip install openai-whisper` | | **opencc-python-reimplemented** | >= 0.1.2 | 繁体中文→简体转换 | `pip install opencc-python-reimplemented` | | **PyTorch** | >= 2.0 | Whisper 底层依赖（含 MPS 支持） | `pip install torch` | ### 一键安装依赖 ```bash pip install openai-whisper opencc-python-reimplemented torch brew install ffmpeg # macOS ``` ## Whisper 模型选择指南 | 模型 | 大小 | MPS速度* | 中文质量 | 推荐场景 | |------|------|----------|----------|----------| | `tiny` | 39MB | 最快 (~5x) | 差 | 仅用于粗略定位，不推荐生产使用 | | `base` | 139MB | 快 (~1x) | 中等良好 | **日常使用推荐** ✅ 默认选择 | | `small` | 461M | 中等 (~0.5x) | 较好 | 需要高精度转写时 | | `medium` | 1.5GB | 慢 (~0.15x) | 好 | 专业级（慎用，非常慢） | *\* MPS 速度为相对于 base 模型在 Apple M 系列芯片上的大致倍率* ## 文件输出规范 完成后的标准输出目录结构： ``` project_output/ ├── output/ # ✅ 最终切片视频（交付物） │ ├── clip1_<主题>.mp4 # 每个 3-8 min，H.264 编码 │ ├── clip2_<主题>.mp4 │ └── clipN_<主题>.mp4 ├── frames/ # 采样关键帧（调试/定位用） │ ├── frame_00:00:00.jpg │ ├── frame_00:05:00.jpg │ └── fine_00_40.jpg # 精细采样帧 ├── transcript/ # 转写文本数据 │ ├── speech.json # 结构化 JSON（含精确时间戳） │ ├── speech.txt # 可读纯文本 │ └── segment.aac # 提取的音频片段 └── clips_plan.md # 切片方案文档（人工可审阅） ``` ## 常见问题与避坑指南 ### Q1: 全视频太长（3h+），Whisper 处理超时怎么办？ **A: 这是新手最容易踩的坑！** 核心优化技巧——先提取音频再转写： ```bash # ❌ 错误：直接喂 3 小时视频给 Whisper（可能需要 30min+ 或直接 OOM） whisper long_video.mp4 --model base --language zh # ✅ 正确：先用 ffmpeg 提取 27 分钟音频片段，再转写（只需 2-3min） ffmpeg -y -i long_video.mp4 -ss 00:40 -to 01:07 -vn -acodec copy segment.aac whisper segment.aac --model base --language zh ``` ### Q2: 切割后视频开头有黑屏/画面滞后？ **A:** 使用 re-encoding 模式（`-c:v libx264`）而非 stream copy（`-c:v copy`）。重编码模式能确保切割点的帧级精确度。 ### Q3: 如何保证每个切片在完整句子处结束？ **A:** 利用 Whisper 转写的 sentence-level 时间戳（`segments[].end`），找到最近的句子结束时间作为切割终点。**不要**用任意秒数切割。 ### Q4: Whisper 转出的中文是繁体字？ **A:** 这是正常现象（Whisper 训练数据包含大量繁体文本）。使用 OpenCC 的 `t2s` 模式即可自动转换为简体。 ### Q5: macOS 上运行报 SSL Certificate 错误？ **A:** 在导入 whisper 之前添加以下代码： ```python import ssl, certifi ssl._create_default_https_context = lambda: ssl.create_default_context(cafile=certifi.where()) os.environ['SSL_CERT_FILE'] = certifi.where() ``` ### Q6: 视频是 HEVC/H.265 编码，切割后不兼容？ **A:** 使用 `-c:v libx264` 重编码为 H.264 即可解决兼容性问题。 ## 版本历史 | 版本 | 日期 | 变更说明 | |------|------|----------| | 1.0.0 | 2026-05-16 | 初始版本，基于中关村「教育+科技」创新周实战验证 | | 1.1.0 | 2026-05-16 | 增加 ClawHub 元数据规范，优化 description 触发词 | | 1.2.0 | 2026-05-16 | 完整用户手册，标准化输出格式，兼容性增强 | ## 相关文档 - 详细用户手册: `{baseDir}/README.md` - 一键脚本源码: `{baseDir}/scripts/video_slicer.py`

path: README.md
content: # Video Slicer — 视频智能切片工具 用户手册

> **版本**: 1.2.0 | **作者**: Bill (米赋教育) | **兼容模型**: glm-5v-turbo / GPT-4o / Claude 3.5 Sonnet
>
> **ClawHub**: https://clawhub.ai/bill-mifu/video-slicer
>
> **许可证**: MIT-0

---

## 目录

1. [简介](#1-简介)
2. [快速开始](#2-快速开始)
3. [安装与环境配置](#3-安装与环境配置)
4. [完整工作流详解](#4-完整工作流详解)
5. [一键脚本使用指南](#5-一键脚本使用指南)
6. [API 参考](#6-api-参考)
7. [进阶技巧](#7-进阶技巧)
8. [常见问题 FAQ](#8-常见问题-faq)
9. [版本历史](#9-版本历史)
10. [贡献指南](#10-贡献指南)

---

## 1. 简介

### 1.1 这个 Skill 做什么？

**Video Slicer** 是一个面向 AI Agent 的视频智能切割技能。它能从数小时的长视频中，精准定位目标演讲/段落，自动分析内容结构，并按主题切割出适合在抖音、B站、小红书等平台发布的独立短视频（**每个 3-5 分钟，最长不超过 8 分钟**）。

### 1.2 核心技术栈

| 组件 | 技术选型 | 理由 |
|------|----------|------|
| 视频处理引擎 | **ffmpeg** | 业界标准，支持几乎所有格式 |
| 语音转文字 | **OpenAI Whisper (local)** | 本地运行，隐私安全，中文效果优秀 |
| GPU 加速 | **Apple MPS** (macOS) | Apple Silicon 原生加速，速度提升 3-5x |
| 繁简转换 | **OpenCC** | 自动将 Whisper 输出的繁体转为简体 |
| AI 模型推荐 | **GLM-5V-Turbo** | 多模态理解能力强，适合视觉+文本联合分析 |

### 1.3 典型应用场景

| 场景 | 示例 | 切片数量 |
|------|------|----------|
| 学术会议精剪 | 从 3h 会议中提取某教授的核心发言 | 4-8 个 |
| 课程视频分章 | 将 2h 录播课拆为知识点片段 | 6-12 个 |
| 播客/访谈分段 | 按话题切分长访谈 | 5-10 个 |
| 直播回放精华 | 提取直播中的高光时刻 | 3-6 个 |
| 个人录制剪辑 | 自己录制的教学/分享视频 | 自定义 |

---

## 2. 快速开始

### 2.1 最快上手（3 步）

```bash
# Step 1: 安装依赖（一次性）
pip install openai-whisper opencc-python-reimplemented torch
brew install ffmpeg   # macOS

# Step 2: 运行一键脚本
python3 scripts/video_slicer.py ~/Downloads/你的视频.mp4 ./输出目录

# Step 3: 查看采样帧 → 手动指定时间范围 → 获得切片结果
```

### 2.2 Agent 调用示例

当你对 AI Agent 说以下任一指令时，此 Skill 会被触发：

```
"帮我把这个视频切成短视频"
"从会议录像中提取张三的发言"
"把这个3小时的讲座按主题切一下"
"帮我做自媒体用的视频切片"
```

Agent 会自动执行完整的 4 阶段工作流。

---

## 3. 安装与环境配置

### 3.1 系统要求

| 要求 | 最低配置 | 推荐配置 |
|------|----------|----------|
| 操作系统 | macOS 12+ / Linux / Windows WSL2 | macOS 14+ (Apple Silicon) |
| Python | 3.10+ | 3.12+ |
| 内存 | 8 GB | 16 GB+ |
| 磁盘空间 | 2 GB 可用 | 10 GB+ (Whisper 模型缓存) |
| GPU | 无（CPU 可用） | Apple M1/M2/M3 (MPS) 或 NVIDIA CUDA |

### 3.2 依赖安装

#### macOS（推荐）

```bash
# Homebrew 安装 ffmpeg
brew install ffmpeg

# pip 安装 Python 依赖
pip3 install openai-whisper opencc-python-reimplemented torch torchvision

# 验证安装
ffmpeg -version        # 应显示 >= 5.0 版本
python3 -c "import whisper; print(whisper.__version__)"  # 应显示版本号
python3 -c "from opencc import OpenCC; print('OK')"       # 应打印 OK
```

#### Linux (Ubuntu/Debian)

```bash
# apt 安装 ffmpeg
sudo apt update && sudo apt install -y ffmpeg

# pip 安装 Python 依赖
pip3 install openai-whisper opencc-python-reimplemented torch torchvision --index-url https://download.pytorch.org/whl/cpu
```

#### Windows (WSL2)

```bash
# 在 WSL2 中执行与 Linux 相同的操作
sudo apt update && sudo apt install -y ffmpeg
pip3 install openai-whisper opencc-python-reimplemented torch torchvision
```

### 3.3 Whisper 模型首次下载

首次运行时会自动下载模型（约 139MB for base）：

```
模型下载位置: ~/.cache/whisper/
模型大小:
  tiny    ~39 MB    (最快, 质量低)
  base    ~139 MB   (推荐默认)
  small   ~461 MB   (质量较好)
  medium  ~1.5 GB   (质量高, 很慢)
```

如需手动下载或更换路径：

```python
import whisper
model = whisper.load_model("base", download_root="/自定义/路径")
```

---

## 4. 完整工作流详解

### Phase 1: 快速预览 — 定位目标区域

**目标：在几分钟内找到视频中目标内容的起止时间范围**

#### Step 1: 获取视频元数据

```bash
ffmpeg -i your_video.mp4 2>&1 | grep -E "Duration|Stream"
```

期望输出：
```
Duration: 03:27:15.42  # 时长 3小时27分钟
Stream #0:0: Video: hevc ... 1024x576  # HEVC编码, 分辨率
Stream #0:1: Audio: aac ... 44100 Hz  # 音频信息
```

Agent 需要记录的关键信息：
- **总时长** → 决定采样策略
- **编码格式** → 决定是否需要转码（HEVC 需转为 H.264）
- **分辨率** → 影响关键帧可读性

#### Step 2: 粗粒度关键帧采样

每 **5 分钟**采一帧，快速浏览整体结构：

```bash
VIDEO="your_video.mp4"
OUT_DIR="./frames"
mkdir -p "$OUT_DIR"

# 计算采样点（每5分钟一个）
TOTAL_SEC=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$VIDEO" | cut -d. -f1)

for t in $(seq 0 5 $(( TOTAL_SEC / 60 * 60 ))); do
  h=$((t / 3600))
  m=$(((t % 3600) / 60))
  s=$((t % 60))
  ts=$(printf "%02d:%02d:%02d" $h $m $s)
  ffmpeg -y -ss "$ts" -i "$VIDEO" -frames:v 1 -q:v 2 "${OUT_DIR}/frame_${ts//:/_}.jpg" 2>/dev/null &
done
wait
echo "[OK] 采样完成，共 $(ls ${OUT_DIR}/*.jpg | wc -l) 帧"
```

> **并行技巧**: 使用 `&` 和 `wait` 并行提取，速度提升 3-5 倍。

#### Step 3: Agent 视觉检查关键帧

AI Agent 使用视觉能力（Read 工具读取图片）逐帧分析：

```
frame_00_00_00.jpg → 开场画面，可能是主持人/赞助商
frame_00_05_00.jpg → 第一位演讲者开始
frame_00_40_00.jpg → ✅ 发现目标演讲者（陈丽教授）！
frame_01_07_00.jpg → 目标演讲者仍在台上
frame_01_10_00.jpg → 已切换到下一位演讲者
```

**结论**: 目标区间 ≈ `00:40:00` ~ `01:07:00`

### Phase 2: 精细定位 — 精确到秒级

#### Step 4: 过渡区间精细采样

在粗定位的边界前后每 **2 分钟**采一帧，找到精确的起止点：

```bash
VIDEO="your_video.mp4"

# 起始边界精细搜索 (35-45分钟区间)
for m in 36 38 40 42 44; do
  ffmpeg -y -ss "00:${m}:00" -i "$VIDEO" -frames:v 1 -q:v 2 "./frames/fine_start_${m}.jpg" 2>/dev/null
done

# 结束边界精细搜索 (1:05-1:10 区间)
for m in 65 67 69; do
  ffmpeg -y -ss "01:${m}:00" -i "$VIDEO" -frames:v 1 -q:v 2 "./frames/fine_end_${m}.jpg" 2>/dev/null
done
```

#### Step 5: 音频提取 + Whisper 转写

**这是整个工作流最核心的步骤。**

##### 为什么先提取音频再转写？

| 方式 | 27分钟音频处理时间 | 内存占用 | 稳定性 |
|------|-------------------|----------|--------|
| 直接喂视频给 Whisper | ~15-20 min | 高 (~4GB) | 容易 OOM |
| **先提取音频再转写** | **~2-3 min** | **低 (~500MB)** | **稳定** |

##### 执行步骤

```python
"""
transcribe_target_segment.py
功能：提取目标区间的音频并调用 Whisper 转写
"""

import os, sys, json, ssl, certifi, subprocess
from datetime import timedelta

# ================== 配置 ==================
VIDEO_PATH = "/path/to/your_video.mp4"
OUTPUT_DIR = "./transcript"
START_TIME = "00:40:00"      # 目标起始时间
END_TIME = "01:07:00"        # 目标结束时间
WHISPER_MODEL = "base"       # tiny/base/small/medium
INITIAL_PROMPT = "北京师范大学陈丽教授，未来学习中心，教育数字化转型"
# =========================================

def time_to_seconds(time_str):
    """将 HH:MM:SS 格式转换为秒数"""
    parts = time_str.split(":")
    return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])

def main():
    # SSL 修复（macOS 必需）
    ssl._create_default_https_context = lambda: ssl.create_default_context(
        cafile=certifi.where()
    )
    os.environ['SSL_CERT_FILE'] = certifi.where()

    os.makedirs(OUTPUT_DIR, exist_ok=True)

    # === Step A: 提取音频 ===
    audio_path = os.path.join(OUTPUT_DIR, "segment.aac")
    start_sec = time_to_seconds(START_TIME)
    end_sec = time_to_seconds(END_TIME)
    duration = end_sec - start_sec

    print(f"[Step 1/3] 提取音频: {START_TIME} ~ {END_TIME} ({duration}秒)")
    result = subprocess.run([
        "ffmpeg", "-y",
        "-i", VIDEO_PATH,
        "-ss", START_TIME,
        "-to", END_TIME,
        "-vn", "-acodec", "copy",
        audio_path
    ], capture_output=True, text=True)

    if not os.path.exists(audio_path):
        print(f"[ERROR] 音频提取失败: {result.stderr}")
        return None

    audio_size_mb = os.path.getsize(audio_path) / (1024 * 1024)
    print(f"         音频文件大小: {audio_size_mb:.1f} MB")

    # === Step B: Whisper 转写 ===
    import torch
    try:
        import whisper
    except ImportError:
        print("[ERROR] 未安装 whisper: pip install openai-whisper")
        return None

    device = "mps" if torch.backends.mps.is_available() else "cpu"
    print(f"[Step 2/3] 加载 Whisper 模型 ({WHISPER_MODEL}, device={device})...")
    model = whisper.load_model(WHISPER_MODEL, device=device)

    print(f"[Step 3/3] 开始转写...")
    result = model.transcribe(
        audio_path,
        language="zh",
        verbose=False,
        initial_prompt=INITIAL_PROMPT,
        word_timestamps=True  # 启用词级时间戳（更精确）
    )

    # 繁简转换 + 时间戳校正
    from opencc import OpenCC
    cc = OpenCC('t2s')

    segments = []
    for seg in result["segments"]:
        segments.append({
            "id": len(segments) + 1,
            "start": round(seg["start"] + start_sec, 1),
            "end": round(seg["end"] + start_sec, 1),
            "text": cc.convert(seg["text"].strip()),
            "logprob": round(seg.get("avg_logprob", 0), 2)
        })

    # === 保存结果 ===
    json_path = os.path.join(OUTPUT_DIR, "speech.json")
    txt_path = os.path.join(OUTPUT_DIR, "speech.txt")

    with open(json_path, "w", encoding="utf-8") as f:
        json.dump({
            "meta": {
                "source_video": VIDEO_PATH,
                "audio_segment": f"{START_TIME} ~ {END_TIME}",
                "model": WHISPER_MODEL,
                "device": device,
                "total_segments": len(segments)
            },
            "segments": segments
        }, f, ensure_ascii=False, indent=2)

    with open(txt_path, "w", encoding="utf-8") as f:
        for seg in segments:
            sh, sm = divmod(int(seg["start"]), 60)
            eh, em = divmod(int(seg["end"]), 60)
            f.write(f"[{sh:02d}:{sm:02d} -> {eh:02d}:{em:02d}] {seg['text']}\n")

    total_dur = segments[-1]["end"] - segments[0]["start"]
    print(f"\n{'='*50}")
    print(f"[OK] 转写完成!")
    print(f"     片段数: {len(segments)}")
    print(f"     总时长: {int(total_dur // 60)}分{int(total_dur % 60)}秒")
    print(f"     JSON:   {json_path}")
    print(f"     TXT:    {txt_path}")
    print(f"{'='*50}")

    return json_path


if __name__ == "__main__":
    main()
```

运行方式：
```bash
python3 transcribe_target_segment.py
```

### Phase 3: 内容分析与切片规划

**这一步由 AI Agent 的智能分析能力完成。**

Agent 读取 `speech.json` 中的带时间戳文本后，需要：

1. **语义分段**：识别自然的话题转折点
2. **主题标注**：为每段提炼核心主题
3. **时长控制**：确保每段 3-8 分钟
4. **Hook 设计**：识别每段开头是否有吸引力
5. **方案输出**：生成结构化的切割计划

#### Agent 分析流程（伪代码）

```
输入: speech.json (357个带时间戳的句子片段)

PROCESS:
  1. 全文阅读 → 识别核心论点架构
  2. 按话题聚类 → 划分 4-6 个主题块
  3. 为每个主题块确定起止句号
  4. 计算每个块的时长
  5. 调整边界使时长落在 3-8min 范围内
  6. 为每个块拟定标题和 Hook

输出: clips_plan.json + clips_plan.md
```

#### 切片计划模板

```json
{
  "video_info": {
    "source": "~/Downloads/中关村「教育+科技」创新周_original.mp4",
    "total_duration": "03:27:15",
    "speaker": "陈丽教授 (北京师范大学)",
    "target_range": "00:40:00 - 01:07:00"
  },
  "clips": [
    {
      "clip_id": 1,
      "title": "教育为什么必须变",
      "theme": "开场震撼篇",
      "start": "00:40:00",
      "end": "00:45:12",
      "duration_sec": 312,
      "hook": "工业时代的教育体系已经过时了...",
      "key_points": ["学校不再是唯一的知识来源", "互联网重构知识传递方式"],
      "sentence_start_idx": 0,
      "sentence_end_idx": 45
    },
    {
      "clip_id": 2,
      "title": "未来学习中心到底是什么",
      "theme": "核心概念篇",
      "start": "00:45:08",
      "end": "00:50:43",
      "duration_sec": 335,
      "hook": "未来的学校会变成什么样？",
      "key_points": ["学习中心的三大特征", "从教到学的范式转移"],
      "sentence_start_idx": 43,
      "sentence_end_idx": 98
    }
  ],
  "summary": {
    "total_clips": 4,
    "total_duration_min": 19.3,
    "average_clip_min": 4.8
  }
}
```

### Phase 4: 执行切割

#### 单段切割命令

```bash
ffmpeg -y \
  -ss 00:40:00 \
  -to 00:45:12 \
  -i "/path/to/source.mp4" \
  -c:v libx264 \
  -preset fast \
  -crf 18 \
  -c:a aac \
  -b:a 128k \
  -movflags +faststart \
  "./output/clip1_教育为什么必须变.mp4"
```

> `-movflags +faststart` 让视频可以在网上边下边播（重要！）

#### 批量切割脚本

```bash
#!/bin/bash
# batch_cut.sh — 批量切割脚本
# 用法: bash batch_cut.sh <clips_plan.json>

PLAN_FILE="$1"
VIDEO="$HOME/Downloads/source_video.mp4"
OUT="./output"
mkdir -p "$OUT"

# 用 python 解析 JSON 并逐行输出切割参数
python3 << 'PYEOF'
import json, sys
plan = json.load(open(sys.argv[1]))
for c in plan["clips"]:
    print(f"{c['start']}|{c['end']}|clip{c['clip_id']}_{c['title']}.mp4")
PYEOF
"$PLAN_FILE" | while IFS='|' read -r start end filename; do
  echo ">>> 切割: $filename ($start -> $end)"
  ffmpeg -y -ss "$start" -to "$end" -i "$VIDEO" \
    -c:v libx264 -preset fast -crf 18 \
    -c:a aac -b:a 128k -movflags +faststart \
    "${OUT}/${filename}" 2>/dev/null
done

echo ""
echo "=== 切割完成 ==="
ls -lh "$OUT"/*.mp4
```

---

## 5. 一键脚本使用指南

Skill 附带的 `scripts/video_slicer.py` 提供基础自动化功能。

### 5.1 基本用法

```bash
# 最简单用法
python3 scripts/video_slicer.py <视频路径> [输出目录]

# 示例
python3 scripts/video_slicer.py ~/Downloads/lecture.mp4 ./my_output
```

### 5.2 脚本执行流程

```
启动
 ↓
[1] 获取视频信息（时长、分辨率）
 ↓
[2] 均匀采样关键帧（每5分钟一帧）→ frames/
 ↓
[3] 判断视频长度:
      ├─ ≤30min → 自动全量 Whisper 转写
      └─ >30min → 提示手动指定范围
 ↓
[4] 输出结构化数据
 ↓
完成 → 输出目录已准备就绪
```

### 5.3 在代码中导入使用

```python
from video_slicer import get_video_info, sample_frames, transcribe_segment, cut_clips

# 获取视频信息
info = get_video_info("~/Downloads/video.mp4")
print(info)  # {"duration": 12345, "path": "..."}

# 采样关键帧
frames_dir = sample_frames("~/Downloads/video.mp4", "./output", info["duration"])

# 转写指定段落
json_path = transcribe_segment(
    "~/Downloads/video.mp4",
    "./output",
    start_sec=2400,   # 00:40:00
    end_sec=4020      # 01:07:00
)

# 执行切割
cut_clips(
    "~/Downloads/video.mp4",
    "./output",
    clips_plan=[
        {"start": "00:40:00", "end": "00:45:00", "title": "开场震撼"},
        {"start": "00:45:00", "end": "00:50:30", "title": "核心概念"},
    ]
)
```

### 5.4 配置项说明

在 `video_slicer.py` 顶部可调整的常量：

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `SAMPLE_INTERVAL` | 300 | 关键帧采样间隔（秒），默认5分钟 |
| `MIN_CLIP` | 180 | 最短切片时长（秒），默认3分钟 |
| `MAX_CLIP` | 480 | 最长切片时长（秒），默认8分钟 |
| `WHISPER_MODEL` | `"base"` | Whisper 模型选择 |

---

## 6. API 参考

### 6.1 `get_video_info(path)` → dict

获取视频基本信息。

**参数**:
- `path` (str): 视频文件绝对路径

**返回值**:
```python
{
    "duration": 12345,     # 总时长（秒）
    "path": "/path/to/file.mp4"
}
```
**异常**: 视频无法读取时抛出 Exception

### 6.2 `sample_frames(video_path, out_dir, duration)` → str

均匀采样视频关键帧。

**参数**:
- `video_path` (str): 视频路径
- `out_dir` (str): 输出目录
- `duration` (int/float): 视频总时长（秒）

**返回值**: `str` — 帧保存目录路径

**输出**: 在 `{out_dir}/frames/` 下生成 `frame_HH-MM-SS.jpg` 文件

### 6.3 `transcribe_segment(video_path, out_dir, start_sec, end_sec)` → str | None

提取音频片段并执行 Whisper 转写。

**参数**:
- `video_path` (str): 视频路径
- `out_dir` (str): 输出目录
- `start_sec` (int): 起始时间（秒）
- `end_sec` (int): 结束时间（秒）

**返回值**: `str` — JSON 文件路径；失败时返回 `None`

**输出文件**:
- `{out_dir}/transcript/segment.aac` — 提取的音频
- `{out_dir}/transcript/speech.json` — 结构化转写结果
- `{out_dir}/transcript/speech.txt` — 纯文本格式

### 6.4 `cut_clips(video_path, out_dir, clips_plan)` → list[dict]

按计划批量切割视频。

**参数**:
- `video_path` (str): 源视频路径
- `out_dir` (str): 输出目录
- `clips_plan` (list[dict]): 切片计划列表，每项包含:
  - `"start"` (str): 起始时间 `"HH:MM:SS"`
  - `"end"` (str): 结束时间 `"HH:MM:SS"`
  - `"title"` (str): 切片标题

**返回值**: `list[dict]` — 每项包含 `"file"` 和 `"size_mb"`

---

## 7. 进阶技巧

### 7.1 处理超长视频（> 5 小时）

对于超长视频，建议采用**分层策略**：

1. 先用 **10 分钟间隔** 采样（而非 5 分钟）
2. 只对确认的目标区间进行精细采样和转写
3. 如果目标区间仍 > 30min，拆分为多个子区间分别转写

### 7.2 提高 Whisper 中文准确率

在 `initial_prompt` 参数中提供领域关键词：

```python
# 教育类视频
initial_prompt = "北京师范大学, 未来学习中心, 教育数字化, 知识服务, 互联网+教育"

# 医疗类视频
initial_prompt = "临床诊断, 病例讨论, 手术方案, 药物治疗, 预后评估"

# 法律类视频
initial_prompt = "民法典, 合同纠纷, 证据规则, 诉讼程序, 司法解释"
```

### 7.3 批量处理多段视频

```bash
# 对文件夹中所有 mp4 执行相同操作
for video in ~/Videos/*.mp4; do
    name=$(basename "$video" .mp4)
    echo "Processing: $name"
    python3 scripts/video_slicer.py "$video" "./output/${name}"
done
```

### 7.4 切片质量优化

| 优化项 | 默认值 | 更高质量选项 |
|--------|--------|-------------|
| CRF 质量 | 18 | `17`（更高质量，文件更大） |
| 编码预设 | fast | `medium`（更慢但压缩更好） |
| 音频码率 | 128k | `192k`（更高音质） |
| 分辨率 | 原始 | `-vf scale=1080:-2`（统一 1080p）|

### 7.5 与其他 Skill 协作

| 场景 | 协作 Skill | 方式 |
|------|-----------|------|
| 切片后加字幕 | 字幕生成 Skill | 将转写文本传入 |
| 切片后发社媒 | 小红书/微信 Skill | 将标题和摘要传入 |
| 切片封面图 | 图像生成 Skill | 根据主题生成封面 |

---

## 8. 常见问题 FAQ

### Q: 支持哪些视频格式？

A: ffmpeg 支持几乎所有主流格式：MP4、MKV、MOV、AVI、FLV、WMV 等。
对于特殊格式（如某些摄像机输出的 `.mts`），可能需要额外编解码器。

### Q: Whisper 转写不准确怎么办？

A: 尝试以下方法（按优先级排序）：
1. **换更大的模型**：`base` → `small` → `medium`
2. **优化 initial_prompt**：加入视频相关的专业术语
3. **提高音频质量**：确保源视频音质清晰，无背景噪音过大
4. **分段转写**：将长音频拆为 < 10min 的短段分别转写后再合并

### Q: 切割后的视频太大怎么办？

A: 调整编码参数：
- 提高 CRF 值（18 → 23，文件缩小约 50%）
- 降低分辨率：添加 `-vf scale=720:-2`（720p）
- 降低帧率：添加 `-r 24`（24fps 足够）

### Q: 可以在 Windows 上运行吗？

A: 可以，但有以下限制：
- 没有 MPS 加速（只有 CUDA 或 CPU）
- 需要 WSL2 或原生 Windows Python 环境
- 路径分隔符需要注意

### Q: 如何处理多人同时说话的视频？

A: Whisper 不支持说话人分离（diarization）。如果需要区分不同演讲者：
1. 结合关键帧视觉判断切换时间点
2. 使用 pyannote.audio 等工具做说话人分离
3. 或手动根据转写内容和画面切换划分归属

### Q: 发布新版本后用户如何更新？

A:
```bash
clawhub install bill-mifu/video-slicer --update
# 或
openclaw skills update video-slicer
```

---

## 9. 版本历史

| 版本 | 日期 | 作者 | 变更说明 |
|------|------|------|----------|
| **1.2.0** | 2026-05-16 | Bill | ClawHub 发布版：完整用户手册、标准化 API 文档、进阶技巧、FAQ |
| 1.1.0 | 2026-05-16 | Bill | 增加 ClawHub 元数据规范，优化 description 触发词覆盖率 |
| **1.0.0** | 2026-05-16 | Bill | 初始版本，基于中关村「教育+科技」创新周实战验证 |

---

## 10. 贡献指南

欢迎提交 Issue 和 Pull Request！

### 反馈问题

请到 [ClawHub Issues](https://clawhub.ai/bill-mifu/video-slicer) 提交，包含：

1. **复现步骤**
2. **期望行为 vs 实际行为**
3. **环境信息**（OS、Python 版本、ffmpeg 版本）
4. **错误日志**

### 功能建议

我们特别希望看到以下方向的贡献：

- [ ] 支持更多语言（日文、韩文、英文等）
- [ ] 集成说话人分离（Speaker Diarization）
- [ ] 自动字幕生成（SRT 格式）
- [ ] Web UI 界面
- [ ] Docker 一键部署

---

## 致谢

- **OpenAI** — Whisper 语音识别模型
- **FFmpeg** — 多媒体处理框架
- **OpenCC** — 中文繁简转换
- **米赋教育** — 实战验证与应用场景

---

*本手册最后更新于 2026-05-16 by Bill (AI 分身)*


path: scripts/video_slicer.py
content: #!/usr/bin/env python3
"""
Video Slicer — 视频智能切片工具（一键版）
================================================

用法:
  python3 video_slicer.py <视频文件> [输出目录] [选项]

示例:
  # 基础用法（采样帧 + 短视频自动转写）
  python3 video_slicer.py ~/Downloads/演讲视频.mp4 ./output

  # 指定时间范围转写长视频中的一部分
  python3 video_slicer.py 长视频.mp4 ./output --start 00:40:00 --end 01:07:00

  # 仅采样关键帧，不做转写
  python3 video_slicer.py 视频.mp4 ./output --frames-only

工作流:
  Phase 1: 获取视频信息 → 均匀采样关键帧
  Phase 2: 提取目标音频 → Whisper 智能转写（中文+MPS加速）
  Phase 3: 输出结构化数据(JSON) → 供 Agent 规划切割方案
  Phase 4: （可选）执行 ffmpeg 批量切割

输出:
  output/
  ├── frames/           关键帧图片（jpg）
  ├── transcript/       转写结果（json + txt + aac）
  └── output/           切割后的短视频片段（mp4）

作者: Bill (米赋教育)
版本: 1.2.0 | 兼容模型: glm-5v-turbo / GPT-4o / Claude 3.5 Sonnet
"""

import os
import sys
import json
import subprocess
import ssl
import certifi
import shutil
import argparse
from datetime import timedelta


# ======================== 全局配置 ========================
DEFAULT_CONFIG = {
    "sample_interval": 300,      # 采样间隔(秒)，默认5分钟
    "min_clip": 180,             # 最短切片时长(秒) = 3分钟
    "max_clip": 480,             # 最长切片时长(秒) = 8分钟
    "whisper_model": "base",     # Whisper 模型: tiny/base/small/medium
    "crf": 18,                   # 视频质量 (0-51, 越小越好, 推荐17-23)
    "audio_bitrate": "128k",     # 音频码率
}


def time_to_seconds(time_str):
    """
    将时间字符串转换为秒数。
    支持格式: "HH:MM:SS", "MM:SS", 或纯数字(秒)

    参数:
        time_str (str): 时间字符串
    返回:
        int: 总秒数
    """
    if isinstance(time_str, (int, float)):
        return int(time_str)

    parts = str(time_str).strip().split(":")
    if len(parts) == 3:
        return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
    elif len(parts) == 2:
        return int(parts[0]) * 60 + int(parts[1])
    else:
        return int(parts[0])


def seconds_to_hms(total_sec):
    """
    将秒数转换为 HH:MM:SS 格式。

    参数:
        total_sec (int/float): 总秒数
    返回:
        str: 格式化的时间字符串
    """
    total_sec = int(total_sec)
    h = total_sec // 3600
    m = (total_sec % 3600) // 60
    s = total_sec % 60
    return f"{h:02d}:{m:02d}:{s:02d}"


# ============================================================
#                    Phase 1: 视频信息 & 帧采样
# ============================================================

def get_video_info(video_path):
    """
    获取视频基本信息：时长、分辨率、编码格式等。

    参数:
        video_path (str): 视频文件绝对路径
    返回:
        dict: 包含 duration(秒), path, resolution, codec 的字典
    异常:
        Exception: 无法读取视频时抛出
    """
    import re

    result = subprocess.run(
        ["ffmpeg", "-i", video_path],
        capture_output=True, text=True
    )
    stderr = result.stderr

    # 解析时长
    dur_match = re.search(r'Duration:\s*(\d{2}):(\d{2}):(\d{2})', stderr)
    if not dur_match:
        raise Exception(f"无法从视频获取时长信息: {video_path}")

    h, m, s = map(int, dur_match.groups())
    duration_sec = h * 3600 + m * 60 + s

    # 解析视频流信息
    codec_match = re.search(r'Stream.*Video:\s*(\w+).*?(\d+x\d+)', stderr)
    resolution = codec_match.group(2) if codec_match else "未知"
    codec = codec_match.group(1) if codec_match else "未知"

    return {
        "duration": duration_sec,
        "path": os.path.abspath(video_path),
        "filename": os.path.basename(video_path),
        "resolution": resolution,
        "codec": codec,
        "duration_hms": seconds_to_hms(duration_sec),
        "size_mb": round(os.path.getsize(video_path) / (1024 * 1024), 1),
    }


def sample_frames(video_path, out_dir, duration, interval=None):
    """
    在均匀时间间隔上采样视频关键帧。

    参数:
        video_path (str): 视频文件路径
        out_dir (str): 输出根目录
        duration (int): 视频总时长（秒）
        interval (int|None): 采样间隔（秒），默认使用配置值 300
    返回:
        dict: {"dir": 帧目录路径, "count": 采样帧数量, "files": 文件名列表}
    """
    interval = interval or DEFAULT_CONFIG["sample_interval"]
    frames_dir = os.path.join(out_dir, "frames")
    os.makedirs(frames_dir, exist_ok=True)

    # 生成采样时间点列表
    times = list(range(0, int(duration) + 1, interval))
    if times and times[-1] != int(duration):
        times.append(int(duration))

    files = []
    for t in times:
        ts = seconds_to_hms(t)
        safe_ts = ts.replace(":", "_")  # 文件名安全格式
        out_path = os.path.join(frames_dir, f"frame_{safe_ts}.jpg")

        if not os.path.exists(out_path):
            proc = subprocess.run([
                "ffmpeg", "-y",
                "-ss", ts,
                "-i", video_path,
                "-frames:v", "1",
                "-q:v", "2",
                out_path
            ], capture_output=True, timeout=30)

            if proc.returncode != 0:
                continue  # 跳过失败的帧（通常是超出视频长度）

        files.append(f"frame_{safe_ts}.jpg")

    return {
        "dir": os.path.abspath(frames_dir),
        "count": len(files),
        "files": files,
    }


def sample_frames_fine(video_path, out_dir, start_sec, end_sec, interval=120):
    """
    在指定区间进行精细关键帧采样。

    参数:
        video_path (str): 视频文件路径
        out_dir (str): 输出目录
        start_sec (int): 区间起始（秒）
        end_sec (int): 区间结束（秒）
        interval (int): 采样间隔（秒），默认 120（2分钟）
    返回:
        dict: 同 sample_frames()
    """
    frames_dir = os.path.join(out_dir, "frames")
    os.makedirs(frames_dir, exist_ok=True)

    files = []
    t = start_sec
    while t <= end_sec:
        ts = seconds_to_hms(t)
        safe_ts = ts.replace(":", "_")
        label = "fine"
        out_path = os.path.join(frames_dir, f"{label}_{safe_ts}.jpg")

        if not os.path.exists(out_path):
            subprocess.run([
                "ffmpeg", "-y",
                "-ss", ts,
                "-i", video_path,
                "-frames:v", "1",
                "-q:v", "2",
                out_path
            ], capture_output=True, timeout=30)

        files.append(f"{label}_{safe_ts}.jpg")
        t += interval

    return {"dir": os.path.abspath(frames_dir), "count": len(files), "files": files}


# ============================================================
#                 Phase 2: 音频提取 + Whisper 转写
# ============================================================

def transcribe_segment(video_path, out_dir, start_sec, end_sec,
                        model_name=None, initial_prompt=""):
    """
    从视频中提取指定区间的音频并执行 Whisper 转写。

    核心优化：先提取音频再转写，比直接喂视频快 ~10 倍。

    参数:
        video_path (str): 视频文件路径
        out_dir (str): 输出目录
        start_sec (int/float): 起始时间（秒）
        end_sec (int/float): 结束时间（秒）
        model_name (str|None): Whisper 模型名，默认 base
        initial_prompt (str): 领域关键词提示（提高准确率）
    返回:
        str|None: 成功返回 speech.json 的绝对路径，失败返回 None
    """
    model_name = model_name or DEFAULT_CONFIG["whisper_model"]

    try:
        import torch
        import whisper
        from opencc import OpenCC
    except ImportError as e:
        print(json.dumps({
            "status": "error",
            "error": f"缺少依赖库: {e}",
            "fix": "请运行: pip install openai-whisper opencc-python-reimplemented torch"
        }, ensure_ascii=False))
        return None

    # === macOS SSL 修复 ===
    ssl._create_default_https_context = lambda: ssl.create_default_context(
        cafile=certifi.where()
    )
    os.environ['SSL_CERT_FILE'] = certifi.where()

    # === 准备输出目录 ===
    transcript_dir = os.path.join(out_dir, "transcript")
    os.makedirs(transcript_dir, exist_ok=True)

    audio_path = os.path.join(transcript_dir, "segment.aac")
    ts_start = seconds_to_hms(start_sec)
    ts_end = seconds_to_hms(end_sec)
    duration = end_sec - start_sec

    # === Step 1: 提取音频 ===
    print(f"[1/3] 提取音频: {ts_start} ~ {ts_end} ({duration}秒)", file=sys.stderr)
    result = subprocess.run([
        "ffmpeg", "-y",
        "-i", video_path,
        "-ss", ts_start,
        "-to", ts_end,
        "-vn", "-acodec", "copy",
        audio_path
    ], capture_output=True, text=True)

    if not os.path.exists(audio_path) or os.path.getsize(audio_path) < 10000:
        error_msg = result.stderr[-500:] if result.stderr else "未知错误"
        print(json.dumps({
            "status": "error",
            "error": f"音频提取失败或文件过小",
            "details": error_msg.strip()[:300]
        }, ensure_ascii=False), file=sys.stderr)
        return None

    audio_mb = os.path.getsize(audio_path) / (1024 * 1024)
    print(f"      音频大小: {audio_mb:.1f} MB", file=sys.stderr)

    # === Step 2: Whisper 转写 ===
    device = "mps" if torch.backends.mps.is_available() else "cpu"
    print(f"[2/3] 加载模型 ({model_name}, device={device})...", file=sys.stderr)

    try:
        model = whisper.load_model(model_name, device=device)
    except Exception as e:
        print(json.dumps({
            "status": "error",
            "error": f"Whisper 模型加载失败: {e}"
        }, ensure_ascii=False), file=sys.stderr)
        return None

    print(f"[3/3] 转写中...", file=sys.stderr)
    try:
        whisper_result = model.transcribe(
            audio_path,
            language="zh",
            verbose=False,
            initial_prompt=initial_prompt,
            word_timestamps=False
        )
    except Exception as e:
        print(json.dumps({
            "status": "error",
            "error": f"Whisper 转写过程出错: {e}"
        }, ensure_ascii=False), file=sys.stderr)
        return None

    # === Step 3: 后处理（繁简转换 + 时间戳校正）===
    cc = OpenCC('t2s')
    segments = []
    for seg in whisper_result["segments"]:
        text_clean = cc.convert(seg["text"].strip())
        if not text_clean:
            continue
        segments.append({
            "id": len(segments) + 1,
            "start": round(seg["start"] + start_sec, 1),
            "end": round(seg["end"] + start_sec, 1),
            "text": text_clean,
            "logprob": round(seg.get("avg_logprob", 0), 2),
        })

    # === 保存结果 ===
    json_path = os.path.join(transcript_dir, "speech.json")
    txt_path = os.path.join(transcript_dir, "speech.txt")

    output_data = {
        "meta": {
            "source_video": os.path.abspath(video_path),
            "audio_segment": f"{ts_start} ~ {ts_end}",
            "audio_duration_sec": round(duration, 1),
            "whisper_model": model_name,
            "device": device,
            "total_segments": len(segments),
            "generated_at": __import__("datetime").datetime.now().isoformat(),
        },
        "segments": segments,
    }

    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(output_data, f, ensure_ascii=False, indent=2)

    with open(txt_path, "w", encoding="utf-8") as f:
        for seg in segments:
            sh, sm = divmod(int(seg["start"]), 60)
            eh, em = divmod(int(seg["end"]), 60)
            f.write(f"[{sh:02d}:{sm:02d} -> {eh:02d}:{em:02d}] {seg['text']}\n")

    # 输出结构化 JSON 结果到 stdout（供 Agent 解析）
    result_obj = {
        "status": "success",
        "json_path": os.path.abspath(json_path),
        "txt_path": os.path.abspath(txt_path),
        "audio_path": os.path.abspath(audio_path),
        "segment_count": len(segments),
        "duration_min": round((segments[-1]["end"] - segments[0]["end"]) / 60, 1) if segments else 0,
        "model": model_name,
        "device": device,
    }
    print(json.dumps(result_obj, ensure_ascii=False, indent=2))

    return json_path


# ============================================================
#                  Phase 4: 批量切割执行
# ============================================================

def cut_clips(video_path, out_dir, clips_plan, crf=None, audio_bitrate=None):
    """
    按 clips_plan 方案批量切割视频片段。

    参数:
        video_path (str): 源视频路径
        out_dir (str): 输出目录
        clips_plan (list[dict]): 切片计划列表，每项包含:
            - "start" (str): 起始时间 "HH:MM:SS"
            - "end" (str): 结束时间 "HH:MM:SS"
            - "title" (str): 切片标题（用于文件名）
        crf (int|None): 视频质量参数，默认使用配置值 18
        audio_bitrate (str|None): 音频码率，默认 "128k"
    返回:
        list[dict]: 切片结果列表 [{"file": path, "size_mb": float, "title": str}]
    """
    crf = crf or DEFAULT_CONFIG["crf"]
    audio_bitrate = audio_bitrate or DEFAULT_CONFIG["audio_bitrate"]

    output_dir = os.path.join(out_dir, "output")
    os.makedirs(output_dir, exist_ok=True)

    results = []
    total = len(clips_plan)

    for i, clip in enumerate(clips_plan, 1):
        start = clip.get("start")
        end = clip.get("end")
        title = clip.get("title", f"clip{i}")
        # 清理文件名中的非法字符
        safe_title = "".join(c for c in title if c.isalnum() or c in ("_", "-", " "))
        filename = f"clip{i}_{safe_title}.mp4"
        out_path = os.path.join(output_dir, filename)

        dur_sec = time_to_seconds(end) - time_to_seconds(start)
        print(f"[{i}/{total}] {title} ({start} -> {end}, ~{dur_sec // 60}min{dur_sec % 60}s)", file=sys.stderr)

        proc = subprocess.run([
            "ffmpeg", "-y",
            "-ss", start,
            "-to", end,
            "-i", video_path,
            "-c:v", "libx264",
            "-preset", "fast",
            "-crf", str(crf),
            "-c:a", "aac",
            "-b:a", audio_bitrate,
            "-movflags", "+faststart",
            out_path
        ], capture_output=True, timeout=600)

        if proc.returncode == 0 and os.path.exists(out_path):
            size_mb = os.path.getsize(out_path) / (1024 * 1024)
            results.append({
                "file": os.path.abspath(out_path),
                "size_mb": round(size_mb, 1),
                "title": title,
                "status": "ok",
            })
            print(f"      OK: {filename} ({size_mb:.1f} MB)", file=sys.stderr)
        else:
            results.append({
                "file": out_path,
                "size_mb": 0,
                "title": title,
                "status": "failed",
                "error": (proc.stderr[-300:] if proc.stderr else "Unknown error").decode('utf-8', errors='replace') if isinstance(proc.stderr, bytes) else str(proc.stderr)[-300:] if proc.stderr else "Unknown error",
            })
            print(f"      FAIL: {filename}", file=sys.stderr)

    # 最终汇总输出（JSON）
    summary = {
        "status": "done",
        "total_clips": total,
        "succeeded": sum(1 for r in results if r["status"] == "ok"),
        "failed": sum(1 for r in results if r["status"] == "failed"),
        "output_dir": os.path.abspath(output_dir),
        "clips": results,
    }
    print(json.dumps(summary, ensure_ascii=False, indent=2))

    print(f"\n{'='*50}", file=sys.stderr)
    print(f"[完成] {summary['succeeded']}/{total} 个切片成功", file=sys.stderr)
    print(f"[目录] {summary['output_dir']}", file=sys.stderr)
    print(f"{'='*50}", file=sys.stderr)

    return results


# ============================================================
#                      CLI 入口
# ============================================================

def main():
    parser = argparse.ArgumentParser(
        description="Video Slicer — 视频智能切片工具 v1.2.0",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  python3 video_slicer.py 视频.mp4 ./output
  python3 video_slicer.py 视频.mp4 ./output --start 00:40 --end 01:07
  python3 video_slicer.py 视频.mp4 ./output --frames-only
  python3 video_slicer.py 视频.mp4 ./output --model small
        """,
    )

    parser.add_argument("video", help="源视频文件路径")
    parser.add_argument("outdir", nargs=".", default="./video_slicer_output",
                        help="输出目录（默认: ./video_slicer_output）")
    parser.add_argument("--start", type=str, default=None,
                        help="转写起始时间 (格式: HH:MM:SS 或 MM:SS)")
    parser.add_argument("--end", type=str, default=None,
                        help="转写结束时间 (格式: HH:MM:SS 或 MM:SS)")
    parser.add_argument("--model", type=str, default=None,
                        choices=["tiny", "base", "small", "medium"],
                        help=f'Whisper 模型 (默认: {DEFAULT_CONFIG["whisper_model"]})')
    parser.add_argument("--prompt", type=str, default="",
                        help="领域关键词提示（提高转写准确率）")
    parser.add_argument("--frames-only", action="store_true",
                        help="仅采样关键帧，跳过转写步骤")
    parser.add_argument("--interval", type=int, default=None,
                        help="帧采样间隔（秒，默认: 300）")
    parser.add_argument("--crf", type=int, default=None,
                        help=f'视频 CRF 质量 (默认: {DEFAULT_CONFIG["crf"]}, 范围 0-51)')
    parser.add_argument("--fine-start", type=int, default=None,
                        help="精细采样起始位置（秒）")
    parser.add_argument("--fine-end", type=int, default=None,
                        help="精细采样结束位置（秒）")

    args = parser.parse_args()

    video_path = os.path.expanduser(args.video)
    out_dir = args.outdir

    # ====== 验证输入 ======
    if not os.path.exists(video_path):
        print(json.dumps({"status": "error", "error": f"视频文件不存在: {video_path}"}))
        sys.exit(1)

    os.makedirs(out_dir, exist_ok=True)

    # 应用命令行覆盖的配置
    if args.model:
        DEFAULT_CONFIG["whisper_model"] = args.model
    if args.crf is not None:
        DEFAULT_CONFIG["crf"] = args.crf

    print("=" * 55, file=sys.stderr)
    print("  Video Slicer v1.2.0 — 视频智能切片工具", file=sys.stderr)
    print("=" * 55, file=sys.stderr)

    # ====== Step 1: 获取视频信息 ======
    print(f"\n[Step 1] 分析视频...", file=sys.stderr)
    try:
        info = get_video_info(video_path)
    except Exception as e:
        print(json.dumps({"status": "error", "error": str(e)}))
        sys.exit(1)

    print(f"  文件:   {info['filename']}", file=sys.stderr)
    print(f"  时长:   {info['duration_hms']} ({info['duration']}秒)", file=sys.stderr)
    print(f"  分辨率: {info['resolution']} | 编码: {info['codec']} | 大小: {info['size_mb']}MB", file=sys.stderr)

    # ====== Step 2: 采样关键帧 ======
    print(f"\n[Step 2] 采样关键帧 (每{args.interval or DEFAULT_CONFIG['sample_interval']}秒)...", file=sys.stderr)
    frame_result = sample_frames(video_path, out_dir, info["duration"], args.interval)
    print(f"  完成: {frame_result['count']} 帧 -> {frame_result['dir']}", file=sys.stderr)

    # 可选：精细采样
    if args.fine_start is not None and args.fine_end is not None:
        print(f"\n[Step 2b] 精细采样 ({args.fine_start}-{args.fine_end}秒)...", file=sys.stderr)
        fine_result = sample_frames_fine(
            video_path, out_dir, args.fine_start, args.fine_end
        )
        print(f"  完成: {fine_result['count']} 帧", file=sys.stderr)

    # ====== Step 3: 转写（除非仅采帧模式）=====
    if not args.frames_only:
        if args.start and args.end:
            # 用户指定了时间范围
            start_sec = time_to_seconds(args.start)
            end_sec = time_to_seconds(args.end)
            print(f"\n[Step 3] 转写目标段落: {args.start} ~ {args.end} ...", file=sys.stderr)
            transcribe_segment(
                video_path, out_dir, start_sec, end_sec,
                model_name=args.model,
                initial_prompt=args.prompt
            )
        elif info["duration"] <= 1800:
            # 短视频（<=30min）：自动全量转写
            print(f"\n[Step 3] 视频较短({info['duration']}s<1800s)，全量转写...", file=sys.stderr)
            transcribe_segment(
                video_path, out_dir, 0, info["duration"],
                model_name=args.model,
                initial_prompt=args.prompt
            )
        else:
            # 长视频（>30min）：提示用户手动指定
            print(f"""
{'='*55}
  视频较长 ({info['duration']//60}分{info['duration']%60}秒)，建议手动指定转写范围。

  帧目录: {frame_result['dir']}
  请查看关键帧后，运行:

  python3 {__file__} "{video_path}" "{out_dir}" \\
      --start HH:MM:SS --end HH:MM:SS \\
      --prompt "关键词提示"

  或在 Python 中调用:
  from video_slicer import transcribe_segment
  transcribe_segment("{video_path}", "{out_dir}", 2400, 4020)
{'='*55}
""", file=sys.stderr)

    # ====== 完成 ======
    print(f"\n[OK] 工作目录: {os.path.abspath(out_dir)}", file=sys.stderr)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)