multimodalart HF Staff commited on
Commit
57d1566
·
verified ·
1 Parent(s): 51939fb

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app.py +98 -91
  2. requirements.txt +3 -2
app.py CHANGED
@@ -1,7 +1,7 @@
1
  """MiniMax-H3 video generation with synchronized audio — FL2VA (text / first-last-frame to video+audio).
2
 
3
- Based on the diffusers MiniMax-H3 pipeline. Uses Int8 weight-only quantization on both the
4
- 33B transformer and the 32B Qwen3-VL text encoder to fit within ZeroGPU xlarge (96 GB VRAM).
5
  """
6
 
7
  from __future__ import annotations
@@ -66,17 +66,20 @@ def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
66
 
67
 
68
  PIPE = None
 
 
69
  LOAD_ERROR: str | None = None
70
  LOADED_IN: float | None = None
71
 
72
 
73
  def load_models() -> str | None:
74
- """Load the full MiniMax-H3 FL2VA pipeline with Int8 quantization at startup.
75
 
76
- Int8 weight-only quantization roughly halves VRAM for both the 33B transformer and
77
- the 32B Qwen3-VL text encoder, bringing the total to ~63 GB fits in xlarge (96 GB).
 
78
  """
79
- global PIPE, LOAD_ERROR, LOADED_IN
80
 
81
  if PIPE is not None or LOAD_ERROR is not None:
82
  return LOAD_ERROR
@@ -84,65 +87,49 @@ def load_models() -> str | None:
84
  started = time.time()
85
  try:
86
  import torch
87
- from diffusers import ModularPipeline, MiniMaxH3Transformer3DModel, TorchAoConfig
88
- from transformers import Qwen3VLForConditionalGeneration
89
- from transformers import TorchAoConfig as TransformersTorchAoConfig
90
- from torchao.quantization import Int8WeightOnlyConfig
91
 
92
- lower_duration_floor()
93
 
94
- print(f"[load] loading MiniMax-H3 from {MODEL_REPO} with Int8 quantization ...", flush=True)
95
-
96
- pipe = ModularPipeline.from_pretrained(MODEL_REPO)
97
-
98
- # Quantize the 33B transformer with Int8 weight-only quantization
99
- pipe.update_components(
100
- transformer=MiniMaxH3Transformer3DModel.from_pretrained(
101
- MODEL_REPO,
102
- subfolder="transformer",
103
- dtype=torch.bfloat16,
104
- quantization_config=TorchAoConfig(
105
- Int8WeightOnlyConfig(version=2),
106
- modules_to_not_convert=[
107
- "proj_in", "audio_proj_in", "context_embedder", "time_embedder", "time_proj",
108
- "token_refiner", "norm_out", "proj_out", "audio_proj_out",
109
- ],
110
- ),
111
- ),
112
- # Quantize the 32B Qwen3-VL text encoder with Int8 weight-only quantization
113
- text_encoder=Qwen3VLForConditionalGeneration.from_pretrained(
114
- MODEL_REPO,
115
- subfolder="text_encoder",
116
- dtype=torch.bfloat16,
117
- quantization_config=TransformersTorchAoConfig(
118
- Int8WeightOnlyConfig(version=2),
119
- modules_to_not_convert=[
120
- "model.visual",
121
- "model.language_model.embed_tokens",
122
- "model.language_model.norm",
123
- "lm_head",
124
- ],
125
- ),
126
- ),
127
  )
128
- pipe.load_components(dtype=torch.bfloat16)
129
 
130
- # VAEs stay full precision a bfloat16 audio VAE decodes the soundtrack ~20 dB too quiet.
131
- # VAEs are small (~6 GB) so we can pack them at startup; the quantized transformer (~31 GB) and
132
- # text encoder (~26 GB) are too large to pack alongside the BF16 download on disk, so they stay
133
- # as CPU tensors and move to CUDA on the first GPU call.
134
- pipe.vae.to("cuda")
135
- pipe.audio_vae.to("cuda")
136
 
137
- # Use cuDNN fused attention (10-20% faster than SDPA, no extra deps)
138
  try:
139
- pipe.transformer.set_attention_backend("_native_cudnn")
140
- except Exception:
141
- pipe.transformer.set_attention_backend("sdpa")
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- PIPE = pipe
144
  LOADED_IN = time.time() - started
145
- print(f"[load] ready in {LOADED_IN:.0f}s", flush=True)
146
  except Exception as error:
