fix: 帧文件按帧号数值排序,修复超 9999 帧字典序错位

- frame-extract 新增 _sorted_frame_files:ffmpeg %04d 编号超过 9999 帧后扩为
  5 位,sorted() 字典序会把 5 位编号排在 4 位之前,导致 frames.json 时间与
  图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务)
- subtitle-ocr 提取 _merge_kept 供重组装复用
- 回归测试用真实任务留存数据(testdata/frames_boundary/),并新增真实 OCR
  数据按正确时间轴重组装为 SRT 的集成测试(test_integration_reassemble_ocr)
This commit is contained in:
2026-08-17 23:20:11 +08:00
parent f9ba96ef3a
commit 2b3a650612
17 changed files with 160 additions and 14 deletions
+19 -1
View File
@@ -111,6 +111,24 @@ def _parse_progress_line(line: str) -> int | None:
except ValueError:
return None
def _sorted_frame_files(frames_dir: Path) -> list[Path]:
"""按文件名中的帧号数值排序返回帧文件列表(自然排序,非字典序)。
关键点:ffmpeg 的 %04d 编号在超过 9999 帧后会自动扩为 5 位
frame_10000.png 等),此时 sorted() 默认的字典序会把 5 位编号排在
4 位编号之前(如 "frame_10009" < "frame_1009"),导致帧号回退、
manifest 时间与图像错位(曾真实发生于 run_339ec7ee437f 的 14236 帧
任务,全片后半段时间轴全部错乱)。必须解析出帧号按数值排序,
才能保证"第 k 个文件 = 第 k 个选中帧 = 时间 index*step/fps"成立。
"""
def frame_number(path: Path) -> int:
# 文件名形如 frame_0001.png,取下划线后的数字部分。
return int(path.stem.split("_", 1)[1])
return sorted(frames_dir.glob("frame_*.png"), key=frame_number)
def invoke(request: InvokeRequest) -> InvokeResponse:
"""按帧间隔抽取并裁切视频帧,输出 frames.json 清单。"""
video_uri = request.inputs.get("video_uri")
@@ -203,7 +221,7 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
return InvokeResponse(status="failed", error=stderr[-500:] or "ffmpeg failed")
# 第 k 个输出文件对应原始帧号 k×step,时间 = 帧号 / fps(帧精确,无累计偏差)。
files = sorted(frames_dir.glob("frame_*.png"))
files = _sorted_frame_files(frames_dir)
manifest = [
{"time": round((index * step) / fps, 3), "image_uri": str(path)}
for index, path in enumerate(files)
+24 -13
View File
@@ -63,6 +63,28 @@ def _assemble_srt(
return lines
def _merge_kept(
manifest: list[dict], texts: list[str]
) -> list[tuple[float, float, str]]:
"""按 manifest 时间轴把逐帧 OCR 文本合并为字幕条目。
kept 元素为 (起始帧时间, 最后可见帧时间, 文本):空文本(无文字帧)跳过;
连续帧相同字幕合并为一条(字幕停留多帧属正常现象),仅更新最后可见帧
时间,起始时间保持首次出现。要求 texts 与 manifest 按帧顺序一一对应
(调用方保证),重组装旧数据时也复用此逻辑保证行为一致。
"""
kept: list[tuple[float, float, str]] = []
for index, text in enumerate(texts):
if not text:
continue
time = float(manifest[index]["time"])
if kept and kept[-1][2] == text:
kept[-1] = (kept[-1][0], time, text)
continue
kept.append((time, time, text))
return kept
def invoke(request: InvokeRequest) -> InvokeResponse:
"""逐帧 OCR 并汇总字幕,产物为 subtitle.srt。"""
manifest_uri = request.inputs.get("frames_manifest")
@@ -138,19 +160,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
)
texts = pool.map(list(enumerate(manifest)))
# kept 元素为 (起始帧时间, 最后可见帧时间, 文本);按帧顺序合并连续相同字幕。
kept: list[tuple[float, float, str]] = []
for index, text in enumerate(texts):
if not text:
continue
time = float(manifest[index]["time"])
# 连续帧相同字幕合并为一条(字幕停留多帧属正常现象):
# 仅更新最后可见帧时间,起始时间保持首次出现。
if kept and kept[-1][2] == text:
kept[-1] = (kept[-1][0], time, text)
continue
kept.append((time, time, text))
# 按帧顺序合并连续相同字幕(与重组装旧数据共用 _merge_kept
kept = _merge_kept(manifest, texts)
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "subtitle.srt"