dima0987654321 commited on
Commit
7af6720
·
verified ·
1 Parent(s): 1c9dd72

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +198 -642
  2. requirements.txt +1 -2
app.py CHANGED
@@ -1,197 +1,46 @@
1
- """MiniMax-H3 4-Step FlashGen LoRA — text (and optional keyframes) to video + audio in 4 steps.
2
-
3
- The FlashGen LoRA (``Beidouqixing/minimax-h3-4step-lora-flashgen``) is a data-free DMD2 distillation
4
- adapter (rank 64, alpha == rank) that reduces MiniMax-H3's T2VA inference from ~20 steps to **4**. It
5
- targets the ``transformer/`` partition in the original checkpoint's naming (``blocks.N.attn.qkv_proj``,
6
- ``mlp.fc1/fc2``, ``attn.out_proj``, ``adaln_proj.linear``). Following ``merge_lora_ckpt.py`` — the merge
7
- script the LoRA repo ships — it is *merged* into the base weights at startup rather than attached as a
8
- runtime PEFT adapter: ``W' = W + scale * (lora_B @ lora_A)`` at ``scale = 1.0``, with every target
9
- required to resolve. See the LoRA section below for the layout transforms the diffusers port needs.
10
-
11
- The distilled schedule ``[1.0, 0.7, 0.4, 0.15, 0.0]`` is a ``base_schedule``: it replaces the *uniform*
12
- ``linspace(1, 0, num_inference_steps)`` grid that ``MiniMaxH3Scheduler.set_timesteps`` builds, and H3's
13
- per-modality exponential sigma shift (``shift = 12.0`` video, ``3.0`` audio — the release's
14
- ``_minimax_h3.sigma_shift_scales``) still applies on top of it. A single ``base_schedule`` serves two
15
- modalities precisely because it is pre-shift. It is injected through
16
- ``MiniMaxH3Scheduler.set_timesteps(sigmas=...)``, which takes a sigma grid verbatim, so each scheduler is
17
- handed ``BASE_SCHEDULE`` already pushed through *its own* shift. Note that ``num_inference_steps`` counts
18
- *sigma grid points* for this scheduler (the terminal ``0.0`` included), so a 4-step schedule is 5 points
19
- and the pipeline has to be asked for 5, not 4 — and because the pipeline re-runs ``set_timesteps`` itself
20
- inside the denoise step, the distilled grid is pinned onto both schedulers rather than merely set before
21
- the call.
22
-
23
- H3's 62 GiB Qwen3-VL text encoder does not fit alongside the 66 GiB transformer on a single ZeroGPU
24
- worker, so conditioning is delegated to ``multimodalart/qwen3vl-conditioner`` over the gradio API —
25
- the same split every MiniMax-H3 LoRA Space uses.
26
- """
27
-
28
  from __future__ import annotations
29
 
30
  import os
31
  import tempfile
32
  import time
33
- import traceback
34
- from functools import cache
35
 
36
  import spaces
 
 
 
37
  import gradio as gr
38
  import torch
39
 
40
- MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
41
- LORA_REPO = os.environ.get("H3_LORA_REPO", "Beidouqixing/minimax-h3-4step-lora-flashgen")
42
- LORA_FILE = os.environ.get("H3_LORA_FILE", "minimax_h3_4step_lora_flashgen_v1.0_768p_bf16.safetensors")
43
- CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
44
- # ``pack`` places the transformer at startup (66 GB on disk via the spaces pack); VAEs move on first GPU call.
45
- PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
46
- ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
47
- GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
48
-
49
- # The distilled schedule from the LoRA README: 5 *base* sigma points = 4 Euler steps. This is the grid
50
- # before H3's per-modality shift, i.e. the replacement for `linspace(1, 0, num_inference_steps)` — see
51
- # `shifted_schedule` below.
52
- BASE_SCHEDULE = [1.0, 0.7, 0.4, 0.15, 0.0]
53
- NUM_STEPS = len(BASE_SCHEDULE) - 1 # 4 model evaluations
54
- # `MiniMaxH3Scheduler.set_timesteps(num_inference_steps=...)` counts *sigma grid points*, terminal 0.0
55
- # included, and runs `len(sigmas) - 1` model evaluations (`timesteps = 1 - sigmas[:-1]`). So the number the
56
- # pipeline has to be given for NUM_STEPS Euler steps is NUM_STEPS + 1 == len(BASE_SCHEDULE); passing 4 there
57
- # yields a 4-point grid and only 3 steps.
58
- NUM_SIGMA_POINTS = len(BASE_SCHEDULE) # 5
59
-
60
- FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
61
- MIN_UI_DURATION, MAX_UI_DURATION = 2, 14
62
 
63
  CANVASES = {
64
- # 16:9
65
  "960x544 · 16:9 fast": (544, 960),
66
  "1024x576 · 16:9 fast": (576, 1024),
67
- "1152x640 · 16:9": (640, 1152),
68
- "1280x704 · 16:9": (704, 1280),
69
- "1344x768 · 16:9 full": (768, 1344),
70
- # 9:16
71
  "544x960 · 9:16 fast": (960, 544),
72
- "640x1152 · 9:16": (1152, 640),
73
- "768x1344 · 9:16 full": (1344, 768),
74
- # 1:1
75
  "544x544 · 1:1 fast": (544, 544),
76
- "768x768 · 1:1 full": (768, 768),
77
- # 4:3 / 3:4
78
  "768x576 · 4:3 fast": (576, 768),
79
- "1024x768 · 4:3 full": (768, 1024),
80
  "576x768 · 3:4 fast": (768, 576),
81
- "768x1024 · 3:4 full": (1024, 768),
82
- # 21:9
83
  "1152x512 · 21:9 fast": (512, 1152),
84
- "1536x672 · 21:9 full": (672, 1536),
85
  }
86
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
87
 
88
- OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "h3-flashgen-out")
89
- os.makedirs(OUTPUT_DIR, exist_ok=True)
90
-
91
- PIPE = None
92
- LOAD_ERROR: str | None = None
93
- LOADED_IN: float | None = None
94
-
95
-
96
- def snap_frames(seconds: float) -> int:
97
- """The frame count MiniMax-H3's video VAE can decode: the next ``17 * n + 5`` at 24 fps."""
98
- frames = max(1, round(float(seconds) * FPS))
99
- while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
100
- frames += 1
101
- return frames
102
 