147
  traceback.print_exc()
148
  LOAD_ERROR = f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`"
@@ -153,14 +140,17 @@ def status() -> str:
153
  if LOAD_ERROR:
154
  return LOAD_ERROR
155
  if PIPE is None:
156
- return f"Loading `{MODEL_REPO}` (~119 GB BF16, Int8-quantized at load). Watch the Space logs."
157
- return f"Ready · Int8 quantized · loaded in {LOADED_IN:.0f}s"
 
 
 
158
 
159
 
160
- # Duration estimation: linear in rows (matmuls) + quadratic (attention) + decode cost
161
- _DUR_B, _DUR_C = 1.5e-4, 5.0e-9
162
  _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
163
- _PAD = 15
164
 
165
 
166
  def get_duration(prompt, image, last_image, height, width, num_frames, steps, seed, *a, **k):
@@ -171,44 +161,54 @@ def get_duration(prompt, image, last_image, height, width, num_frames, steps, se
171
  rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
172
  denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
173
  decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
174
- # Extra allowance for quantized model being somewhat slower per step + cold-start weight transfer
175
- quant_allowance = 30
176
- return max(60, int(denoise + decode) + quant_allowance + _PAD)
177
 
178
 
179
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
180
- def generate(prompt, image, last_image, height, width, num_frames, steps, seed, progress=gr.Progress(track_tqdm=True)):
181
- """Generate video with synchronized audio from text and optional keyframes.
182
-
183
- Args:
184
- prompt: Text description of the video to generate.
185
- image: Optional first frame image (PIL Image).
186
- last_image: Optional last frame image (PIL Image).
187
- height: Output video height in pixels.
188
- width: Output video width in pixels.
189
- num_frames: Number of frames to generate (must be 17*n+5).
190
- steps: Number of denoising steps.
191
- seed: Random seed for reproducibility.
192
- """
193
  import torch
194
- from diffusers.utils import encode_video
195
 
196
- # Move the quantized transformer and text encoder to CUDA on each cold worker.
197
- # They were kept as CPU tensors at startup to avoid exceeding the 150 GB disk
198
- # quota (BF16 download ~119 GB + packed copy would be too large).
199
- PIPE.transformer.to("cuda")
200
- PIPE.text_encoder.to("cuda")
201
 
202
- state = PIPE(
 
203
  prompt=prompt,
204
  image=image,
205
  last_image=last_image,
206
  height=int(height),
207
  width=int(width),
208
- num_frames=int(num_frames),
209
- num_inference_steps=int(steps),
210
- generator=torch.Generator("cpu").manual_seed(int(seed)),
211
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  videos = state.get("videos")
214
  audio = state.get("audio")
@@ -228,7 +228,8 @@ def generate(prompt, image, last_image, height, width, num_frames, steps, seed,
228
 
229
 
230
  def run_generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS,
231
- duration=5, steps=28, seed=42, progress=gr.Progress(track_tqdm=True)):
 
232
  """Handle a generation request from the Gradio UI."""
233
  if LOAD_ERROR:
234
  raise gr.Error(LOAD_ERROR)
@@ -249,7 +250,7 @@ def run_generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_C
249
  final_frame = keyframe(last_image_path) if last_image_path else None
250
 
251
  progress(0.05, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
252
- path = generate(prompt, first_frame, final_frame, height, width, num_frames, steps, seed, progress)
253
  return path
254
 
255
 
@@ -325,6 +326,12 @@ with gr.Blocks(title="MiniMax-H3") as demo:
325
  with gr.Accordion("Advanced options", open=False):
326
  canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
327
  duration = gr.Slider(label="Duration (s)", minimum=MIN_UI_DURATION, maximum=MAX_UI_DURATION, step=1, value=5)
 
 
 
 
 
 
328
  steps = gr.Slider(label="Steps", minimum=10, maximum=40, step=1, value=28)
329
  seed = gr.Number(label="Seed", value=42, precision=0)
330
 
@@ -348,7 +355,7 @@ with gr.Blocks(title="MiniMax-H3") as demo:
348
 
349
  run.click(
350
  run_generate,
351
- [prompt, image, last_image, canvas, duration, steps, seed],
352
  [video],
353
  api_name="generate",
354
  )
 
1
  """MiniMax-H3 video generation with synchronized audio — FL2VA (text / first-last-frame to video+audio).
2
 
3
+ Uses the pruned NVFP4 transformer and local truncated NVFP4-AWQ Qwen3-VL conditioner for compact
4
+ downloads (~42 GB total vs 119 GB BF16), fitting in ZeroGPU xlarge (96 GB VRAM).
5
  """
6
 
7
  from __future__ import annotations
 
66
 
67
 
68
  PIPE = None
69
+ COND_PIPE = None
70
+ COND_ERROR: str | None = None
71
  LOAD_ERROR: str | None = None
72
  LOADED_IN: float | None = None
73
 
74
 
75
  def load_models() -> str | None:
76
+ """Load the compact generator and local truncated conditioner at startup.
77
 
78
+ Uses MiniMaxH3GeneratorBlocks (only VAEs + schedulers + video_processor from MiniMaxAI/MiniMax-H3,
79
+ ~10 GB) plus the pruned NVFP4 transformer from lilcheaty/MiniMax-H3-NVFP4 (~16 GB) and the local
80
+ NVFP4-AWQ Qwen3-VL conditioner from Comfy-Org/MiniMax-H3 (~16 GB). Total download: ~42 GB.
81
  """
82
+ global PIPE, COND_PIPE, COND_ERROR, LOAD_ERROR, LOADED_IN
83
 
84
  if PIPE is not None or LOAD_ERROR is not None:
85
  return LOAD_ERROR
 
87
  started = time.time()
88
  try:
89
  import torch
90
+ from diffusers import ComponentsManager
 
 
 
91
 
92
+ from h3_split_blocks import MiniMaxH3GeneratorBlocks
93
 
94
+ lower_duration_floor()
95
+ manager = ComponentsManager()
96
+ blocks = MiniMaxH3GeneratorBlocks()
97
+ print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
98
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
99
+
100
+ # Load only VAEs, schedulers, and video_processor from the main repo (~10 GB)
101
+ pipe.load_components(
102
+ names=["vae", "audio_vae", "scheduler", "audio_scheduler", "video_processor"],
103
+ dtype=torch.bfloat16,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  )
 
105
 
106
+ # Load the pruned NVFP4 transformer from the separate checkpoint repo
107
+ from h3_nvfp4 import load_transformer
108
+
109
+ pipe.update_components(transformer=load_transformer())
110
+ pipe.transformer.set_attention_backend("_native_cudnn")
 
111
 
112
+ # Load the local truncated NVFP4-AWQ Qwen3-VL conditioner
113
  try:
114
+ from h3_local_conditioner import load_local_conditioner
115
+ from h3_split_blocks import MiniMaxH3ConditionerBlocks
116
+
117
+ print("[cond] loading the local truncated NVFP4-AWQ conditioner ...", flush=True)
118
+ text_encoder, tokenizer, processor = load_local_conditioner()
119
+ cond_pipe = MiniMaxH3ConditionerBlocks().init_pipeline(MODEL_REPO)
120
+ cond_pipe.update_components(
121
+ text_encoder=text_encoder,
122
+ tokenizer=tokenizer,
123
+ processor=processor,
124
+ )
125
+ except Exception as error:
126
+ traceback.print_exc()
127
+ COND_ERROR = f"{type(error).__name__}: {error}"
128
+ print(f"[cond] local load failed ({COND_ERROR})", flush=True)
129
 
130
+ PIPE, COND_PIPE = pipe, cond_pipe
131
  LOADED_IN = time.time() - started
132
+ print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
133
  except Exception as error:
134
  traceback.print_exc()
135
  LOAD_ERROR = f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`"
 
