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 15 ## 2026-08-23(晚)— H3 官方案例测试 + 提示词写作 skill(`h3-prompt-writer`)搭建与 benchmark 收敛
4 16  
5 17 **结果**:为了系统性验证 raylight 复现 MiniMax H3 官方案例效果,做了两件事:
... ...
h3-console/server.py
... ... @@ -28,6 +28,8 @@ DB_PATH = BASE / &quot;console.sqlite3&quot;
28 28 GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000")
29 29 CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README
30 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 33 ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1"
32 34 # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名),
33 35 # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。
... ... @@ -86,23 +88,39 @@ init_db()
86 88 # api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用
87 89 # urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」,
88 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 96 @router.post("/v1/assets")
90 97 async def upload_asset(file: UploadFile = File(...)):
91 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 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 114 aid = uuid.uuid4().hex[:16]
98 115 path = ASSETS / f"{aid}{ext}"
99 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 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 144 # 尝试提交给 gateway;提交不上(503/429)就留在本地队列里,交给下面的 reconciler_loop 每隔几秒重试,
127 145 # 弹性调度器看到的 pending 计数正是这些「还没提交上去」的本地排队任务。
128 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 162 def _row_view(row) -> dict:
... ... @@ -220,7 +248,7 @@ async def _refresh_from_gateway(cid: str, gateway_id: str):
220 248 if row is None:
221 249 return
222 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 252 return # 已经是终态,不要覆盖
225 253 view["status"] = "failed"
226 254 view["error"] = "任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展"
... ... @@ -259,6 +287,41 @@ async def create_video(req: dict):
259 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 325 @router.get("/v1/videos/{jid}")
263 326 async def get_video(jid: str):
264 327 with db() as con:
... ...
h3-console/static/index.html
... ... @@ -48,9 +48,11 @@
48 48 button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; }
49 49 button.submit:disabled { opacity: .5; cursor: default; }
50 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 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 56 .err { color: #c33; font-size: 13px; margin-top: 8px; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace;
55 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 101 </div>
100 102 <div class="hint" id="text-hint"></div>
101 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 105 <div><label>步数</label><select id="text-steps"></select></div>
104 106 <div><label>Seed</label><select id="text-seed">
105 107 <option value="0" selected>0</option>
... ... @@ -136,7 +138,7 @@
136 138 </div>
137 139 <div class="hint" id="fl2va-hint"></div>
138 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 142 <div><label>步数</label><select id="fl2va-steps"></select></div>
141 143 <div><label>Seed</label><select id="fl2va-seed">
142 144 <option value="0" selected>0</option>
... ... @@ -165,13 +167,19 @@
165 167 <label>参考图(可多选,没有张数上限,越多序列越长越慢)</label>
166 168 <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti()">
167 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 176 <div class="row">
169 177 <div><label>分辨率</label><select id="ref2va-res" class="res-select"></select></div>
170 178 <div><label>时长</label><select id="ref2va-dur" class="dur-select"></select></div>
171 179 </div>
172 180 <div class="hint" id="ref2va-hint"></div>
173 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 183 <div><label>步数</label><select id="ref2va-steps"></select></div>
176 184 <div><label>Seed</label><select id="ref2va-seed">
177 185 <option value="0" selected>0</option>
... ... @@ -204,7 +212,7 @@
204 212 // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。
205 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 216 let jobsCache = [];
209 217 let trashCache = [];
210 218 let viewingTrash = false;
... ... @@ -293,7 +301,7 @@ function initParamSelects(prefix) {
293 301 const res = document.getElementById(prefix + '-res');
294 302 const dur = document.getElementById(prefix + '-dur');
295 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 305 if (res) { syncDurOptions(prefix); res.onchange = () => syncDurOptions(prefix); }
298 306  
299 307 const turboSelect = document.getElementById(prefix + '-turbo');
... ... @@ -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 375 // 分辨率/时长/Turbo/步数/Seed 五个字段,三个 tab 结构完全一样,抽出来一份读取逻辑。
341 376 function readParamSelects(prefix) {
342 377 const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value);
... ... @@ -371,10 +406,14 @@ async function submitJob(kind) {
371 406 ...readParamSelects('fl2va'),
372 407 };
373 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 412 body = {
376 413 prompt: document.getElementById('ref2va-prompt').value,
377 414 reference_image_urls: uploaded['ref2va-refs'],
  415 + reference_video_urls: uploaded['ref2va-videos'],
  416 + reference_audio_urls: uploaded['ref2va-audios'],
378 417 ...readParamSelects('ref2va'),
379 418 };
380 419 }
... ... @@ -398,7 +437,7 @@ function setThumbs(elId, urls) {
398 437  
399 438 function prefillCreate(req) {
400 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 442 document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === kind));
404 443 ['text', 'fl2va', 'ref2va'].forEach(t => document.getElementById('tab-' + t).style.display = (t === kind ? 'block' : 'none'));
... ... @@ -438,7 +477,11 @@ function prefillCreate(req) {
438 477 setThumbs('fl2va-thumbs', [req.first_frame_url, req.last_frame_url]);
439 478 } else if (kind === 'ref2va') {
440 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 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 487 showCreate();
... ... @@ -453,8 +496,23 @@ async function rerunJob(id) {
453 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 514 function statusLabel(s) {
457   - const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败' };
  515 + const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
458 516 return `<span class="status-${s}">${map[s] || s}</span>`;
459 517 }
460 518  
... ... @@ -608,6 +666,8 @@ async function refreshDetail() {
608 666 resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'&lt;')}</div>`;
609 667 } else if (j.status === 'running') {
610 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 671 } else {
612 672 resultHtml = `<div>排队中,等待可用的 ${j.partition || ''} 后端…</div>`;
613 673 }
... ... @@ -617,12 +677,17 @@ async function refreshDetail() {
617 677 if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]);
618 678 if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]);
619 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 687 box.innerHTML = `
623 688 <div class="back" onclick="showCreate()">← 新建任务</div>
624 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 691 <dl class="dl">
627 692 <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'&lt;')}</dd>
628 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 121 结果,这段自愈逻辑现在是死代码。真机测试中一次 OOM 后 GPU1/2/3 卡在接近满载没释放,
122 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 146 - `docs/todo/raylight-comfyui-multi-gpu-eval.md`——完整调研+验证+生产部署过程,踩过的坑
... ...
raylight/scripts/server.py
... ... @@ -16,6 +16,7 @@ import os
16 16 import queue
17 17 import re
18 18 import sqlite3
  19 +import subprocess
