Commit 5b4b1522c3ba64c8a75acdeafb2ba25b56092dba

Authored by yibo
1 parent 70eee74e

同步另一进程在服务器/本地留下的未提交改动

CFG scale/negative prompt 参数入口(api.py + console 前端「高级参数」)、
参考素材点击插入 <Picture N>/<Video N> 标记、RTX PRO 6000 int8-convrot
冒烟测试成功的记录(h3-rtxpro6000/ 新目录)。这些改动之前只存在于服务器
磁盘和本地磁盘,没进 git,这次是同步不是新增功能。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG.md
1 1 # 变更记录
2 2  
  3 +## 2026-08-21(凌晨)— 新机器 RTX PRO 6000 装 H3,int8-convrot 冒烟测试成功,画质待人工确认
  4 +
  5 +**背景**:用户怀疑 2080Ti 上用的 NF4(4-bit)量化是画质不行的根源。新租了一台 AutoDL
  6 +容器(单卡 RTX PRO 6000 Blackwell 96GB,跟 192.168.110.67 完全独立),换更高精度的
  7 +int8-convrot 量化做对照。完整细节见新建的 `h3-rtxpro6000/README.md` 和
  8 +`docs/todo/h3-rtxpro6000-setup.md`。
  9 +
  10 +**结果**:环境装好、71GB 权重下完、首次冒烟测试成功——832×480×124f、50步全量、
  11 +int8-convrot、bf16,**3.7s/步**(2080Ti fp32 是 107.3s/步,快 ~29×),峰值显存 33.9G
  12 +(96G 卡里绰绰有余,不需要 CPU offload),视频音频有限性/纯色检查全过。**唯一没验证的
  13 +是画质本身**——脚本检查只能排除全黑/NaN 这类硬故障,测不出"好不好看",需要人看样片
  14 +`t2va_20260821-002322.mp4` 才能下结论。
  15 +
  16 +**被推翻/更正的假设**:
  17 +- 中途算错过一次数字:以为非剪枝 int8-convrot DiT 是 20.97GB,实际是 **34.04GB**——
  18 + 20.97GB 是剪枝版的大小,两者看混了。实际总下载量 ~71GB,不是最初估的 54GB。
  19 +- 排除了 HF 上下载量最高的 GGUF 版本(`Abiray/MiniMax-H3-GGUF`)——model card 没说明
  20 + 配哪个推理引擎,DiffSynth-Studio 不支持 GGUF 加载,换了等于换整条推理栈,风险收益不对等。
  21 +
  22 +**实测数据(部署过程,不是模型本身)**:
  23 +- ModelScope 直连是这台机器上最快的下载源,比 hf-mirror.com(0.5MB/s)、`network_turbo`
  24 + 代理到 HF 官方(0.3MB/s)都快。单连接 1.5–4MB/s,aria2c 16 连接 5–11MB/s,但**多连接
  25 + 不是线性提升**——两文件各开 8 连接同时下,合计吞吐反而比一个文件开 16 连接更低,
  26 + 说明限速在 ModelScope 端是整体限速,不是单连接限速。
  27 +- `diffsynth[quant]` 不够,运行时连续炸 `torchaudio`/`av` 两个 `ModuleNotFoundError`——
  28 + `pyproject.toml` 里这两个在单独的 `audio` extras 分组,要 `diffsynth[audio,quant]` 一起装。
  29 +- AutoDL 容器扩容数据盘必须走网页控制台重启,容器内 `reboot`/`shutdown` 无效(PID1 是裸
  30 + bash,接不住信号)。
  31 +- `expect` 里的 SSH 命令包含方括号(如 `pip install "pkg[extra]"`)会被 Tcl 当命令替换
  32 + 语法解析报错,稳定做法是写成本地 `.sh` 文件 `scp` 上去执行,不要塞进 `expect -c` 字符串。
  33 +
  34 +---
  35 +
  36 +## 2026-08-20(夜,又续八)— CFG/负面提示词入口 + 参考素材点击插入 `<Picture N>`/`<Video N>` 标记
  37 +
  38 +**结果**:两件 UI 事,都在本地用真实浏览器(claude-in-chrome)交互验证过,不是只看代码/截图猜。
  39 +
  40 +1. **CFG scale / 负面提示词**:三个 tab 都加了一个默认折叠的"高级参数"(`<details>`),
  41 + 里面是 CFG Scale 数字输入框 + 负面提示词文本框,留空就不放进请求体,`api.py` 那边
  42 + `Optional[None]` 不覆盖 pipeline 自己的默认值,行为跟以前完全一样。填了才生效——串通
  43 + `minimax-h3/api.py` 的 `GenReq.cfg_scale`/`negative_prompt` 两个新字段(这两个参数
  44 + pipeline 本来就支持,之前我们的代码从没传过,见 `docs/todo/quality-tuning-plan.md`)。
  45 +2. **参考素材点击插入引用标记**:Ref2VA 的参考图/参考视频缩略图左上角现在有个"P1"/"V1"
  46 + 这样的编号角标,点一下就把 `<Picture 1>`/`<Video 1>` 插到 prompt 光标所在位置——R2V 的
  47 + prompt 本来就要求显式指着第几张图/第几段视频,之前完全靠用户自己数序号手打,容易错位。
  48 + 图片、视频分开编号,跟 pipeline 内部的计数方式一致。任务详情页的参考素材展示也跟着把
  49 + 标签从"参考图1"改成了 `<Picture 1>` 这种直接能抄的写法。
  50 +
  51 +**本地浏览器验证**:起了一个本地 uvicorn 实例(`H3_GATEWAY_URL` 指向 `qwen.wudaima.com`——
  52 +这个域名现在实际是 gateway.py,见前面「重要」笔记,所以纯 T2V 现在本地就能测通;
  53 +`H3_CONSOLE_PUBLIC_BASE` 只能设成 127.0.0.1,I2V/R2V 提交后卡住是预期的,见下面「本地开发的
  54 +限制」)。切到"多参考图生视频" tab,粘贴一个公网图片 URL 验证了:缩略图正确渲染、角标显示
  55 +"P1"、点击角标后 prompt 文本框里正确出现了 `<Picture 1>`、光标定位在标记后面。展开"高级参数"
  56 +确认 CFG Scale 输入框和负面提示词文本框都正常渲染,placeholder 文字正确。**没有提交真实生成
  57 +任务**——服务器上当时已经有一条别的任务在跑(8011 running×1),没必要为了纯 UI 验证多占用一
  58 +个 GPU 名额。
  59 +
  60 +---
  61 +
3 62 ## 2026-08-20(夜,又续七)— Ref2VA 加参考视频/URL 直填,本地开发,⚠️ 没有 GPU 没测过
4 63  
5 64 **背景**:用户脱离了公司网络,只能靠 `qwen.wudaima.com/console` 这条公网线路访问服务器,
... ...
docs/README.md
... ... @@ -11,8 +11,9 @@
11 11 - [quality-tuning-plan.md](todo/quality-tuning-plan.md) — 为什么生成视频画质比 ComfyUI 官方
12 12 演示差很多:分辨率/Turbo步数/CFG/negative prompt 几个待实测方向,按优先级排好了
13 13 - [h3-rtxpro6000-setup.md](todo/h3-rtxpro6000-setup.md) — 新租的 RTX PRO 6000(Blackwell
14   - 96GB)装 H3 的调研:为什么不用 GGUF、精度选型对比(NF4/int8-convrot/fp8/bf16 实测文件大小)、
15   - uv 环境搭建方式,还没开始下权重
  14 + 96GB)装 H3:为什么不用 GGUF、精度选型对比、uv 环境搭建方式、装环境踩的坑。
  15 + **首次冒烟测试已成功**(int8-convrot,3.7s/步),环境/部署细节挪到了
  16 + `h3-rtxpro6000/README.md`,**画质待人工确认**是唯一剩下的事