103
-
104
- def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
105
- """Let the pipeline generate below its 5 s floor."""
106
- from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
107
-
108
- MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
109
-
110
-
111
- def shifted_schedule(shift: float) -> list[float]:
112
- """``BASE_SCHEDULE`` pushed through H3's exponential sigma shift ``s*σ / (1 + (s - 1)*σ)``.
113
-
114
- ``base_schedule`` is what ``merge_lora_ckpt.py`` pins into ``_minimax_h3.base_schedule``, and the name
115
- is literal: it replaces the *base* grid — ``MiniMaxH3Scheduler.set_timesteps``'s
116
- ``linspace(1, 0, num_inference_steps)``, which the merge script calls "the server's uniform schedule" —
117
- and the release's per-modality ``sigma_shift_scales`` (video ``12.0``, audio ``3.0``) still apply after
118
- it. One schedule can only serve both modalities because it is pre-shift.
119
-
120
- Feeding the base grid to the model as if it were already shifted is what produced the ghosted,
121
- superimposed-frames output: every step conditioned the transformer on a timestep far from the latent's
122
- real noise level (σ 0.7 instead of 0.966, 0.4 instead of 0.889, 0.15 instead of 0.679), so each
123
- prediction was an average over noise levels the student was never distilled at. The shift fixes 1.0 and
124
- 0.0, so the endpoints — and the 4-step count — are unchanged.
125
- """
126
- return [shift * sigma / (1 + (shift - 1) * sigma) for sigma in BASE_SCHEDULE]
127
-
128
-
129
- def pin_distilled_schedule(scheduler) -> None:
130
- """Pin the shifted ``BASE_SCHEDULE`` onto a scheduler so *every* ``set_timesteps`` call lands on it.
131
-
132
- Setting the sigmas before calling the pipeline is not enough: the denoise step contains
133
- ``MiniMaxH3SetTimestepsStep``, which calls ``scheduler.set_timesteps(num_inference_steps, device=...)``
134
- itself and so rebuilds the uniform shifted grid, throwing the distilled one away. That grid is also
135
- where the missing step went — ``num_inference_steps`` is a *grid point* count for this scheduler, so
136
- the 4 we asked for produced a 4-sigma grid, i.e. ``len(sigmas) - 1 == 3`` model evaluations.
137
-
138
- The schedule pinned here is ``BASE_SCHEDULE`` under *this* scheduler's own ``shift``, so the video
139
- scheduler (``shift = 12.0``) and the audio one (``shift = 3.0``) keep the distinct grids the two
140
- modalities are denoised on. ``set_timesteps(sigmas=...)`` takes its argument verbatim, so the shift has
141
- to be applied here rather than left to the scheduler.
142
-
143
- Pinning is the equivalent of ``merge_lora_ckpt.py``'s ``_minimax_h3.base_schedule`` in
144
- ``model_index.json``: the served checkpoint carries its distilled schedule and the caller's step count
145
- cannot silently replace it.
146
- """
147
- if getattr(scheduler, "_flashgen_pinned", False):
148
- return
149
-
150
- original = scheduler.set_timesteps
151
- pinned = shifted_schedule(scheduler.shift)
152
-
153
- def set_timesteps(num_inference_steps=None, device=None, sigmas=None):
154
- return original(device=device, sigmas=pinned if sigmas is None else sigmas)
155
-
156
- scheduler.set_timesteps = set_timesteps
157
- scheduler._flashgen_pinned = True
158
-
159
-
160
- # ── LoRA loading (weight folding) ─────────────────────────────────────────────
161
- #
162
- # This mirrors ``merge_lora_ckpt.py``, the merge script the LoRA repo itself ships, rather than a
163
- # default ``load_lora_adapter`` + ``set_adapters``. That script folds the adapter into the base
164
- # weights ahead of serving (``W' = W + scale * (lora_B @ lora_A)``, accumulated in fp32, with
165
- # ``scale = lora_alpha / rank = 1.0`` because FlashGen's alpha equals its rank of 64), pins the
166
- # distilled ``base_schedule``, and hard-fails if any LoRA target finds no base weight. We reproduce
167
- # all of that here — merge-and-unload semantics, not a runtime adapter.
168
- #
169
- # The one thing the reference script never has to do is translate names: it merges into the
170
- # *original* MiniMax-H3 partition, whose keys are exactly the LoRA's own
171
- # (``blocks.N.attn.qkv_proj.weight``). Merging into the diffusers port instead means replaying the
172
- # very same transforms ``scripts/convert_minimax_h3_to_diffusers.py`` applied to the base weights,
173
- # because a delta is only valid in the layout of the weight it is added to:
174
- #
175
- # * ``blocks.`` -> ``transformer_blocks.``, ``token_refiner.blocks.`` ->
176
- # ``token_refiner.refiner_blocks.``, ``final_layer.adaln_proj.linear`` -> ``norm_out.linear``,
177
- # ``attn.out_proj`` -> ``attn.to_out.0``, ``mlp.fc2`` -> ``ff.net.2`` (pure renames),
178
- # * ``mlp.fc1`` -> ``ff.net.0.proj`` with its two fused halves swapped, because diffusers' SwiGLU
179
- # reads ``[value; gate]`` where the checkpoint stores ``[gate; value]``,
180
- # * ``attn.qkv_proj`` -> ``to_q`` / ``to_k`` / ``to_v``, and this is the subtle one: the fused QKV
181
- # rows of the original checkpoint are **per-head interleaved**
182
- # (``[head0: q k v, head1: q k v, ...]``), so they must be de-interleaved into
183
- # ``[q_all; k_all; v_all]`` *before* being split into thirds. Splitting the raw rows into
184
- # contiguous thirds directly — the naive reading — scatters every head's q/k/v across all three
185
- # projections, which turns the adapter into structured noise on all 52 attention blocks.
186
-
187
-
188
- def _reorder_interleaved_qkv(weight, num_heads: int, head_dim: int):
189
- """De-interleave per-head fused-QKV rows into ``[q_all; k_all; v_all]``.
190
-
191
- Identical to ``reorder_interleaved_qkv`` in diffusers' MiniMax-H3 conversion script, which
192
- applies it to the base fused QKV weight before splitting it. ``lora_B`` indexes that same fused
193
- output space, so the delta needs the same reorder.
194
- """
195
  grouped = weight.reshape(num_heads, 3 * head_dim, *weight.shape[1:])
196
  query, key, value = grouped.split(head_dim, dim=1)
197
  return torch.cat(
@@ -200,29 +49,22 @@ def _reorder_interleaved_qkv(weight, num_heads: int, head_dim: int):
200
 
201
 
202
  def _lora_target_name(source_name: str) -> str:
203
- """Map an original-checkpoint base name (without ``.lora_A/B.*``) to the diffusers parameter path."""
204
- if source_name.startswith("token_refiner.blocks."):
205
- target = source_name.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1)
206
- elif source_name.startswith("blocks."):
207
- target = source_name.replace("blocks.", "transformer_blocks.", 1)
208
- else:
209
- target = source_name
210
  return target.replace("final_layer.adaln_proj.linear", "norm_out.linear")
211
 
212
 
213
- def _lora_targets(name: str, b_weight, num_heads: int, head_dim: int):
214
- """Yield ``(diffusers_param_key, row_transformed_B)`` for one original LoRA base name."""
215
  target = _lora_target_name(name)
216
-
217
  if target.endswith(".attn.qkv_proj"):
218
  prefix = target.removesuffix("qkv_proj")
219
- # De-interleave the per-head rows first, then split contiguous thirds — exactly the
220
- # composition the base fused QKV weight went through during conversion.
221
  rows = _reorder_interleaved_qkv(b_weight, num_heads, head_dim)