19 20 import threading
20 21 import time
21 22 import urllib.parse
... ... @@ -51,6 +52,21 @@ COMFYUI_LOG_PATH = Path(&quot;/data/models/raylight/comfyui-server.log&quot;)
51 52 # steps 的那次匹配,因为同一句"Model Initializing"文案在 FSDP 初始化阶段也会打印、但那里的
52 53 # 总数是 GPU 数(4),跟真正的采样步数(这里的 steps 字段)容易混淆。
53 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 71 # 每次提交必须逐字节相同才能让 ComfyUI 跳过重建集群——不要给任何一个请求字段改这几个值。
56 72 RAY_INIT_CFG = {
... ... @@ -108,6 +124,12 @@ class GenReq(BaseModel):
108 124 first_frame_url: Optional[str] = None
109 125 last_frame_url: Optional[str] = None
110 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 134 @model_validator(mode="after")
113 135 def _check_known_resolution_ceiling(self):
... ... @@ -124,7 +146,7 @@ class GenReq(BaseModel):
124 146  
125 147 class Job(BaseModel):
126 148 id: str
127   - status: Literal["queued", "running", "done", "failed"]
  149 + status: Literal["queued", "running", "done", "failed", "cancelled"]
128 150 req: GenReq
129 151 created_at: float
130 152 started_at: Optional[float] = None
... ... @@ -183,8 +205,16 @@ def _load_jobs():
183 205 j = Job.model_validate_json(row["data"])
184 206 except Exception:
185 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 218 j.status = "failed"
189 219 j.error = "进程重启导致这条任务的执行状态丢失,无法恢复"
190 220 j.finished_at = time.time()
... ... @@ -195,19 +225,27 @@ def _load_jobs():
195 225  
196 226  
197 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 229 return "ref2va"
200 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 246 fname = f"{uuid.uuid4().hex}{ext}"
209 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 249 f.write(resp.read())
212 250 return fname
213 251  
... ... @@ -274,6 +312,21 @@ def build_ref2va_graph(req: GenReq) -&gt; dict:
274 312 nid = f"n_ref{i}"
275 313 g[nid] = {"class_type": "LoadImage", "inputs": {"image": fname}}
276 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 330 g["n_cond"] = {"class_type": "MiniMaxH3ReferenceToVideo", "inputs": conditioning_inputs}
278 331  
279 332 _append_sampling_tail(g, req, positive_node="n_cond")
... ... @@ -403,10 +456,7 @@ async def _run_job_async(j: Job):
403 456  
404 457 status = hist.get("status") or {}
405 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 461 # 视频文件在 outputs.<save节点id>.images[] 里,不是 "videos" key——ComfyUI 内部历史遗留命名
412 462 save_out = hist["outputs"]["n_save"]["images"][0]
... ... @@ -426,6 +476,10 @@ def worker():
426 476 j = JOBS.get(jid)
427 477 if j is None:
428 478 continue
  479 + if j.status == "cancelled":
  480 + # 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑
  481 + Q.task_done()
  482 + continue
429 483 j.status = "running"
430 484 j.started_at = time.time()
431 485 _persist(j)
... ... @@ -491,6 +545,39 @@ def create_video(req: GenReq):
491 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 581 @app.get("/v1/videos/{jid}")
495 582 def get_video(jid: str):
496 583 j = JOBS.get(jid)
... ...