16 17 - [raylight-comfyui-multi-gpu-eval.md](todo/raylight-comfyui-multi-gpu-eval.md) — 评估要不要把
17 18 2080Ti 那套换成 ComfyUI + Raylight 做序列并行,把 4×22G 当一个显存池用来跑更高分辨率/更长
18 19 时长——调研阶段,确认现有 DiffSynth-Studio 架构不支持多卡拆分;Raylight 确认支持 Turing+PCIe+
... ...
docs/todo/h3-rtxpro6000-setup.md
... ... @@ -104,9 +104,74 @@ pipe = MiniMaxH3Pipeline.from_pretrained(
104 104 这次新机器的 int8-convrot 选型算是补了这一项;如果换精度后画质明显变好,说明 2080Ti 那份
105 105 清单该加一条「量化精度」;如果没变化,说明那份清单列的四个方向才是真正的瓶颈,量化本身不是。
106 106  
  107 +## 已完成:2026-08-21 00:23 首次冒烟测试成功
  108 +
  109 +脚本:`h3-rtxpro6000/scripts/t2va-rtxpro6000.py`(本地副本,服务器上是
  110 +`/root/autodl-tmp/minimax-h3/t2va-rtxpro6000.py`,顶层平铺,不在 `scripts/` 下,
  111 +跟 2080Ti 那边 `run.sh` 的路径不一致是同一种情况,见 minimax-h3/README.md 末尾)。
  112 +
  113 +配置:832×480×124f,50 步全量(不开 Turbo LoRA),int8-convrot,seed=0。
  114 +
  115 +| 项目 | 实测 |
  116 +|---|---|
  117 +| 权重加载 | 83s |
  118 +| 生成耗时 | **183s(3.7s/步)** |
  119 +| 峰值显存 | **33.9G**(96G 卡里绰绰有余,全程没触发 CPU offload) |
  120 +| 视频 | h264 832×480 · 24fps · **124 帧**(无截断) |
  121 +| 音频 | aac 32kHz 立体声 · mean **-35.6dB** · max **-17.9dB**(静音应为 -91dB,非静音) |
  122 +| 脚本内建有限性/纯色检查 | 通过(无 `[FAIL]`,退出码 0) |
  123 +| 产物 | `outputs/t2va_20260821-002322.mp4`,2.5MB,已下载到本地待人工看画质 |
  124 +
  125 +**对比 2080Ti**:fp32 50 步是 107.3s/步(单步耗时不随分辨率/步数变化,见 minimax-h3/README.md
  126 +验收记录),这张卡 **3.7s/步,快 ~29×**。差距来源是架构代差(Blackwell 原生 bf16 tensor core +
  127 +高效 attention kernel)叠加不用 CPU offload(2080Ti 权重要在 CPU/GPU 间来回倒腾)。
  128 +
  129 +画质是否真的比 NF4 好,需要人眼看这条视频后才能下结论——脚本自带的检查只能排除"全黑/NaN"这类
  130 +硬故障,测不出"能不能看"。
  131 +
  132 +### 装环境踩的坑:`diffsynth[quant]` 不够,还要 `[audio]`
  133 +
  134 +第一次冒烟测试连续炸了两次 `ModuleNotFoundError`(先 `torchaudio` 后 `av`)。查
  135 +`DiffSynth-Studio/pyproject.toml` 才发现 `av`/`torchaudio`/`torchcodec`/`librosa` 是单独一个
  136 +`audio` extras 分组,跟 `quant`(`bitsandbytes`/`comfy-kitchen`/`torchao`)是分开的。
  137 +正确装法是两个 extras 一起装:
  138 +
  139 +```bash
  140 +uv pip install -e './DiffSynth-Studio[audio,quant]'
  141 +```
  142 +
  143 +### 装环境另一个坑:uv 通过 SSH 套 Tcl expect 调用时方括号会被 Tcl 当命令替换
  144 +
  145 +`uv pip install -e "./DiffSynth-Studio[quant]"` 这类命令直接塞进 `expect -c '...'` 的
  146 +spawn 字符串会报 `invalid command name "quant"`——Tcl 把 `[quant]` 解析成命令替换语法,
  147 +不是 shell 语法层面的问题,转义方括号本身也容易在多层引号里出错。稳定做法是把要跑的命令
  148 +写成本地 `.sh` 文件,`scp` 上去再 `ssh ... "bash script.sh"` 执行,彻底绕开这层转义。
  149 +
  150 +### 下载速度踩的坑:ModelScope 单连接慢,多连接不是线性提升但仍值得用
  151 +
  152 +实测对比(见下方,这次调研前测过):
  153 +- ModelScope 单连接:1.5–4 MB/s(不稳定,随时间波动,后段测到过 11MB/s)
  154 +- hf-mirror.com:0.5 MB/s(比 ModelScope 还慢)
  155 +- HF 官方 + AutoDL 学术加速代理:0.3 MB/s(最慢,文档自己也说"不承诺稳定性")
  156 +- **aria2c 16 连接单文件**:5.1–11 MB/s(比单连接快,但不是线性——测过两个文件各 8 连接
  157 + 同时跑,合计吞吐反而比一个文件 16 连接更低,说明限速在 ModelScope 端是整体限速不是
  158 + 单连接限速,多开文件并不能叠加带宽)
  159 +
  160 +最终 ~71GB(DiT int8-convrot 32G + 文本编码器 27G + video_vae 9.8G + audio_vae 0.6G)
  161 +从 ModelScope 用 aria2c 下完,全程 setsid nohup 脱离 SSH 会话。
  162 +
  163 +### 之前算错的数字(已更正)
  164 +
  165 +上一版这份文档写 DiT int8-convrot 是 20.97GB——那其实是**剪枝版**
  166 +(`pruned_int8_convrot`)的大小,非剪枝版实际是 **34.04GB(aria2c 实测 31GiB)**。
  167 +实际总下载量是 ~71GB,不是最初估的 54GB。100GB 数据盘依然够用(用了约 75GB)。
  168 +
107 169 ## 还没做的事
108 170  
109   -1. `uv pip install "diffsynth[quant]"` + clone DiffSynth-Studio 源码
110   -2. 确认 `Comfy-Org/MiniMax-H3` / `MiniMax/MiniMax-H3` 在 ModelScope 直连能下(还没实测,只是表格链接看到有镜像)
111   -3. 照官方 int8-convrot 示例写这张卡专用的 `t2va.py`(去掉 2080Ti 的 fp32 patch 和 sm_75 assert)
112   -4. 下载 ~54GB 权重,实测出片,跟 2080Ti 上的验收样本对比画质
  171 +1. **人工看画质**——本地视频文件:`t2va_rtxpro6000_smoketest.mp4`,跟 2080Ti NF4 的样本对比,
  172 + 确认这次换 int8-convrot 是否真的解决了画质问题
  173 +2. 如果画质OK:补齐 `h3-rtxpro6000/README.md`(按顶层 `CLAUDE.md` 的目录约定)、
  174 + 把这篇文档挪到 `docs/done/`
  175 +3. 如果画质仍不理想:回到 `quality-tuning-plan.md` 列的其他方向(分辨率/CFG/negative prompt),
  176 + 量化精度就不是瓶颈了
  177 +4. 还没测 Turbo LoRA 在 int8-convrot 权重上是否能正常挂载(脚本里 `--turbo` 参数还没跑过)
... ...
docs/todo/quality-tuning-plan.md
... ... @@ -24,16 +24,18 @@ rows 涓嶈秴闄愶級锛岃窡鐜板湪鐨 832脳480 榛樿妗e悓 prompt/seed 瀵规瘮锛岀湅
24 24 鑲夌溂瑙傛劅銆備箣鍓嶅湪绾枃鐢熻棰戜笂娴嬭繃涓娆$被浼肩殑锛堢粨璁烘槸姝ユ暟瓒婂甯ч棿宸紓瓒婂皬銆侀煶璐ㄨ秺濂斤紝瑙
25 25 `CHANGELOG.md` 銆5 绉掓椂闀垮疄娴嬨嶄竴鑺傦級锛屼絾娌℃湁鍦ㄧ幇鍦ㄧ殑榛樿鍒嗚鲸鐜/R2V 鍦烘櫙涓嬮噸鏂伴獙璇佽繃銆
26 26  
27   -## 3. CFG scale / negative prompt鈥斺斿畬鍏ㄦ病璋冭繃
  27 +## 3. CFG scale / negative prompt鈥斺斿叆鍙e凡缁忓姞涓婁簡锛岃繕娌″仛杩 A/B
28 28  
29 29 pipeline 鏈韩鏀寔 `cfg_scale`锛堥粯璁 1.0锛岀瓑浜庢病鏈 CFG 寮曞锛夊拰 `negative_prompt`锛堥粯璁ょ┖
30   -瀛楃涓诧級锛屾垜浠殑 `api.py`/`t2va.py` 浠庢潵娌′紶杩囪繖涓や釜鍙傛暟锛屼竴鐩撮獞鐫榛樿鍊艰8璺戙侰omfyUI 鏂囨。
31   -娌℃湁鏄庣‘缁欏嚭鎺ㄨ崘鍊硷紙钂搁妯″瀷甯歌鎶 guidance 鐒欒繘 LoRA 閲岋紝涓嶄竴瀹氶渶瑕佸紑 CFG锛夛紝杩欐槸涓涓畬鍏
32   -娌″仛杩 A/B 鐨勭┖鐧姐
  30 +瀛楃涓诧級锛屼箣鍓嶆垜浠殑 `api.py`/`t2va.py` 浠庢潵娌′紶杩囪繖涓や釜鍙傛暟锛屼竴鐩撮獞鐫榛樿鍊艰8璺戙侰omfyUI
  31 +鏂囨。娌℃湁鏄庣‘缁欏嚭鎺ㄨ崘鍊硷紙钂搁妯″瀷甯歌鎶 guidance 鐒欒繘 LoRA 閲岋紝涓嶄竴瀹氶渶瑕佸紑 CFG锛夈
33 32  
34   -**瑕佸仛鐨勫疄娴**锛氬厛纭榛樿鍊兼槸涓嶆槸鐪熺殑鏈浼樷斺擿cfg_scale` 璋冨埌 2-3 璇曡瘯鏄惁鐢昏川鎻愬崌锛屽悓鏃剁湅
35   -鏄惁浼氬洜涓鸿捀棣 LoRA 鐨勭紭鏁呭弽鑰屽彉宸紙杩囧害寮曞鍦ㄨ捀棣忔ā鍨嬩笂鏈夋椂浼氬嚭浼奖锛夈俙negative_prompt`
36   -鍙互鍏堝姞鍑犱釜甯歌璐熼潰璇嶏紙妯$硦銆佹姈鍔ㄣ佸彉褰級璇曡瘯銆
  33 +**2026-08-20 宸插畬鎴**锛歚api.py` 鐨 `GenReq` 鍔犱簡 `cfg_scale`/`negative_prompt`锛堥兘鏄
  34 +`Optional`锛岀暀绌=涓嶈鐩栵紝琛屼负璺熶互鍓嶄竴鏍凤級锛宑onsole 涓変釜 tab 閮藉姞浜嗗搴旂殑"楂樼骇鍙傛暟"杈撳叆妗
  35 +锛堟湰鍦版祻瑙堝櫒楠岃瘉杩囨覆鏌/浜や簰娌¢棶棰橈紝娌℃湁鐪熸満娴嬭繃鐢熸垚鏁堟灉锛夈**瑕佸仛鐨勫疄娴**娌″彉锛氬厛纭榛樿鍊
  36 +鏄笉鏄湡鐨勬渶浼樷斺擿cfg_scale` 璋冨埌 2-3 璇曡瘯鏄惁鐢昏川鎻愬崌锛屽悓鏃剁湅鏄惁浼氬洜涓鸿捀棣 LoRA 鐨勭紭鏁
  37 +鍙嶈屽彉宸紙杩囧害寮曞鍦ㄨ捀棣忔ā鍨嬩笂鏈夋椂浼氬嚭浼奖锛夈俙negative_prompt` 鍙互鍏堝姞鍑犱釜甯歌璐熼潰璇
  38 +锛堟ā绯娿佹姈鍔ㄣ佸彉褰級璇曡瘯銆
37 39  
38 40 ## 4. audio_flow_shift鈥斺斿悓鏍疯8璺戦粯璁ゅ
39 41  
... ... @@ -49,5 +51,6 @@ pipeline 鏈韩鏀寔 `cfg_scale`锛堥粯璁 1.0锛岀瓑浜庢病鏈 CFG 寮曞锛夊拰 `
49 51  
50 52 ## 寤鸿鐨勫疄娴嬮『搴
51 53  
52   -1 鍜 2 涓嶉渶瑕佷笅杞戒换浣曟柊鏂囦欢銆佷笉闇瑕佹敼浠g爜锛屾渶蹇兘鍑虹粨鏋滐紝浼樺厛鍋氥 闇瑕佸皬鏀 `api.py`
53   -鎶婅繖涓や釜鍙傛暟涓蹭笂锛堢洰鍓嶅畬鍏ㄦ病鏈夊叆鍙o級銆 鏈鍚庡啀鐪嬭涓嶈鎶曞叆銆
  54 +1 鍜 2 涓嶉渶瑕佷笅杞戒换浣曟柊鏂囦欢銆佷笉闇瑕佹敼浠g爜锛屾渶蹇兘鍑虹粨鏋滐紝浼樺厛鍋氥 鐨勫叆鍙e凡缁忓姞涓婁簡
  55 +锛堣涓婇潰锛夛紝鐩存帴鍦 console 閲屽~鍙傛暟娴嬪氨琛屻 闇瑕佸皬鏀 `api.py` 鎶 `audio_flow_shift` 涓蹭笂
  56 +锛堢洰鍓嶈繕娌℃湁鍏ュ彛锛夈 鏈鍚庡啀鐪嬭涓嶈鎶曞叆銆
... ...
h3-console/server.py
... ... @@ -83,48 +83,26 @@ init_db()
83 83  
84 84  
85 85 # ---------------- 资产上传 ----------------
86   -# api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls /
87   -# reference_video_urls),内部用 urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 ->
88   -# 吐一个后端能拉到的 URL」,不改 api.py 生成逻辑一行代码。
89   -IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
90   -VIDEO_EXTS = (".mp4", ".mov", ".webm")
91   -MAX_VIDEO_BYTES = 200 * 1024 * 1024 # 参考视频比参考图重得多,20MB 装不下几秒钟的片段
92   -
93   -# 轻量魔数校验,不是完整格式解析——只为挡住"随便改个后缀的非视频文件"。mp4/mov 都是 ISO
94   -# base media 容器,前 12 字节里第 4-7 位是 "ftyp";webm 是 EBML 容器,固定以这 4 字节开头。
95   -# 没有为此新引入解码依赖(PyAV/opencv),跟这个 venv 里其它依赖保持一致地轻。
96   -def _looks_like_video(head: bytes, ext: str) -> bool:
97   - if ext == ".webm":
98   - return head[:4] == b"\x1a\x45\xdf\xa3"
99   - return len(head) >= 12 and head[4:8] == b"ftyp" # .mp4 / .mov
100   -
101   -
  86 +# api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用
  87 +# urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」,
  88 +# 不改 api.py 一行代码。
102 89 @router.post("/v1/assets")
103 90 async def upload_asset(file: UploadFile = File(...)):
104 91 ext = Path(file.filename or "").suffix.lower()
105   - is_video = ext in VIDEO_EXTS
106   - if not is_video and ext not in IMAGE_EXTS:
107   - raise HTTPException(
108   - 400, f"不支持的文件类型 {ext},只收图片({'/'.join(e[1:] for e in IMAGE_EXTS)})"
109   - f"或视频({'/'.join(e[1:] for e in VIDEO_EXTS)})")
  92 + if ext not in (".jpg", ".jpeg", ".png", ".webp"):
  93 + raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)")
