diff --git a/h3-console/server.py b/h3-console/server.py index a622f40..2756a2b 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -231,6 +231,18 @@ def _row_view(row) -> dict: # 两行判断中间没有 await,天然原子,不会被其它协程插进来)。 _SUBMITTING: set[str] = set() +# 2026-09-04:上面这把 _SUBMITTING 只挡得住"同一个 cid 被提交两次",挡不住另一种真实踩到过的 +# 重复——_try_submit 从发 POST 到把 gateway_id 写回数据库之间有一段 await 窗口,这段时间里 +# gateway(raylight) 那边这条任务已经创建、能在它自己的 /v1/videos 列表里查到了,但 console 的 +# jobs 表还没记上这个 gateway_id。如果 reconciler_loop 的 _discover_from_gateway() 正好在这个 +# 窗口执行,会把这条"看起来没人认领"的 gateway 任务当成孤儿任务,拿 gateway_id 本身当 console id +# 再插一行——于是同一个真实生成任务在任务列表里出现两条记录,状态/进度看起来一样,其实是同一个 +# comfy_prompt_id(案例:4c99f52a3a7a / ff818215fd3e,2026-09-04)。 +# 修法:把"提交"(发请求→写回 gateway_id)和"发现孤儿"(读已知 gateway_id 集合→插入孤儿行) +# 这两段簿记操作用同一把锁互斥,全程不重叠——反正 raylight 本来就一次只处理一个请求,串行化 +# 这两段轻量 HTTP 调用不影响真正的生成吞吐量。 +_SUBMIT_LOCK = asyncio.Lock() + async def _try_submit(cid: str): """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。""" @@ -242,31 +254,32 @@ async def _try_submit(cid: str): return # 任务不存在,或已经提交过 _SUBMITTING.add(cid) try: - req = json.loads(row["req_json"]) - try: - resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req) - except httpx.HTTPError: - return # gateway 暂时连不上,下一轮再试 - now = time.time() - if resp.status_code < 400: - job = resp.json() - gateway_id = job["id"] - job["id"] = cid # 对外只暴露 console 自己的 id - with db() as con: - con.execute( - "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?", - (gateway_id, job.get("backend"), job.get("status", "queued"), - now, json.dumps(job, ensure_ascii=False), cid), - ) - elif resp.status_code in (503, 429): - return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试 - else: - # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败 - err_view = {"id": cid, "status": "failed", "error": resp.text, - "req": req, "partition": row["partition"], "created_at": row["created_at"]} - with db() as con: - con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", - (now, json.dumps(err_view, ensure_ascii=False), cid)) + async with _SUBMIT_LOCK: + req = json.loads(row["req_json"]) + try: + resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req) + except httpx.HTTPError: + return # gateway 暂时连不上,下一轮再试 + now = time.time() + if resp.status_code < 400: + job = resp.json() + gateway_id = job["id"] + job["id"] = cid # 对外只暴露 console 自己的 id + with db() as con: + con.execute( + "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?", + (gateway_id, job.get("backend"), job.get("status", "queued"), + now, json.dumps(job, ensure_ascii=False), cid), + ) + elif resp.status_code in (503, 429): + return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试 + else: + # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败 + err_view = {"id": cid, "status": "failed", "error": resp.text, + "req": req, "partition": row["partition"], "created_at": row["created_at"]} + with db() as con: + con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?", + (now, json.dumps(err_view, ensure_ascii=False), cid)) finally: _SUBMITTING.discard(cid) @@ -313,42 +326,46 @@ async def _discover_from_gateway(): 捞回来。两边后端都有 GET /v1/videos 全量列表(raylight 直接返回 list,老 gateway 包一层 {"jobs":[...]}),本来就是给这个用的。2026-08-24 真出过一次:有人直连后端提交,console 界面完全看不到,两边还因为都不知道对方在用哪张卡而抢 GPU。 - 没有反过来发现「新任务」的必要去重锁——本函数只在 reconciler_loop 里单协程顺序调用, - 不会跟自己并发。""" - try: - resp = await client.get(f"{GATEWAY_URL}/v1/videos", params={"limit": 200}) - except httpx.HTTPError: - return - if resp.status_code != 200: - return - body = resp.json() - remote = body.get("jobs", body) if isinstance(body, dict) else body - with db() as con: - known = {r["gateway_id"] for r in con.execute( - "SELECT gateway_id FROM jobs WHERE gateway_id IS NOT NULL")} - for summary in remote: - gid = summary.get("id") - if not gid or gid in known: - continue + 2026-09-04 更正:本函数确实只在 reconciler_loop 里单协程顺序调用、不会跟自己并发,但会跟 + 别的协程(create_video/get_video 触发的 _try_submit)并发——如果正好在某个 _try_submit + "已经把请求 POST 给 gateway、gateway_id 还没写回数据库"的窗口执行,会把这条其实已经被 + console 自己认领、只是还没记上账的任务当成孤儿,插出一条重复记录(同一个 comfy_prompt_id + 在任务列表里出现两条)。用跟 _try_submit 共享的 _SUBMIT_LOCK 互斥,两段簿记操作不再重叠。""" + async with _SUBMIT_LOCK: try: - detail = await client.get(f"{GATEWAY_URL}/v1/videos/{gid}") + resp = await client.get(f"{GATEWAY_URL}/v1/videos", params={"limit": 200}) except httpx.HTTPError: - continue - if detail.status_code != 200: - continue - job = detail.json() - req = job.get("req", {}) - now = time.time() - job["id"] = gid # 发现的任务没有独立的 console id,直接用 gateway id 本身 + return + if resp.status_code != 200: + return + body = resp.json() + remote = body.get("jobs", body) if isinstance(body, dict) else body with db() as con: - con.execute( - "INSERT OR IGNORE INTO jobs " - "(id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) " - "VALUES (?,?,?,?,?,?,?,?,?)", - (gid, gid, job.get("partition") or _partition_of(req), job.get("backend"), - job.get("status", "queued"), json.dumps(req, ensure_ascii=False), - job.get("created_at", now), now, json.dumps(job, ensure_ascii=False)), - ) + known = {r["gateway_id"] for r in con.execute( + "SELECT gateway_id FROM jobs WHERE gateway_id IS NOT NULL")} + for summary in remote: + gid = summary.get("id") + if not gid or gid in known: + continue + try: + detail = await client.get(f"{GATEWAY_URL}/v1/videos/{gid}") + except httpx.HTTPError: + continue + if detail.status_code != 200: + continue + job = detail.json() + req = job.get("req", {}) + now = time.time() + job["id"] = gid # 发现的任务没有独立的 console id,直接用 gateway id 本身 + with db() as con: + con.execute( + "INSERT OR IGNORE INTO jobs " + "(id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (gid, gid, job.get("partition") or _partition_of(req), job.get("backend"), + job.get("status", "queued"), json.dumps(req, ensure_ascii=False), + job.get("created_at", now), now, json.dumps(job, ensure_ascii=False)), + ) @api.post("/v1/videos", status_code=202) @@ -527,7 +544,7 @@ async def download_video(jid: str, request: Request): @api.get("/v1/videos") -async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: str = "", +async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q: str = "", status: str = "", kind: str = "", sort: str = "desc"): # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, @@ -552,12 +569,14 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: params.append(kind) order_col = "deleted_at" if trash else "created_at" order = f"{order_col} {'ASC' if sort == 'asc' else 'DESC'}" - params.append(limit) with db() as con: + # 前端要做真分页(第X/共Y页),得知道筛选条件下总共有多少条,不能只看这一页拿回了几条。 + total = con.execute(f"SELECT COUNT(*) FROM jobs WHERE {' AND '.join(where)}", params).fetchone()[0] rows = con.execute( - f"SELECT * FROM jobs WHERE {' AND '.join(where)} ORDER BY {order} LIMIT ?", params + f"SELECT * FROM jobs WHERE {' AND '.join(where)} ORDER BY {order} LIMIT ? OFFSET ?", + params + [limit, offset], ).fetchall() - return {"jobs": [_row_view(r) for r in rows]} + return {"jobs": [_row_view(r) for r in rows], "total": total} @api.delete("/v1/videos/{jid}") diff --git a/h3-console/static/index.html b/h3-console/static/index.html index 570b65e..c8455e8 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -9,7 +9,9 @@ body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 0; display: flex; height: 100vh; overflow: hidden; } /* ---- 左侧任务记录 ---- */ - #sidebar { width: 300px; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } + /* 2026-09-04:原来 300px 定宽太窄,列表里看不出每条任务在生成什么,只能点进详情才知道—— + 改成视口 50%,腾出空间放缩略图(见下面 .jr-thumb)。 */ + #sidebar { width: 50%; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } #sidebar-header h1 { font-size: 16px; margin: 0; } #sidebar-filters { padding: 8px 16px; border-bottom: 1px solid #ddd; display: flex; flex-direction: column; gap: 6px; } @@ -21,6 +23,9 @@ #job-sort-btn:hover { background: rgba(128,128,128,.15); } #job-list { flex: 1; overflow-y: auto; } #job-list-footer { padding: 10px 16px; text-align: center; font-size: 11px; opacity: .55; } + .page-btn { font-size: 12px; padding: 3px 10px; border: 1px solid #888; background: transparent; + border-radius: 4px; cursor: pointer; } + .page-btn:disabled { opacity: .4; cursor: default; } .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } .job-row:hover { background: rgba(128,128,128,.08); } .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } @@ -29,6 +34,16 @@ .job-row .jr-actions { display: flex; align-items: center; gap: 6px; } .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; } + /* 2026-09-04:任务列表加缩略图——已完成的显示结果视频(取第一帧当封面),还没完成的 + 显示输入的首帧图/参考图,两者都没有就显示任务类型文字兜底。 */ + .jr-body { display: flex; gap: 10px; align-items: flex-start; } + .jr-thumb { width: 88px; height: 118px; flex-shrink: 0; border-radius: 6px; overflow: hidden; + background: rgba(128,128,128,.15); display: flex; align-items: center; justify-content: center; } + .jr-thumb img, .jr-thumb video { width: 100%; height: 100%; object-fit: cover; display: block; } + .jr-thumb-empty { font-size: 12px; opacity: .5; text-align: center; padding: 0 4px; } + .jr-info { flex: 1; min-width: 0; } + .jr-info .jr-prompt { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; + -webkit-line-clamp: 4; } .jr-menu-btn { cursor: pointer; padding: 0 4px; opacity: .55; font-size: 14px; line-height: 1; } .jr-menu-btn:hover { opacity: 1; } .jr-menu { position: absolute; top: 22px; right: 12px; background: Canvas; color: CanvasText; @@ -310,20 +325,26 @@ let trashCache = []; let viewingTrash = false; let openMenuFor = null; -// 任务列表原来固定 limit=50,服务器上实测已经攒了 232 条未删除任务——超出的 182 条根本 -//没请求回来,#job-list 的 overflow-y:auto 只能滚动已加载的这 50 条,看起来像"下拉到底 -// 就没了"。改成滚到底部自动把 loadedLimit 加 50 再重新拉,直到某次返回条数小于请求的 -// limit(说明已经拉到头,noMoreJobs=true,不用再滚)。search/status/kind/sort 每次改动 -// 都把 loadedLimit 重置回 50 重新拉——过滤条件变了,"已经拉到第几页"这个状态就该归零。 -let loadedLimit = 50; -let noMoreJobs = false; -let loadingMore = false; +// 2026-09-04:原来是"滚到底部自动多拉50条"的无限滚动,列表越滚越长,每条任务的缩略图 +// (视频/图片)也跟着越攒越多,同时几十个