Commit 4a799a9e5bb898345ca8ce065f5aaf829b0320b6

Authored by yibo
1 parent 5c47845c

参考视频/音频 + 停止按钮:修 completion 检测遗留 bug、加媒体预览、硬重启式停止运行中任务

排队/取消:新增 cancelled 状态区分"排队未跑就被取消"和"运行中被打断";运行中任务现在能点
"停止"——实测确认 ComfyUI 的 /interrupt 打断不了 Raylight 的 Ray 子进程(打断前后 GPU 一直
100%利用率),唯一办法是硬重启整个服务,已实测两种场景(初始化阶段/满载采样阶段)都能正确
收回 GPU。

参考视频/音频:GenReq 加 reference_video_urls/reference_audio_urls,用 ComfyUI 核心节点
LoadVideo→GetVideoComponents 接参考视频(自动带上视频自己的音轨),LoadAudio 接独立音频。
console 上传接口按类型分级限流,"多参考图生视频"tab 加对应上传框,task.html 的参考图/视频/
音频现在都能直接预览播放(不再只显示文件名),不再需要跳出去看。

顺带修复:报错文本截取从头/尾盲截改成直接定位"XxxError: ..."模式,不会再被 traceback 里的
超长张量 dump 淹没掉真正的报错原因;console 自己的 partition 判断补上新字段,跟 raylight 那份
保持一致。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG.md
1 # 变更记录 1 # 变更记录
2 2
  3 +## 2026-08-24 — 多参考图生视频加上参考视频/独立音频参考
  4 +
  5 +**结果**:查 ComfyUI `/object_info` 发现 `MiniMaxH3ReferenceToVideo` 节点其实支持
  6 +`ref_videos`(参考视频,最多3段)/`ref_audios`(独立音频参考,最多3个)/`ref_video_audios`
  7 +(对应视频的音轨),之前只接了 `ref_images`。现在都接上了:`raylight/server.py` 的 `GenReq`
  8 +加 `reference_video_urls`/`reference_audio_urls`,`build_ref2va_graph` 用 ComfyUI 核心节点
  9 +`LoadVideo`→`GetVideoComponents`(拆出images+audio两路)接参考视频,`LoadAudio`接独立音频;
  10 +`h3-console` 的 `/v1/assets` 上传接口按扩展名分流校验(视频100MB/音频30MB/图片20MB三档),
  11 +"多参考图生视频"tab 加了两个上传框。端到端测过(2秒测试视频+独立音频文件),成功生成。
  12 +顺带修了 console 自己的 `_partition_of` 漏加这两个新字段的判断(跟`raylight/server.py`那份
  13 +不一致,纯展示层,不影响实际生成)。
  14 +
3 ## 2026-08-23(晚)— H3 官方案例测试 + 提示词写作 skill(`h3-prompt-writer`)搭建与 benchmark 收敛 15 ## 2026-08-23(晚)— H3 官方案例测试 + 提示词写作 skill(`h3-prompt-writer`)搭建与 benchmark 收敛
4 16
5 **结果**:为了系统性验证 raylight 复现 MiniMax H3 官方案例效果,做了两件事: 17 **结果**:为了系统性验证 raylight 复现 MiniMax H3 官方案例效果,做了两件事:
h3-console/server.py
@@ -28,6 +28,8 @@ DB_PATH = BASE / &quot;console.sqlite3&quot; @@ -28,6 +28,8 @@ DB_PATH = BASE / &quot;console.sqlite3&quot;
28 GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000") 28 GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000")
29 CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README 29 CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README
30 MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB,够单张参考图/首尾帧,防止占满磁盘 30 MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB,够单张参考图/首尾帧,防止占满磁盘
  31 +MAX_VIDEO_UPLOAD_BYTES = 100 * 1024 * 1024 # 100MB,参考视频(2-15秒)比图片大得多
  32 +MAX_AUDIO_UPLOAD_BYTES = 30 * 1024 * 1024 # 30MB,独立音频参考
31 ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" 33 ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1"
32 # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名), 34 # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名),
33 # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。 35 # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。
@@ -86,23 +88,39 @@ init_db() @@ -86,23 +88,39 @@ init_db()
86 # api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用 88 # api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用
87 # urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」, 89 # urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」,
88 # 不改 api.py 一行代码。 90 # 不改 api.py 一行代码。
  91 +IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
  92 +VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv")
  93 +AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".ogg", ".flac")
  94 +
  95 +
89 @router.post("/v1/assets") 96 @router.post("/v1/assets")
90 async def upload_asset(file: UploadFile = File(...)): 97 async def upload_asset(file: UploadFile = File(...)):
91 ext = Path(file.filename or "").suffix.lower() 98 ext = Path(file.filename or "").suffix.lower()
92 - if ext not in (".jpg", ".jpeg", ".png", ".webp"):  
93 - raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)") 99 + # 2026-08-23:原来只收图片,现在参考视频/独立音频参考也走这个接口——按扩展名分流到
  100 + # 各自的大小上限,视频/音频没有 PIL 那样现成的解码校验,先不做,坏文件会在 ComfyUI
  101 + # LoadVideo/LoadAudio 那一步报错,属于可接受的失败模式。
  102 + if ext in IMAGE_EXTS:
  103 + kind, max_bytes = "image", MAX_UPLOAD_BYTES
  104 + elif ext in VIDEO_EXTS:
  105 + kind, max_bytes = "video", MAX_VIDEO_UPLOAD_BYTES
  106 + elif ext in AUDIO_EXTS:
  107 + kind, max_bytes = "audio", MAX_AUDIO_UPLOAD_BYTES
  108 + else:
  109 + raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)/"
  110 + f"视频(mp4/mov/webm/mkv)/音频(mp3/wav/m4a/ogg/flac)")
