dima0987654321 commited on
Commit
effd7bf
·
verified ·
1 Parent(s): dd9c3a8

Upload 6 files

Browse files
Files changed (5) hide show
  1. README.md +70 -14
  2. app.py +933 -169
  3. h3_split_blocks.py +64 -9
  4. packages.txt +1 -0
  5. requirements.txt +5 -1
README.md CHANGED
@@ -1,25 +1,81 @@
1
  ---
2
- title: MiniMax-H3 Motion Enhancer
3
- emoji: 🎬
4
- colorFrom: indigo
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
  app_file: app.py
9
- short_description: Animate a still image with MiniMax-H3 motion
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
 
 
 
 
12
  ---
13
 
14
- # MiniMax-H3 Motion Enhancer
15
 
16
- Animate a still image with the 8-step motion-enhancer LoRA for [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3).
17
- The app generates a short video and the model's native soundtrack.
 
18
 
19
- ## Model
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- - LoRA: [rzgar/minimax-h3_fl2v_8Step_motion_enhancer](https://huggingface.co/rzgar/minimax-h3_fl2v_8Step_motion_enhancer)
22
- - Base: [MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3)
23
- - License: Apache-2.0 for the LoRA; see the base model's [MiniMax H3 Community License](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/LICENSE)
24
 
25
- Use only images you own or have permission to animate. Do not upload private or intimate imagery of real people without their explicit consent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MiniMax-H3 4-Step FlashGen LoRA
3
+ emoji: ⚡
4
+ colorFrom: blue
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 6.25.0
8
  app_file: app.py
 
9
  python_version: "3.12"
10
  startup_duration_timeout: 1h
11
+ short_description: 4-step text-to-video+audio with MiniMax-H3 FlashGen LoRA
12
+ models:
13
+ - MiniMaxAI/MiniMax-H3
14
+ - Beidouqixing/minimax-h3-4step-lora-flashgen
15
  ---
16
 
17
+ # MiniMax-H3 4-Step FlashGen LoRA
18
 
19
+ A demo of the [FlashGen 4-step distillation LoRA](https://huggingface.co/Beidouqixing/minimax-h3-4step-lora-flashgen)
20
+ applied to [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3), MiniMax's 33B omni-modal model that generates
21
+ video with native stereo audio.
22
 
23
+ The FlashGen LoRA uses distribution-matching distillation (DMD2 / VSD, data-free) to reduce inference from ~20 steps
24
+ to just **4 steps** — approximately a 5x speedup. The distilled `base_schedule` `[1.0, 0.7, 0.4, 0.15, 0.0]` replaces
25
+ the *uniform* `linspace(1, 0, num_inference_steps)` grid `MiniMaxH3Scheduler.set_timesteps` would otherwise build, and
26
+ H3's per-modality exponential sigma shift still applies on top of it — a single base schedule can serve both
27
+ modalities precisely because it is pre-shift. So the video scheduler runs
28
+ σ `1 → 0.9655 → 0.8889 → 0.6792 → 0` (`shift = 12.0`) and the audio scheduler σ `1 → 0.875 → 0.6667 → 0.3462 → 0`
29
+ (`shift = 3.0`). Each shifted grid is injected through `MiniMaxH3Scheduler.set_timesteps(sigmas=...)`, which takes it
30
+ verbatim, and *pinned* onto its scheduler, because the pipeline's own denoise step calls
31
+ `set_timesteps(num_inference_steps, ...)` again — and for this scheduler `num_inference_steps` counts sigma grid
32
+ points (terminal `0.0` included), so it runs `len(sigmas) - 1` model evaluations. Pinning is the runtime equivalent of
33
+ `merge_lora_ckpt.py` writing `_minimax_h3.base_schedule` into `model_index.json`. Each generation reports the number
34
+ of Euler steps the scheduler actually took, read back off `scheduler.step_index`, rather than a hardcoded 4.
35
 
36
+ ## How it works
 
 
37
 
38
+ | Piece | What runs here |
39
+ |---|---|
40
+ | Transformer | `MiniMaxH3Transformer3DModel` — the 33B / 50-block H3 DiT, bf16, with the FlashGen LoRA folded in |
41
+ | VAEs | `AutoencoderKLMiniMaxH3` (video) + `AutoencoderKLMiniMaxH3Audio` (audio), float32 |
42
+ | Schedulers | `MiniMaxH3Scheduler` with the 4-step distilled `base_schedule` `[1.0, 0.7, 0.4, 0.15, 0.0]` under each modality's own shift (video `12.0`, audio `3.0`) |
43
+ | Conditioning | Delegated to [`multimodalart/qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner) over the gradio API (the 62 GB Qwen3-VL text encoder does not fit alongside the transformer on a single ZeroGPU worker) |
44
+
45
+ ## How the LoRA is applied
46
+
47
+ The LoRA repo ships its own merge script,
48
+ [`merge_lora_ckpt.py`](https://huggingface.co/Beidouqixing/minimax-h3-4step-lora-flashgen/blob/main/merge_lora_ckpt.py),
49
+ and this Space follows it rather than a default diffusers LoRA load. The adapter is **merged** into the base weights
50
+ at startup — `W' = W + scale * (lora_B @ lora_A)`, accumulated in fp32, at `scale = lora_alpha / rank = 1.0`
51
+ (FlashGen's alpha equals its rank of 64) — instead of being attached as a runtime PEFT adapter, and all 259 LoRA
52
+ targets are required to resolve or startup fails, exactly as the reference script does.
53
+
54
+ The reference script merges into the *original* MiniMax-H3 partition, whose keys are the LoRA's own
55
+ (`blocks.N.attn.qkv_proj.weight`, `mlp.fc1/fc2`, `attn.out_proj`, `adaln_proj.linear`). Merging into the diffusers
56
+ port instead requires replaying the same layout transforms
57
+ [`convert_minimax_h3_to_diffusers.py`](https://github.com/huggingface/diffusers/blob/main/scripts/convert_minimax_h3_to_diffusers.py)
58
+ applied to the base weights, because a delta is only valid in the layout of the weight it is added to:
59
+
60
+ | Original LoRA target | diffusers parameter | Transform |
61
+ |---|---|---|
62
+ | `blocks.N.…` / `token_refiner.blocks.N.…` | `transformer_blocks.N.…` / `token_refiner.refiner_blocks.N.…` | rename |
63
+ | `attn.out_proj` | `attn.to_out.0` | rename |
64
+ | `mlp.fc2` | `ff.net.2` | rename |
65
+ | `final_layer.adaln_proj.linear` | `norm_out.linear` | rename |
66
+ | `adaln_proj.linear` | `adaln_proj.linear` | identity |
67
+ | `mlp.fc1` | `ff.net.0.proj` | fused halves swapped, `[gate; value]` → `[value; gate]` for diffusers' `SwiGLU` |
68
+ | `attn.qkv_proj` | `attn.to_q` / `to_k` / `to_v` | **de-interleave the per-head rows** (`[head0: q k v, head1: q k v, …]` → `[q_all; k_all; v_all]`), *then* split thirds |
69
+
70
+ That last row is the one that is easy to get wrong: the checkpoint's fused QKV rows are per-head interleaved, so
71
+ splitting them into contiguous thirds — the naive reading — scatters every head's q/k/v across all three projections
72
+ and turns the adapter into noise on all 52 attention blocks. With the de-interleave in place, the merged weights
73
+ reproduce `merge_lora_ckpt.py`-then-convert to within bf16 rounding.
74
+
75
+ ## License
76
+
77
+ MiniMax-H3 is covered by the **MiniMax H3 Community License Agreement**. The FlashGen LoRA is Apache-2.0.
78
+ Read both before using any output of this Space.
79
+ ## Extend Video (experimental)
80
+
81
+ The UI includes a separate **Extend Video** section. Upload a video, describe the next segment, and the app extracts its final frame, generates a new segment using the existing MiniMax-H3 pipeline, then concatenates the original and generated clips. This feature does not change the FlashGen four-step schedule or any LoRA loading/scales. It is final-frame-conditioned continuation, not full temporal video-to-video conditioning; motion and audio may not be perfectly continuous at the join. Joining requires `ffmpeg` to be available in the Space runtime.
app.py CHANGED
@@ -1,66 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- CONDITIONER_SPACE = "multimodalart/qwen3vl-conditioner"
22
- FPS = 24
23
- STEPS = 8
24
- OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "minimax-h3-motion-enhancer")
25
- os.makedirs(OUTPUT_DIR, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  CANVASES = {
 
28
  "960x544 · 16:9 fast": (544, 960),
29
  "1024x576 · 16:9 fast": (576, 1024),
 
 
 
 
30
  "544x960 · 9:16 fast": (960, 544),
 
 
 
31
  "544x544 · 1:1 fast": (544, 544),
 
 
32
  "768x576 · 4:3 fast": (576, 768),
 
33
  "576x768 · 3:4 fast": (768, 576),
 
 
34
  "1152x512 · 21:9 fast": (512, 1152),
 
35
  }
36
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- def _reorder_interleaved_qkv(weight: torch.Tensor, num_heads: int, head_dim: int) -> torch.Tensor:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  grouped = weight.reshape(num_heads, 3 * head_dim, *weight.shape[1:])
41
  query, key, value = grouped.split(head_dim, dim=1)
42
  return torch.cat(
43
  [part.reshape(num_heads * head_dim, *weight.shape[1:]) for part in (query, key, value)], dim=0
44
  )
45
 
46
-
47
  def _lora_target_name(source_name: str) -> str:
48
- target = source_name.removeprefix("diffusion_model.")
49
- if target.startswith("token_refiner.blocks."):
50
- target = target.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1)
51
- elif target.startswith("blocks."):
52
- target = target.replace("blocks.", "transformer_blocks.", 1)
 
 
53
  return target.replace("final_layer.adaln_proj.linear", "norm_out.linear")
54
 
55
-
56
- def _lora_targets(name: str, b_weight: torch.Tensor, num_heads: int, head_dim: int):
57
  target = _lora_target_name(name)
 
58
  if target.endswith(".attn.qkv_proj"):
59
  prefix = target.removesuffix("qkv_proj")
 
 
60
  rows = _reorder_interleaved_qkv(b_weight, num_heads, head_dim)
61
  for kind, part in zip(("q", "k", "v"), rows.split(num_heads * head_dim, dim=0)):
62
  yield f"{prefix}to_{kind}.weight", part.contiguous()
63
  elif target.endswith(".mlp.fc1"):
 
64
  gate, value = b_weight.chunk(2, dim=0)
65
  yield target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous()
66
  elif target.endswith(".mlp.fc2"):
@@ -68,108 +246,367 @@ def _lora_targets(name: str, b_weight: torch.Tensor, num_heads: int, head_dim: i
68
  elif target.endswith(".attn.out_proj"):
69
  yield target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", b_weight
70
  else:
 
71
  yield target + ".weight", b_weight
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- def _apply_motion_lora(transformer) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  from huggingface_hub import hf_hub_download
76
  from safetensors.torch import load_file
77
 
78
- path = hf_hub_download(LORA_REPO, LORA_FILE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  lora = load_file(path, device="cpu")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  suffix_a = ".lora_A.weight"
81
  suffix_b = ".lora_B.weight"
82
- bases = sorted({key[: -len(suffix_a)] for key in lora if key.endswith(suffix_a)})
 
 
83
  if not bases:
84
- raise ValueError("The motion enhancer LoRA did not contain any lora_A/lora_B pairs")
 
85
 
86
  params = dict(transformer.named_parameters())
87
  num_heads = transformer.config.num_attention_heads
88
  head_dim = transformer.config.attention_head_dim
89
  folded = 0
90
  missed = []
 
91
  with torch.no_grad():
92
  for name in bases:
93
  a = lora[f"{name}{suffix_a}"]
94
  b = lora.get(f"{name}{suffix_b}")
95
  if b is None:
96
- raise ValueError(f"Missing LoRA B matrix for {name}")
97
- for key, b_part in _lora_targets(name, b, num_heads, head_dim):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  param = params.get(key)
99
  if param is None:
100
  missed.append(key)
101
  continue
102
- delta = b_part.float() @ a.float()
 
103
  if delta.shape != param.shape:
104
- raise ValueError(f"LoRA shape mismatch for {key}: {delta.shape} vs {param.shape}")
105
- param.data = (param.data.float() + delta.to(param.device)).to(param.dtype)
 
 
 
 
 
106
  folded += 1
107
 
108
  if missed:
109
- raise ValueError(f"{len(missed)} LoRA targets did not match the H3 transformer, e.g. {missed[:3]}")
110
- return f"Motion enhancer loaded: {len(bases)} adapters, {folded} matrices"
 
 
111
 
 
 
 
 
112
 
113
- def _load_pipeline():
114
- from diffusers import ComponentsManager
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- manager = ComponentsManager()
117
- blocks = MiniMaxH3GeneratorBlocks()
118
- pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
119
- pipe.load_components(dtype=torch.bfloat16)
120
- pipe.vae.set_attention_backend("native")
121
- pipe.audio_vae.set_attention_backend("native")
122
- pipe.transformer.set_attention_backend(os.environ.get("H3_ATTENTION", "_native_cudnn"))
123
- print(_apply_motion_lora(pipe.transformer), flush=True)
124
- # Module-scope placement lets ZeroGPU pack the weights once instead of per request.
125
- pipe.transformer.to("cuda")
126
- return pipe
127
 
 
 
 
128
 
129
- PIPE = _load_pipeline()
 
130
 
 
 
 
 
131
 
132
- @lru_cache(maxsize=1)
133
- def _conditioner_client():
134
- from gradio_client import Client
135
 
136
- return Client(CONDITIONER_SPACE)
 
 
 
 
 
137
 
 
 
 
138
 
139
- def _snap_frames(seconds: float) -> int:
140
- frames = max(120, round(float(seconds) * FPS))
141
- while frames % 17 != 5:
142
- frames += 1
143
- return frames
144
 
 
 
 
 
145
 
146
- def _cover_crop(image_path: str, canvas: str) -> str:
147
- from PIL import Image, ImageOps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- height, width = CANVASES[canvas]
150
- with Image.open(image_path) as image:
151
- image = ImageOps.exif_transpose(image).convert("RGB")
152
- image = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
153
- output = os.path.join(OUTPUT_DIR, f"frame-{time.time_ns()}.png")
154
- image.save(output)
155
- return output
156
 
 
157
 
158
- def _condition(prompt: str, image_path: str, canvas: str, num_frames: int, rewrite_prompt: bool = False):
159
- """Encode the prompt through the same Qwen3-VL conditioner path used by FlashGen.
160
 
161
- When rewrite_prompt=True, the conditioner first rewrites the user's prompt into
162
- MiniMax-H3's structured shot description, then encodes that rewritten prompt.
163
- This is the "Upsample/Enhance prompt" behavior from the FlashGen Space.
 
 
 
164
  """
165
  from gradio_client import handle_file
166
  from safetensors import safe_open
167
 
168
  def call():
169
- return _conditioner_client().predict(
170
  prompt=prompt,
171
- image_path=handle_file(image_path),
172
- last_image_path=None,
173
  canvas=canvas,
174
  num_frames=num_frames,
175
  rewrite_prompt=bool(rewrite_prompt),
@@ -179,149 +616,476 @@ def _condition(prompt: str, image_path: str, canvas: str, num_frames: int, rewri
179
  try:
180
  path, plan = call()
181
  except Exception as first:
182
- # The conditioner can occasionally leave a stale Gradio client behind.
183
- # Retry once with a fresh client, matching the FlashGen implementation.
184
  print(f"[conditioner] retrying with a fresh client after: {first}", flush=True)
185
- _conditioner_client.cache_clear()
186
  path, plan = call()
187
 
188
  with safe_open(path, framework="pt") as handle:
189
- embeds = handle.get_tensor("prompt_embeds")
190
- tags = handle.get_tensor("text_token_tags")
191
  metadata = handle.metadata()
192
- return embeds, tags, metadata, plan
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
 
194
 
195
- @spaces.GPU(duration=120, size="xlarge")
196
- def _generate_gpu(embeds, tags, image, height: int, width: int, num_frames: int, seed: int):
197
- """Run MiniMax-H3 denoising and return CPU video frames plus soundtrack."""
198
- PIPE.vae.to("cuda")
199
- PIPE.audio_vae.to("cuda")
200
- pipe_state = PIPE(
201
- prompt_embeds=embeds.to("cuda"),
202
- text_token_tags=tags,
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  image=image,
204
- height=int(height),
205
- width=int(width),
206
- num_frames=int(num_frames),
207
- num_inference_steps=STEPS + 1,
 
 
208
  generator=torch.Generator("cpu").manual_seed(int(seed)),
209
  )
210
- frames = pipe_state.get("videos")[0]
211
- audio = pipe_state.get("audio")[0]
212
- if torch.is_tensor(frames):
213
- frames = frames.cpu()
214
- if torch.is_tensor(audio):
215
- audio = audio.cpu()
216
- return frames, audio, int(pipe_state.get("sampling_rate"))
 
 
 
 
 
 
 
217
 
 
 
218
 
219
  def generate(
220
- prompt: str,
221
- image_path: str | None,
222
- canvas: str,
223
- duration: float,
224
- seed: int,
225
- enhance_prompt: bool = False,
 
226
  progress=gr.Progress(track_tqdm=True),
227
  ):
228
- """Animate an uploaded image, optionally using H3's Qwen3-VL prompt enhancement."""
229
- if not image_path:
230
- raise gr.Error("Upload a first frame before generating.")
 
 
 
 
 
 
 
231
  if not prompt or not prompt.strip():
232
- raise gr.Error("Describe the motion you want to see.")
 
 
233
 
234
- height, width = CANVASES[canvas]
235
- num_frames = _snap_frames(duration)
236
- cropped_path = _cover_crop(image_path, canvas)
237
- from PIL import Image
238
  from diffusers.utils import encode_video
239
 
240
- progress(
241
- 0.05,
242
- desc="Enhancing prompt, then encoding..." if enhance_prompt else "Encoding the first frame...",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  )
244
- embeds, tags, metadata, plan = _condition(
245
- prompt.strip(), cropped_path, canvas, num_frames, rewrite_prompt=bool(enhance_prompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  )
247
- height = int(metadata.get("height", height))
248
- width = int(metadata.get("width", width))
249
- num_frames = int(metadata.get("num_frames", num_frames))
250
 
251
- enhanced = (plan or {}).get("refined_prompt") or ""
252
- progress(0.15, desc=f"Generating {num_frames / FPS:.1f}s at {width}x{height} in {STEPS} steps")
253
- with Image.open(cropped_path) as image:
254
- frames, audio, sampling_rate = _generate_gpu(
255
- embeds, tags, image.convert("RGB"), height, width, num_frames, int(seed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  )
 
257
 
258
- output = os.path.join(OUTPUT_DIR, f"motion-enhanced-{time.time_ns()}.mp4")
259
- encode_video(frames, fps=FPS, output_path=output, audio=audio, audio_sample_rate=sampling_rate)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
- status = (
262
- f"{width}x{height} · {num_frames / FPS:.2f}s · {STEPS} denoising steps · "
263
- f"seed {int(seed)}"
264
- )
265
- return output, status, enhanced, gr.update(visible=bool(enhanced))
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- DESCRIPTION = """
269
- # MiniMax-H3 Motion Enhancer
270
 
271
- Upload a still image and describe the movement. The Space uses the **8-step Motion Enhancer LoRA**
272
- with MiniMax-H3 and synchronized audio.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- **Enhance / Upsample prompt** uses the same Qwen3-VL prompt-rewriting path as the MiniMax-H3 FlashGen
275
- Space: your short instruction is first expanded into a structured shot/action/sound description and
276
- then those rewritten embeddings are sent to H3.
277
 
278
- Use only images you own or have permission to animate.
 
279
  """
280
 
 
 
 
 
 
 
 
281
 
282
- with gr.Blocks(title="MiniMax-H3 Motion Enhancer") as demo:
283
- gr.Markdown(DESCRIPTION)
284
  with gr.Row(equal_height=True):
285
- with gr.Column(scale=1):
286
- image = gr.Image(label="First frame", type="filepath", sources=["upload", "webcam"])
287
  prompt = gr.Textbox(
288
- label="Motion prompt",
289
- value="The person naturally performs the described action. Keep the same identity and appearance. Natural body motion, realistic facial expression, synchronized sound effects and speech if specified.",
290
- lines=5,
291
- placeholder="Describe the action, camera movement and sound...",
292
  )
293
  enhance = gr.Checkbox(
294
- label="Enhance / Upsample prompt",
295
  value=False,
296
- info="Rewrite the prompt with H3's Qwen3-VL into a detailed structured shot description before generation.",
297
  )
298
- with gr.Row():
299
- canvas = gr.Dropdown(list(CANVASES), value=DEFAULT_CANVAS, label="Output canvas")
300
- duration = gr.Slider(5, 15, value=5, step=1, label="Duration (seconds)")
301
- seed = gr.Number(value=42, precision=0, label="Seed")
302
- generate_button = gr.Button("Generate motion", variant="primary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
- with gr.Column(scale=1):
305
- video = gr.Video(label="Generated video + soundtrack", autoplay=True)
306
- status = gr.Markdown()
307
- with gr.Accordion("Upsampled prompt", open=False, visible=False) as enhanced_panel:
308
- enhanced_box = gr.Textbox(show_label=False, lines=10, interactive=False)
 
309
 
310
- outputs = [video, status, enhanced_box, enhanced_panel]
311
 
312
- generate_button.click(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  fn=generate,
314
- inputs=[prompt, image, canvas, duration, seed, enhance],
315
- outputs=outputs,
316
- api_name="generate",
317
  )
318
 
 
 
 
 
 
 
319
  prompt.submit(
320
- fn=generate,
321
- inputs=[prompt, image, canvas, duration, seed, enhance],
322
- outputs=outputs,
323
  api_name="generate_submit",
324
  )
325
 
 
326
 
327
- demo.queue(max_size=8).launch(mcp_server=True)
 
 
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 shutil
32
+ import subprocess
33
  import tempfile
34
  import time
35
+ import traceback
36
+ from functools import cache
37
 
38
  import spaces
 
 
 
39
  import gradio as gr
40
  import torch
41
 
42
+ MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
43
+ LORA_REPO = os.environ.get("H3_LORA_REPO", "Beidouqixing/minimax-h3-4step-lora-flashgen")
44
+ LORA_FILE = os.environ.get("H3_LORA_FILE", "minimax_h3_4step_lora_flashgen_v1.0_768p_bf16.safetensors")
45
 
46
+ # Additional LoRAs, merged on top of the FlashGen 4-step weights.
47
+ MOTION_LORA_REPO = os.environ.get(
48
+ "H3_MOTION_LORA_REPO",
49
+ "rzgar/minimax-h3_fl2v_8Step_motion_enhancer",
50
+ )
51
+ MOTION_LORA_FILE = os.environ.get(
52
+ "H3_MOTION_LORA_FILE",
53
+ "minimax-h3_fl2v_8Step_motion_enhancer.safetensors",
54
+ )
55
+ MOTION_LORA_SCALE = float(os.environ.get("H3_MOTION_LORA_SCALE", "1.0"))
56
 
57
+ BLOWJOB_LORA_REPO = os.environ.get(
58
+ "H3_BLOWJOB_LORA_REPO",
59
+ "iamgroot1212/minimax-h3-loras",
60
+ )
61
+ BLOWJOB_LORA_FILE = os.environ.get(
62
+ "H3_BLOWJOB_LORA_FILE",
63
+ "MMH3-Hybrid-BlowJob-lora.safetensors",
64
+ )
65
+ BLOWJOB_LORA_SCALE = float(os.environ.get("H3_BLOWJOB_LORA_SCALE", "1.0"))
66
+
67
+ CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
68
+ # ``pack`` places the transformer at startup (66 GB on disk via the spaces pack); VAEs move on first GPU call.
69
+ PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
70
+ ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
71
+ GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
72
+
73
+ # The distilled schedule from the LoRA README: 5 *base* sigma points = 4 Euler steps. This is the grid
74
+ # before H3's per-modality shift, i.e. the replacement for `linspace(1, 0, num_inference_steps)` — see
75
+ # `shifted_schedule` below.
76
+ BASE_SCHEDULE = [1.0, 0.7, 0.4, 0.15, 0.0]
77
+ NUM_STEPS = len(BASE_SCHEDULE) - 1 # 4 model evaluations
78
+ # `MiniMaxH3Scheduler.set_timesteps(num_inference_steps=...)` counts *sigma grid points*, terminal 0.0
79
+ # included, and runs `len(sigmas) - 1` model evaluations (`timesteps = 1 - sigmas[:-1]`). So the number the
80
+ # pipeline has to be given for NUM_STEPS Euler steps is NUM_STEPS + 1 == len(BASE_SCHEDULE); passing 4 there
81
+ # yields a 4-point grid and only 3 steps.
82
+ NUM_SIGMA_POINTS = len(BASE_SCHEDULE) # 5
83
+
84
+ FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
85
+ MIN_UI_DURATION, MAX_UI_DURATION = 2, 14
86
 
87
  CANVASES = {
88
+ # 16:9
89
  "960x544 · 16:9 fast": (544, 960),
90
  "1024x576 · 16:9 fast": (576, 1024),
91
+ "1152x640 · 16:9": (640, 1152),
92
+ "1280x704 · 16:9": (704, 1280),
93
+ "1344x768 · 16:9 full": (768, 1344),
94
+ # 9:16
95
  "544x960 · 9:16 fast": (960, 544),
96
+ "640x1152 · 9:16": (1152, 640),
97
+ "768x1344 · 9:16 full": (1344, 768),
98
+ # 1:1
99
  "544x544 · 1:1 fast": (544, 544),
100
+ "768x768 · 1:1 full": (768, 768),
101
+ # 4:3 / 3:4
102
  "768x576 · 4:3 fast": (576, 768),
103
+ "1024x768 · 4:3 full": (768, 1024),
104
  "576x768 · 3:4 fast": (768, 576),
105
+ "768x1024 · 3:4 full": (1024, 768),
106
+ # 21:9
107
  "1152x512 · 21:9 fast": (512, 1152),
108
+ "1536x672 · 21:9 full": (672, 1536),
109
  }
110
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
111
 
112
+ OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "h3-flashgen-out")
113
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
114
+
115
+ PIPE = None
116
+ LOAD_ERROR: str | None = None
117
+ LOADED_IN: float | None = None
118
+
119
+ def snap_frames(seconds: float) -> int:
120
+ """The frame count MiniMax-H3's video VAE can decode: the next ``17 * n + 5`` at 24 fps."""
121
+ frames = max(1, round(float(seconds) * FPS))
122
+ while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
123
+ frames += 1
124
+ return frames
125
+
126
+ def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
127
+ """Let the pipeline generate below its 5 s floor."""
128
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
129
+
130
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
131
+
132
+ def shifted_schedule(shift: float) -> list[float]:
133
+ """``BASE_SCHEDULE`` pushed through H3's exponential sigma shift ``s*σ / (1 + (s - 1)*σ)``.
134
+
135
+ ``base_schedule`` is what ``merge_lora_ckpt.py`` pins into ``_minimax_h3.base_schedule``, and the name
136
+ is literal: it replaces the *base* grid — ``MiniMaxH3Scheduler.set_timesteps``'s
137
+ ``linspace(1, 0, num_inference_steps)``, which the merge script calls "the server's uniform schedule" —
138
+ and the release's per-modality ``sigma_shift_scales`` (video ``12.0``, audio ``3.0``) still apply after
139
+ it. One schedule can only serve both modalities because it is pre-shift.
140
 
141
+ Feeding the base grid to the model as if it were already shifted is what produced the ghosted,
142
+ superimposed-frames output: every step conditioned the transformer on a timestep far from the latent's
143
+ real noise level (σ 0.7 instead of 0.966, 0.4 instead of 0.889, 0.15 instead of 0.679), so each
144
+ prediction was an average over noise levels the student was never distilled at. The shift fixes 1.0 and
145
+ 0.0, so the endpoints — and the 4-step count — are unchanged.
146
+ """
147
+ return [shift * sigma / (1 + (shift - 1) * sigma) for sigma in BASE_SCHEDULE]
148
+
149
+ def pin_distilled_schedule(scheduler) -> None:
150
+ """Pin the shifted ``BASE_SCHEDULE`` onto a scheduler so *every* ``set_timesteps`` call lands on it.
151
+
152
+ Setting the sigmas before calling the pipeline is not enough: the denoise step contains
153
+ ``MiniMaxH3SetTimestepsStep``, which calls ``scheduler.set_timesteps(num_inference_steps, device=...)``
154
+ itself and so rebuilds the uniform shifted grid, throwing the distilled one away. That grid is also
155
+ where the missing step went — ``num_inference_steps`` is a *grid point* count for this scheduler, so
156
+ the 4 we asked for produced a 4-sigma grid, i.e. ``len(sigmas) - 1 == 3`` model evaluations.
157
+
158
+ The schedule pinned here is ``BASE_SCHEDULE`` under *this* scheduler's own ``shift``, so the video
159
+ scheduler (``shift = 12.0``) and the audio one (``shift = 3.0``) keep the distinct grids the two
160
+ modalities are denoised on. ``set_timesteps(sigmas=...)`` takes its argument verbatim, so the shift has
161
+ to be applied here rather than left to the scheduler.
162
+
163
+ Pinning is the equivalent of ``merge_lora_ckpt.py``'s ``_minimax_h3.base_schedule`` in
164
+ ``model_index.json``: the served checkpoint carries its distilled schedule and the caller's step count
165
+ cannot silently replace it.
166
+ """
167
+ if getattr(scheduler, "_flashgen_pinned", False):
168
+ return
169
+
170
+ original = scheduler.set_timesteps
171
+ pinned = shifted_schedule(scheduler.shift)
172
+
173
+ def set_timesteps(num_inference_steps=None, device=None, sigmas=None):
174
+ return original(device=device, sigmas=pinned if sigmas is None else sigmas)
175
+
176
+ scheduler.set_timesteps = set_timesteps
177
+ scheduler._flashgen_pinned = True
178
+
179
+ # ── LoRA loading (weight folding) ─────────────────────────────────────────────
180
+ #
181
+ # This mirrors ``merge_lora_ckpt.py``, the merge script the LoRA repo itself ships, rather than a
182
+ # default ``load_lora_adapter`` + ``set_adapters``. That script folds the adapter into the base
183
+ # weights ahead of serving (``W' = W + scale * (lora_B @ lora_A)``, accumulated in fp32, with
184
+ # ``scale = lora_alpha / rank = 1.0`` because FlashGen's alpha equals its rank of 64), pins the
185
+ # distilled ``base_schedule``, and hard-fails if any LoRA target finds no base weight. We reproduce
186
+ # all of that here — merge-and-unload semantics, not a runtime adapter.
187
+ #
188
+ # The one thing the reference script never has to do is translate names: it merges into the
189
+ # *original* MiniMax-H3 partition, whose keys are exactly the LoRA's own
190
+ # (``blocks.N.attn.qkv_proj.weight``). Merging into the diffusers port instead means replaying the
191
+ # very same transforms ``scripts/convert_minimax_h3_to_diffusers.py`` applied to the base weights,
192
+ # because a delta is only valid in the layout of the weight it is added to:
193
+ #
194
+ # * ``blocks.`` -> ``transformer_blocks.``, ``token_refiner.blocks.`` ->
195
+ # ``token_refiner.refiner_blocks.``, ``final_layer.adaln_proj.linear`` -> ``norm_out.linear``,
196
+ # ``attn.out_proj`` -> ``attn.to_out.0``, ``mlp.fc2`` -> ``ff.net.2`` (pure renames),
197
+ # * ``mlp.fc1`` -> ``ff.net.0.proj`` with its two fused halves swapped, because diffusers' SwiGLU
198
+ # reads ``[value; gate]`` where the checkpoint stores ``[gate; value]``,
199
+ # * ``attn.qkv_proj`` -> ``to_q`` / ``to_k`` / ``to_v``, and this is the subtle one: the fused QKV
200
+ # rows of the original checkpoint are **per-head interleaved**
201
+ # (``[head0: q k v, head1: q k v, ...]``), so they must be de-interleaved into
202
+ # ``[q_all; k_all; v_all]`` *before* being split into thirds. Splitting the raw rows into
203
+ # contiguous thirds directly — the naive reading — scatters every head's q/k/v across all three
204
+ # projections, which turns the adapter into structured noise on all 52 attention blocks.
205
+
206
+ def _reorder_interleaved_qkv(weight, num_heads: int, head_dim: int):
207
+ """De-interleave per-head fused-QKV rows into ``[q_all; k_all; v_all]``.
208
+
209
+ Identical to ``reorder_interleaved_qkv`` in diffusers' MiniMax-H3 conversion script, which
210
+ applies it to the base fused QKV weight before splitting it. ``lora_B`` indexes that same fused
211
+ output space, so the delta needs the same reorder.
212
+ """
213
  grouped = weight.reshape(num_heads, 3 * head_dim, *weight.shape[1:])
214
  query, key, value = grouped.split(head_dim, dim=1)
215
  return torch.cat(
216
  [part.reshape(num_heads * head_dim, *weight.shape[1:]) for part in (query, key, value)], dim=0
217
  )
218
 
 
219
  def _lora_target_name(source_name: str) -> str:
220
+ """Map an original-checkpoint base name (without ``.lora_A/B.*``) to the diffusers parameter path."""
221
+ if source_name.startswith("token_refiner.blocks."):
222
+ target = source_name.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1)
223
+ elif source_name.startswith("blocks."):
224
+ target = source_name.replace("blocks.", "transformer_blocks.", 1)
225
+ else:
226
+ target = source_name
227
  return target.replace("final_layer.adaln_proj.linear", "norm_out.linear")
228
 
229
+ def _lora_targets(name: str, b_weight, num_heads: int, head_dim: int):
230
+ """Yield ``(diffusers_param_key, row_transformed_B)`` for one original LoRA base name."""
231
  target = _lora_target_name(name)
232
+
233
  if target.endswith(".attn.qkv_proj"):
234
  prefix = target.removesuffix("qkv_proj")
235
+ # De-interleave the per-head rows first, then split contiguous thirds — exactly the
236
+ # composition the base fused QKV weight went through during conversion.
237
  rows = _reorder_interleaved_qkv(b_weight, num_heads, head_dim)
238
  for kind, part in zip(("q", "k", "v"), rows.split(num_heads * head_dim, dim=0)):
239
  yield f"{prefix}to_{kind}.weight", part.contiguous()
240
  elif target.endswith(".mlp.fc1"):
241
+ # SwiGLU gate/value swap: the checkpoint stores [gate, value]; diffusers stores [value, gate].
242
  gate, value = b_weight.chunk(2, dim=0)
243
  yield target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous()
244
  elif target.endswith(".mlp.fc2"):
 
246
  elif target.endswith(".attn.out_proj"):
247
  yield target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", b_weight
248
  else:
249
+ # adaln_proj.linear (block-level) and norm_out.linear: identical row layout.
250
  yield target + ".weight", b_weight
251
 
252
+ def load_and_apply_lora(transformer) -> str:
253
+ """Download the FlashGen LoRA, remap keys, and fold ``scale * (B @ A)`` into the base weights."""
254
+ import torch
255
+ from huggingface_hub import hf_hub_download
256
+ from safetensors.torch import load_file
257
+
258
+ lora_path = hf_hub_download(LORA_REPO, LORA_FILE)
259
+ lora = load_file(lora_path)
260
+
261
+ # Same pairing and validation contract as merge_lora_ckpt.py's `load_lora_pairs`.
262
+ suffix_a, suffix_b = ".lora_A.default.weight", ".lora_B.default.weight"
263
+ bases = sorted({key[: -len(suffix_a)] for key in lora if key.endswith(suffix_a)})
264
+ unexpected = [key for key in lora if not key.endswith((suffix_a, suffix_b))]
265
+ if unexpected:
266
+ raise ValueError(f"{LORA_FILE} holds {len(unexpected)} non-LoRA tensors, e.g. {unexpected[:5]}")
267
+ if not bases:
268
+ raise ValueError(f"No lora_A/lora_B pairs found in {LORA_FILE}")
269
+
270
+ ranks = set()
271
+ for name in bases:
272
+ if f"{name}{suffix_b}" not in lora:
273
+ raise ValueError(f"LoRA is missing the lora_B twin of {name}{suffix_a}")
274
+ ranks.add(lora[f"{name}{suffix_a}"].shape[0])
275
+ if len(ranks) != 1:
276
+ raise ValueError(f"LoRA mixes ranks {sorted(ranks)}; a DMD2 student export uses a single rank")
277
+ rank = ranks.pop()
278
 
279
+ num_heads = transformer.config.num_attention_heads
280
+ head_dim = transformer.config.attention_head_dim
281
+ # merge_lora_ckpt.py's --scale, i.e. lora_alpha / rank; 1.0 because FlashGen's alpha == rank.
282
+ scale = float(os.environ.get("H3_LORA_SCALE", "1.0"))
283
+
284
+ params = dict(transformer.named_parameters())
285
+ folded, missed = 0, []
286
+ with torch.no_grad():
287
+ for name in bases:
288
+ a = lora[f"{name}{suffix_a}"]
289
+ b = lora[f"{name}{suffix_b}"]
290
+ for key, b_part in _lora_targets(name, b, num_heads, head_dim):
291
+ param = params.get(key)
292
+ if param is None:
293
+ missed.append(key)
294
+ continue
295
+ # Accumulate in fp32 so a bf16 base does not swallow the update.
296
+ delta = scale * (b_part.to(torch.float32) @ a.to(torch.float32))
297
+ if delta.shape != param.shape:
298
+ raise ValueError(
299
+ f"LoRA delta for `{key}` has shape {tuple(delta.shape)}, "
300
+ f"base weight is {tuple(param.shape)}"
301
+ )
302
+ param.data = (param.data.float() + delta.to(param.device)).to(param.dtype)
303
+ folded += 1
304
+
305
+ # merge_lora_ckpt.py treats an unmatched target as fatal, and so do we: a silently skipped
306
+ # target means the name mapping is wrong and the demo would serve a half-applied adapter.
307
+ if missed:
308
+ raise ValueError(
309
+ f"{len(missed)} LoRA targets matched no transformer weight, e.g. {missed[:5]}. "
310
+ "The LoRA and the diffusers transformer disagree on module naming."
311
+ )
312
+
313
+ return (
314
+ f"FlashGen LoRA merged · {len(bases)} targets, rank {rank}, scale {scale:g} "
315
+ f"-> {folded} weight deltas · {LORA_FILE}"
316
+ )
317
+
318
+ # ── Additional LoRAs ─────────────────────────────────────────────────────────
319
+ #
320
+ # These are folded after FlashGen, so the original 4-step FlashGen schedule and
321
+ # all of its QKV/layout handling remain unchanged. The extra adapters use the
322
+ # standard .lora_A.weight/.lora_B.weight export used by the motion-enhancer and
323
+ # additional H3 checkpoints.
324
+
325
+ def _apply_extra_lora(
326
+ transformer,
327
+ repo_id: str,
328
+ filename: str,
329
+ label: str,
330
+ scale: float = 1.0,
331
+ ) -> str:
332
  from huggingface_hub import hf_hub_download
333
  from safetensors.torch import load_file
334
 
335
+ if repo_id.startswith("civitai://"):
336
+ import requests
337
+ model_version_id = repo_id.removeprefix("civitai://")
338
+ download_url = f"https://civitai.com/api/download/models/{model_version_id}"
339
+ cache_dir = os.path.join(tempfile.gettempdir(), "h3-lora-cache")
340
+ os.makedirs(cache_dir, exist_ok=True)
341
+ path = os.path.join(cache_dir, filename)
342
+ if not os.path.isfile(path) or os.path.getsize(path) == 0:
343
+ tmp_path = f"{path}.part"
344
+ with requests.get(download_url, stream=True, timeout=120, allow_redirects=True) as response:
345
+ response.raise_for_status()
346
+ with open(tmp_path, "wb") as handle:
347
+ for chunk in response.iter_content(chunk_size=8 * 1024 * 1024):
348
+ if chunk:
349
+ handle.write(chunk)
350
+ os.replace(tmp_path, path)
351
+ elif repo_id == "aronzhan/H3-Loras-bucket":
352
+ import requests
353
+ bucket_url = f"https://huggingface.co/buckets/{repo_id}/resolve/{filename}"
354
+ cache_dir = os.path.join(tempfile.gettempdir(), "h3-lora-cache")
355
+ os.makedirs(cache_dir, exist_ok=True)
356
+ path = os.path.join(cache_dir, filename)
357
+ if not os.path.isfile(path) or os.path.getsize(path) == 0:
358
+ tmp_path = f"{path}.part"
359
+ with requests.get(bucket_url, stream=True, timeout=60, allow_redirects=True) as response:
360
+ response.raise_for_status()
361
+ with open(tmp_path, "wb") as handle:
362
+ for chunk in response.iter_content(chunk_size=8 * 1024 * 1024):
363
+ if chunk:
364
+ handle.write(chunk)
365
+ os.replace(tmp_path, path)
366
+ else:
367
+ path = hf_hub_download(repo_id, filename)
368
+
369
  lora = load_file(path, device="cpu")
370
+
371
+ normalized = {}
372
+ for key, tensor in lora.items():
373
+ k = key
374
+ if k.startswith("lora_unet__"):
375
+ k = k.replace("lora_unet__", "", 1)
376
+ k = k.replace("_blocks_", ".blocks.")
377
+ k = k.replace("_self_attn_", ".attn.")
378
+ k = k.replace("_ffn_net_0_proj", ".mlp.fc1")
379
+ k = k.replace("_ffn_net_2", ".mlp.fc2")
380
+ k = k.replace("to_out_0", "out_proj")
381
+ elif k.startswith("lora_unet_"):
382
+ body = k[len("lora_unet_"):]
383
+ parts = body.split(".")
384
+ head = parts[0].split("_")
385
+ out = []
386
+ i = 0
387
+ while i < len(head):
388
+ if head[i] == "blocks" and i + 1 < len(head) and head[i + 1].isdigit():
389
+ out += ["blocks", head[i + 1]]
390
+ i += 2
391
+ continue
392
+ if head[i] == "self" and i + 1 < len(head) and head[i + 1] == "attn":
393
+ out.append("attn")
394
+ i += 2
395
+ continue
396
+ out.append(head[i])
397
+ i += 1
398
+ k = ".".join(out + parts[1:])
399
+ for prefix in ("base_model.model.", "diffusion_model.", "transformer.", "pipe.dit."):
400
+ if k.startswith(prefix):
401
+ k = k[len(prefix):]
402
+ k = k.replace(".self_attn.", ".attn.")
403
+ k = k.replace(".ffn.0.", ".mlp.fc1.")
404
+ k = k.replace(".ffn.2.", ".mlp.fc2.")
405
+ k = k.replace(".lora_down.weight", ".lora_A.weight")
406
+ k = k.replace(".lora_up.weight", ".lora_B.weight")
407
+ k = k.replace(".lora_A.default.weight", ".lora_A.weight")
408
+ k = k.replace(".lora_B.default.weight", ".lora_B.weight")
409
+ normalized[k] = tensor
410
+ lora = normalized
411
  suffix_a = ".lora_A.weight"
412
  suffix_b = ".lora_B.weight"
413
+ bases = sorted(
414
+ {key[: -len(suffix_a)] for key in lora if key.endswith(suffix_a)}
415
+ )
416
  if not bases:
417
+ sample = list(lora.keys())[:5]
418
+ raise ValueError(f"{label}: no lora_A/lora_B pairs found; keys: {sample}")
419
 
420
  params = dict(transformer.named_parameters())
421
  num_heads = transformer.config.num_attention_heads
422
  head_dim = transformer.config.attention_head_dim
423
  folded = 0
424
  missed = []
425
+
426
  with torch.no_grad():
427
  for name in bases:
428
  a = lora[f"{name}{suffix_a}"]
429
  b = lora.get(f"{name}{suffix_b}")
430
  if b is None:
431
+ raise ValueError(f"{label}: missing lora_B for {name}")
432
+
433
+ # These older H3 LoRAs use the original checkpoint names. Reuse
434
+ # the same layout transforms as the FlashGen merge path.
435
+ source_name = name.removeprefix("diffusion_model.")
436
+ target_name = _lora_target_name(source_name)
437
+
438
+ # ComfyUI H3 LoRAs may store q/k/v as separate targets. The
439
+ # diffusers H3 transformer used by this Space has separate to_q/k/v
440
+ # parameters, so those can be folded directly.
441
+ if target_name.endswith((".attn.q", ".attn.k", ".attn.v")):
442
+ kind = target_name.rsplit(".", 1)[-1]
443
+ target_parts = [(
444
+ target_name.replace(f".attn.{kind}", f".attn.to_{kind}") + ".weight",
445
+ b,
446
+ )]
447
+ elif target_name.endswith(".attn.qkv_proj"):
448
+ prefix = target_name.removesuffix("qkv_proj")
449
+ rows = _reorder_interleaved_qkv(b, num_heads, head_dim)
450
+ targets = zip(
451
+ ("q", "k", "v"),
452
+ rows.split(num_heads * head_dim, dim=0),
453
+ )
454
+ target_parts = [
455
+ (f"{prefix}to_{kind}.weight", part.contiguous())
456
+ for kind, part in targets
457
+ ]
458
+ elif target_name.endswith(".mlp.fc1"):
459
+ gate, value = b.chunk(2, dim=0)
460
+ target_parts = [(
461
+ target_name.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight",
462
+ torch.cat([value, gate]).contiguous(),
463
+ )]
464
+ elif target_name.endswith(".mlp.fc2"):
465
+ target_parts = [(
466
+ target_name.replace(".mlp.fc2", ".ff.net.2") + ".weight",
467
+ b,
468
+ )]
469
+ elif target_name.endswith(".attn.out_proj"):
470
+ target_parts = [(
471
+ target_name.replace(".attn.out_proj", ".attn.to_out.0") + ".weight",
472
+ b,
473
+ )]
474
+ else:
475
+ target_parts = [(target_name + ".weight", b)]
476
+
477
+ for key, b_part in target_parts:
478
  param = params.get(key)
479
  if param is None:
480
  missed.append(key)
481
  continue
482
+
483
+ delta = float(scale) * (b_part.float() @ a.float())
484
  if delta.shape != param.shape:
485
+ raise ValueError(
486
+ f"{label}: LoRA shape mismatch for {key}: "
487
+ f"{tuple(delta.shape)} vs {tuple(param.shape)}"
488
+ )
489
+ param.data = (
490
+ param.data.float() + delta.to(param.device)
491
+ ).to(param.dtype)
492
  folded += 1
493
 
494
  if missed:
495
+ raise ValueError(
496
+ f"{label}: {len(missed)} LoRA targets did not match the H3 transformer, "
497
+ f"e.g. {missed[:5]}"
498
+ )
499
 
500
+ return (
501
+ f"{label} merged · {len(bases)} targets, "
502
+ f"{folded} weight deltas · scale {float(scale):g}"
503
+ )
504
 
505
+ def load_and_apply_extra_loras(transformer) -> list[str]:
506
+ return [
507
+ _apply_extra_lora(
508
+ transformer,
509
+ MOTION_LORA_REPO,
510
+ MOTION_LORA_FILE,
511
+ "Motion Enhancer LoRA",
512
+ MOTION_LORA_SCALE,
513
+ ),
514
+ _apply_extra_lora(
515
+ transformer,
516
+ BLOWJOB_LORA_REPO,
517
+ BLOWJOB_LORA_FILE,
518
+ "MiniMax H3 BlowJob LoRA",
519
+ BLOWJOB_LORA_SCALE,
520
+ ),
521
+ ]
522
 
523
+ # ── Model loading ───────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
524
 
525
+ def load_models() -> str | None:
526
+ """Load the denoising half at startup: transformer + VAEs + schedulers, then fold the LoRA in."""
527
+ global PIPE, LOAD_ERROR, LOADED_IN
528
 
529
+ if PIPE is not None or LOAD_ERROR is not None:
530
+ return LOAD_ERROR
531
 
532
+ started = time.time()
533
+ try:
534
+ import torch
535
+ from diffusers import ComponentsManager
536
 
537
+ from h3_split_blocks import MiniMaxH3GeneratorBlocks
 
 
538
 
539
+ lower_duration_floor()
540
+ manager = ComponentsManager()
541
+ blocks = MiniMaxH3GeneratorBlocks()
542
+ print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
543
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
544
+ pipe.load_components(dtype=torch.bfloat16)
545
 
546
+ pipe.vae.set_attention_backend("native")
547
+ pipe.audio_vae.set_attention_backend("native")
548
+ pipe.transformer.set_attention_backend(ATTENTION)
549
 
550
+ # Fold the original FlashGen LoRA first. Its distilled 4-step schedule
551
+ # remains the schedule used by the merged transformer.
552
+ lora_status = load_and_apply_lora(pipe.transformer)
553
+ print(f"[gen] {lora_status}", flush=True)
 
554
 
555
+ # Then fold the requested Motion Enhancer and BlowJob LoRA into the
556
+ # same transformer. They add their deltas on top of FlashGen.
557
+ for extra_status in load_and_apply_extra_loras(pipe.transformer):
558
+ print(f"[gen] {extra_status}", flush=True)
559
 
560
+ # ... and pin the distilled schedule the merged weights were trained for, so the pipeline's own
561
+ # `set_timesteps` call cannot fall back to a uniform grid (and to NUM_STEPS - 1 evaluations).
562
+ pin_distilled_schedule(pipe.scheduler)
563
+ pin_distilled_schedule(pipe.audio_scheduler)
564
+ print(
565
+ f"[gen] distilled base_schedule {BASE_SCHEDULE} pinned -> {NUM_STEPS} steps; "
566
+ f"video σ (shift {pipe.scheduler.shift:g}) "
567
+ f"{[round(s, 4) for s in shifted_schedule(pipe.scheduler.shift)]}, "
568
+ f"audio σ (shift {pipe.audio_scheduler.shift:g}) "
569
+ f"{[round(s, 4) for s in shifted_schedule(pipe.audio_scheduler.shift)]}",
570
+ flush=True,
571
+ )
572
+
573
+ if PLACEMENT == "pack":
574
+ pipe.transformer.to("cuda")
575
+
576
+ PIPE = pipe
577
+ LOADED_IN = time.time() - started
578
+ print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
579
+ except Exception as error:
580
+ traceback.print_exc()
581
+ LOAD_ERROR = f"**Loading failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`"
582
+ return LOAD_ERROR
583
 
584
+ # ── Remote conditioning ──────────────────────────────────────────────────────
585
+
586
+ @cache
587
+ def conditioner():
588
+ from gradio_client import Client
 
 
589
 
590
+ return Client(CONDITIONER_SPACE)
591
 
592
+ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, ip_token=None, rewrite_prompt=False):
593
+ """Call the conditioner Space to get ``prompt_embeds`` + ``text_token_tags`` + resolved geometry.
594
 
595
+ ``rewrite_prompt`` is the same prompt-enhancement path ``multimodalart/minimax-h3`` uses: the conditioner
596
+ Space already hosts H3's Qwen3-VL, so it first has that model *rewrite* the prompt into MiniMax-H3's
597
+ structured shot description (the LLM-based rewriting step) and then encodes the rewritten text instead of
598
+ the raw one. The rewritten text comes back in the plan under ``refined_prompt`` (``None`` when no rewrite
599
+ happened). Encoding the enhanced prompt on the conditioner keeps the enhancement and the embeddings in
600
+ sync — nothing about the FlashGen merge, the 4-step schedule, or the VAEs is involved.
601
  """
602
  from gradio_client import handle_file
603
  from safetensors import safe_open
604
 
605
  def call():
606
+ return conditioner_client(ip_token).predict(
607
  prompt=prompt,
608
+ image_path=handle_file(image_path) if image_path else None,
609
+ last_image_path=handle_file(last_image_path) if last_image_path else None,
610
  canvas=canvas,
611
  num_frames=num_frames,
612
  rewrite_prompt=bool(rewrite_prompt),
 
616
  try:
617
  path, plan = call()
618
  except Exception as first:
 
 
619
  print(f"[conditioner] retrying with a fresh client after: {first}", flush=True)
620
+ conditioner.cache_clear()
621
  path, plan = call()
622
 
623
  with safe_open(path, framework="pt") as handle:
 
 
624
  metadata = handle.metadata()
625
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
626
+
627
+ def conditioner_client(ip_token):
628
+ if not ip_token:
629
+ return conditioner()
630
+ from gradio_client import Client
631
+
632
+ return Client(CONDITIONER_SPACE, headers={"x-ip-token": ip_token})
633
+
634
+ # ── Geometry helpers ────────────────────────────────────────────────────────
635
+
636
+ def _snap_canvas(aspect: float, current_canvas: str) -> str:
637
+ """The supported canvas whose aspect ratio is closest to ``aspect``.
638
+
639
+ Same selection ``multimodalart/minimax-h3`` makes: among the canvases sharing a ratio keep the
640
+ smallest (fastest) one, then take the ratio nearest the image's — so a 3:4 photo renders 3:4 rather
641
+ than being letterboxed into the 16:9 default. A canvas the user already picked is kept when it is at
642
+ least as close to the image as the best candidate, so ``1344x768`` survives a 1.75 image.
643
+ """
644
+ fastest: dict[float, tuple[str, tuple[int, int]]] = {}
645
+ for label, (h, w) in CANVASES.items():
646
+ r = w / h
647
+ if r not in fastest or w * h < fastest[r][1][0] * fastest[r][1][1]:
648
+ fastest[r] = (label, (h, w))
649
+ ratio = min(fastest, key=lambda r: abs(r - aspect))
650
+
651
+ cur_h, cur_w = CANVASES[current_canvas]
652
+ if abs(cur_w / cur_h - aspect) <= abs(ratio - aspect):
653
+ return current_canvas
654
+ return fastest[ratio][0]
655
+
656
+ def _cover_crop(image_path: str, canvas_label: str) -> str:
657
+ """Center cover-crop the file at ``image_path`` to ``canvas_label``'s aspect ratio, in place.
658
+
659
+ The keyframe has to arrive at the model already matching the canvas, otherwise the pipeline
660
+ letterboxes/stretches it and the first frame does not line up with the generated ones.
661
+ """
662
+ from PIL import Image as _Image
663
+
664
+ h, w = CANVASES[canvas_label]
665
+ target = w / h
666
+ img = _Image.open(image_path)
667
+ if abs(img.width / img.height - target) <= 1e-3:
668
+ return image_path
669
+ if img.width / img.height > target:
670
+ new_w = int(img.height * target)
671
+ left = (img.width - new_w) // 2
672
+ img = img.crop((left, 0, left + new_w, img.height))
673
+ else:
674
+ new_h = int(img.width / target)
675
+ top = (img.height - new_h) // 2
676
+ img = img.crop((0, top, img.width, top + new_h))
677
+ img.save(image_path)
678
+ return image_path
679
+
680
+ def _fit_keyframe(image_path, current_canvas):
681
+ """Snap the canvas to a keyframe's aspect ratio, then cover-crop the keyframe to that canvas."""
682
+ from PIL import Image as _Image
683
+
684
+ with _Image.open(image_path) as img:
685
+ aspect = img.width / img.height
686
+ label = _snap_canvas(aspect, current_canvas)
687
+ return _cover_crop(image_path, label), label
688
+
689
+ def _fit_keyframe_ui(image_path, current_canvas):
690
+ """``image.upload`` handler — the reference Space's behaviour, ported.
691
+
692
+ Doing this on upload (rather than only server-side inside ``generate``) is the whole point: the
693
+ user immediately sees the keyframe cropped to the ratio the video will be rendered at, and the
694
+ Canvas dropdown moves to the snapped resolution instead of sitting on the 16:9 default.
695
+ """
696
+ path = _as_path(image_path)
697
+ if not path:
698
+ return gr.update(), gr.update()
699
+ cropped, label = _fit_keyframe(path, current_canvas)
700
+ return gr.update(value=cropped), gr.update(value=label)
701
+
702
+ def _fit_last_keyframe_ui(last_image_path, image_path, current_canvas):
703
+ """``last_image.upload`` handler. The first frame owns the canvas when there is one, so a last
704
+ frame uploaded on top of it is only cover-cropped to the canvas already chosen."""
705
+ path = _as_path(last_image_path)
706
+ if not path:
707
+ return gr.update(), gr.update()
708
+ if _as_path(image_path):
709
+ return gr.update(value=_cover_crop(path, current_canvas)), gr.update()
710
+ return _fit_keyframe_ui(path, current_canvas)
711
+
712
+ def _as_path(value):
713
+ if isinstance(value, dict):
714
+ value = value.get("path") or (value.get("url") or "").removeprefix("/gradio_api/file=")
715
+ return value or None
716
+
717
+ # ── GPU duration estimation ─────────────────────────────────────────────────
718
+
719
+ _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
720
+ _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
721
+ _PLACEMENT_ALLOWANCE, _PAD = 12, 10
722
+
723
+ def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, seed, *a, **k):
724
+ """Estimate GPU duration for the ``_generate`` call. Steps is fixed at NUM_STEPS (4)."""
725
+ height, width, num_frames = int(height), int(width), int(num_frames)
726
+ steps = NUM_STEPS
727
+ latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
728
+ patches = (height // 32) * (width // 32)
729
+ rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
730
+ denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
731
+ decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
732
+ return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
733
 
734
+ # ── Inference ───────────────────────────────────────────────────────────────
735
 
736
+ @spaces.GPU(duration=get_duration, size=GPU_SIZE)
737
+ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, seed):
738
+ """Denoise + decode on GPU time. The LoRA is already folded at startup; 4 steps with custom sigmas."""
739
+ import torch
740
+
741
+ if PLACEMENT == "pack":
742
+ PIPE.vae.to("cuda")
743
+ PIPE.audio_vae.to("cuda")
744
+ elif PLACEMENT == "lazy":
745
+ PIPE.to("cuda")
746
+
747
+ # Inject the distilled base_schedule into both schedulers, and keep it there: the pipeline calls
748
+ # `set_timesteps` again inside its denoise step, so the schedule has to survive that call.
749
+ pin_distilled_schedule(PIPE.scheduler)
750
+ pin_distilled_schedule(PIPE.audio_scheduler)
751
+ PIPE.scheduler.set_timesteps(device="cuda")
752
+ PIPE.audio_scheduler.set_timesteps(device="cuda")
753
+
754
+ state = PIPE(
755
+ prompt_embeds=prompt_embeds.to("cuda"),
756
+ text_token_tags=text_token_tags,
757
  image=image,
758
+ last_image=last_image,
759
+ height=height,
760
+ width=width,
761
+ num_frames=num_frames,
762
+ # Grid points, terminal 0.0 included -> NUM_STEPS Euler steps. See NUM_SIGMA_POINTS above.
763
+ num_inference_steps=NUM_SIGMA_POINTS,
764
  generator=torch.Generator("cpu").manual_seed(int(seed)),
765
  )
766
+ # Read the step count back off the scheduler rather than trusting the constant: `step_index` counted
767
+ # the Euler updates the denoise loop actually took, and `sigmas` is the grid it took them on.
768
+ steps_taken = int(PIPE.scheduler.step_index or 0)
769
+ sigmas_used = [round(float(s), 4) for s in PIPE.scheduler.sigmas.tolist()]
770
+ return (
771
+ state.get("videos")[0],
772
+ state.get("audio")[0].cpu(),
773
+ state.get("sampling_rate"),
774
+ steps_taken,
775
+ sigmas_used,
776
+ )
777
+
778
+ def _caller_ip_token() -> str | None:
779
+ from gradio.context import LocalContext
780
 
781
+ request = LocalContext.request.get()
782
+ return request.headers.get("x-ip-token") if request is not None else None
783
 
784
  def generate(
785
+ prompt,
786
+ image_path=None,
787
+ last_image_path=None,
788
+ canvas=DEFAULT_CANVAS,
789
+ duration=5,
790
+ seed=42,
791
+ enhance_prompt=False,
792
  progress=gr.Progress(track_tqdm=True),
793
  ):
794
+ """One request: prompt (+ optional first/last frame) -> video with synchronized audio.
795
+
796
+ ``enhance_prompt`` turns on the conditioner's LLM prompt rewriting (the same enhancement
797
+ ``multimodalart/minimax-h3`` offers); off, the raw prompt is encoded verbatim as before. It is last and
798
+ defaults to off, so a positional API client written against the previous signature is unaffected.
799
+ """
800
+ if LOAD_ERROR:
801
+ raise gr.Error(LOAD_ERROR.replace("**", "").replace("`", ""))
802
+ if PIPE is None:
803
+ raise gr.Error("The model is still loading — watch the Space logs and retry shortly.")
804
  if not prompt or not prompt.strip():
805
+ raise gr.Error("MiniMax-H3 always takes a prompt, keyframes or not.")
806
+
807
+ from PIL import Image, ImageOps
808
 
 
 
 
 
809
  from diffusers.utils import encode_video
810
 
811
+ # The UI already snapped + cropped on upload, so this is a no-op there; it still runs for API
812
+ # callers (and for a canvas the user changed afterwards) so the output geometry always follows the
813
+ # I2V image. The *first* frame owns the canvas — a last frame is only cover-cropped to match it.
814
+ first, last = _as_path(image_path), _as_path(last_image_path)
815
+ if first:
816
+ first, canvas = _fit_keyframe(first, canvas)
817
+ if last:
818
+ last = _cover_crop(last, canvas)
819
+ elif last:
820
+ last, canvas = _fit_keyframe(last, canvas)
821
+
822
+ num_frames = snap_frames(duration)
823
+
824
+ progress(0.0, desc="Enhancing the prompt, then encoding ..." if enhance_prompt else "Encoding prompt ...")
825
+ conditioned = time.time()
826
+ condition_prompt = prompt
827
+
828
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(
829
+ condition_prompt,
830
+ first,
831
+ last,
832
+ canvas,
833
+ num_frames,
834
+ ip_token=_caller_ip_token(),
835
+ rewrite_prompt=bool(enhance_prompt),
836
  )
837
+ condition_seconds = time.time() - conditioned
838
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
839
+ # `None` whenever no rewrite happened — either none was asked for, or the conditioner declined one.
840
+ enhanced = (plan or {}).get("refined_prompt") or ""
841
+
842
+ def keyframe(path):
843
+ return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
844
+
845
+ progress(0.1, desc=f"Generating {num_frames / FPS:.1f}s at {width}x{height} in {NUM_STEPS} steps ...")
846
+ started = time.time()
847
+ frames, audio, sampling_rate, steps_taken, sigmas_used = _generate(
848
+ prompt_embeds,
849
+ text_token_tags,
850
+ keyframe(first),
851
+ keyframe(last),
852
+ height,
853
+ width,
854
+ num_frames,
855
+ seed,
856
  )
857
+ generate_seconds = time.time() - started
 
 
858
 
859
+ path = os.path.join(OUTPUT_DIR, f"h3-flashgen-{int(time.time() * 1000)}.mp4")
860
+ encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
861
+
862
+ # Reported, not assumed: `steps_taken` is the number of Euler updates the scheduler actually took.
863
+ schedule = " → ".join(f"{s:g}" for s in sigmas_used)
864
+ report = (
865
+ f"{width}x{height} · {num_frames} frames ({num_frames / FPS:.3f}s) · "
866
+ f"{steps_taken} steps (FlashGen σ {schedule}) · "
867
+ f"conditioner {condition_seconds:.0f}s{' (prompt enhanced)' if enhanced else ''} · "
868
+ f"denoise + decode {generate_seconds:.0f}s · seed {int(seed)}"
869
+ )
870
+ print(f"[gen] {report}", flush=True)
871
+ expected_sigmas = [round(s, 4) for s in shifted_schedule(PIPE.scheduler.shift)]
872
+ if steps_taken != NUM_STEPS or sigmas_used != expected_sigmas:
873
+ print(
874
+ f"[gen] WARNING: expected {NUM_STEPS} steps on {expected_sigmas} "
875
+ f"(base_schedule {BASE_SCHEDULE} at shift {PIPE.scheduler.shift:g}), "
876
+ f"ran {steps_taken} on {sigmas_used}",
877
+ flush=True,
878
  )
879
+ return path, report, enhanced, gr.update(visible=bool(enhanced))
880
 
881
+ # ── Extend existing video (separate path; does not alter model, LoRAs, or 4-step schedule) ──
882
+
883
+ def _extract_last_frame(video_path: str) -> str:
884
+ """Extract the final decodable frame from an uploaded video for H3 image conditioning."""
885
+ import av
886
+ from PIL import Image
887
+
888
+ video_path = _as_path(video_path)
889
+ if not video_path or not os.path.isfile(video_path):
890
+ raise gr.Error("Upload a video to extend first.")
891
+ out_path = os.path.join(OUTPUT_DIR, f"extend-frame-{int(time.time() * 1000)}.png")
892
+ try:
893
+ with av.open(video_path) as container:
894
+ stream = next((st for st in container.streams if st.type == "video"), None)
895
+ if stream is None:
896
+ raise ValueError("No video stream found")
897
+ last_frame = None
898
+ for frame in container.decode(stream):
899
+ last_frame = frame
900
+ if last_frame is None:
901
+ raise ValueError("No decodable frames found")
902
+ Image.fromarray(last_frame.to_ndarray(format="rgb24")).save(out_path)
903
+ except Exception as exc:
904
+ raise gr.Error(f"Could not read the uploaded video: {type(exc).__name__}: {exc}")
905
+ return out_path
906
 
 
 
 
 
 
907
 
908
+ def _concat_video_audio(original_path: str, generated_path: str) -> str:
909
+ """Join original and generated segments, re-encoding for reliable stream compatibility."""
910
+ ffmpeg = shutil.which("ffmpeg")
911
+ if not ffmpeg:
912
+ raise gr.Error("Extend generated the next segment, but ffmpeg is unavailable to join the clips.")
913
+ output_path = os.path.join(OUTPUT_DIR, f"h3-extended-{int(time.time() * 1000)}.mp4")
914
+ # Normalize both inputs to the Space's 24 fps output and concatenate video + audio.
915
+ # If the source has no audio, synthesize silence for that segment so concat has matching streams.
916
+ cmd = [
917
+ ffmpeg, "-y", "-i", original_path, "-i", generated_path,
918
+ "-filter_complex",
919
+ "[0:v]fps=24,format=yuv420p[v0];"
920
+ "[1:v]fps=24,format=yuv420p[v1];"
921
+ "[0:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a0];"
922
+ "[1:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a1];"
923
+ "[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]",
924
+ "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast",
925
+ "-crf", "18", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output_path,
926
+ ]
927
+ proc = subprocess.run(cmd, capture_output=True, text=True)
928
+ if proc.returncode != 0:
929
+ # Common case: the uploaded source has no audio stream. Retry with generated audio plus
930
+ # silent audio synthesized for the original segment, preserving the source duration.
931
+ try:
932
+ import av
933
+ with av.open(original_path) as c:
934
+ vstream = next((st for st in c.streams if st.type == "video"), None)
935
+ if vstream is None:
936
+ raise ValueError("No video stream found")
937
+ source_duration = float(c.duration / av.time_base) if c.duration else 0.0
938
+ if source_duration <= 0:
939
+ raise ValueError("Could not determine source video duration")
940
+ except Exception as exc:
941
+ raise gr.Error(f"Could not join the clips: {proc.stderr[-1200:]} ({exc})")
942
+ cmd = [
943
+ ffmpeg, "-y", "-i", original_path, "-i", generated_path,
944
+ "-f", "lavfi", "-t", str(source_duration), "-i", "anullsrc=r=48000:cl=stereo",
945
+ "-filter_complex",
946
+ "[0:v]fps=24,format=yuv420p[v0];[1:v]fps=24,format=yuv420p[v1];"
947
+ "[2:a]atrim=duration=" + str(source_duration) + ",asetpts=PTS-STARTPTS[a0];"
948
+ "[1:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[a1];"
949
+ "[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]",
950
+ "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast",
951
+ "-crf", "18", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output_path,
952
+ ]
953
+ proc = subprocess.run(cmd, capture_output=True, text=True)
954
+ if proc.returncode != 0:
955
+ raise gr.Error(f"Could not join the clips: {proc.stderr[-1500:]}")
956
+ return output_path
957
 
 
 
958
 
959
+ def extend_video(video_path, prompt, canvas=DEFAULT_CANVAS, duration=5, seed=42, enhance_prompt=False,
960
+ progress=gr.Progress(track_tqdm=True)):
961
+ """Generate a new segment conditioned on the uploaded video's final frame, then append it."""
962
+ video_path = _as_path(video_path)
963
+ if not video_path or not os.path.isfile(video_path):
964
+ raise gr.Error("Upload the video you want to extend.")
965
+ if not prompt or not prompt.strip():
966
+ raise gr.Error("Describe what should happen in the next segment.")
967
+ frame_path = _extract_last_frame(video_path)
968
+ progress(0.02, desc="Using the video's final frame as the continuation start ...")
969
+ generated_path, report, enhanced, panel = generate(
970
+ prompt, image_path=frame_path, last_image_path=None, canvas=canvas,
971
+ duration=duration, seed=seed, enhance_prompt=enhance_prompt, progress=progress,
972
+ )
973
+ progress(0.95, desc="Joining original video and continuation ...")
974
+ joined = _concat_video_audio(video_path, generated_path)
975
+ return joined, f"Extended by {report}. Original segment preserved; continuation starts from its final frame.", enhanced, panel
976
+
977
 
978
+ # ── UI ──────────────────────────────────────────────────────────────────────
 
 
979
 
980
+ CSS = """
981
+ .main.fillable { max-width: 1100px !important; }
982
  """
983
 
984
+ EXAMPLE_PROMPTS = [
985
+ "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.",
986
+ "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.",
987
+ "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.",
988
+ "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.",
989
+ "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.",
990
+ ]
991
 
992
+ with gr.Blocks(title="MiniMax-H3 4-Step FlashGen") as demo:
 
993
  with gr.Row(equal_height=True):
994
+ with gr.Column():
 
995
  prompt = gr.Textbox(
996
+ label="Prompt",
997
+ lines=4,
998
+ value=EXAMPLE_PROMPTS[0],
999
+ placeholder="Describe the scene, shot style, and soundscape ...",
1000
  )
1001
  enhance = gr.Checkbox(
1002
+ label="Enhance prompt",
1003
  value=False,
 
1004
  )
1005
+ with gr.Accordion("Image-to-video (optional)", open=True):
1006
+ with gr.Row():
1007
+ image = gr.Image(label="First frame (optional)", type="filepath", height=200)
1008
+ last_image = gr.Image(label="Last frame (optional)", type="filepath", height=200)
1009
+
1010
+ run = gr.Button("Generate", variant="primary", size="lg")
1011
+
1012
+ with gr.Accordion("Advanced", open=False):
1013
+ canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
1014
+ duration = gr.Slider(
1015
+ label="Duration (s)",
1016
+ minimum=MIN_UI_DURATION,
1017
+ maximum=MAX_UI_DURATION,
1018
+ step=1,
1019
+ value=5,
1020
+ )
1021
+ seed = gr.Number(label="Seed", value=42, precision=0)
1022
 
1023
+ with gr.Column():
1024
+ result = gr.Video(label="Video + soundtrack", autoplay=True)
1025
+ report = gr.Markdown()
1026
+ # An output, so it is revealed only for a request that actually asked for an enhancement.
1027
+ with gr.Accordion("Enhanced prompt", open=False, visible=False) as enhanced_panel:
1028
+ enhanced_box = gr.Textbox(show_label=False, lines=8, interactive=False)
1029
 
1030
+ OUTPUTS = [result, report, enhanced_box, enhanced_panel]
1031
 
1032
+ # An uploaded I2V frame decides the output geometry: snap Canvas to the nearest supported ratio and
1033
+ # cover-crop the frame to it, right away, so what is shown is what will be rendered.
1034
+ image.upload(_fit_keyframe_ui, [image, canvas], [image, canvas])
1035
+ last_image.upload(_fit_last_keyframe_ui, [last_image, image, canvas], [last_image, canvas])
1036
+
1037
+ with gr.Accordion("Extend Video (experimental)", open=False):
1038
+ gr.Markdown(
1039
+ "Upload a video, describe the next scene, and generate a continuation from its final frame. "
1040
+ "The original generation mode, LoRAs, and 4-step FlashGen schedule are unchanged. "
1041
+ "This is final-frame-conditioned continuation, not full temporal video-to-video conditioning."
1042
+ )
1043
+ extend_input = gr.Video(label="Video to extend")
1044
+ extend_prompt = gr.Textbox(label="What happens next?", lines=3,
1045
+ placeholder="Describe the continuation while keeping the same scene and characters ...")
1046
+ with gr.Row():
1047
+ extend_canvas = gr.Dropdown(label="Continuation resolution", choices=list(CANVASES), value=DEFAULT_CANVAS)
1048
+ extend_duration = gr.Slider(label="Additional duration (s)", minimum=MIN_UI_DURATION,
1049
+ maximum=MAX_UI_DURATION, step=1, value=5)
1050
+ with gr.Row():
1051
+ extend_seed = gr.Number(label="Continuation seed", value=42, precision=0)
1052
+ extend_enhance = gr.Checkbox(label="Enhance continuation prompt", value=False)
1053
+ extend_run = gr.Button("Extend video", variant="primary")
1054
+ extend_result = gr.Video(label="Extended video + audio", autoplay=True)
1055
+ extend_report = gr.Markdown()
1056
+ extend_enhanced_box = gr.Textbox(label="Enhanced continuation prompt", lines=6, interactive=False, visible=True)
1057
+ extend_panel_state = gr.State(False)
1058
+
1059
+ extend_run.click(
1060
+ extend_video,
1061
+ inputs=[extend_input, extend_prompt, extend_canvas, extend_duration, extend_seed, extend_enhance],
1062
+ outputs=[extend_result, extend_report, extend_enhanced_box, extend_panel_state],
1063
+ api_name="extend_video",
1064
+ )
1065
+
1066
+ gr.Examples(
1067
+ examples=[[p] for p in EXAMPLE_PROMPTS],
1068
+ inputs=[prompt],
1069
  fn=generate,
1070
+ outputs=OUTPUTS,
1071
+ cache_examples=True,
1072
+ cache_mode="lazy",
1073
  )
1074
 
1075
+ run.click(
1076
+ generate,
1077
+ inputs=[prompt, image, last_image, canvas, duration, seed, enhance],
1078
+ outputs=OUTPUTS,
1079
+ api_name="generate",
1080
+ )
1081
  prompt.submit(
1082
+ generate,
1083
+ inputs=[prompt, image, last_image, canvas, duration, seed, enhance],
1084
+ outputs=OUTPUTS,
1085
  api_name="generate_submit",
1086
  )
1087
 
1088
+ load_models()
1089
 
1090
+ if __name__ == "__main__":
1091
+ demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS, allowed_paths=[OUTPUT_DIR])
h3_split_blocks.py CHANGED
@@ -1,7 +1,15 @@
1
- """The denoising half of MiniMax-H3 used by this Space.
2
 
3
- The Qwen3-VL conditioner is served by the public
4
- multimodalart/qwen3vl-conditioner Space so the generator can fit on ZeroGPU.
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep
@@ -24,26 +32,43 @@ from diffusers.modular_pipelines.modular_pipeline_utils import OutputParam
24
 
25
 
26
  def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
 
27
  return [
28
  OutputParam.template("prompt_embeds"),
29
- OutputParam("text_token_tags", description="The per-row modality tag of every row of prompt_embeds."),
30
- OutputParam("height", type_hint=int),
31
- OutputParam("width", type_hint=int),
32
- *([OutputParam("num_frames", type_hint=int)] if num_frames else []),
 
 
 
 
33
  ]
34
 
35
 
36
  class MiniMaxH3ConditionerBlocks(SequentialPipelineBlocks):
 
 
37
  model_name = "minimax-h3"
38
  block_classes = [MiniMaxH3AutoResizeStep, MiniMaxH3TextEncoderStep]
39
  block_names = ["resize", "text_encoder"]
40
 
 
 
 
 
 
 
 
 
41
  @property
42
  def outputs(self):
43
  return _wire_outputs(num_frames=False)
44
 
45
 
46
  class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
 
 
47
  model_name = "minimax-h3"
48
  block_classes = [
49
  MiniMaxH3AutoResizeStep,
@@ -57,8 +82,9 @@ class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
57
  @property
58
  def description(self):
59
  return (
60
- "The denoising half of MiniMax-H3 with the text encoder removed. "
61
- "Prompt embeddings and token tags arrive from the external conditioner Space."
 
62
  )
63
 
64
  @property
@@ -67,16 +93,37 @@ class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
67
 
68
 
69
  class MiniMaxH3Ref2VAConditionerBlocks(SequentialPipelineBlocks):
 
 
 
 
 
 
 
70
  model_name = "minimax-h3"
71
  block_classes = [MiniMaxH3Ref2VASetupStep, MiniMaxH3Ref2VATextEncoderStep]
72
  block_names = ["setup", "text_encoder"]
73
 
 
 
 
 
 
 
 
 
74
  @property
75
  def outputs(self):
76
  return _wire_outputs()
77
 
78
 
79
  class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
 
 
 
 
 
 
80
  model_name = "minimax-h3"
81
  block_classes = [
82
  MiniMaxH3Ref2VASetupStep,
@@ -87,6 +134,14 @@ class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
87
  ]
88
  block_names = ["setup", "reference_encoder", "denoise", "after_denoise", "decode"]
89
 
 
 
 
 
 
 
 
 
90
  @property
91
  def outputs(self):
92
  return _generation_outputs()
 
1
+ """The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
2
 
3
+ MiniMax-H3 is 195.9 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage, so `MiniMaxH3Blocks` is cut
4
+ at its `text_encoder` step: the 62.14 GiB Qwen3-VL runs in the conditioner Space, everything else in a generator
5
+ Space, and `prompt_embeds` + `text_token_tags` is the whole wire format between them.
6
+
7
+ `resize` / `setup` run on **both** sides: they own no pretrained component, and each half needs the canvas and the
8
+ prepared keyframes or normalized references. Both conditioner halves also return the resolved `height` / `width` /
9
+ `num_frames`, which the generating half pins rather than re-deriving.
10
+
11
+ Two things the blocks leave to the caller: a keyframe reaches them EXIF-transposed and in RGB, and the `t2va` / `fl2va`
12
+ frame count is aligned to `17 * n + 5` before the call, since that arithmetic lives on the denoising side of the cut.
13
  """
14
 
15
  from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep
 
32
 
33
 
34
  def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
35
+ """The wire format of the split. `num_frames` is declared by the `ref2va` half alone, whose setup resolves one."""
36
  return [
37
  OutputParam.template("prompt_embeds"),
38
+ OutputParam("text_token_tags", description="The per-row modality tag of every row of `prompt_embeds`."),
39
+ OutputParam("height", type_hint=int, description="Resolved height of the generated video in pixels."),
40
+ OutputParam("width", type_hint=int, description="Resolved width of the generated video in pixels."),
41
+ *(
42
+ [OutputParam("num_frames", type_hint=int, description="Resolved number of frames, of the form 17 * n + 5.")]
43
+ if num_frames
44
+ else []
45
+ ),
46
  ]
47
 
48
 
49
  class MiniMaxH3ConditionerBlocks(SequentialPipelineBlocks):
50
+ """The conditioner half of a split MiniMax-H3: the keyframes on the canvas plus the Qwen3-VL read at layer 50."""
51
+
52
  model_name = "minimax-h3"
53
  block_classes = [MiniMaxH3AutoResizeStep, MiniMaxH3TextEncoderStep]
54
  block_names = ["resize", "text_encoder"]
55
 
56
+ @property
57
+ def description(self):
58
+ return (
59
+ "The conditioner half of a split MiniMax-H3 deployment: puts the keyframes onto the target canvas and "
60
+ "encodes MiniMax-H3's presentation of the request into the `prompt_embeds` / `text_token_tags` pair the "
61
+ "denoising half consumes. The frame count is the caller's to align."
62
+ )
63
+
64
  @property
65
  def outputs(self):
66
  return _wire_outputs(num_frames=False)
67
 
68
 
69
  class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
70
+ """The denoising half of a split MiniMax-H3: `MiniMaxH3Blocks` with its `text_encoder` step removed."""
71
+
72
  model_name = "minimax-h3"
73
  block_classes = [
74
  MiniMaxH3AutoResizeStep,
 
82
  @property
83
  def description(self):
84
  return (
85
+ "The denoising half of a split MiniMax-H3 deployment: the `t2va` / `fl2va` branch of `MiniMaxH3Blocks` "
86
+ "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
87
+ "62.14 GiB Qwen3-VL conditioner is never loaded here."
88
  )
89
 
90
  @property
 
93
 
94
 
95
  class MiniMaxH3Ref2VAConditionerBlocks(SequentialPipelineBlocks):
96
+ """The conditioner half of a split `ref2va`: the resolved plan plus the Qwen3-VL read at its 50th layer.
97
+
98
+ Component for component this is `MiniMaxH3ConditionerBlocks`, so one conditioner Space serves both partitions.
99
+ What differs is the presentation: `ref2va` prepends a label per reference and a vision block per image and per
100
+ merged video frame pair, so the references themselves have to reach this half.
101
+ """
102
+
103
  model_name = "minimax-h3"
104
  block_classes = [MiniMaxH3Ref2VASetupStep, MiniMaxH3Ref2VATextEncoderStep]
105
  block_names = ["setup", "text_encoder"]
106
 
107
+ @property
108
+ def description(self):
109
+ return (
110
+ "The conditioner half of a split MiniMax-H3 `ref2va` deployment: resolves the request plan (canvas, frame "
111
+ "count, references normalized onto MiniMax-H3's own rates and resolutions) and encodes MiniMax-H3's "
112
+ "presentation of it into the `prompt_embeds` / `text_token_tags` pair the denoising half consumes."
113
+ )
114
+
115
  @property
116
  def outputs(self):
117
  return _wire_outputs()
118
 
119
 
120
  class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
121
+ """The denoising half of a split `ref2va`: the `ref2va` branch with its `text_encoder` step removed.
122
+
123
+ `reference_encoder` stays here, next to the two autoencoders it runs: its output shapes are where every reference
124
+ block's geometry in the packed layout comes from.
125
+ """
126
+
127
  model_name = "minimax-h3"
128
  block_classes = [
129
  MiniMaxH3Ref2VASetupStep,
 
134
  ]
135
  block_names = ["setup", "reference_encoder", "denoise", "after_denoise", "decode"]
136
 
137
+ @property
138
+ def description(self):
139
+ return (
140
+ "The denoising half of a split MiniMax-H3 `ref2va` deployment: the `ref2va` branch of `MiniMaxH3Blocks` "
141
+ "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
142
+ "62.14 GiB Qwen3-VL conditioner is never loaded here. The transformer is the `transformer_ref` partition."
143
+ )
144
+
145
  @property
146
  def outputs(self):
147
  return _generation_outputs()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt CHANGED
@@ -1,4 +1,6 @@
1
- # MiniMax-H3 modular pipeline support is currently provided by this reviewed PR.
 
 
2
  --extra-index-url https://download.pytorch.org/whl/cu130
3
  diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
4
  torch==2.11.0
@@ -6,8 +8,10 @@ torchvision==0.26.0
6
  transformers==5.8.0
7
  accelerate==1.14.0
8
  huggingface-hub==1.24.0
 
9
  av
10
  pillow
11
  numpy
12
  requests
13
  safetensors>=0.8.0
 
 
1
+ # diffusers from the MiniMax-H3 PR (https://github.com/huggingface/diffusers/pull/14371), pinned to a commit
2
+ # on its minimax-h3-refactor branch. This Space subclasses its block classes in h3_split_blocks.py.
3
+ # 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
4
  --extra-index-url https://download.pytorch.org/whl/cu130
5
  diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
6
  torch==2.11.0
 
8
  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
  av
13
  pillow
14
  numpy
15
  requests
16
  safetensors>=0.8.0
17
+ peft