diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37ab32d..9e80ba7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,56 @@
# 变更记录
+## 2026-09-03 — raylight 修复中文参考文件名 UnicodeEncodeError + 排队优先级/插队 + 重启不再清空队列
+
+**触发**:任务 `f2de718e1e30`(ref2va,带一个中文文件名的参考视频)秒失败,报
+`UnicodeEncodeError: 'ascii' codec can't encode characters in position 30-32`。
+
+**根因**:h3-console 上传时的文件名清洗(`re.sub(r"[^\w.-]+", "_", ...)`)用的是 Python 3
+默认的 Unicode `\w`,中文字符被当"合法单词字符"保留,磁盘文件名和资源 URL 里都留着原始中文
+(如 `.../assets/e31b708ce7084928__第二段_短片_002.mp4`)。这个 URL 原样传给 raylight
+`server.py` 的 `_fetch_to_input()`,里面用 `urllib.request.urlopen(url)` 直接下载——
+`http.client` 构造请求行时要求整行必须是纯 ASCII,中文文件名没做 percent-encode 直接炸了。
+数了一下"第二段"三个字正好落在报错说的 position 30-32,实锤。
+
+**修复1(编码 bug)**:`_fetch_to_input` 在请求前用 `urllib.parse.quote` 对 URL 的 path 部分
+percent-encode 一遍,跟上游怎么生成文件名无关,任何非 ASCII 文件名都不会再炸。
+
+**顺带发现并修复的架构问题**:排查时发现 raylight 重启(`start.sh`,真杀进程重启,不是热
+reload)会把所有**排队中**的任务直接标记 `cancelled`(`_load_jobs()` 里的兜底逻辑),因为
+排队队列 `Q = queue.Queue()` 是进程内对象,重启后是全新空队列,没有任何地方把 sqlite 里
+记录的 queued 任务重新塞回去。发现的时候正好撞上 20 条排队任务攒在那,重启会连带取消掉。
+用户判断"排队中的任务本来就没执行过,没有理由不能恢复"——同意,改法:
+
+- `Job` 加 `queue_seq`(`time.time_ns()`,决定排队顺序),`Q` 从 `queue.Queue()` 换成
+ `queue.PriorityQueue()`,存 `(queue_seq, jid)`。
+- `_load_jobs()` 里 `queued` 状态不再改 `cancelled`,原样保留、按 `queue_seq` 重新入队;
+ 旧记录没有 `queue_seq`字段(默认0)时用 `created_at` 补一个值,避免这批旧记录退化成按
+ jid 字符串排序。`running` 状态还是标 `failed`——这个救不回来,GPU/ComfyUI 执行进度真丢了。
+- 新增 `POST /v1/videos/{jid}/prioritize`(插队):把该任务 `queue_seq` 改成比当前所有排队
+ 任务都小的值,重新入队;worker 弹出任务时核对 `queue_seq` 是否还等于弹出值,不等说明是
+ 插队前的旧 entry,跳过(decrease-key 的标准兜底写法,不用真的从堆里摘除旧 entry)。
+ `GET /v1/videos/{jid}` 和插队接口的返回值都带 `queue_position`(1-based,前面还有几条)。
+- h3-console `server.py` 加对应代理接口,`static/index.html` 任务列表"⋯"菜单和详情页都加了
+ "优先插队"按钮(只在 `status==queued` 时出现),点击后 alert 显示插到第几位。
+
+**顺带修复的另一个遗留漂移**:8月24日提交 `c6815d5`(ComfyUI 存活探测 `_comfyui_alive`,修
+`/health` 在 ComfyUI 进程崩溃后仍骗人报 ready 的盲区)核实后发现**从未真正部署到服务器**
+(所有历史备份文件里都没有这段代码)——`/health` 一直是有这个监控盲区的。用户确认后重新加回
+`server.py`。同时理清了本地 `raylight/server.py`(顶层,git 只有 8/24 那一次提交,之后没再
+同步)和 `raylight/scripts/server.py`(9/1 同步过一次,但也停在 9/1)两份文件长期分叉的问题
+(`CHANGELOG.md` 9/1 那节已经记过但没处理)——两份现已同步成服务器当前内容,一致。
+
+**部署**:三处改动一次性写进服务器 `/data/models/raylight/server.py`,等当时正在跑的任务
+(`9bc15718b3ea`,帧数243/步数20,约287秒跑完)自然结束后立即 `start.sh` 重启(h3-console
+8080/8081 因为不碰 GPU/队列,改完当场重启,没等)。
+
+**结果(实测)**:重启前 18 条排队+1条运行中,重启后 **17 条排队任务原样保留、顺序不变**
+(只有卡在重启瞬间那一条本来就"从queued刚变running"的任务按预期判了 failed,运行中的执行
+状态确实救不回来,这是设计内代价,不是bug)。插队接口验证:对排在最后的任务调用
+`/prioritize`,直接跳到 `queue_position: 1`;通过 h3-console 代理(`/v1/videos/{id}/prioritize`,
+带 Bearer token)同样验证通过。`/health` 的 `comfy_alive` 检查确认生效(ComfyUI 存活时
+`model: "ready"`)。
+
## 2026-09-01 — h3-console 三个新建表单默认档位改成竖屏 480×832/10s/Turbo关/20步
用户要求把"新建任务"默认参数从横屏改成竖屏,三个 tab(文生视频/首尾帧/参考图)统一改。
diff --git a/h3-console/server.py b/h3-console/server.py
index 98ce25b..45edf51 100644
--- a/h3-console/server.py
+++ b/h3-console/server.py
@@ -408,6 +408,32 @@ async def cancel_video(jid: str):
return _row_view(row)
+@api.post("/v1/videos/{jid}/prioritize")
+async def prioritize_video(jid: str):
+ with db() as con:
+ row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone()
+ if row is None:
+ raise HTTPException(404, "无此任务")
+ if row["status"] != "queued":
+ raise HTTPException(409, f"任务当前状态是 {row['status']},只有排队中的任务能插队")
+ if not row["gateway_id"]:
+ raise HTTPException(409, "任务还没提交给后端,稍后再试")
+ try:
+ resp = await client.post(f"{GATEWAY_URL}/v1/videos/{row['gateway_id']}/prioritize")
+ except httpx.HTTPError:
+ raise HTTPException(502, "插队请求发不到后端,稍后再试")
+ if resp.status_code >= 400:
+ raise HTTPException(resp.status_code, resp.text)
+ job = resp.json()
+ job["id"] = jid
+ with db() as con:
+ con.execute(
+ "UPDATE jobs SET updated_at=?, last_known_json=? WHERE id=?",
+ (time.time(), json.dumps(job, ensure_ascii=False), jid),
+ )
+ return job
+
+
@api.get("/v1/videos/{jid}")
async def get_video(jid: str):
with db() as con:
diff --git a/h3-console/static/index.html b/h3-console/static/index.html
index c554b7c..bbec832 100644
--- a/h3-console/static/index.html
+++ b/h3-console/static/index.html
@@ -887,6 +887,7 @@ function renderSidebar() {
${prompt}
${timeAgo(j.created_at)}
${menuOpen ? `` : ''}`;
box.appendChild(row);
@@ -913,6 +914,18 @@ function toggleJobMenu(id) {
renderSidebar();
}
+async function prioritizeJob(id) {
+ openMenuFor = null;
+ try {
+ const r = await fetch(`${H3_BASE}/v1/videos/${id}/prioritize`, { method: 'POST' });
+ if (!r.ok) throw new Error(await r.text());
+ const job = await r.json();
+ alert(`已插队到第 ${job.queue_position} 位`);
+ await refreshJobs();
+ if (selectedId === id) await refreshDetail();
+ } catch (e) { alert('插队失败: ' + e.message); }
+}
+
async function deleteJob(id) {
openMenuFor = null;
try {
@@ -1015,7 +1028,7 @@ async function refreshDetail() {
box.innerHTML = `
← 新建任务
${j.id}
- ${statusLabel(j.status)} ${j.status === 'queued' ? ` ` : ''}${j.status === 'running' ? ` ` : ''}
+ ${statusLabel(j.status)}${j.status === 'queued' && j.queue_position ? ` 排第 ${j.queue_position} 位` : ''} ${j.status === 'queued' ? ` ` : ''}${j.status === 'running' ? ` ` : ''}
- Prompt
- ${(req.prompt || '').replace(/
- 分区
- ${j.partition || '—'}
diff --git a/raylight/scripts/server.py b/raylight/scripts/server.py
index d2469fd..e0b429f 100644
--- a/raylight/scripts/server.py
+++ b/raylight/scripts/server.py
@@ -165,10 +165,14 @@ class Job(BaseModel):
partition: str = "ref2va"
comfy_prompt_id: Optional[str] = None
backend: str = BACKEND_URL
+ queue_seq: int = 0 # 2026-09-03: 排队顺序+插队用,见 _queue_position/prioritize_video
JOBS: dict[str, Job] = {}
-Q: "queue.Queue[str]" = queue.Queue()
+# 2026-09-03: 从 queue.Queue 换成 PriorityQueue(seq, jid),支持插队——"插队"就是把某条任务的
+# queue_seq 改小再塞一份新entry回去,worker弹出时核对 queue_seq 是否还等于当前值,不等就是
+# 被插队指令淘汰的旧entry,跳过(decrease-key的标准兜底写法,不需要真的从堆里摘除旧entry)。
+Q: "queue.PriorityQueue[tuple[int, str]]" = queue.PriorityQueue()
LOCK = threading.Lock()
STATE = {"model": "loading", "loaded_at": None}
@@ -216,10 +220,15 @@ def _load_jobs():
# 执行过程可言,"failed"意味着跑起来又出错了,这两者不是一回事。真的在跑到一半被打断的
# 才算failed。
if j.status == "queued":
- j.status = "cancelled"
- j.error = "进程重启导致这条排队中的任务被取消,需要重新提交"
- j.finished_at = time.time()
- _persist(j)
+ # 2026-09-03: 之前这里直接判cancelled——但排队中的任务根本没开始执行,没有任何
+ # 执行状态会丢失,没理由不能恢复。现在改成原样保留queued,重新塞回新队列,
+ # 用户重新提交这一步就省了(案例:一次重启差点连带取消20条排队任务)。
+ if j.queue_seq == 0:
+ # 兼容加 queue_seq 字段之前创建的旧记录:用 created_at 补一个值,不然这批
+ # 旧记录全平局在 0,重新入队时会退化成按 jid 字符串排序,原本的先后顺序就乱了
+ j.queue_seq = int(j.created_at * 1e9)
+ _persist(j)
+ Q.put((j.queue_seq, j.id))
elif j.status == "running":
j.status = "failed"
j.error = "进程重启导致这条任务的执行状态丢失,无法恢复"
@@ -246,12 +255,16 @@ _INPUT_EXTS = {
def _fetch_to_input(url: str, kind: str = "image") -> str:
"""下载一个文件到 ComfyUI 的 input/ 目录,LoadImage/LoadVideo/LoadAudio 节点按文件名读取。"""
allowed = _INPUT_EXTS[kind]
- ext = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower()
+ parsed = urllib.parse.urlsplit(url)
+ ext = os.path.splitext(parsed.path)[1].lower()
if ext not in allowed:
ext = allowed[0]
fname = f"{uuid.uuid4().hex}{ext}"
dest = INPUT_DIR / fname
- with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as f:
+ # 2026-09-03: 参考图/参考视频的 URL 可能带非 ASCII 文件名(比如中文),http.client 的请求行
+ # 必须是纯 ASCII,不 quote 会在 urlopen 里直接炸 UnicodeEncodeError(案例 f2de718e1e30)。
+ safe_url = urllib.parse.urlunsplit(parsed._replace(path=urllib.parse.quote(parsed.path)))
+ with urllib.request.urlopen(safe_url, timeout=120) as resp, open(dest, "wb") as f:
f.write(resp.read())
return fname
@@ -478,9 +491,11 @@ def worker():
STATE["loaded_at"] = time.time()
loop = asyncio.new_event_loop()
while True:
- jid = Q.get()
+ seq, jid = Q.get()
j = JOBS.get(jid)
- if j is None:
+ if j is None or j.queue_seq != seq:
+ # queue_seq对不上=这个entry是插队前的旧entry,已经被更小的新entry取代了,跳过
+ Q.task_done()
continue
if j.status == "cancelled":
# 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑
@@ -538,13 +553,28 @@ def _gpu_status() -> list:
return gpus
+def _comfyui_alive() -> bool:
+ # 2026-08-24:这之前是唯一的健康检查盲区——/health 只看 STATE["model"](worker()线程
+ # 启动时设一次"ready",之后再也不更新),ComfyUI(8188)本体真的崩了,这层壳(server.py)
+ # 没死的话 /health 会一直报 ready。实测过一次:8188 进程在一次OOM后整个退出,/health
+ # 骗了将近20分钟才被人工发现,期间提交的任务全部秒失败(ConnectError)。改成每次 /health
+ # 都真的探一下 8188 活不活,跟 start.sh 自己的就绪检测用同一个端点(/system_stats)。
+ try:
+ r = httpx.get(f"{COMFY_BASE}/system_stats", timeout=2.0)
+ return r.status_code == 200
+ except Exception:
+ return False
+
+
@app.get("/health")
def health():
running = sum(1 for j in JOBS.values() if j.status == "running")
- ready = 1 if STATE["model"] == "ready" else 0
+ comfy_alive = _comfyui_alive()
+ ready = 1 if (STATE["model"] == "ready" and comfy_alive) else 0
+ model_state = STATE["model"] if comfy_alive else "comfyui_unreachable"
backend = {
"status": "ok" if ready else "loading",
- "model": STATE["model"],
+ "model": model_state,
"gpu": "0,1,2,3",
"partition": "raylight-fsdp", # 不再是路由依据,纯展示——4卡是一个集群,没有单卡分区概念了
"offload": "fsdp",
@@ -567,14 +597,40 @@ def create_video(req: GenReq):
raise HTTPException(400, "turbo 模式建议 steps<=8(当前实现turbo LoRA还没接,steps按原样生效)")
jid = uuid.uuid4().hex[:12]
partition = _partition_of(req)
+ seq = time.time_ns()
with LOCK:
- JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), partition=partition)
+ JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(),
+ partition=partition, queue_seq=seq)
_persist(JOBS[jid])
- Q.put(jid)
- return {"id": jid, "status": "queued", "queue_position": Q.qsize(),
+ Q.put((seq, jid))
+ return {"id": jid, "status": "queued", "queue_position": _queue_position(JOBS[jid]),
"eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL}
+def _queue_position(j: Job) -> Optional[int]:
+ """这条任务前面还有几条排队中的任务,1-based;不是queued状态返回None。"""
+ if j.status != "queued":
+ return None
+ return 1 + sum(1 for x in JOBS.values() if x.status == "queued" and x.queue_seq < j.queue_seq)
+
+
+@app.post("/v1/videos/{jid}/prioritize")
+def prioritize_video(jid: str):
+ """插队:把这条任务的排队顺序改到当前排在最前面,返回插队后的位置。"""
+ with LOCK:
+ j = JOBS.get(jid)
+ if j is None:
+ raise HTTPException(404, "无此任务")
+ if j.status != "queued":
+ raise HTTPException(409, f"任务当前状态是 {j.status},只有排队中的任务能插队")
+ others = [x.queue_seq for x in JOBS.values() if x.status == "queued" and x.id != jid]
+ new_seq = (min(others) - 1) if others else time.time_ns()
+ j.queue_seq = new_seq
+ _persist(j)
+ Q.put((new_seq, jid))
+ return {"id": jid, "status": "queued", "queue_position": _queue_position(j)}
+
+
def _trigger_restart_async():
# sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启,
# 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。
@@ -635,6 +691,8 @@ def get_video(jid: str):
if j is None:
raise HTTPException(404, "无此任务")
out = j.model_dump()
+ if j.status == "queued":
+ out["queue_position"] = _queue_position(j)
if j.status == "running" and j.started_at:
out["elapsed_seconds"] = time.time() - j.started_at
if j.status == "done":
diff --git a/raylight/server.py b/raylight/server.py
index 9667093..e0b429f 100644
--- a/raylight/server.py
+++ b/raylight/server.py
@@ -96,11 +96,17 @@ RAY_INIT_CFG = {
# 或画质边界(448×256,见notes.md——技术上测到3000帧都没OOM,是用户看过实际输出判定50秒往后
# 画质不能看,不是显存拦的)。跟 h3-console 前端 RESOLUTIONS 里的 maxFrames 保持一致——
# 两边算法对不上就会出现"前端放行、后端拒绝"或反过来,数字改动两边一起改。
+# 2026-09-01:竖屏(宽高互换)条目沿用对应横屏档位的实测上限——总像素数不变,显存/耗时特征预期
+# 一致,但没有专门为竖屏重跑过 OOM 边界测试,如果实测发现竖屏更早 OOM 要单独下调,不要假设两者永远相等。
RESOLUTION_CEILINGS = {
(832, 480): 1125,
+ (480, 832): 1125,
(448, 256): 1200,
+ (256, 448): 1200,
(1024, 576): 743,
+ (576, 1024): 743,
(1344, 768): 412,
+ (768, 1344): 412,
}
CLIP_NAME = "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
VAE_VIDEO = "minimax_h3_video_vae_fp16.safetensors"
@@ -159,10 +165,14 @@ class Job(BaseModel):
partition: str = "ref2va"
comfy_prompt_id: Optional[str] = None
backend: str = BACKEND_URL
+ queue_seq: int = 0 # 2026-09-03: 排队顺序+插队用,见 _queue_position/prioritize_video
JOBS: dict[str, Job] = {}
-Q: "queue.Queue[str]" = queue.Queue()
+# 2026-09-03: 从 queue.Queue 换成 PriorityQueue(seq, jid),支持插队——"插队"就是把某条任务的
+# queue_seq 改小再塞一份新entry回去,worker弹出时核对 queue_seq 是否还等于当前值,不等就是
+# 被插队指令淘汰的旧entry,跳过(decrease-key的标准兜底写法,不需要真的从堆里摘除旧entry)。
+Q: "queue.PriorityQueue[tuple[int, str]]" = queue.PriorityQueue()
LOCK = threading.Lock()
STATE = {"model": "loading", "loaded_at": None}
@@ -210,10 +220,15 @@ def _load_jobs():
# 执行过程可言,"failed"意味着跑起来又出错了,这两者不是一回事。真的在跑到一半被打断的
# 才算failed。
if j.status == "queued":
- j.status = "cancelled"
- j.error = "进程重启导致这条排队中的任务被取消,需要重新提交"
- j.finished_at = time.time()
- _persist(j)
+ # 2026-09-03: 之前这里直接判cancelled——但排队中的任务根本没开始执行,没有任何
+ # 执行状态会丢失,没理由不能恢复。现在改成原样保留queued,重新塞回新队列,
+ # 用户重新提交这一步就省了(案例:一次重启差点连带取消20条排队任务)。
+ if j.queue_seq == 0:
+ # 兼容加 queue_seq 字段之前创建的旧记录:用 created_at 补一个值,不然这批
+ # 旧记录全平局在 0,重新入队时会退化成按 jid 字符串排序,原本的先后顺序就乱了
+ j.queue_seq = int(j.created_at * 1e9)
+ _persist(j)
+ Q.put((j.queue_seq, j.id))
elif j.status == "running":
j.status = "failed"
j.error = "进程重启导致这条任务的执行状态丢失,无法恢复"
@@ -240,12 +255,16 @@ _INPUT_EXTS = {
def _fetch_to_input(url: str, kind: str = "image") -> str:
"""下载一个文件到 ComfyUI 的 input/ 目录,LoadImage/LoadVideo/LoadAudio 节点按文件名读取。"""
allowed = _INPUT_EXTS[kind]
- ext = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower()
+ parsed = urllib.parse.urlsplit(url)
+ ext = os.path.splitext(parsed.path)[1].lower()
if ext not in allowed:
ext = allowed[0]
fname = f"{uuid.uuid4().hex}{ext}"
dest = INPUT_DIR / fname
- with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as f:
+ # 2026-09-03: 参考图/参考视频的 URL 可能带非 ASCII 文件名(比如中文),http.client 的请求行
+ # 必须是纯 ASCII,不 quote 会在 urlopen 里直接炸 UnicodeEncodeError(案例 f2de718e1e30)。
+ safe_url = urllib.parse.urlunsplit(parsed._replace(path=urllib.parse.quote(parsed.path)))
+ with urllib.request.urlopen(safe_url, timeout=120) as resp, open(dest, "wb") as f:
f.write(resp.read())
return fname
@@ -472,9 +491,11 @@ def worker():
STATE["loaded_at"] = time.time()
loop = asyncio.new_event_loop()
while True:
- jid = Q.get()
+ seq, jid = Q.get()
j = JOBS.get(jid)
- if j is None:
+ if j is None or j.queue_seq != seq:
+ # queue_seq对不上=这个entry是插队前的旧entry,已经被更小的新entry取代了,跳过
+ Q.task_done()
continue
if j.status == "cancelled":
# 排在队里的时候被取消了——直接跳过,不要覆盖回running,也不要真的去跑
@@ -576,14 +597,40 @@ def create_video(req: GenReq):
raise HTTPException(400, "turbo 模式建议 steps<=8(当前实现turbo LoRA还没接,steps按原样生效)")
jid = uuid.uuid4().hex[:12]
partition = _partition_of(req)
+ seq = time.time_ns()
with LOCK:
- JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(), partition=partition)
+ JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time(),
+ partition=partition, queue_seq=seq)
_persist(JOBS[jid])
- Q.put(jid)
- return {"id": jid, "status": "queued", "queue_position": Q.qsize(),
+ Q.put((seq, jid))
+ return {"id": jid, "status": "queued", "queue_position": _queue_position(JOBS[jid]),
"eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL}
+def _queue_position(j: Job) -> Optional[int]:
+ """这条任务前面还有几条排队中的任务,1-based;不是queued状态返回None。"""
+ if j.status != "queued":
+ return None
+ return 1 + sum(1 for x in JOBS.values() if x.status == "queued" and x.queue_seq < j.queue_seq)
+
+
+@app.post("/v1/videos/{jid}/prioritize")
+def prioritize_video(jid: str):
+ """插队:把这条任务的排队顺序改到当前排在最前面,返回插队后的位置。"""
+ with LOCK:
+ j = JOBS.get(jid)
+ if j is None:
+ raise HTTPException(404, "无此任务")
+ if j.status != "queued":
+ raise HTTPException(409, f"任务当前状态是 {j.status},只有排队中的任务能插队")
+ others = [x.queue_seq for x in JOBS.values() if x.status == "queued" and x.id != jid]
+ new_seq = (min(others) - 1) if others else time.time_ns()
+ j.queue_seq = new_seq
+ _persist(j)
+ Q.put((new_seq, jid))
+ return {"id": jid, "status": "queued", "queue_position": _queue_position(j)}
+
+
def _trigger_restart_async():
# sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启,
# 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。
@@ -644,6 +691,8 @@ def get_video(jid: str):
if j is None:
raise HTTPException(404, "无此任务")
out = j.model_dump()
+ if j.status == "queued":
+ out["queue_position"] = _queue_position(j)
if j.status == "running" and j.started_at:
out["elapsed_seconds"] = time.time() - j.started_at
if j.status == "done":