批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组, 组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS) 再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载; 产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点): 该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续, 用于实现阶段边界。 - nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after > LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警), 新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model(); 新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。 - src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败 节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持 QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死: 引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录; 每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。 - src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。 - 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生 N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run 语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列 (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置 推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。 - 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING 外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py 改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。 测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、 阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时 置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、 前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
239 lines
9.1 KiB
Python
Executable File
239 lines
9.1 KiB
Python
Executable File
"""用户端应用路由。
|
||
|
||
面向普通用户暴露“应用中心”能力:列出已发布工作流、上传输入创建任务、
|
||
查询进度、重试失败任务以及下载产物。用户只看到输入 -> 进度 -> 结果。
|
||
"""
|
||
|
||
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, Query, 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(
|
||
include_batch: bool = Query(False),
|
||
db: Database = Depends(_get_db),
|
||
) -> list[dict]:
|
||
"""返回最近的运行记录,供任务管理页展示。
|
||
|
||
默认排除批量 run(每个批量任务会产生 N 条单视频 run,属于批量页的明细,
|
||
混进来会把列表占满);`?include_batch=1` 可包含它们供排查。
|
||
"""
|
||
return db.list_runs(include_batch=include_batch)
|
||
|
||
|
||
@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())
|
||
# 重试前清除可能残留的暂停信号(任务失败时信号文件可能仍在)。
|
||
from wov_app.config import STORAGE_DIR
|
||
|
||
(STORAGE_DIR / "runs" / run_id / "paused.flag").unlink(missing_ok=True)
|
||
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())
|
||
# 写入暂停信号文件:运行中的节点(如 OCR)逐帧检查到后立即中止,
|
||
# 由调度器保持 PAUSED;resume 时清除。
|
||
from wov_app.config import STORAGE_DIR
|
||
|
||
run_dir = STORAGE_DIR / "runs" / run_id
|
||
run_dir.mkdir(parents=True, exist_ok=True)
|
||
(run_dir / "paused.flag").write_text("", encoding="utf-8")
|
||
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())
|
||
# 清除暂停信号文件,避免节点误判仍处于暂停状态。
|
||
from wov_app.config import STORAGE_DIR
|
||
|
||
(STORAGE_DIR / "runs" / run_id / "paused.flag").unlink(missing_ok=True)
|
||
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 必须通过批量任务入口删除。"""
|
||
run = db.get_run(run_id)
|
||
if run is None:
|
||
raise HTTPException(status_code=404, detail="run not found")
|
||
# 批量 run 的输入是用户原始视频,且由 batch_videos 关联管理。
|
||
# 在任何数据库/文件删除之前拒绝,避免误删媒体库或留下悬空的批量明细。
|
||
if run.get("source") == "batch":
|
||
raise HTTPException(status_code=422, detail="请通过批量任务入口删除该任务")
|
||
from wov_app.config import STORAGE_DIR
|
||
|
||
# 先删数据库记录(含产物表),再清理磁盘上的上传与中间产物。
|
||
db.delete_run(run_id)
|
||
# 删除范围仅来自该任务的私有存储布局,绝不由 input_uri 推导:
|
||
# 即使历史上传记录引用外部路径,也必须保留源视频和同目录的用户文件。
|
||
shutil.rmtree(STORAGE_DIR / "uploads" / run_id, 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,
|
||
)
|