222
  for kind, part in zip(("q", "k", "v"), rows.split(num_heads * head_dim, dim=0)):
223
  yield f"{prefix}to_{kind}.weight", part.contiguous()
224
  elif target.endswith(".mlp.fc1"):
225
- # SwiGLU gate/value swap: the checkpoint stores [gate, value]; diffusers stores [value, gate].
226
  gate, value = b_weight.chunk(2, dim=0)
227
  yield target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous()
228
  elif target.endswith(".mlp.fc2"):
@@ -230,546 +72,260 @@ def _lora_targets(name: str, b_weight, num_heads: int, head_dim: int):
230
  elif target.endswith(".attn.out_proj"):
231
  yield target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", b_weight
232
  else:
233
- # adaln_proj.linear (block-level) and norm_out.linear: identical row layout.
234
  yield target + ".weight", b_weight
235
 
236
 
237
- def load_and_apply_lora(transformer) -> str:
238
- """Download the FlashGen LoRA, remap keys, and fold ``scale * (B @ A)`` into the base weights."""
239
- import torch
 
 
 
 
240
  from huggingface_hub import hf_hub_download
241
  from safetensors.torch import load_file
242
 
243
- lora_path = hf_hub_download(LORA_REPO, LORA_FILE)
244
- lora = load_file(lora_path)
245
-
246
- # Same pairing and validation contract as merge_lora_ckpt.py's `load_lora_pairs`.
247
- suffix_a, suffix_b = ".lora_A.default.weight", ".lora_B.default.weight"
248
  bases = sorted({key[: -len(suffix_a)] for key in lora if key.endswith(suffix_a)})
249
- unexpected = [key for key in lora if not key.endswith((suffix_a, suffix_b))]
250
- if unexpected:
251
- raise ValueError(f"{LORA_FILE} holds {len(unexpected)} non-LoRA tensors, e.g. {unexpected[:5]}")
252
  if not bases:
253
- raise ValueError(f"No lora_A/lora_B pairs found in {LORA_FILE}")
254
-
255
- ranks = set()
256
- for name in bases:
257
- if f"{name}{suffix_b}" not in lora:
258
- raise ValueError(f"LoRA is missing the lora_B twin of {name}{suffix_a}")
259
- ranks.add(lora[f"{name}{suffix_a}"].shape[0])
260
- if len(ranks) != 1:
261
- raise ValueError(f"LoRA mixes ranks {sorted(ranks)}; a DMD2 student export uses a single rank")
262
- rank = ranks.pop()
263
 
 
264
  num_heads = transformer.config.num_attention_heads
265
  head_dim = transformer.config.attention_head_dim
266
- # merge_lora_ckpt.py's --scale, i.e. lora_alpha / rank; 1.0 because FlashGen's alpha == rank.
267
- scale = float(os.environ.get("H3_LORA_SCALE", "1.0"))
268
 
269
- params = dict(transformer.named_parameters())
270
- folded, missed = 0, []
271
  with torch.no_grad():
272
  for name in bases:
273
  a = lora[f"{name}{suffix_a}"]
274
- b = lora[f"{name}{suffix_b}"]
 
 
 
275
  for key, b_part in _lora_targets(name, b, num_heads, head_dim):
276
  param = params.get(key)
277
  if param is None:
278
  missed.append(key)
279
  continue
280
- # Accumulate in fp32 so a bf16 base does not swallow the update.
281
- delta = scale * (b_part.to(torch.float32) @ a.to(torch.float32))
282
  if delta.shape != param.shape:
283
  raise ValueError(
284
- f"LoRA delta for `{key}` has shape {tuple(delta.shape)}, "
285
- f"base weight is {tuple(param.shape)}"
286
  )
287
  param.data = (param.data.float() + delta.to(param.device)).to(param.dtype)
288
  folded += 1
289
 
290
- # merge_lora_ckpt.py treats an unmatched target as fatal, and so do we: a silently skipped
291
- # target means the name mapping is wrong and the demo would serve a half-applied adapter.
292
  if missed:
293
  raise ValueError(
294
- f"{len(missed)} LoRA targets matched no transformer weight, e.g. {missed[:5]}. "
295
- "The LoRA and the diffusers transformer disagree on module naming."
296
  )
297
 
298
- return (
299
- f"FlashGen LoRA merged · {len(bases)} targets, rank {rank}, scale {scale:g} "
300
- f"-> {folded} weight deltas · {LORA_FILE}"
301
- )
302
-
303
-
304
- # ── Model loading ───────────────────────────────────────────────────────────
305
 
306
 
307
- def load_models() -> str | None:
308
- """Load the denoising half at startup: transformer + VAEs + schedulers, then fold the LoRA in."""
309
- global PIPE, LOAD_ERROR, LOADED_IN
 
 
 
 
 
310
 
311
- if PIPE is not None or LOAD_ERROR is not None:
312
- return LOAD_ERROR
313
 
314
- started = time.time()
315
- try:
316
- import torch
317
- from diffusers import ComponentsManager
 
 
 
 
318
 
319
- from h3_split_blocks import MiniMaxH3GeneratorBlocks
320
 
321
- lower_duration_floor()
322
- manager = ComponentsManager()
323
- blocks = MiniMaxH3GeneratorBlocks()
324
- print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
325
- pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
326
- pipe.load_components(dtype=torch.bfloat16)
327
 
328
- pipe.vae.set_attention_backend("native")
329
- pipe.audio_vae.set_attention_backend("native")
330
- pipe.transformer.set_attention_backend(ATTENTION)
 
 
 
 
 
 
 
 
 
331
 
332
- # Fold the FlashGen LoRA into the transformer before any GPU placement.
333
- lora_status = load_and_apply_lora(pipe.transformer)
334
- print(f"[gen] {lora_status}", flush=True)
335
 
336
- # ... and pin the distilled schedule the merged weights were trained for, so the pipeline's own
337
- # `set_timesteps` call cannot fall back to a uniform grid (and to NUM_STEPS - 1 evaluations).
338
- pin_distilled_schedule(pipe.scheduler)
339
- pin_distilled_schedule(pipe.audio_scheduler)
340
- print(
341
- f"[gen] distilled base_schedule {BASE_SCHEDULE} pinned -> {NUM_STEPS} steps; "
342
- f"video σ (shift {pipe.scheduler.shift:g}) "
343
- f"{[round(s, 4) for s in shifted_schedule(pipe.scheduler.shift)]}, "
344
- f"audio σ (shift {pipe.audio_scheduler.shift:g}) "
345
- f"{[round(s, 4) for s in shifted_schedule(pipe.audio_scheduler.shift)]}",
346
- flush=True,
347
- )
348
 
349
- if PLACEMENT == "pack":
350
- pipe.transformer.to("cuda")
351
 
352
- PIPE = pipe
353
- LOADED_IN = time.time() - started
354
- print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
355
- except Exception as error:
356
- traceback.print_exc()
357
- LOAD_ERROR = f"**Loading failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`"
358
- return LOAD_ERROR
359
 
 
360
 