110 94 body = await file.read()
111   - limit = MAX_VIDEO_BYTES if is_video else MAX_UPLOAD_BYTES
112   - if len(body) > limit:
113   - raise HTTPException(413, f"文件超过 {limit // 1024 // 1024}MB 上限")
  95 + if len(body) > MAX_UPLOAD_BYTES:
  96 + raise HTTPException(413, f"文件超过 {MAX_UPLOAD_BYTES // 1024 // 1024}MB 上限")
114 97 aid = uuid.uuid4().hex[:16]
115 98 path = ASSETS / f"{aid}{ext}"
116 99 path.write_bytes(body)
117   - if is_video:
118   - if not _looks_like_video(body[:16], ext):
119   - path.unlink(missing_ok=True)
120   - raise HTTPException(400, "文件不是有效视频")
121   - else:
122   - try:
123   - Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制
124   - except Exception:
125   - path.unlink(missing_ok=True)
126   - raise HTTPException(400, "文件不是有效图片")
127   - return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}", "kind": "video" if is_video else "image"}
  100 + try:
  101 + Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制
  102 + except Exception:
  103 + path.unlink(missing_ok=True)
  104 + raise HTTPException(400, "文件不是有效图片")
  105 + return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}"}
128 106  
129 107  
130 108 def _public_base() -> str:
... ...
h3-console/static/index.html
... ... @@ -49,7 +49,7 @@
49 49 button.submit:disabled { opacity: .5; cursor: default; }
50 50 .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; }
51 51 .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
52   - .thumbrow img, .thumbrow video { height: 60px; border-radius: 4px; }
  52 + .thumbrow img { height: 60px; border-radius: 4px; }
