feat: doco P1 收尾(字幕裁切写死/dry-run/crop_params完善)
This commit is contained in:
+76
-17
@@ -18,11 +18,25 @@ Doco 将一期《军事科技》节目视频拆分为两路输入,供下游三
|
|||||||
|
|
||||||
## 系统依赖
|
## 系统依赖
|
||||||
|
|
||||||
- **Python >= 3.12**
|
### ffmpeg (必须)
|
||||||
- **ffmpeg >= 4.x** (必须安装并加入 PATH)
|
|
||||||
- Windows 下载: https://ffmpeg.org/download.html
|
**Windows 用户:**
|
||||||
- macOS: `brew install ffmpeg`
|
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg (建议用 essentials 版本)
|
||||||
- Linux: `apt install ffmpeg`
|
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
|
||||||
|
|
||||||
```
|
见上方"系统依赖"一节。安装后打开 cmd 验证 `ffmpeg -version` 能看到版本号。
|
||||||
programs/
|
|
||||||
└── ep001_20260612_fangkong_fandao/
|
### Step B. 生成迷你测试视频(验证 ffmpeg 装好了)
|
||||||
└── source/
|
|
||||||
└── video.mp4 ← 放入节目视频
|
```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
|
```bash
|
||||||
doco split \
|
doco split \
|
||||||
@@ -76,10 +135,10 @@ doco split \
|
|||||||
|
|
||||||
```
|
```
|
||||||
programs/ep001_20260612_fangkong_fandao/work/
|
programs/ep001_20260612_fangkong_fandao/work/
|
||||||
├── frames/ # 抽出的所有帧(临时)
|
├── frames/ # 抽出的帧(临时)
|
||||||
├── audio_16k.wav # 音频(16kHz/单声道/16bit)
|
├── audio_16k.wav # 音频(16kHz/单声道/16bit)
|
||||||
├── b_manuscript.txt # B 稿([Nm Ns] 句子格式)
|
├── b_manuscript.txt # B 稿([Nm Ns] 句子格式)
|
||||||
└── keyframes.json # 关键帧索引
|
└── keyframes.json # 关键帧索引(含裁切参数)
|
||||||
```
|
```
|
||||||
|
|
||||||
## P1 验收标准
|
## P1 验收标准
|
||||||
@@ -101,7 +160,7 @@ doco/
|
|||||||
├── tests/
|
├── tests/
|
||||||
│ ├── test_video_split.py # 单元测试
|
│ ├── test_video_split.py # 单元测试
|
||||||
│ └── fixtures/
|
│ └── fixtures/
|
||||||
│ └── mini_test.mp4 # 迷你测试视频
|
│ └── mini_test.mp4 # 迷你测试视频(需 Step B 生成)
|
||||||
├── docs/
|
├── docs/
|
||||||
├── .env.example # 凭证模板
|
├── .env.example # 凭证模板
|
||||||
├── README.md
|
├── README.md
|
||||||
|
|||||||
+29
-3
@@ -44,11 +44,29 @@ def main():
|
|||||||
type=int,
|
type=int,
|
||||||
help="pHash 海明距离阈值,用于检测字幕变化(默认 8)",
|
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: 视频双路拆分
|
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)
|
video_path = Path(input_video)
|
||||||
out_dir = Path(output_dir)
|
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] input_video={video_path}")
|
||||||
click.echo(f"[doco split] output_dir={out_dir}")
|
click.echo(f"[doco split] output_dir={out_dir}")
|
||||||
click.echo(f"[doco split] phash_threshold={phash_threshold}")
|
click.echo(f"[doco split] phash_threshold={phash_threshold}")
|
||||||
|
click.echo(f"[doco split] dry_run={dry_run}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = split_video(
|
result = split_video(
|
||||||
@@ -64,7 +83,14 @@ def split(episode_id: str, input_video: str, output_dir: str, phash_threshold: i
|
|||||||
output_dir=out_dir,
|
output_dir=out_dir,
|
||||||
episode_id=episode_id,
|
episode_id=episode_id,
|
||||||
phash_threshold=phash_threshold,
|
phash_threshold=phash_threshold,
|
||||||
|
dry_run=dry_run,
|
||||||
)
|
)
|
||||||
|
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] B 稿: {result['b_manuscript_path']}")
|
||||||
click.echo(f"[ok] 音频: {result['audio_path']}")
|
click.echo(f"[ok] 音频: {result['audio_path']}")
|
||||||
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
|
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
|
||||||
|
|||||||
+88
-11
@@ -29,6 +29,16 @@ import imagehash
|
|||||||
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 字幕裁切参数
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 字幕裁切区域:全宽 × 下方 20%
|
||||||
|
# 适用于《军事科技》栏目特殊视频(黑底白字字幕,位置固定)
|
||||||
|
# 全年 52 期一致,如换栏目需重新评估此参数
|
||||||
|
SUBTITLE_CROP = "iw:ih*0.2:0:ih*0.8"
|
||||||
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# FFmpeg 封装
|
# FFmpeg 封装
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -49,9 +59,19 @@ def extract_frames(
|
|||||||
video_path: Path,
|
video_path: Path,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
fps: int = 1,
|
fps: int = 1,
|
||||||
|
crop: str = SUBTITLE_CROP,
|
||||||
|
dry_run: bool = False,
|
||||||
) -> List[Tuple[int, int, Path]]:
|
) -> 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), ...]
|
返回: [(frame_index, timestamp_ms, image_path), ...]
|
||||||
"""
|
"""
|
||||||
check_ffmpeg()
|
check_ffmpeg()
|
||||||
@@ -59,12 +79,20 @@ def extract_frames(
|
|||||||
frames_dir = output_dir / "frames"
|
frames_dir = output_dir / "frames"
|
||||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
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")
|
frame_pattern = str(frames_dir / "frame_%04d.png")
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-i", str(video_path),
|
"-i", str(video_path),
|
||||||
"-vf", f"fps={fps}",
|
"-vf", filter_chain,
|
||||||
"-q:v", "2", # JPEG 质量
|
"-q:v", "2", # JPEG 质量
|
||||||
frame_pattern,
|
frame_pattern,
|
||||||
"-y", # 覆盖
|
"-y", # 覆盖
|
||||||
@@ -271,6 +299,7 @@ def split_video(
|
|||||||
episode_id: str,
|
episode_id: str,
|
||||||
phash_threshold: int = 8,
|
phash_threshold: int = 8,
|
||||||
fps: int = 1,
|
fps: int = 1,
|
||||||
|
dry_run: bool = False,
|
||||||
) -> Dict[str, any]:
|
) -> Dict[str, any]:
|
||||||
"""
|
"""
|
||||||
视频双路拆分主流程
|
视频双路拆分主流程
|
||||||
@@ -281,6 +310,7 @@ def split_video(
|
|||||||
episode_id: 节目 ID
|
episode_id: 节目 ID
|
||||||
phash_threshold: pHash 海明距离阈值,默认 8
|
phash_threshold: pHash 海明距离阈值,默认 8
|
||||||
fps: 抽帧帧率,默认 1(每秒一帧)
|
fps: 抽帧帧率,默认 1(每秒一帧)
|
||||||
|
dry_run: True 则不调 OCR,只输出裁切帧和 keyframes.json
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
{
|
{
|
||||||
@@ -288,6 +318,7 @@ def split_video(
|
|||||||
"audio_path": Path,
|
"audio_path": Path,
|
||||||
"keyframes_path": Path,
|
"keyframes_path": Path,
|
||||||
"keyframe_count": int,
|
"keyframe_count": int,
|
||||||
|
"dry_run": bool,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
video_path = Path(video_path)
|
video_path = Path(video_path)
|
||||||
@@ -302,17 +333,24 @@ def split_video(
|
|||||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
print(f"[video_split] 开始处理: {video_path.name}")
|
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 ----
|
# ---- A 路:抽帧 + pHash 检测 + OCR ----
|
||||||
print("[video_split] A路:抽帧...")
|
print(f"[video_split] A路:抽帧({'裁切模式' if dry_run else '完整帧模式'})...")
|
||||||
frames = extract_frames(video_path, output_dir, fps=fps)
|
frames = extract_frames(video_path, output_dir, fps=fps, crop=SUBTITLE_CROP, dry_run=dry_run)
|
||||||
print(f"[video_split] 抽帧完成,共 {len(frames)} 帧")
|
print(f"[video_split] 抽帧完成,共 {len(frames)} 帧")
|
||||||
|
|
||||||
print("[video_split] pHash 变化检测...")
|
print("[video_split] pHash 变化检测...")
|
||||||
keyframes = find_keyframes(frames, threshold=phash_threshold)
|
keyframes = find_keyframes(frames, threshold=phash_threshold)
|
||||||
print(f"[video_split] 检测到 {len(keyframes)} 个关键帧")
|
print(f"[video_split] 检测到 {len(keyframes)} 个关键帧")
|
||||||
|
|
||||||
|
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 关键帧...")
|
print("[video_split] OCR 关键帧...")
|
||||||
keyframes = ocr_keyframes(keyframes)
|
keyframes = ocr_keyframes(keyframes)
|
||||||
print(f"[video_split] OCR 完成")
|
print(f"[video_split] OCR 完成")
|
||||||
@@ -324,17 +362,58 @@ def split_video(
|
|||||||
print(f"[video_split] 音频提取完成: {audio_path}")
|
print(f"[video_split] 音频提取完成: {audio_path}")
|
||||||
|
|
||||||
# ---- 输出产物 ----
|
# ---- 输出产物 ----
|
||||||
# B 稿
|
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/ 目录下的关键帧图片,确认裁切框位置正确")
|
||||||
|
|
||||||
|
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_lines = build_b_manuscript(keyframes)
|
||||||
b_manuscript_path = output_dir / "b_manuscript.txt"
|
b_manuscript_path = output_dir / "b_manuscript.txt"
|
||||||
write_b_manuscript(b_lines, b_manuscript_path)
|
write_b_manuscript(b_lines, b_manuscript_path)
|
||||||
print(f"[video_split] B稿写入: {b_manuscript_path} ({len(b_lines)} 行)")
|
print(f"[video_split] B稿写入: {b_manuscript_path} ({len(b_lines)} 行)")
|
||||||
|
|
||||||
# 关键帧索引 JSON
|
|
||||||
keyframes_data = {
|
keyframes_data = {
|
||||||
"video_path": str(video_path),
|
"video_path": str(video_path),
|
||||||
"fps_sampled": fps,
|
"fps_sampled": fps,
|
||||||
"phash_threshold": phash_threshold,
|
"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": keyframes,
|
||||||
}
|
}
|
||||||
keyframes_path = output_dir / "keyframes.json"
|
keyframes_path = output_dir / "keyframes.json"
|
||||||
@@ -342,12 +421,10 @@ def split_video(
|
|||||||
json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
|
json.dump(keyframes_data, f, ensure_ascii=False, indent=2)
|
||||||
print(f"[video_split] 关键帧索引写入: {keyframes_path}")
|
print(f"[video_split] 关键帧索引写入: {keyframes_path}")
|
||||||
|
|
||||||
# 清理临时帧文件(可选,保留供调试)
|
|
||||||
# shutil.rmtree(frames_dir)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"b_manuscript_path": str(b_manuscript_path),
|
"b_manuscript_path": str(b_manuscript_path),
|
||||||
"audio_path": str(audio_path),
|
"audio_path": str(audio_path),
|
||||||
"keyframes_path": str(keyframes_path),
|
"keyframes_path": str(keyframes_path),
|
||||||
"keyframe_count": len(keyframes),
|
"keyframe_count": len(keyframes),
|
||||||
|
"dry_run": False,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user