361
- # ── Remote conditioning ──────────────────────────────────────────────────────
362
 
 
 
 
 
 
363
 
364
- @cache
365
- def conditioner():
366
- from gradio_client import Client
367
 
368
- return Client(CONDITIONER_SPACE)
 
369
 
 
 
 
 
 
 
 
370
 
371
- def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, ip_token=None, rewrite_prompt=False):
372
- """Call the conditioner Space to get ``prompt_embeds`` + ``text_token_tags`` + resolved geometry.
373
 
374
- ``rewrite_prompt`` is the same prompt-enhancement path ``multimodalart/minimax-h3`` uses: the conditioner
375
- Space already hosts H3's Qwen3-VL, so it first has that model *rewrite* the prompt into MiniMax-H3's
376
- structured shot description (the LLM-based rewriting step) and then encodes the rewritten text instead of
377
- the raw one. The rewritten text comes back in the plan under ``refined_prompt`` (``None`` when no rewrite
378
- happened). Encoding the enhanced prompt on the conditioner keeps the enhancement and the embeddings in
379
- sync — nothing about the FlashGen merge, the 4-step schedule, or the VAEs is involved.
380
- """
381
  from gradio_client import handle_file
382
  from safetensors import safe_open
383
 
384
- def call():
385
- return conditioner_client(ip_token).predict(
386
- prompt=prompt,
387
- image_path=handle_file(image_path) if image_path else None,
388
- last_image_path=handle_file(last_image_path) if last_image_path else None,
389
- canvas=canvas,
390
- num_frames=num_frames,
391
- rewrite_prompt=bool(rewrite_prompt),
392
- api_name="/encode",
393
- )
394
-
395
- try:
396
- path, plan = call()
397
- except Exception as first:
398
- print(f"[conditioner] retrying with a fresh client after: {first}", flush=True)
399
- conditioner.cache_clear()
400
- path, plan = call()
401
-
402
  with safe_open(path, framework="pt") as handle:
 
 
403
  metadata = handle.metadata()
404
- return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
405
-
406
-
407
- def conditioner_client(ip_token):
408
- if not ip_token:
409
- return conditioner()
410
- from gradio_client import Client
411
-
412
- return Client(CONDITIONER_SPACE, headers={"x-ip-token": ip_token})
413
-
414
-
415
- # ── Geometry helpers ────────────────────────────────────────────────────────
416
-
417
-
418
- def _snap_canvas(aspect: float, current_canvas: str) -> str:
419
- """The supported canvas whose aspect ratio is closest to ``aspect``.
420
-
421
- Same selection ``multimodalart/minimax-h3`` makes: among the canvases sharing a ratio keep the
422
- smallest (fastest) one, then take the ratio nearest the image's — so a 3:4 photo renders 3:4 rather
423
- than being letterboxed into the 16:9 default. A canvas the user already picked is kept when it is at
424
- least as close to the image as the best candidate, so ``1344x768`` survives a 1.75 image.
425
- """
426
- fastest: dict[float, tuple[str, tuple[int, int]]] = {}
427
- for label, (h, w) in CANVASES.items():
428
- r = w / h
429
- if r not in fastest or w * h < fastest[r][1][0] * fastest[r][1][1]:
430
- fastest[r] = (label, (h, w))
431
- ratio = min(fastest, key=lambda r: abs(r - aspect))
432
 
433
- cur_h, cur_w = CANVASES[current_canvas]
434
- if abs(cur_w / cur_h - aspect) <= abs(ratio - aspect):
435
- return current_canvas
436
- return fastest[ratio][0]
437
 
438
-
439
- def _cover_crop(image_path: str, canvas_label: str) -> str:
440
- """Center cover-crop the file at ``image_path`` to ``canvas_label``'s aspect ratio, in place.
441
-
442
- The keyframe has to arrive at the model already matching the canvas, otherwise the pipeline
443
- letterboxes/stretches it and the first frame does not line up with the generated ones.
444
- """
445
- from PIL import Image as _Image
446
-
447
- h, w = CANVASES[canvas_label]
448
- target = w / h
449
- img = _Image.open(image_path)
450
- if abs(img.width / img.height - target) <= 1e-3:
451
- return image_path
452
- if img.width / img.height > target:
453
- new_w = int(img.height * target)
454
- left = (img.width - new_w) // 2
455
- img = img.crop((left, 0, left + new_w, img.height))
456
- else:
457
- new_h = int(img.width / target)
458
- top = (img.height - new_h) // 2
459
- img = img.crop((0, top, img.width, top + new_h))
460
- img.save(image_path)
461
- return image_path
462
-
463
-
464
- def _fit_keyframe(image_path, current_canvas):
465
- """Snap the canvas to a keyframe's aspect ratio, then cover-crop the keyframe to that canvas."""
466
- from PIL import Image as _Image
467
-
468
- with _Image.open(image_path) as img:
469
- aspect = img.width / img.height
470
- label = _snap_canvas(aspect, current_canvas)
471
- return _cover_crop(image_path, label), label
472
-
473
-
474
- def _fit_keyframe_ui(image_path, current_canvas):
475
- """``image.upload`` handler — the reference Space's behaviour, ported.
476
-
477
- Doing this on upload (rather than only server-side inside ``generate``) is the whole point: the
478
- user immediately sees the keyframe cropped to the ratio the video will be rendered at, and the
479
- Canvas dropdown moves to the snapped resolution instead of sitting on the 16:9 default.
480
- """
481
- path = _as_path(image_path)
482
- if not path:
483
- return gr.update(), gr.update()
484
- cropped, label = _fit_keyframe(path, current_canvas)
485
- return gr.update(value=cropped), gr.update(value=label)
486
-
487
-
488
- def _fit_last_keyframe_ui(last_image_path, image_path, current_canvas):
489
- """``last_image.upload`` handler. The first frame owns the canvas when there is one, so a last
490
- frame uploaded on top of it is only cover-cropped to the canvas already chosen."""
491
- path = _as_path(last_image_path)
492
- if not path:
493
- return gr.update(), gr.update()
494
- if _as_path(image_path):
495
- return gr.update(value=_cover_crop(path, current_canvas)), gr.update()
496
- return _fit_keyframe_ui(path, current_canvas)
497
-
498
-
499
- def _as_path(value):
500
- if isinstance(value, dict):
501
- value = value.get("path") or (value.get("url") or "").removeprefix("/gradio_api/file=")
502
- return value or None
503
-
504
-
505
- # ── GPU duration estimation ─────────────────────────────────────────────────
506
-
507
- _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
508
- _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
509
- _PLACEMENT_ALLOWANCE, _PAD = 12, 10
510
-
511
-
512
- def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, seed, *a, **k):
513
- """Estimate GPU duration for the ``_generate`` call. Steps is fixed at NUM_STEPS (4)."""
514
- height, width, num_frames = int(height), int(width), int(num_frames)
515
- steps = NUM_STEPS
516
- latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
517
- patches = (height // 32) * (width // 32)
518
- rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
519
- denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
520
- decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
521
- return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
522
-
523
-
524
- # ── Inference ───────────────────────────────────────────────────────────────
525
-
526
-
527
- @spaces.GPU(duration=get_duration, size=GPU_SIZE)
528
- def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, seed):
529
- """Denoise + decode on GPU time. The LoRA is already folded at startup; 4 steps with custom sigmas."""
530
- import torch
531
-
532
- if PLACEMENT == "pack":
533
- PIPE.vae.to("cuda")
534
- PIPE.audio_vae.to("cuda")
535
- elif PLACEMENT == "lazy":
536
- PIPE.to("cuda")
537
-
538
- # Inject the distilled base_schedule into both schedulers, and keep it there: the pipeline calls
539
- # `set_timesteps` again inside its denoise step, so the schedule has to survive that call.
540
- pin_distilled_schedule(PIPE.scheduler)
541
- pin_distilled_schedule(PIPE.audio_scheduler)
542
- PIPE.scheduler.set_timesteps(device="cuda")
543
- PIPE.audio_scheduler.set_timesteps(device="cuda")
544
-
545
- state = PIPE(
546
- prompt_embeds=prompt_embeds.to("cuda"),
547
- text_token_tags=text_token_tags,
548
  image=image,
549
- last_image=last_image,
550
- height=height,
551
- width=width,
552
- num_frames=num_frames,
553
- # Grid points, terminal 0.0 included -> NUM_STEPS Euler steps. See NUM_SIGMA_POINTS above.
554
- num_inference_steps=NUM_SIGMA_POINTS,
555
  generator=torch.Generator("cpu").manual_seed(int(seed)),
556
  )
