From aa59ef78e02169e904f8fcf9f0e2682703690f26 Mon Sep 17 00:00:00 2001 From: simonkoson <28867558@qq.com> Date: Fri, 12 Jun 2026 16:38:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20doco=20P1=20=E6=94=B6=E5=B0=BE(?= =?UTF-8?q?=E5=AD=97=E5=B9=95=E8=A3=81=E5=88=87=E5=86=99=E6=AD=BB/dry-run/?= =?UTF-8?q?crop=5Fparams=E5=AE=8C=E5=96=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doco/README.md | 93 +++++++++++++++++++++----- doco/src/cli.py | 40 +++++++++-- doco/src/video_split.py | 143 ++++++++++++++++++++++++++++++---------- 3 files changed, 219 insertions(+), 57 deletions(-) diff --git a/doco/README.md b/doco/README.md index 51348a5..9d353b2 100644 --- a/doco/README.md +++ b/doco/README.md @@ -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 diff --git a/doco/src/cli.py b/doco/src/cli.py index 0a1057c..e1617a8 100644 --- a/doco/src/cli.py +++ b/doco/src/cli.py @@ -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) diff --git a/doco/src/video_split.py b/doco/src/video_split.py index bdd2adb..8325e10 100644 --- a/doco/src/video_split.py +++ b/doco/src/video_split.py @@ -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, + }