Commit 3d4f4c994f94de144bf5537dc39b382c6ae8482d
1 parent
dca9de24
h3-console: done任务视频跳过raylight代理修卡顿 + 列表加分页页码/跳转
已完成任务的视频文件其实早就在本机磁盘,之前无条件代理到raylight(4卡跑满时 HTTP请求要排在GPU计算后面),改成done状态直接本地FileResponse并加长期 Cache-Control,顺手给列表分页加页码按钮和跳转输入框。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJEL5jqdZR1QPToULRaTAz
Showing
3 changed files
with
80 additions
and
5 deletions
CHANGELOG.md
| 1 | # 变更记录 | 1 | # 变更记录 |
| 2 | 2 | ||
| 3 | +## 2026-09-09 — h3-console 加分页页码/跳转 + 修 done 任务视频走 raylight 忙代理导致的卡顿 | ||
| 4 | + | ||
| 5 | +**触发**:用户反馈 `qwen.wudaima.com/console` 点开页面很慢,怀疑是不是一次加载了太多不相关数据。 | ||
| 6 | + | ||
| 7 | +**根因排查**:不是数据量问题——列表/详情各自只请求自己需要的那部分数据,5秒轮询也早有 | ||
| 8 | +`thumbSig` 缓存(`static/index.html`),已完成任务的缩略图只在首次渲染时创建一次,不会 | ||
| 9 | +每轮重新拉取。真正的坑在 `server.py` 的 `GET /v1/videos/{jid}/file`:不管任务是什么状态, | ||
| 10 | +一律无条件把请求代理转发给 `row['backend']`(也就是 raylight 的 `server.py`,8100端口)。 | ||
| 11 | +`<video preload="metadata">`(列表缩略图)和详情页播放器发的 Range 探测请求也走这条路径。 | ||
| 12 | +raylight 4卡跑 FSDP 集群时长期 100% GPU 利用率,这个 HTTP 请求要排在它的计算之后才被处理, | ||
| 13 | +而这台机器上已完成任务的视频文件其实早就落在本地磁盘(`ComfyUI/output/video/`),h3-console | ||
| 14 | +和 raylight 同机,本可以直接读文件,完全不需要绕这层代理。 | ||
| 15 | + | ||
| 16 | +**验证**:实测一次 Range 探测(`bytes=0-1023`)改前后差异明显,改后本机 206 响应稳定在 | ||
| 17 | +0.1s 左右(改之前没抓到准确数字,但用户反馈的"点击很慢"跟这条热路径吻合,raylight 一直 | ||
| 18 | +显示 4×100% GPU 利用率)。 | ||
| 19 | + | ||
| 20 | +**修复**: | ||
| 21 | +- `download_video()` 里 `status == "done"` 时直接从 `last_known_json` 里的 `file` 字段 | ||
| 22 | + 读本地路径,用 `FileResponse` 原生返回(自带 Range/ETag/Last-Modified),完全跳过代理。 | ||
| 23 | +- 顺带给这条路径和原有的"后端忘了任务、兜底读本地文件"分支都加上 | ||
| 24 | + `Cache-Control: public, max-age=31536000, immutable`——`done` 之后文件内容不会再变, | ||
| 25 | + 之前完全没有缓存头,同一个视频每次翻页/重新打开详情都要重新按上面这条慢路径走一遍。 | ||
| 26 | + 改完之后同一条已完成任务的视频,浏览器端重复访问可以完全走本地缓存,不再发请求。 | ||
| 27 | +- 顺手把左侧任务列表的分页从只有"上一页/下一页"加了页码按钮(当前页高亮,首末页常显, | ||
| 28 | + 中间折叠省略号)和一个跳页输入框,纯前端渲染,不涉及后端接口改动。 | ||
| 29 | + | ||
| 30 | +**已部署**:`server.py`/`static/index.html` 都已 rsync 到服务器 | ||
| 31 | +`/data/models/minimax-h3/console/`,`systemctl restart h3-console-lan h3-console-public` | ||
| 32 | +生效(两个 systemd 单元都是 `active`)。 | ||
| 33 | + | ||
| 3 | ## 2026-09-03 — raylight 修复中文参考文件名 UnicodeEncodeError + 排队优先级/插队 + 重启不再清空队列 | 34 | ## 2026-09-03 — raylight 修复中文参考文件名 UnicodeEncodeError + 排队优先级/插队 + 重启不再清空队列 |
| 4 | 35 | ||
| 5 | **触发**:任务 `f2de718e1e30`(ref2va,带一个中文文件名的参考视频)秒失败,报 | 36 | **触发**:任务 `f2de718e1e30`(ref2va,带一个中文文件名的参考视频)秒失败,报 |
h3-console/server.py
| @@ -524,6 +524,19 @@ async def download_video(jid: str, request: Request): | @@ -524,6 +524,19 @@ async def download_video(jid: str, request: Request): | ||
| 524 | download_filename = f"h3-{jid}.mp4" | 524 | download_filename = f"h3-{jid}.mp4" |
| 525 | content_disposition = f'attachment; filename="{download_filename}"' | 525 | content_disposition = f'attachment; filename="{download_filename}"' |
| 526 | 526 | ||
| 527 | + # 2026-09-09:done 状态的文件已经在本机磁盘定型(跟 raylight 同一台机器),不用再 | ||
| 528 | + # 经它那层代理——之前不管什么状态都无条件走 live 代理,raylight 4卡跑满时这个 HTTP | ||
| 529 | + # 请求要排在 GPU 计算后面才被处理,导致点开已完成任务的详情/列表缩略图经常卡好几秒, | ||
| 530 | + # 其实文件早就躺在本地磁盘上。顺便加 Cache-Control——done 之后文件内容不会再变, | ||
| 531 | + # 之前完全没有缓存头,同一个视频每次翻页/重新打开详情都要重新走一遍上面这条慢路径。 | ||
| 532 | + if row["status"] == "done": | ||
| 533 | + cached = json.loads(row["last_known_json"]) if row["last_known_json"] else {} | ||
| 534 | + file_path = cached.get("file") | ||
| 535 | + if file_path and os.path.exists(file_path): | ||
| 536 | + return FileResponse(file_path, media_type="video/mp4", | ||
| 537 | + headers={"Content-Disposition": content_disposition, | ||
| 538 | + "Cache-Control": "public, max-age=31536000, immutable"}) | ||
| 539 | + | ||
| 527 | upstream_url = f"{row['backend']}/v1/videos/{row['gateway_id']}/file" | 540 | upstream_url = f"{row['backend']}/v1/videos/{row['gateway_id']}/file" |
| 528 | headers = {} | 541 | headers = {} |
| 529 | if "range" in request.headers: | 542 | if "range" in request.headers: |
| @@ -545,7 +558,8 @@ async def download_video(jid: str, request: Request): | @@ -545,7 +558,8 @@ async def download_video(jid: str, request: Request): | ||
| 545 | file_path = cached.get("file") | 558 | file_path = cached.get("file") |
| 546 | if file_path and os.path.exists(file_path): | 559 | if file_path and os.path.exists(file_path): |
| 547 | return FileResponse(file_path, media_type="video/mp4", | 560 | return FileResponse(file_path, media_type="video/mp4", |
| 548 | - headers={"Content-Disposition": content_disposition}) | 561 | + headers={"Content-Disposition": content_disposition, |
| 562 | + "Cache-Control": "public, max-age=31536000, immutable"}) | ||
| 549 | raise HTTPException(404, "后端进程重启后忘了这条任务的记录,磁盘上也没找到对应文件") | 563 | raise HTTPException(404, "后端进程重启后忘了这条任务的记录,磁盘上也没找到对应文件") |
| 550 | 564 | ||
| 551 | async def stream(): | 565 | async def stream(): |
h3-console/static/index.html
| @@ -986,14 +986,44 @@ function renderSidebar() { | @@ -986,14 +986,44 @@ function renderSidebar() { | ||
| 986 | return; | 986 | return; |
| 987 | } | 987 | } |
| 988 | const totalPages = Math.max(1, Math.ceil(totalJobs / PAGE_SIZE)); | 988 | const totalPages = Math.max(1, Math.ceil(totalJobs / PAGE_SIZE)); |
| 989 | + const numBtns = pageNumbers(currentPage, totalPages).map(p => | ||
| 990 | + p === '…' ? `<span style="padding:0 2px;opacity:.5">…</span>` | ||
| 991 | + : `<button class="page-btn" style="${p === currentPage ? 'background:#4a7;color:#fff;border-color:#4a7' : ''}" onclick="gotoPage(${p})">${p}</button>` | ||
| 992 | + ).join(''); | ||
| 989 | footer.innerHTML = ` | 993 | footer.innerHTML = ` |
| 990 | - <div style="display:flex;align-items:center;justify-content:center;gap:10px"> | ||
| 991 | - <button class="page-btn" ${currentPage <= 1 ? 'disabled' : ''} onclick="gotoPage(${currentPage - 1})">‹ 上一页</button> | ||
| 992 | - <span>第 ${currentPage}/${totalPages} 页 · 共 ${totalJobs} 条</span> | ||
| 993 | - <button class="page-btn" ${currentPage >= totalPages ? 'disabled' : ''} onclick="gotoPage(${currentPage + 1})">下一页 ›</button> | 994 | + <div style="display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap"> |
| 995 | + <button class="page-btn" ${currentPage <= 1 ? 'disabled' : ''} onclick="gotoPage(${currentPage - 1})">‹</button> | ||
| 996 | + ${numBtns} | ||
| 997 | + <button class="page-btn" ${currentPage >= totalPages ? 'disabled' : ''} onclick="gotoPage(${currentPage + 1})">›</button> | ||
| 998 | + <span style="opacity:.7">共 ${totalJobs} 条</span> | ||
| 999 | + ${totalPages > 1 ? ` | ||
| 1000 | + <span style="display:flex;align-items:center;gap:4px"> | ||
| 1001 | + <input id="page-jump-input" type="number" min="1" max="${totalPages}" placeholder="页码" style="width:52px;padding:2px 4px;font-size:12px" | ||
| 1002 | + onkeydown="if(event.key==='Enter') jumpToPage(${totalPages})"> | ||
| 1003 | + <button class="page-btn" onclick="jumpToPage(${totalPages})">跳转</button> | ||
| 1004 | + </span>` : ''} | ||
| 994 | </div>`; | 1005 | </div>`; |
| 995 | } | 1006 | } |
| 996 | 1007 | ||
| 1008 | +// 页码窗口:首末页永远显示,当前页前后各2页,其余折叠成省略号——页数一多不至于铺满一整行 | ||
| 1009 | +function pageNumbers(current, total) { | ||
| 1010 | + const set = new Set([1, total, current, current - 1, current + 1, current - 2, current + 2]); | ||
| 1011 | + const nums = [...set].filter(p => p >= 1 && p <= total).sort((a, b) => a - b); | ||
| 1012 | + const out = []; | ||
| 1013 | + for (let i = 0; i < nums.length; i++) { | ||
| 1014 | + if (i > 0 && nums[i] - nums[i - 1] > 1) out.push('…'); | ||
| 1015 | + out.push(nums[i]); | ||
| 1016 | + } | ||
| 1017 | + return out; | ||
| 1018 | +} | ||
| 1019 | + | ||
| 1020 | +function jumpToPage(totalPages) { | ||
| 1021 | + const input = document.getElementById('page-jump-input'); | ||
| 1022 | + const p = parseInt(input.value, 10); | ||
| 1023 | + if (!p || p < 1 || p > totalPages) return; | ||
| 1024 | + gotoPage(p); | ||
| 1025 | +} | ||
| 1026 | + | ||
| 997 | // 任意点击落在某条任务的「⋯」菜单外面,就把菜单收起来——不然点开一个菜单后点别处它会一直挂着 | 1027 | // 任意点击落在某条任务的「⋯」菜单外面,就把菜单收起来——不然点开一个菜单后点别处它会一直挂着 |
| 998 | document.addEventListener('click', () => { if (openMenuFor !== null) { openMenuFor = null; renderSidebar(); } }); | 1028 | document.addEventListener('click', () => { if (openMenuFor !== null) { openMenuFor = null; renderSidebar(); } }); |
| 999 | 1029 |