Commit 3d4f4c994f94de144bf5537dc39b382c6ae8482d

Authored by yibo
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
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 34 ## 2026-09-03 — raylight 修复中文参考文件名 UnicodeEncodeError + 排队优先级/插队 + 重启不再清空队列
4 35  
5 36 **触发**:任务 `f2de718e1e30`(ref2va,带一个中文文件名的参考视频)秒失败,报
... ...
h3-console/server.py
... ... @@ -524,6 +524,19 @@ async def download_video(jid: str, request: Request):
524 524 download_filename = f"h3-{jid}.mp4"
525 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 540 upstream_url = f"{row['backend']}/v1/videos/{row['gateway_id']}/file"
528 541 headers = {}
529 542 if "range" in request.headers:
... ... @@ -545,7 +558,8 @@ async def download_video(jid: str, request: Request):
545 558 file_path = cached.get("file")
546 559 if file_path and os.path.exists(file_path):
547 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 563 raise HTTPException(404, "后端进程重启后忘了这条任务的记录,磁盘上也没找到对应文件")
550 564  
551 565 async def stream():
... ...
h3-console/static/index.html
... ... @@ -986,14 +986,44 @@ function renderSidebar() {
986 986 return;
987 987 }
988 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 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 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 1028 document.addEventListener('click', () => { if (openMenuFor !== null) { openMenuFor = null; renderSidebar(); } });
999 1029  
... ...