Name Last Update
..
static Loading commit data...
systemd Loading commit data...
DESIGN.md Loading commit data...
README.md Loading commit data...
run.sh Loading commit data...
server.py Loading commit data...

README.md

H3 Console

给 minimax-h3/ 的四后端 API(gateway.py + 4× api.py)加一层 Web 管理台: 左侧任务记录列表 + 右侧新建/详情,上传首尾帧/参考图 → 提交生成 → 点开任务看状态和结果。 不重做调度,只加 gateway.py 没有的东西(Web UI、文件上传、任务持久化、按需 fl2va/ref2va 弹性分卡)。

需求/架构决策见 <code>DESIGN.md</code>——先读那份,尤其「四点五、弹性 partition 调度器」。

页面结构

左侧栏固定:顶部一条新建任务(选中态=当前在填新表单),下面是任务记录列表,按提交时间倒序, 点哪条就直接跳到那条的详情。右侧是新建表单(三个 tab:文生/首尾帧/参考图)或任务详情二选一。

URL 是真实可分享的路径,不是纯前端状态:

  • /newtask.html(或裸 /)— 新建任务页
  • /task.html/<任务id> — 某个任务的详情页,刷新、直接打开、发链接给别人都能定位到同一条

这三条路径背后是同一个 static/index.html,server.py 里 /task.html/{tid} 和 /newtask.html 两条路由原样返回这份文件,靠前端 JS 读 location.pathname 决定渲染哪个视图——不是三个页面文件, 省得同一份侧边栏/样式在多个 html 里重复维护。提交完新任务会自动跳到 /task.html/<新id>。

状态(2026-08-20)

已部署到服务器,端口 8080,跑在 /data/models/minimax-h3/console/,弹性调度器全链路验证通过。

本地冒烟测试:上传图片 → 拒绝非图片文件 → 提交生成任务 → 轮询到 done → 下载代理拿到合法 mp4 → sqlite 影子记录核对无误 → 浏览器打开首页、三个 tab 切换、任务列表内联播放,均正常。

服务器上用真实 GPU 做的弹性调度全链路验证(这部分本地测不了,见 DESIGN.md「四点五」): 上传首帧图 → 提交 fl2va 任务(当时四卡全 ref2va)→ console 正确返回 queued(不是 503)→ supervisor 30 秒后选中一个空闲 ref2va 后端 flip 成 fl2va → reconciler 在新后端 ready 后自动把 排队的任务提交上去 → 任务 running → done → 通过 console 下载代理拿到合法 mp4。全程无人工干预。

部署过程中揪出的两个真实 bug(修完才让上面这条链路跑通)

  1. _flip_backend 里的 PID 提取从来没匹配上过:ss -lptn 的输出里 pid 不是独立的空格分隔 token,是嵌在 users:(("python",pid=NNNN,fd=14)) 里面的——tok.startswith("pid=") 永远不命中, kill 从来没真的执行。后果是"flip"只是在已被占用的端口上起了一个新 uvicorn 进程,因端口冲突 立刻退出,旧进程全程没受影响——不是数据损坏,但功能是假的。改用 re.findall(r"pid=(\d+)", ...)。
  2. 弹性调度器不知道"已经在切换中",会对同一个需求连续 flip 好几张卡:api.py 冷启动要 13-20 秒,跨了不止一个 10 秒的 tick。中间几轮 tick 看到目标分区的 ready 数还是 0, 误判成需求没满足,接着把第二张、第三张卡也切了过去——一个 pending=1 的请求切了三张卡。 加了 flipping: dict[port, (目标分区, 发起时间)] 记账,效果数(ready + 正在切换中的) 满足需求就不再触发新的 flip,60 秒还没起来算超时放弃。
  3. pkill -f "<pattern>" 杀掉了自己所在的 SSH 会话:这条不是 console 代码的 bug,是我操作 服务器时踩的坑——start-all.sh 注释里其实早就警告过这个("pgrep -f 会匹配到自己那条命令行"), 这次是真的踩了一遍:pkill -f "uvicorn server:app.*8080" 这条命令的完整命令行本身就包含 被搜索的那串文本,pkill -f 把自己所在的 shell 一起杀了,SSH 连接直接断,命令链后半截全没执行。 之后全程改用 ss -lptn "sport = :PORT" | grep -o "pid=..." 按端口精确定位再 kill,不再用 pkill -f。

