"""MiniMax-H3 文生视频 HTTP API(异步任务式)。 为什么不是同步接口:模型加载 238s、单条视频 7 分钟(50 步 90 分钟), 同步 HTTP 必然超时。所以是「提交任务拿 id → 轮询状态 → 下载文件」。 为什么是串行单 worker:一条生成占 ~12.3G 显存 + ~32G CPU 内存, 一张 22G 卡同时只能跑一条。并发提交会排队,不会 OOM。 """ import os, sys, time, uuid, threading, queue, traceback, io, sqlite3, urllib.request from typing import Optional, Literal from pathlib import Path BASE = Path("/data/models/minimax-h3") OUT = BASE / "outputs"; OUT.mkdir(exist_ok=True) # 一个进程只跑一个分区。两套权重的 DiT、processor、Turbo LoRA 三样都不同, # 同进程装两套要么重建 pipe(每次重读 16G 权重),要么常驻 77G 内存。 # 每卡一个进程本来就是这台机器唯一的并发方式(H3 不支持模型并行),顺手就把分区分开了。 PARTITION = os.environ.get("H3_PARTITION", "fl2va") # 没上 GPU 的那部分权重存哪。cpu 是每进程一份 40G 匿名内存,并发被 125G 内存卡在 3 个; # disk 让权重留在 safetensors 文件里(layers.py 里 offload() 就是 .to("meta")), # 每次前向从文件读进显存,走 OS 页缓存——多个进程读同一个文件,物理内存里只有一份。 OFFLOAD = os.environ.get("H3_OFFLOAD", "cpu") os.environ.setdefault("DIFFSYNTH_ATTENTION_IMPLEMENTATION", "torch") os.environ.setdefault("DIFFSYNTH_MODEL_BASE_PATH", str(BASE / "models")) os.environ.setdefault("MODELSCOPE_CACHE", str(BASE / ".mscache")) os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True") import torch from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel, Field, model_validator def _rows(width: int, height: int, frames: int) -> float: """序列行数:潜帧数 x 宽 x 高 / 1024。真正决定显存/算力的量,帧数和分辨率是同一笔预算。""" latent_frames = (max(frames, 5) - 5) // 17 * 5 + 2 return latent_frames * width * height / 1024 # 22G 卡的显存墙按 rows 算,不是单独卡 frames——实测边界(2026-08-20 long15s.sh): # 832x480x124f rows=14430 稳定基准;448x256x362f rows=11984、576x320x362f rows=19260 都能跑 # (后者峰值 14.5G,换入换出吃紧但没崩);640x384x362f rows=25680 OOM(撞到 20.6G)。 # 20000 卡在 19260(能跑)和 25680(OOM)之间,留安全余量。 # 支持环境变量覆盖:只用于临时拉高某一个 worker 做边界探测实验,默认值(生产用的这道 # 安全线)不变——测完记得把起这个 worker 的进程重启回不带这个环境变量的状态。 ROWS_LIMIT = int(os.environ.get("H3_ROWS_LIMIT", 20000)) # ---------------- 任务模型 ---------------- class GenReq(BaseModel): prompt: str = Field(min_length=1, max_length=2000) # 上界取模型自己的默认档 1344x768。pipeline 本身不限分辨率,只把长宽向上取整到 32 的倍数, # 真正的墙是这张 22G 卡:序列行数按 h*w/1024 每潜帧算,832x480 是 390 行/帧 x 37 帧 = 14430 行, # 1344x768 是 1008 行/帧 = 37296 行,注意力是平方级,涨 2.6 倍行数就是涨约 6.7 倍算力。 # 832x480 带三张参考图实测峰值 13.7G,往上哪一档撞墙没测过,超了就是这条任务 failed,不会拖死服务。 width: int = Field(832, ge=64, le=1344) height: int = Field(480, ge=64, le=768) # 上界 362=15s(协议规格上限),是否真能生成看下面 _check_rows——832x480 撞了还是会被拒。 frames: int = Field(124, ge=8, le=362) steps: int = Field(4, ge=1, le=50) turbo: bool = True seed: int = Field(0, ge=0, le=2**31 - 1) flow_shift: Optional[float] = None # 首末帧条件(FL2VA)。给了首帧,这一段就从那张画面长出去——同一间屋子、同一个人。 # 不给就是纯文生视频,屋子和人由模型自己编,跨条对不上。 first_frame_url: Optional[str] = None last_frame_url: Optional[str] = None # 参考图(Ref2VA)。首帧只管这一段从哪开始,参考图全程参与注意力,管的是「中间别乱改」。 # 张数没有硬上限,代价是序列变长:一张按 ref_image_short_edge 缩放后折成 token, # 短边 480 时约 390 行,而目标视频本身才 14430 行。 reference_image_urls: list[str] = [] ref_image_short_edge: int = Field(480, ge=64, le=2048) @model_validator(mode="after") def _check_rows(self): rows = _rows(self.width, self.height, self.frames) if rows > ROWS_LIMIT: raise ValueError(f"分辨率×帧数超出显存安全上限(rows={rows:.0f} > {ROWS_LIMIT});" f"降低 frames 或分辨率,例如 448x256/576x320 可到 362 帧,832x480 建议 ≤124 帧") return self class Job(BaseModel): id: str status: Literal["queued", "loading", "running", "done", "failed"] req: GenReq created_at: float started_at: Optional[float] = None finished_at: Optional[float] = None step: int = 0 total_steps: int = 0 file: Optional[str] = None size_bytes: Optional[int] = None error: Optional[str] = None peak_vram_gb: Optional[float] = None JOBS: dict[str, Job] = {} Q: "queue.Queue[str]" = queue.Queue() LOCK = threading.Lock() STATE = {"model": "loading", "loaded_at": None, "gpu": os.environ.get("CUDA_VISIBLE_DEVICES", "?")} # ---------------- 任务记录落盘 ---------------- # 之前 JOBS 只在内存里,进程重启(比如 h3-console 的弹性调度器切分区)就把这张表清空—— # 视频文件在 outputs/ 没事,但 id→文件路径这层映射没了,前端/API 都查不到。改成每次状态变化 # 都往 sqlite 写一份,进程起来时先读回内存。四个 GPU 各一个进程,按 GPU id 分开的文件, # 不共享一份——共享一份要处理多进程并发写 sqlite 的锁竞争,没必要:反正一个进程只会在同一张 # GPU 上起来,per-GPU 的文件天然不会有另一个进程来抢。 JOBS_DB = BASE / f"jobs_gpu{os.environ.get('CUDA_VISIBLE_DEVICES', 'x')}.sqlite3" _JOBS_CON = sqlite3.connect(JOBS_DB, check_same_thread=False) _JOBS_CON.execute( "CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, updated_at REAL NOT NULL, data TEXT NOT NULL)" ) _JOBS_CON.commit() def _persist(job: Job): with LOCK: _JOBS_CON.execute( "INSERT INTO jobs (id, updated_at, data) VALUES (?,?,?) " "ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, data=excluded.data", (job.id, time.time(), job.model_dump_json()), ) _JOBS_CON.commit() def _load_jobs(): """进程刚起来时把上次落盘的任务记录读回内存。in-flight 的状态没法恢复——Q 是空的, GPU 上下文也是全新的,没有任何东西能"接着跑"。落盘时正好是 queued/running 的一律 改判为 failed 并写清原因,不然它会卡成一条永远显示"运行中"但没有 worker 会再碰的 死记录,比直接查不到这个 id 更容易让人误判成"卡住了,再等等"。""" n = 0 for (data,) in _JOBS_CON.execute("SELECT data FROM jobs"): try: j = Job.model_validate_json(data) except Exception: continue # 单条记录损坏不该挡住整个服务启动 if j.status in ("queued", "running"): j.status = "failed" j.error = "进程重启导致这条任务的执行状态丢失(GPU 上下文和排队状态不落盘,无法恢复)" j.finished_at = j.finished_at or time.time() JOBS[j.id] = j n += 1 if n: print(f"[api] 从磁盘恢复了 {n} 条历史任务记录({JOBS_DB.name})", flush=True) _load_jobs() # ---------------- 推理 worker ---------------- # DiT、processor、Turbo LoRA 三样成套,跨分区混用会对不上层。 # ref2v 的 Turbo LoRA 只有 v0.1,fl2v 那个是 v1.0 768p——这一项两边成色不同。 PARTITIONS = { "fl2va": {"dit": "minimax-h3-fl2va-nf4.safetensors", "processor": "FL2VA/processor/", "lora": "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"}, # ref2v 的 bf16 版实测出 124 帧噪点:同尺寸同 seed,挂上它是噪点、摘掉就出画面, # 换 flow_shift 无效(见 docs/H3 的二分表)。改用 comfyui 版试另一套键名。 "ref2va": {"dit": "minimax-h3-ref2va-nf4.safetensors", "processor": "Ref2VA/processor/", "lora": "minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors"}, } def _turbo_lora_config(): from diffsynth.pipelines.minimax_h3_audio_video import ModelConfig return ModelConfig(model_id="lightx2v/Minimax-h3-Turbo", origin_file_pattern=PARTITIONS[PARTITION]["lora"]) def _build_pipe(): from diffsynth.pipelines.minimax_h3_audio_video import MiniMaxH3Pipeline, ModelConfig part = PARTITIONS[PARTITION] DT = torch.float32 # fp16 在 sm_75 上出全黑视频,见 README # 常驻显存里留给权重多少 GB,剩下的全是激活。默认公式是按 832x480 调的(留 14G 给激活,实测峰值 13.7G)。 # 分辨率往上走就得调低它:1280x720 在默认值下要 22.3G、卡只有 21.49G,差约 1G 撞 OOM; # 调低后权重多从内存流几趟,慢一点,换来激活空间。用 H3_VRAM_LIMIT 覆盖。 free = torch.cuda.mem_get_info("cuda")[0] / 1024**3 vram_limit = float(os.environ.get("H3_VRAM_LIMIT", max(4.0, free - 14.0))) offload_dt = "disk" if OFFLOAD == "disk" else DT vc = dict(offload_dtype=offload_dt, offload_device=OFFLOAD, onload_dtype=offload_dt, onload_device=OFFLOAD, preparing_dtype=DT, preparing_device="cuda", computation_dtype=DT, computation_device="cuda") NF4 = "DiffSynth-Studio/MiniMax-H3-NF4" pipe = MiniMaxH3Pipeline.from_pretrained( torch_dtype=DT, device="cuda", model_configs=[ModelConfig(model_id=NF4, origin_file_pattern=f, **vc) for f in [part["dit"], "minimax-h3-text-encoder-nf4.safetensors", "video_vae_nf4.safetensors", "audio_vae_nf4.safetensors"]], processor_config=ModelConfig(model_id="MiniMax/MiniMax-H3", origin_file_pattern=part["processor"]), vram_limit=vram_limit) pipe.load_lora(pipe.dit, _turbo_lora_config()) return pipe def _fetch_image(url): from PIL import Image with urllib.request.urlopen(url, timeout=60) as resp: return Image.open(io.BytesIO(resp.read())).convert("RGB") def _keyframes(req): """取首末帧图,缩到出片尺寸。都没给就返回 (None, None),pipeline 退回纯文生视频。""" from PIL import Image images, indices = [], [] for index, url in ((0, req.first_frame_url), (-1, req.last_frame_url)): if url is None: continue images.append(_fetch_image(url).resize((req.width, req.height), Image.LANCZOS)) indices.append(index) return (images, indices) if images else (None, None) def _references(req): """参考图按请求顺序取下来。尺寸交给 pipeline 按 ref_image_short_edge 归一,这里不动。""" if not req.reference_image_urls: return None return [{"type": "image", "image": _fetch_image(url)} for url in req.reference_image_urls] def worker(): from diffsynth.utils.data.audio_video import write_video_audio import numpy as np t0 = time.time() pipe = _build_pipe() STATE["model"] = "ready"; STATE["loaded_at"] = time.time() print(f"[api] 模型就绪,耗时 {time.time()-t0:.0f}s", flush=True) while True: jid = Q.get() j = JOBS[jid] try: j.status = "running"; j.started_at = time.time() j.total_steps = j.req.steps _persist(j) torch.cuda.reset_peak_memory_stats() fs = j.req.flow_shift if j.req.flow_shift is not None else (6.0 if j.req.turbo else 12.0) if not j.req.turbo: pipe.clear_lora() keyframes, keyframe_indices = _keyframes(j.req) video, audio = pipe(prompt=j.req.prompt, height=j.req.height, width=j.req.width, num_frames=j.req.frames, num_inference_steps=j.req.steps, seed=j.req.seed, flow_shift=fs, keyframes=keyframes, keyframe_indices=keyframe_indices, references=_references(j.req), ref_image_short_edge=j.req.ref_image_short_edge) # 产物必须验过才算成功——全黑视频也能写出文件(踩过) a = np.stack([np.asarray(f.convert("RGB"), dtype=np.float32) for f in video[:: max(1, len(video)//8)]]) if a.std() < 1.0: raise RuntimeError(f"生成结果近乎纯色 (std={a.std():.3f}),判为失败") if torch.is_tensor(audio) and not torch.isfinite(audio).all(): raise RuntimeError("音频含非有限值,判为失败") out = OUT / f"api_{jid}.mp4" write_video_audio(video=video, audio=audio, output_path=str(out), fps=24, audio_sample_rate=32000) j.file = str(out); j.size_bytes = out.stat().st_size j.peak_vram_gb = round(torch.cuda.max_memory_allocated()/1024**3, 2) j.status = "done" except Exception as e: j.status = "failed"; j.error = f"{type(e).__name__}: {e}" traceback.print_exc() finally: j.finished_at = time.time() _persist(j) if not j.req.turbo: # 下一个任务可能要 turbo,把 LoRA 挂回去 try: pipe.load_lora(pipe.dit, _turbo_lora_config()) except Exception: traceback.print_exc() Q.task_done() # ---------------- HTTP ---------------- app = FastAPI(title="MiniMax-H3 Video API", version="1.0") @app.on_event("startup") def _start(): threading.Thread(target=worker, daemon=True).start() @app.get("/health") def health(): return {"status": "ok" if STATE["model"] == "ready" else "loading", "model": STATE["model"], "gpu": STATE["gpu"], "partition": PARTITION, "offload": OFFLOAD, "queued": Q.qsize(), "running": sum(1 for j in JOBS.values() if j.status == "running")} @app.post("/v1/videos", status_code=202) def create(req: GenReq): if req.turbo and req.steps > 8: raise HTTPException(400, "turbo 模式步数应 ≤8(LoRA 是 4 步蒸馏的);要多步请传 turbo=false") jid = uuid.uuid4().hex[:12] with LOCK: JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time()) _persist(JOBS[jid]) # LOCK 外面调用 —— _persist 自己会拿 LOCK,Lock 不可重入,嵌套会死锁 Q.put(jid) # 耗时的自变量是序列行数(潜帧数 x 宽 x 高 / 1024),不是分辨率——分辨率和帧数是同一笔预算。 # 基准点 832x480x124f = 14430 行,4 步实测 692 秒 = 4 x 132 + 163 #(163 是文本编码 + 参考图编码 + VAE 解码,它也跟帧数走,所以一起按行数缩放)。 # 回代:1280x736x56f(15640 行)估 749 / 实测 736;832x480x22f(2730 行)估 131 / 实测 96。 # 注意力是平方级,行数越大这个线性估计偏得越小。 rows = _rows(req.width, req.height, req.frames) est = round((req.steps * 132 + 163) * rows / 14430) return {"id": jid, "status": "queued", "queue_position": Q.qsize(), "eta_seconds": est, "poll": f"/v1/videos/{jid}"} @app.get("/v1/videos/{jid}") def get(jid: str): j = JOBS.get(jid) if not j: raise HTTPException(404, "无此任务") d = j.model_dump() if j.status == "running" and j.started_at: d["elapsed_seconds"] = round(time.time() - j.started_at) if j.status == "done": d["download"] = f"/v1/videos/{jid}/file" return d @app.get("/v1/videos/{jid}/file") def download(jid: str): j = JOBS.get(jid) if not j: raise HTTPException(404, "无此任务") if j.status != "done": raise HTTPException(409, f"任务尚未完成(当前 {j.status})") return FileResponse(j.file, media_type="video/mp4", filename=f"{jid}.mp4") @app.get("/v1/videos") def listjobs(limit: int = 50): js = sorted(JOBS.values(), key=lambda x: x.created_at, reverse=True)[:limit] return {"jobs": [{"id": j.id, "status": j.status, "created_at": j.created_at, "size_bytes": j.size_bytes, "error": j.error} for j in js]}