"""真实任务 OCR 数据重组装集成测试。 run_339ec7ee437f 是 2026-08 真实跑过的 ocr-subtitle 任务(14236 帧 / 2 小时 视频)。该任务产出时期存在 frame-extract 帧文件排序 bug:ffmpeg 的 %04d 编号 超过 9999 帧后扩为 5 位,sorted() 字典序把 5 位编号排在 4 位之前,导致 frames.json 中 time 与图像错位(13236/14236 条位置↔帧号错位),最终 SRT 后半段时间轴全部错乱。 本测试读取该任务**已落盘的逐帧 OCR 文本**(ocr_frames/<位置>/ocr.txt,由 vlm-ocr 节点写入的清洗后文本),用 image_uri 解析真实帧号重建正确时间轴, 复用生产合并/组装逻辑生成 SRT,并断言"每条字幕的起始时刻 = 其文本来源帧的 真实时间"这一核心正确性。 数据位于 gitignored 的 data/storage,缺失时跳过(与 test_integration_subtitle_ocr 的真实资产约定一致)。 """ import json import re from pathlib import Path import pytest from nodes.subtitle_ocr import _assemble_srt from nodes.subtitle_ocr import _merge_kept from nodes.subtitle_ocr import _sampling_interval # 单体根目录:tests/ 的上一级。 WORKSPACE = Path(__file__).resolve().parent.parent RUN_DIR = WORKSPACE / "data/storage/runs/run_339ec7ee437f" MANIFEST = RUN_DIR / "steps/extract/frames.json" OCR_DIR = RUN_DIR / "steps/ocr/ocr_frames" # 源视频时长(ffprobe: 02:00:24.80)。 VIDEO_DURATION = 7224.8 # 该视频烧录过的字幕行,被 test_real_hav_sub.png 集成测试确认过识别结果。 KNOWN_LINE = "还有没有什么困扰" def _frame_number(uri: str) -> int: """从 image_uri 文件名解析真实帧号(frame_0001.png -> 1)。""" return int(re.match(r".*frame_(\d+)\.png", Path(uri).name).group(1)) @pytest.mark.integration def test_reassemble_real_run_ocr_data(tmp_path) -> None: """真实任务逐帧 OCR 数据按真实帧时间轴重组装为正确 SRT。""" if not (MANIFEST.is_file() and OCR_DIR.is_dir()): pytest.skip("缺少真实任务 run_339ec7ee437f 数据,跳过集成测试") manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) total = len(manifest) # 等间隔采样:step/fps 从旧 manifest 相邻 time 差恢复(本任务 0.507s/帧)。 dt = manifest[1]["time"] - manifest[0]["time"] assert dt > 0 # ① 读取逐帧 OCR 文本:位置 i -> ocr_frames/{i:04d}/ocr.txt(真实数据)。 texts_by_pos: list[str] = [] for i in range(total): p = OCR_DIR / f"{i:04d}" / "ocr.txt" if not p.is_file(): pytest.fail(f"缺少逐帧 OCR 数据: {p}") texts_by_pos.append(p.read_text(encoding="utf-8").strip()) # ② 旧 manifest 位置 -> 真实帧号:image_uri 才是实际被 OCR 的图像, # 旧 time 字段按位置推导已错位,不可用。 frame_by_pos = [_frame_number(item["image_uri"]) for item in manifest] assert sorted(frame_by_pos) == list(range(1, total + 1)) # 一一对应,无缺无重 # ③ 按真实帧号重建正确时间轴:第 k 帧(1-based)时间 = (k-1)*dt。 ocr_by_frame = {n: texts_by_pos[i] for i, n in enumerate(frame_by_pos)} manifest_corrected = [{"time": round((k - 1) * dt, 3)} for k in range(1, total + 1)] texts_corrected = [ocr_by_frame[k] for k in range(1, total + 1)] # ④ 复用生产合并 + 组装逻辑(与 subtitle_ocr.invoke 完全同一路径)。 kept = _merge_kept(manifest_corrected, texts_corrected) interval = _sampling_interval(manifest_corrected, dt) srt = "\n".join(_assemble_srt(kept, interval)) + "\n" out = tmp_path / "subtitle.srt" out.write_text(srt, encoding="utf-8") # ⑤ 核心正确性:每条字幕起始时刻对应的帧,其 OCR 文本必须就是本条字幕 # 文本(修复前旧 SRT 此处大面积不一致:文本来自其他时刻的帧)。 for start, _end, text in kept: frame_no = int(round(start / dt)) + 1 assert ocr_by_frame[frame_no] == text, f"帧 {frame_no} 时间错位: {text!r}" # ⑥ 时间轴严格递增且不超出片长(含尾部一个采样间隔的消失余量)。 assert all( s1 < s2 for (s1, _1, _), (s2, _2, _) in zip(kept, kept[1:]) ) assert kept[0][0] >= 0.0 assert kept[-1][1] + interval <= VIDEO_DURATION + interval + 1e-6 # ⑦ 已知烧录字幕行必须出现,且字幕条数合理(空帧被跳过、连续相同字幕合并)。 assert KNOWN_LINE in srt assert len(kept) > 100