Commit fc46beb9b7bb22db96813ef38cf854283bb7b32d

Authored by yibo
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
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,'&lt;')}</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() -&gt; 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":
... ...