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 ? `
+ ${j.status === 'queued' ? `
优先插队
` : ''}
删除
` : ''}`; 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":