api.py
16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
"""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]}