94 body = await file.read() 111 body = await file.read()
95 - if len(body) > MAX_UPLOAD_BYTES:  
96 - raise HTTPException(413, f"文件超过 {MAX_UPLOAD_BYTES // 1024 // 1024}MB 上限") 112 + if len(body) > max_bytes:
  113 + raise HTTPException(413, f"文件超过 {max_bytes // 1024 // 1024}MB 上限")
97 aid = uuid.uuid4().hex[:16] 114 aid = uuid.uuid4().hex[:16]
98 path = ASSETS / f"{aid}{ext}" 115 path = ASSETS / f"{aid}{ext}"
99 path.write_bytes(body) 116 path.write_bytes(body)
100 - try:  
101 - Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制  
102 - except Exception:  
103 - path.unlink(missing_ok=True)  
104 - raise HTTPException(400, "文件不是有效图片")  
105 - return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}"} 117 + if kind == "image":
  118 + try:
  119 + Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制
  120 + except Exception:
  121 + path.unlink(missing_ok=True)
  122 + raise HTTPException(400, "文件不是有效图片")
  123 + return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}", "kind": kind}
106 124
107 125
108 def _public_base() -> str: 126 def _public_base() -> str:
@@ -126,9 +144,19 @@ app.mount(f&quot;{BASE_PATH}/assets&quot;, StaticFiles(directory=str(ASSETS)), name=&quot;asset @@ -126,9 +144,19 @@ app.mount(f&quot;{BASE_PATH}/assets&quot;, StaticFiles(directory=str(ASSETS)), name=&quot;asset
126 # 尝试提交给 gateway;提交不上(503/429)就留在本地队列里,交给下面的 reconciler_loop 每隔几秒重试, 144 # 尝试提交给 gateway;提交不上(503/429)就留在本地队列里,交给下面的 reconciler_loop 每隔几秒重试,
127 # 弹性调度器看到的 pending 计数正是这些「还没提交上去」的本地排队任务。 145 # 弹性调度器看到的 pending 计数正是这些「还没提交上去」的本地排队任务。
128 def _partition_of(req: dict) -> str: 146 def _partition_of(req: dict) -> str:
129 - # 和 gateway.py 里 `want = "fl2va" if (...) else "ref2va"` 保持完全一致的判断,  
130 - # 不要各写一套、两边判断分岔的话 console 统计出来的 pending 计数就和 gateway 实际路由对不上。  
131 - return "fl2va" if (req.get("first_frame_url") or req.get("last_frame_url")) else "ref2va" 147 + # 2026-08-23 修复:原来是二选一(没有first/last frame就当ref2va),会把纯文生视频
  148 + # (reference_image_urls也是空的)误标成"ref2va"——跟前端 rerunJob() 自己的 kind 判断
  149 + # (index.html 里早就正确区分 text/fl2va/ref2va 三种)对不上,在 task.html 上看起来
  150 + # "标着ref2va却没有参考图",容易让人怀疑参考图是不是丢了/没显示出来,其实只是这个分类
  151 + # 少了纯文生视频这一档。改成跟前端一致的三分类。
  152 + if req.get("first_frame_url") or req.get("last_frame_url"):
  153 + return "fl2va"
  154 + # 2026-08-23:新增参考视频/独立音频参考字段时漏了这里,跟 raylight/server.py 自己的
  155 + # _partition_of 对不上——那边路由图用的是它自己那份判断(功能不受影响,只是这里的
  156 + # 展示标签会显示错),保持两份判断逻辑一致。
  157 + if req.get("reference_image_urls") or req.get("reference_video_urls") or req.get("reference_audio_urls"):
  158 + return "ref2va"
  159 + return "text"
132 160
133 161
134 def _row_view(row) -> dict: 162 def _row_view(row) -> dict:
@@ -220,7 +248,7 @@ async def _refresh_from_gateway(cid: str, gateway_id: str): @@ -220,7 +248,7 @@ async def _refresh_from_gateway(cid: str, gateway_id: str):
220 if row is None: 248 if row is None:
221 return 249 return
222 view = json.loads(row["last_known_json"]) if row["last_known_json"] else {"id": cid} 250 view = json.loads(row["last_known_json"]) if row["last_known_json"] else {"id": cid}
223 - if view.get("status") in ("done", "failed"): 251 + if view.get("status") in ("done", "failed", "cancelled"):
224 return # 已经是终态,不要覆盖 252 return # 已经是终态,不要覆盖
225 view["status"] = "failed" 253 view["status"] = "failed"
226 view["error"] = "任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展" 254 view["error"] = "任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展"
@@ -259,6 +287,41 @@ async def create_video(req: dict): @@ -259,6 +287,41 @@ async def create_video(req: dict):
259 return _row_view(row) 287 return _row_view(row)
260 288
261 289
  290 +@router.post("/v1/videos/{jid}/cancel")
  291 +async def cancel_video(jid: str):
  292 + with db() as con:
  293 + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone()
  294 + if row is None:
  295 + raise HTTPException(404, "无此任务")
  296 + if row["status"] in ("done", "failed", "cancelled"):
  297 + raise HTTPException(409, f"任务当前状态是 {row['status']},已经是终态,取消不了")
  298 + now = time.time()
  299 + if row["gateway_id"]:
  300 + # 已经提交给后端了,得让后端也知道要取消(后端只允许取消它自己还在排队的任务,
  301 + # 如果已经开始跑了会返回409——这里直接把后端的错误透传出去,不假装取消成功了)
  302 + try:
  303 + resp = await client.post(f"{GATEWAY_URL}/v1/videos/{row['gateway_id']}/cancel")
  304 + except httpx.HTTPError:
  305 + raise HTTPException(502, "取消请求发不到后端,稍后再试")
  306 + if resp.status_code >= 400:
  307 + raise HTTPException(resp.status_code, resp.text)
  308 + job = resp.json()
  309 + job["id"] = jid
  310 + with db() as con:
  311 + con.execute(
  312 + "UPDATE jobs SET status='cancelled', updated_at=?, last_known_json=? WHERE id=?",
  313 + (now, json.dumps(job, ensure_ascii=False), jid),
  314 + )
  315 + else:
  316 + # 还没提交给后端(本地排队中),直接在 console 自己这边标掉,reconciler_loop 只挑
  317 + # status='queued' 的行重试提交,改成 cancelled 之后它就不会再去试着提交这条了
  318 + with db() as con:
  319 + con.execute("UPDATE jobs SET status='cancelled', updated_at=? WHERE id=?", (now, jid))
  320 + with db() as con:
  321 + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone()
  322 + return _row_view(row)
  323 +
  324 +
262 @router.get("/v1/videos/{jid}") 325 @router.get("/v1/videos/{jid}")
263 async def get_video(jid: str): 326 async def get_video(jid: str):
264 with db() as con: 327 with db() as con:
h3-console/static/index.html
@@ -48,9 +48,11 @@ @@ -48,9 +48,11 @@
48 button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; } 48 button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; }
49 button.submit:disabled { opacity: .5; cursor: default; } 49 button.submit:disabled { opacity: .5; cursor: default; }
50 .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; } 50 .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; }
51 - .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; } 51 + .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; align-items: flex-start; }
52 .thumbrow img { height: 60px; border-radius: 4px; } 52 .thumbrow img { height: 60px; border-radius: 4px; }
53 - .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } 53 + .thumbrow video { height: 220px; max-width: 100%; border-radius: 4px; }
  54 + .thumbrow audio { height: 32px; width: 220px; }
  55 + .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } .status-cancelled { color: #999; }
54 .err { color: #c33; font-size: 13px; margin-top: 8px; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace; 56 .err { color: #c33; font-size: 13px; margin-top: 8px; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace;
55 max-height: 400px; overflow-y: auto; background: #fff5f5; border: 1px solid #f3c2c2; border-radius: 6px; padding: 10px; } 57 max-height: 400px; overflow-y: auto; background: #fff5f5; border: 1px solid #f3c2c2; border-radius: 6px; padding: 10px; }
56 58
@@ -99,7 +101,7 @@ @@ -99,7 +101,7 @@
99 </div> 101 </div>
100 <div class="hint" id="text-hint"></div> 102 <div class="hint" id="text-hint"></div>
101 <div class="row"> 103 <div class="row">
102 - <div><label>Turbo</label><select id="text-turbo" class="turbo-select" data-steps="text-steps"><option value="true" selected>开(4步,快)</option><option value="false">关(多步,慢但更稳)</option></select></div> 104 + <div><label>Turbo</label><select id="text-turbo" class="turbo-select" data-steps="text-steps"><option value="true">开(4步,快)</option><option value="false" selected>关(多步,慢但更稳)</option></select></div>
103 <div><label>步数</label><select id="text-steps"></select></div> 105 <div><label>步数</label><select id="text-steps"></select></div>
104 <div><label>Seed</label><select id="text-seed"> 106 <div><label>Seed</label><select id="text-seed">
105 <option value="0" selected>0</option> 107 <option value="0" selected>0</option>
@@ -136,7 +138,7 @@ @@ -136,7 +138,7 @@
136 </div> 138 </div>
137 <div class="hint" id="fl2va-hint"></div> 139 <div class="hint" id="fl2va-hint"></div>
138 <div class="row"> 140 <div class="row">
139 - <div><label>Turbo</label><select id="fl2va-turbo" class="turbo-select" data-steps="fl2va-steps"><option value="true" selected>开(4步,快)</option><option value="false">关(多步,慢但更稳)</option></select></div> 141 + <div><label>Turbo</label><select id="fl2va-turbo" class="turbo-select" data-steps="fl2va-steps"><option value="true">开(4步,快)</option><option value="false" selected>关(多步,慢但更稳)</option></select></div>
140 <div><label>步数</label><select id="fl2va-steps"></select></div> 142 <div><label>步数</label><select id="fl2va-steps"></select></div>
141 <div><label>Seed</label><select id="fl2va-seed"> 143 <div><label>Seed</label><select id="fl2va-seed">
142 <option value="0" selected>0</option> 144 <option value="0" selected>0</option>
@@ -165,13 +167,19 @@ @@ -165,13 +167,19 @@
165 <label>参考图(可多选,没有张数上限,越多序列越长越慢)</label> 167 <label>参考图(可多选,没有张数上限,越多序列越长越慢)</label>
166 <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti()"> 168 <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti()">
167 <div class="thumbrow" id="ref2va-thumbs"></div> 169 <div class="thumbrow" id="ref2va-thumbs"></div>
  170 + <label>参考视频(可选,最多3段,2-15秒,音轨会自动带上)</label>
  171 + <input id="ref2va-videos-input" type="file" accept="video/*" multiple onchange="uploadMultiTagged('ref2va-videos-input','ref2va-videos','ref2va-video-tags')">
  172 + <div class="thumbrow" id="ref2va-video-tags"></div>
  173 + <label>独立音频参考(可选,最多3个,不跟参考视频绑定)</label>
  174 + <input id="ref2va-audios-input" type="file" accept="audio/*" multiple onchange="uploadMultiTagged('ref2va-audios-input','ref2va-audios','ref2va-audio-tags')">
  175 + <div class="thumbrow" id="ref2va-audio-tags"></div>
168 <div class="row"> 176 <div class="row">
169 <div><label>分辨率</label><select id="ref2va-res" class="res-select"></select></div> 177 <div><label>分辨率</label><select id="ref2va-res" class="res-select"></select></div>
170 <div><label>时长</label><select id="ref2va-dur" class="dur-select"></select></div> 178 <div><label>时长</label><select id="ref2va-dur" class="dur-select"></select></div>
171 </div> 179 </div>
172 <div class="hint" id="ref2va-hint"></div> 180 <div class="hint" id="ref2va-hint"></div>
173 <div class="row"> 181 <div class="row">
174 - <div><label>Turbo</label><select id="ref2va-turbo" class="turbo-select" data-steps="ref2va-steps"><option value="true" selected>开(4步,快)</option><option value="false">关(多步,慢但更稳)</option></select></div> 182 + <div><label>Turbo</label><select id="ref2va-turbo" class="turbo-select" data-steps="ref2va-steps"><option value="true">开(4步,快)</option><option value="false" selected>关(多步,慢但更稳)</option></select></div>
175 <div><label>步数</label><select id="ref2va-steps"></select></div> 183 <div><label>步数</label><select id="ref2va-steps"></select></div>
176 <div><label>Seed</label><select id="ref2va-seed"> 184 <div><label>Seed</label><select id="ref2va-seed">
177 <option value="0" selected>0</option> 185 <option value="0" selected>0</option>
@@ -204,7 +212,7 @@ @@ -204,7 +212,7 @@
204 // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。 212 // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。
205 const H3_BASE = "__H3_BASE_PATH__"; 213 const H3_BASE = "__H3_BASE_PATH__";
206 214
207 -const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; 215 +const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [], 'ref2va-videos': [], 'ref2va-audios': [] };
208 let jobsCache = []; 216 let jobsCache = [];
209 let trashCache = []; 217 let trashCache = [];
210 let viewingTrash = false; 218 let viewingTrash = false;
@@ -293,7 +301,7 @@ function initParamSelects(prefix) { @@ -293,7 +301,7 @@ function initParamSelects(prefix) {
293 const res = document.getElementById(prefix + '-res'); 301 const res = document.getElementById(prefix + '-res');
294 const dur = document.getElementById(prefix + '-dur'); 302 const dur = document.getElementById(prefix + '-dur');
295 if (res) fillSelect(res, RESOLUTIONS); 303 if (res) fillSelect(res, RESOLUTIONS);
296 - if (dur) fillSelect(dur, DURATIONS); 304 + if (dur) { fillSelect(dur, DURATIONS); dur.value = '362'; } // 默认时长 15s,2026-08-23 用户要求
297 if (res) { syncDurOptions(prefix); res.onchange = () => syncDurOptions(prefix); } 305 if (res) { syncDurOptions(prefix); res.onchange = () => syncDurOptions(prefix); }
298 306
299 const turboSelect = document.getElementById(prefix + '-turbo'); 307 const turboSelect = document.getElementById(prefix + '-turbo');
@@ -337,6 +345,33 @@ async function uploadMulti() { @@ -337,6 +345,33 @@ async function uploadMulti() {
337 } 345 }
338 } 346 }
339 347
  348 +// 参考视频/独立音频参考共用——不像参考图没有上限,这两个都封顶3个(跟节点协议一致),
  349 +// 视频/音频不方便像图片一样直接<img>预览,用文件名文字标签代替。
  350 +// elId 里带 "video"/"audio" 判断放哪种播放器——跟生成结果的视频一样直接能播放预览,
  351 +// 不再只显示文件名。
  352 +function renderTags(elId, urls) {
  353 + const box = document.getElementById(elId); box.innerHTML = '';
  354 + const isVideo = elId.includes('video');
  355 + urls.forEach(u => {
  356 + const el = document.createElement(isVideo ? 'video' : 'audio');
  357 + el.src = u; el.controls = true;
  358 + box.appendChild(el);
  359 + });
  360 +}
  361 +
  362 +async function uploadMultiTagged(inputId, stateKey, tagsElId) {
  363 + const el = document.getElementById(inputId);
  364 + const errBox = document.getElementById('ref2va-err');
  365 + for (const f of el.files) {
  366 + if (uploaded[stateKey].length >= 3) { errBox.textContent = '最多3个,已达上限'; break; }
  367 + try {
  368 + const res = await uploadFile(f);
  369 + uploaded[stateKey].push(res.url);
  370 + } catch (e) { errBox.textContent = '上传失败: ' + e.message; }
  371 + }
  372 + renderTags(tagsElId, uploaded[stateKey]);
  373 +}
  374 +
340 // 分辨率/时长/Turbo/步数/Seed 五个字段,三个 tab 结构完全一样,抽出来一份读取逻辑。 375 // 分辨率/时长/Turbo/步数/Seed 五个字段,三个 tab 结构完全一样,抽出来一份读取逻辑。
341 function readParamSelects(prefix) { 376 function readParamSelects(prefix) {
342 const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value); 377 const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value);
@@ -371,10 +406,14 @@ async function submitJob(kind) { @@ -371,10 +406,14 @@ async function submitJob(kind) {
371 ...readParamSelects('fl2va'), 406 ...readParamSelects('fl2va'),
372 }; 407 };
373 } else if (kind === 'ref2va') { 408 } else if (kind === 'ref2va') {
374 - if (uploaded['ref2va-refs'].length === 0) { errBox.textContent = '请先上传至少一张参考图'; return; } 409 + if (uploaded['ref2va-refs'].length === 0 && uploaded['ref2va-videos'].length === 0 && uploaded['ref2va-audios'].length === 0) {
  410 + errBox.textContent = '请先上传至少一张参考图/一段参考视频/一个参考音频'; return;
  411 + }
375 body = { 412 body = {
376 prompt: document.getElementById('ref2va-prompt').value, 413 prompt: document.getElementById('ref2va-prompt').value,
377 reference_image_urls: uploaded['ref2va-refs'], 414 reference_image_urls: uploaded['ref2va-refs'],
  415 + reference_video_urls: uploaded['ref2va-videos'],
  416 + reference_audio_urls: uploaded['ref2va-audios'],
378 ...readParamSelects('ref2va'), 417 ...readParamSelects('ref2va'),
379 }; 418 };
380 } 419 }
@@ -398,7 +437,7 @@ function setThumbs(elId, urls) { @@ -398,7 +437,7 @@ function setThumbs(elId, urls) {
398 437
399 function prefillCreate(req) { 438 function prefillCreate(req) {
400 const kind = (req.first_frame_url || req.last_frame_url) ? 'fl2va' 439 const kind = (req.first_frame_url || req.last_frame_url) ? 'fl2va'
401 - : (req.reference_image_urls || []).length ? 'ref2va' : 'text'; 440 + : ((req.reference_image_urls || []).length || (req.reference_video_urls || []).length || (req.reference_audio_urls || []).length) ? 'ref2va' : 'text';
402 441
403 document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === kind)); 442 document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === kind));
404 ['text', 'fl2va', 'ref2va'].forEach(t => document.getElementById('tab-' + t).style.display = (t === kind ? 'block' : 'none')); 443 ['text', 'fl2va', 'ref2va'].forEach(t => document.getElementById('tab-' + t).style.display = (t === kind ? 'block' : 'none'));
@@ -438,7 +477,11 @@ function prefillCreate(req) { @@ -438,7 +477,11 @@ function prefillCreate(req) {
438 setThumbs('fl2va-thumbs', [req.first_frame_url, req.last_frame_url]); 477 setThumbs('fl2va-thumbs', [req.first_frame_url, req.last_frame_url]);
439 } else if (kind === 'ref2va') { 478 } else if (kind === 'ref2va') {
440 uploaded['ref2va-refs'] = (req.reference_image_urls || []).slice(); 479 uploaded['ref2va-refs'] = (req.reference_image_urls || []).slice();
  480 + uploaded['ref2va-videos'] = (req.reference_video_urls || []).slice();
  481 + uploaded['ref2va-audios'] = (req.reference_audio_urls || []).slice();
441 setThumbs('ref2va-thumbs', uploaded['ref2va-refs']); 482 setThumbs('ref2va-thumbs', uploaded['ref2va-refs']);
  483 + renderTags('ref2va-video-tags', uploaded['ref2va-videos']);
  484 + renderTags('ref2va-audio-tags', uploaded['ref2va-audios']);
442 } 485 }
443 486
444 showCreate(); 487 showCreate();
@@ -453,8 +496,23 @@ async function rerunJob(id) { @@ -453,8 +496,23 @@ async function rerunJob(id) {
453 } catch (e) { alert('重跑失败: ' + e.message); } 496 } catch (e) { alert('重跑失败: ' + e.message); }
454 } 497 }
455 498
  499 +async function cancelJob(id, running) {
  500 + // running=true 时是"停止":Raylight的Ray子进程不理会ComfyUI的/interrupt,唯一能真正把
  501 + // GPU收回来的办法是硬重启整个服务——所以要把这个代价明确告诉用户,不能假装是优雅停止。
  502 + const msg = running
  503 + ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间其他任务也会受影响。'
  504 + : '确定取消这条排队中的任务?';
  505 + if (!confirm(msg)) return;
  506 + try {
  507 + const r = await fetch(`${H3_BASE}/v1/videos/${id}/cancel`, { method: 'POST' });
  508 + if (!r.ok) throw new Error(await r.text());
  509 + await refreshDetail();
  510 + await refreshJobs();
  511 + } catch (e) { alert((running ? '停止' : '取消') + '失败: ' + e.message); }
  512 +}
  513 +
