feat: VRSub 单体应用(WOV 单机版)初始提交

为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点
(提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。

- 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁
- 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据
- 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续
- 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用
  自适应线程池弹性并发,并打印数据处理速度进度日志
- 100% 行覆盖率(pytest --cov-fail-under=100)
This commit is contained in:
2026-08-16 23:58:25 +08:00
commit 4746e0363f
75 changed files with 10969 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
"""孤儿数据清理器。
定时扫描存储目录与数据库,清理不再有意义的死数据:
1. 磁盘上存在但没有对应任务记录的上传/步骤目录(删除任务中断等残留)。
2. 状态为 COMPLETED 但产物文件已全部丢失、且超过宽限期的任务记录
(这类任务在任务页会显示"完成"但下载全部 404,属于孤儿数据)。
出于安全考虑,以下数据**不会**被自动清理:
- FAILED 任务(用户可能重试,且失败任务本就可能没有文件)。
- 状态非终态(QUEUED/RUNNING)的任务。
- 最近宽限期内的任务,避免误删刚完成的运行。
手动删除任务仍走删除接口,本模块只做保守的孤儿兜底。
"""
from __future__ import annotations
import shutil
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from wov_app.config import CLEANUP_GRACE_SECONDS, CLEANUP_INTERVAL_SECONDS
from wov_app.db import Database
class OrphanCleaner:
"""后台孤儿清理器:周期扫描并清理孤儿数据,只保留明确的死数据。"""
def __init__(
self,
db: Database,
storage_dir: Path,
interval_seconds: float | None = None,
grace_seconds: float | None = None,
) -> None:
"""保存依赖并初始化轮询线程控制字段。"""
self.db = db
self.storage_dir = storage_dir
self.interval_seconds = interval_seconds or CLEANUP_INTERVAL_SECONDS
self.grace_seconds = grace_seconds or CLEANUP_GRACE_SECONDS
self._thread: threading.Thread | None = None
self._stopping = False
def start(self) -> None:
"""启动清理线程;重复调用无副作用。"""
if self._thread is not None:
return
self._stopping = False
self._thread = threading.Thread(
target=self._loop,
name="wov-orphan-cleaner",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
"""请求停止并等待清理线程退出。"""
self._stopping = True
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
def _loop(self) -> None:
"""周期循环:每隔一个间隔执行一次清理。"""
while not self._stopping:
time.sleep(self.interval_seconds)
self.clean_once()
def clean_once(self) -> int:
"""执行一次孤儿清理,返回清理的数据条目数。"""
run_ids = set(self.db.list_run_ids())
removed = 0
# 1) 无对应任务记录的上传/步骤目录视为残留,直接删除。
removed += self._clean_dangling(self.storage_dir / "uploads", run_ids)
removed += self._clean_dangling(self.storage_dir / "runs", run_ids)
# 2) COMPLETED 且产物文件全失、超过宽限期的任务记录删除。
for run_id in run_ids:
run = self.db.get_run(run_id)
if run is None:
continue
if run["status"] != "COMPLETED":
continue
if not self._expired(run.get("updated_at")):
continue
if self._has_files(self.storage_dir / "runs" / run_id):
continue
removed += self._remove_run(run_id, run)
return removed
def _clean_dangling(self, root: Path, run_ids: set[str]) -> int:
"""删除 root 下没有对应任务记录的残留子目录,返回删除数。"""
if not root.is_dir():
return 0
removed = 0
for child in root.iterdir():
if child.is_dir() and child.name not in run_ids:
shutil.rmtree(child, ignore_errors=True)
removed += 1
return removed
def _expired(self, updated_at: str | None) -> bool:
"""判断任务最后更新时间是否已超过宽限期;无法解析时保守视为未过期。"""
if not updated_at:
return False
try:
updated = datetime.fromisoformat(updated_at)
return (datetime.now(timezone.utc) - updated).total_seconds() > self.grace_seconds
except ValueError:
# 时间格式损坏时保守保留,避免误删。
return False
def _has_files(self, run_dir: Path) -> bool:
"""判断任务目录下是否仍存在产物文件。"""
if not run_dir.is_dir():
return False
return any(path.is_file() for path in run_dir.rglob("*"))
def _remove_run(self, run_id: str, run: dict) -> int:
"""删除孤儿任务:数据库记录(含产物)、上传目录与步骤目录。"""
self.db.delete_run(run_id)
input_uri = run.get("input_uri")
if input_uri:
shutil.rmtree(Path(input_uri).parent, ignore_errors=True)
shutil.rmtree(self.storage_dir / "runs" / run_id, ignore_errors=True)
return 1