Commit 4a799a9e5bb898345ca8ce065f5aaf829b0320b6
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>
Showing
5 changed files
with
287 additions
and
40 deletions
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 / "console.sqlite3" | @@ -28,6 +28,8 @@ DB_PATH = BASE / "console.sqlite3" | ||
| 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"{BASE_PATH}/assets", StaticFiles(directory=str(ASSETS)), name="asset | @@ -126,9 +144,19 @@ app.mount(f"{BASE_PATH}/assets", StaticFiles(directory=str(ASSETS)), name="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,'<')}</div>`; | 666 | resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'<')}</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,'<')}</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,'<')}</dd> | 692 | <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'<')}</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 'cd /data/models/minimax-h3 && export H3_OFFLOAD=disk & | @@ -121,6 +121,26 @@ ssh admin@192.168.110.67 'cd /data/models/minimax-h3 && export H3_OFFLOAD=disk & | ||
| 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("/data/models/raylight/comfyui-server.log") | @@ -51,6 +52,21 @@ COMFYUI_LOG_PATH = Path("/data/models/raylight/comfyui-server.log") | ||
| 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) -> dict: | @@ -274,6 +312,21 @@ def build_ref2va_graph(req: GenReq) -> 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) |