Commit c6815d5ba64b58426171a207b3b9546bb4076dbf
1 parent
e1a52b7e
raylight server.py: /health 加 ComfyUI 存活探测,修复"骗人报健康"的监控盲区
/health 之前只读 STATE["model"](worker()线程启动时设一次"ready",之后再不更新), 一次OOM把ComfyUI(8188)进程本身搞崩溃退出后,server.py这层壳没死,/health继续骗人报 ready近20分钟,期间提交的任务全部秒失败(ConnectError)才被人工发现。改成每次/health都 真的探测一次8188(GET /system_stats,2秒超时,跟start.sh自己的就绪检测用同一个端点)。 不解决actor崩溃不自愈本身(上游代码问题),只是让崩溃能被更快发现。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
698 additions
and
0 deletions
CHANGELOG.md
| 1 | 1 | # 变更记录 |
| 2 | 2 | |
| 3 | +## 2026-08-24(晚,续3)— 修复 raylight `/health` 骗人报健康的盲区,ComfyUI 崩了近20分钟没人发现 | |
| 4 | + | |
| 5 | +**背景**:续2记录的那次 ref2va OOM(`b659b60c6cbc`,FSDP初始化阶段GPU2/GPU3 OOM)实际上 | |
| 6 | +把 ComfyUI(8188) 进程直接搞崩溃退出了,但 `server.py`(8100) 这层壳没死,之后用户重跑的 | |
| 7 | +好几个任务(`c136221759cb`/`02ee11443847`等)全部秒失败报 `ConnectError: All connection | |
| 8 | +attempts failed`,而 console 界面和 `/health` 一直显示"ready",误导了排查方向(一开始以为 | |
| 9 | +是网络抖动/console重启的副作用)。 | |
| 10 | + | |
| 11 | +**根因**:`server.py`的`/health`只读内存里的`STATE["model"]`,这个值在后台`worker()`线程 | |
| 12 | +**启动时设一次`"ready"`,之后再没更新过**——不管ComfyUI 8188是死是活,`/health`永远报同一个 | |
| 13 | +值,这个盲区持续了近20分钟才被人工发现任务反常揪出来。 | |
| 14 | + | |
| 15 | +**修复**:`/health`现在每次都用`start.sh`自己就绪检测同款端点(`GET {COMFY_BASE}/system_stats`, | |
| 16 | +2秒超时)真的探测一次8188,探测失败就把`ready`降级、`model`字段报`comfyui_unreachable`。 | |
| 17 | +已同步部署到服务器并用`bash start.sh`完整重启验证过。详见`raylight/README.md`已知限制第9条。 | |
| 18 | +**这条不解决"actor崩溃不自愈"本身**(第8条那个bug依然在,上游代码问题),只是让崩溃能被 | |
| 19 | +更快发现,不用再靠人工盯任务反常才能发现。 | |
| 20 | + | |
| 3 | 21 | ## 2026-08-24(晚,续2)— 【发现】"1344×768撑412帧不OOM"结论只对0参考图成立,带参考图会OOM |
| 4 | 22 | |
| 5 | 23 | **背景**:用户重启了一个卡住/报错的任务(`task.html/b659b60c6cbc`,1344×768×362帧、ref2va | ... | ... |
raylight/README.md
| ... | ... | @@ -127,6 +127,18 @@ ssh admin@192.168.110.67 'cd /data/models/minimax-h3 && export H3_OFFLOAD=disk & |
| 127 | 127 | 崩溃时重建)里有个确认过的 bug:`needs_restart = False` 无条件覆盖了前面刚算出来的检测 |
| 128 | 128 | 结果,这段自愈逻辑现在是死代码。真机测试中一次 OOM 后 GPU1/2/3 卡在接近满载没释放, |
| 129 | 129 | 必须手动 `bash start.sh` 整个重启才能回收——这不是我们这边能修的,是上游代码的问题。 |
| 130 | +9. **【2026-08-24修复】`/health` 之前不会真的探测 ComfyUI(8188) 存不存活,会骗人报健康**—— | |
| 131 | + 跟第8条是同一类事故的另一半:一次 actor 崩溃(`ray.exceptions.ActorDiedError`)之后, | |
| 132 | + ComfyUI(8188) 进程本身直接退出了,但 `server.py`(8100) 这层壳没死。根因:`/health` 的 | |
| 133 | + `ready` 字段只读 `STATE["model"]`,这个值在 `worker()` 后台线程**启动时设一次`"ready"`, | |
| 134 | + 之后再也不更新**——不管 8188 是死是活,`/health`/`/v1/backends` 永远报同一个值。真实 | |
| 135 | + 故障中这个盲区持续了近20分钟,期间 console 界面一直显示"ready",但提交的每个任务都在 | |
| 136 | + 1秒内秒失败(`ConnectError: All connection attempts failed`,请求根本连不上已经不存在 | |
| 137 | + 的8188),是人工发现任务反常才追查到8188已经不在监听。**已修**:`/health` 现在每次都 | |
| 138 | + 用跟`start.sh`自己就绪检测同一个端点(`GET {COMFY_BASE}/system_stats`,2秒超时)真的 | |
| 139 | + 探测一次8188,探测失败就把`ready`降级成0、`model`字段报`"comfyui_unreachable"`,不再 | |
| 140 | + 单纯相信内存里那个只写一次的标志位。**这条不能顶替第8条**——探测准了只是能更快发现 | |
| 141 | + 崩溃,8188死了以后还是要手动`bash start.sh`重启,自愈本身仍然没有。 | |
| 130 | 142 | |
| 131 | 143 | ## 多参考图生视频支持参考视频/独立音频参考(2026-08-24 新增) |
| 132 | 144 | ... | ... |
raylight/server.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""生产网关:把 h3-console 现有的 gateway.py/api.py 契约(/health, /v1/videos...)搬到 | |
| 3 | +ComfyUI + Raylight 之上。跟旧架构最大的不同:旧架构是4卡4个独立worker、gateway负责按partition | |
| 4 | +选卡;新架构是4卡一个 FSDP 集群,任何时刻只能跑一个请求,所以这里把 gateway 的"选后端"和 api.py | |
| 5 | +的"单卡worker"合并成一个服务:一条队列,一个后台 worker,串行往 ComfyUI 提交/等待/取结果。 | |
| 6 | + | |
| 7 | +RayInitializer/RayUNETLoader/CLIPLoader/VAELoader 这几个"重"节点的输入值每次提交必须逐字节 | |
| 8 | +相同——这是 ComfyUI 节点级缓存跳过重新建集群/重新加载模型的唯一机制(Raylight 自己在这条量化+ | |
| 9 | +FSDP 的配置下没有安全网,见 docs/todo/raylight-comfyui-multi-gpu-eval.md)。所以下面的 | |
| 10 | +RAY_INIT_CFG 是全局常量,任何请求都不应该改它。 | |
| 11 | +""" | |
| 12 | +import asyncio | |
| 13 | +import contextlib | |
| 14 | +import json | |
| 15 | +import os | |
| 16 | +import queue | |
| 17 | +import re | |
| 18 | +import sqlite3 | |
| 19 | +import subprocess | |
| 20 | +import threading | |
| 21 | +import time | |
| 22 | +import urllib.parse | |
| 23 | +import urllib.request | |
| 24 | +import uuid | |
| 25 | +from pathlib import Path | |
| 26 | +from typing import Literal, Optional | |
| 27 | + | |
| 28 | +import httpx | |
| 29 | +import websockets | |
| 30 | +from fastapi import FastAPI, HTTPException | |
| 31 | +from fastapi.responses import FileResponse | |
| 32 | +from pydantic import BaseModel, Field, model_validator | |
| 33 | + | |
| 34 | +COMFY_HOST = os.environ.get("COMFY_HOST", "127.0.0.1") | |
| 35 | +COMFY_PORT = os.environ.get("COMFY_PORT", "8188") | |
| 36 | +COMFY_BASE = f"http://{COMFY_HOST}:{COMFY_PORT}" | |
| 37 | +COMFY_WS = f"ws://{COMFY_HOST}:{COMFY_PORT}/ws" | |
| 38 | +# console 的 _try_submit/_row_view 依赖响应里的 backend 字段来判断"任务分配到哪个后端了", | |
| 39 | +# 没有这个字段就会一直认为任务"还没分配",播放/下载会被 409 拒绝——踩过这个坑,2026-08-22。 | |
| 40 | +SERVER_PORT = os.environ.get("PORT", "8100") | |
| 41 | +BACKEND_URL = f"http://127.0.0.1:{SERVER_PORT}" | |
| 42 | + | |
| 43 | +ROOT = Path("/data/models/raylight/ComfyUI") | |
| 44 | +INPUT_DIR = ROOT / "input" | |
| 45 | +OUTPUT_DIR = ROOT / "output" / "video" | |
| 46 | +DB_PATH = Path("/data/models/raylight/jobs.sqlite3") | |
| 47 | +COMFYUI_LOG_PATH = Path("/data/models/raylight/comfyui-server.log") | |
| 48 | +# ponytail: Raylight 的 FSDP 采样在 Ray 子进程里跑,从不调 comfy.utils.ProgressBar 上报进度, | |
| 49 | +# 所以下面 _run_job_async 里监听的 progress_state websocket 永远拿不到真实 step——只能退而 | |
| 50 | +# 求其次,轮询子进程 tqdm 直接打到 stdout、被日志捕获的这行文字。上游改了这行的措辞/格式, | |
| 51 | +# 这个正则就会失效(退化为进度条不动,不影响任务本身跑成功)。只信任 total 等于本次请求 | |
| 52 | +# steps 的那次匹配,因为同一句"Model Initializing"文案在 FSDP 初始化阶段也会打印、但那里的 | |
| 53 | +# 总数是 GPU 数(4),跟真正的采样步数(这里的 steps 字段)容易混淆。 | |
| 54 | +_PROGRESS_RE = re.compile(r"Model Initializing.*?(\d+)\.\d+/(\d+)\.\d+ \[") | |
| 55 | +# 2026-08-24: 单纯截尾([-2000:])在参考图/参考视频的报错里会失效——Ray traceback里经常在真正 | |
| 56 | +# 报错行后面接着打印一大坨tensor内容(比如OOM报错后把出错的输入张量也dump出来),尾部2000字符 | |
| 57 | +# 全是"0., 0., 0., ..."这种数字,看不到真正的报错类型/原因。改成直接从整段文本里找 | |
| 58 | +# "XxxError: ..."这种模式,取最后一个匹配(Python异常链里最后抛出的通常是最具体的结论), | |
| 59 | +# 找不到才退回截尾兜底。 | |
| 60 | +_ERROR_LINE_RE = re.compile(r'\b([A-Za-z_][\w.]*(?:Error|Exception)):\s*([^\n"\\]*)') | |
| 61 | + | |
| 62 | + | |
| 63 | +def _extract_error_summary(messages: list) -> str: | |
| 64 | + raw = json.dumps(messages, ensure_ascii=False) | |
| 65 | + text = raw.replace("\\n", "\n") # 还原JSON转义的换行,不然"n"会跟前一个词粘连(比如"ntorch...") | |
| 66 | + matches = _ERROR_LINE_RE.findall(text) | |
| 67 | + if matches: | |
| 68 | + return f"{matches[-1][0]}: {matches[-1][1].strip()}"[:2000] | |
| 69 | + return raw[-2000:] | |
| 70 | + | |
| 71 | +# 每次提交必须逐字节相同才能让 ComfyUI 跳过重建集群——不要给任何一个请求字段改这几个值。 | |
| 72 | +RAY_INIT_CFG = { | |
| 73 | + "ray_cluster_address": "local", | |
| 74 | + "ray_cluster_namespace": "default", | |
| 75 | + "GPU": 4, | |
| 76 | + "ulysses_degree": 4, | |
| 77 | + "ring_degree": 1, | |
| 78 | + "cfg_degree": 1, | |
| 79 | + "dp_degree": 1, | |
| 80 | + "sync_ulysses": False, | |
| 81 | + "clear_vram_after_sampling": False, | |
| 82 | + "FSDP": True, | |
| 83 | + "FSDP_CPU_OFFLOAD": False, | |
| 84 | + # 2026-08-22:TORCH_MATH 物化完整attention矩阵,是之前 832x480x124帧/448x256x362帧/ | |
| 85 | + # 1024x576x60帧 反复 OOM 的根因,不是权重装不下。yunchang 其实原生支持 SAGE_FP16,只是环境里 | |
| 86 | + # 从没装过给Turing(sm_75)编译的sageattention——装上以后三个档位全部通过,GPU0峰值~13.3GB, | |
| 87 | + # 还快很多(832x480x124帧从OOM变成100秒)。sageattention来自社区分支 | |
| 88 | + # github.com/1506086927/SageAttention2_Optimized_Test(经 github.com/Eanya-Tonic/2080ti-minimax-h3 | |
| 89 | + # 的补丁接入),详见 notes.md。 | |
| 90 | + "XFuser_attention": "SAGE_FP16", | |
| 91 | + "RAYLIGHT_ULYSSES_KV_INT8": "off", # 通信层INT8量化,跟这次的显存修复无关,保持关闭 | |
| 92 | + "skip_comm_test": False, | |
| 93 | + "use_mmap": True, | |
| 94 | +} | |
| 95 | +# 2026-08-23:4个分辨率×帧数排列组合实测出的真实CUDA OOM边界(832×480/1024×576/1344×768) | |
| 96 | +# 或画质边界(448×256,见notes.md——技术上测到3000帧都没OOM,是用户看过实际输出判定50秒往后 | |
| 97 | +# 画质不能看,不是显存拦的)。跟 h3-console 前端 RESOLUTIONS 里的 maxFrames 保持一致—— | |
| 98 | +# 两边算法对不上就会出现"前端放行、后端拒绝"或反过来,数字改动两边一起改。 | |
| 99 | +RESOLUTION_CEILINGS = { | |
| 100 | + (832, 480): 1125, | |
| 101 | + (448, 256): 1200, | |
| 102 | + (1024, 576): 743, | |
| 103 | + (1344, 768): 412, | |
| 104 | +} | |
| 105 | +CLIP_NAME = "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors" | |
| 106 | +VAE_VIDEO = "minimax_h3_video_vae_fp16.safetensors" | |
| 107 | +VAE_AUDIO = "minimax_h3_audio_vae_fp32.safetensors" | |
| 108 | +DIT_FL2VA = "minimax_h3_fl2va_pruned_int8_convrot.safetensors" | |
| 109 | +DIT_REF2VA = "minimax_h3_ref2va_pruned_int8_convrot.safetensors" | |
| 110 | + | |
| 111 | + | |
| 112 | +class GenReq(BaseModel): | |
| 113 | + prompt: str | |
| 114 | + width: int = Field(832, ge=64, le=2048) | |
| 115 | + height: int = Field(480, ge=64, le=2048) | |
| 116 | + frames: int = Field(124, ge=5, le=3000) # 2026-08-23: 448x256在1200帧(旧上限)时GPU远没跑满 | |
| 117 | + # (轻松成功,646秒),真实显存上限没探到——旧的1200是这个字段自己的硬编码,不是显存拦的。 | |
| 118 | + # 抬到3000留探测空间,其他分辨率的真实上限已经在1200帧以内测到了,不受这次调整影响。见 notes.md | |
| 119 | + steps: int = Field(20, ge=1, le=50) | |
| 120 | + turbo: bool = False # Raylight 图里还没接turbo LoRA,目前是no-op,见 notes.md | |
| 121 | + seed: int = Field(0, ge=0, le=2**31 - 1) | |
| 122 | + cfg_scale: Optional[float] = None # 目前是 no-op:MiniMax H3 在 Raylight 里不支持 CFG | |
| 123 | + negative_prompt: Optional[str] = None # 同上,暂不生效,见 notes.md | |
| 124 | + first_frame_url: Optional[str] = None | |
| 125 | + last_frame_url: Optional[str] = None | |
| 126 | + reference_image_urls: list[str] = [] | |
| 127 | + # 2026-08-23:MiniMaxH3ReferenceToVideo 节点本身支持参考视频/独立音频参考(ComfyUI | |
| 128 | + # /object_info 里的 ref_videos/ref_audios),之前只接了 ref_images 这一种,见 notes.md。 | |
| 129 | + reference_video_urls: list[str] = Field(default_factory=list, max_length=3) # 最多3段, | |
| 130 | + # 每段的音轨会跟着自动带上(对应 ref_video_audios,不用单独传) | |
| 131 | + reference_audio_urls: list[str] = Field(default_factory=list, max_length=3) # 独立音频参考, | |
| 132 | + # 不跟任何参考视频绑定,最多3个(对应节点的 ref_audios) | |
| 133 | + | |
| 134 | + @model_validator(mode="after") | |
| 135 | + def _check_known_resolution_ceiling(self): | |
| 136 | + # 只挡已经实测过的4个分辨率——没测过的组合不拦,跟以前一样"能提交但结果未验证", | |
| 137 | + # 不假装知道我们没测过的显存边界。 | |
| 138 | + ceiling = RESOLUTION_CEILINGS.get((self.width, self.height)) | |
| 139 | + if ceiling is not None and self.frames > ceiling: | |
| 140 | + raise ValueError( | |
| 141 | + f"{self.width}x{self.height} 实测上限是 {ceiling} 帧,{self.frames} 帧会真实CUDA OOM" | |
| 142 | + f"(或448x256画质会退化,见notes.md),不接受这个请求" | |
| 143 | + ) | |
| 144 | + return self | |
| 145 | + | |
| 146 | + | |
| 147 | +class Job(BaseModel): | |
| 148 | + id: str | |
| 149 | + status: Literal["queued", "running", "done", "failed", "cancelled"] | |
| 150 | + req: GenReq | |
| 151 | + created_at: float | |
| 152 | + started_at: Optional[float] = None | |
| 153 | + finished_at: Optional[float] = None | |
| 154 | + step: int = 0 | |
| 155 | + total_steps: int = 0 | |
| 156 | + file: Optional[str] = None | |
| 157 | + size_bytes: Optional[int] = None | |
| 158 | + error: Optional[str] = None | |
| 159 | + partition: str = "ref2va" | |
| 160 | + comfy_prompt_id: Optional[str] = None | |
| 161 | + backend: str = BACKEND_URL | |
| 162 | + | |
| 163 | + | |
| 164 | +JOBS: dict[str, Job] = {} | |
| 165 | +Q: "queue.Queue[str]" = queue.Queue() | |
| 166 | +LOCK = threading.Lock() | |
| 167 | +STATE = {"model": "loading", "loaded_at": None} | |
| 168 | + | |
| 169 | + | |
| 170 | +def db(): | |
| 171 | + conn = sqlite3.connect(DB_PATH) | |
| 172 | + conn.row_factory = sqlite3.Row | |
| 173 | + return conn | |
| 174 | + | |
| 175 | + | |
| 176 | +def init_db(): | |
| 177 | + conn = db() | |
| 178 | + conn.execute( | |
| 179 | + "CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, updated_at REAL NOT NULL, data TEXT NOT NULL)" | |
| 180 | + ) | |
| 181 | + conn.commit() | |
| 182 | + conn.close() | |
| 183 | + | |
| 184 | + | |
| 185 | +def _persist(job: Job): | |
| 186 | + with LOCK: | |
| 187 | + conn = db() | |
| 188 | + conn.execute( | |
| 189 | + "INSERT INTO jobs(id, updated_at, data) VALUES (?,?,?) " | |
| 190 | + "ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, data=excluded.data", | |
| 191 | + (job.id, time.time(), job.model_dump_json()), | |
| 192 | + ) | |
| 193 | + conn.commit() | |
| 194 | + conn.close() | |
| 195 | + | |
| 196 | + | |
| 197 | +def _load_jobs(): | |
| 198 | + conn = db() | |
| 199 | + rows = list(conn.execute("SELECT data FROM jobs")) | |
| 200 | + conn.close() # 2026-08-23: 之前在这个连接的SELECT游标还开着的时候就调 _persist() 开新连接写, | |
| 201 | + # SQLite 默认rollback-journal模式下第二个连接的写commit会被第一个连接的读事务锁住,报 | |
| 202 | + # 'database is locked',导致服务启动直接崩溃——先把结果取完关掉连接,再在下面循环里写。 | |
| 203 | + for row in rows: | |
| 204 | + try: | |
| 205 | + j = Job.model_validate_json(row["data"]) | |
| 206 | + except Exception: | |
| 207 | + continue | |
| 208 | + # 进程重启=ComfyUI侧的执行状态和排队状态都丢了,跟老系统的策略一致:不可恢复的直接判定 | |
| 209 | + # 终态。2026-08-23区分开:只是排队中、根本还没开始跑的,判"cancelled"更准确——没有任何 | |
| 210 | + # 执行过程可言,"failed"意味着跑起来又出错了,这两者不是一回事。真的在跑到一半被打断的 | |
| 211 | + # 才算failed。 | |
| 212 | + if j.status == "queued": | |
| 213 | + j.status = "cancelled" | |
| 214 | + j.error = "进程重启导致这条排队中的任务被取消,需要重新提交" | |
| 215 | + j.finished_at = time.time() | |
| 216 | + _persist(j) | |
| 217 | + elif j.status == "running": | |
| 218 | + j.status = "failed" | |
| 219 | + j.error = "进程重启导致这条任务的执行状态丢失,无法恢复" | |
| 220 | + j.finished_at = time.time() | |
| 221 | + _persist(j) # 2026-08-23: 之前只改内存不落盘,sqlite里的status永远停在running/queued, | |
| 222 | + # 下次重启会被这段逻辑重新"发现"一遍、把人工写的详细错因覆盖回这句通用文案——必须写回去 | |
| 223 | + JOBS[j.id] = j | |
| 224 | + conn.close() | |
| 225 | + | |
| 226 | + | |
| 227 | +def _partition_of(req: GenReq) -> str: | |
| 228 | + if req.reference_image_urls or req.reference_video_urls or req.reference_audio_urls: | |
| 229 | + return "ref2va" | |
| 230 | + return "fl2va" | |
| 231 | + | |
| 232 | + | |
| 233 | +_INPUT_EXTS = { | |
| 234 | + "image": (".png", ".jpg", ".jpeg", ".webp"), | |
| 235 | + "video": (".mp4", ".mov", ".webm", ".mkv"), | |
| 236 | + "audio": (".mp3", ".wav", ".m4a", ".ogg", ".flac"), | |
| 237 | +} | |
| 238 | + | |
| 239 | + | |
| 240 | +def _fetch_to_input(url: str, kind: str = "image") -> str: | |
| 241 | + """下载一个文件到 ComfyUI 的 input/ 目录,LoadImage/LoadVideo/LoadAudio 节点按文件名读取。""" | |
| 242 | + allowed = _INPUT_EXTS[kind] | |
| 243 | + ext = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower() | |
| 244 | + if ext not in allowed: | |
| 245 | + ext = allowed[0] | |
| 246 | + fname = f"{uuid.uuid4().hex}{ext}" | |
| 247 | + dest = INPUT_DIR / fname | |
| 248 | + with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as f: | |
| 249 | + f.write(resp.read()) | |
| 250 | + return fname | |
| 251 | + | |
| 252 | + | |
| 253 | +def _static_nodes() -> dict: | |
| 254 | + """三套 workflow 共用的静态节点:CLIP/VAE loader + RayInitializer。节点 id 用字符串前缀 | |
| 255 | + "s_" 避免跟每次请求动态生成的节点 id 冲突。 | |
| 256 | + | |
| 257 | + CLIP/VAE 不走 FSDP 分片,默认会全部堆在 GPU0(跟 Ray rank0 的 DiT 分片挤同一张卡,是 | |
| 258 | + GPU0 在所有 OOM 测试里最先报错的原因之一,见 notes.md)。这个 ComfyUI fork 自带 | |
| 259 | + SelectCLIPDevice/SelectVAEDevice 节点(comfy_extras/nodes_multigpu.py),用它们把这三样 | |
| 260 | + 分别钉到 GPU1/2/3,把 GPU0 净空出来只留 DiT 分片——2026-08-22 加,效果见 notes.md。""" | |
| 261 | + return { | |
| 262 | + "s_clip": {"class_type": "CLIPLoader", | |
| 263 | + "inputs": {"clip_name": CLIP_NAME, "type": "minimax", "device": "default"}}, | |
| 264 | + "s_clip_gpu": {"class_type": "SelectCLIPDevice", "inputs": {"clip": ["s_clip", 0], "device": "gpu:1"}}, | |
| 265 | + "s_vae_v": {"class_type": "VAELoader", "inputs": {"vae_name": VAE_VIDEO}}, | |
| 266 | + "s_vae_v_gpu": {"class_type": "SelectVAEDevice", "inputs": {"vae": ["s_vae_v", 0], "device": "gpu:2"}}, | |
| 267 | + "s_vae_a": {"class_type": "VAELoader", "inputs": {"vae_name": VAE_AUDIO}}, | |
| 268 | + "s_vae_a_gpu": {"class_type": "SelectVAEDevice", "inputs": {"vae": ["s_vae_a", 0], "device": "gpu:3"}}, | |
| 269 | + "s_rayinit": {"class_type": "RayInitializer", "inputs": dict(RAY_INIT_CFG)}, | |
| 270 | + } | |
| 271 | + | |
| 272 | + | |
| 273 | +def build_fl2va_graph(req: GenReq) -> dict: | |
| 274 | + """文生视频 / 首尾帧生视频 —— MiniMaxH3ImageToVideo,不给 first/last frame 就是纯 T2V。""" | |
| 275 | + g = _static_nodes() | |
| 276 | + g["s_unet"] = {"class_type": "RayUNETLoader", | |
| 277 | + "inputs": {"unet_name": DIT_FL2VA, "weight_dtype": "default", "lora": None, | |
| 278 | + "ray_actors_init": ["s_rayinit", 0]}} | |
| 279 | + | |
| 280 | + conditioning_inputs = { | |
| 281 | + "prompt": req.prompt, "clip": ["s_clip_gpu", 0], "vae": ["s_vae_v_gpu", 0], | |
| 282 | + "width": req.width, "height": req.height, "length": req.frames, | |
| 283 | + } | |
| 284 | + if req.first_frame_url: | |
| 285 | + fname = _fetch_to_input(req.first_frame_url) | |
| 286 | + g["n_first"] = {"class_type": "LoadImage", "inputs": {"image": fname}} | |
| 287 | + conditioning_inputs["first_frame"] = ["n_first", 0] | |
| 288 | + if req.last_frame_url: | |
| 289 | + fname = _fetch_to_input(req.last_frame_url) | |
| 290 | + g["n_last"] = {"class_type": "LoadImage", "inputs": {"image": fname}} | |
| 291 | + conditioning_inputs["last_frame"] = ["n_last", 0] | |
| 292 | + g["n_cond"] = {"class_type": "MiniMaxH3ImageToVideo", "inputs": conditioning_inputs} | |
| 293 | + | |
| 294 | + _append_sampling_tail(g, req, positive_node="n_cond") | |
| 295 | + return g | |
| 296 | + | |
| 297 | + | |
| 298 | +def build_ref2va_graph(req: GenReq) -> dict: | |
| 299 | + """多参考图生视频 —— MiniMaxH3ReferenceToVideo,最多9张参考图(协议规格上限)。""" | |
| 300 | + g = _static_nodes() | |
| 301 | + g["s_unet"] = {"class_type": "RayUNETLoader", | |
| 302 | + "inputs": {"unet_name": DIT_REF2VA, "weight_dtype": "default", "lora": None, | |
| 303 | + "ray_actors_init": ["s_rayinit", 0]}} | |
| 304 | + | |
| 305 | + conditioning_inputs = { | |
| 306 | + "prompt": req.prompt, "clip": ["s_clip_gpu", 0], "vae": ["s_vae_v_gpu", 0], | |
| 307 | + "audio_vae": ["s_vae_a_gpu", 0], "width": req.width, "height": req.height, | |
| 308 | + "length": req.frames, "ref_image_size": "match", | |
| 309 | + } | |
| 310 | + for i, url in enumerate(req.reference_image_urls[:9]): | |
| 311 | + fname = _fetch_to_input(url) | |
| 312 | + nid = f"n_ref{i}" | |
| 313 | + g[nid] = {"class_type": "LoadImage", "inputs": {"image": fname}} | |
| 314 | + conditioning_inputs[f"ref_images.ref_image_{i}"] = [nid, 0] | |
| 315 | + for i, url in enumerate(req.reference_video_urls[:3]): | |
| 316 | + fname = _fetch_to_input(url, kind="video") | |
| 317 | + load_nid, split_nid = f"n_refvid{i}_load", f"n_refvid{i}_split" | |
| 318 | + g[load_nid] = {"class_type": "LoadVideo", "inputs": {"file": fname}} | |
| 319 | + # GetVideoComponents 把 VIDEO 拆成 images/audio/fps/bit_depth/color_space—— | |
| 320 | + # ref_video 要 IMAGE 不是 VIDEO,ref_video_audio 用的就是同一段视频自带的音轨, | |
| 321 | + # 不需要用户再单独传一份音频。 | |
| 322 | + g[split_nid] = {"class_type": "GetVideoComponents", "inputs": {"video": [load_nid, 0]}} | |
| 323 | + conditioning_inputs[f"ref_videos.ref_video_{i}"] = [split_nid, 0] | |
| 324 | + conditioning_inputs[f"ref_video_audios.ref_video_audio_{i}"] = [split_nid, 1] | |
| 325 | + for i, url in enumerate(req.reference_audio_urls[:3]): | |
| 326 | + fname = _fetch_to_input(url, kind="audio") | |
| 327 | + nid = f"n_refaudio{i}" | |
| 328 | + g[nid] = {"class_type": "LoadAudio", "inputs": {"audio": fname}} | |
| 329 | + conditioning_inputs[f"ref_audios.ref_audio_{i}"] = [nid, 0] | |
| 330 | + g["n_cond"] = {"class_type": "MiniMaxH3ReferenceToVideo", "inputs": conditioning_inputs} | |
| 331 | + | |
| 332 | + _append_sampling_tail(g, req, positive_node="n_cond") | |
| 333 | + return g | |
| 334 | + | |
| 335 | + | |
| 336 | +def _append_sampling_tail(g: dict, req: GenReq, positive_node: str): | |
| 337 | + """采样+解码+存视频这一段两套 workflow 完全一样,抽出来一份。 | |
| 338 | + cfg_scale/negative_prompt 目前没有入口——MiniMax H3 在 Raylight 这条 pipeline 里不支持 CFG | |
| 339 | + parallel(cfg_degree 只能是1),RayBasicGuider 只吃一路 positive conditioning,见 notes.md。""" | |
| 340 | + g["n_guider"] = {"class_type": "RayBasicGuider", | |
| 341 | + "inputs": {"ray_actors": ["s_unet", 0], "conditioning": [positive_node, 0]}} | |
| 342 | + g["n_sched"] = {"class_type": "RayBasicScheduler", | |
| 343 | + "inputs": {"scheduler": "simple", "steps": req.steps, "denoise": 1.0, | |
| 344 | + "ray_actors": ["s_unet", 0]}} | |
| 345 | + g["n_sampler_sel"] = {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "res_multistep"}} | |
| 346 | + g["n_sample"] = {"class_type": "XFuserSamplerCustomAdvanced", | |
| 347 | + "inputs": {"add_noise": True, "noise_seed": req.seed, | |
| 348 | + "guider": ["n_guider", 0], "sampler": ["n_sampler_sel", 0], | |
| 349 | + "sigmas": ["n_sched", 0], "latent_image": [positive_node, 1]}} | |
| 350 | + g["n_vaedec"] = {"class_type": "VAEDecode", "inputs": {"samples": ["n_sample", 0], "vae": ["s_vae_v_gpu", 0]}} | |
| 351 | + g["n_vaedec_a"] = {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["n_sample", 0], "vae": ["s_vae_a_gpu", 0]}} | |
| 352 | + g["n_video"] = {"class_type": "CreateVideo", | |
| 353 | + "inputs": {"fps": 24, "bit_depth": 8, "images": ["n_vaedec", 0], "audio": ["n_vaedec_a", 0]}} | |
| 354 | + g["n_save"] = {"class_type": "SaveVideo", | |
| 355 | + "inputs": {"filename_prefix": "video/MiniMax_H3", "format": "auto", | |
| 356 | + "video": ["n_video", 0], "format.codec": "auto"}} | |
| 357 | + | |
| 358 | + | |
| 359 | +def build_graph(req: GenReq) -> tuple[dict, str]: | |
| 360 | + partition = _partition_of(req) | |
| 361 | + if partition == "ref2va": | |
| 362 | + return build_ref2va_graph(req), partition | |
| 363 | + return build_fl2va_graph(req), partition | |
| 364 | + | |
| 365 | + | |
| 366 | +# ---------------- ComfyUI 交互(单队列 worker,严格串行——4卡是一个集群,同时只能跑一个请求) ---------------- | |
| 367 | + | |
| 368 | +async def _tail_log_progress(j: Job, expected_total: int, stop: asyncio.Event): | |
| 369 | + """见 _PROGRESS_RE 定义处的 ponytail 注释:progress_state websocket 拿不到真实 step 时的退路。""" | |
| 370 | + while not stop.is_set(): | |
| 371 | + await asyncio.sleep(2) | |
| 372 | + try: | |
| 373 | + with open(COMFYUI_LOG_PATH, "rb") as f: | |
| 374 | + f.seek(0, 2) | |
| 375 | + f.seek(max(0, f.tell() - 8192)) | |
| 376 | + tail = f.read().decode("utf-8", "ignore") | |
| 377 | + except OSError: | |
| 378 | + continue | |
| 379 | + for m in reversed(list(_PROGRESS_RE.finditer(tail))): | |
| 380 | + cur, total = int(m.group(1)), int(m.group(2)) | |
| 381 | + if total == expected_total and (cur, total) != (j.step, j.total_steps): | |
| 382 | + j.step, j.total_steps = cur, total | |
| 383 | + _persist(j) | |
| 384 | + break | |
| 385 | + | |
| 386 | + | |
| 387 | +async def _track_ws_progress(j: Job, client_id: str, prompt_id: str): | |
| 388 | + """尽力而为的进度上报——完成判定不靠这个(见 _run_job_async 里的注释),这个连接断了/ | |
| 389 | + 超时/漏消息都无所谓,静默退出就行,不能让它的失败影响任务本身的成败判定。""" | |
| 390 | + try: | |
| 391 | + ws_url = f"{COMFY_WS}?clientId={client_id}" | |
| 392 | + async with websockets.connect(ws_url, max_size=None) as ws: | |
| 393 | + while True: | |
| 394 | + raw = await ws.recv() | |
| 395 | + if not isinstance(raw, str): | |
| 396 | + continue | |
| 397 | + msg = json.loads(raw) | |
| 398 | + data = msg.get("data", {}) | |
| 399 | + if msg.get("type") == "progress_state" and data.get("prompt_id") == prompt_id: | |
| 400 | + for node_info in data.get("nodes", {}).values(): | |
| 401 | + if node_info.get("state") == "running": | |
| 402 | + j.step = int(node_info.get("value", 0)) | |
| 403 | + j.total_steps = int(node_info.get("max", 0)) | |
| 404 | + _persist(j) | |
| 405 | + except Exception: | |
| 406 | + pass | |
| 407 | + | |
| 408 | + | |
| 409 | +async def _poll_history(prompt_id: str, interval: float = 5.0) -> dict: | |
| 410 | + async with httpx.AsyncClient(timeout=30) as http: | |
| 411 | + while True: | |
| 412 | + resp = await http.get(f"{COMFY_BASE}/history/{prompt_id}") | |
| 413 | + resp.raise_for_status() | |
| 414 | + data = resp.json() | |
| 415 | + if prompt_id in data: | |
| 416 | + return data[prompt_id] | |
| 417 | + await asyncio.sleep(interval) | |
| 418 | + | |
| 419 | + | |
| 420 | +async def _run_job_async(j: Job): | |
| 421 | + graph, _ = build_graph(j.req) | |
| 422 | + client_id = uuid.uuid4().hex | |
| 423 | + async with httpx.AsyncClient(timeout=60) as http: | |
| 424 | + resp = await http.post(f"{COMFY_BASE}/prompt", json={"prompt": graph, "client_id": client_id}) | |
| 425 | + resp.raise_for_status() | |
| 426 | + body = resp.json() | |
| 427 | + if body.get("node_errors"): | |
| 428 | + raise RuntimeError(f"workflow 校验失败: {json.dumps(body['node_errors'], ensure_ascii=False)}") | |
| 429 | + prompt_id = body["prompt_id"] | |
| 430 | + j.comfy_prompt_id = prompt_id | |
| 431 | + _persist(j) | |
| 432 | + | |
| 433 | + stop_tail = asyncio.Event() | |
| 434 | + tail_task = asyncio.create_task(_tail_log_progress(j, j.req.steps, stop_tail)) | |
| 435 | + ws_task = asyncio.create_task(_track_ws_progress(j, client_id, prompt_id)) | |
| 436 | + try: | |
| 437 | + # 2026-08-23: 之前完成判定只靠 websocket 的 execution_success 消息——这条连接在大分辨率/ | |
| 438 | + # 长帧数任务上复现过至少2次"消息收不到"(ComfyUI日志明明有"Prompt executed",这边永远 | |
| 439 | + # 卡在running),把单队列堵死。改成轮询 /history 作为完成判定的唯一权威来源, | |
| 440 | + # 跟websocket解耦:websocket只用来拿实时进度,它断线/丢消息不影响完成判定。 | |
| 441 | + hist = await _poll_history(prompt_id) | |
| 442 | + finally: | |
| 443 | + stop_tail.set() | |
| 444 | + tail_task.cancel() | |
| 445 | + ws_task.cancel() | |
| 446 | + # 2026-08-23: 真根因找到了——asyncio.CancelledError 在py3.8+是BaseException的子类, | |
| 447 | + # 不是Exception的子类,contextlib.suppress(Exception)根本压不住它。之前这里一旦 | |
| 448 | + # cancel()完再await,CancelledError会从这个finally里直接飞出去,绕过worker()的 | |
| 449 | + # except Exception,导致status永远停在running但finished_at已经被worker()的finally | |
| 450 | + # 写上了——外面看起来就是"denoise明明跑完了、任务却卡死",其实是这个而不是websocket | |
| 451 | + # 断线(早前怀疑的方向)。这就是这两天反复出现的那个"卡死"bug的真身。 | |
| 452 | + with contextlib.suppress(asyncio.CancelledError): | |
| 453 | + await tail_task | |
| 454 | + with contextlib.suppress(asyncio.CancelledError): | |
| 455 | + await ws_task | |
| 456 | + | |
| 457 | + status = hist.get("status") or {} | |
| 458 | + if status.get("status_str") == "error": | |
| 459 | + raise RuntimeError(f"ComfyUI execution_error: {_extract_error_summary(status.get('messages', []))}") | |
| 460 | + | |
| 461 | + # 视频文件在 outputs.<save节点id>.images[] 里,不是 "videos" key——ComfyUI 内部历史遗留命名 | |
| 462 | + save_out = hist["outputs"]["n_save"]["images"][0] | |
| 463 | + fpath = OUTPUT_DIR / save_out["filename"] | |
| 464 | + if not fpath.exists(): | |
| 465 | + raise RuntimeError(f"ComfyUI 报告完成但文件不存在: {fpath}") | |
| 466 | + j.file = str(fpath) | |
| 467 | + j.size_bytes = fpath.stat().st_size | |
| 468 | + | |
| 469 | + | |
| 470 | +def worker(): | |
| 471 | + STATE["model"] = "ready" # ComfyUI 自己管加载,这里没有独立的"模型加载"阶段 | |
| 472 | + STATE["loaded_at"] = time.time() | |
| 473 | + loop = asyncio.new_event_loop() | |
| 474 | + while True: | |
| 475 | + jid = Q.get() | |
| 476 | + j = JOBS.get(jid) | |
| 477 | + if j is None: | |
| 478 | + continue | |
| 479 | + if j.status == "cancelled": | |
| 480 | + # 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑 | |
| 481 | + Q.task_done() | |
| 482 | + continue | |
| 483 | + j.status = "running" | |
| 484 | + j.started_at = time.time() | |
| 485 | + _persist(j) | |
| 486 | + try: | |
| 487 | + loop.run_until_complete(_run_job_async(j)) | |
| 488 | + j.status = "done" | |
| 489 | + except Exception as e: | |
| 490 | + j.status = "failed" | |
| 491 | + j.error = f"{type(e).__name__}: {e}" | |
| 492 | + print(f"[job {jid}] failed: {j.error}") | |
| 493 | + finally: | |
| 494 | + j.finished_at = time.time() | |
| 495 | + _persist(j) | |
| 496 | + Q.task_done() | |
| 497 | + | |
| 498 | + | |
| 499 | +# ---------------- HTTP API —— 跟 gateway.py 现有契约保持一致,console 不用改一行 UI 代码 ---------------- | |
| 500 | + | |
| 501 | +app = FastAPI() | |
| 502 | + | |
| 503 | + | |
| 504 | +@app.on_event("startup") | |
| 505 | +def _start(): | |
| 506 | + INPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| 507 | + init_db() | |
| 508 | + _load_jobs() | |
| 509 | + threading.Thread(target=worker, daemon=True).start() | |
| 510 | + | |
| 511 | + | |
| 512 | +def _gpu_status() -> list: | |
| 513 | + # 2026-08-24:给 console 首页显示实时显存/利用率——之前撞过好几次OOM,后来发现是别的 | |
| 514 | + # 工作负载(比如旧 minimax-h3 四卡worker)在偷偷抢显存,提交任务前完全看不出来。 | |
| 515 | + # 直接读 nvidia-smi,不缓存,每次 /health 请求都是当下的真实数字。 | |
| 516 | + try: | |
| 517 | + out = subprocess.run( | |
| 518 | + ["nvidia-smi", "--query-gpu=index,memory.used,memory.total,utilization.gpu", | |
| 519 | + "--format=csv,noheader,nounits"], | |
| 520 | + capture_output=True, text=True, timeout=3, check=True, | |
| 521 | + ).stdout | |
| 522 | + except Exception: | |
| 523 | + return [] | |
| 524 | + gpus = [] | |
| 525 | + for line in out.strip().splitlines(): | |
| 526 | + parts = [x.strip() for x in line.split(",")] | |
| 527 | + if len(parts) != 4: | |
| 528 | + continue | |
| 529 | + idx, used, total, util = parts | |
| 530 | + gpus.append({"index": int(idx), "memory_used_mb": int(used), | |
| 531 | + "memory_total_mb": int(total), "utilization_pct": int(util)}) | |
| 532 | + return gpus | |
| 533 | + | |
| 534 | + | |
| 535 | +def _comfyui_alive() -> bool: | |
| 536 | + # 2026-08-24:这之前是唯一的健康检查盲区——/health 只看 STATE["model"](worker()线程 | |
| 537 | + # 启动时设一次"ready",之后再也不更新),ComfyUI(8188)本体真的崩了,这层壳(server.py) | |
| 538 | + # 没死的话 /health 会一直报 ready。实测过一次:8188 进程在一次OOM后整个退出,/health | |
| 539 | + # 骗了将近20分钟才被人工发现,期间提交的任务全部秒失败(ConnectError)。改成每次 /health | |
| 540 | + # 都真的探一下 8188 活不活,跟 start.sh 自己的就绪检测用同一个端点(/system_stats)。 | |
| 541 | + try: | |
| 542 | + r = httpx.get(f"{COMFY_BASE}/system_stats", timeout=2.0) | |
| 543 | + return r.status_code == 200 | |
| 544 | + except Exception: | |
| 545 | + return False | |
| 546 | + | |
| 547 | + | |
| 548 | +@app.get("/health") | |
| 549 | +def health(): | |
| 550 | + running = sum(1 for j in JOBS.values() if j.status == "running") | |
| 551 | + comfy_alive = _comfyui_alive() | |
| 552 | + ready = 1 if (STATE["model"] == "ready" and comfy_alive) else 0 | |
| 553 | + model_state = STATE["model"] if comfy_alive else "comfyui_unreachable" | |
| 554 | + backend = { | |
| 555 | + "status": "ok" if ready else "loading", | |
| 556 | + "model": model_state, | |
| 557 | + "gpu": "0,1,2,3", | |
| 558 | + "partition": "raylight-fsdp", # 不再是路由依据,纯展示——4卡是一个集群,没有单卡分区概念了 | |
| 559 | + "offload": "fsdp", | |
| 560 | + "queued": Q.qsize(), | |
| 561 | + "running": running, | |
| 562 | + "backend": BACKEND_URL, | |
| 563 | + } | |
| 564 | + return { | |
| 565 | + "status": "ok" if ready else "unavailable", | |
| 566 | + "ready": ready, "total": 1, | |
| 567 | + "queued": Q.qsize(), "running": running, | |
| 568 | + "backends": [backend], | |
| 569 | + "gpus": _gpu_status(), | |
| 570 | + } | |
| 571 | + | |
| 572 | + | |
| 573 | +@app.post("/v1/videos") | |
| 574 | +def create_video(req: GenReq): | |
| 575 | + if req.turbo and req.steps > 8: | |
| 576 | + raise HTTPException(400, "turbo 模式建议 steps<=8(当前实现turbo LoRA还没接,steps按原样生效)") | |
| 577 | + jid = uuid.uuid4().hex[:12] | |
| 578 | + partition = _partition_of(req) | |
| 579 | + with LOCK: | |
| 580 | + JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), partition=partition) | |
| 581 | + _persist(JOBS[jid]) | |
| 582 | + Q.put(jid) | |
| 583 | + return {"id": jid, "status": "queued", "queue_position": Q.qsize(), | |
| 584 | + "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL} | |
| 585 | + | |
| 586 | + | |
| 587 | +def _trigger_restart_async(): | |
| 588 | + # sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启, | |
| 589 | + # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。 | |
| 590 | + subprocess.Popen( | |
| 591 | + ["bash", "-c", "sleep 2 && bash /data/models/raylight/start.sh " | |
| 592 | + ">> /data/models/raylight/stop-restart.log 2>&1"], | |
| 593 | + start_new_session=True, | |
| 594 | + ) | |
| 595 | + | |
| 596 | + | |
| 597 | +@app.post("/v1/videos/{jid}/cancel") | |
| 598 | +def cancel_video(jid: str): | |
| 599 | + j = JOBS.get(jid) | |
| 600 | + if j is None: | |
| 601 | + raise HTTPException(404, "无此任务") | |
| 602 | + if j.status == "queued": | |
| 603 | + with LOCK: | |
| 604 | + j.status = "cancelled" | |
| 605 | + j.finished_at = time.time() | |
| 606 | + j.error = "用户手动取消" | |
| 607 | + _persist(j) | |
| 608 | + return j.model_dump() | |
| 609 | + if j.status == "running": | |
| 610 | + # 2026-08-24实测确认:ComfyUI的/interrupt打断不了Raylight——它的实际计算跑在独立的 | |
| 611 | + # Ray子进程里(FSDP分布式),根本不检查ComfyUI主进程的打断标志位(打断前后GPU一直 | |
| 612 | + # 100%利用率,采样步数照样往前走,完全没停)。唯一能真正把GPU立刻收回来的办法是硬 | |
| 613 | + # 重启整个服务,跟"actor崩溃/卡死"的恢复手段一样——不假装能优雅停止。 | |
| 614 | + with LOCK: | |
| 615 | + j.status = "cancelled" | |
| 616 | + j.finished_at = time.time() | |
| 617 | + j.error = "用户手动停止(硬重启GPU——Raylight的Ray子进程收不到ComfyUI的/interrupt信号,见notes.md)" | |
| 618 | + _persist(j) | |
| 619 | + _trigger_restart_async() | |
| 620 | + return j.model_dump() | |
| 621 | + raise HTTPException(409, f"任务当前状态是 {j.status},无法取消/停止") | |
| 622 | + | |
| 623 | + | |
| 624 | +@app.post("/admin/reset-gpu") | |
| 625 | +def reset_gpu(): | |
| 626 | + # 通用重置——不依赖有没有具体任务在跑。已知上游bug:Raylight actor崩溃/OOM后不会自愈 | |
| 627 | + # (ensure_fresh_actors里needs_restart被无条件覆盖成False,见README),显存可能卡在 | |
| 628 | + # 接近满载但JOBS里根本没有running任务,这时候"停止"按钮不会出现(只在有running任务时才 | |
| 629 | + # 显示),需要一个不挂在具体任务上的兜底重置入口。 | |
| 630 | + with LOCK: | |
| 631 | + for j in JOBS.values(): | |
| 632 | + if j.status == "running": | |
| 633 | + j.status = "cancelled" | |
| 634 | + j.finished_at = time.time() | |
| 635 | + j.error = "用户手动重置GPU(硬重启,见 /v1/videos/{id}/cancel 同款说明)" | |
| 636 | + _persist(j) | |
| 637 | + _trigger_restart_async() | |
| 638 | + return {"status": "restarting", "message": "已触发服务重启,约15-20秒后GPU恢复空闲"} | |
| 639 | + | |
| 640 | + | |
| 641 | +@app.get("/v1/videos/{jid}") | |
| 642 | +def get_video(jid: str): | |
| 643 | + j = JOBS.get(jid) | |
| 644 | + if j is None: | |
| 645 | + raise HTTPException(404, "无此任务") | |
| 646 | + out = j.model_dump() | |
| 647 | + if j.status == "running" and j.started_at: | |
| 648 | + out["elapsed_seconds"] = time.time() - j.started_at | |
| 649 | + if j.status == "done": | |
| 650 | + out["download"] = f"/v1/videos/{jid}/file" | |
| 651 | + return out | |
| 652 | + | |
| 653 | + | |
| 654 | +@app.get("/v1/videos/{jid}/file") | |
| 655 | +def get_video_file(jid: str): | |
| 656 | + j = JOBS.get(jid) | |
| 657 | + if j is None: | |
| 658 | + raise HTTPException(404, "无此任务") | |
| 659 | + if j.status != "done" or not j.file: | |
| 660 | + raise HTTPException(409, "任务还没完成") | |
| 661 | + return FileResponse(j.file, media_type="video/mp4", filename=f"{jid}.mp4") | |
| 662 | + | |
| 663 | + | |
| 664 | +@app.get("/v1/videos") | |
| 665 | +def list_videos(limit: int = 50): | |
| 666 | + jobs = sorted(JOBS.values(), key=lambda j: j.created_at, reverse=True)[:limit] | |
| 667 | + return [{"id": j.id, "status": j.status, "created_at": j.created_at, | |
| 668 | + "size_bytes": j.size_bytes, "error": j.error} for j in jobs] | ... | ... |