456 function statusLabel(s) { 514 function statusLabel(s) {
457 - const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败' }; 515 + const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
458 return `<span class="status-${s}">${map[s] || s}</span>`; 516 return `<span class="status-${s}">${map[s] || s}</span>`;
459 } 517 }
460 518
@@ -608,6 +666,8 @@ async function refreshDetail() { @@ -608,6 +666,8 @@ async function refreshDetail() {
608 resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'&lt;')}</div>`; 666 resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'&lt;')}</div>`;
609 } else if (j.status === 'running') { 667 } else if (j.status === 'running') {
610 resultHtml = `<div>已运行 ${j.elapsed_seconds || 0} 秒(共 ${j.total_steps || req.steps || '?'} 步,当前第 ${j.step || 0} 步)</div>`; 668 resultHtml = `<div>已运行 ${j.elapsed_seconds || 0} 秒(共 ${j.total_steps || req.steps || '?'} 步,当前第 ${j.step || 0} 步)</div>`;
  669 + } else if (j.status === 'cancelled') {
  670 + resultHtml = `<div class="err">${(j.error || '已取消').replace(/</g,'&lt;')}</div>`;
611 } else { 671 } else {
612 resultHtml = `<div>排队中,等待可用的 ${j.partition || ''} 后端…</div>`; 672 resultHtml = `<div>排队中,等待可用的 ${j.partition || ''} 后端…</div>`;
613 } 673 }
@@ -617,12 +677,17 @@ async function refreshDetail() { @@ -617,12 +677,17 @@ async function refreshDetail() {
617 if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]); 677 if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]);
618 if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]); 678 if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]);
619 (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u])); 679 (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u]));
620 - if (imgs.length) refThumbs = `<div class="thumbrow">${imgs.map(([label,u]) => `<img src="${u}" title="${label}">`).join('')}</div>`; 680 + // 图片点开看大图,参考视频/音频跟生成结果的视频一样用原生播放器直接预览,不再只显示文件名。
  681 + const imgTags = imgs.map(([label, u]) => `<a href="${u}" target="_blank" title="${label},点击查看大图"><img src="${u}" title="${label}"></a>`).join('');
  682 + const videoTags = (req.reference_video_urls || []).map((u, i) => `<video controls src="${u}" title="参考视频${i+1}"></video>`).join('');
  683 + const audioTags = (req.reference_audio_urls || []).map((u, i) => `<audio controls src="${u}" title="参考音频${i+1}"></audio>`).join('');
  684 + if (imgTags || videoTags) refThumbs += `<div class="thumbrow">${imgTags}${videoTags}</div>`;
  685 + if (audioTags) refThumbs += `<div class="thumbrow">${audioTags}</div>`;
