diff --git a/nodes/subtitle_ocr.py b/nodes/subtitle_ocr.py index af7a6f5..fdb8930 100644 --- a/nodes/subtitle_ocr.py +++ b/nodes/subtitle_ocr.py @@ -59,6 +59,28 @@ def _load_partial(output_dir: Path) -> dict[int, str]: result[int(item["frame"])] = str(item["text"]) return result + +def _format_eta(seconds: float) -> str: + """把剩余秒数格式化为可读的预计完成时间(如 34分13秒 / 2小时05分)。""" + total = max(0, int(seconds)) + hours, remainder = divmod(total, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}小时{minutes:02d}分" + if minutes: + return f"{minutes}分{secs:02d}秒" + return f"{secs}秒" + + +def _eta_suffix(done: int, total: int, rate: float) -> str: + """根据当前处理速度计算剩余时间后缀(供进度日志追加)。 + + 剩余时间 = 剩余帧数 / 当前速度;速度为 0(刚开始或耗时不可测)时 + 返回空串,进度提示不显示 ETA。 + """ + if rate <= 0: + return "" + return f", 预计剩余 {_format_eta((total - done) / rate)}" # 默认垃圾词:无文字帧的模型输出可能反复出现这些词。 def _sampling_interval(manifest: list[dict], default: float) -> float: """从帧清单时间轴推导采样间隔(相邻帧时间差的中位数)。 @@ -206,12 +228,14 @@ def invoke(request: InvokeRequest) -> InvokeResponse: if pending: # 进度日志:打印已识别帧数、总数、平均处理速度(帧/s)、最近窗口平均 - # 单帧耗时与当前线程数——便于判断多线程是否因单帧处理过慢而未启用 - # (窗口平均响应 ≥ fast_threshold 时自适应池不会扩容)。 + # 单帧耗时、当前线程数与**预计剩余完成时间**(剩余帧/当前速度)—— + # 便于判断多线程是否因单帧处理过慢而未启用(窗口平均响应 ≥ + # fast_threshold 时自适应池不会扩容)以及整体还需要多久。 def log_progress(done: int, total: int, rate: float, avg_time: float, workers: int) -> None: logger.info( - "OCR 进度 %d/%d 帧 (%.1f 帧/s, 平均 %.2fs/帧, 线程 %d/%d)", + "OCR 进度 %d/%d 帧 (%.1f 帧/s, 平均 %.2fs/帧, 线程 %d/%d%s)", done, total, rate, avg_time, workers, pool.max_workers, + _eta_suffix(done, total, rate), ) # 自适应并发调用 vlm-ocr:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限 diff --git a/tests/test_ocr_flow.py b/tests/test_ocr_flow.py index bee450d..622acdb 100644 --- a/tests/test_ocr_flow.py +++ b/tests/test_ocr_flow.py @@ -539,3 +539,29 @@ def test_ocr_interrupts_on_pause_flag(monkeypatch, tmp_path) -> None: assert "暂停" in (response.error or "") assert calls == [] # 一帧都没有真正 OCR。 assert not (out_dir / "ocr_partial.jsonl").exists() # 未处理帧不入存档。 + + +def test_format_eta() -> None: + """预计剩余时间的格式化:秒/分/小时三种量级与边界值。""" + from nodes.subtitle_ocr import _format_eta + + assert _format_eta(0) == "0秒" + assert _format_eta(59.9) == "59秒" # 不足 1 分只显示秒。 + assert _format_eta(61) == "1分01秒" + assert _format_eta(2058.4) == "34分18秒" + assert _format_eta(3600) == "1小时00分" # 1 小时整。 + assert _format_eta(3725) == "1小时02分" # 超过 1 小时只显示到分钟。 + assert _format_eta(-5) == "0秒" # 负数钳制为 0。 + + +def test_eta_suffix() -> None: + """进度日志的 ETA 后缀:速度为 0 不显示,速度正常时按剩余帧估算。""" + from nodes.subtitle_ocr import _eta_suffix + + # 速率未知(0/负)→ 不显示 ETA。 + assert _eta_suffix(done=100, total=100, rate=0) == "" + assert _eta_suffix(done=100, total=100, rate=-1) == "" + # 2673/22222 帧、9.5 帧/s:剩余 (22222-2673)/9.5 ≈ 2057.8s ≈ 34分。 + assert _eta_suffix(done=2673, total=22222, rate=9.5) == ", 预计剩余 34分17秒" + # 全部完成时剩余 0 秒。 + assert _eta_suffix(done=22222, total=22222, rate=9.5) == ", 预计剩余 0秒"