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:
Executable
+213
@@ -0,0 +1,213 @@
|
||||
"""用户端应用路由。
|
||||
|
||||
面向普通用户暴露“应用中心”能力:列出已发布工作流、上传输入创建任务、
|
||||
查询进度、重试失败任务以及下载产物。用户只看到输入 -> 进度 -> 结果。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from wov_app.db import Database
|
||||
|
||||
router = APIRouter(tags=["apps"])
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 格式字符串。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _get_db() -> Database:
|
||||
"""从 FastAPI 应用状态中延迟获取数据库实例。"""
|
||||
from wov_app.main import app
|
||||
|
||||
return app.state.db
|
||||
|
||||
|
||||
@router.get("/api/apps")
|
||||
def list_apps(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回全部已发布工作流及其最新版本定义。"""
|
||||
apps = []
|
||||
for workflow in db.list_workflows():
|
||||
# 草稿工作流不对用户端可见。
|
||||
if not workflow["published"]:
|
||||
continue
|
||||
latest = db.get_latest_workflow_version(workflow["id"])
|
||||
apps.append(
|
||||
{
|
||||
"id": workflow["id"],
|
||||
"name": workflow["name"],
|
||||
"description": workflow["description"],
|
||||
"version": workflow["latest_version"],
|
||||
"definition": latest["definition"] if latest else None,
|
||||
}
|
||||
)
|
||||
return apps
|
||||
|
||||
|
||||
@router.post("/api/apps/{workflow_id}/runs")
|
||||
async def create_run(
|
||||
workflow_id: str,
|
||||
file: UploadFile = File(...),
|
||||
params: str = Form(default=""),
|
||||
db: Database = Depends(_get_db),
|
||||
) -> dict:
|
||||
"""接收用户上传文件,创建排队中的工作流任务。"""
|
||||
workflow = db.get_workflow(workflow_id)
|
||||
# 只允许对已发布且存在版本的工作流发起任务。
|
||||
if workflow is None or not workflow["published"]:
|
||||
raise HTTPException(status_code=404, detail="published workflow not found")
|
||||
|
||||
latest = db.get_latest_workflow_version(workflow_id)
|
||||
if latest is None:
|
||||
raise HTTPException(status_code=422, detail="workflow has no version")
|
||||
|
||||
run_id = f"run_{uuid.uuid4().hex[:12]}"
|
||||
# 使用安全文件名,避免路径穿越。
|
||||
filename = Path(file.filename or "upload.bin").name
|
||||
from wov_app.config import STORAGE_DIR
|
||||
|
||||
# 上传文件按 run 隔离存放,调度器通过 input_uri 引用。
|
||||
input_dir = STORAGE_DIR / "uploads" / run_id
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
input_uri = input_dir / filename
|
||||
content = await file.read()
|
||||
input_uri.write_bytes(content)
|
||||
|
||||
# 可选参数覆盖(如前端框选的 crop):{节点ID: {参数: 值}},随任务持久化。
|
||||
param_overrides = None
|
||||
if params.strip():
|
||||
try:
|
||||
parsed = json.loads(params)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=422, detail="params must be valid JSON") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=422, detail="params must be a JSON object")
|
||||
param_overrides = parsed
|
||||
|
||||
now = _now_iso()
|
||||
db.create_run(
|
||||
{
|
||||
"id": run_id,
|
||||
"workflow_id": workflow_id,
|
||||
"workflow_version": latest["version"],
|
||||
"status": "QUEUED",
|
||||
"progress": 0,
|
||||
"input_uri": str(input_uri),
|
||||
"param_overrides": param_overrides,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": run_id,
|
||||
"status": "QUEUED",
|
||||
"progress": 0,
|
||||
"artifacts": [],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/runs")
|
||||
def list_runs(db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回最近的运行记录,供任务管理页展示。"""
|
||||
return db.list_runs()
|
||||
|
||||
|
||||
@router.get("/api/runs/{run_id}")
|
||||
def get_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""返回任务详情,并附带当前产物列表。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
run["artifacts"] = db.list_artifacts(run_id)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/api/runs/{run_id}/retry")
|
||||
def retry_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""重置失败任务为排队状态,清空旧产物后重新执行。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
if run["status"] != "FAILED":
|
||||
raise HTTPException(status_code=422, detail="only failed runs can be retried")
|
||||
# reset_run 会清空进度、错误和旧产物,确保从头开始。
|
||||
db.reset_run(run_id, _now_iso())
|
||||
return {"id": run_id, "status": "QUEUED"}
|
||||
|
||||
|
||||
@router.post("/api/runs/{run_id}/pause")
|
||||
def pause_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""暂停任务:排队中或运行中的任务可暂停,运行中的任务在节点边界停下。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
if run["status"] not in ("QUEUED", "RUNNING"):
|
||||
raise HTTPException(status_code=422, detail="only queued or running runs can be paused")
|
||||
db.pause_run(run_id, _now_iso())
|
||||
return {"id": run_id, "status": "PAUSED"}
|
||||
|
||||
|
||||
@router.post("/api/runs/{run_id}/resume")
|
||||
def resume_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""继续任务:暂停的任务恢复排队,由调度器从断点继续执行。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
if run["status"] != "PAUSED":
|
||||
raise HTTPException(status_code=422, detail="only paused runs can be resumed")
|
||||
db.resume_run(run_id, _now_iso())
|
||||
return {"id": run_id, "status": "QUEUED"}
|
||||
|
||||
@router.delete("/api/runs/{run_id}")
|
||||
def delete_run(run_id: str, db: Database = Depends(_get_db)) -> dict:
|
||||
"""删除任务:清理产物记录、上传文件与步骤产物目录。"""
|
||||
run = db.get_run(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
from wov_app.config import STORAGE_DIR
|
||||
|
||||
# 先删数据库记录(含产物表),再清理磁盘上的上传与中间产物。
|
||||
db.delete_run(run_id)
|
||||
input_uri = run.get("input_uri")
|
||||
if input_uri:
|
||||
# 上传文件位于 <storage>/uploads/<run_id>/,整目录一并删除。
|
||||
shutil.rmtree(Path(input_uri).parent, ignore_errors=True)
|
||||
# 步骤产物位于 <storage>/runs/<run_id>/,整目录一并删除。
|
||||
shutil.rmtree(STORAGE_DIR / "runs" / run_id, ignore_errors=True)
|
||||
return {"deleted": run_id}
|
||||
|
||||
@router.get("/api/runs/{run_id}/artifacts")
|
||||
def list_run_artifacts(run_id: str, db: Database = Depends(_get_db)) -> list[dict]:
|
||||
"""返回任务全部产物记录。"""
|
||||
if db.get_run(run_id) is None:
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
return db.list_artifacts(run_id)
|
||||
|
||||
|
||||
@router.get("/api/runs/{run_id}/artifacts/{artifact_name}")
|
||||
def download_artifact(
|
||||
run_id: str,
|
||||
artifact_name: str,
|
||||
db: Database = Depends(_get_db),
|
||||
) -> FileResponse:
|
||||
"""按任务与产物名下载文件,文件缺失时返回 404。"""
|
||||
artifact = db.get_artifact(run_id, artifact_name)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=404, detail="artifact not found")
|
||||
path = Path(artifact["uri"])
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="artifact file missing")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=artifact["mime_type"],
|
||||
filename=path.name,
|
||||
)
|
||||
Reference in New Issue
Block a user