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:
@@ -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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
### 模型权重解析(本地优先)
|
||||
@@ -269,7 +269,12 @@ http://127.0.0.1:8000/docs API 文档
|
||||
缺失时测试跳过而非现场生成。大体积视频素材放 `data/testdata/`(gitignored)。OCR 相关资产:
|
||||
`ocr_text.png`(有文字)、`ocr_notext.png`(无文字帧)、`subtitle_10s.mp4`
|
||||
(烧录 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(红-绿-重构)**:任何新功能/修复必须先写失败测试(红),
|
||||
再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。
|
||||
- 测试运行:`uv run pytest`;全部测试位于 `tests/`。
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,215 @@
|
||||
"""多线程下字幕顺序正确性测试(模拟真实 API 返回,全量真实数据)。
|
||||
|
||||
目标:验证 subtitle-ocr 在**多线程**执行时能否正确处理字幕顺序。
|
||||
|
||||
- 不走真实 vlm-ocr(Ollama)网络调用: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
|
||||
Reference in New Issue
Block a user