621 686
622 box.innerHTML = ` 687 box.innerHTML = `
623 <div class="back" onclick="showCreate()">← 新建任务</div> 688 <div class="back" onclick="showCreate()">← 新建任务</div>
624 <h2>${j.id}</h2> 689 <h2>${j.id}</h2>
625 - <div>${statusLabel(j.status)} <button class="submit" style="padding:4px 12px;margin-left:8px" onclick="rerunJob('${j.id}')">重跑</button></div> 690 + <div>${statusLabel(j.status)} <button class="submit" style="padding:4px 12px;margin-left:8px" onclick="rerunJob('${j.id}')">重跑</button>${j.status === 'queued' ? ` <button style="padding:4px 12px;margin-left:8px" onclick="cancelJob('${j.id}',false)">取消</button>` : ''}${j.status === 'running' ? ` <button style="padding:4px 12px;margin-left:8px;color:#c33" onclick="cancelJob('${j.id}',true)">停止</button>` : ''}</div>
626 <dl class="dl"> 691 <dl class="dl">
627 <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'&lt;')}</dd> 692 <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'&lt;')}</dd>
628 <dt>分区</dt><dd>${j.partition || '—'}</dd> 693 <dt>分区</dt><dd>${j.partition || '—'}</dd>
raylight/README.md
@@ -121,6 +121,26 @@ ssh admin@192.168.110.67 &#39;cd /data/models/minimax-h3 &amp;&amp; export H3_OFFLOAD=disk &amp; @@ -121,6 +121,26 @@ ssh admin@192.168.110.67 &#39;cd /data/models/minimax-h3 &amp;&amp; export H3_OFFLOAD=disk &amp;
121 结果,这段自愈逻辑现在是死代码。真机测试中一次 OOM 后 GPU1/2/3 卡在接近满载没释放, 121 结果,这段自愈逻辑现在是死代码。真机测试中一次 OOM 后 GPU1/2/3 卡在接近满载没释放,
122 必须手动 `bash start.sh` 整个重启才能回收——这不是我们这边能修的,是上游代码的问题。 122 必须手动 `bash start.sh` 整个重启才能回收——这不是我们这边能修的,是上游代码的问题。
123 123
  124 +## 多参考图生视频支持参考视频/独立音频参考(2026-08-24 新增)
  125 +
  126 +`MiniMaxH3ReferenceToVideo` 节点本身支持的输入比之前接的多——查 ComfyUI `/object_info` 发现
  127 +除了 `ref_images`(参考图,早就接了),还有 `ref_videos`(参考视频,最多3段)、
  128 +`ref_video_audios`(对应视频自己的音轨)、`ref_audios`(独立音频参考,最多3个,不跟任何
  129 +视频绑定)。之前只做了参考图这一种,现在都接上了:
  130 +
  131 +- `GenReq` 加了 `reference_video_urls`/`reference_audio_urls`(各自最多3个)。
  132 +- 参考视频走 `LoadVideo` → `GetVideoComponents` 拆成 images/audio 两路,images 接
  133 + `ref_videos.ref_video_{i}`,audio 自动接 `ref_video_audios.ref_video_audio_{i}`——**音轨
  134 + 是从视频文件自己解出来的,不用用户再单独传一份**,这是"同一段视频自己的音轨"这个语义
  135 + 最省事的实现方式。
  136 +- 独立音频走 `LoadAudio` 直接接 `ref_audios.ref_audio_{i}`。
  137 +- console 的"多参考图生视频"tab 加了两个上传框(参考视频/独立音频参考),`/v1/assets`
  138 + 上传接口按扩展名分流校验(视频100MB上限、音频30MB上限,图片还是20MB)。
  139 +- 端到端测过:2秒测试视频(带音轨)+独立音频文件,`status`正确转`done`,视频文件正常生成。
  140 +
  141 +`LoadVideo`/`LoadAudio`/`GetVideoComponents` 都是 ComfyUI 核心节点(`comfy_extras`),不是
  142 +自定义节点包,没有额外安装/兼容性风险。
  143 +
