Commit 5b4b1522c3ba64c8a75acdeafb2ba25b56092dba
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>
Showing
9 changed files
with
452 additions
and
161 deletions
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 | ... | ... |