557
- # Read the step count back off the scheduler rather than trusting the constant: `step_index` counted
558
- # the Euler updates the denoise loop actually took, and `sigmas` is the grid it took them on.
559
- steps_taken = int(PIPE.scheduler.step_index or 0)
560
- sigmas_used = [round(float(s), 4) for s in PIPE.scheduler.sigmas.tolist()]
561
- return (
562
- state.get("videos")[0],
563
- state.get("audio")[0].cpu(),
564
- state.get("sampling_rate"),
565
- steps_taken,
566
- sigmas_used,
567
- )
568
-
569
-
570
- def _caller_ip_token() -> str | None:
571
- from gradio.context import LocalContext
572
-
573
- request = LocalContext.request.get()
574
- return request.headers.get("x-ip-token") if request is not None else None
575
 
576
 
577
  def generate(
578
- prompt,
579
- image_path=None,
580
- last_image_path=None,
581
- canvas=DEFAULT_CANVAS,
582
- duration=5,
583
- seed=42,
584
- enhance_prompt=False,
585
  progress=gr.Progress(track_tqdm=True),
586
  ):
587
- """One request: prompt (+ optional first/last frame) -> video with synchronized audio.
588
-
589
- ``enhance_prompt`` turns on the conditioner's LLM prompt rewriting (the same enhancement
590
- ``multimodalart/minimax-h3`` offers); off, the raw prompt is encoded verbatim as before. It is last and
591
- defaults to off, so a positional API client written against the previous signature is unaffected.
592
- """
593
- if LOAD_ERROR:
594
- raise gr.Error(LOAD_ERROR.replace("**", "").replace("`", ""))
595
- if PIPE is None:
596
- raise gr.Error("The model is still loading — watch the Space logs and retry shortly.")
597
  if not prompt or not prompt.strip():
598
- raise gr.Error("MiniMax-H3 always takes a prompt, keyframes or not.")
599
-
600
- from PIL import Image, ImageOps
601
 
 
 
 
 
602
  from diffusers.utils import encode_video
603
 
604
- # The UI already snapped + cropped on upload, so this is a no-op there; it still runs for API
605
- # callers (and for a canvas the user changed afterwards) so the output geometry always follows the
606
- # I2V image. The *first* frame owns the canvas — a last frame is only cover-cropped to match it.
607
- first, last = _as_path(image_path), _as_path(last_image_path)
608
- if first:
609
- first, canvas = _fit_keyframe(first, canvas)
610
- if last:
611
- last = _cover_crop(last, canvas)
612
- elif last:
613
- last, canvas = _fit_keyframe(last, canvas)
614
-
615
- num_frames = snap_frames(duration)
616
-
617
- progress(0.0, desc="Enhancing the prompt, then encoding ..." if enhance_prompt else "Encoding prompt ...")
618
- conditioned = time.time()
619
- prompt_embeds, text_token_tags, metadata, plan = encode_remote(
620
- prompt,
621
- first,
622
- last,
623
- canvas,
624
- num_frames,
625
- ip_token=_caller_ip_token(),
626
- rewrite_prompt=bool(enhance_prompt),
627
- )
628
- condition_seconds = time.time() - conditioned
629
- height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
630
- # `None` whenever no rewrite happened — either none was asked for, or the conditioner declined one.
631
- enhanced = (plan or {}).get("refined_prompt") or ""
632
-
633
- def keyframe(path):
634
- return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
635
-
636
- progress(0.1, desc=f"Generating {num_frames / FPS:.1f}s at {width}x{height} in {NUM_STEPS} steps ...")
637
- started = time.time()
638
- frames, audio, sampling_rate, steps_taken, sigmas_used = _generate(
639
- prompt_embeds,
640
- text_token_tags,
641
- keyframe(first),
642
- keyframe(last),
643
- height,
644
- width,
645
- num_frames,
646
- seed,
647
- )
648
- generate_seconds = time.time() - started
649
-
650
- path = os.path.join(OUTPUT_DIR, f"h3-flashgen-{int(time.time() * 1000)}.mp4")
651
- encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
652
-
653
- # Reported, not assumed: `steps_taken` is the number of Euler updates the scheduler actually took.
654
- schedule = " → ".join(f"{s:g}" for s in sigmas_used)
655
- report = (
656
- f"{width}x{height} · {num_frames} frames ({num_frames / FPS:.3f}s) · "
657
- f"{steps_taken} steps (FlashGen σ {schedule}) · "
658
- f"conditioner {condition_seconds:.0f}s{' (prompt enhanced)' if enhanced else ''} · "
659
- f"denoise + decode {generate_seconds:.0f}s · seed {int(seed)}"
660
- )
661
- print(f"[gen] {report}", flush=True)
662
- expected_sigmas = [round(s, 4) for s in shifted_schedule(PIPE.scheduler.shift)]
663
- if steps_taken != NUM_STEPS or sigmas_used != expected_sigmas:
664
- print(
665
- f"[gen] WARNING: expected {NUM_STEPS} steps on {expected_sigmas} "
666
- f"(base_schedule {BASE_SCHEDULE} at shift {PIPE.scheduler.shift:g}), "
667
- f"ran {steps_taken} on {sigmas_used}",
668
- flush=True,
669
  )