播放/下载查出来的三个坑(用户反馈"播放和下载好像有问题"后排查,均已修)

  1. download/poll 字段抄了 gateway 内部 id:GET /v1/videos/{id} 原样带回 api.py/gateway.py 响应里的这两个字段,值是 gateway 内部那个 id,不是 console 自己对外用的 id——直接用这两个字段 拼链接会 404。页面本身没受影响(前端一直是自己用 j.id 拼的 URL,没读这两个字段),但接口本身 是错的。_row_view() 里统一改成用 row['id'](console 自己的 id)重新拼。
  2. 详情页每 5 秒轮询会把播放到一半的 <video> 冲掉:refreshDetail() 是整段 innerHTML 重建, 任务到了 done/failed 这种终态之后还继续每 5 秒重建一次,会把正在播放的 <video> 元素连根 拔掉重建,播放进度弹回 0。改成只有任务还在 queued/running 时才继续拉详情,终态之后不再重建。
  3. 下载接口不支持 HTTP Range,<video> 播放前的探测请求拿不到有效响应:之前经 gateway.py 转发(StreamingResponse(urlopen(...))),Range 请求一律回 200 而不是 206,也没有 Content-Length/Accept-Ranges——<video> 标签播放前会先探一次 Range 拿时长,探不到就卡在 readyState=0 播不出来(用 curl/页面里直接 fetch() 都测不出来,因为它们不做这个探测)。 改成绕过 gateway 这层转发,直接连去真正跑这条任务的后端(api.py 自己是 Starlette FileResponse,原生支持 Range),把状态码/头原样带回来。

顺带加了个磁盘兜底:如果目标后端进程已经重启、忘了这条任务(api.py 当时 JOBS 还没 落盘,2026-08-20 之后这个根因已经在 api.py 修了,见 minimax-h3/API.md「任务记录会落盘」), console 会用自己 sqlite 里存的 file 路径直接读磁盘发送——两台机器是同一台,文件一直都在, 只是那个进程的内存不认得这个 id 了。根因修了之后这层兜底退化成第二道保险,不是唯一防线。

怎么起(本地开发)

cd h3-console
./run.sh    # 默认 H3_GATEWAY_URL=http://192.168.110.67:8000,端口 8090

浏览器打开 http://localhost:8090。三个 tab(文生视频/首尾帧/参考图)+ 任务列表都在首页。

⚠️ 本地开发时上传功能不完整:H3_CONSOLE_PUBLIC_BASE 没配的话 /v1/assets 会直接报错—— 因为上传图片的 URL 最终要被服务器上的 api.py worker 反向拉取,本地 Mac 猜不出自己对服务器 可达的地址。要在本地测完整的首尾帧/参考图流程,配成 Mac 在局域网里的地址:

H3_CONSOLE_PUBLIC_BASE=http://<你的 Mac 局域网 IP>:8090 ./run.sh

前提是服务器能反向连回你的 Mac(同一局域网通常没问题;接了会劫持路由的 VPN 就不行, HANDOFF.md 里记过一次这个坑:nc -z 会给假阳性,出问题用 ping+route -n get 确认路径)。 不配这个变量时,文生视频(不需要上传)那个 tab 可以正常测。

部署到服务器(已完成,2026-08-20)

rsync -av --exclude='.venv' --exclude='__pycache__' --exclude='console.sqlite3' --exclude='assets/*' \
  h3-console/ admin@192.168.110.67:/data/models/minimax-h3/console/
ssh admin@192.168.110.67
cd /data/models/minimax-h3/console
H3_GATEWAY_URL=http://127.0.0.1:8000 \
H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 \
H3_ENABLE_SUPERVISOR=1 \
PORT=8080 \
setsid nohup ./run.sh > console.log 2>&1 < /dev/null &

外加第二个实例专供公网子路径(同一个目录,端口和环境变量不同,其它不变):

cd /data/models/minimax-h3/console
H3_GATEWAY_URL=http://127.0.0.1:8000 \
H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 \
H3_CONSOLE_BASE_PATH=/console \
PORT=8081 \
setsid nohup ./run.sh > console-subpath.log 2>&1 < /dev/null &

对外访问原计划用独立域名 gateway.wudaima.com(配置已存在,见 DESIGN.md 问题2)—— 2026-08-20 实测坐实这条线路走不通,缺口在 frp 服务端 60.205.214.169 那边(本机这边 frpc-qwen-client 配置是对的、早就 start proxy success 注册上了,问题不在这台机器,详见 顶层 CHANGELOG.md「排查清楚:gateway.wudaima.com 公网转发为什么一直不通」)——不是这台 机器能改的,需要能登录 60.205.214.169 的人补上,暂不等它。2026-08-29 已放弃这条线, frpc.qwen.ini 里的 [qwen-gw-20260610] 那段客户端配置直接删了(走不通只是"目前观测到的 状态",留着是个隐患——服务端哪天补上 vhost 就会瞬间暴露开着弹性调度器的 8080 局域网实例), 不再等它,永久走下面的子路径方案。

改走 qwen.wudaima.com/console 子路径(同一天改的,详见 CHANGELOG「h3-console 公网访问 改走 qwen.wudaima.com/console 子路径」),已验证可用:

  • 局域网直连:http://192.168.110.67:8080(端口 8080 的实例,H3_CONSOLE_BASE_PATH 留空, 开着弹性调度器)
  • 公网:http://qwen.wudaima.com/console(另起的端口 8081 实例,H3_CONSOLE_BASE_PATH=/console, 不开弹性调度器——两个进程的调度器同时抢着 flip 分区会重现"一个请求切三张卡"那个老 bug, 这件事只能一个进程做主)