140
  if LOAD_ERROR:
141
  return LOAD_ERROR
142
  if PIPE is None:
143
+ return "Loading pruned NVFP4 transformer + local NVFP4 conditioner + full-precision VAEs (~42 GB). Watch the Space logs."
144
+ import h3_nvfp4
145
+ engine_status = h3_nvfp4.status()
146
+ cond_status = "local NVFP4-AWQ" if COND_PIPE is not None else f"unavailable ({COND_ERROR})"
147
+ return f"Ready · {engine_status} · VAEs full precision · loaded in {LOADED_IN:.0f}s · conditioner {cond_status}"
148
 
149
 
150
+ # Duration estimation
151
+ _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
152
  _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
153
+ _PLACEMENT_ALLOWANCE, _PAD = 12, 10
154
 
155
 
156
  def get_duration(prompt, image, last_image, height, width, num_frames, steps, seed, *a, **k):
 
161
  rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
162
  denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
163
  decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
164
+ local_conditioning = 20
165
+ return max(60, int(denoise + decode) + local_conditioning + _PLACEMENT_ALLOWANCE + _PAD)
 
166
 
167
 
168
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
169
+ def generate(prompt, image, last_image, height, width, num_frames, steps, seed,
170
+ acceleration="Balanced", progress=gr.Progress(track_tqdm=True)):
171
+ """Generate video with synchronized audio from text and optional keyframes."""
 
 
 
 
 
 
 
 
 
 
172
  import torch
 
