"""视频字幕生成流水线 → 时间对齐集成测试(真实数据复现"字幕时间不吻合")。 背景:用户反馈 demo"视频字幕生成"产物的字幕与人物说话时间不吻合——存在 字幕**过早**或**过晚**展示。本测试**不自己跑 whisper**(长视频转写慢、低 显存易 OOM),而是: 1. 直接用现有"视频字幕生成"流水线跑一遍真实视频(该流水线已产出 transcript.srt 中文翻译字幕,时间轴来自 whisper + 提示词翻译); 2. 测试读取流水线**产物文件夹**里的最终字幕(.srt/.ass); 3. 与人工校对的**参考字幕**(testdata/alignment/.reference.srt,即 硬字幕 OCR 提取、时间轴为"说话真实发生的时间")做时间对齐量化评估, 复现"过早/过晚"问题(红),为修复提供绿标准。 产物来源(用户告知):流水线结果文件夹,含最终字幕文件。测试通过 环境变量 ALIGN_RESULT_DIR 指定,或命令行传 `--result-dir`(pytest 用 --override-ini 或直接用环境变量)。 数据契约: - 参考字幕:``testdata/alignment/.reference.srt``(硬字幕/人工校对) - 流水线产物:``$ALIGN_RESULT_DIR/*.srt|*.ass``(最终字幕,时间轴为产物) 素材/参考/产物任一缺失时测试整体跳过(不污染 100% 覆盖率门禁)。 """ from __future__ import annotations import os from pathlib import Path import pytest from tests.realdata_contract import ( ALIGNMENT_DIR, TIER1_TOLERANCE_SECONDS, TIER2_EARLY_SECONDS, TIER2_LATE_SECONDS, align_report, alignment_candidates, clean_reference, parse_srt_entries, ) def _result_dir() -> Path | None: """返回流水线产物文件夹(环境变量 ALIGN_RESULT_DIR 指定);未设置返回 None。""" raw = os.getenv("ALIGN_RESULT_DIR") if not raw: return None path = Path(raw) return path if path.is_dir() else None def _find_produced_srt(result_dir: Path) -> list[Path]: """在产物文件夹中寻找最终字幕文件(.srt / .ass),按名称排序。""" files = [] for ext in (".srt", ".ass"): files.extend(result_dir.glob(f"*{ext}")) # 排除参考字幕与临时文件。 files = [f for f in files if ".reference" not in f.name and not f.name.startswith("._")] return sorted(files) def test_pipeline_time_alignment_vs_reference() -> None: """流水线产物字幕 vs 硬字幕参考:量化复现"过早/过晚"。 做法(真实数据,不 mock): - 取 testdata/alignment/.reference.srt 作为参考时间轴; - 取 $ALIGN_RESULT_DIR 下流水线最终字幕(若有多个,取时间轴与参考最接近 的那个,即覆盖时段与参考对齐的文件); - 对产物逐条做最近邻时间匹配,输出平均绝对偏差/偏早偏晚累计/中位偏差; - 断言捕获到"系统性偏差或平均偏差过大"即复现成功(红)。 修复后(vad 策略/对齐策略/翻译时间戳策略改善)偏差应回落,测试转绿。 """ candidates = alignment_candidates() if not candidates: pytest.skip( f"缺少参考数据({ALIGNMENT_DIR}/. + .reference.srt)," "跳过时间对齐集成测试" ) result_dir = _result_dir() if result_dir is None: pytest.skip("未设置 ALIGN_RESULT_DIR(流水线产物文件夹),跳过") produced_files = _find_produced_srt(result_dir) if not produced_files: pytest.skip( f"{result_dir} 下没有 .srt/.ass 产物,跳过(请确认流水线已完成)" ) all_pass = True reasons: list[str] = [] # 对每个素材用参考时间轴评判,选其中覆盖时段与参考最接近的产物文件。 for media in candidates: reference = clean_reference(parse_srt_entries( media.with_suffix(".reference.srt").read_text(encoding="utf-8") )) if not reference: reasons.append(f"{media.stem}: 参考字幕为空") all_pass = False continue # 择优:产物文件与参考起始时间差最小的作为该素材的评判对象。 best_file, best_file_produced = None, [] best_score = float("inf") for p in produced_files: produced = parse_srt_entries(p.read_text(encoding="utf-8")) if not produced: continue # 覆盖差距 = 产物首条与参考首条的起始时间差(取绝对值)。 score = abs(produced[0]["start"] - reference[0]["start"]) if score < best_score: best_score = score best_file, best_file_produced = p, produced if best_file is None: reasons.append(f"{media.stem}: 产物文件均无法与参考匹配") all_pass = False continue report = align_report(media.stem, True, best_file_produced, reference) print("\n" + report.format_summary()) print(f" 产物文件: {best_file.name}") reason = None if report.mean_abs_error > TIER1_TOLERANCE_SECONDS: reason = ( f"{media.stem} 平均绝对偏差 {report.mean_abs_error:.2f}s > " f"容差 {TIER1_TOLERANCE_SECONDS}s(过早/过晚普遍存在)" ) elif report.consistently_early: reason = ( f"{media.stem} 系统性偏早(中位 {report.bias:+.2f}s < " f"-{TIER2_EARLY_SECONDS}s)" ) elif report.consistently_late: reason = ( f"{media.stem} 系统性偏晚(中位 {report.bias:+.2f}s > " f"+{TIER2_LATE_SECONDS}s)" ) if reason: reasons.append(reason) all_pass = False else: print(f" 对齐正常: {media.stem}") # 结论性断言:至少量化捕获到一个"过早/过晚"信号才是"复现成功"。 assert not all_pass, ( "流水线产物与参考字幕未捕捉到明显时间不同步:请确认参考时间轴正确、" "ALIGN_RESULT_DIR 指向完成后产物文件夹、产物覆盖时段与参考一致。" ) if reasons: raise AssertionError( "已量化复现字幕时间与说话时间不吻合:\n- " + "\n- ".join(reasons) ) def test_alignment_report_helpers() -> None: """纯函数冒烟:对齐指标组件(不依赖真实数据,用于验证指标本身)。""" produced = [ {"start": 1.0, "end": 2.0, "text": "a"}, {"start": 4.0, "end": 5.0, "text": "b"}, ] reference = [ {"start": 1.0, "end": 2.0, "text": "A"}, {"start": 4.0, "end": 5.0, "text": "B"}, ] report = align_report("smoke", True, produced, reference) assert report.mean_abs_error == 0.0 # 完美对齐时平均绝对偏差为 0 assert not report.consistently_early assert not report.consistently_late assert report.bias == 0.0 # 系统性偏晚:产物起始全部比参考晚 1.5s。 produced_late = [ {"start": 2.5, "end": 3.5, "text": "a"}, {"start": 5.5, "end": 6.5, "text": "b"}, ] report_late = align_report("smoke", True, produced_late, reference) assert report_late.consistently_late assert report_late.bias > TIER2_LATE_SECONDS # 系统性偏早:产物起始全部比参考早 1.5s。 ref_early = [ {"start": 2.0, "end": 3.0, "text": "X"}, ] produced_early = [ {"start": 0.5, "end": 1.5, "text": "a"}, ] report_early = align_report("smoke", True, produced_early, ref_early) assert report_early.consistently_early assert report_early.bias < -TIER2_EARLY_SECONDS