test: 多线程字幕顺序验证(全量真实数据夹具)+ 文档更新

- 新增 TestSubtitleOcrOrderThreading:模拟真实 vlm-ocr API 返回(不走真实
  OCR),全量 14236 帧真实数据 + 种子化可变延迟(约 8% 慢帧,模拟真实 OCR
  延迟波动),验证 4/16 线程产物与单线程确认结果逐字节一致、并发真实发生、
  完成顺序乱序但输出保序、时间轴严格递增、每条字幕与起始时刻帧文本对齐
- 全量夹具:frames_manifest_full.json + ocr_frames_full.json(真实任务
  run_ac7f480a3ccb 全部 14236 帧),常驻测试不跳过
- AGENTS.md:更新 llm-filter 参数说明与测试资产清单
This commit is contained in:
2026-08-17 23:20:20 +08:00
parent 5ffa2ac39e
commit 4ebbfc5198
4 changed files with 225 additions and 3 deletions
+8 -3
View File
@@ -54,9 +54,9 @@ vrsub/
| `faster-whisper` | `audio_uri`16kHz 单声道) | `srt_uri` | 参数:`language``task``model_path``device``compute_type``beam_size``vad_filter`(默认开)、`condition_on_previous_text``chunk_seconds` | | `faster-whisper` | `audio_uri`16kHz 单声道) | `srt_uri` | 参数:`language``task``model_path``device``compute_type``beam_size``vad_filter`(默认开)、`condition_on_previous_text``chunk_seconds` |
| `llm-translate` | `srt_uri` | `cn_srt_uri` | 参数:`target_language``model` | | `llm-translate` | `srt_uri` | `cn_srt_uri` | 参数:`target_language``model` |
| `vlm-ocr` | `image_uri` | `text``text_uri` | 直接调本地 Ollama 多模态模型(glm-ocr)的 `/api/chat` 做视频帧 OCR(流式 + 5s 上限),参数:`model``ollama_host``prompt``timeout_seconds``keep_alive``num_predict``temperature``repeat_penalty` | | `vlm-ocr` | `image_uri` | `text``text_uri` | 直接调本地 Ollama 多模态模型(glm-ocr)的 `/api/chat` 做视频帧 OCR(流式 + 5s 上限),参数:`model``ollama_host``prompt``timeout_seconds``keep_alive``num_predict``temperature``repeat_penalty` |
| `frame-extract` | `video_uri` | `frames_manifest``frame_count` | 按**帧间隔**抽帧(解析 fps → step=round(间隔秒×fps)ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:`interval_seconds`(默认 0.5)、`crop`[x,y,w,h] 0~1 | | `frame-extract` | `video_uri` | `frames_manifest``frame_count` | 按**帧间隔**抽帧(解析 fps → step=round(间隔秒×fps)ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:`interval_seconds`(默认 0.5)、`crop`[x,y,w,h] 0~1。**帧文件必须按帧号数值排序读取**(`_sorted_frame_files`):ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` 会把 5 位编号排在 4 位之前导致时间与图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务,回归测试见 `test_frame_files_read_order_matches_frame_number` |
| `subtitle-ocr` | `frames_manifest` | `srt_uri``count` | 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:`min_chars``min_alnum_ratio``garbage_tokens``pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | | `subtitle-ocr` | `frames_manifest` | `srt_uri``count` | 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:`min_chars``min_alnum_ratio``garbage_tokens``pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` |
| `llm-filter` | `srt_uri` | `srt_uri``kept``removed` | LLM 过滤无意义字幕(自适应线程池并发判断):每条连同前后各 `context_size`(默认 10)条纯文本(不含时间戳)分批给 LLM,仅判断目标字幕是否多余/无意义,判定删除则该条连同时间戳移除并重新编号,参数:`context_size``model``pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | | `llm-filter` | `srt_uri` | `srt_uri``kept``removed` | 两级过滤:①**规则层**(不调 LLM)直接删横线装饰/HTML 水印 token/URL/邮箱/单双 ASCII 字符;②**LLM 五类分类**garbage/overlay/noise 删,repeat/dialogue 留,未识别回退保留)每条连同前后各 `context_size`(默认 10)条纯文本分批判断,**按文本去重**(忽略空白/大小写,相同文本只调一次 LLM,上下文取首次出现)保证判定一致并省调用,**长文本保护**(≥`min_keep_len` 默认 12 时 noise 不构成删除依据)。参数:`context_size``min_keep_len``overlay_tokens`JSON 数组)、`dedupe`(默认开)`model``pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold`。回归数据:testdata/ocr_srt_run_ac7f480a3ccb.srt(真实任务 1666 条 OCR 输出) |
| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`,如 `3840x1920` | | `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`,如 `3840x1920` |
### 模型权重解析(本地优先) ### 模型权重解析(本地优先)
@@ -269,7 +269,12 @@ http://127.0.0.1:8000/docs API 文档
缺失时测试跳过而非现场生成。大体积视频素材放 `data/testdata/`gitignored)。OCR 相关资产: 缺失时测试跳过而非现场生成。大体积视频素材放 `data/testdata/`gitignored)。OCR 相关资产:
`ocr_text.png`(有文字)、`ocr_notext.png`(无文字帧)、`subtitle_10s.mp4` `ocr_text.png`(有文字)、`ocr_notext.png`(无文字帧)、`subtitle_10s.mp4`
(烧录 SUB 001@1-4s / SUB 002@6-9s 的 10s 测试视频)、`test_real_hav_sub.png` (烧录 SUB 001@1-4s / SUB 002@6-9s 的 10s 测试视频)、`test_real_hav_sub.png`
(真实视频字幕截图,VLM 集成测试期望识别出"还有没有什么困扰 或者奇怪的地方吗") (真实视频字幕截图,VLM 集成测试期望识别出"还有没有什么困扰 或者奇怪的地方吗")
`ocr_srt_run_ac7f480a3ccb.srt`(真实任务 1666 条 OCR 输出,llm-filter 回归)、
`frames_manifest_full.json` + `ocr_frames_full.json`(真实任务 run_ac7f480a3ccb
**全部 14236 帧**的帧清单与逐帧 OCR 文本,多线程顺序测试常驻夹具;配合
`ocr_srt_run_ac7f480a3ccb.srt` 作为单线程确认基线,见
`tests/test_subtitle_ocr_order_threading.py`)。
- **开发流程强制 TDD(红-绿-重构)**:任何新功能/修复必须先写失败测试(红), - **开发流程强制 TDD(红-绿-重构)**:任何新功能/修复必须先写失败测试(红),
再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。 再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。
- 测试运行:`uv run pytest`;全部测试位于 `tests/` - 测试运行:`uv run pytest`;全部测试位于 `tests/`
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+215
View File
@@ -0,0 +1,215 @@
"""多线程下字幕顺序正确性测试(模拟真实 API 返回,全量真实数据)。
目标:验证 subtitle-ocr 在**多线程**执行时能否正确处理字幕顺序。
- 不走真实 vlm-ocrOllama)网络调用:registry.invoke 被替换为
FakeVlmOcrApi,按 image_uri 文件名中的帧号,直接从测试数据(真实任务
run_ac7f480a3ccb 的**全量**逐帧 OCR 结果)取该帧文本返回,模拟真实
API 返回结构;输入可以是文件名(frames_manifest / image_uri);
- **真实 OCR 延迟模拟**:真实 vlm-ocr 每次调用延迟不可预判——大部分帧快、
少量帧明显慢(复杂画面/模型排队,真实约 0.1s~5s,测试按比例缩放)。
假 API 用种子化随机生成同样的快/慢分布(约 8% 慢帧),制造真实波动下
的乱序完成,对线程池的保序能力施加最贴近真实情况的压力;
- 核心断言:多线程(4/16 线程)产出的 SRT 与**用户确认过的精确结果**逐字节
一致——该结果正是真实任务 run_ac7f480a3ccb 以**单线程**workflow v4
pool 1/1)运行产出并经用户确认的,因此逐字节一致即证明"多线程 == 单线程"
同时断言并发真实发生、完成顺序确实乱序、全量时间轴严格递增、每条字幕
与其起始时刻帧的文本对齐。
全量真实数据(testdata/,常驻夹具,真实任务 run_ac7f480a3ccb 全部 14236 帧):
- frames_manifest_full.json:完整帧清单(14236 条,image_uri 改为文件名);
- ocr_frames_full.json{帧号: 该帧 OCR 文本}(位置 p ↔ 帧 p+1);
- ocr_srt_run_ac7f480a3ccb.srt:该任务单线程运行产出、用户确认过的精确结果(1666 条)。
"""
import json
import random
import re
import threading
import time
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
WORKSPACE = Path(__file__).resolve().parent.parent
TESTDATA = WORKSPACE / "testdata"
FULL_MANIFEST = TESTDATA / "frames_manifest_full.json"
FULL_OCR_TEXTS = TESTDATA / "ocr_frames_full.json"
CONFIRMED_SRT = TESTDATA / "ocr_srt_run_ac7f480a3ccb.srt"
TOTAL_FRAMES = 14236
# 帧号解析:image_uri 文件名形如 frame_0411.png。
_FRAME_RE = re.compile(r".*frame_(\d+)\.png")
def _frame_no(uri: str) -> int:
"""从 image_uri 文件名解析帧号。"""
return int(_FRAME_RE.match(Path(uri).name).group(1))
def _ts_to_seconds(ts: str) -> float:
"""SRT 时间戳(HH:MM:SS,mmm)转秒。"""
h, m, s = ts.replace(",", ".").split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
class FakeVlmOcrApi:
"""模拟真实 vlm-ocr API:不发起真实网络调用,从测试数据返回该帧 OCR 文本。
- 输入是 image_uri 文件名,解析帧号后从全量真实 OCR 结果({帧号: 文本}
取该帧文本返回,模拟真实 API 返回结构(status=completed / outputs.text);
- **可变延迟模拟真实 OCR**:每次调用独立随机,大部分帧快
0.2~2ms,对应真实约 0.1~1s),约 slow_ratio 比例的帧明显慢
6~18ms,对应真实约 3~9s,如复杂画面/模型排队)。延迟不可预判,
对线程池的乱序恢复能力施加与真实情况一致的随机压力;
- 线程安全地记录并发峰值、完成顺序与每次延迟,供断言"多线程确实发生、
乱序完成、且存在明显慢帧"
"""
def __init__(self, texts_by_frame: dict[int, str], seed: int = 20260817,
fast_ms: float = 1.0, slow_ms: float = 12.0,
slow_ratio: float = 0.08) -> None:
self._texts_by_frame = texts_by_frame
self._fast_ms = fast_ms
self._slow_ms = slow_ms
self._slow_ratio = slow_ratio
# 种子化随机源:延迟波动可复现(固定种子 → 测试确定性,不 flaky)。
self._rng = random.Random(seed)
self._lock = threading.Lock()
self.active = 0
self.max_active = 0
# 完成顺序(帧号):用于断言乱序完成确实发生。
self.completed_frames: list[int] = []
# 每次调用的实际延迟(毫秒):用于断言快/慢分布确实发生。
self.delays_ms: list[float] = []
def __call__(self, node_id: str, request: InvokeRequest) -> InvokeResponse:
frame_no = _frame_no(request.inputs["image_uri"])
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
# 真实 OCR 延迟:慢帧比例固定,具体哪帧慢由随机决定(不可预判)。
if self._rng.random() < self._slow_ratio:
delay = self._slow_ms * (0.5 + self._rng.random())
else:
delay = self._fast_ms * (0.2 + self._rng.random() * 1.8)
self.delays_ms.append(delay)
time.sleep(delay / 1000.0)
text = self._texts_by_frame[frame_no]
finally:
with self._lock:
self.active -= 1
self.completed_frames.append(frame_no)
# 与真实 vlm-ocr 节点一致的返回结构。
return InvokeResponse(status="completed", outputs={"text": text})
def _run_ocr(monkeypatch, manifest_path: Path, texts_by_frame: dict[int, str],
pool_min: int, pool_max: int, output_dir: Path, seed: int,
) -> tuple[Path, FakeVlmOcrApi]:
"""用给定线程配置运行 subtitle-ocr,返回 (产物路径, 假 API 实例)。"""
from nodes.subtitle_ocr import invoke as ocr_invoke
fake = FakeVlmOcrApi(texts_by_frame, seed=seed)
monkeypatch.setattr("wov_app.registry.invoke", fake)
response = ocr_invoke(
InvokeRequest(
run_id="order_test",
node_instance_id="",
inputs={"frames_manifest": str(manifest_path)},
params={"pool_min_workers": pool_min, "pool_max_workers": pool_max},
output_dir=str(output_dir),
)
)
assert response.status == "completed", response.error
return Path(response.outputs["srt_uri"]), fake
def _load_full_data() -> tuple[list[dict], dict[int, str]]:
"""加载全量夹具:manifest 与 {帧号: 文本}。"""
manifest = json.loads(FULL_MANIFEST.read_text(encoding="utf-8"))
texts_by_frame = {
int(key): value for key, value in json.loads(FULL_OCR_TEXTS.read_text(encoding="utf-8")).items()
}
return manifest, texts_by_frame
def _assert_alignment(srt_text: str, manifest: list[dict], texts_by_frame: dict[int, str]) -> None:
"""核心对齐断言:每条字幕的起始时刻对应的帧,其 OCR 文本必须就是本条字幕文本。
这正是"多线程下顺序正确"的最终验证:无论线程如何并发/乱序完成,
每条字幕贴的时刻必须是它真实来源帧的时刻。
"""
time_text = {
round(float(entry["time"]), 3): texts_by_frame[_frame_no(entry["image_uri"])]
for entry in manifest
}
blocks = re.findall(
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)",
srt_text, re.DOTALL,
)
times = []
for start, _end, text in blocks:
start_s = _ts_to_seconds(start)
# 帧时间与 SRT 时间戳间允许 ±2ms 容差:format_timestamp 用 int 截断,
# 浮点 256.258 会以 256.25799.. 截断为 256,257(真实运行同样行为)。
best = min(time_text, key=lambda t: abs(t - start_s))
assert abs(best - start_s) <= 0.002, f"字幕起始时刻 {start_s}s 无对应帧"
assert time_text[best] == text.strip(), f"时刻 {start_s}s 的文本与帧不一致"
times.append(start_s)
assert all(a < b for a, b in zip(times, times[1:])), "时间轴必须严格递增"
class TestSubtitleOcrOrderUnderThreading:
"""多线程下字幕顺序正确性测试类(全量 14236 帧真实数据 + 真实可变延迟)。
确认结果(testdata/ocr_srt_run_ac7f480a3ccb.srt)是真实任务 run_ac7f480a3ccb
以**单线程**workflow v4 pool 1/1)运行产出、经用户确认的精确结果:
- 多线程(4/16 线程)+ 真实可变延迟下重放全量数据,产物必须与它逐字节一致;
- 同时必须真实发生"多线程 + 乱序完成 + 明显慢帧",而顺序仍正确。
数据全部为常驻夹具,测试始终执行(不依赖 gitignored 数据)。
"""
def test_full_real_data_variable_latency_keeps_order(self, monkeypatch, tmp_path) -> None:
"""全量真实数据 + 真实可变延迟:多线程产物与单线程确认结果逐字节一致。"""
manifest, texts_by_frame = _load_full_data()
confirmed = CONFIRMED_SRT.read_text(encoding="utf-8")
outputs: dict[tuple, str] = {}
fakes: dict[tuple, FakeVlmOcrApi] = {}
for pool_min, pool_max in ((4, 4), (16, 16)):
out_dir = tmp_path / f"p{pool_min}-{pool_max}"
srt_path, fake = _run_ocr(
monkeypatch, FULL_MANIFEST, texts_by_frame,
pool_min=pool_min, pool_max=pool_max,
output_dir=out_dir, seed=20260817,
)
outputs[(pool_min, pool_max)] = srt_path.read_text(encoding="utf-8")
fakes[(pool_min, pool_max)] = fake
# ① 多线程产物与用户确认过的精确结果(真实单线程运行)逐字节一致。
assert outputs[(4, 4)] == confirmed, "4 线程产物与确认结果不一致"
assert outputs[(16, 16)] == confirmed, "16 线程产物与确认结果不一致"
assert outputs[(4, 4)] == outputs[(16, 16)]
# ② 多线程确实发生(并发峰值>1),且可变延迟下完成顺序乱序(靠后帧
# 先完成/慢帧滞后),但输出仍与确认结果一致——线程池正确恢复了顺序。
for (pool_min, pool_max), fake in fakes.items():
assert fake.max_active > 1, f"{pool_max} 线程配置下应真实并发"
assert fake.completed_frames != sorted(fake.completed_frames), \
f"{pool_max} 线程下可变延迟应产生乱序完成"
# ③ 延迟模拟符合真实 OCR:存在明显慢帧(>3 倍快帧均值),也含极快帧。
for (_, pool_max), fake in fakes.items():
delays = fake.delays_ms
assert len(delays) == TOTAL_FRAMES, "每帧都应产生一次调用延迟"
assert max(delays) > 3.0, f"{pool_max} 线程下应存在明显慢帧(真实 OCR 延迟波动)"
assert min(delays) < 1.0, "应存在快帧(大部分帧响应快)"
# ④ 全量时间轴严格递增 + 每条字幕与其起始时刻帧的文本对齐。
_assert_alignment(confirmed, manifest, texts_by_frame)
# ⑤ 已知真实内容存在于结果中(确认结果的代表性条目)。
for line in ("北冈小姐", "这是特别病房患者的病历表", "应该已经察觉到 至今为止的一切了吧"):
assert line in confirmed, line