124 ## 相关文档 144 ## 相关文档
125 145
126 - `docs/todo/raylight-comfyui-multi-gpu-eval.md`——完整调研+验证+生产部署过程,踩过的坑 146 - `docs/todo/raylight-comfyui-multi-gpu-eval.md`——完整调研+验证+生产部署过程,踩过的坑
raylight/scripts/server.py
@@ -16,6 +16,7 @@ import os @@ -16,6 +16,7 @@ import os
16 import queue 16 import queue
17 import re 17 import re
18 import sqlite3 18 import sqlite3
  19 +import subprocess
19 import threading 20 import threading
20 import time 21 import time
21 import urllib.parse 22 import urllib.parse
@@ -51,6 +52,21 @@ COMFYUI_LOG_PATH = Path(&quot;/data/models/raylight/comfyui-server.log&quot;) @@ -51,6 +52,21 @@ COMFYUI_LOG_PATH = Path(&quot;/data/models/raylight/comfyui-server.log&quot;)
51 # steps 的那次匹配,因为同一句"Model Initializing"文案在 FSDP 初始化阶段也会打印、但那里的 52 # steps 的那次匹配,因为同一句"Model Initializing"文案在 FSDP 初始化阶段也会打印、但那里的
52 # 总数是 GPU 数(4),跟真正的采样步数(这里的 steps 字段)容易混淆。 53 # 总数是 GPU 数(4),跟真正的采样步数(这里的 steps 字段)容易混淆。
53 _PROGRESS_RE = re.compile(r"Model Initializing.*?(\d+)\.\d+/(\d+)\.\d+ \[") 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:]
54 70
55 # 每次提交必须逐字节相同才能让 ComfyUI 跳过重建集群——不要给任何一个请求字段改这几个值。 71 # 每次提交必须逐字节相同才能让 ComfyUI 跳过重建集群——不要给任何一个请求字段改这几个值。
56 RAY_INIT_CFG = { 72 RAY_INIT_CFG = {
@@ -108,6 +124,12 @@ class GenReq(BaseModel): @@ -108,6 +124,12 @@ class GenReq(BaseModel):
108 first_frame_url: Optional[str] = None 124 first_frame_url: Optional[str] = None
109 last_frame_url: Optional[str] = None 125 last_frame_url: Optional[str] = None
110 reference_image_urls: list[str] = [] 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)
111 133
112 @model_validator(mode="after") 134 @model_validator(mode="after")
113 def _check_known_resolution_ceiling(self): 135 def _check_known_resolution_ceiling(self):
@@ -124,7 +146,7 @@ class GenReq(BaseModel): @@ -124,7 +146,7 @@ class GenReq(BaseModel):
124 146
125 class Job(BaseModel): 147 class Job(BaseModel):
126 id: str 148 id: str
127 - status: Literal["queued", "running", "done", "failed"] 149 + status: Literal["queued", "running", "done", "failed", "cancelled"]
128 req: GenReq 150 req: GenReq
129 created_at: float 151 created_at: float
130 started_at: Optional[float] = None 152 started_at: Optional[float] = None
@@ -183,8 +205,16 @@ def _load_jobs(): @@ -183,8 +205,16 @@ def _load_jobs():
183 j = Job.model_validate_json(row["data"]) 205 j = Job.model_validate_json(row["data"])
184 except Exception: 206 except Exception:
185 continue 207 continue
186 - # 进程重启=ComfyUI侧的执行状态和排队状态都丢了,跟老系统的策略一致:不可恢复的直接判失败  
187 - if j.status in ("queued", "running"): 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":
188 j.status = "failed" 218 j.status = "failed"
189 j.error = "进程重启导致这条任务的执行状态丢失,无法恢复" 219 j.error = "进程重启导致这条任务的执行状态丢失,无法恢复"
190 j.finished_at = time.time() 220 j.finished_at = time.time()
@@ -195,19 +225,27 @@ def _load_jobs(): @@ -195,19 +225,27 @@ def _load_jobs():
195 225
196 226
197 def _partition_of(req: GenReq) -> str: 227 def _partition_of(req: GenReq) -> str:
198 - if req.reference_image_urls: 228 + if req.reference_image_urls or req.reference_video_urls or req.reference_audio_urls:
199 return "ref2va" 229 return "ref2va"
200 return "fl2va" 230 return "fl2va"
201 231
202 232
203 -def _fetch_to_input(url: str) -> str:  
204 - """下载一张图到 ComfyUI 的 input/ 目录,LoadImage 节点按文件名读取。"""  
205 - ext = os.path.splitext(urllib.parse.urlparse(url).path)[1] or ".png"  
206 - if ext.lower() not in (".png", ".jpg", ".jpeg", ".webp"):  
207 - ext = ".png" 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]
208 fname = f"{uuid.uuid4().hex}{ext}" 246 fname = f"{uuid.uuid4().hex}{ext}"
209 dest = INPUT_DIR / fname 247 dest = INPUT_DIR / fname
210 - with urllib.request.urlopen(url, timeout=60) as resp, open(dest, "wb") as f: 248 + with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as f:
211 f.write(resp.read()) 249 f.write(resp.read())
212 return fname 250 return fname
213 251
@@ -274,6 +312,21 @@ def build_ref2va_graph(req: GenReq) -&gt; dict: @@ -274,6 +312,21 @@ def build_ref2va_graph(req: GenReq) -&gt; dict:
274 nid = f"n_ref{i}" 312 nid = f"n_ref{i}"
275 g[nid] = {"class_type": "LoadImage", "inputs": {"image": fname}} 313 g[nid] = {"class_type": "LoadImage", "inputs": {"image": fname}}
276 conditioning_inputs[f"ref_images.ref_image_{i}"] = [nid, 0] 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]
277 g["n_cond"] = {"class_type": "MiniMaxH3ReferenceToVideo", "inputs": conditioning_inputs} 330 g["n_cond"] = {"class_type": "MiniMaxH3ReferenceToVideo", "inputs": conditioning_inputs}
278 331
279 _append_sampling_tail(g, req, positive_node="n_cond") 332 _append_sampling_tail(g, req, positive_node="n_cond")
@@ -403,10 +456,7 @@ async def _run_job_async(j: Job): @@ -403,10 +456,7 @@ async def _run_job_async(j: Job):
403 456
404 status = hist.get("status") or {} 457 status = hist.get("status") or {}
405 if status.get("status_str") == "error": 458 if status.get("status_str") == "error":
406 - # 2026-08-23: 之前截前2000字符——Python异常堆栈的关键结论行(比如真正的  
407 - # "CUDA out of memory")在traceback最后,截头会把它切没,console上只能看到一堆  
408 - # 无意义的调用栈开头。改成截尾,真正有用的报错原因才留得下来。  
409 - raise RuntimeError(f"ComfyUI execution_error: {json.dumps(status.get('messages', []), ensure_ascii=False)[-2000:]}") 459 + raise RuntimeError(f"ComfyUI execution_error: {_extract_error_summary(status.get('messages', []))}")
410 460
411 # 视频文件在 outputs.<save节点id>.images[] 里,不是 "videos" key——ComfyUI 内部历史遗留命名 461 # 视频文件在 outputs.<save节点id>.images[] 里,不是 "videos" key——ComfyUI 内部历史遗留命名
412 save_out = hist["outputs"]["n_save"]["images"][0] 462 save_out = hist["outputs"]["n_save"]["images"][0]
@@ -426,6 +476,10 @@ def worker(): @@ -426,6 +476,10 @@ def worker():
426 j = JOBS.get(jid) 476 j = JOBS.get(jid)
427 if j is None: 477 if j is None:
428 continue 478 continue
  479 + if j.status == "cancelled":
  480 + # 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑
  481 + Q.task_done()
  482 + continue