53 53 .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; }
54 54 .err { color: #c33; font-size: 13px; margin-top: 8px; }
55 55  
... ... @@ -148,17 +148,8 @@
148 148 <label>Prompt</label>
149 149 <textarea id="ref2va-prompt" rows="10" placeholder="..."></textarea>
150 150 <label>参考图(可多选,没有张数上限,越多序列越长越慢)</label>
151   - <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti('ref2va-refs','image')">
  151 + <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti()">
152 152 <div class="thumbrow" id="ref2va-thumbs"></div>
153   - <label>参考视频(可多选,最多 3 段,会带上视频自己的声音;协议规格上限,不是随便定的)</label>
154   - <input id="ref2va-videos" type="file" accept="video/*" multiple onchange="uploadMulti('ref2va-videos','video')">
155   - <div class="thumbrow" id="ref2va-video-thumbs"></div>
156   - <label>或直接粘贴已经在别处的图片/视频 URL(不经过我们的上传,省一次搬运)</label>
157   - <div class="row">
158   - <div><input id="ref2va-url-input" type="text" placeholder="https://.../xxx.jpg 或 .mp4"></div>
159   - <div style="flex:0"><button type="button" class="submit" style="margin-top:0" onclick="addRefUrl()">添加</button></div>
160   - </div>
161   - <div class="err" id="ref2va-url-err"></div>
162 153 <div class="row">
163 154 <div><label>分辨率</label><select id="ref2va-res" class="res-select"></select></div>
164 155 <div><label>时长</label><select id="ref2va-dur" class="dur-select"></select></div>
... ... @@ -191,7 +182,7 @@
191 182 // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。
192 183 const H3_BASE = "__H3_BASE_PATH__";
193 184  
194   -const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [], 'ref2va-videos': [] };
  185 +const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] };
