diff --git a/CHANGELOG.md b/CHANGELOG.md index ae619c8..c41f696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 变更记录 +## 2026-08-24 — 多参考图生视频加上参考视频/独立音频参考 + +**结果**:查 ComfyUI `/object_info` 发现 `MiniMaxH3ReferenceToVideo` 节点其实支持 +`ref_videos`(参考视频,最多3段)/`ref_audios`(独立音频参考,最多3个)/`ref_video_audios` +(对应视频的音轨),之前只接了 `ref_images`。现在都接上了:`raylight/server.py` 的 `GenReq` +加 `reference_video_urls`/`reference_audio_urls`,`build_ref2va_graph` 用 ComfyUI 核心节点 +`LoadVideo`→`GetVideoComponents`(拆出images+audio两路)接参考视频,`LoadAudio`接独立音频; +`h3-console` 的 `/v1/assets` 上传接口按扩展名分流校验(视频100MB/音频30MB/图片20MB三档), +"多参考图生视频"tab 加了两个上传框。端到端测过(2秒测试视频+独立音频文件),成功生成。 +顺带修了 console 自己的 `_partition_of` 漏加这两个新字段的判断(跟`raylight/server.py`那份 +不一致,纯展示层,不影响实际生成)。 + ## 2026-08-23(晚)— H3 官方案例测试 + 提示词写作 skill(`h3-prompt-writer`)搭建与 benchmark 收敛 **结果**:为了系统性验证 raylight 复现 MiniMax H3 官方案例效果,做了两件事: diff --git a/h3-console/server.py b/h3-console/server.py index 9586ccf..1cfe38d 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -28,6 +28,8 @@ DB_PATH = BASE / "console.sqlite3" GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000") CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB,够单张参考图/首尾帧,防止占满磁盘 +MAX_VIDEO_UPLOAD_BYTES = 100 * 1024 * 1024 # 100MB,参考视频(2-15秒)比图片大得多 +MAX_AUDIO_UPLOAD_BYTES = 30 * 1024 * 1024 # 30MB,独立音频参考 ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" # 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名), # 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。 @@ -86,23 +88,39 @@ init_db() # api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用 # urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」, # 不改 api.py 一行代码。 +IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp") +VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv") +AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".ogg", ".flac") + + @router.post("/v1/assets") async def upload_asset(file: UploadFile = File(...)): ext = Path(file.filename or "").suffix.lower() - if ext not in (".jpg", ".jpeg", ".png", ".webp"): - raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)") + # 2026-08-23:原来只收图片,现在参考视频/独立音频参考也走这个接口——按扩展名分流到 + # 各自的大小上限,视频/音频没有 PIL 那样现成的解码校验,先不做,坏文件会在 ComfyUI + # LoadVideo/LoadAudio 那一步报错,属于可接受的失败模式。 + if ext in IMAGE_EXTS: + kind, max_bytes = "image", MAX_UPLOAD_BYTES + elif ext in VIDEO_EXTS: + kind, max_bytes = "video", MAX_VIDEO_UPLOAD_BYTES + elif ext in AUDIO_EXTS: + kind, max_bytes = "audio", MAX_AUDIO_UPLOAD_BYTES + else: + raise HTTPException(400, f"不支持的文件类型 {ext},只收图片(jpg/png/webp)/" + f"视频(mp4/mov/webm/mkv)/音频(mp3/wav/m4a/ogg/flac)") body = await file.read() - if len(body) > MAX_UPLOAD_BYTES: - raise HTTPException(413, f"文件超过 {MAX_UPLOAD_BYTES // 1024 // 1024}MB 上限") + if len(body) > max_bytes: + raise HTTPException(413, f"文件超过 {max_bytes // 1024 // 1024}MB 上限") aid = uuid.uuid4().hex[:16] path = ASSETS / f"{aid}{ext}" path.write_bytes(body) - try: - Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制 - except Exception: - path.unlink(missing_ok=True) - raise HTTPException(400, "文件不是有效图片") - return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}"} + if kind == "image": + try: + Image.open(path).verify() # 校验确实是能解码的图片,不是随便什么二进制 + except Exception: + path.unlink(missing_ok=True) + raise HTTPException(400, "文件不是有效图片") + return {"id": aid, "url": f"{_public_base()}/assets/{aid}{ext}", "kind": kind} def _public_base() -> str: @@ -126,9 +144,19 @@ app.mount(f"{BASE_PATH}/assets", StaticFiles(directory=str(ASSETS)), name="asset # 尝试提交给 gateway;提交不上(503/429)就留在本地队列里,交给下面的 reconciler_loop 每隔几秒重试, # 弹性调度器看到的 pending 计数正是这些「还没提交上去」的本地排队任务。 def _partition_of(req: dict) -> str: - # 和 gateway.py 里 `want = "fl2va" if (...) else "ref2va"` 保持完全一致的判断, - # 不要各写一套、两边判断分岔的话 console 统计出来的 pending 计数就和 gateway 实际路由对不上。 - return "fl2va" if (req.get("first_frame_url") or req.get("last_frame_url")) else "ref2va" + # 2026-08-23 修复:原来是二选一(没有first/last frame就当ref2va),会把纯文生视频 + # (reference_image_urls也是空的)误标成"ref2va"——跟前端 rerunJob() 自己的 kind 判断 + # (index.html 里早就正确区分 text/fl2va/ref2va 三种)对不上,在 task.html 上看起来 + # "标着ref2va却没有参考图",容易让人怀疑参考图是不是丢了/没显示出来,其实只是这个分类 + # 少了纯文生视频这一档。改成跟前端一致的三分类。 + if req.get("first_frame_url") or req.get("last_frame_url"): + return "fl2va" + # 2026-08-23:新增参考视频/独立音频参考字段时漏了这里,跟 raylight/server.py 自己的 + # _partition_of 对不上——那边路由图用的是它自己那份判断(功能不受影响,只是这里的 + # 展示标签会显示错),保持两份判断逻辑一致。 + if req.get("reference_image_urls") or req.get("reference_video_urls") or req.get("reference_audio_urls"): + return "ref2va" + return "text" def _row_view(row) -> dict: @@ -220,7 +248,7 @@ async def _refresh_from_gateway(cid: str, gateway_id: str): if row is None: return view = json.loads(row["last_known_json"]) if row["last_known_json"] else {"id": cid} - if view.get("status") in ("done", "failed"): + if view.get("status") in ("done", "failed", "cancelled"): return # 已经是终态,不要覆盖 view["status"] = "failed" view["error"] = "任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展" @@ -259,6 +287,41 @@ async def create_video(req: dict): return _row_view(row) +@router.post("/v1/videos/{jid}/cancel") +async def cancel_video(jid: str): + with db() as con: + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() + if row is None: + raise HTTPException(404, "无此任务") + if row["status"] in ("done", "failed", "cancelled"): + raise HTTPException(409, f"任务当前状态是 {row['status']},已经是终态,取消不了") + now = time.time() + if row["gateway_id"]: + # 已经提交给后端了,得让后端也知道要取消(后端只允许取消它自己还在排队的任务, + # 如果已经开始跑了会返回409——这里直接把后端的错误透传出去,不假装取消成功了) + try: + resp = await client.post(f"{GATEWAY_URL}/v1/videos/{row['gateway_id']}/cancel") + except httpx.HTTPError: + raise HTTPException(502, "取消请求发不到后端,稍后再试") + if resp.status_code >= 400: + raise HTTPException(resp.status_code, resp.text) + job = resp.json() + job["id"] = jid + with db() as con: + con.execute( + "UPDATE jobs SET status='cancelled', updated_at=?, last_known_json=? WHERE id=?", + (now, json.dumps(job, ensure_ascii=False), jid), + ) + else: + # 还没提交给后端(本地排队中),直接在 console 自己这边标掉,reconciler_loop 只挑 + # status='queued' 的行重试提交,改成 cancelled 之后它就不会再去试着提交这条了 + with db() as con: + con.execute("UPDATE jobs SET status='cancelled', updated_at=? WHERE id=?", (now, jid)) + with db() as con: + row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() + return _row_view(row) + + @router.get("/v1/videos/{jid}") async def get_video(jid: str): with db() as con: diff --git a/h3-console/static/index.html b/h3-console/static/index.html index 8b0c4b4..af95841 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -48,9 +48,11 @@ button.submit { margin-top: 12px; padding: 10px 20px; background: #4a7; color: white; border: none; border-radius: 6px; cursor: pointer; } button.submit:disabled { opacity: .5; cursor: default; } .hint { font-size: 12px; opacity: .65; margin: 6px 0 4px; line-height: 1.5; } - .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; } + .thumbrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; align-items: flex-start; } .thumbrow img { height: 60px; border-radius: 4px; } - .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } + .thumbrow video { height: 220px; max-width: 100%; border-radius: 4px; } + .thumbrow audio { height: 32px; width: 220px; } + .status-done { color: #2a7; } .status-failed { color: #c33; } .status-running { color: #a70; } .status-queued { color: #777; } .status-cancelled { color: #999; } .err { color: #c33; font-size: 13px; margin-top: 8px; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace; max-height: 400px; overflow-y: auto; background: #fff5f5; border: 1px solid #f3c2c2; border-radius: 6px; padding: 10px; } @@ -99,7 +101,7 @@
-
+
+
+ + +
+ + +
-
+