429 j.status = "running" 483 j.status = "running"
430 j.started_at = time.time() 484 j.started_at = time.time()
431 _persist(j) 485 _persist(j)
@@ -491,6 +545,39 @@ def create_video(req: GenReq): @@ -491,6 +545,39 @@ def create_video(req: GenReq):
491 "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL} 545 "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL}
492 546
493 547
  548 +@app.post("/v1/videos/{jid}/cancel")
  549 +def cancel_video(jid: str):
  550 + j = JOBS.get(jid)
  551 + if j is None:
  552 + raise HTTPException(404, "无此任务")
  553 + if j.status == "queued":
  554 + with LOCK:
  555 + j.status = "cancelled"
  556 + j.finished_at = time.time()
  557 + j.error = "用户手动取消"
  558 + _persist(j)
  559 + return j.model_dump()
  560 + if j.status == "running":
  561 + # 2026-08-24实测确认:ComfyUI的/interrupt打断不了Raylight——它的实际计算跑在独立的
  562 + # Ray子进程里(FSDP分布式),根本不检查ComfyUI主进程的打断标志位(打断前后GPU一直
  563 + # 100%利用率,采样步数照样往前走,完全没停)。唯一能真正把GPU立刻收回来的办法是硬
  564 + # 重启整个服务,跟"actor崩溃/卡死"的恢复手段一样——不假装能优雅停止。
  565 + with LOCK:
  566 + j.status = "cancelled"
  567 + j.finished_at = time.time()
  568 + j.error = "用户手动停止(硬重启GPU——Raylight的Ray子进程收不到ComfyUI的/interrupt信号,见notes.md)"
  569 + _persist(j)
  570 + # sleep 2 秒让这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启,
  571 + # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。
  572 + subprocess.Popen(
  573 + ["bash", "-c", "sleep 2 && bash /data/models/raylight/start.sh "
  574 + ">> /data/models/raylight/stop-restart.log 2>&1"],
  575 + start_new_session=True,
  576 + )
  577 + return j.model_dump()
  578 + raise HTTPException(409, f"任务当前状态是 {j.status},无法取消/停止")
  579 +
  580 +
494 @app.get("/v1/videos/{jid}") 581 @app.get("/v1/videos/{jid}")
495 def get_video(jid: str): 582 def get_video(jid: str):
496 j = JOBS.get(jid) 583 j = JOBS.get(jid)