195 186 let jobsCache = [];
196 187 let trashCache = [];
197 188 let viewingTrash = false;
... ... @@ -299,61 +290,29 @@ async function uploadFile(file) {
299 290 return await r.json();
300 291 }
301 292  
302   -// 图片用 <img>、视频用 <video controls muted>——缩略图这一层不区分是上传来的还是直接粘贴
303   -// URL 来的,两条路径(uploadMulti/addRefUrl)都调这个函数,视觉上完全一致。
304   -function appendThumb(container, url, kind) {
305   - const el = kind === 'video' ? document.createElement('video') : document.createElement('img');
306   - el.src = url;
307   - if (kind === 'video') { el.controls = true; el.muted = true; }
308   - container.appendChild(el);
309   -}
310   -
311 293 async function uploadTo(inputId) {
312 294 const el = document.getElementById(inputId);
313 295 if (!el.files[0]) return;
314 296 try {
315 297 const res = await uploadFile(el.files[0]);
316 298 uploaded[inputId] = res.url;
317   - appendThumb(document.getElementById('fl2va-thumbs'), res.url, 'image');
  299 + const thumbs = document.getElementById('fl2va-thumbs');
  300 + const img = document.createElement('img'); img.src = res.url; thumbs.appendChild(img);
318 301 } catch (e) { document.getElementById('fl2va-err').textContent = '上传失败: ' + e.message; }
319 302 }
320 303  
321   -// kind 是 'image' 或 'video',两个上传框(参考图/参考视频)共用这一个函数,
322   -// 只是目标数组、缩略图容器、期望的文件类型不同。
323   -async function uploadMulti(inputId, kind) {
324   - const el = document.getElementById(inputId);
325   - const thumbs = document.getElementById(kind === 'video' ? 'ref2va-video-thumbs' : 'ref2va-thumbs');
  304 +async function uploadMulti() {
  305 + const el = document.getElementById('ref2va-refs');
  306 + const thumbs = document.getElementById('ref2va-thumbs');
326 307 for (const f of el.files) {
327 308 try {
328 309 const res = await uploadFile(f);
329   - uploaded[inputId].push(res.url);
330   - appendThumb(thumbs, res.url, kind);
  310 + uploaded['ref2va-refs'].push(res.url);
  311 + const img = document.createElement('img'); img.src = res.url; thumbs.appendChild(img);
331 312 } catch (e) { document.getElementById('ref2va-err').textContent = '上传失败: ' + e.message; }
332 313 }
333 314 }
334 315  
335   -// "粘贴 URL" 这条路径不经过我们的 /v1/assets——用户已经有一个别处能公网访问的图片/视频链接时,
336   -// 没必要先下载再上传再吐 URL 绕一圈。按扩展名判断图片还是视频,判断不出来就报错,不瞎猜。
337   -function addRefUrl() {
338   - const input = document.getElementById('ref2va-url-input');
339   - const errBox = document.getElementById('ref2va-url-err');
340   - errBox.textContent = '';
341   - const url = input.value.trim();
342   - if (!url) return;
343   - const bare = url.split('?')[0].split('#')[0].toLowerCase();
344   - if (/\.(mp4|mov|webm)$/.test(bare)) {
345   - uploaded['ref2va-videos'].push(url);
346   - appendThumb(document.getElementById('ref2va-video-thumbs'), url, 'video');
347   - } else if (/\.(jpg|jpeg|png|webp)$/.test(bare)) {
348   - uploaded['ref2va-refs'].push(url);
349   - appendThumb(document.getElementById('ref2va-thumbs'), url, 'image');
350   - } else {
351   - errBox.textContent = '看不出是图片还是视频——URL 需要以 .jpg/.png/.webp 或 .mp4/.mov/.webm 结尾';
352   - return;
353   - }
354   - input.value = '';
355   -}
356   -
357 316 // 分辨率/时长/Turbo/步数/Seed 五个字段,三个 tab 结构完全一样,抽出来一份读取逻辑。
358 317 function readParamSelects(prefix) {
359 318 const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value);
... ... @@ -381,13 +340,10 @@ async function submitJob(kind) {
381 340 ...readParamSelects('fl2va'),
382 341 };
383 342 } else if (kind === 'ref2va') {
384   - if (uploaded['ref2va-refs'].length === 0 && uploaded['ref2va-videos'].length === 0) {
385   - errBox.textContent = '请先上传至少一张参考图或一段参考视频'; return;
386   - }
  343 + if (uploaded['ref2va-refs'].length === 0) { errBox.textContent = '请先上传至少一张参考图'; return; }
387 344 body = {
388 345 prompt: document.getElementById('ref2va-prompt').value,
389 346 reference_image_urls: uploaded['ref2va-refs'],
390   - reference_video_urls: uploaded['ref2va-videos'],
391 347 ...readParamSelects('ref2va'),
392 348 };
393 349 }
... ... @@ -404,14 +360,14 @@ async function submitJob(kind) {
404 360 // 重跑:不直接提交,而是把这条任务当初的 req 灌回"新建任务"表单(对应的 tab、参数、
405 361 // 已上传的图片 URL 都原样带过去),跳到 /newtask.html,用户看一眼确认无误后自己点提交——
406 362 // 跟正常新建任务走的是同一条路径、同一次人工确认,不是绕过表单直接后台再发一次请求。
407   -function setThumbs(elId, urls, kind = 'image') {
  363 +function setThumbs(elId, urls) {
408 364 const box = document.getElementById(elId); box.innerHTML = '';
409   - urls.forEach(u => { if (u) appendThumb(box, u, kind); });
  365 + urls.forEach(u => { if (!u) return; const img = document.createElement('img'); img.src = u; box.appendChild(img); });
410 366 }
411 367  
412 368 function prefillCreate(req) {
413 369 const kind = (req.first_frame_url || req.last_frame_url) ? 'fl2va'
414   - : ((req.reference_image_urls || []).length || (req.reference_video_urls || []).length) ? 'ref2va' : 'text';
  370 + : (req.reference_image_urls || []).length ? 'ref2va' : 'text';
415 371  
416 372 document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === kind));
417 373 ['text', 'fl2va', 'ref2va'].forEach(t => document.getElementById('tab-' + t).style.display = (t === kind ? 'block' : 'none'));
... ... @@ -448,9 +404,7 @@ function prefillCreate(req) {
448 404 setThumbs('fl2va-thumbs', [req.first_frame_url, req.last_frame_url]);
449 405 } else if (kind === 'ref2va') {
450 406 uploaded['ref2va-refs'] = (req.reference_image_urls || []).slice();
451   - uploaded['ref2va-videos'] = (req.reference_video_urls || []).slice();
452   - setThumbs('ref2va-thumbs', uploaded['ref2va-refs'], 'image');
453   - setThumbs('ref2va-video-thumbs', uploaded['ref2va-videos'], 'video');
  407 + setThumbs('ref2va-thumbs', uploaded['ref2va-refs']);
454 408 }
455 409  
456 410 showCreate();
... ... @@ -625,17 +579,11 @@ async function refreshDetail() {
625 579 }
626 580  
627 581 let refThumbs = '';
628   - const imgs = [], vids = [];
  582 + const imgs = [];
629 583 if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]);
630 584 if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]);
631 585 (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u]));
632   - (req.reference_video_urls || []).forEach((u, i) => vids.push(['参考视频'+(i+1), u]));
633   - if (imgs.length || vids.length) {
634   - refThumbs = `<div class="thumbrow">`
635   - + imgs.map(([label,u]) => `<img src="${u}" title="${label}">`).join('')
636   - + vids.map(([label,u]) => `<video src="${u}" title="${label}" controls muted></video>`).join('')
637   - + `</div>`;
638   - }
  586 + if (imgs.length) refThumbs = `<div class="thumbrow">${imgs.map(([label,u]) => `<img src="${u}" title="${label}">`).join('')}</div>`;
