783 lines
27 KiB
Python
783 lines
27 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
doco CLI 入口
|
||
|
|
P1: doco split 子命令
|
||
|
|
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
|
||
|
|
P3 C1: doco terms 子命令
|
||
|
|
P3 run: 一键全流程 P1→P2→C1→C2→C3→C4
|
||
|
|
"""
|
||
|
|
|
||
|
|
import click
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
# P1 相关
|
||
|
|
from .video_split import split_video, extract_audio
|
||
|
|
|
||
|
|
# P3 C1 术语提取
|
||
|
|
from .term_extract import run_terms
|
||
|
|
|
||
|
|
# P3 C2 讯飞 ASR
|
||
|
|
from .asr_adapter import get_hot_words, transcribe, write_asr_result
|
||
|
|
|
||
|
|
# P3 C3 B稿⊕ASR 交叉复审融合
|
||
|
|
from .fusion_review import run_fusion
|
||
|
|
|
||
|
|
# P3 C4 分段对齐 → 融合A稿
|
||
|
|
from .fusion_align import run_compose, run_skeleton
|
||
|
|
|
||
|
|
|
||
|
|
@click.group()
|
||
|
|
@click.version_option(version="0.1.0")
|
||
|
|
def main():
|
||
|
|
"""TPS 中台 - 终版文稿生成工具"""
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("split")
|
||
|
|
@click.option(
|
||
|
|
"--episode-id",
|
||
|
|
required=True,
|
||
|
|
help="节目 ID,如 ep001_20260612_fangkong_fandao",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--input-video",
|
||
|
|
required=True,
|
||
|
|
type=click.Path(exists=True),
|
||
|
|
help="输入视频文件路径",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--output-dir",
|
||
|
|
required=True,
|
||
|
|
type=click.Path(),
|
||
|
|
help="输出目录(work/ 路径)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--hash-algorithm",
|
||
|
|
default="dhash",
|
||
|
|
type=click.Choice(["dhash", "phash"]),
|
||
|
|
help="哈希算法:dhash(默认,对边缘敏感) 或 phash(感知哈希)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--phash-threshold",
|
||
|
|
default=2,
|
||
|
|
type=int,
|
||
|
|
help="pHash 海明距离阈值(默认 2)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--dhash-threshold",
|
||
|
|
default=5,
|
||
|
|
type=int,
|
||
|
|
help="dHash 海明距离阈值(默认 5)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--iou-threshold",
|
||
|
|
default=0.95,
|
||
|
|
type=float,
|
||
|
|
help="IoU 保底阈值:二值化帧间 IoU > 此值视为同字幕(默认 0.95)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--dry-run",
|
||
|
|
is_flag=True,
|
||
|
|
default=False,
|
||
|
|
help="只抽帧+裁切,不调 OCR API;用于验证裁切框位置是否正确",
|
||
|
|
)
|
||
|
|
def split(
|
||
|
|
episode_id: str,
|
||
|
|
input_video: str,
|
||
|
|
output_dir: str,
|
||
|
|
hash_algorithm: str,
|
||
|
|
phash_threshold: int,
|
||
|
|
dhash_threshold: int,
|
||
|
|
iou_threshold: float,
|
||
|
|
dry_run: bool,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P1: 视频双路拆分
|
||
|
|
|
||
|
|
A 路:抽帧 + 空白帧过滤 + 哈希变化检测 + 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)
|
||
|
|
|
||
|
|
click.echo(f"[doco split] episode_id={episode_id}")
|
||
|
|
click.echo(f"[doco split] input_video={video_path}")
|
||
|
|
click.echo(f"[doco split] output_dir={out_dir}")
|
||
|
|
click.echo(f"[doco split] hash_algorithm={hash_algorithm}")
|
||
|
|
click.echo(f"[doco split] phash_threshold={phash_threshold}")
|
||
|
|
click.echo(f"[doco split] dhash_threshold={dhash_threshold}")
|
||
|
|
click.echo(f"[doco split] iou_threshold={iou_threshold}")
|
||
|
|
click.echo(f"[doco split] dry_run={dry_run}")
|
||
|
|
|
||
|
|
try:
|
||
|
|
result = split_video(
|
||
|
|
video_path=video_path,
|
||
|
|
output_dir=out_dir,
|
||
|
|
episode_id=episode_id,
|
||
|
|
hash_algorithm=hash_algorithm,
|
||
|
|
phash_threshold=phash_threshold,
|
||
|
|
dhash_threshold=dhash_threshold,
|
||
|
|
iou_threshold=iou_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] 音频: {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)
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("process")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID")
|
||
|
|
@click.option("--input-video", required=True, type=click.Path(exists=True), help="输入视频")
|
||
|
|
@click.option("--input-a-draft", required=True, type=click.Path(exists=True), help="A 稿 docx")
|
||
|
|
@click.option("--output-dir", required=True, type=click.Path(), help="输出目录")
|
||
|
|
@click.option(
|
||
|
|
"--cleanup-level",
|
||
|
|
default="medium",
|
||
|
|
type=click.Choice(["keep_all", "medium", "clean"]),
|
||
|
|
help="口语清理档位(默认 medium)",
|
||
|
|
)
|
||
|
|
def process(
|
||
|
|
episode_id: str,
|
||
|
|
input_video: str,
|
||
|
|
input_a_draft: str,
|
||
|
|
output_dir: str,
|
||
|
|
cleanup_level: str,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P3: 三方融合全流程
|
||
|
|
需要 A 稿 + B 稿(本命令调用 split) + ASR 结果,融合输出终版 docx + 差异报告
|
||
|
|
"""
|
||
|
|
click.echo("[doco process] P3 全流程暂未实现,请先使用 split 命令")
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("terms")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||
|
|
@click.option(
|
||
|
|
"--a-script",
|
||
|
|
required=True,
|
||
|
|
type=click.Path(exists=True),
|
||
|
|
help="A 稿 txt 文件路径(按纯文本读取)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--no-ai",
|
||
|
|
is_flag=True,
|
||
|
|
default=False,
|
||
|
|
help="跳过 AI 层提取(Claude),仅使用规则层",
|
||
|
|
)
|
||
|
|
def terms(
|
||
|
|
episode_id: str,
|
||
|
|
a_script: str,
|
||
|
|
no_ai: bool,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P3 C1: 术语提取 + 累积词典 + 本期热词表
|
||
|
|
|
||
|
|
从本期 A 稿提取专有名词 → 更新中台累积词典 → 产出本期热词表(给讯飞 ASR 用)。
|
||
|
|
|
||
|
|
两层提取:
|
||
|
|
A) 规则层(必跑):正则抓型号/番号/兵器名/国名/机构名/人名
|
||
|
|
B) AI 层(--no-ai 跳过):调 Claude 补抓专名并归类
|
||
|
|
|
||
|
|
产物:
|
||
|
|
- doco/data/term_dict.json(累积词典,幂等更新)
|
||
|
|
- doco/programs/<episode-id>/本期热词表.txt(| 分隔,最多 200 条)
|
||
|
|
- doco/programs/<episode-id>/c1_term_candidates.json(三段留痕)
|
||
|
|
"""
|
||
|
|
script_path = Path(a_script)
|
||
|
|
|
||
|
|
click.echo(f"[doco terms] episode_id={episode_id}")
|
||
|
|
click.echo(f"[doco terms] A 稿={script_path}")
|
||
|
|
click.echo(f"[doco terms] no_ai={no_ai}")
|
||
|
|
|
||
|
|
try:
|
||
|
|
result = run_terms(
|
||
|
|
episode_id=episode_id,
|
||
|
|
a_script_path=script_path,
|
||
|
|
no_ai=no_ai,
|
||
|
|
)
|
||
|
|
click.echo(f"[ok] 规则候选: {result['rule_count']} 条")
|
||
|
|
click.echo(f"[ok] AI 候选: {result['ai_count']} 条")
|
||
|
|
click.echo(f"[ok] 合并后: {result['merged_count']} 条")
|
||
|
|
click.echo(f"[ok] 词典新增: {result['dict_new_entries']} 条 / 词典共 {result['dict_total']} 条")
|
||
|
|
click.echo(f"[ok] 本期热词表: {result['hotword_count']} 条 → {result['hotwords_path']}")
|
||
|
|
click.echo(f"[ok] 留痕: {result['audit_path']}")
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[error] {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("fuse")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||
|
|
@click.option(
|
||
|
|
"--output-dir",
|
||
|
|
default=None,
|
||
|
|
type=click.Path(),
|
||
|
|
help="输出目录(默认 programs/<episode-id>/)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--no-ai",
|
||
|
|
is_flag=True,
|
||
|
|
default=False,
|
||
|
|
help="跳过 LLM 只跑规则层(=全 unchanged)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--batch-size",
|
||
|
|
default=35,
|
||
|
|
type=int,
|
||
|
|
help="每批送审行数(默认 35)",
|
||
|
|
)
|
||
|
|
def fuse(
|
||
|
|
episode_id: str,
|
||
|
|
output_dir: str,
|
||
|
|
no_ai: bool,
|
||
|
|
batch_size: int,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P3 C3: B稿 ⊕ ASR 交叉复审融合
|
||
|
|
|
||
|
|
逐行复审 B稿(屏幕字幕OCR),以 ASR(口语转写)为上下文参考,
|
||
|
|
只做纠错,绝不合并行、不拆行、不增删行、不改时间戳。
|
||
|
|
|
||
|
|
--no-ai: 跳过 LLM,全 unchanged(验证管道)
|
||
|
|
--batch-size: 每批送审行数,默认 35
|
||
|
|
|
||
|
|
产物:
|
||
|
|
- 融合B稿.txt(与 B稿_v2 逐行时间戳一致)
|
||
|
|
- fusion_review.csv(仅含 change_type≠unchanged 或 confidence<0.8 的行)
|
||
|
|
"""
|
||
|
|
if output_dir is None:
|
||
|
|
out_dir = Path("programs") / episode_id
|
||
|
|
else:
|
||
|
|
out_dir = Path(output_dir)
|
||
|
|
|
||
|
|
click.echo(f"[doco fuse] episode_id={episode_id}")
|
||
|
|
click.echo(f"[doco fuse] output_dir={out_dir}")
|
||
|
|
click.echo(f"[doco fuse] no_ai={no_ai}")
|
||
|
|
click.echo(f"[doco fuse] batch_size={batch_size}")
|
||
|
|
|
||
|
|
try:
|
||
|
|
stats = run_fusion(
|
||
|
|
episode_id=episode_id,
|
||
|
|
output_dir=str(out_dir),
|
||
|
|
no_ai=no_ai,
|
||
|
|
batch_size=batch_size,
|
||
|
|
)
|
||
|
|
click.echo(f"[ok] 总行数: {stats['total_lines']}")
|
||
|
|
click.echo(f"[ok] 各 change_type 计数: {stats['change_counts']}")
|
||
|
|
click.echo(f"[ok] 进 review 行数: {stats['review_lines']}")
|
||
|
|
click.echo(f"[ok] 融合B稿: {stats['fused_path']}")
|
||
|
|
click.echo(f"[ok] review CSV: {stats['csv_path']}")
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[error] {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("skeleton")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
|
||
|
|
@click.option("--a-script", required=True, type=click.Path(exists=True), help="A 稿 docx 路径")
|
||
|
|
@click.option(
|
||
|
|
"--output-dir",
|
||
|
|
default=None,
|
||
|
|
type=click.Path(),
|
||
|
|
help="输出目录(默认 programs/<episode-id>/)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--max-tokens",
|
||
|
|
default=16000,
|
||
|
|
type=int,
|
||
|
|
help="LLM max_tokens(默认 16000,长稿可调大)",
|
||
|
|
)
|
||
|
|
def skeleton(
|
||
|
|
episode_id: str,
|
||
|
|
a_script: str,
|
||
|
|
output_dir: str,
|
||
|
|
max_tokens: int,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P3 新增: LLM 分段骨架抽取(只产骨架,不跑对齐)
|
||
|
|
|
||
|
|
流程:
|
||
|
|
1. extract_a_paragraphs: 纯 docx 段落样式提取
|
||
|
|
2. extract_skeleton_llm: LLM 判断分段结构 → JSON 骨架
|
||
|
|
3. validate_skeleton_coverage: 全覆盖硬校验
|
||
|
|
4. 落盘 <episode_id>_a_skeleton.json + 打印人类可读预览表
|
||
|
|
|
||
|
|
跑完请人工核验骨架预览表(role_label 是否含真人姓名? ignore 是否漏/多?)
|
||
|
|
确认无误后,再跑 doco compose 完成对齐。
|
||
|
|
"""
|
||
|
|
if output_dir is None:
|
||
|
|
out_dir = Path("programs") / episode_id
|
||
|
|
else:
|
||
|
|
out_dir = Path(output_dir)
|
||
|
|
|
||
|
|
click.echo(f"[doco skeleton] episode_id={episode_id}")
|
||
|
|
click.echo(f"[doco skeleton] a_script={a_script}")
|
||
|
|
click.echo(f"[doco skeleton] output_dir={out_dir}")
|
||
|
|
click.echo(f"[doco skeleton] max_tokens={max_tokens}")
|
||
|
|
|
||
|
|
try:
|
||
|
|
result = run_skeleton(
|
||
|
|
episode_id=episode_id,
|
||
|
|
a_script_path=a_script,
|
||
|
|
output_dir=str(out_dir),
|
||
|
|
max_tokens=max_tokens,
|
||
|
|
)
|
||
|
|
click.echo(f"[ok] 段落数: {result['total_paras']} (含标题)")
|
||
|
|
click.echo(f"[ok] 骨架段数: {result['skeleton_count']}")
|
||
|
|
click.echo(f"[ok] 全覆盖校验: {'通过' if result['coverage_ok'] else '失败'}")
|
||
|
|
click.echo(f"[ok] 骨架已保存: {result['skeleton_path']}")
|
||
|
|
click.echo(f"[提示] 请人工确认骨架预览表后,再运行: doco compose --episode-id {episode_id}")
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[error] {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("asr")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||
|
|
@click.option(
|
||
|
|
"--input-video",
|
||
|
|
required=True,
|
||
|
|
type=click.Path(exists=True),
|
||
|
|
help="输入视频文件路径",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--output-dir",
|
||
|
|
default=None,
|
||
|
|
type=click.Path(),
|
||
|
|
help="输出目录(默认 programs/<episode-id>/)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--skip-asr",
|
||
|
|
is_flag=True,
|
||
|
|
default=False,
|
||
|
|
help="只分离音频不调讯飞,用于先验证 WAV",
|
||
|
|
)
|
||
|
|
def asr(
|
||
|
|
episode_id: str,
|
||
|
|
input_video: str,
|
||
|
|
output_dir: str,
|
||
|
|
skip_asr: bool,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P3 C2: 讯飞 ASR 转写
|
||
|
|
|
||
|
|
流程:
|
||
|
|
1. video_split.extract_audio() 分离 16kHz/单声道/16bit WAV
|
||
|
|
2. get_hot_words() 读取本期热词表
|
||
|
|
3. --skip-asr 时到此为止;否则调 transcribe() → write_asr_result()
|
||
|
|
|
||
|
|
产物:
|
||
|
|
- audio_16k.wav(音频)
|
||
|
|
- asr_v2_timed.txt(带时间戳的转写文本)
|
||
|
|
- asr_result_raw.json(讯飞原始返回,断点续跑用)
|
||
|
|
"""
|
||
|
|
from .asr_adapter import get_audio_duration_ms as _wav_duration
|
||
|
|
|
||
|
|
video_path = Path(input_video)
|
||
|
|
if output_dir is None:
|
||
|
|
out_dir = Path("programs") / episode_id
|
||
|
|
else:
|
||
|
|
out_dir = Path(output_dir)
|
||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
click.echo(f"[doco asr] episode_id={episode_id}")
|
||
|
|
click.echo(f"[doco asr] input_video={video_path}")
|
||
|
|
click.echo(f"[doco asr] output_dir={out_dir}")
|
||
|
|
click.echo(f"[doco asr] skip_asr={skip_asr}")
|
||
|
|
|
||
|
|
# ---- a. 音频分离 ----
|
||
|
|
wav_path = out_dir / "audio_16k.wav"
|
||
|
|
if wav_path.exists():
|
||
|
|
click.echo(f"[doco asr] audio_16k.wav 已存在,复用: {wav_path}")
|
||
|
|
else:
|
||
|
|
click.echo("[doco asr] 从视频分离音频(16kHz/单声道/16bit)...")
|
||
|
|
try:
|
||
|
|
extract_audio(video_path, wav_path)
|
||
|
|
click.echo(f"[doco asr] 音频分离完成: {wav_path}")
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[error] 音频分离失败: {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# 打印 WAV 时长
|
||
|
|
try:
|
||
|
|
dur_ms = _wav_duration(str(wav_path))
|
||
|
|
dur_sec = dur_ms / 1000.0
|
||
|
|
fsize = wav_path.stat().st_size
|
||
|
|
click.echo(f"[doco asr] audio_16k.wav 大小: {fsize / 1024 / 1024:.1f} MB, 时长: {dur_sec:.1f}s ({dur_ms} ms)")
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[doco asr] 无法读取 WAV 时长: {e}")
|
||
|
|
|
||
|
|
# ---- b. --skip-asr 时到此为止 ----
|
||
|
|
if skip_asr:
|
||
|
|
click.echo(f"[doco asr] --skip-asr 模式,到此为止。WAV: {wav_path}")
|
||
|
|
return
|
||
|
|
|
||
|
|
# ---- c. 热词 ----
|
||
|
|
hot_words = get_hot_words(episode_id)
|
||
|
|
click.echo(f"[doco asr] 热词条数: {len(hot_words)}")
|
||
|
|
|
||
|
|
# ---- d. 转写 ----
|
||
|
|
click.echo("[doco asr] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
|
||
|
|
try:
|
||
|
|
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[error] ASR 转写失败: {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
timed_path, raw_path = write_asr_result(
|
||
|
|
sentences,
|
||
|
|
str(out_dir),
|
||
|
|
raw_order_result=raw_order_result,
|
||
|
|
)
|
||
|
|
|
||
|
|
# ---- e. 打印摘要 ----
|
||
|
|
click.echo(f"[ok] 热词条数: {len(hot_words)}")
|
||
|
|
click.echo(f"[ok] 句子数: {len(sentences)}")
|
||
|
|
click.echo(f"[ok] asr_v2_timed.txt: {timed_path}")
|
||
|
|
click.echo(f"[ok] asr_result_raw.json: {raw_path}")
|
||
|
|
|
||
|
|
|
||
|
|
# 模板脚本目录(stage_a_extract_ocr.py / stage_b_dedup_output.py)
|
||
|
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||
|
|
|
||
|
|
|
||
|
|
def _stage_header(title: str):
|
||
|
|
"""打印阶段分隔线"""
|
||
|
|
click.echo("═════════════════════════════")
|
||
|
|
click.echo(title)
|
||
|
|
click.echo("═════════════════════════════")
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("compose")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||
|
|
@click.option(
|
||
|
|
"--output-dir",
|
||
|
|
default=None,
|
||
|
|
type=click.Path(),
|
||
|
|
help="输出目录(默认 programs/<episode-id>/)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--no-ai",
|
||
|
|
is_flag=True,
|
||
|
|
default=False,
|
||
|
|
help="跳过 LLM 对齐,按时间均分到各段(仅验证管道)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--batch-size",
|
||
|
|
default=40,
|
||
|
|
type=int,
|
||
|
|
help="每批送对齐行数(默认 40)",
|
||
|
|
)
|
||
|
|
def compose(
|
||
|
|
episode_id: str,
|
||
|
|
output_dir: str,
|
||
|
|
no_ai: bool,
|
||
|
|
batch_size: int,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
P3 C4: 融合B稿 + A稿分段骨架 → 融合A稿.docx(公文格式)
|
||
|
|
|
||
|
|
AI 唯一职责: 给每行 B 句打段序号,正文一字不改、纯规则拼接。
|
||
|
|
|
||
|
|
产物:
|
||
|
|
- 融合A稿.docx (GB/T 9704 公文格式)
|
||
|
|
- c4_alignment.csv (分段对齐留痕)
|
||
|
|
"""
|
||
|
|
if output_dir is None:
|
||
|
|
out_dir = Path("programs") / episode_id
|
||
|
|
else:
|
||
|
|
out_dir = Path(output_dir)
|
||
|
|
|
||
|
|
click.echo(f"[doco compose] episode_id={episode_id}")
|
||
|
|
click.echo(f"[doco compose] output_dir={out_dir}")
|
||
|
|
click.echo(f"[doco compose] no_ai={no_ai}")
|
||
|
|
click.echo(f"[doco compose] batch_size={batch_size}")
|
||
|
|
|
||
|
|
try:
|
||
|
|
stats = run_compose(
|
||
|
|
episode_id=episode_id,
|
||
|
|
output_dir=str(out_dir),
|
||
|
|
no_ai=no_ai,
|
||
|
|
batch_size=batch_size,
|
||
|
|
)
|
||
|
|
click.echo(f"[ok] 总行数: {stats['total_lines']}")
|
||
|
|
click.echo(f"[ok] 段数: {stats['segment_count']}")
|
||
|
|
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
|
||
|
|
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
|
||
|
|
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
|
||
|
|
click.echo(f"[ok] 融合A稿: {stats['docx_path']}")
|
||
|
|
click.echo(f"[ok] 留痕 CSV: {stats['csv_path']}")
|
||
|
|
except Exception as e:
|
||
|
|
click.echo(f"[error] {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
@main.command("run")
|
||
|
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
|
||
|
|
@click.option(
|
||
|
|
"--a-script",
|
||
|
|
required=True,
|
||
|
|
type=click.Path(exists=True),
|
||
|
|
help="A 稿 docx 路径",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--input-video",
|
||
|
|
required=True,
|
||
|
|
type=click.Path(exists=True),
|
||
|
|
help="输入视频 mp4 路径",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--batch-size",
|
||
|
|
default=25,
|
||
|
|
type=int,
|
||
|
|
help="C4 对齐用每批行数(默认 25)",
|
||
|
|
)
|
||
|
|
@click.option(
|
||
|
|
"--skip-p1",
|
||
|
|
is_flag=True,
|
||
|
|
default=False,
|
||
|
|
help="跳过 P1/P2(抽帧+OCR+去重),从 C1 续跑(已有 B稿v2 时)",
|
||
|
|
)
|
||
|
|
def run(
|
||
|
|
episode_id: str,
|
||
|
|
a_script: str,
|
||
|
|
input_video: str,
|
||
|
|
batch_size: int,
|
||
|
|
skip_p1: bool,
|
||
|
|
):
|
||
|
|
"""
|
||
|
|
一键全流程: P1→P2→C1→C2→C3→C4
|
||
|
|
|
||
|
|
串联抽帧+OCR(P1)、文本去重(P2)、术语提取(C1)、ASR 转写(C2)、
|
||
|
|
融合复审(C3)、对齐出稿(C4) 六个阶段。
|
||
|
|
|
||
|
|
用 --skip-p1 可跳过 P1/P2,从 C1 续跑(适用于已有 B稿v2 的场景)。
|
||
|
|
C4 开始前要求骨架文件已存在(需先手动跑 doco skeleton 并人工核验)。
|
||
|
|
"""
|
||
|
|
from .asr_adapter import get_audio_duration_ms as _wav_duration
|
||
|
|
|
||
|
|
episode_dir = Path("programs") / episode_id
|
||
|
|
episode_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
video_path = Path(input_video)
|
||
|
|
a_script_path = Path(a_script)
|
||
|
|
|
||
|
|
# 记录已完成阶段,用于失败时打印
|
||
|
|
completed_stages: list = []
|
||
|
|
|
||
|
|
# ── 汇总数据 ──
|
||
|
|
b_v2_lines = 0
|
||
|
|
hotword_count = 0
|
||
|
|
asr_sentence_count = 0
|
||
|
|
fused_b_lines = 0
|
||
|
|
fused_a_docx = ""
|
||
|
|
|
||
|
|
try:
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
# P1: 抽帧 + OCR
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
if not skip_p1:
|
||
|
|
_stage_header("P1: 抽帧 + OCR")
|
||
|
|
|
||
|
|
stage_a_path = episode_dir / "stage_a_extract_ocr.py"
|
||
|
|
if not stage_a_path.exists():
|
||
|
|
src = TEMPLATES_DIR / "stage_a_extract_ocr.py"
|
||
|
|
click.echo(f"[run] 复制模板: {src} → {stage_a_path}")
|
||
|
|
shutil.copy2(str(src), str(stage_a_path))
|
||
|
|
|
||
|
|
click.echo(f"[run] 执行: {sys.executable} {stage_a_path}")
|
||
|
|
click.echo(f"[run] 工作目录: {episode_dir}")
|
||
|
|
|
||
|
|
proc = subprocess.run(
|
||
|
|
[sys.executable, str(stage_a_path)],
|
||
|
|
cwd=str(episode_dir),
|
||
|
|
)
|
||
|
|
if proc.returncode != 0:
|
||
|
|
raise RuntimeError(f"P1 stage_a_extract_ocr.py 退出码: {proc.returncode}")
|
||
|
|
|
||
|
|
completed_stages.append("P1: 抽帧 + OCR")
|
||
|
|
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
# P2: 文本去重
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
if not skip_p1:
|
||
|
|
_stage_header("P2: 文本去重")
|
||
|
|
|
||
|
|
stage_b_path = episode_dir / "stage_b_dedup_output.py"
|
||
|
|
if not stage_b_path.exists():
|
||
|
|
src = TEMPLATES_DIR / "stage_b_dedup_output.py"
|
||
|
|
click.echo(f"[run] 复制模板: {src} → {stage_b_path}")
|
||
|
|
shutil.copy2(str(src), str(stage_b_path))
|
||
|
|
|
||
|
|
click.echo(f"[run] 执行: {sys.executable} {stage_b_path}")
|
||
|
|
click.echo(f"[run] 工作目录: {episode_dir}")
|
||
|
|
|
||
|
|
proc = subprocess.run(
|
||
|
|
[sys.executable, str(stage_b_path)],
|
||
|
|
cwd=str(episode_dir),
|
||
|
|
)
|
||
|
|
if proc.returncode != 0:
|
||
|
|
raise RuntimeError(f"P2 stage_b_dedup_output.py 退出码: {proc.returncode}")
|
||
|
|
|
||
|
|
b_v2_path = episode_dir / "B稿_v2.txt"
|
||
|
|
if not b_v2_path.exists():
|
||
|
|
raise FileNotFoundError(f"P2 跑完但 B稿_v2.txt 不存在: {b_v2_path}")
|
||
|
|
|
||
|
|
completed_stages.append("P2: 文本去重")
|
||
|
|
|
||
|
|
# 读 B稿_v2 行数(无论是否 skip_p1,后续步骤都用得到)
|
||
|
|
b_v2_path = episode_dir / "B稿_v2.txt"
|
||
|
|
if b_v2_path.exists():
|
||
|
|
with open(b_v2_path, "r", encoding="utf-8") as fh:
|
||
|
|
b_v2_lines = sum(1 for line in fh if line.strip())
|
||
|
|
elif not skip_p1:
|
||
|
|
raise FileNotFoundError(f"B稿_v2.txt 不存在: {b_v2_path}")
|
||
|
|
else:
|
||
|
|
raise FileNotFoundError(
|
||
|
|
f"使用 --skip-p1 但 B稿_v2.txt 不存在: {b_v2_path}\n"
|
||
|
|
"请先跑 P1+P2 或确认 B稿_v2.txt 已就绪。"
|
||
|
|
)
|
||
|
|
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
# C1: 术语提取
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
_stage_header("C1: 术语提取")
|
||
|
|
|
||
|
|
c1_result = run_terms(
|
||
|
|
episode_id=episode_id,
|
||
|
|
a_script_path=a_script_path,
|
||
|
|
no_ai=False,
|
||
|
|
)
|
||
|
|
hotword_count = c1_result.get("hotword_count", 0)
|
||
|
|
click.echo(f"[run] C1 完成: 规则 {c1_result.get('rule_count', 0)} 条, "
|
||
|
|
f"AI {c1_result.get('ai_count', 0)} 条, "
|
||
|
|
f"热词 {hotword_count} 条")
|
||
|
|
|
||
|
|
completed_stages.append("C1: 术语提取")
|
||
|
|
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
# C2: ASR
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
_stage_header("C2: ASR 转写")
|
||
|
|
|
||
|
|
asr_timed_path = episode_dir / "asr_v2_timed.txt"
|
||
|
|
wav_path = episode_dir / "audio_16k.wav"
|
||
|
|
|
||
|
|
# 分离音频(已存在则复用)
|
||
|
|
if wav_path.exists():
|
||
|
|
click.echo(f"[run] audio_16k.wav 已存在,复用: {wav_path}")
|
||
|
|
else:
|
||
|
|
click.echo("[run] 从视频分离音频(16kHz/单声道/16bit)...")
|
||
|
|
extract_audio(video_path, wav_path)
|
||
|
|
click.echo(f"[run] 音频分离完成: {wav_path}")
|
||
|
|
|
||
|
|
if asr_timed_path.exists():
|
||
|
|
click.echo(f"[run] asr_v2_timed.txt 已存在,跳过 ASR(花钱的步骤不重复跑): {asr_timed_path}")
|
||
|
|
else:
|
||
|
|
hot_words = get_hot_words(episode_id)
|
||
|
|
click.echo(f"[run] 热词条数: {len(hot_words)}")
|
||
|
|
click.echo("[run] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
|
||
|
|
|
||
|
|
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
|
||
|
|
asr_sentence_count = len(sentences)
|
||
|
|
|
||
|
|
timed_path, raw_path = write_asr_result(
|
||
|
|
sentences,
|
||
|
|
str(episode_dir),
|
||
|
|
raw_order_result=raw_order_result,
|
||
|
|
)
|
||
|
|
click.echo(f"[run] ASR 完成: {asr_sentence_count} 句")
|
||
|
|
|
||
|
|
# 如果跳过了 ASR(已存在),读取句子数用于汇总
|
||
|
|
if asr_sentence_count == 0 and asr_timed_path.exists():
|
||
|
|
with open(asr_timed_path, "r", encoding="utf-8") as fh:
|
||
|
|
asr_sentence_count = sum(1 for line in fh if line.strip())
|
||
|
|
|
||
|
|
completed_stages.append("C2: ASR")
|
||
|
|
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
# C3: 融合复审
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
_stage_header("C3: 融合复审")
|
||
|
|
|
||
|
|
c3_stats = run_fusion(
|
||
|
|
episode_id=episode_id,
|
||
|
|
output_dir=str(episode_dir),
|
||
|
|
no_ai=False,
|
||
|
|
batch_size=35,
|
||
|
|
)
|
||
|
|
fused_b_lines = c3_stats.get("total_lines", 0)
|
||
|
|
click.echo(f"[run] C3 完成: 融合B稿 {fused_b_lines} 行")
|
||
|
|
|
||
|
|
completed_stages.append("C3: 融合复审")
|
||
|
|
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
# C4: 对齐出稿
|
||
|
|
# ════════════════════════════════════════════════════════
|
||
|
|
_stage_header("C4: 对齐出稿")
|
||
|
|
|
||
|
|
# 检查骨架文件
|
||
|
|
skeleton_path = episode_dir / f"{episode_id}_a_skeleton.json"
|
||
|
|
if not skeleton_path.exists():
|
||
|
|
raise FileNotFoundError(
|
||
|
|
f"骨架文件不存在: {skeleton_path}\n"
|
||
|
|
f"骨架需人工核验,请先手动运行: doco skeleton --episode-id {episode_id} "
|
||
|
|
f"--a-script {a_script}\n"
|
||
|
|
f"核验无误后,再运行 doco run。"
|
||
|
|
)
|
||
|
|
|
||
|
|
c4_stats = run_compose(
|
||
|
|
episode_id=episode_id,
|
||
|
|
output_dir=str(episode_dir),
|
||
|
|
no_ai=False,
|
||
|
|
batch_size=batch_size,
|
||
|
|
)
|
||
|
|
fused_a_docx = c4_stats.get("docx_path", "")
|
||
|
|
click.echo(f"[run] C4 完成: 融合A稿 → {fused_a_docx}")
|
||
|
|
|
||
|
|
completed_stages.append("C4: 对齐出稿")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
click.echo("")
|
||
|
|
click.echo("═════════════════════════════")
|
||
|
|
click.echo("❌ 流程中断")
|
||
|
|
click.echo("═════════════════════════════")
|
||
|
|
if completed_stages:
|
||
|
|
click.echo("已完成的阶段:")
|
||
|
|
for s in completed_stages:
|
||
|
|
click.echo(f" ✅ {s}")
|
||
|
|
click.echo(f"失败阶段: {e}", err=True)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# ── 全部完成 ──
|
||
|
|
click.echo("")
|
||
|
|
_stage_header("✅ 全流程完成")
|
||
|
|
click.echo(f"B稿v2: {b_v2_lines} 行")
|
||
|
|
click.echo(f"热词: {hotword_count} 条")
|
||
|
|
click.echo(f"ASR: {asr_sentence_count} 句")
|
||
|
|
click.echo(f"融合B稿: {fused_b_lines} 行")
|
||
|
|
click.echo(f"融合A稿: {fused_a_docx}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|