670
- return path, report, enhanced, gr.update(visible=bool(enhanced))
671
-
672
-
673
- # ── UI ──────────────────────────────────────────────────────────────────────
674
-
675
-
676
- INTRO = """# MiniMax-H3 4-Step FlashGen LoRA
677
-
678
- <div align="center">
679
- <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ base model ]</strong></a> &nbsp;
680
- <a href="https://huggingface.co/Beidouqixing/minimax-h3-4step-lora-flashgen" target="_blank" rel="noopener"><strong>[ LoRA ]</strong></a>
681
- </div>
682
 
683
- **MiniMax-H3** is a 33B omni-modal model that generates video with native stereo audio.
684
- This Space applies the **FlashGen 4-step distillation LoRA** by
685
- [Beidouqixing](https://huggingface.co/Beidouqixing), which uses distribution-matching distillation
686
- to reduce inference from ~20 steps to just **4 steps** — a ~5x speedup.
 
687
 
688
- Type a prompt (and optionally add a first frame, last frame, or both for FL2VA mode),
689
- pick a canvas, and generate. The output includes a synchronized soundtrack."""
690
 
691
- CSS = """
692
- .main.fillable { max-width: 1100px !important; }
693
  """
694
 
695
- EXAMPLE_PROMPTS = [
696
- "Cinematic, medium wide shot. A golden retriever puppy chases autumn leaves through a sunlit park, leaves swirling around it. Warm afternoon light, shallow depth of field. The dog barks playfully and leaves rustle.",
697
- "A serene Japanese garden with a koi pond. Cherry blossom petals drift slowly on the water's surface. A small wooden bridge arcs over the pond. Gentle wind chimes tinkle in the breeze. Ambient birdsong and water sounds.",
698
- "Close-up of a chef's hands kneading dough on a flour-dusted wooden board. Flour particles float in the warm kitchen light. The dough stretches and folds rhythmically. Soft thumping sounds of dough and ambient kitchen noise.",
699
- "A neon-lit cyberpunk street in the rain. Reflected neon signs shimmer in puddles. A figure in a long coat walks away from camera. Rain patters on metal awnings, distant synth music echoes.",
700
- "A majestic eagle soars over snow-capped mountains at golden hour. The camera follows from behind, sweeping past peaks. Wind rushes past. Distant orchestral music swells.",
701
- ]
702
-
703
- with gr.Blocks(title="MiniMax-H3 4-Step FlashGen") as demo:
704
- gr.Markdown(INTRO)
705
 
706
- with gr.Row(equal_height=True):
707
- with gr.Column():
 
 
 
708
  prompt = gr.Textbox(
709
- label="Prompt",
 
710
  lines=4,
711
- value=EXAMPLE_PROMPTS[0],
712
- placeholder="Describe the scene, shot style, and soundscape ...",
713
  )
714
  enhance = gr.Checkbox(
715
- label="Enhance prompt",
716
  value=False,
 
717
  )
718
- with gr.Accordion("Image-to-video (optional)", open=True):
719
- with gr.Row():
720
- image = gr.Image(label="First frame (optional)", type="filepath", height=200)
721
- last_image = gr.Image(label="Last frame (optional)", type="filepath", height=200)
722
-
723
- run = gr.Button("Generate", variant="primary", size="lg")
724
-
725
- with gr.Accordion("Advanced", open=False):
726
- canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
727
- duration = gr.Slider(
728
- label="Duration (s)",
729
- minimum=MIN_UI_DURATION,
730
- maximum=MAX_UI_DURATION,
731
- step=1,
732
- value=5,
733
- )
734
- seed = gr.Number(label="Seed", value=42, precision=0)
735
-
736
- with gr.Column():
737
- result = gr.Video(label="Video + soundtrack", autoplay=True)
738
- report = gr.Markdown()
739
- # An output, so it is revealed only for a request that actually asked for an enhancement.
740
- with gr.Accordion("Enhanced prompt", open=False, visible=False) as enhanced_panel:
741
- enhanced_box = gr.Textbox(show_label=False, lines=8, interactive=False)
742
-
743
- OUTPUTS = [result, report, enhanced_box, enhanced_panel]
744
-
745
- # An uploaded I2V frame decides the output geometry: snap Canvas to the nearest supported ratio and
746
- # cover-crop the frame to it, right away, so what is shown is what will be rendered.
747
- image.upload(_fit_keyframe_ui, [image, canvas], [image, canvas])
748
- last_image.upload(_fit_last_keyframe_ui, [last_image, image, canvas], [last_image, canvas])
749
-
750
- gr.Examples(
751
- examples=[[p] for p in EXAMPLE_PROMPTS],
752
- inputs=[prompt],
753
  fn=generate,
754
- outputs=OUTPUTS,
755
- cache_examples=True,
756
- cache_mode="lazy",
757
- )
758
-
759
- run.click(
760
- generate,
761
- inputs=[prompt, image, last_image, canvas, duration, seed, enhance],
762
- outputs=OUTPUTS,
763
  api_name="generate",
764
  )
765
- prompt.submit(
766
- generate,
767
- inputs=[prompt, image, last_image, canvas, duration, seed, enhance],
768
- outputs=OUTPUTS,
769
- api_name="generate_submit",
770
- )
771
-
772
- load_models()
773
 
774
- if __name__ == "__main__":
775
- demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS, allowed_paths=[OUTPUT_DIR])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import os
4
  import tempfile
5
  import time
6
+ from functools import lru_cache
 
7
 
8
  import spaces
9
+
10
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
11
+
12
  import gradio as gr
13
  import torch
14
 
15
+ from h3_split_blocks import MiniMaxH3GeneratorBlocks
16
+
17
+
18
+ MODEL_REPO = "MiniMaxAI/MiniMax-H3"
19
+ LORA_REPO = "rzgar/minimax-h3_fl2v_8Step_motion_enhancer"
20
+ LORA_FILE = "minimax-h3_fl2v_8Step_motion_enhancer.safetensors"
21
+ HMCUMSHOT_REPO = "Hearmeman/minimax-h3-loras"
22
+ HMCUMSHOT_FILE = "HMCumshot_V1.0.safetensors"
23
+ HMCUMSHOT_STRENGTH = 1.0
24
+ HMCUMSHOT_TRIGGER = "cumshot"
25
+ CONDITIONER_SPACE = "multimodalart/qwen3vl-conditioner"
26
+ FPS = 24
27
+ STEPS = 8
28
+ OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "minimax-h3-motion-enhancer")
29
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
 
 
 
 
 
 
 
30
 
31
  CANVASES = {
 
32
  "960x544 · 16:9 fast": (544, 960),
33
  "1024x576 · 16:9 fast": (576, 1024),
 
 
 
 
34
  "544x960 · 9:16 fast": (960, 544),
 
 
 
35
  "544x544 · 1:1 fast": (544, 544),
 
 
36
  "768x576 · 4:3 fast": (576, 768),
 
37
  "576x768 · 3:4 fast": (768, 576),
 
 
38
  "1152x512 · 21:9 fast": (512, 1152),
 
39
  }
40
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
+ def _reorder_interleaved_qkv(weight: torch.Tensor, num_heads: int, head_dim: int) -> torch.Tensor:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  grouped = weight.reshape(num_heads, 3 * head_dim, *weight.shape[1:])
45
  query, key, value = grouped.split(head_dim, dim=1)
