Commit 6b3f5a6a2a27374f3a16d4b0c436b0e7020e8775
1 parent
c082a92d
Ref2VA 加参考视频/URL 直填 + docs/todo/done 任务追踪目录
本地开发(脱离办公网络,只能走公网反代),未在真实 GPU 上验证,详见 docs/todo/verify-video-reference-on-gpu.md。 - minimax-h3/api.py: GenReq 加 reference_video_urls,_references() 打通到 pipeline 已支持的 video/video_audio 参考类型 - h3-console/server.py: /v1/assets 加视频上传支持(mp4/mov/webm,200MB) - h3-console/static/index.html: 参考视频多选上传 + URL 直填 + 预览, 重跑/任务详情页同步支持 新增 docs/todo 与 docs/done:把「明天要验证」和「画质改进方案」落成文档, docs/README.md 做索引,不重复 CHANGELOG 已有的细节。
Showing
9 changed files
with
355 additions
and
34 deletions
CHANGELOG.md
| 1 | 1 | # 变更记录 |
| 2 | 2 | |
| 3 | +## 2026-08-20(夜,又续七)— Ref2VA 加参考视频/URL 直填,本地开发,⚠️ 没有 GPU 没测过 | |
| 4 | + | |
| 5 | +**背景**:用户脱离了公司网络,只能靠 `qwen.wudaima.com/console` 这条公网线路访问服务器, | |
| 6 | +SSH 和局域网直连都不通了。今晚在本地 Mac 上改代码,**明天回公司网络才能真正跑一次生成** | |
| 7 | +验证——这条改动因此比之前几轮多一层风险:语法/接口层面本地能自测,但视频参考这条链路 | |
| 8 | +从没在真实 GPU + 真实模型上跑过。 | |
| 9 | + | |
| 10 | +读了 https://docs.comfy.org/zh/tutorials/video/minimax/minimax-h3 对照现状,发现两件事: | |
| 11 | +1. R2V(参考生视频)协议规格是最多 9 张参考图 + 3 段参考视频(各带自己的音轨)+ 3 段独立 | |
| 12 | + 音频,prompt 里用 `<Picture N>`/`<Video N>`/`<Audio N>` 标记引用;我们只做了参考图。 | |
| 13 | +2. 但实际跑模型的那层 pipeline(DiffSynth-Studio 的 `MiniMaxH3Pipeline`)本来就支持 | |
| 14 | + `references` 列表里放 `{"type":"video","video":[...]}` / `{"type":"video_audio",...}` / | |
| 15 | + `{"type":"audio",...}`——**这是我们自己代码的封装没跟上,不用换模型**。 | |
| 16 | + | |
| 17 | +**这次先做参考视频(图片已经支持多选,音频先不做,用户明确选的范围)**: | |
| 18 | + | |
| 19 | +- `minimax-h3/api.py`:`GenReq` 加 `reference_video_urls`(`max_length=3`,对应协议规格上限)。 | |
| 20 | + `_references()` 从"只吐 image 类型"扩展成图片+视频都处理:视频先下载到临时文件(`read_video_audio` | |
| 21 | + 只认本地路径),调 `diffsynth.utils.data.audio_video.read_video_audio()` 解码+对齐音轨, | |
| 22 | + 读不到音轨(`waveform is None`)就退化成 `"video"`(静音)类型,读到了就用 `"video_audio"` | |
| 23 | + (pipeline 对这两种类型的字段要求不同,"video" 类型强制不能带 audio 字段,混用会报错)。 | |
| 24 | + ⚠️ **`read_video_audio` 的 `height`/`width`/`num_frames` 参数我用的是输出视频自己的分辨率/ | |
| 25 | + 帧数**(跟 `_keyframes()` 处理首末帧的方式一致),这是没验证过的简单假设——pipeline 其实 | |
| 26 | + 另外暴露了 `ref_video_short_edge`/`ref_video_max_pixels` 两个参数专门做参考视频自己的归一化 | |
| 27 | + (`worker()` 里已经按默认值传给 `pipe()` 了,只是从没真正生效过)。**明天上 GPU 如果参考 | |
| 28 | + 视频的运镜/动作锁不住,先怀疑这个 decode 分辨率选择,不是模型不支持。** | |
| 29 | +- `h3-console/server.py`:`/v1/assets` 加视频类型支持(`.mp4/.mov/.webm`),上限从图片的 | |
| 30 | + 20MB 提到视频的 200MB;没有引入新的解码依赖(PyAV/opencv),改用轻量魔数校验(mp4/mov | |
| 31 | + 是 `ftyp` box、webm 是 EBML 头)挡明显不是视频的文件,跟图片那边 `PIL.Image.verify()` | |
| 32 | + 同样的"校验但不做重活"思路。响应里加了 `kind` 字段(`image`/`video`),前端不用再猜。 | |
| 33 | +- `h3-console/static/index.html`:参考图 tab 加"参考视频"多选上传框(最多3段,跟后端上限 | |
| 34 | + 一致)+ 一个"粘贴图片/视频 URL"输入框(按扩展名判断类型,不经过我们的上传接口,用户已经 | |
| 35 | + 有公网可访问的素材时省一次搬运)。图片/视频缩略图统一走一个 `appendThumb()` helper, | |
| 36 | + 重跑(`prefillCreate`)和任务详情页的参考素材展示也跟着更新成图片+视频都渲染。 | |
| 37 | + | |
| 38 | +**本地验证过的部分(不需要 GPU)**:`node --check` 过了 JS 语法;起本地 uvicorn 实测 | |
| 39 | +`/v1/assets`——合法图片/合法 magic-byte 的 mp4 都能传上去且 `kind` 字段正确,改了后缀但内容 | |
| 40 | +不是视频的文件、完全不支持的扩展名都正确 400。页面渲染正常,新增的 DOM 元素都在。 | |
| 41 | + | |
| 42 | +**完全没验证过的部分**:`_references()` 里视频这段代码、`read_video_audio` 的调用方式、 | |
| 43 | +"video"/"video_audio" 类型选择的分支逻辑——本地没有 GPU 也没有装 DiffSynth-Studio,纯靠读 | |
| 44 | +源码(`diffsynth/pipelines/minimax_h3_audio_video.py`、`diffsynth/utils/data/audio_video.py` | |
| 45 | +的函数签名和注释)写的,**明天部署到服务器后第一件事应该是拿一段真实参考视频端到端跑一次**。 | |
| 46 | + | |
| 47 | +--- | |
| 48 | + | |
| 3 | 49 | ## 2026-08-20(夜,又续六)— 下载文件名统一叫 file.mp4 的问题 |
| 4 | 50 | |
| 5 | 51 | **背景**:用户发现下载多个任务的视频,文件名都叫 `file.mp4`——存到同一个下载目录会互相 | ... | ... |
docs/README.md
0 → 100644
| 1 | +# docs 索引 | |
| 2 | + | |
| 3 | +按状态分两个目录:一件事没做完在 `todo/`,做完验证过了挪到 `done/`(`mv` 过去,不是复制)。 | |
| 4 | +每份文档自己保持简短,完整的踩坑记录/实测数据以顶层 `CHANGELOG.md` 为准,这里的文件只做 | |
| 5 | +索引和"现在该干什么"的摘要,不重复 CHANGELOG 已经写过的细节。 | |
| 6 | + | |
| 7 | +## todo | |
| 8 | + | |
| 9 | +- [verify-video-reference-on-gpu.md](todo/verify-video-reference-on-gpu.md) — Ref2VA 新加的 | |
| 10 | + 参考视频功能(2026-08-20 本地写的,没有 GPU 没测过),明天回服务器第一件事要验证 | |
| 11 | +- [quality-tuning-plan.md](todo/quality-tuning-plan.md) — 为什么生成视频画质比 ComfyUI 官方 | |
| 12 | + 演示差很多:分辨率/Turbo步数/CFG/negative prompt 几个待实测方向,按优先级排好了 | |
| 13 | + | |
| 14 | +## done | |
| 15 | + | |
| 16 | +- [h3-console-build-and-deploy.md](done/h3-console-build-and-deploy.md) — h3-console 从零 | |
| 17 | + 构建到部署上线,公网访问改走 `qwen.wudaima.com/console` | |
| 18 | +- [h3-console-reliability-and-ux-fixes.md](done/h3-console-reliability-and-ux-fixes.md) — | |
| 19 | + 上线后的一系列可靠性修复(假运行中、并发竞态、任务落盘、下载文件名)+ 常用功能(重跑、 | |
| 20 | + 回收站、rows 动态帧数校验) | ... | ... |
docs/done/h3-console-build-and-deploy.md
0 → 100644
| 1 | +# h3-console:从零构建到公网可访问 | |
| 2 | + | |
| 3 | +**完成时间**:2026-08-20。 | |
| 4 | + | |
| 5 | +给 `minimax-h3/` 的四后端 API(`gateway.py` + 4× `api.py`)加了一层 Web 管理台:三个 tab | |
| 6 | +(文生视频 T2V / 首尾帧 I2V / 参考图 R2V)+ 上传 + 任务列表 + fl2va/ref2va 弹性调度器。 | |
| 7 | + | |
| 8 | +- 部署在服务器 `/data/models/minimax-h3/console/`,两个进程共享同一份数据库/图片目录: | |
| 9 | + 局域网 `:8080`(开弹性调度器)+ 公网子路径专用 `:8081`(不开调度器,避免两个调度器互抢)。 | |
| 10 | +- 公网访问:`http://qwen.wudaima.com/console`。原计划用独立域名 `gateway.wudaima.com`,实测 | |
| 11 | + 确认这条线路走不通(缺口在 frp 服务端 `60.205.214.169`,不是这台机器能改的),改走 | |
| 12 | + `qwen.wudaima.com` 的 `/console` 子路径,frp 按 `locations` 做路径分流,不影响原有服务。 | |
| 13 | +- 弹性调度器(kill+restart 某张 GPU 卡切 fl2va/ref2va)全链路在真实 GPU 上验证过:提交任务 → | |
| 14 | + 没有对应分区的空闲后端时先排队而不是直接 503 → supervisor 探测到需求后自动 flip 一张卡 → | |
| 15 | + 新后端 ready 后自动把排队任务提交上去 → 跑完 → 下载。 | |
| 16 | + | |
| 17 | +完整过程、踩过的坑(PID 提取正则写错、调度器连续 flip 好几张卡、`pkill -f` 误杀自己的 SSH | |
| 18 | +会话等)见顶层 `CHANGELOG.md` 里 2026-08-20「h3-console 部署上线」「新增 h3-console 管理台」 | |
| 19 | +「公网访问改走 qwen.wudaima.com/console 子路径」这几节,以及 `h3-console/README.md`。 | ... | ... |
docs/done/h3-console-reliability-and-ux-fixes.md
0 → 100644
| 1 | +# h3-console:可靠性修复 + 常用功能补齐 | |
| 2 | + | |
| 3 | +**完成时间**:2026-08-20,部署上线之后的一系列迭代(均已部署并验证)。 | |
| 4 | + | |
| 5 | +## 可靠性修复 | |
| 6 | + | |
| 7 | +- **任务卡在假"运行中"**:所在后端进程被重启(弹性调度器 flip 分区)后,gateway 会 404, | |
| 8 | + 但 console 原来遇到非 200 一律静默跳过、保留上次已知状态,导致 status 永远停在 | |
| 9 | + `running`。改成 404 时直接判 `failed` 并写清原因。 | |
| 10 | +- **并发提交竞态**:`create_video()` 首次提交、轮询时顺手提交、后台对账循环每 5 秒扫描, | |
| 11 | + 三条路都可能在同一个任务还没拿到 `gateway_id` 的窗口里并发提交给 gateway,导致同一条 | |
| 12 | + 任务在两张卡上各烧一遍 GPU。加了进程内的提交占坑集合堵住这个窗口。 | |
| 13 | +- **`api.py` 任务记录改成落盘**:原来只在内存里,弹性调度器 kill+restart 某张卡是常规操作 | |
| 14 | + (不是稀有事件),进程重启就把内存里的任务记录清空。改成每次状态变化都往 sqlite 写一份, | |
| 15 | + 进程重启后能读回来。 | |
| 16 | +- **下载文件名统一叫 `file.mp4`**:下载接口没设 `Content-Disposition`,浏览器只能从 URL | |
| 17 | + (以 `/file` 结尾,没扩展名)猜文件名。加上 `filename="h3-{任务id}.mp4"`。 | |
| 18 | + | |
| 19 | +## 常用功能 | |
| 20 | + | |
| 21 | +- **重跑按钮**:详情页加「重跑」,把当初的参数/上传素材灌回新建任务表单,用户确认无误后 | |
| 22 | + 自己点提交——不是静默后台重新提交(第一版是静默提交,用户反馈后改成回填表单)。 | |
| 23 | +- **软删除/回收站**:任务列表加「⋯」菜单删除、左下角加回收站入口带恢复,不碰 gateway/api.py | |
| 24 | + 上的任务本身。 | |
| 25 | +- **后端状态行补上 running/queued**:之前明明有任务在跑也只显示 `ok`,看不出哪张卡忙。 | |
| 26 | +- **frames 上限改按 rows(分辨率×帧数)动态判定**:原来硬编码 124,跟分辨率无关一刀切, | |
| 27 | + 改成按显存安全线动态判定,448×256/576×320 这些低分辨率档能跑到 362 帧(15秒)。 | |
| 28 | +- **任务详情页加显式"时长"字段**:原来只显示帧数,用户得自己心算除以 24fps。 | |
| 29 | + | |
| 30 | +完整排查过程和实测数据见顶层 `CHANGELOG.md` 对应日期的各节(标题里能直接搜到,比如 | |
| 31 | +「修复 console 任务提交竞态」「api.py 任务记录改成落盘」「下载文件名统一叫 file.mp4」)。 | ... | ... |
docs/todo/quality-tuning-plan.md
0 → 100644
| 1 | +# 画质改进方案:为什么我们的视频比 ComfyUI 文档演示的差很多 | |
| 2 | + | |
| 3 | +**背景**:对照 https://docs.comfy.org/zh/tutorials/video/minimax/minimax-h3 (官方 ComfyUI | |
| 4 | +工作流文档)跟我们现在的 `api.py`/`t2va.py`/console 默认参数,找出的差距,按影响力排序。 | |
| 5 | +都还没动手实测,需要真实 GPU,明天回服务器再做。 | |
| 6 | + | |
| 7 | +## 1. 分辨率——大概率是最大的差距 | |
| 8 | + | |
| 9 | +H3 原生分辨率是 1344×768(短边 768px)。我们线上默认 **832×480**,只有原生像素量的约 40%。 | |
| 10 | +真实上限被 `api.py` 的 `ROWS_LIMIT=20000`(帧数×宽×高的显存安全线)卡死——这是 Turing 显卡 | |
| 11 | +fp32-only(无高效 bf16/fp16 attention)导致的显存代价,不是随手设的参数。 | |
| 12 | + | |
| 13 | +**要做的实测**:在服务器上直接跑一次接近原生的分辨率(比如 1344×768,短时长/低帧数控制 | |
| 14 | +rows 不超限),跟现在的 832×480 默认档同 prompt/seed 对比,看画质差距有多大、显存/耗时代价 | |
| 15 | +多大。`ROWS_LIMIT` 支持 `H3_ROWS_LIMIT` 环境变量临时覆盖,不用改代码就能测。 | |
| 16 | + | |
| 17 | +## 2. Turbo 4 步 vs 关闭 Turbo 跑 20+ 步——现成的开关,从没系统测过 | |
| 18 | + | |
| 19 | +文档里 T2V/I2V 用的是 8 步蒸馏 LoRA,我们下载的两个 LoRA 文件名都写死是 `turbo_4step` | |
| 20 | +(`fl2va`/`ref2va` 各一个)。不用换文件就能验证的路径:console 早就支持关掉 Turbo、跑 20/50 | |
| 21 | +步全精度(`turbo=false`),但**默认值是"Turbo 开/4步"**(为了速度),从没系统对比过画质。 | |
| 22 | + | |
| 23 | +**要做的实测**:同 prompt/seed,Turbo 开(4步) vs 关(20步) 各跑一条,对比帧间差异/音频响度/ | |
| 24 | +肉眼观感。之前在纯文生视频上测过一次类似的(结论是步数越多帧间差异越小、音质越好,见 | |
| 25 | +`CHANGELOG.md` 「15 秒时长实测」一节),但没有在现在的默认分辨率/R2V 场景下重新验证过。 | |
| 26 | + | |
| 27 | +## 3. CFG scale / negative prompt——完全没调过 | |
| 28 | + | |
| 29 | +pipeline 本身支持 `cfg_scale`(默认 1.0,等于没有 CFG 引导)和 `negative_prompt`(默认空 | |
| 30 | +字符串),我们的 `api.py`/`t2va.py` 从来没传过这两个参数,一直骑着默认值裸跑。ComfyUI 文档 | |
| 31 | +没有明确给出推荐值(蒸馏模型常见把 guidance 焙进 LoRA 里,不一定需要开 CFG),这是一个完全 | |
| 32 | +没做过 A/B 的空白。 | |
| 33 | + | |
| 34 | +**要做的实测**:先确认默认值是不是真的最优——`cfg_scale` 调到 2-3 试试是否画质提升,同时看 | |
| 35 | +是否会因为蒸馏 LoRA 的缘故反而变差(过度引导在蒸馏模型上有时会出伪影)。`negative_prompt` | |
| 36 | +可以先加几个常见负面词(模糊、抖动、变形)试试。 | |
| 37 | + | |
| 38 | +## 4. audio_flow_shift——同样裸跑默认值 | |
| 39 | + | |
| 40 | +`audio_flow_shift` 默认 3.0,从没被覆盖过,音频质量没调过。跟 `flow_shift`(视频的)性质 | |
| 41 | +类似,值得跟着上面的实测一起做个粗调。 | |
| 42 | + | |
| 43 | +## 5. 8 步 Turbo LoRA——需要下载新文件,不是参数调整 | |
| 44 | + | |
| 45 | +文档里 T2V/I2V 用的是 8 步蒸馏 LoRA,我们只有 4 步版。如果第 2 项(关 Turbo 跑 20+ 步)验证 | |
| 46 | +画质确实好很多、但耗时代价太高,可以考虑换一个 8 步版 LoRA 做折中——**这个需要先找到/下载 | |
| 47 | +对应的模型文件,不是改个参数就行**,工作量和网络下载时间都要单独评估(服务器出口带宽约 | |
| 48 | +5.9MB/s,见顶层 `CLAUDE.md`「硬件的三条硬限制」)。 | |
| 49 | + | |
| 50 | +## 建议的实测顺序 | |
| 51 | + | |
| 52 | +1 和 2 不需要下载任何新文件、不需要改代码,最快能出结果,优先做。3、4 需要小改 `api.py` | |
| 53 | +把这两个参数串上(目前完全没有入口)。5 最后再看要不要投入。 | ... | ... |
docs/todo/verify-video-reference-on-gpu.md
0 → 100644
| 1 | +# 验证:参考视频(Ref2VA video reference)端到端跑通 | |
| 2 | + | |
| 3 | +**状态**:代码已写完(2026-08-20 夜),**完全没有在真实 GPU 上跑过**。本地 Mac 开发机没有卡, | |
| 4 | +也没装 DiffSynth-Studio,`_references()` 里视频这段是纯读 pipeline 源码写的,语法和上传校验 | |
| 5 | +本地测过,生成效果没测过。 | |
| 6 | + | |
| 7 | +## 改了什么 | |
| 8 | + | |
| 9 | +- `minimax-h3/api.py`:`GenReq` 加 `reference_video_urls`(最多 3 段)。`_references()` 把参考 | |
| 10 | + 视频下载到临时文件,调 `diffsynth.utils.data.audio_video.read_video_audio()` 解码+对齐音轨, | |
| 11 | + 读不到音轨就用 `"video"`(静音)类型,读到了用 `"video_audio"` 类型。 | |
| 12 | +- `h3-console/server.py`:`/v1/assets` 加 mp4/mov/webm 支持,200MB 上限,轻量魔数校验。 | |
| 13 | +- `h3-console/static/index.html`:参考图 tab 加视频多选上传 + URL 直填两条路径。 | |
| 14 | + | |
| 15 | +## ⚠️ 最大的不确定性:视频解码分辨率的选择 | |
| 16 | + | |
| 17 | +`read_video_audio(path, height, width, num_frames, fps=24)` 的 `height`/`width`/`num_frames` | |
| 18 | +现在传的是**输出视频自己的分辨率和帧数**(跟 `_keyframes()` 处理首末帧图片的方式一致)。这是 | |
| 19 | +没验证过的简单假设——pipeline 其实另外暴露了 `ref_video_short_edge`/`ref_video_max_pixels` | |
| 20 | +两个参数,专门用来做参考视频自己的归一化(`worker()` 里已经按默认值传给 `pipe()` 了,只是从 | |
| 21 | +没真正生效过,因为这条链路从没跑过)。 | |
| 22 | + | |
| 23 | +## 验证步骤 | |
| 24 | + | |
| 25 | +1. 部署到服务器(`server.py`/`api.py` 都要重启,跟着 README 的安全重启法:`ss` 按端口找 PID | |
| 26 | + kill,不要 `pkill -f`;重启前确认 `/v1/backends` 四张卡都空闲)。 | |
| 27 | +2. 找一段几秒钟的短视频(比如已有的某条历史生成产物),通过 h3-console 的参考图 tab 上传, | |
| 28 | + 提交一个 R2V 任务。 | |
| 29 | +3. 确认任务能跑完(不是在 `_references()` 这步就报错——先验证代码本身没有语法/接口层面的 | |
| 30 | + bug,这一步失败大概率是 `read_video_audio`/pipeline 调用姿势不对)。 | |
| 31 | +4. 跑完了看输出:参考视频里的角色/风格/运镜是不是真的被锁住了。如果锁不住(生成结果跟没给 | |
| 32 | + 参考视频差不多),先怀疑上面那条"解码分辨率假设",试试把 `read_video_audio` 的 | |
| 33 | + `height`/`width` 换成按 `ref_video_short_edge` 算出来的值,而不是输出分辨率。 | |
| 34 | +5. 如果视频带音轨,确认走的是 `"video_audio"` 分支,产出的音频里能听出参考视频的声音特征 | |
| 35 | + (不是纯粹被输出侧的文本/音频生成盖过去)。 | |
| 36 | + | |
| 37 | +## 参考 | |
| 38 | + | |
| 39 | +代码和排查思路的完整记录见顶层 `CHANGELOG.md`「2026-08-20(夜,又续七)— Ref2VA 加参考视频/ | |
| 40 | +URL 直填,本地开发,⚠️ 没有 GPU 没测过」。 | ... | ... |
h3-console/server.py
| ... | ... | @@ -83,26 +83,48 @@ init_db() |
| 83 | 83 | |
| 84 | 84 | |
| 85 | 85 | # ---------------- 资产上传 ---------------- |
| 86 | -# api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用 | |
| 87 | -# urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」, | |
| 88 | -# 不改 api.py 一行代码。 | |
| 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 | + | |
| 89 | 102 | @router.post("/v1/assets") |
| 90 | 103 | async def upload_asset(file: UploadFile = File(...)): |
| 91 | 104 | ext = Path(file.filename or "").suffix.lower() |
| 92 | - if ext not in (".jpg", ".jpeg", ".png", ".webp"): | |
| 93 | - raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)") | |
| 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)})") | |
| 94 | 110 | body = await file.read() |
| 95 | - if len(body) > MAX_UPLOAD_BYTES: | |
| 96 | - raise HTTPException(413, f"文件超过 {MAX_UPLOAD_BYTES // 1024 // 1024}MB 上限") | |
| 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 上限") | |
| 97 | 114 | aid = uuid.uuid4().hex[:16] |
| 98 | 115 | path = ASSETS / f"{aid}{ext}" |
| 99 | 116 | path.write_bytes(body) |
| 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}"} | |
| 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"} | |
| 106 | 128 | |
| 107 | 129 | |
| 108 | 130 | 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 { height: 60px; border-radius: 4px; } | |
| 52 | + .thumbrow img, .thumbrow video { 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,8 +148,17 @@ |
| 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()"> | |
| 151 | + <input id="ref2va-refs" type="file" accept="image/*" multiple onchange="uploadMulti('ref2va-refs','image')"> | |
| 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> | |
| 153 | 162 | <div class="row"> |
| 154 | 163 | <div><label>分辨率</label><select id="ref2va-res" class="res-select"></select></div> |
| 155 | 164 | <div><label>时长</label><select id="ref2va-dur" class="dur-select"></select></div> |
| ... | ... | @@ -182,7 +191,7 @@ |
| 182 | 191 | // 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。 |
| 183 | 192 | const H3_BASE = "__H3_BASE_PATH__"; |
| 184 | 193 | |
| 185 | -const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; | |
| 194 | +const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [], 'ref2va-videos': [] }; | |
| 186 | 195 | let jobsCache = []; |
| 187 | 196 | let trashCache = []; |
| 188 | 197 | let viewingTrash = false; |
| ... | ... | @@ -290,29 +299,61 @@ async function uploadFile(file) { |
| 290 | 299 | return await r.json(); |
| 291 | 300 | } |
| 292 | 301 | |
| 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 | + | |
| 293 | 311 | async function uploadTo(inputId) { |
| 294 | 312 | const el = document.getElementById(inputId); |
| 295 | 313 | if (!el.files[0]) return; |
| 296 | 314 | try { |
| 297 | 315 | const res = await uploadFile(el.files[0]); |
| 298 | 316 | uploaded[inputId] = res.url; |
| 299 | - const thumbs = document.getElementById('fl2va-thumbs'); | |
| 300 | - const img = document.createElement('img'); img.src = res.url; thumbs.appendChild(img); | |
| 317 | + appendThumb(document.getElementById('fl2va-thumbs'), res.url, 'image'); | |
| 301 | 318 | } catch (e) { document.getElementById('fl2va-err').textContent = '上传失败: ' + e.message; } |
| 302 | 319 | } |
| 303 | 320 | |
| 304 | -async function uploadMulti() { | |
| 305 | - const el = document.getElementById('ref2va-refs'); | |
| 306 | - const thumbs = document.getElementById('ref2va-thumbs'); | |
| 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'); | |
| 307 | 326 | for (const f of el.files) { |
| 308 | 327 | try { |
| 309 | 328 | const res = await uploadFile(f); |
| 310 | - uploaded['ref2va-refs'].push(res.url); | |
| 311 | - const img = document.createElement('img'); img.src = res.url; thumbs.appendChild(img); | |
| 329 | + uploaded[inputId].push(res.url); | |
| 330 | + appendThumb(thumbs, res.url, kind); | |
| 312 | 331 | } catch (e) { document.getElementById('ref2va-err').textContent = '上传失败: ' + e.message; } |
| 313 | 332 | } |
| 314 | 333 | } |
| 315 | 334 | |
| 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 | + | |
| 316 | 357 | // 分辨率/时长/Turbo/步数/Seed 五个字段,三个 tab 结构完全一样,抽出来一份读取逻辑。 |
| 317 | 358 | function readParamSelects(prefix) { |
| 318 | 359 | const res = RESOLUTIONS.find(r => r.value === document.getElementById(prefix + '-res').value); |
| ... | ... | @@ -340,10 +381,13 @@ async function submitJob(kind) { |
| 340 | 381 | ...readParamSelects('fl2va'), |
| 341 | 382 | }; |
| 342 | 383 | } else if (kind === 'ref2va') { |
| 343 | - if (uploaded['ref2va-refs'].length === 0) { errBox.textContent = '请先上传至少一张参考图'; return; } | |
| 384 | + if (uploaded['ref2va-refs'].length === 0 && uploaded['ref2va-videos'].length === 0) { | |
| 385 | + errBox.textContent = '请先上传至少一张参考图或一段参考视频'; return; | |
| 386 | + } | |
| 344 | 387 | body = { |
| 345 | 388 | prompt: document.getElementById('ref2va-prompt').value, |
| 346 | 389 | reference_image_urls: uploaded['ref2va-refs'], |
| 390 | + reference_video_urls: uploaded['ref2va-videos'], | |
| 347 | 391 | ...readParamSelects('ref2va'), |
| 348 | 392 | }; |
| 349 | 393 | } |
| ... | ... | @@ -360,14 +404,14 @@ async function submitJob(kind) { |
| 360 | 404 | // 重跑:不直接提交,而是把这条任务当初的 req 灌回"新建任务"表单(对应的 tab、参数、 |
| 361 | 405 | // 已上传的图片 URL 都原样带过去),跳到 /newtask.html,用户看一眼确认无误后自己点提交—— |
| 362 | 406 | // 跟正常新建任务走的是同一条路径、同一次人工确认,不是绕过表单直接后台再发一次请求。 |
| 363 | -function setThumbs(elId, urls) { | |
| 407 | +function setThumbs(elId, urls, kind = 'image') { | |
| 364 | 408 | const box = document.getElementById(elId); box.innerHTML = ''; |
| 365 | - urls.forEach(u => { if (!u) return; const img = document.createElement('img'); img.src = u; box.appendChild(img); }); | |
| 409 | + urls.forEach(u => { if (u) appendThumb(box, u, kind); }); | |
| 366 | 410 | } |
| 367 | 411 | |
| 368 | 412 | function prefillCreate(req) { |
| 369 | 413 | const kind = (req.first_frame_url || req.last_frame_url) ? 'fl2va' |
| 370 | - : (req.reference_image_urls || []).length ? 'ref2va' : 'text'; | |
| 414 | + : ((req.reference_image_urls || []).length || (req.reference_video_urls || []).length) ? 'ref2va' : 'text'; | |
| 371 | 415 | |
| 372 | 416 | document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === kind)); |
| 373 | 417 | ['text', 'fl2va', 'ref2va'].forEach(t => document.getElementById('tab-' + t).style.display = (t === kind ? 'block' : 'none')); |
| ... | ... | @@ -404,7 +448,9 @@ function prefillCreate(req) { |
| 404 | 448 | setThumbs('fl2va-thumbs', [req.first_frame_url, req.last_frame_url]); |
| 405 | 449 | } else if (kind === 'ref2va') { |
| 406 | 450 | uploaded['ref2va-refs'] = (req.reference_image_urls || []).slice(); |
| 407 | - setThumbs('ref2va-thumbs', uploaded['ref2va-refs']); | |
| 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'); | |
| 408 | 454 | } |
| 409 | 455 | |
| 410 | 456 | showCreate(); |
| ... | ... | @@ -579,11 +625,17 @@ async function refreshDetail() { |
| 579 | 625 | } |
| 580 | 626 | |
| 581 | 627 | let refThumbs = ''; |
| 582 | - const imgs = []; | |
| 628 | + const imgs = [], vids = []; | |
| 583 | 629 | if (req.first_frame_url) imgs.push(['首帧', req.first_frame_url]); |
| 584 | 630 | if (req.last_frame_url) imgs.push(['尾帧', req.last_frame_url]); |
| 585 | 631 | (req.reference_image_urls || []).forEach((u, i) => imgs.push(['参考图'+(i+1), u])); |
| 586 | - if (imgs.length) refThumbs = `<div class="thumbrow">${imgs.map(([label,u]) => `<img src="${u}" title="${label}">`).join('')}</div>`; | |
| 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 | + } | |
| 587 | 639 | |
| 588 | 640 | box.innerHTML = ` |
| 589 | 641 | <div class="back" onclick="showCreate()">← 新建任务</div> | ... | ... |
minimax-h3/api.py
| ... | ... | @@ -67,6 +67,10 @@ 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) | |
| 70 | 74 | |
| 71 | 75 | @model_validator(mode="after") |
| 72 | 76 | def _check_rows(self): |
| ... | ... | @@ -200,11 +204,45 @@ def _keyframes(req): |
| 200 | 204 | indices.append(index) |
| 201 | 205 | return (images, indices) if images else (None, None) |
| 202 | 206 | |
| 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 | + | |
| 203 | 216 | def _references(req): |
| 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] | |
| 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 | |
| 208 | 246 | |
| 209 | 247 | def worker(): |
| 210 | 248 | from diffsynth.utils.data.audio_video import write_video_audio | ... | ... |