639 587  
640 588 box.innerHTML = `
641 589 <div class="back" onclick="showCreate()">← 新建任务</div>
... ...
h3-rtxpro6000/README.md 0 → 100644
  1 +# MiniMax-H3 视频生成部署(RTX PRO 6000 Blackwell,单卡 96GB)
  2 +
  3 +新租的 AutoDL 容器,跟 `192.168.110.67`(2080Ti 那台)完全独立,不共享任何环境/显存/磁盘。
  4 +起这台的直接原因:怀疑 2080Ti 上用的 NF4(4-bit)量化是画质问题的根源,这张卡显存/磁盘都
  5 +宽松,换成更高精度的量化(int8-convrot)做对照。
  6 +
  7 +> **状态(2026-08-21 00:23)**:环境装好,模型下完,**首次冒烟测试成功**(50步全量出片,
  8 +> 有限性/纯色检查通过)。**画质待人工确认**——脚本能测出"全黑/NaN"这类硬故障,测不出
  9 +> "好不好看",这一步必须人看视频。样片:`t2va_20260821-002322.mp4`(生成时留在服务器
  10 +> `outputs/` 下,同时已下载到发起这次会话的本地机器)。
  11 +
  12 +---
  13 +
  14 +## 接入
  15 +
  16 +```bash
  17 +ssh -p 17473 root@connect.westd.seetacloud.com # 密码 aka3QXmPlg/V
  18 +```
  19 +
  20 +容器化实例(`/.dockerenv` 存在,PID1 是裸 bash 不是 init)。**数据盘扩容/机器重启必须走
  21 +AutoDL 网页控制台**,容器内 `reboot`/`shutdown` 没用——PID1 接不住信号,扩容不生效
  22 +(实测过:容器内重启后 `df` 还是旧容量,网页控制台重启后才变)。
  23 +
  24 +## 硬件
  25 +
  26 +| 项 | 值 |
  27 +|---|---|
  28 +| GPU | RTX PRO 6000 Blackwell,**96GB**(`nvidia-smi` 显示 97887MiB),**compute cap 12.0(sm_120)** |
  29 +| 驱动/CUDA | 595.58.03 / CUDA 13.2 |
  30 +| CPU/内存 | 208 核 / 1TB |
  31 +| 系统盘 | 30GB,**容器重置就没了,别往这儿装东西** |
  32 +| 数据盘 | `/root/autodl-tmp`,已从免费 50GB 扩到 **100GB**(当前用量约 75GB) |
  33 +| 网络 | ModelScope **直连**(`no_proxy` 已含 `modelscope.com`);HuggingFace/GitHub 需 `source /etc/network_turbo`(仅限学术用途,AutoDL 自己也说"不承诺稳定性") |
  34 +
  35 +`ModelScope` 直连是这台机器上最快的下载源,实测比 hf-mirror.com(0.5MB/s)、
  36 +`network_turbo` 代理到 HF 官方(0.3MB/s)都快。单连接 1.5–4MB/s、aria2c 16 连接能到
  37 +5–11MB/s(**多连接不是线性提升**——测过两个文件各开 8 连接同时下,合计吞吐反而比一个
  38 +文件开 16 连接更低,说明限速在 ModelScope 端是整体限速,不是单连接限速)。下大文件一律
  39 +`aria2c -x16 -s16` + `setsid nohup` 脱离 SSH 会话。
  40 +
  41 +## 环境(uv,不用 conda 装依赖)
  42 +
  43 +```bash
  44 +source /root/miniconda3/etc/profile.d/conda.sh && conda activate base # 只为了拿到 uv 二进制
  45 +pip install -q uv # /etc/pip.conf 已配好 aliyun 源
  46 +cd /root/autodl-tmp/minimax-h3
  47 +export UV_INDEX_URL=http://mirrors.aliyun.com/pypi/simple
  48 +uv venv --python 3.12 .venv && source .venv/bin/activate
  49 +uv pip install torch torchaudio # torch 预装镜像自带 2.13.0+cu130,torchaudio 要单独装
  50 +uv pip install -e './DiffSynth-Studio[audio,quant]' # 见下面"装环境的坑",两个 extras 都要
  51 +```
  52 +
  53 +阿里云源直接镜像了 PyPI 官方的 cu130 捆绑 wheel,不需要单独配 pytorch.org 的 index。
  54 +
  55 +尝试过让 uv 连 Python 解释器本身都自己管(`uv python install 3.12`,不借 conda 的
  56 +python3.12),但 uv 的 Python 发行版从 GitHub Releases 下,走 `network_turbo` 代理两次
  57 +都卡死没装成——不是关键路径,放弃了,venv 底层解释器目前还是 conda 那个 3.12.3,
  58 +不影响包管理走 uv 独立安装这个核心诉求。
  59 +
  60 +## 精度选型:int8-convrot,不是 NF4
  61 +
  62 +完整的调研过程、GGUF 为什么被排除、各精度组合的真实文件大小对比表,见
  63 +`docs/todo/h3-rtxpro6000-setup.md`。这里只记结论:
  64 +
  65 +- **DiffSynth-Studio 官方支持** Comfy-Org 发布的 int8/fp8 量化权重(不止自家 NF4),
  66 + 通过 `comfy-kitchen` 后端,需要 `pip install "diffsynth[quant]"`。
  67 +- 用的是 `Comfy-Org/MiniMax-H3` 的**非剪枝** `int8_convrot`(DiT 34GB + 文本编码器 27GB),
  68 + VAE 走 `MiniMax/MiniMax-H3` 官方 bf16 版(video_vae 9.8GB + audio_vae 0.6GB),
  69 + 合计约 71GB。
  70 +- **不需要 NF4/bitsandbytes**:int8-convrot 是完全不同的量化路径(comfy-kitchen),
  71 + 比 NF4 的 4-bit 精度高一倍。
  72 +- **全程 bf16**,不是 fp32/fp16。2080Ti 那套 `_force_fp32_linear`/`_patch_dit_residual_fp32`
  73 + 补丁是 Turing 无 bf16 attention kernel 逼出来的(fp16 65504 上限装不下 50 层残差累积),
  74 + Blackwell 原生支持 bf16 attention + fp32 等量的动态范围,这些补丁**不需要**,直接照抄
  75 + DiffSynth 官方 int8-convrot 示例代码即可。
  76 +- **不需要 CPU offload**:单卡 96GB,71GB 权重常驻显存还有富余,冒烟测试峰值显存只有
  77 + **33.9G**。
  78 +
  79 +## 目录与路径
  80 +
  81 +| 用途 | 路径 |
  82 +|---|---|
  83 +| 根目录 | `/root/autodl-tmp/minimax-h3/` |
  84 +| venv | `/root/autodl-tmp/minimax-h3/.venv/` |
  85 +| DiffSynth 源码 | `/root/autodl-tmp/minimax-h3/DiffSynth-Studio/` |
  86 +| 模型权重 | `/root/autodl-tmp/minimax-h3/models/`(`<model_id>/<origin_file_pattern>` 平铺,DiffSynth 自己管,`.incomplete` 后缀标记未下完) |
  87 +| 日志 | `/root/autodl-tmp/minimax-h3/logs/` |
  88 +| 输出视频 | `/root/autodl-tmp/minimax-h3/outputs/` |
  89 +| 推理脚本 | `/root/autodl-tmp/minimax-h3/t2va-rtxpro6000.py`(**顶层平铺,不在 scripts/ 下**——本地副本在 `h3-rtxpro6000/scripts/`,跟 2080Ti 那套 `run.sh` 顶层/本地 scripts/ 路径不一致是同一种情况) |
  90 +
  91 +## 启动命令
  92 +
  93 +```bash
  94 +cd /root/autodl-tmp/minimax-h3 && source .venv/bin/activate
  95 +python t2va-rtxpro6000.py --seed 0 # 默认 832x480x124f,50步全量,不开turbo
  96 +python t2va-rtxpro6000.py --turbo # 4步Turbo LoRA,未在int8-convrot权重上验证过LoRA能不能正常挂载
  97 +python t2va-rtxpro6000.py --prompt "$(cat prompt.txt)" # prompt 含引号时从文件读,避开多层 shell 转义
  98 +```
  99 +
  100 +## 首次冒烟测试(2026-08-21 00:23)
  101 +
  102 +配置:832×480×124f,50 步全量(不开 Turbo),int8-convrot,seed=0。
  103 +
  104 +| 项目 | 实测 |
  105 +|---|---|
  106 +| 权重加载 | 83s |
  107 +| 生成耗时 | **183s(3.7s/步)** |
  108 +| 峰值显存 | **33.9G** |
  109 +| 视频 | h264 832×480 · 24fps · 124 帧(无截断) |
  110 +| 音频 | aac 32kHz 立体声 · mean -35.6dB · max -17.9dB(非静音) |
  111 +| 脚本内建检查 | 通过(无 `[FAIL]`,退出码 0) |
  112 +
  113 +**对比 2080Ti**:fp32 50 步是 107.3s/步,这张卡 3.7s/步,**快 ~29×**(Blackwell 原生
  114 +bf16 tensor core/attention kernel + 不用 CPU offload 的综合结果)。
  115 +
  116 +## 装环境踩的坑
  117 +
  118 +1. **`diffsynth[quant]` 不够,还要 `[audio]`**:`av`/`torchaudio`/`torchcodec`/`librosa`
  119 + 是单独一个 `audio` extras 分组(见 `DiffSynth-Studio/pyproject.toml`),跟 `quant`
  120 + (`bitsandbytes`/`comfy-kitchen`/`torchao`)分开。只装 `[quant]` 会在运行时连续炸
  121 + `ModuleNotFoundError: torchaudio` 然后 `ModuleNotFoundError: av`。正确装法:
  122 + `uv pip install -e './DiffSynth-Studio[audio,quant]'`。
  123 +2. **SSH 命令套 `expect` 时,方括号会被 Tcl 当命令替换语法**:`pip install "pkg[extra]"`
  124 + 这类命令直接塞进 `expect -c '...spawn ssh ... "..."'` 会报 `invalid command name "extra"`,
  125 + 多层转义极易出错。稳定做法:把要跑的命令写成本地 `.sh` 文件,`scp` 上去再
  126 + `ssh ... "bash script.sh"` 执行,彻底绕开这层转义。
  127 +3. **AutoDL 扩容数据盘要走网页控制台重启,容器内 `reboot` 无效**:`/.dockerenv` 存在、
  128 + PID1 是裸 bash,容器内跑 `reboot`/`shutdown` 接不住信号,`df` 容量不会变。
  129 +
  130 +## 还没做的事
  131 +
  132 +1. **人工看画质**——这是唯一悬而未决的验证。如果 int8-convrot 确实比 NF4 画质好,
  133 + `docs/todo/h3-rtxpro6000-setup.md` 该挪到 `docs/done/`;如果没有明显差异,说明
  134 + `docs/todo/quality-tuning-plan.md` 里列的分辨率/CFG/negative prompt 才是真正瓶颈,
  135 + 量化精度不是。
  136 +2. Turbo LoRA 在 int8-convrot 权重上是否能正常挂载,还没测过。
  137 +3. 还没封 HTTP API / console(2080Ti 那套 `api.py`/`h3-console` 要不要迁过来,看画质
  138 + 验证结果再决定值不值得投入)。
... ...
h3-rtxpro6000/scripts/t2va-rtxpro6000.py 0 → 100644
  1 +#!/usr/bin/env python
  2 +"""MiniMax-H3 FL2VA (int8-convrot) Text -> Video+Audio on RTX PRO 6000 (Blackwell / sm_120).
  3 +
  4 +Blackwell has native bf16 attention kernels and native int8/fp8 tensor cores, so none of
  5 +the Turing-era workarounds in the 2080Ti script (t2va.py) apply here:
  6 + * No fp32 residual-stream patch, no fp32 condition_proj patch -- those existed because
  7 + sm_75 has zero bf16 attention kernel and fp16's 65504 max overflowed after 50 residual
  8 + blocks (see 2080tiserver/minimax-h3/README.md). bf16 has fp32's dynamic range, so this
  9 + pipeline runs bf16 end-to-end untouched, matching DiffSynth's own official example
  10 + (examples/minimax_h3/model_inference/MiniMax-H3-Int8-ConvRot-FL2VA.py).
  11 + * No CPU weight offload -- single 96GB GPU, ~71GB of int8-convrot weights fit resident.
  12 + vram_config below still declares offload_device="cpu" (copied from the official example
  13 + verbatim) but with this much headroom it should rarely trigger.
  14 + * No sm_75 assert -- this script targets sm_80+ (native bf16 attention) only.
  15 +
  16 +Precision: int8-convrot (Comfy-Org/MiniMax-H3), not NF4. Roughly 2x the bits of the NF4
  17 +build used on the 2080Ti; chosen specifically to test whether NF4's 4-bit quantization was
  18 +behind the quality complaints on that machine. See docs/todo/h3-rtxpro6000-setup.md for the
  19 +size/quality tradeoff table across NF4 / int8-convrot / fp8-pruned / bf16.
  20 +
  21 +Needs `diffsynth[quant]` installed (comfy-kitchen backend for int8-convrot loading).
  22 +"""
  23 +import os, sys, time, argparse
  24 +
  25 +BASE = os.path.dirname(os.path.abspath(__file__))
  26 +os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", os.path.join(BASE, "models"))
  27 +
  28 +p = argparse.ArgumentParser()
  29 +p.add_argument("--prompt", default="A girl is very happy, she is speaking in english: "
  30 + "“I enjoy working with Diffsynth-Studio, it's a perfect framework.”")
  31 +p.add_argument("--height", type=int, default=480)
  32 +p.add_argument("--width", type=int, default=832)
  33 +p.add_argument("--frames", type=int, default=124)
  34 +p.add_argument("--steps", type=int, default=None, help="默认: turbo=4 / 非turbo=50")
  35 +p.add_argument("--flow-shift", type=float, default=None,
  36 + help="不传则用 pipeline 内置默认值——2080Ti 脚本硬编码过 6.0/12.0,"
  37 + "但官方 int8-convrot 示例根本没传这个参数,不要凭空套用未经验证的值")
  38 +p.add_argument("--seed", type=int, default=0)
  39 +p.add_argument("--turbo", action="store_true", default=False,
  40 + help="4步 Turbo LoRA。未在 int8-convrot 权重上验证过 LoRA 挂载是否生效,"
  41 + "第一轮测画质建议先不开,跑官方默认的 50 步全量")
  42 +p.add_argument("--vram-reserve", type=float, default=2.0,
  43 + help="留给激活的显存(GB)。单卡独占,照官方示例默认留 2G")
  44 +p.add_argument("--out", default=None)
  45 +args = p.parse_args()
  46 +
  47 +import torch
  48 +from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig
  49 +from diffsynth.utils.data.audio_video import write_video_audio
  50 +
  51 +prop = torch.cuda.get_device_properties(0)
  52 +print(f"[env] GPU={prop.name} sm_{prop.major}{prop.minor}", flush=True)
  53 +assert prop.major >= 8, ("this script assumes native bf16 attention kernels (sm_80+); "
  54 + "on Turing (sm_75) use minimax-h3/scripts/t2va.py instead")
  55 +
  56 +DTYPE = torch.bfloat16
  57 +vram_config = dict(
  58 + offload_dtype=DTYPE, offload_device="cpu",
  59 + onload_dtype=DTYPE, onload_device="cpu",
  60 + preparing_dtype=DTYPE, preparing_device="cuda",
  61 + computation_dtype=DTYPE, computation_device="cuda",
  62 +)
  63 +COMFY = "Comfy-Org/MiniMax-H3"
  64 +MMH3 = "MiniMax/MiniMax-H3"
  65 +
  66 +total_gb = torch.cuda.mem_get_info("cuda")[1] / 1024**3
  67 +vram_limit = total_gb - args.vram_reserve
  68 +print(f"[env] dtype={DTYPE} total_vram={total_gb:.1f}G vram_limit={vram_limit:.1f}G", flush=True)
  69 +
  70 +t0 = time.time()
  71 +pipe = MiniMaxH3Pipeline.from_pretrained(
  72 + torch_dtype=DTYPE, device="cuda",
  73 + model_configs=[
  74 + ModelConfig(model_id=COMFY, origin_file_pattern="diffusion_models/minimax_h3_fl2va_int8_convrot.safetensors", **vram_config),
  75 + ModelConfig(model_id=COMFY, origin_file_pattern="text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors", **vram_config),
  76 + ModelConfig(model_id=MMH3, origin_file_pattern="FL2VA/video_vae/source/model.safetensors", **vram_config),
  77 + ModelConfig(model_id=MMH3, origin_file_pattern="FL2VA/audio_vae/model.safetensors", **vram_config),
  78 + ],
  79 + processor_config=ModelConfig(model_id=MMH3, origin_file_pattern="FL2VA/processor/"),
  80 + vram_limit=vram_limit,
  81 +)
  82 +print(f"[load] pipeline ready in {time.time()-t0:.0f}s", flush=True)
  83 +
  84 +if args.turbo:
  85 + pipe.load_lora(pipe.dit, ModelConfig(
  86 + model_id="lightx2v/Minimax-h3-Turbo",
  87 + origin_file_pattern="minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"))
  88 +steps = args.steps if args.steps is not None else (4 if args.turbo else 50)
  89 +
  90 +gen_kwargs = dict(
  91 + prompt=args.prompt,
  92 + height=args.height, width=args.width, num_frames=args.frames,
  93 + num_inference_steps=steps, seed=args.seed,
  94 +)
  95 +if args.flow_shift is not None:
  96 + gen_kwargs["flow_shift"] = args.flow_shift
  97 +
  98 +print(f"[gen ] {args.width}x{args.height} {args.frames}f steps={steps} "
  99 + f"turbo={args.turbo} flow_shift={args.flow_shift if args.flow_shift is not None else 'pipeline默认'} "
  100 + f"seed={args.seed}", flush=True)
  101 +t1 = time.time()
  102 +video, audio = pipe(**gen_kwargs)
  103 +gen_s = time.time() - t1
  104 +
  105 +# 退出码不可信是 2080Ti 上踩过的坑:原脚本从不检查 video 是否非有限/纯色,全黑视频照样退出码 0。
  106 +# 照抄那次修的检查(见 minimax-h3/README.md「退出码不可信」一节)。
  107 +_bad = []
  108 +if not torch.isfinite(audio if torch.is_tensor(audio) else torch.tensor(0.)).all():
  109 + _bad.append("audio 含非有限值")
  110 +def _video_stats(v):
  111 + import numpy as _np
  112 + if torch.is_tensor(v):
  113 + a = v.float()
  114 + return float(a.mean()), float(a.std()), bool(torch.isfinite(a).all())
  115 + try:
  116 + arrs = [_np.asarray(f.convert("RGB"), dtype=_np.float32) for f in v[:: max(1, len(v)//8)]]
  117 + a = _np.stack(arrs)
  118 + return float(a.mean()), float(a.std()), True
  119 + except Exception:
  120 + return None, None, True
  121 +_mean, _std, _fin = _video_stats(video)
  122 +if not _fin:
  123 + _bad.append("video 含非有限值")
  124 +if _std is not None and _std < 1.0:
  125 + _bad.append(f"video 近乎纯色 (std={_std:.3f}, mean={_mean:.3f})")
  126 +if _bad:
  127 + print("[FAIL] " + ";".join(_bad), flush=True)
  128 +
  129 +out = args.out or os.path.join(BASE, "outputs", f"t2va_{time.strftime('%Y%m%d-%H%M%S')}.mp4")
  130 +os.makedirs(os.path.dirname(out), exist_ok=True)
  131 +write_video_audio(video=video, audio=audio, output_path=out, fps=24, audio_sample_rate=32000)
  132 +
  133 +print(f"[done] generation {gen_s:.0f}s ({gen_s/max(steps,1):.1f}s/step), "
  134 + f"peak VRAM {torch.cuda.max_memory_allocated()/1024**3:.1f}G", flush=True)
  135 +print(f"[out ] {out} ({os.path.getsize(out)/1024**2:.1f} MB)", flush=True)
  136 +if _bad:
  137 + sys.exit(3)
... ...
minimax-h3/api.py
... ... @@ -67,10 +67,6 @@ class GenReq(BaseModel):
67 67 # 短边 480 时约 390 行,而目标视频本身才 14430 行。
68 68 reference_image_urls: list[str] = []
69 69 ref_image_short_edge: int = Field(480, ge=64, le=2048)
70   - # 参考视频(Ref2VA 的第二种引用类型)。2026-08-20 新加,本地开发机没有 GPU,还没在真实
71   - # 模型上跑过一次——pipeline 层的 references 协议支持 "video"/"video_audio" 两种(帧列表
72   - # +可选音轨),细节见 _references()。上限 3 段是文档写的 R2V 协议规格,不是这里随便定的。
73   - reference_video_urls: list[str] = Field(default_factory=list, max_length=3)
74 70  
75 71 @model_validator(mode="after")
76 72 def _check_rows(self):
... ... @@ -204,45 +200,11 @@ def _keyframes(req):
204 200 indices.append(index)
205 201 return (images, indices) if images else (None, None)
206 202  
207   -def _fetch_to_tmp(url: str, suffix: str) -> str:
208   - """视频不像图片那样能直接从内存 bytes 解码——PyAV 要能 seek 的文件,最简单的办法是
209   - 先落一份临时文件再读,读完调用方自己删。"""
210   - import tempfile
211   - fd, path = tempfile.mkstemp(suffix=suffix)
212   - with os.fdopen(fd, "wb") as f, urllib.request.urlopen(url, timeout=120) as resp:
213   - f.write(resp.read())
214   - return path
215   -
216 203 def _references(req):
217   - """参考图/参考视频按请求顺序取下来拼成 pipeline 要的 references 列表。
218   -
219   - 图片这段是原来就有的:尺寸交给 pipeline 按 ref_image_short_edge 归一,这里不resize。
220   -
221   - 视频这段是 2026-08-20 新加的,⚠️ 本地开发机没有 GPU,从没在真实模型上跑过一次——
222   - height/width 用 req 的输出分辨率、num_frames 用 req.frames,是"参考视频≈目标视频
223   - 同尺寸同时长"这个简单假设,不是从官方文档验证过的最优值。pipeline 其实另外单独暴露了
224   - ref_video_short_edge/ref_video_max_pixels 两个参数用来做参考视频自己的归一化(在
225   - worker() 的 pipe() 调用里已经按默认值传了),如果真机测试发现参考视频的运镜/动作锁不住,
226   - 先怀疑这里的 decode 分辨率选择,不是模型不支持。
227   - 有无音轨(read_video_audio 读不到音轨时返回 None)决定 type 是 "video" 还是
228   - "video_audio"——pipeline 对 "video" 类型强制要求不能带 audio 字段,两者不能混用。
229   - """
230   - refs = [{"type": "image", "image": _fetch_image(url)} for url in req.reference_image_urls]
231   - if req.reference_video_urls:
232   - from diffsynth.utils.data.audio_video import read_video_audio
233   - for url in req.reference_video_urls:
234   - ext = os.path.splitext(url.split("?")[0])[1] or ".mp4"
235   - path = _fetch_to_tmp(url, ext)
236   - try:
237   - frames, audio, sr = read_video_audio(path, height=req.height, width=req.width,
238   - num_frames=req.frames, fps=24)
239   - if audio is not None:
240   - refs.append({"type": "video_audio", "video": frames, "audio": audio, "sample_rate": sr})
241   - else:
242   - refs.append({"type": "video", "video": frames})
243   - finally:
244   - os.unlink(path)
245   - return refs or None
  204 + """参考图按请求顺序取下来。尺寸交给 pipeline 按 ref_image_short_edge 归一,这里不动。"""
  205 + if not req.reference_image_urls:
  206 + return None
  207 + return [{"type": "image", "image": _fetch_image(url)} for url in req.reference_image_urls]
246 208  
247 209 def worker():
248 210 from diffsynth.utils.data.audio_video import write_video_audio
... ...