173
 
174
+ # Move models to CUDA on each cold worker
175
+ if COND_PIPE is not None:
176
+ COND_PIPE.text_encoder.to("cuda")
177
+ PIPE.to("cuda")
 
178
 
179
+ # Local conditioning
180
+ condition_state = COND_PIPE(
181
  prompt=prompt,
182
  image=image,
183
  last_image=last_image,
184
  height=int(height),
185
  width=int(width),
 
 
 
186
  )
187
+ prompt_embeds = condition_state.get("prompt_embeds")
188
+ text_token_tags = condition_state.get("text_token_tags")
189
+
190
+ begin_request = getattr(PIPE.transformer, "begin_request", None)
191
+ end_request = getattr(PIPE.transformer, "end_request", None)
192
+ if begin_request is not None:
193
+ begin_request(int(steps), acceleration)
194
+ try:
195
+ with torch.inference_mode():
196
+ state = PIPE(
197
+ prompt_embeds=prompt_embeds.to("cuda", non_blocking=True),
198
+ text_token_tags=text_token_tags,
199
+ image=image,
200
+ last_image=last_image,
201
+ height=int(height),
202
+ width=int(width),
203
+ num_frames=int(num_frames),
204
+ num_inference_steps=int(steps),
205
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
206
+ )
207
+ finally:
208
+ if end_request is not None:
209
+ end_request()
210
+
211
+ from diffusers.utils import encode_video
212
 
213
  videos = state.get("videos")
214
  audio = state.get("audio")
 
228
 
229
 
230
  def run_generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS,
231
+ duration=5, steps=28, seed=42, acceleration="Balanced",
232
+ progress=gr.Progress(track_tqdm=True)):
233
  """Handle a generation request from the Gradio UI."""
234
  if LOAD_ERROR:
235
  raise gr.Error(LOAD_ERROR)
 
250
  final_frame = keyframe(last_image_path) if last_image_path else None
251
 
252
  progress(0.05, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
253
+ path = generate(prompt, first_frame, final_frame, height, width, num_frames, steps, seed, acceleration, progress)
254
  return path
255
 
256
 
 
326
  with gr.Accordion("Advanced options", open=False):
327
  canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
328
  duration = gr.Slider(label="Duration (s)", minimum=MIN_UI_DURATION, maximum=MAX_UI_DURATION, step=1, value=5)
329
+ acceleration = gr.Radio(
330
+ label="Acceleration",
331
+ choices=["Balanced", "Exact"],
332
+ value="Balanced",
333
+ info="Balanced uses adaptive step reuse; Exact evaluates every step.",
334
+ )
335
  steps = gr.Slider(label="Steps", minimum=10, maximum=40, step=1, value=28)
336
  seed = gr.Number(label="Seed", value=42, precision=0)
337
 
 
355
 
356
  run.click(
357
  run_generate,
358
+ [prompt, image, last_image, canvas, duration, steps, seed, acceleration],
359
  [video],
360
  api_name="generate",
361
  )
requirements.txt CHANGED
@@ -7,8 +7,9 @@ torchvision==0.26.0
7
  # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
8
  transformers==5.8.0
9
  accelerate==1.14.0
10
- # Int8 weight-only quantization for the 33B transformer and 32B text encoder
11
- torchao==0.18.0
 
12
  # PyAV muxes the generated soundtrack onto the frames (encode_video)
13
  av
14
  pillow
 
7
  # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
8
  transformers==5.8.0
9
  accelerate==1.14.0
10
+ # Blackwell-native NVFP4 GEMMs and the fused Q/K RMSNorm + split-half RoPE kernel used by h3_nvfp4.py.
11
+ # CUDA 13 is mandatory: older builds emulate this path and are slower than BF16.
12
+ comfy-kitchen==0.2.26
13
  # PyAV muxes the generated soundtrack onto the frames (encode_video)
14
  av
15
  pillow