prithivMLmods commited on
Commit
3967288
·
verified ·
1 Parent(s): ad5957b

add app [— main]

Browse files
Files changed (3) hide show
  1. app.py +652 -0
  2. pre-requirements.txt +1 -0
  3. requirements.txt +30 -0
app.py ADDED
@@ -0,0 +1,652 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMax-H3-Turbo-LoRA-Fast — the denoising half of a split MiniMax-H3 deployment.
2
+
3
+ The 62 GiB Qwen3-VL text encoder runs in a separate conditioner Space; this Space loads the transformer and the
4
+ VAEs, folds the selected Turbo/Style LoRA into the bf16 weights, and serves first/last-frame video generation up
5
+ to 20 seconds with optional audio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+ import traceback
17
+ from functools import cache
18
+
19
+ import spaces
20
+ import gradio as gr
21
+
22
+ MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
23
+ CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
24
+ PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
25
+ ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
26
+ GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
27
+
28
+ CANVASES = {
29
+ "960x544 · 16:9 fast": (544, 960),
30
+ "1024x576 · 16:9 fast": (576, 1024),
31
+ "1152x640 · 16:9": (640, 1152),
32
+ "1280x704 · 16:9": (704, 1280),
33
+ "1344x768 · 16:9 full": (768, 1344),
34
+ "544x960 · 9:16 fast": (960, 544),
35
+ "640x1152 · 9:16": (1152, 640),
36
+ "768x1344 · 9:16 full": (1344, 768),
37
+ "544x544 · 1:1 fast": (544, 544),
38
+ "768x768 · 1:1 full": (768, 768),
39
+ "768x576 · 4:3 fast": (576, 768),
40
+ "1024x768 · 4:3 full": (768, 1024),
41
+ "576x768 · 3:4 fast": (768, 576),
42
+ "768x1024 · 3:4 full": (1024, 768),
43
+ "1152x512 · 21:9 fast": (512, 1152),
44
+ "1536x672 · 21:9 full": (672, 1536),
45
+ }
46
+ DEFAULT_CANVAS = "960x544 · 16:9 fast"
47
+
48
+ FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
49
+
50
+ MIN_UI_DURATION = float(os.environ.get("H3_MIN_DURATION", "2"))
51
+ MAX_UI_DURATION = float(os.environ.get("H3_MAX_DURATION", "20"))
52
+ PIPELINE_MAX_DURATION = MAX_UI_DURATION + 1.0
53
+ CHUNK_SECONDS = float(os.environ.get("H3_CHUNK_SECONDS", "14"))
54
+ CONDITIONER_MAX_FRAMES = int(os.environ.get("H3_CONDITIONER_MAX_FRAMES", "345"))
55
+
56
+ # Allowed GPU-duration choices (seconds) exposed in the Advanced panel.
57
+ GPU_DURATION_CHOICES = (60, 90, 120, 150, 180, 250)
58
+ DEFAULT_GPU_DURATION = 90
59
+
60
+ OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "h3-outputs")
61
+
62
+ PIPE = None
63
+ MUTE_PIPE = None
64
+ MANAGER = None
65
+ LOAD_ERROR: str | None = None
66
+ MUTE_ERROR: str | None = None
67
+ LOADED_IN: float | None = None
68
+ LORA_STATUS: str | None = None
69
+
70
+
71
+ def snap_frames(seconds: float) -> int:
72
+ frames = max(1, round(float(seconds) * FPS))
73
+ while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
74
+ frames += 1
75
+ return frames
76
+
77
+
78
+ def patch_duration_window(minimum: float, maximum: float) -> None:
79
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
80
+
81
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(minimum))
82
+ MiniMaxH3ModularPipeline.max_duration = property(lambda self: float(maximum))
83
+
84
+
85
+ def _apply_lora(transformer) -> str | None:
86
+ try:
87
+ import h3_lora
88
+
89
+ return h3_lora.apply_lora(transformer)
90
+ except ImportError:
91
+ return "⚠️ `h3_lora.py` is missing — running without the Turbo LoRA. Raise the steps or add the file (README)"
92
+ except Exception as error:
93
+ traceback.print_exc()
94
+ return f"⚠️ LoRA loading failed ({type(error).__name__}: {error}) — running without"
95
+
96
+
97
+ def _set_lora(transformer, name: str) -> str:
98
+ try:
99
+ import h3_lora
100
+
101
+ return h3_lora.set_active(transformer, name)
102
+ except Exception:
103
+ return "off"
104
+
105
+
106
+ def status() -> str:
107
+ if LOAD_ERROR:
108
+ return LOAD_ERROR
109
+ if PIPE is None:
110
+ return f"Loading `{MODEL_REPO}` (transformer + VAEs, 77.3 GB). See the Space logs."
111
+ audio_note = "video-only pipeline active" if MUTE_PIPE is not None else f"video-only pipeline not available ({MUTE_ERROR})"
112
+ return (
113
+ f"Ready · transformer + VAEs **bfloat16, unquantized** · placement `{PLACEMENT}` · attention `{ATTENTION}` "
114
+ f"· {LORA_STATUS or 'no LoRA'} · {audio_note} · window {MIN_UI_DURATION:g}–{MAX_UI_DURATION:g}s "
115
+ f"· loaded in {LOADED_IN:.0f}s · conditioner `{CONDITIONER_SPACE}`"
116
+ )
117
+
118
+
119
+ def load_models() -> str | None:
120
+ global PIPE, MUTE_PIPE, MANAGER, LOAD_ERROR, MUTE_ERROR, LOADED_IN, LORA_STATUS
121
+
122
+ if PIPE is not None or LOAD_ERROR is not None:
123
+ return LOAD_ERROR
124
+
125
+ started = time.time()
126
+ try:
127
+ import torch
128
+ from diffusers import ComponentsManager
129
+
130
+ from h3_split_blocks import MiniMaxH3GeneratorBlocks
131
+
132
+ patch_duration_window(MIN_UI_DURATION, PIPELINE_MAX_DURATION)
133
+ manager = ComponentsManager()
134
+ blocks = MiniMaxH3GeneratorBlocks()
135
+ print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
136
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
137
+ pipe.load_components(dtype=torch.bfloat16)
138
+
139
+ LORA_STATUS = _apply_lora(pipe.transformer)
140
+ if LORA_STATUS:
141
+ print(f"[gen] {LORA_STATUS}", flush=True)
142
+
143
+ pipe.transformer.set_attention_backend(ATTENTION)
144
+
145
+ if PLACEMENT == "pack":
146
+ pipe.transformer.to("cuda")
147
+ if PLACEMENT == "offload":
148
+ manager.enable_auto_cpu_offload(device="cuda")
149
+ _arm_decode_hooks(pipe)
150
+
151
+ PIPE, MANAGER = pipe, manager
152
+ MUTE_PIPE, MUTE_ERROR = _build_mute_pipe(pipe)
153
+ LOADED_IN = time.time() - started
154
+ print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
155
+ except Exception as error:
156
+ traceback.print_exc()
157
+ LOAD_ERROR = (
158
+ f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
159
+ f"`{type(error).__name__}: {error}`"
160
+ )
161
+ return LOAD_ERROR
162
+
163
+
164
+ def _build_mute_pipe(pipe):
165
+ try:
166
+ from h3_split_blocks import MiniMaxH3MuteGeneratorBlocks
167
+
168
+ blocks = MiniMaxH3MuteGeneratorBlocks()
169
+ mute = blocks.init_pipeline(MODEL_REPO)
170
+ shared = {}
171
+ for spec in blocks.expected_components:
172
+ component = getattr(pipe, spec.name, None)
173
+ if component is not None:
174
+ shared[spec.name] = component
175
+ mute.update_components(**shared)
176
+ print(f"[gen] video-only pipeline over {sorted(shared)}", flush=True)
177
+ return mute, None
178
+ except Exception as error:
179
+ traceback.print_exc()
180
+ return None, f"{type(error).__name__}: {error}"
181
+
182
+
183
+ def _arm_decode_hooks(pipe):
184
+ for name in ("vae", "audio_vae"):
185
+ module = getattr(pipe, name, None)
186
+ if module is None:
187
+ continue
188
+ inner = module.decode
189
+
190
+ def armed(*args, _module=module, _decode=inner, **kwargs):
191
+ hook = getattr(_module, "_hf_hook", None)
192
+ if hook is not None:
193
+ hook.pre_forward(_module)
194
+ return _decode(*args, **kwargs)
195
+
196
+ module.decode = armed
197
+
198
+
199
+ @cache
200
+ def conditioner():
201
+ from gradio_client import Client
202
+
203
+ return Client(CONDITIONER_SPACE)
204
+
205
+
206
+ def conditioner_client(ip_token):
207
+ if not ip_token:
208
+ return conditioner()
209
+ from gradio_client import Client
210
+
211
+ return Client(CONDITIONER_SPACE, headers={"x-ip-token": ip_token})
212
+
213
+
214
+ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False, ip_token=None):
215
+ from gradio_client import handle_file
216
+ from safetensors import safe_open
217
+
218
+ requested = int(num_frames)
219
+ wire_frames = min(requested, CONDITIONER_MAX_FRAMES)
220
+ path, plan = conditioner_client(ip_token).predict(
221
+ prompt=prompt,
222
+ image_path=handle_file(image_path) if image_path else None,
223
+ last_image_path=handle_file(last_image_path) if last_image_path else None,
224
+ canvas=canvas,
225
+ num_frames=wire_frames,
226
+ rewrite_prompt=bool(rewrite_prompt),
227
+ api_name="/encode",
228
+ )
229
+ with safe_open(path, framework="pt") as handle:
230
+ metadata = dict(handle.metadata())
231
+ metadata["num_frames"] = str(requested)
232
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
233
+
234
+
235
+ _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
236
+ _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
237
+ _PLACEMENT_ALLOWANCE, _PAD = 12, 10
238
+
239
+
240
+ def estimate_seconds(height, width, num_frames, steps, num_keyframes=0, with_audio=True) -> int:
241
+ height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
242
+ latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
243
+ patches = (height // 32) * (width // 32)
244
+ rows = latent_frames * patches + int(num_keyframes) * patches
245
+ denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
246
+ decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
247
+ if not with_audio:
248
+ decode *= 0.85
249
+ return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
250
+
251
+
252
+ def get_duration(
253
+ prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed,
254
+ lora="larry", with_audio=True, gpu_duration=DEFAULT_GPU_DURATION, *a, **k
255
+ ):
256
+ """Duration callback for `@spaces.GPU`.
257
+
258
+ Honors an explicit user override (`gpu_duration`) when it is one of the
259
+ allowed choices; otherwise falls back to the empirical estimate.
260
+ """
261
+ if gpu_duration:
262
+ try:
263
+ value = int(gpu_duration)
264
+ except (TypeError, ValueError):
265
+ value = 0
266
+ if value in GPU_DURATION_CHOICES:
267
+ return value
268
+ keyframes = int(image is not None) + int(last_image is not None)
269
+ return estimate_seconds(height, width, num_frames, steps, keyframes, with_audio)
270
+
271
+
272
+ @spaces.GPU(duration=get_duration, size=GPU_SIZE)
273
+ def _generate(
274
+ prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed,
275
+ lora="larry", with_audio=True, gpu_duration=DEFAULT_GPU_DURATION,
276
+ ):
277
+ import torch
278
+
279
+ active_lora = _set_lora(PIPE.transformer, lora)
280
+
281
+ pipe = PIPE if (with_audio or MUTE_PIPE is None) else MUTE_PIPE
282
+ decoded_audio = with_audio or MUTE_PIPE is None
283
+
284
+ if PLACEMENT == "lazy":
285
+ PIPE.to("cuda")
286
+ elif PLACEMENT == "pack":
287
+ PIPE.vae.to("cuda")
288
+ if decoded_audio:
289
+ PIPE.audio_vae.to("cuda")
290
+
291
+ state = pipe(
292
+ prompt_embeds=prompt_embeds.to("cuda"),
293
+ text_token_tags=text_token_tags,
294
+ image=image,
295
+ last_image=last_image,
296
+ height=int(height),
297
+ width=int(width),
298
+ num_frames=int(num_frames),
299
+ num_inference_steps=int(steps) + 1,
300
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
301
+ )
302
+ frames = state.get("videos")[0]
303
+ if not decoded_audio:
304
+ return frames, None, None, active_lora
305
+ return frames, state.get("audio")[0].cpu(), state.get("sampling_rate"), active_lora
306
+
307
+
308
+ def _fit_keyframe(image_path, current_canvas):
309
+ from PIL import Image as _Image
310
+
311
+ img = _Image.open(image_path)
312
+ aspect = img.width / img.height
313
+ fastest = {}
314
+ for label, (h, w) in CANVASES.items():
315
+ r = w / h
316
+ if r not in fastest or w * h < fastest[r][1][0] * fastest[r][1][1]:
317
+ fastest[r] = (label, (h, w))
318
+ ratio = min(fastest, key=lambda r: abs(r - aspect))
319
+ label, (h, w) = fastest[ratio]
320
+
321
+ cur_h, cur_w = CANVASES[current_canvas]
322
+ if abs(cur_w / cur_h - aspect) <= abs(ratio - aspect):
323
+ label = current_canvas
324
+ h, w = cur_h, cur_w
325
+
326
+ target = w / h
327
+ if abs(img.width / img.height - target) > 1e-3:
328
+ if img.width / img.height > target:
329
+ new_w = int(img.height * target)
330
+ left = (img.width - new_w) // 2
331
+ img = img.crop((left, 0, left + new_w, img.height))
332
+ else:
333
+ new_h = int(img.width / target)
334
+ top = (img.height - new_h) // 2
335
+ img = img.crop((0, top, img.width, top + new_h))
336
+ img.save(image_path)
337
+ return image_path, label
338
+
339
+
340
+ LORA_NAMES = ("larry", "lightx", "lightx8", "realism", "joyfox", "H3-Facial-Realism-CloseUp", "off")
341
+
342
+
343
+ def _resolve_lora(lora) -> str:
344
+ if not isinstance(lora, str) or not lora.strip():
345
+ return "larry"
346
+ value = lora.strip().lower()
347
+ lowered = {name.lower(): name for name in LORA_NAMES}
348
+ if value in lowered:
349
+ return lowered[value]
350
+ matches = [name for lower, name in lowered.items() if lower.startswith(value)]
351
+ if len(matches) == 1:
352
+ return matches[0]
353
+ raise gr.Error(f"Unknown LoRA `{lora}`. Allowed: {', '.join(LORA_NAMES)}.")
354
+
355
+
356
+ def _as_path(value):
357
+ if isinstance(value, dict):
358
+ value = value.get("path") or (value.get("url") or "").removeprefix("/gradio_api/file=")
359
+ return value or None
360
+
361
+
362
+ def keyframe(path):
363
+ from PIL import Image, ImageOps
364
+
365
+ return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
366
+
367
+
368
+ def _render_clip(prompt, first, last, canvas, duration, steps, seed, upsample, lora, with_audio, ip_token, work,
369
+ gpu_duration=DEFAULT_GPU_DURATION):
370
+ from diffusers.utils import encode_video
371
+
372
+ num_frames = snap_frames(duration)
373
+
374
+ conditioned = time.time()
375
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(
376
+ prompt, first, last, canvas, num_frames, rewrite_prompt=upsample, ip_token=ip_token
377
+ )
378
+ condition_seconds = time.time() - conditioned
379
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
380
+ refined = plan.get("refined_prompt") or ""
381
+
382
+ started = time.time()
383
+ frames, audio, sampling_rate, active_lora = _generate(
384
+ prompt_embeds, text_token_tags, keyframe(first), keyframe(last),
385
+ height, width, num_frames, steps, seed, lora, with_audio, gpu_duration,
386
+ )
387
+ generate_seconds = time.time() - started
388
+
389
+ path = os.path.join(work, f"clip-{int(time.time() * 1000)}.mp4")
390
+ if audio is not None:
391
+ encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
392
+ else:
393
+ encode_video(frames, fps=FPS, output_path=path)
394
+
395
+ report = (
396
+ f"{width}x{height} · {num_frames} frames ({num_frames / FPS:.3f} s) · {int(steps)} steps · "
397
+ f"conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens"
398
+ f"{', upsampled' if refined else ''}) · denoise+decode {generate_seconds:.0f}s "
399
+ f"({generate_seconds / int(steps):.1f} s/step) · LoRA {active_lora} · "
400
+ f"audio {'yes' if audio is not None else 'no'} · seed {int(seed)} · GPU booking {int(gpu_duration)}s"
401
+ )
402
+ print(f"[gen] {report}", flush=True)
403
+ return path, frames[-1], report, refined
404
+
405
+
406
+ def _concat(paths, output_path):
407
+ listing = os.path.join(os.path.dirname(output_path), "concat.txt")
408
+ with open(listing, "w", encoding="utf-8") as handle:
409
+ for path in paths:
410
+ handle.write(f"file '{os.path.abspath(path)}'\n")
411
+
412
+ base = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", listing]
413
+ proc = subprocess.run(base + ["-c", "copy", output_path], capture_output=True, text=True)
414
+ if proc.returncode != 0:
415
+ proc = subprocess.run(
416
+ base + ["-r", str(FPS), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
417
+ "-movflags", "+faststart", output_path],
418
+ capture_output=True, text=True,
419
+ )
420
+ if proc.returncode != 0:
421
+ raise gr.Error(f"ffmpeg concat failed: {proc.stderr[-1500:]}")
422
+ return output_path
423
+
424
+
425
+ def plan_clips(total: float, chunk: float) -> list[float]:
426
+ count = max(1, math.ceil(total / chunk))
427
+ each = total / count
428
+ if each < MIN_UI_DURATION:
429
+ count = max(1, int(total // MIN_UI_DURATION))
430
+ each = total / count
431
+ return [each] * count
432
+
433
+
434
+ CHAIN_SUFFIX = (
435
+ "\n\nContinue seamlessly from the provided first frame. Preserve the same subject identity, clothing, "
436
+ "environment, lighting and camera style; do not cut to a new shot."
437
+ )
438
+
439
+
440
+ def generate(
441
+ prompt, first_frame=None, last_frame=None, canvas=DEFAULT_CANVAS, duration=5.0, mode="single",
442
+ steps=6, seed=42, upsample=False, lora="larry", with_audio=True, ip_token=None, progress=None,
443
+ gpu_duration=DEFAULT_GPU_DURATION,
444
+ ):
445
+ if LOAD_ERROR:
446
+ raise gr.Error(LOAD_ERROR.replace("**", "").replace("`", ""))
447
+ if PIPE is None:
448
+ raise gr.Error("The denoiser is still loading — check the Space logs and try again in a moment.")
449
+ if not prompt or not str(prompt).strip():
450
+ raise gr.Error("MiniMax-H3 always needs a prompt, with or without keyframes.")
451
+
452
+ prompt = str(prompt).strip()
453
+ lora = _resolve_lora(lora)
454
+ canvas = canvas if canvas in CANVASES else DEFAULT_CANVAS
455
+ duration = max(MIN_UI_DURATION, min(float(duration), MAX_UI_DURATION))
456
+ with_audio = bool(with_audio)
457
+
458
+ # Normalize GPU-duration override to one of the allowed choices.
459
+ try:
460
+ gpu_duration = int(gpu_duration)
461
+ except (TypeError, ValueError):
462
+ gpu_duration = DEFAULT_GPU_DURATION
463
+ if gpu_duration not in GPU_DURATION_CHOICES:
464
+ gpu_duration = min(GPU_DURATION_CHOICES, key=lambda v: abs(v - gpu_duration))
465
+
466
+ first, last = _as_path(first_frame), _as_path(last_frame)
467
+ if first:
468
+ first, canvas = _fit_keyframe(first, canvas)
469
+ if last:
470
+ last, canvas = _fit_keyframe(last, canvas)
471
+
472
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
473
+ work = tempfile.mkdtemp(prefix="h3-", dir=OUTPUT_DIR)
474
+ durations = [duration] if mode == "single" else plan_clips(duration, CHUNK_SECONDS)
475
+
476
+ try:
477
+ segments, reports, refined_all = [], [], []
478
+ carry = first
479
+ for index, clip_duration in enumerate(durations):
480
+ is_last = index == len(durations) - 1
481
+ if progress is not None:
482
+ progress(index / len(durations), desc=f"Clip {index + 1}/{len(durations)} ({clip_duration:.1f}s)")
483
+ clip_prompt = prompt if index == 0 else prompt + CHAIN_SUFFIX
484
+ path, tail, report, refined = _render_clip(
485
+ clip_prompt, carry, last if is_last else None, canvas, clip_duration,
486
+ steps, int(seed) + index, upsample and index == 0, lora, with_audio, ip_token, work, gpu_duration,
487
+ )
488
+ segments.append(path)
489
+ reports.append(f"Clip {index + 1}: {report}")
490
+ if refined:
491
+ refined_all.append(refined)
492
+ if not is_last:
493
+ carry = os.path.join(work, f"handoff-{index}.png")
494
+ tail.save(carry)
495
+
496
+ final = os.path.join(OUTPUT_DIR, f"h3-{int(time.time() * 1000)}.mp4")
497
+ if len(segments) == 1:
498
+ shutil.move(segments[0], final)
499
+ else:
500
+ _concat(segments, final)
501
+
502
+ summary = (
503
+ f"**{mode}** · {len(durations)} clip(s) · target {duration:.2f}s · "
504
+ f"actual {sum(snap_frames(d) for d in durations) / FPS:.2f}s · GPU booking {gpu_duration}s/clip\n\n"
505
+ + "\n\n".join(reports)
506
+ )
507
+ if mode == "single" and duration > 15:
508
+ summary += "\n\n> Beyond 15 s is outside the H3 training distribution. Try chain mode if the video drifts."
509
+ return final, summary, "\n\n---\n\n".join(refined_all)
510
+ except gr.Error:
511
+ raise
512
+ except Exception as error:
513
+ message = str(error).lower()
514
+ if any(hint in message for hint in ("gpu limit", "quota", "could not allocate", "too many", "concurrent")):
515
+ raise gr.Error(
516
+ "The shared ZeroGPU pool is currently at its limit — this is not a problem with your inputs. "
517
+ "Wait a minute and try again."
518
+ ) from error
519
+ traceback.print_exc()
520
+ raise gr.Error(f"Generation failed: {type(error).__name__}: {error}") from error
521
+ finally:
522
+ shutil.rmtree(work, ignore_errors=True)
523
+
524
+
525
+ def _caller_ip_token(request: gr.Request | None) -> str | None:
526
+ if request is None:
527
+ return None
528
+ headers = getattr(request, "headers", None)
529
+ return headers.get("x-ip-token") if headers is not None else None
530
+
531
+
532
+ def run(prompt, first_frame, last_frame, canvas, duration, mode, steps, seed, upsample, lora, with_audio,
533
+ gpu_duration, request: gr.Request = None, progress=gr.Progress()):
534
+ return generate(
535
+ prompt, first_frame, last_frame, canvas, duration, mode, steps, seed, upsample, lora, with_audio,
536
+ ip_token=_caller_ip_token(request), progress=progress, gpu_duration=gpu_duration,
537
+ )
538
+
539
+
540
+ def cost_hint(canvas, duration, mode, steps, first_frame, last_frame, with_audio, gpu_duration):
541
+ try:
542
+ height, width = CANVASES.get(canvas, CANVASES[DEFAULT_CANVAS])
543
+ keyframes = int(bool(first_frame)) + int(bool(last_frame))
544
+ durations = [float(duration)] if mode == "single" else plan_clips(float(duration), CHUNK_SECONDS)
545
+ estimated = sum(
546
+ estimate_seconds(height, width, snap_frames(d), int(steps), keyframes, bool(with_audio))
547
+ for d in durations
548
+ )
549
+ try:
550
+ gd = int(gpu_duration)
551
+ except (TypeError, ValueError):
552
+ gd = DEFAULT_GPU_DURATION
553
+ booking = gd * len(durations) if gd in GPU_DURATION_CHOICES else estimated
554
+ frames = sum(snap_frames(d) for d in durations)
555
+ parts = [
556
+ f"{width}x{height}",
557
+ f"{frames} frames ({frames / FPS:.2f}s)",
558
+ f"{len(durations)} pass(es)",
559
+ f"~{booking}s GPU booking" + (f" ({gd}s/clip override)" if gd in GPU_DURATION_CHOICES else " (auto-estimate)"),
560
+ ]
561
+ if not with_audio:
562
+ parts.append("audio VAE stays on the host")
563
+ if mode == "single" and float(duration) > 15:
564
+ parts.append("⚠️ >15s extrapolates")
565
+ return " · ".join(parts)
566
+ except Exception as error:
567
+ return f"(estimate unavailable: {error})"
568
+
569
+
570
+ INTRO = f"""# MiniMax-H3-Turbo-LoRA-Fast
571
+
572
+ Denoising half of a split deployment: the 62 GiB Qwen3-VL text encoder runs in
573
+ [`{CONDITIONER_SPACE}`](https://huggingface.co/spaces/{CONDITIONER_SPACE}); this Space holds only the transformer
574
+ and the VAEs.
575
+
576
+ * **First/Last Frame** — both optional and usable individually. A last frame alone means "generate *towards* this image".
577
+ * **Length** — `single` is one pass with a raised ceiling (H3 is trained up to 15 s; beyond that is experimental).
578
+ `chain` cuts the video into clips of at most {CHUNK_SECONDS:g}s and hands the last frame of each clip to the next.
579
+ * **Audio** — when disabled, the audio VAE is never declared, never moved to the GPU and never decoded. The audio
580
+ rows themselves are always denoised by H3; they are part of the same packed sequence.
581
+ * **GPU Duration** — override the ZeroGPU booking per clip in the Advanced panel
582
+ (choices: {', '.join(str(v) for v in GPU_DURATION_CHOICES)} s; default {DEFAULT_GPU_DURATION} s).
583
+ """
584
+
585
+
586
+ with gr.Blocks(title="MiniMax-H3-Turbo-LoRA-Fast") as demo:
587
+ gr.Markdown(INTRO)
588
+ banner = gr.Markdown(status())
589
+
590
+ with gr.Row():
591
+ with gr.Column(scale=3):
592
+ prompt = gr.Textbox(
593
+ label="Prompt",
594
+ lines=4,
595
+ value="An extreme close-up shot focuses on a young Asian woman with auburn-tinted hair and silver ear piercings, gazing intensely toward the camera. She subtly blinks and shifts her deep brown eyes with a contemplative expression as soft, warm ambient light highlights fine skin texture, natural blemishes, and delicate facial micro-movements.",
596
+ )
597
+ with gr.Row():
598
+ first_frame = gr.Image(label="First Frame (optional)", type="filepath", height=200)
599
+ last_frame = gr.Image(label="Last Frame (optional)", type="filepath", height=200)
600
+ with gr.Row():
601
+ canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
602
+ mode = gr.Radio(
603
+ label="Duration mode",
604
+ choices=[("single (1 pass)", "single"), (f"chain (clips ≤ {CHUNK_SECONDS:g}s)", "chain")],
605
+ value="single",
606
+ )
607
+ duration = gr.Slider(
608
+ MIN_UI_DURATION, MAX_UI_DURATION, value=5, step=0.5,
609
+ label="Length (s)", info="Rounded up to the next 17n+5 at 24 fps.",
610
+ )
611
+ with gr.Row():
612
+ with_audio = gr.Checkbox(label="Generate audio", value=True)
613
+ upsample = gr.Checkbox(label="Prompt upsampling (+15–50s)", value=False)
614
+ with gr.Accordion("Advanced", open=False):
615
+ steps = gr.Slider(2, 12, value=6, step=1, label="Steps")
616
+ lora = gr.Dropdown(label="Turbo/Style-LoRA", choices=list(LORA_NAMES), value="larry")
617
+ seed = gr.Number(label="Seed (clip i = seed + i)", value=42, precision=0)
618
+ gpu_duration = gr.Slider(
619
+ minimum=GPU_DURATION_CHOICES[0],
620
+ maximum=GPU_DURATION_CHOICES[-1],
621
+ value=DEFAULT_GPU_DURATION,
622
+ step=10,
623
+ label="GPU Duration (s per clip)",
624
+ info=f"Allowed: {', '.join(str(v) for v in GPU_DURATION_CHOICES)} s · "
625
+ f"default {DEFAULT_GPU_DURATION} s · overrides ZeroGPU booking per clip.",
626
+ )
627
+ cost = gr.Markdown()
628
+ run_button = gr.Button("Generate video", variant="primary")
629
+
630
+ with gr.Column(scale=2):
631
+ output_video = gr.Video(label="Result", format="mp4", autoplay=True)
632
+ report = gr.Markdown(label="Report")
633
+ refined = gr.Textbox(label="Refined Prompt", lines=4)
634
+
635
+ cost_inputs = [canvas, duration, mode, steps, first_frame, last_frame, with_audio, gpu_duration]
636
+ for control in cost_inputs:
637
+ control.change(cost_hint, cost_inputs, cost)
638
+ demo.load(cost_hint, cost_inputs, cost)
639
+ demo.load(status, None, banner)
640
+
641
+ run_button.click(
642
+ run,
643
+ [prompt, first_frame, last_frame, canvas, duration, mode, steps, seed, upsample, lora, with_audio, gpu_duration],
644
+ [output_video, report, refined],
645
+ api_name="generate",
646
+ )
647
+
648
+
649
+ load_models()
650
+
651
+ if __name__ == "__main__":
652
+ demo.queue(default_concurrency_limit=1, max_size=8).launch(show_error=True, allowed_paths=[OUTPUT_DIR])
pre-requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ pip>=26.1.2
requirements.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `diffusers` is installed from the canonical MiniMax-H3 pull request,
2
+ # https://github.com/huggingface/diffusers/pull/14371 ("Minimax h3 follow up (review & refactor)"), pinned to a
3
+ # **commit** rather than to its `minimax-h3-refactor` branch: the PR is a WIP and its head moves, and this Space's
4
+ # blocks subclass its block classes. Re-pin — and re-check `h3_split_blocks.py` against the block names of the new
5
+ # head — whenever the PR updates.
6
+ #
7
+ # 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
8
+ --extra-index-url https://download.pytorch.org/whl/cu130
9
+ diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
10
+ torch==2.11.0
11
+ torchvision==0.26.0
12
+ # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
13
+ transformers==5.8.0
14
+ accelerate==1.14.0
15
+ # diffusers pins <2.
16
+ huggingface-hub==1.24.0
17
+ # Track Gradio's main branch (https://github.com/gradio-app/gradio) via the prebuilt preview wheel — installing
18
+ # from git does not build the frontend assets, so the app never serves. a9ce60ae03123ed6374b739d862e62dcae2f451e
19
+ # = refs/heads/main at the time of this deploy; re-pin to the new head (and bump `sdk_version` in README.md if the
20
+ # version changed) to move past it.
21
+ gradio
22
+ spaces==0.51.1
23
+ # No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
24
+ # transformers 5.8.0 at import.
25
+ # PyAV muxes the generated soundtrack onto the frames (`encode_video`).
26
+ av
27
+ pillow
28
+ numpy
29
+ requests
30
+ safetensors>=0.8.0