46
  return torch.cat(
 
49
 
50
 
51
  def _lora_target_name(source_name: str) -> str:
52
+ target = source_name.removeprefix("diffusion_model.")
53
+ if target.startswith("token_refiner.blocks."):
54
+ target = target.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1)
55
+ elif target.startswith("blocks."):
56
+ target = target.replace("blocks.", "transformer_blocks.", 1)
 
 
57
  return target.replace("final_layer.adaln_proj.linear", "norm_out.linear")
58
 
59
 
60
+ def _lora_targets(name: str, b_weight: torch.Tensor, num_heads: int, head_dim: int):
 
61
  target = _lora_target_name(name)
 
62
  if target.endswith(".attn.qkv_proj"):
63
  prefix = target.removesuffix("qkv_proj")
 
 
64
  rows = _reorder_interleaved_qkv(b_weight, num_heads, head_dim)
65
  for kind, part in zip(("q", "k", "v"), rows.split(num_heads * head_dim, dim=0)):
66
  yield f"{prefix}to_{kind}.weight", part.contiguous()
67
  elif target.endswith(".mlp.fc1"):
 
68
  gate, value = b_weight.chunk(2, dim=0)
69
  yield target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous()
70
  elif target.endswith(".mlp.fc2"):
 
72
  elif target.endswith(".attn.out_proj"):
73
  yield target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", b_weight
74
  else:
 
75
  yield target + ".weight", b_weight
76
 
77
 
78
+ def _apply_lora(
79
+ transformer,
80
+ repo_id: str,
81
+ filename: str,
82
+ label: str,
83
+ strength: float = 1.0,
84
+ ) -> str:
85
  from huggingface_hub import hf_hub_download
86
  from safetensors.torch import load_file
87
 
88
+ path = hf_hub_download(repo_id, filename)
89
+ lora = load_file(path, device="cpu")
90
+ suffix_a = ".lora_A.weight"
91
+ suffix_b = ".lora_B.weight"
 
92
  bases = sorted({key[: -len(suffix_a)] for key in lora if key.endswith(suffix_a)})
 
 
 
93
  if not bases:
94
+ raise ValueError(f"{label}: no lora_A/lora_B pairs found")
 
 
 
 
 
 
 
 
 
95
 
96
+ params = dict(transformer.named_parameters())
97
  num_heads = transformer.config.num_attention_heads
98
  head_dim = transformer.config.attention_head_dim
99
+ folded = 0
100
+ missed = []
101
 
 
 
102
  with torch.no_grad():
103
  for name in bases:
104
  a = lora[f"{name}{suffix_a}"]
105
+ b = lora.get(f"{name}{suffix_b}")
106
+ if b is None:
107
+ raise ValueError(f"{label}: missing LoRA B matrix for {name}")
108
+
109
  for key, b_part in _lora_targets(name, b, num_heads, head_dim):
110
  param = params.get(key)
111
  if param is None:
112
  missed.append(key)
113
  continue
114
+
115
+ delta = (b_part.float() @ a.float()) * float(strength)
116
  if delta.shape != param.shape:
117
  raise ValueError(
118
+ f"{label}: LoRA shape mismatch for {key}: "
119
+ f"{delta.shape} vs {param.shape}"
120
  )
121
  param.data = (param.data.float() + delta.to(param.device)).to(param.dtype)
122
  folded += 1
123
 
 
 
124
  if missed:
125
  raise ValueError(
126
+ f"{label}: {len(missed)} LoRA targets did not match the H3 transformer, "
127
+ f"e.g. {missed[:3]}"
128
  )
129
 
130
+ return f"{label} loaded: {len(bases)} adapters, {folded} matrices, strength={strength}"
 
 
 
 
 
 
131
 
132
 
133
+ def _apply_motion_lora(transformer) -> str:
134
+ return _apply_lora(
135
+ transformer,
136
+ LORA_REPO,
137
+ LORA_FILE,
138
+ "Motion enhancer",
139
+ strength=1.0,
140
+ )
141
 
 
 
142
 
143
+ def _apply_hmcumshot_lora(transformer) -> str:
144
+ return _apply_lora(
145
+ transformer,
146
+ HMCUMSHOT_REPO,
147
+ HMCUMSHOT_FILE,
148
+ "HMCumshot v1.0",
149
+ strength=HMCUMSHOT_STRENGTH,
150
+ )
151
 
 
152
 
153
+ def _load_pipeline():
154
+ from diffusers import ComponentsManager
 
 
 
 
155
 
156
+ manager = ComponentsManager()
157
+ blocks = MiniMaxH3GeneratorBlocks()
158
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
159
+ pipe.load_components(dtype=torch.bfloat16)
160
+ pipe.vae.set_attention_backend("native")
161
+ pipe.audio_vae.set_attention_backend("native")
162
+ pipe.transformer.set_attention_backend(os.environ.get("H3_ATTENTION", "_native_cudnn"))
163
+ print(_apply_motion_lora(pipe.transformer), flush=True)
164
+ print(_apply_hmcumshot_lora(pipe.transformer), flush=True)
165
+ # Module-scope placement lets ZeroGPU pack the weights once instead of per request.
166
+ pipe.transformer.to("cuda")
167
+ return pipe
168
 
 
 
 
169
 
170
+ PIPE = _load_pipeline()
 
 
 
 
 
 
 
 
 
 
 
171
 
 
 
172
 
173
+ @lru_cache(maxsize=1)
174
+ def _conditioner_client():
175
+ from gradio_client import Client
 
 
 
 
176
 
177
+ return Client(CONDITIONER_SPACE)
178
 
 
179
 
180
+ def _snap_frames(seconds: float) -> int:
181
+ frames = max(120, round(float(seconds) * FPS))
182
+ while frames % 17 != 5:
183
+ frames += 1
184
+ return frames
185
 
 
 
 
186
 
187
+ def _cover_crop(image_path: str, canvas: str) -> str:
188
+ from PIL import Image, ImageOps
189
 
190
+ height, width = CANVASES[canvas]
191
+ with Image.open(image_path) as image:
192
+ image = ImageOps.exif_transpose(image).convert("RGB")
193
+ image = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
194
+ output = os.path.join(OUTPUT_DIR, f"frame-{time.time_ns()}.png")
195
+ image.save(output)
196
+ return output
197
 
 
 
198
 
199
+ def _condition(prompt: str, image_path: str, canvas: str, num_frames: int, rewrite_prompt: bool = False):
 
 
 
 
 
 
200
  from gradio_client import handle_file
201
  from safetensors import safe_open
202
 
203
+ path, plan = _conditioner_client().predict(
204
+ prompt=prompt,
205
+ image_path=handle_file(image_path),
206
+ last_image_path=None,
207
+ canvas=canvas,
208
+ num_frames=num_frames,
209
+ rewrite_prompt=bool(rewrite_prompt),
210
+ api_name="/encode",
211
+ )
 
 
 
 
 
 
 
 
 
212
  with safe_open(path, framework="pt") as handle:
