Commit 6b3f5a6a2a27374f3a16d4b0c436b0e7020e8775

Authored by yibo
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 已有的细节。
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
... ...