两个进程共享同一个工作目录(同一份 console.sqlite3、assets/),局域网和公网看到的任务列表、 上传的图片是同一份数据,不会分叉。server.py 支持 H3_CONSOLE_BASE_PATH 这个环境变量后, static/index.html 里所有原来硬编码的绝对路径都改成了拼 H3_BASE 前缀(页面里的占位符 __H3_BASE_PATH__ 由 server.py 渲染时替换),留空就是原来的根路径行为,两种模式都有本地 uvicorn 验证过。frp 那边在 frpc.qwen.ini 里新增了一段 locations = /console 的代理, 跟 qwen-llm(没有 locations,相当于兜底捕获其它所有路径)共用同一个 custom_domains, 两边不冲突。

重启/重新部署时不要用 pkill -f(见上面「揪出的坑」第3条,会误杀自己的 SSH 会话),改用:

PID=$(ss -lptn "sport = :8080" | grep -o "pid=[0-9]*" | cut -d= -f2 | sort -u)
[ -n "$PID" ] && kill $PID

开机自启(2026-08-25 补):两个实例现在都由 systemd 管理——/etc/systemd/system/ h3-console-lan.service(8080)、h3-console-public.service(8081),本地副本见 systemd/ 目录,已 enable。ExecStart 直接指向 run.sh(脚本末尾 exec uvicorn, 进程替换后 systemd 能正确跟踪),日志仍 append 到原来的 console-8080.log/ console-subpath.log(不改用 journald),加了 Restart=on-failure。改配置后用 systemctl restart h3-console-lan h3-console-public 生效,不用再手动 kill 端口。

部署后验过:curl http://192.168.110.67:8080/v1/backends 看到 4/4 ready;提交一个 fl2va 首尾帧任务,console.log 里能看到 [supervisor] flip port=... ref2va -> fl2va,30 秒后(空闲 判定)触发一次、只触发一次,新后端 ready 后任务自动提交并跑完,下载代理拿到合法 mp4。

回滚

console 本身是独立进程,出问题直接 kill console 进程即可,不影响四个 H3 后端和 gateway。

⚠️ 2026-08-20 起这条不再完全成立:api.py 加了任务记录落盘(见 minimax-h3/API.md 「任务记录会落盘」),这是碰了 api.py 本身的改动,不属于 console 这一层。要回滚这部分, 需要把服务器上的 /data/models/minimax-h3/api.py 换回旧版本,再按「只重启空闲的那张卡」的 纪律逐张滚动重启四个后端(参考本文档「部署过程中揪出的两个真实 bug」同样的操作方式)—— 不能像 console 自己的改动那样一个 kill 就完事。

已知限制

  • 鉴权:2026-08-29 已加。H3_CONSOLE_AUTH_TOKEN 环境变量设了才校验(两个 systemd 实例 都已配置同一个 token),/v1/* 全部接口要求 Authorization: Bearer <token>;页面本身 (/、/newtask.html、/task.html/{tid})和 /assets/* 静态文件不校验——前者是鸡生蛋 蛋生鸡(没加载出页面无从输入token),后者是因为服务器上的 GPU worker 要能反向拉取上传的 参考图/参考视频,且它们本身敏感度低。<video src>/下载 <a href> 是浏览器原生请求加不上 头,改成认 ?token= 查询参数,跟 header 走同一份校验(server.py 的 _require_token)。 前端 static/index.html 首次调用 API 时 prompt() 弹一次令牌存 localStorage,401 会清空 重弹。
  • 任务持久化是 console 自己的 sqlite,和 api.py 内存态之间有轮询间隔的短暂不一致(几秒级), 不追求强一致。
  • 磁盘:/ 只剩 29G,/data 83% 满,上传接口目前只做单文件 20MB 上限校验,没做磁盘余量检查, 真到磁盘紧张时会和其他生成任务一样直接写失败,没有额外保护——按 DESIGN.md「开放风险」记录的, 真出问题再加。
  • 参考图/视频/音频的 URL 必须走专门字段,写进 prompt 文本里不生效:prompt 走文本编码器, 跟 reference_image_urls/reference_video_urls/reference_audio_urls(ref2va)、 first_frame_url/last_frame_url(fl2va)是完全独立的通道(raylight/server.py 的 build_ref2va_graph/build_fl2va_graph),后端不会去 prompt 字符串里扫描/下载 URL。 三个 reference 字段都留空时 _partition_of() 直接判成 partition: "text" 纯文生,不会报错, 只是参考图完全不起作用。2026-08-30 已用两条真实任务对照验证:683ab2fd4681(URL 放进 reference_image_urls,参考图生效)vs 1fa40bca47f5(同一份 prompt,URL 写进文本里、 三个字段留空,partition 变成 "text",人物/场景确认没参考上)。 newtask 页面(static/index.html)2026-08-30 已加"或粘贴 URL"输入框,就是为了让外部 已托管的图片/视频/音频链接能走正确字段,不用先下载再重新上传。