Commit fc46beb9b7bb22db96813ef38cf854283bb7b32d
1 parent
3c59ced1
raylight: 修复中文参考文件名 UnicodeEncodeError,加排队优先级/插队,重启不再清空队列
任务 f2de718e1e30(参考视频文件名带中文)秒失败:h3-console 上传时的文件名清洗用了
Python 3 默认的 Unicode \w,中文被当合法字符保留,资源 URL 里带着原始中文传给
urllib.request.urlopen,http.client 构造请求行要求纯 ASCII 直接炸了。改成请求前对
URL 的 path 部分 quote 一遍。
排查时发现 raylight 重启会把所有排队中的任务直接标记 cancelled(内存队列重启即清空,
sqlite 里的 queued 记录没有任何路径被重新入队)。改成 Job 加 queue_seq、Q 换成
PriorityQueue,重启时排队任务原样保留、按序重新入队;顺带加了 POST
/v1/videos/{jid}/prioritize 插队接口和对应的 h3-console 代理 + 前端"优先插队"按钮。
另外把 8月24日提交过、但实际从未部署到服务器的 ComfyUI 存活探测(_comfyui_alive)
重新加回,并解决了 raylight/server.py 和 raylight/scripts/server.py 长期分叉的问题。
已在服务器上验证:重启后 17 条排队任务原样保留、顺序不变;插队接口直接测试和通过
console 代理都验证通过;/health 的 comfy_alive 检查确认生效。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PA1AWh8e7wJ3vyhKBAEQux
Showing
5 changed files
with
224 additions
and
27 deletions
CHANGELOG.md
| 1 | 1 | # 变更记录 |
| 2 | 2 | |
| 3 | +## 2026-09-03 — raylight 修复中文参考文件名 UnicodeEncodeError + 排队优先级/插队 + 重启不再清空队列 | |
| 4 | + | |
| 5 | +**触发**:任务 `f2de718e1e30`(ref2va,带一个中文文件名的参考视频)秒失败,报 | |
| 6 | +`UnicodeEncodeError: 'ascii' codec can't encode characters in position 30-32`。 | |
| 7 | + | |
| 8 | +**根因**:h3-console 上传时的文件名清洗(`re.sub(r"[^\w.-]+", "_", ...)`)用的是 Python 3 | |
| 9 | +默认的 Unicode `\w`,中文字符被当"合法单词字符"保留,磁盘文件名和资源 URL 里都留着原始中文 | |
| 10 | +(如 `.../assets/e31b708ce7084928__第二段_短片_002.mp4`)。这个 URL 原样传给 raylight | |
| 11 | +`server.py` 的 `_fetch_to_input()`,里面用 `urllib.request.urlopen(url)` 直接下载—— | |
| 12 | +`http.client` 构造请求行时要求整行必须是纯 ASCII,中文文件名没做 percent-encode 直接炸了。 | |
| 13 | +数了一下"第二段"三个字正好落在报错说的 position 30-32,实锤。 | |
| 14 | + | |
| 15 | +**修复1(编码 bug)**:`_fetch_to_input` 在请求前用 `urllib.parse.quote` 对 URL 的 path 部分 | |
| 16 | +percent-encode 一遍,跟上游怎么生成文件名无关,任何非 ASCII 文件名都不会再炸。 | |
| 17 | + | |
| 18 | +**顺带发现并修复的架构问题**:排查时发现 raylight 重启(`start.sh`,真杀进程重启,不是热 | |
| 19 | +reload)会把所有**排队中**的任务直接标记 `cancelled`(`_load_jobs()` 里的兜底逻辑),因为 | |
| 20 | +排队队列 `Q = queue.Queue()` 是进程内对象,重启后是全新空队列,没有任何地方把 sqlite 里 | |
| 21 | +记录的 queued 任务重新塞回去。发现的时候正好撞上 20 条排队任务攒在那,重启会连带取消掉。 | |
| 22 | +用户判断"排队中的任务本来就没执行过,没有理由不能恢复"——同意,改法: | |
| 23 | + | |
| 24 | +- `Job` 加 `queue_seq`(`time.time_ns()`,决定排队顺序),`Q` 从 `queue.Queue()` 换成 | |
| 25 | + `queue.PriorityQueue()`,存 `(queue_seq, jid)`。 | |
| 26 | +- `_load_jobs()` 里 `queued` 状态不再改 `cancelled`,原样保留、按 `queue_seq` 重新入队; | |
| 27 | + 旧记录没有 `queue_seq`字段(默认0)时用 `created_at` 补一个值,避免这批旧记录退化成按 | |
| 28 | + jid 字符串排序。`running` 状态还是标 `failed`——这个救不回来,GPU/ComfyUI 执行进度真丢了。 | |
| 29 | +- 新增 `POST /v1/videos/{jid}/prioritize`(插队):把该任务 `queue_seq` 改成比当前所有排队 | |
| 30 | + 任务都小的值,重新入队;worker 弹出任务时核对 `queue_seq` 是否还等于弹出值,不等说明是 | |
| 31 | + 插队前的旧 entry,跳过(decrease-key 的标准兜底写法,不用真的从堆里摘除旧 entry)。 | |
| 32 | + `GET /v1/videos/{jid}` 和插队接口的返回值都带 `queue_position`(1-based,前面还有几条)。 | |
| 33 | +- h3-console `server.py` 加对应代理接口,`static/index.html` 任务列表"⋯"菜单和详情页都加了 | |
| 34 | + "优先插队"按钮(只在 `status==queued` 时出现),点击后 alert 显示插到第几位。 | |
| 35 | + | |
| 36 | +**顺带修复的另一个遗留漂移**:8月24日提交 `c6815d5`(ComfyUI 存活探测 `_comfyui_alive`,修 | |
| 37 | +`/health` 在 ComfyUI 进程崩溃后仍骗人报 ready 的盲区)核实后发现**从未真正部署到服务器** | |
| 38 | +(所有历史备份文件里都没有这段代码)——`/health` 一直是有这个监控盲区的。用户确认后重新加回 | |
| 39 | +`server.py`。同时理清了本地 `raylight/server.py`(顶层,git 只有 8/24 那一次提交,之后没再 | |
| 40 | +同步)和 `raylight/scripts/server.py`(9/1 同步过一次,但也停在 9/1)两份文件长期分叉的问题 | |
| 41 | +(`CHANGELOG.md` 9/1 那节已经记过但没处理)——两份现已同步成服务器当前内容,一致。 | |
| 42 | + | |
| 43 | +**部署**:三处改动一次性写进服务器 `/data/models/raylight/server.py`,等当时正在跑的任务 | |
| 44 | +(`9bc15718b3ea`,帧数243/步数20,约287秒跑完)自然结束后立即 `start.sh` 重启(h3-console | |
| 45 | +8080/8081 因为不碰 GPU/队列,改完当场重启,没等)。 | |
| 46 | + | |
| 47 | +**结果(实测)**:重启前 18 条排队+1条运行中,重启后 **17 条排队任务原样保留、顺序不变** | |
| 48 | +(只有卡在重启瞬间那一条本来就"从queued刚变running"的任务按预期判了 failed,运行中的执行 | |
| 49 | +状态确实救不回来,这是设计内代价,不是bug)。插队接口验证:对排在最后的任务调用 | |
| 50 | +`/prioritize`,直接跳到 `queue_position: 1`;通过 h3-console 代理(`/v1/videos/{id}/prioritize`, | |
| 51 | +带 Bearer token)同样验证通过。`/health` 的 `comfy_alive` 检查确认生效(ComfyUI 存活时 | |
| 52 | +`model: "ready"`)。 | |
| 53 | + | |
| 3 | 54 | ## 2026-09-01 — h3-console 三个新建表单默认档位改成竖屏 480×832/10s/Turbo关/20步 |
| 4 | 55 | |
| 5 | 56 | 用户要求把"新建任务"默认参数从横屏改成竖屏,三个 tab(文生视频/首尾帧/参考图)统一改。 | ... | ... |
h3-console/server.py
| ... | ... | @@ -408,6 +408,32 @@ async def cancel_video(jid: str): |
| 408 | 408 | return _row_view(row) |
| 409 | 409 | |
| 410 | 410 | |
| 411 | +@api.post("/v1/videos/{jid}/prioritize") | |
| 412 | +async def prioritize_video(jid: str): | |
| 413 | + with db() as con: | |
| 414 | + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() | |
| 415 | + if row is None: | |
| 416 | + raise HTTPException(404, "无此任务") | |
| 417 | + if row["status"] != "queued": | |
| 418 | + raise HTTPException(409, f"任务当前状态是 {row['status']},只有排队中的任务能插队") | |
| 419 | + if not row["gateway_id"]: | |
| 420 | + raise HTTPException(409, "任务还没提交给后端,稍后再试") | |
| 421 | + try: | |
| 422 | + resp = await client.post(f"{GATEWAY_URL}/v1/videos/{row['gateway_id']}/prioritize") | |
| 423 | + except httpx.HTTPError: | |
| 424 | + raise HTTPException(502, "插队请求发不到后端,稍后再试") | |
| 425 | + if resp.status_code >= 400: | |
| 426 | + raise HTTPException(resp.status_code, resp.text) | |
| 427 | + job = resp.json() | |
| 428 | + job["id"] = jid | |
| 429 | + with db() as con: | |
| 430 | + con.execute( | |
| 431 | + "UPDATE jobs SET updated_at=?, last_known_json=? WHERE id=?", | |
| 432 | + (time.time(), json.dumps(job, ensure_ascii=False), jid), | |
| 433 | + ) | |
| 434 | + return job | |
| 435 | + | |
| 436 | + | |
| 411 | 437 | @api.get("/v1/videos/{jid}") |
| 412 | 438 | async def get_video(jid: str): |
| 413 | 439 | with db() as con: | ... | ... |
h3-console/static/index.html
| ... | ... | @@ -887,6 +887,7 @@ function renderSidebar() { |
| 887 | 887 | <div class="jr-prompt" title="${prompt}">${prompt}</div> |
| 888 | 888 | <div class="jr-time">${timeAgo(j.created_at)}</div> |
| 889 | 889 | ${menuOpen ? `<div class="jr-menu" onclick="event.stopPropagation()"> |
| 890 | + ${j.status === 'queued' ? `<div class="jr-menu-item" onclick="prioritizeJob('${j.id}')">优先插队</div>` : ''} | |
| 890 | 891 | <div class="jr-menu-item" onclick="deleteJob('${j.id}')">删除</div> |
| 891 | 892 | </div>` : ''}`; |
| 892 | 893 | box.appendChild(row); |
| ... | ... | @@ -913,6 +914,18 @@ function toggleJobMenu(id) { |
| 913 | 914 | renderSidebar(); |
| 914 | 915 | } |
| 915 | 916 | |
| 917 | +async function prioritizeJob(id) { | |
| 918 | + openMenuFor = null; | |
| 919 | + try { | |
| 920 | + const r = await fetch(`${H3_BASE}/v1/videos/${id}/prioritize`, { method: 'POST' }); | |
| 921 | + if (!r.ok) throw new Error(await r.text()); | |
| 922 | + const job = await r.json(); | |
| 923 | + alert(`已插队到第 ${job.queue_position} 位`); | |
| 924 | + await refreshJobs(); | |
| 925 | + if (selectedId === id) await refreshDetail(); | |
| 926 | + } catch (e) { alert('插队失败: ' + e.message); } | |
| 927 | +} | |
| 928 | + | |
| 916 | 929 | async function deleteJob(id) { |
| 917 | 930 | openMenuFor = null; |
| 918 | 931 | try { |
| ... | ... | @@ -1015,7 +1028,7 @@ async function refreshDetail() { |
| 1015 | 1028 | box.innerHTML = ` |
| 1016 | 1029 | <div class="back" onclick="showCreate()">← 新建任务</div> |
| 1017 | 1030 | <h2>${j.id}</h2> |
| 1018 | - <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> | |
| 1031 | + <div>${statusLabel(j.status)}${j.status === 'queued' && j.queue_position ? ` <span style="opacity:.7;font-size:13px">排第 ${j.queue_position} 位</span>` : ''} <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="prioritizeJob('${j.id}')">优先插队</button> <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> | |
| 1019 | 1032 | <dl class="dl"> |
| 1020 | 1033 | <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'<')}</dd> |
| 1021 | 1034 | <dt>分区</dt><dd>${j.partition || '—'}</dd> | ... | ... |
raylight/scripts/server.py
| ... | ... | @@ -165,10 +165,14 @@ class Job(BaseModel): |
| 165 | 165 | partition: str = "ref2va" |
| 166 | 166 | comfy_prompt_id: Optional[str] = None |
| 167 | 167 | backend: str = BACKEND_URL |
| 168 | + queue_seq: int = 0 # 2026-09-03: 排队顺序+插队用,见 _queue_position/prioritize_video | |
| 168 | 169 | |
| 169 | 170 | |
| 170 | 171 | JOBS: dict[str, Job] = {} |
| 171 | -Q: "queue.Queue[str]" = queue.Queue() | |
| 172 | +# 2026-09-03: 从 queue.Queue 换成 PriorityQueue(seq, jid),支持插队——"插队"就是把某条任务的 | |
| 173 | +# queue_seq 改小再塞一份新entry回去,worker弹出时核对 queue_seq 是否还等于当前值,不等就是 | |
| 174 | +# 被插队指令淘汰的旧entry,跳过(decrease-key的标准兜底写法,不需要真的从堆里摘除旧entry)。 | |
| 175 | +Q: "queue.PriorityQueue[tuple[int, str]]" = queue.PriorityQueue() | |
| 172 | 176 | LOCK = threading.Lock() |
| 173 | 177 | STATE = {"model": "loading", "loaded_at": None} |
| 174 | 178 | |
| ... | ... | @@ -216,10 +220,15 @@ def _load_jobs(): |
| 216 | 220 | # 执行过程可言,"failed"意味着跑起来又出错了,这两者不是一回事。真的在跑到一半被打断的 |
| 217 | 221 | # 才算failed。 |
| 218 | 222 | if j.status == "queued": |
| 219 | - j.status = "cancelled" | |
| 220 | - j.error = "进程重启导致这条排队中的任务被取消,需要重新提交" | |
| 221 | - j.finished_at = time.time() | |
| 222 | - _persist(j) | |
| 223 | + # 2026-09-03: 之前这里直接判cancelled——但排队中的任务根本没开始执行,没有任何 | |
| 224 | + # 执行状态会丢失,没理由不能恢复。现在改成原样保留queued,重新塞回新队列, | |
| 225 | + # 用户重新提交这一步就省了(案例:一次重启差点连带取消20条排队任务)。 | |
| 226 | + if j.queue_seq == 0: | |
| 227 | + # 兼容加 queue_seq 字段之前创建的旧记录:用 created_at 补一个值,不然这批 | |
| 228 | + # 旧记录全平局在 0,重新入队时会退化成按 jid 字符串排序,原本的先后顺序就乱了 | |
| 229 | + j.queue_seq = int(j.created_at * 1e9) | |
| 230 | + _persist(j) | |
| 231 | + Q.put((j.queue_seq, j.id)) | |
| 223 | 232 | elif j.status == "running": |
| 224 | 233 | j.status = "failed" |
| 225 | 234 | j.error = "进程重启导致这条任务的执行状态丢失,无法恢复" |
| ... | ... | @@ -246,12 +255,16 @@ _INPUT_EXTS = { |
| 246 | 255 | def _fetch_to_input(url: str, kind: str = "image") -> str: |
| 247 | 256 | """下载一个文件到 ComfyUI 的 input/ 目录,LoadImage/LoadVideo/LoadAudio 节点按文件名读取。""" |
| 248 | 257 | allowed = _INPUT_EXTS[kind] |
| 249 | - ext = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower() | |
| 258 | + parsed = urllib.parse.urlsplit(url) | |
| 259 | + ext = os.path.splitext(parsed.path)[1].lower() | |
| 250 | 260 | if ext not in allowed: |
| 251 | 261 | ext = allowed[0] |
| 252 | 262 | fname = f"{uuid.uuid4().hex}{ext}" |
| 253 | 263 | dest = INPUT_DIR / fname |
| 254 | - with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as f: | |
| 264 | + # 2026-09-03: 参考图/参考视频的 URL 可能带非 ASCII 文件名(比如中文),http.client 的请求行 | |
| 265 | + # 必须是纯 ASCII,不 quote 会在 urlopen 里直接炸 UnicodeEncodeError(案例 f2de718e1e30)。 | |
| 266 | + safe_url = urllib.parse.urlunsplit(parsed._replace(path=urllib.parse.quote(parsed.path))) | |
| 267 | + with urllib.request.urlopen(safe_url, timeout=120) as resp, open(dest, "wb") as f: | |
| 255 | 268 | f.write(resp.read()) |
| 256 | 269 | return fname |
| 257 | 270 | |
| ... | ... | @@ -478,9 +491,11 @@ def worker(): |
| 478 | 491 | STATE["loaded_at"] = time.time() |
| 479 | 492 | loop = asyncio.new_event_loop() |
| 480 | 493 | while True: |
| 481 | - jid = Q.get() | |
| 494 | + seq, jid = Q.get() | |
| 482 | 495 | j = JOBS.get(jid) |
| 483 | - if j is None: | |
| 496 | + if j is None or j.queue_seq != seq: | |
| 497 | + # queue_seq对不上=这个entry是插队前的旧entry,已经被更小的新entry取代了,跳过 | |
| 498 | + Q.task_done() | |
| 484 | 499 | continue |
| 485 | 500 | if j.status == "cancelled": |
| 486 | 501 | # 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑 |
| ... | ... | @@ -538,13 +553,28 @@ def _gpu_status() -> list: |
| 538 | 553 | return gpus |
| 539 | 554 | |
| 540 | 555 | |
| 556 | +def _comfyui_alive() -> bool: | |
| 557 | + # 2026-08-24:这之前是唯一的健康检查盲区——/health 只看 STATE["model"](worker()线程 | |
| 558 | + # 启动时设一次"ready",之后再也不更新),ComfyUI(8188)本体真的崩了,这层壳(server.py) | |
| 559 | + # 没死的话 /health 会一直报 ready。实测过一次:8188 进程在一次OOM后整个退出,/health | |
| 560 | + # 骗了将近20分钟才被人工发现,期间提交的任务全部秒失败(ConnectError)。改成每次 /health | |
| 561 | + # 都真的探一下 8188 活不活,跟 start.sh 自己的就绪检测用同一个端点(/system_stats)。 | |
| 562 | + try: | |
| 563 | + r = httpx.get(f"{COMFY_BASE}/system_stats", timeout=2.0) | |
| 564 | + return r.status_code == 200 | |
| 565 | + except Exception: | |
| 566 | + return False | |
| 567 | + | |
| 568 | + | |
| 541 | 569 | @app.get("/health") |
| 542 | 570 | def health(): |
| 543 | 571 | running = sum(1 for j in JOBS.values() if j.status == "running") |
| 544 | - ready = 1 if STATE["model"] == "ready" else 0 | |
| 572 | + comfy_alive = _comfyui_alive() | |
| 573 | + ready = 1 if (STATE["model"] == "ready" and comfy_alive) else 0 | |
| 574 | + model_state = STATE["model"] if comfy_alive else "comfyui_unreachable" | |
| 545 | 575 | backend = { |
| 546 | 576 | "status": "ok" if ready else "loading", |
| 547 | - "model": STATE["model"], | |
| 577 | + "model": model_state, | |
| 548 | 578 | "gpu": "0,1,2,3", |
| 549 | 579 | "partition": "raylight-fsdp", # 不再是路由依据,纯展示——4卡是一个集群,没有单卡分区概念了 |
| 550 | 580 | "offload": "fsdp", |
| ... | ... | @@ -567,14 +597,40 @@ def create_video(req: GenReq): |
| 567 | 597 | raise HTTPException(400, "turbo 模式建议 steps<=8(当前实现turbo LoRA还没接,steps按原样生效)") |
| 568 | 598 | jid = uuid.uuid4().hex[:12] |
| 569 | 599 | partition = _partition_of(req) |
| 600 | + seq = time.time_ns() | |
| 570 | 601 | with LOCK: |
| 571 | - JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), partition=partition) | |
| 602 | + JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), | |
| 603 | + partition=partition, queue_seq=seq) | |
| 572 | 604 | _persist(JOBS[jid]) |
| 573 | - Q.put(jid) | |
| 574 | - return {"id": jid, "status": "queued", "queue_position": Q.qsize(), | |
| 605 | + Q.put((seq, jid)) | |
| 606 | + return {"id": jid, "status": "queued", "queue_position": _queue_position(JOBS[jid]), | |
| 575 | 607 | "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL} |
| 576 | 608 | |
| 577 | 609 | |
| 610 | +def _queue_position(j: Job) -> Optional[int]: | |
| 611 | + """这条任务前面还有几条排队中的任务,1-based;不是queued状态返回None。""" | |
| 612 | + if j.status != "queued": | |
| 613 | + return None | |
| 614 | + return 1 + sum(1 for x in JOBS.values() if x.status == "queued" and x.queue_seq < j.queue_seq) | |
| 615 | + | |
| 616 | + | |
| 617 | +@app.post("/v1/videos/{jid}/prioritize") | |
| 618 | +def prioritize_video(jid: str): | |
| 619 | + """插队:把这条任务的排队顺序改到当前排在最前面,返回插队后的位置。""" | |
| 620 | + with LOCK: | |
| 621 | + j = JOBS.get(jid) | |
| 622 | + if j is None: | |
| 623 | + raise HTTPException(404, "无此任务") | |
| 624 | + if j.status != "queued": | |
| 625 | + raise HTTPException(409, f"任务当前状态是 {j.status},只有排队中的任务能插队") | |
| 626 | + others = [x.queue_seq for x in JOBS.values() if x.status == "queued" and x.id != jid] | |
| 627 | + new_seq = (min(others) - 1) if others else time.time_ns() | |
| 628 | + j.queue_seq = new_seq | |
| 629 | + _persist(j) | |
| 630 | + Q.put((new_seq, jid)) | |
| 631 | + return {"id": jid, "status": "queued", "queue_position": _queue_position(j)} | |
| 632 | + | |
| 633 | + | |
| 578 | 634 | def _trigger_restart_async(): |
| 579 | 635 | # sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启, |
| 580 | 636 | # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。 |
| ... | ... | @@ -635,6 +691,8 @@ def get_video(jid: str): |
| 635 | 691 | if j is None: |
| 636 | 692 | raise HTTPException(404, "无此任务") |
| 637 | 693 | out = j.model_dump() |
| 694 | + if j.status == "queued": | |
| 695 | + out["queue_position"] = _queue_position(j) | |
| 638 | 696 | if j.status == "running" and j.started_at: |
| 639 | 697 | out["elapsed_seconds"] = time.time() - j.started_at |
| 640 | 698 | if j.status == "done": | ... | ... |
raylight/server.py
| ... | ... | @@ -96,11 +96,17 @@ RAY_INIT_CFG = { |
| 96 | 96 | # 或画质边界(448×256,见notes.md——技术上测到3000帧都没OOM,是用户看过实际输出判定50秒往后 |
| 97 | 97 | # 画质不能看,不是显存拦的)。跟 h3-console 前端 RESOLUTIONS 里的 maxFrames 保持一致—— |
| 98 | 98 | # 两边算法对不上就会出现"前端放行、后端拒绝"或反过来,数字改动两边一起改。 |
| 99 | +# 2026-09-01:竖屏(宽高互换)条目沿用对应横屏档位的实测上限——总像素数不变,显存/耗时特征预期 | |
| 100 | +# 一致,但没有专门为竖屏重跑过 OOM 边界测试,如果实测发现竖屏更早 OOM 要单独下调,不要假设两者永远相等。 | |
| 99 | 101 | RESOLUTION_CEILINGS = { |
| 100 | 102 | (832, 480): 1125, |
| 103 | + (480, 832): 1125, | |
| 101 | 104 | (448, 256): 1200, |
| 105 | + (256, 448): 1200, | |
| 102 | 106 | (1024, 576): 743, |
| 107 | + (576, 1024): 743, | |
| 103 | 108 | (1344, 768): 412, |
| 109 | + (768, 1344): 412, | |
| 104 | 110 | } |
| 105 | 111 | CLIP_NAME = "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors" |
| 106 | 112 | VAE_VIDEO = "minimax_h3_video_vae_fp16.safetensors" |
| ... | ... | @@ -159,10 +165,14 @@ class Job(BaseModel): |
| 159 | 165 | partition: str = "ref2va" |
| 160 | 166 | comfy_prompt_id: Optional[str] = None |
| 161 | 167 | backend: str = BACKEND_URL |
| 168 | + queue_seq: int = 0 # 2026-09-03: 排队顺序+插队用,见 _queue_position/prioritize_video | |
| 162 | 169 | |
| 163 | 170 | |
| 164 | 171 | JOBS: dict[str, Job] = {} |
| 165 | -Q: "queue.Queue[str]" = queue.Queue() | |
| 172 | +# 2026-09-03: 从 queue.Queue 换成 PriorityQueue(seq, jid),支持插队——"插队"就是把某条任务的 | |
| 173 | +# queue_seq 改小再塞一份新entry回去,worker弹出时核对 queue_seq 是否还等于当前值,不等就是 | |
| 174 | +# 被插队指令淘汰的旧entry,跳过(decrease-key的标准兜底写法,不需要真的从堆里摘除旧entry)。 | |
| 175 | +Q: "queue.PriorityQueue[tuple[int, str]]" = queue.PriorityQueue() | |
| 166 | 176 | LOCK = threading.Lock() |
| 167 | 177 | STATE = {"model": "loading", "loaded_at": None} |
| 168 | 178 | |
| ... | ... | @@ -210,10 +220,15 @@ def _load_jobs(): |
| 210 | 220 | # 执行过程可言,"failed"意味着跑起来又出错了,这两者不是一回事。真的在跑到一半被打断的 |
| 211 | 221 | # 才算failed。 |
| 212 | 222 | if j.status == "queued": |
| 213 | - j.status = "cancelled" | |
| 214 | - j.error = "进程重启导致这条排队中的任务被取消,需要重新提交" | |
| 215 | - j.finished_at = time.time() | |
| 216 | - _persist(j) | |
| 223 | + # 2026-09-03: 之前这里直接判cancelled——但排队中的任务根本没开始执行,没有任何 | |
| 224 | + # 执行状态会丢失,没理由不能恢复。现在改成原样保留queued,重新塞回新队列, | |
| 225 | + # 用户重新提交这一步就省了(案例:一次重启差点连带取消20条排队任务)。 | |
| 226 | + if j.queue_seq == 0: | |
| 227 | + # 兼容加 queue_seq 字段之前创建的旧记录:用 created_at 补一个值,不然这批 | |
| 228 | + # 旧记录全平局在 0,重新入队时会退化成按 jid 字符串排序,原本的先后顺序就乱了 | |
| 229 | + j.queue_seq = int(j.created_at * 1e9) | |
| 230 | + _persist(j) | |
| 231 | + Q.put((j.queue_seq, j.id)) | |
| 217 | 232 | elif j.status == "running": |
| 218 | 233 | j.status = "failed" |
| 219 | 234 | j.error = "进程重启导致这条任务的执行状态丢失,无法恢复" |
| ... | ... | @@ -240,12 +255,16 @@ _INPUT_EXTS = { |
| 240 | 255 | def _fetch_to_input(url: str, kind: str = "image") -> str: |
| 241 | 256 | """下载一个文件到 ComfyUI 的 input/ 目录,LoadImage/LoadVideo/LoadAudio 节点按文件名读取。""" |
| 242 | 257 | allowed = _INPUT_EXTS[kind] |
| 243 | - ext = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower() | |
| 258 | + parsed = urllib.parse.urlsplit(url) | |
| 259 | + ext = os.path.splitext(parsed.path)[1].lower() | |
| 244 | 260 | if ext not in allowed: |
| 245 | 261 | ext = allowed[0] |
| 246 | 262 | fname = f"{uuid.uuid4().hex}{ext}" |
| 247 | 263 | dest = INPUT_DIR / fname |
| 248 | - with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as f: | |
| 264 | + # 2026-09-03: 参考图/参考视频的 URL 可能带非 ASCII 文件名(比如中文),http.client 的请求行 | |
| 265 | + # 必须是纯 ASCII,不 quote 会在 urlopen 里直接炸 UnicodeEncodeError(案例 f2de718e1e30)。 | |
| 266 | + safe_url = urllib.parse.urlunsplit(parsed._replace(path=urllib.parse.quote(parsed.path))) | |
| 267 | + with urllib.request.urlopen(safe_url, timeout=120) as resp, open(dest, "wb") as f: | |
| 249 | 268 | f.write(resp.read()) |
| 250 | 269 | return fname |
| 251 | 270 | |
| ... | ... | @@ -472,9 +491,11 @@ def worker(): |
| 472 | 491 | STATE["loaded_at"] = time.time() |
| 473 | 492 | loop = asyncio.new_event_loop() |
| 474 | 493 | while True: |
| 475 | - jid = Q.get() | |
| 494 | + seq, jid = Q.get() | |
| 476 | 495 | j = JOBS.get(jid) |
| 477 | - if j is None: | |
| 496 | + if j is None or j.queue_seq != seq: | |
| 497 | + # queue_seq对不上=这个entry是插队前的旧entry,已经被更小的新entry取代了,跳过 | |
| 498 | + Q.task_done() | |
| 478 | 499 | continue |
| 479 | 500 | if j.status == "cancelled": |
| 480 | 501 | # 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑 |
| ... | ... | @@ -576,14 +597,40 @@ def create_video(req: GenReq): |
| 576 | 597 | raise HTTPException(400, "turbo 模式建议 steps<=8(当前实现turbo LoRA还没接,steps按原样生效)") |
| 577 | 598 | jid = uuid.uuid4().hex[:12] |
| 578 | 599 | partition = _partition_of(req) |
| 600 | + seq = time.time_ns() | |
| 579 | 601 | with LOCK: |
| 580 | - JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), partition=partition) | |
| 602 | + JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), | |
| 603 | + partition=partition, queue_seq=seq) | |
| 581 | 604 | _persist(JOBS[jid]) |
| 582 | - Q.put(jid) | |
| 583 | - return {"id": jid, "status": "queued", "queue_position": Q.qsize(), | |
| 605 | + Q.put((seq, jid)) | |
| 606 | + return {"id": jid, "status": "queued", "queue_position": _queue_position(JOBS[jid]), | |
| 584 | 607 | "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL} |
| 585 | 608 | |
| 586 | 609 | |
| 610 | +def _queue_position(j: Job) -> Optional[int]: | |
| 611 | + """这条任务前面还有几条排队中的任务,1-based;不是queued状态返回None。""" | |
| 612 | + if j.status != "queued": | |
| 613 | + return None | |
| 614 | + return 1 + sum(1 for x in JOBS.values() if x.status == "queued" and x.queue_seq < j.queue_seq) | |
| 615 | + | |
| 616 | + | |
| 617 | +@app.post("/v1/videos/{jid}/prioritize") | |
| 618 | +def prioritize_video(jid: str): | |
| 619 | + """插队:把这条任务的排队顺序改到当前排在最前面,返回插队后的位置。""" | |
| 620 | + with LOCK: | |
| 621 | + j = JOBS.get(jid) | |
| 622 | + if j is None: | |
| 623 | + raise HTTPException(404, "无此任务") | |
| 624 | + if j.status != "queued": | |
| 625 | + raise HTTPException(409, f"任务当前状态是 {j.status},只有排队中的任务能插队") | |
| 626 | + others = [x.queue_seq for x in JOBS.values() if x.status == "queued" and x.id != jid] | |
| 627 | + new_seq = (min(others) - 1) if others else time.time_ns() | |
| 628 | + j.queue_seq = new_seq | |
| 629 | + _persist(j) | |
| 630 | + Q.put((new_seq, jid)) | |
| 631 | + return {"id": jid, "status": "queued", "queue_position": _queue_position(j)} | |
| 632 | + | |
| 633 | + | |
| 587 | 634 | def _trigger_restart_async(): |
| 588 | 635 | # sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启, |
| 589 | 636 | # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。 |
| ... | ... | @@ -644,6 +691,8 @@ def get_video(jid: str): |
| 644 | 691 | if j is None: |
| 645 | 692 | raise HTTPException(404, "无此任务") |
| 646 | 693 | out = j.model_dump() |
| 694 | + if j.status == "queued": | |
| 695 | + out["queue_position"] = _queue_position(j) | |
| 647 | 696 | if j.status == "running" and j.started_at: |
| 648 | 697 | out["elapsed_seconds"] = time.time() - j.started_at |
| 649 | 698 | if j.status == "done": | ... | ... |