feat: 任务断点续跑/暂停中断/LLM 过滤优化与调度容错
调度与状态机: - 修复 PAUSED 任务被拾起后复活执行(点击暂停反而开始任务):next_queued_run 只取 QUEUED,execute_run 以 PAUSED 进入直接返回,暂停必须显式 resume - 重启恢复:启动时 recover_interrupted_runs 把遗留 RUNNING 置 QUEUED(保留产物) - 暂停信号 paused.flag:暂停接口写、继续/重试清除,OCR 逐帧检查秒级中断, 节点内被暂停保持 PAUSED 不误报 FAILED - 调度轮询容错:_loop 异常不杀死线程(曾致任务永久停留 QUEUED) subtitle-ocr 节点级断点: - ocr_partial.jsonl 逐帧存档,重启/暂停后只处理未处理帧,产物与一次跑完一致 - 进度日志携带窗口平均耗时与线程数;取消后抑制进度日志井喷 llm-filter 过滤质量与限流自适应: - 上下文净化:喂给 LLM 的是过滤后的字幕(规则层垃圾从上下文剔除) - 正则确定性过滤:裸网址域名、HTML/水印模式直接删除 - 429/5xx 指数退避重试;worker 限流错误 report_failure 内存临时降最大线程数 并缩容(无错误窗口回升),失败条目降并发后重试一轮 - 保留长文本保护(noise 不删 ≥min_keep_len 文本,LLM 判定不稳的必要兜底) 前端: - 工作流编排页支持选择工作流编辑(加载最新/历史版本)、版本历史面板、 新建/编辑双模式;管理后台编辑跳转 workflow.html?edit=<id> 工作流:ocr-subtitle v7(filter pool_max_workers=20、pool_fast_threshold=1)
This commit is contained in:
+110
-30
@@ -10,6 +10,7 @@ SRT 基准数据:每条字幕消失时间 = 最后可见帧时间 + 采样间
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from nodes.adaptive_pool import AdaptiveThreadPool
|
||||
@@ -20,6 +21,44 @@ from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||
|
||||
logger = get_logger("subtitle-ocr")
|
||||
|
||||
# 节点级断点存档的写锁:多线程 OCR 并发完成时串行化追加写,避免行交错。
|
||||
_partial_lock = threading.Lock()
|
||||
|
||||
# 断点存档文件名:每行 {"frame": 帧序号(0-based), "text": 该帧OCR文本}。
|
||||
# OCR 每帧完成后立即追加一行;进程重启后从存档恢复已处理帧,只对未处理
|
||||
# 帧重新调用 vlm-ocr——2 小时视频级任务中断后不浪费已完成的帧。
|
||||
_PARTIAL_NAME = "ocr_partial.jsonl"
|
||||
|
||||
# 暂停信号文件名:位于 run 根目录(<storage>/runs/<run_id>/paused.flag),
|
||||
# 由暂停接口写入、继续/重试/删除时清除;节点逐帧检查,存在即中止。
|
||||
_PAUSE_FLAG = "paused.flag"
|
||||
|
||||
|
||||
class PauseRequested(Exception):
|
||||
"""节点内暂停信号:OCR 检测到任务被暂停后抛出,由调度器保持 PAUSED。
|
||||
|
||||
不把暂停误报为 FAILED:调度器捕获异常时若任务状态已是 PAUSED,
|
||||
则保持暂停等待用户 resume,从断点存档继续未处理帧。
|
||||
"""
|
||||
|
||||
|
||||
def _load_partial(output_dir: Path) -> dict[int, str]:
|
||||
"""读取节点级断点存档,返回 {帧序号: OCR 文本};无存档时返回空字典。"""
|
||||
path = output_dir / _PARTIAL_NAME
|
||||
if not path.is_file():
|
||||
return {}
|
||||
result: dict[int, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# 进程被杀时可能残留半行写入:跳过该行,对应帧视为未处理。
|
||||
continue
|
||||
result[int(item["frame"])] = str(item["text"])
|
||||
return result
|
||||
|
||||
# 默认垃圾词:无文字帧的模型输出可能反复出现这些词。
|
||||
def _sampling_interval(manifest: list[dict], default: float) -> float:
|
||||
"""从帧清单时间轴推导采样间隔(相邻帧时间差的中位数)。
|
||||
@@ -112,9 +151,30 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
if request.params.get(key) is not None
|
||||
}
|
||||
|
||||
# 节点级断点:读取已处理帧存档,只对未处理帧调用 vlm-ocr。
|
||||
output_dir = Path(request.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
partial_path = output_dir / _PARTIAL_NAME
|
||||
partial_texts = _load_partial(output_dir)
|
||||
pending = [(i, item) for i, item in enumerate(manifest) if i not in partial_texts]
|
||||
if partial_texts:
|
||||
logger.info(
|
||||
"检测到节点级断点:%d/%d 帧已处理,本次只处理剩余 %d 帧",
|
||||
len(partial_texts), len(manifest), len(pending),
|
||||
)
|
||||
|
||||
# 单帧 OCR:并行池的工作函数,返回该帧识别文本(失败/空/超长均返回空串)。
|
||||
# 每帧无论结果如何都把 {frame, text} 追加到断点存档,重启后不再重跑该帧。
|
||||
def ocr_frame(payload) -> str:
|
||||
index, item = payload
|
||||
# 暂停检查:调度器置 PAUSED 并向 run 根写入 paused.flag 后,工作线程
|
||||
# 立即中止(不 OCR、不写存档,该帧恢复时重跑),让 map 快速结束。
|
||||
if (Path(request.output_dir).parent.parent / _PAUSE_FLAG).exists():
|
||||
# 抑制后续进度回调:队列中剩余大量帧会快速失败退出,避免逐项
|
||||
# 打印"OCR 进度"导致日志井喷。
|
||||
pool.cancel()
|
||||
raise PauseRequested(f"OCR 被暂停(run {request.run_id})")
|
||||
text = ""
|
||||
response = registry.invoke(
|
||||
"vlm-ocr",
|
||||
InvokeRequest(
|
||||
@@ -128,42 +188,62 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
if response.status != "completed":
|
||||
# 单帧失败不中断整体,跳过该帧继续汇总。
|
||||
logger.warning("帧 %d OCR 失败,跳过: %s", index, response.error)
|
||||
return ""
|
||||
logger.info("帧 %d/%d OCR 完成: %r", index + 1, len(manifest), response.outputs.get("text"))
|
||||
text = str(response.outputs.get("text", "")).strip()
|
||||
if not text:
|
||||
return ""
|
||||
# 超长输出视为模型异常(重复循环等),直接报错并跳过该帧。
|
||||
if len(text) > max_result_chars:
|
||||
logger.warning(
|
||||
"帧 %d OCR 输出超长(%d > %d),跳过: %r",
|
||||
index, len(text), max_result_chars, text[:60],
|
||||
)
|
||||
return ""
|
||||
else:
|
||||
logger.info("帧 %d/%d OCR 完成: %r", index + 1, len(manifest), response.outputs.get("text"))
|
||||
text = str(response.outputs.get("text", "")).strip()
|
||||
if len(text) > max_result_chars:
|
||||
# 超长输出视为模型异常(重复循环等),直接报错并跳过该帧。
|
||||
logger.warning(
|
||||
"帧 %d OCR 输出超长(%d > %d),跳过: %r",
|
||||
index, len(text), max_result_chars, text[:60],
|
||||
)
|
||||
text = ""
|
||||
# 断点存档:成功/失败/空串都记录"已处理",恢复时保持一致行为。
|
||||
with _partial_lock:
|
||||
with partial_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"frame": index, "text": text}, ensure_ascii=False) + "\n")
|
||||
return text
|
||||
|
||||
# 进度日志:打印已识别帧数、总数与平均处理速度(帧/s)。
|
||||
def log_progress(done: int, total: int, rate: float) -> None:
|
||||
logger.info("OCR 进度 %d/%d 帧 (%.1f 帧/s)", done, total, rate)
|
||||
if pending:
|
||||
# 进度日志:打印已识别帧数、总数、平均处理速度(帧/s)、最近窗口平均
|
||||
# 单帧耗时与当前线程数——便于判断多线程是否因单帧处理过慢而未启用
|
||||
# (窗口平均响应 ≥ 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)",
|
||||
done, total, rate, avg_time, workers, pool.max_workers,
|
||||
)
|
||||
|
||||
# 自适应并发调用 vlm-ocr:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
|
||||
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
|
||||
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
|
||||
pool = AdaptiveThreadPool(
|
||||
worker=ocr_frame,
|
||||
on_progress=log_progress,
|
||||
min_workers=int(request.params.get("pool_min_workers", 1)),
|
||||
max_workers=int(request.params.get("pool_max_workers", 16)),
|
||||
window_seconds=float(request.params.get("pool_window_seconds", 10.0)),
|
||||
fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)),
|
||||
slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)),
|
||||
)
|
||||
texts = pool.map(list(enumerate(manifest)))
|
||||
# 自适应并发调用 vlm-ocr:10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
|
||||
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
|
||||
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
|
||||
pool = AdaptiveThreadPool(
|
||||
worker=ocr_frame,
|
||||
on_progress=log_progress,
|
||||
min_workers=int(request.params.get("pool_min_workers", 1)),
|
||||
max_workers=int(request.params.get("pool_max_workers", 16)),
|
||||
window_seconds=float(request.params.get("pool_window_seconds", 10.0)),
|
||||
fast_threshold=float(request.params.get("pool_fast_threshold", 0.3)),
|
||||
slow_threshold=float(request.params.get("pool_slow_threshold", 1.0)),
|
||||
)
|
||||
pending_texts = pool.map(pending)
|
||||
# 任一工作线程检测到暂停信号即整体中止:已写盘的断点存档保留,
|
||||
# resume 后从剩余帧续跑;以 failed 返回让调度器保持 PAUSED(不误报失败)。
|
||||
if any(isinstance(text, PauseRequested) for text in pending_texts):
|
||||
return InvokeResponse(status="failed", error=f"OCR 被暂停(run {request.run_id})")
|
||||
# 新增结果按帧号归位,与断点存档合并成完整帧序文本列表。
|
||||
new_by_index = {i: t for (i, _item), t in zip(pending, pending_texts)}
|
||||
else:
|
||||
new_by_index = {}
|
||||
|
||||
# 完整帧序文本:优先取存档,其次取本次新增(未处理帧必有其一条目)。
|
||||
texts = [
|
||||
partial_texts.get(i, new_by_index.get(i, ""))
|
||||
for i in range(len(manifest))
|
||||
]
|
||||
# 按帧顺序合并连续相同字幕(与重组装旧数据共用 _merge_kept)。
|
||||
kept = _merge_kept(manifest, texts)
|
||||
output_dir = Path(request.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
# output_dir 已在断点初始化时创建(mkdir 幂等),此处直接使用。
|
||||
output_path = output_dir / "subtitle.srt"
|
||||
output_path.write_text("\n".join(_assemble_srt(kept, interval)), encoding="utf-8")
|
||||
logger.info("字幕汇总完成: %d 条", len(kept))
|
||||
|
||||
Reference in New Issue
Block a user