213
+ embeds = handle.get_tensor("prompt_embeds")
214
+ tags = handle.get_tensor("text_token_tags")
215
  metadata = handle.metadata()
216
+ return embeds, tags, metadata, plan
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
 
 
 
 
218
 
219
+ @spaces.GPU(duration=120, size="xlarge")
220
+ def _generate_gpu(embeds, tags, image, height: int, width: int, num_frames: int, seed: int):
221
+ """Run MiniMax-H3 denoising and return CPU video frames plus soundtrack."""
222
+ PIPE.vae.to("cuda")
223
+ PIPE.audio_vae.to("cuda")
224
+ pipe_state = PIPE(
225
+ prompt_embeds=embeds.to("cuda"),
226
+ text_token_tags=tags,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  image=image,
228
+ height=int(height),
229
+ width=int(width),
230
+ num_frames=int(num_frames),
231
+ num_inference_steps=STEPS + 1,
 
 
232
  generator=torch.Generator("cpu").manual_seed(int(seed)),
233
  )
234
+ frames = pipe_state.get("videos")[0]
235
+ audio = pipe_state.get("audio")[0]
236
+ if torch.is_tensor(frames):
237
+ frames = frames.cpu()
238
+ if torch.is_tensor(audio):
239
+ audio = audio.cpu()
240
+ return frames, audio, int(pipe_state.get("sampling_rate"))
 
 
 
 
 
 
 
 
 
 
 
241
 
242
 
243
  def generate(
244
+ prompt: str,
245
+ image_path: str | None,
246
+ canvas: str,
247
+ duration: float,
248
+ seed: int,
249
+ enhance_prompt: bool = False,
 
250
  progress=gr.Progress(track_tqdm=True),
251
  ):
252
+ """Animate an uploaded image with the MiniMax-H3 motion-enhancer LoRA."""
253
+ if not image_path:
254
+ raise gr.Error("Upload a first frame before generating.")
 
 
 
 
 
 
 
255
  if not prompt or not prompt.strip():
256
+ raise gr.Error("Describe the motion you want to see.")
 
 
257
 
258
+ height, width = CANVASES[canvas]
259
+ num_frames = _snap_frames(duration)
260
+ cropped_path = _cover_crop(image_path, canvas)
261
+ from PIL import Image
262
  from diffusers.utils import encode_video
263
 
264
+ condition_prompt = prompt.strip()
265
+ if HMCUMSHOT_TRIGGER.lower() not in condition_prompt.lower():
266
+ condition_prompt = f"{HMCUMSHOT_TRIGGER}. {condition_prompt}"
267
+
268
+ progress(0.05, desc="Upsampling prompt, then encoding..." if enhance_prompt else "Encoding the first frame")
269
+ embeds, tags, metadata, plan = _condition(condition_prompt, cropped_path, canvas, num_frames, rewrite_prompt=bool(enhance_prompt))
270
+ height = int(metadata.get("height", height))
271
+ width = int(metadata.get("width", width))
272
+ num_frames = int(metadata.get("num_frames", num_frames))
273
+ progress(0.15, desc=f"Generating {num_frames / FPS:.1f}s at {width}x{height}")
274
+ with Image.open(cropped_path) as image:
275
+ frames, audio, sampling_rate = _generate_gpu(
276
+ embeds,
277
+ tags,
278
+ image.convert("RGB"),
279
+ height,
280
+ width,
281
+ num_frames,
282
+ int(seed),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  )
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ output = os.path.join(OUTPUT_DIR, f"motion-enhanced-{time.time_ns()}.mp4")
286
+ encode_video(frames, fps=FPS, output_path=output, audio=audio, audio_sample_rate=sampling_rate)
287
+ enhanced = (plan or {}).get("refined_prompt") or ""
288
+ status = f"{width}x{height} · {num_frames / FPS:.2f}s · {STEPS} denoising steps · seed {int(seed)}"
289
+ return output, status, enhanced, gr.update(visible=bool(enhanced))
290
 
 
 
291
 
292
+ DESCRIPTION = """
293
+ # Motion Enhancer
294
  """
295
 
 
 
 
 
 
 
 
 
 
 
296
 
297
+ with gr.Blocks(title="MiniMax-H3 Motion Enhancer") as demo:
298
+ gr.Markdown(DESCRIPTION)
299
+ with gr.Row():
300
+ with gr.Column(scale=1):
301
+ image = gr.Image(label="First frame", type="filepath", sources=["upload", "webcam"])
302
  prompt = gr.Textbox(
303
+ label="Motion prompt",
304
+ value="A cinematic shot with natural, fluid movement and a gentle camera push-in.",
305
  lines=4,
306
+ placeholder="Describe the action, camera movement and sound...",
 
307
  )
308
  enhance = gr.Checkbox(
309
+ label="Enhance / Upsample prompt",
310
  value=False,
311
+ info="Rewrite the prompt with the Qwen3-VL conditioner before MiniMax-H3 generation.",
312
  )
313
+ canvas = gr.Dropdown(list(CANVASES), value=DEFAULT_CANVAS, label="Output canvas")
314
+ with gr.Row():
315
+ duration = gr.Slider(5, 15, value=5, step=1, label="Duration (seconds)")
316
+ seed = gr.Number(value=42, precision=0, label="Seed")
317
+ generate_button = gr.Button("Generate motion", variant="primary")
318
+ with gr.Column(scale=1):
319
+ video = gr.Video(label="Generated video + soundtrack", autoplay=True)
320
+ status = gr.Markdown()
321
+ with gr.Accordion("Upsampled prompt", open=False, visible=False) as enhanced_panel:
322
+ enhanced_box = gr.Textbox(show_label=False, lines=10, interactive=False)
323
+
324
+ generate_button.click(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  fn=generate,
326
+ inputs=[prompt, image, canvas, duration, seed, enhance],
327
+ outputs=[video, status, enhanced_box, enhanced_panel],
 
 
 
 
 
 
 
328
  api_name="generate",
329
  )
 
 
 
 
 
 
 
 
330
 
331
+ demo.queue(max_size=8).launch(mcp_server=True)
 
requirements.txt CHANGED
@@ -9,10 +9,9 @@ transformers==5.8.0
9
  accelerate==1.14.0
10
  huggingface-hub==1.24.0
11
  gradio @ https://huggingface.co/buckets/gradio/pypi-previews/resolve/a9ce60ae03123ed6374b739d862e62dcae2f451e/gradio-6.25.0-py3-none-any.whl
12
- spaces==0.51.1
13
  av
14
  pillow
15
  numpy
16
  requests
17
  safetensors>=0.8.0
18
- peft
 
9
  accelerate==1.14.0
10
  huggingface-hub==1.24.0
11
  gradio @ https://huggingface.co/buckets/gradio/pypi-previews/resolve/a9ce60ae03123ed6374b739d862e62dcae2f451e/gradio-6.25.0-py3-none-any.whl
 
12
  av
13
  pillow
14
  numpy
15
  requests
16
  safetensors>=0.8.0
17
+ peft