feat: doco P1 收尾(字幕裁切写死/dry-run/crop_params完善)

This commit is contained in:
simonkoson
2026-06-12 16:38:41 +08:00
parent 0e6322ad97
commit aa59ef78e0
3 changed files with 219 additions and 57 deletions
+76 -17
View File
@@ -18,11 +18,25 @@ Doco 将一期《军事科技》节目视频拆分为两路输入,供下游三
## 系统依赖
- **Python >= 3.12**
- **ffmpeg >= 4.x** (必须安装并加入 PATH)
- Windows 下载: https://ffmpeg.org/download.html
- macOS: `brew install ffmpeg`
- Linux: `apt install ffmpeg`
### ffmpeg (必须)
**Windows 用户:**
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg (建议用 essentials 版本)
2. 解压到本地目录(如 `C:\ffmpeg`)
3.`C:\ffmpeg\bin` 加入系统 PATH
4. 打开 cmd,验证: `ffmpeg -version`
**Mac 用户:**
```bash
brew install ffmpeg
```
**Linux 用户:**
```bash
apt install ffmpeg
```
### Python >= 3.12
## 安装
@@ -50,20 +64,65 @@ Doco 使用三组独立凭证,互不混用:
> 注意: 讯飞要用"录音文件转写标准版",不要用"大模型版"
## P1 使用方法
## 使用步骤(按顺序,不要跳步)
### 准备素材目录
### Step A. 安装 ffmpeg
```
programs/
└── ep001_20260612_fangkong_fandao/
└── source/
└── video.mp4 ← 放入节目视频
见上方"系统依赖"一节。安装后打开 cmd 验证 `ffmpeg -version` 能看到版本号。
### Step B. 生成迷你测试视频(验证 ffmpeg 装好了)
```bash
ffmpeg -f lavfi -i testsrc=duration=5:size=320x240:rate=1 \
-f lavfi -i anullsrc=channel_layout=mono:sample_rate=16000 \
-c:v libx264 -c:a aac -shortest \
doco/tests/fixtures/mini_test.mp4 -y
```
> video.mp4 由制片人放入,不放进 git
出现 `mini_test.mp4` 文件即成功。
### 运行拆分
### Step C. 把 demo 视频放到指定位置
```bash
# 把 demo 视频文件复制到:
programs/ep001_20260612_fangkong_fandao/source/video.mp4
```
> video.mp4 由制片人放入,不放进 git(已加入 .gitignore)
### Step D. 配置凭证
```bash
cp doco/.env.example doco/.env
# 用记事本或 VS Code 编辑 doco/.env,填入三组真实 API key
```
### Step E. 安装 doco 包
```bash
cd doco && pip install -e .
```
### Step F. 跑 dry-run(只裁切,不调 OCR API)
**重要:先跑 dry-run,确认裁切框包住字幕后再跑正式版。**
```bash
doco split \
--episode-id ep001_20260612_fangkong_fandao \
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
--output-dir programs/ep001_20260612_fangkong_fandao/work/ \
--dry-run
```
**验收 dry-run 结果:**
1. 检查 `work/frames/` 目录下的前 3-5 张关键帧小图
2. 确认字幕被完整框住、没有切掉字
3. 如果裁切位置不对,停下来反馈
### Step G. 跑正式版(去掉 --dry-run)
dry-run 验收通过后,跑正式版:
```bash
doco split \
@@ -76,10 +135,10 @@ doco split \
```
programs/ep001_20260612_fangkong_fandao/work/
├── frames/ # 抽出的所有帧(临时)
├── frames/ # 抽出的帧(临时)
├── audio_16k.wav # 音频(16kHz/单声道/16bit)
├── b_manuscript.txt # B 稿([Nm Ns] 句子格式)
└── keyframes.json # 关键帧索引
└── keyframes.json # 关键帧索引(含裁切参数)
```
## P1 验收标准
@@ -101,7 +160,7 @@ doco/
├── tests/
│ ├── test_video_split.py # 单元测试
│ └── fixtures/
│ └── mini_test.mp4 # 迷你测试视频
│ └── mini_test.mp4 # 迷你测试视频(需 Step B 生成)
├── docs/
├── .env.example # 凭证模板
├── README.md
+33 -7
View File
@@ -44,11 +44,29 @@ def main():
type=int,
help="pHash 海明距离阈值,用于检测字幕变化(默认 8)",
)
def split(episode_id: str, input_video: str, output_dir: str, phash_threshold: int):
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="只抽帧+裁切,不调 OCR API;用于验证裁切框位置是否正确",
)
def split(
episode_id: str,
input_video: str,
output_dir: str,
phash_threshold: int,
dry_run: bool,
):
"""
P1: 视频双路拆分
- A 路:抽帧 + pHash 变化检测 + OCR → B 稿 txt
- B 路:提取音频(16kHz/单声道/16bit WAV)
A 路:抽帧 + pHash 变化检测 + OCR → B 稿 txt
B 路:提取音频(16kHz/单声道/16bit WAV)
使用 --dry-run 可跳过 OCR 调用,先验证裁切框位置:
1. 运行 dry-run
2. 检查 work/frames/ 下的前几张关键帧小图
3. 确认字幕被完整框住后,去掉 --dry-run 跑正式版
"""
video_path = Path(input_video)
out_dir = Path(output_dir)
@@ -57,6 +75,7 @@ def split(episode_id: str, input_video: str, output_dir: str, phash_threshold: i
click.echo(f"[doco split] input_video={video_path}")
click.echo(f"[doco split] output_dir={out_dir}")
click.echo(f"[doco split] phash_threshold={phash_threshold}")
click.echo(f"[doco split] dry_run={dry_run}")
try:
result = split_video(
@@ -64,11 +83,18 @@ def split(episode_id: str, input_video: str, output_dir: str, phash_threshold: i
output_dir=out_dir,
episode_id=episode_id,
phash_threshold=phash_threshold,
dry_run=dry_run,
)
click.echo(f"[ok] B 稿: {result['b_manuscript_path']}")
click.echo(f"[ok] 音频: {result['audio_path']}")
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
if dry_run:
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
click.echo(f"[ok] 音频: {result['audio_path']}")
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
click.echo("[ok] dry-run 完成,请检查 frames/ 目录下的关键帧小图")
else:
click.echo(f"[ok] B 稿: {result['b_manuscript_path']}")
click.echo(f"[ok] 音频: {result['audio_path']}")
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
+110 -33
View File
@@ -29,6 +29,16 @@ import imagehash
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
# ========================================================================
# 字幕裁切参数
# ========================================================================
# 字幕裁切区域:全宽 × 下方 20%
# 适用于《军事科技》栏目特殊视频(黑底白字字幕,位置固定)
# 全年 52 期一致,如换栏目需重新评估此参数
SUBTITLE_CROP = "iw:ih*0.2:0:ih*0.8"
# ========================================================================
# FFmpeg 封装
# ========================================================================
@@ -49,9 +59,19 @@ def extract_frames(
video_path: Path,
output_dir: Path,
fps: int = 1,
crop: str = SUBTITLE_CROP,
dry_run: bool = False,
) -> List[Tuple[int, int, Path]]:
"""
按固定 fps 抽帧
按固定 fps 抽帧,并裁切字幕区域
参数:
video_path: 输入视频路径
output_dir: 输出目录
fps: 抽帧帧率,默认 1(每秒一帧)
crop: ffmpeg crop 表达式,默认 SUBTITLE_CROP
dry_run: True 则输出裁切后的小图;False 则输出完整帧
返回: [(frame_index, timestamp_ms, image_path), ...]
"""
check_ffmpeg()
@@ -59,12 +79,20 @@ def extract_frames(
frames_dir = output_dir / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
# ffmpeg 抽帧,格式 frame_%04d.png
# 构建 ffmpeg 滤镜链
if dry_run:
# dry-run:裁切字幕区域输出小图
filter_chain = f"fps={fps},crop={crop}"
else:
# 正式:完整帧
filter_chain = f"fps={fps}"
# ffmpeg 抽帧
frame_pattern = str(frames_dir / "frame_%04d.png")
cmd = [
"ffmpeg",
"-i", str(video_path),
"-vf", f"fps={fps}",
"-vf", filter_chain,
"-q:v", "2", # JPEG 质量
frame_pattern,
"-y", # 覆盖
@@ -271,6 +299,7 @@ def split_video(
episode_id: str,
phash_threshold: int = 8,
fps: int = 1,
dry_run: bool = False,
) -> Dict[str, any]:
"""
视频双路拆分主流程
@@ -281,6 +310,7 @@ def split_video(
episode_id: 节目 ID
phash_threshold: pHash 海明距离阈值,默认 8
fps: 抽帧帧率,默认 1(每秒一帧)
dry_run: True 则不调 OCR,只输出裁切帧和 keyframes.json
返回:
{
@@ -288,6 +318,7 @@ def split_video(
"audio_path": Path,
"keyframes_path": Path,
"keyframe_count": int,
"dry_run": bool,
}
"""
video_path = Path(video_path)
@@ -302,20 +333,27 @@ def split_video(
frames_dir.mkdir(parents=True, exist_ok=True)
print(f"[video_split] 开始处理: {video_path.name}")
print(f"[video_split] 抽帧 fps={fps}, pHash threshold={phash_threshold}")
print(f"[video_split] fps={fps}, pHash threshold={phash_threshold}, dry_run={dry_run}")
# ---- A 路:抽帧 + pHash 检测 + OCR ----
print("[video_split] A路:抽帧...")
frames = extract_frames(video_path, output_dir, fps=fps)
print(f"[video_split] A路:抽帧({'裁切模式' if dry_run else '完整帧模式'})...")
frames = extract_frames(video_path, output_dir, fps=fps, crop=SUBTITLE_CROP, dry_run=dry_run)
print(f"[video_split] 抽帧完成,共 {len(frames)}")
print("[video_split] pHash 变化检测...")
keyframes = find_keyframes(frames, threshold=phash_threshold)
print(f"[video_split] 检测到 {len(keyframes)} 个关键帧")
print("[video_split] OCR 关键帧...")
keyframes = ocr_keyframes(keyframes)
print(f"[video_split] OCR 完成")
if dry_run:
# dry-run:不调 OCR,只输出关键帧信息
print("[video_split] dry-run:跳过 OCR")
keyframes = [
{**kf, "ocr_text": ""} for kf in keyframes
]
else:
print("[video_split] OCR 关键帧...")
keyframes = ocr_keyframes(keyframes)
print(f"[video_split] OCR 完成")
# ---- B 路:音频提取 ----
print("[video_split] B路:提取音频...")
@@ -324,30 +362,69 @@ def split_video(
print(f"[video_split] 音频提取完成: {audio_path}")
# ---- 输出产物 ----
# B 稿
b_lines = build_b_manuscript(keyframes)
b_manuscript_path = output_dir / "b_manuscript.txt"
write_b_manuscript(b_lines, b_manuscript_path)
print(f"[video_split] B稿写入: {b_manuscript_path} ({len(b_lines)} 行)")
if dry_run:
# dry-run:不写 B 稿,只写 keyframes.json
keyframes_data = {
"video_path": str(video_path),
"fps_sampled": fps,
"phash_threshold": phash_threshold,
"dry_run": True,
"crop_params": {
"width_ratio": 1.0,
"height_ratio": 0.2,
"x_offset": 0.0,
"y_offset": 0.8,
"hardcoded": True,
"applies_to": "军事科技 全栏目特殊视频",
"ffmpeg_crop": SUBTITLE_CROP,
},
"keyframes": keyframes,
}
keyframes_path = output_dir / "keyframes.json"
with open(keyframes_path, "w", encoding="utf-8") as f:
json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
print(f"[video_split] dry-run keyframes.json 写入: {keyframes_path}")
print("[video_split] 请检查 frames/ 目录下的关键帧图片,确认裁切框位置正确")
# 关键帧索引 JSON
keyframes_data = {
"video_path": str(video_path),
"fps_sampled": fps,
"phash_threshold": phash_threshold,
"keyframes": keyframes,
}
keyframes_path = output_dir / "keyframes.json"
with open(keyframes_path, "w", encoding="utf-8") as f:
json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
print(f"[video_split] 关键帧索引写入: {keyframes_path}")
return {
"keyframes_path": str(keyframes_path),
"audio_path": str(audio_path),
"keyframe_count": len(keyframes),
"dry_run": True,
"b_manuscript_path": None,
}
else:
# 正式:写 B 稿 + keyframes.json
b_lines = build_b_manuscript(keyframes)
b_manuscript_path = output_dir / "b_manuscript.txt"
write_b_manuscript(b_lines, b_manuscript_path)
print(f"[video_split] B稿写入: {b_manuscript_path} ({len(b_lines)} 行)")
# 清理临时帧文件(可选,保留供调试)
# shutil.rmtree(frames_dir)
keyframes_data = {
"video_path": str(video_path),
"fps_sampled": fps,
"phash_threshold": phash_threshold,
"dry_run": False,
"crop_params": {
"width_ratio": 1.0,
"height_ratio": 0.2,
"x_offset": 0.0,
"y_offset": 0.8,
"hardcoded": True,
"applies_to": "军事科技 全栏目特殊视频",
"ffmpeg_crop": SUBTITLE_CROP,
},
"keyframes": keyframes,
}
keyframes_path = output_dir / "keyframes.json"
with open(keyframes_path, "w", encoding="utf-8") as f:
json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
print(f"[video_split] 关键帧索引写入: {keyframes_path}")
return {
"b_manuscript_path": str(b_manuscript_path),
"audio_path": str(audio_path),
"keyframes_path": str(keyframes_path),
"keyframe_count": len(keyframes),
}
return {
"b_manuscript_path": str(b_manuscript_path),
"audio_path": str(audio_path),
"keyframes_path": str(keyframes_path),
"keyframe_count": len(keyframes),
"dry_run": False,
}