Lonuhbow commited on
Commit
bd676bd
·
verified ·
1 Parent(s): 6597d77

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +778 -159
app.py CHANGED
@@ -2,30 +2,39 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import os
 
6
  import tempfile
7
  import time
8
  import traceback
9
  from functools import cache
10
 
11
- # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
12
- # startup rather than on GPU time.
13
  import spaces
14
  from fastapi.responses import HTMLResponse
15
  from gradio import Request, Server
16
  from gradio.data_classes import FileData
17
 
 
18
  MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
19
- CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
20
- # `pack` places the transformer at startup, `lazy` moves everything on the first GPU call, `offload` hands placement to
21
- # `ComponentsManager.enable_auto_cpu_offload`.
 
 
 
 
22
  PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
23
- # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
 
24
  ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
 
25
  GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
26
 
27
- # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
28
- # is rejected there and surfaces as a failure here.
29
  CANVASES = {
30
  # 16:9
31
  "960x544 · 16:9 fast": (544, 960),
@@ -33,81 +42,127 @@ CANVASES = {
33
  "1152x640 · 16:9": (640, 1152),
34
  "1280x704 · 16:9": (704, 1280),
35
  "1344x768 · 16:9 full": (768, 1344),
 
36
  # 9:16
37
  "544x960 · 9:16 fast": (960, 544),
38
  "640x1152 · 9:16": (1152, 640),
39
  "768x1344 · 9:16 full": (1344, 768),
 
40
  # 1:1
41
  "544x544 · 1:1 fast": (544, 544),
42
  "768x768 · 1:1 full": (768, 768),
 
43
  # 4:3 / 3:4
44
  "768x576 · 4:3 fast": (576, 768),
45
  "1024x768 · 4:3 full": (768, 1024),
46
  "576x768 · 3:4 fast": (768, 576),
47
  "768x1024 · 3:4 full": (1024, 768),
 
48
  # 21:9
49
  "1152x512 · 21:9 fast": (512, 1152),
50
  "1536x672 · 21:9 full": (672, 1536),
51
  }
 
52
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
53
- FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
54
- # It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
55
- # 15.083 s, and is refused.
56
- MIN_UI_DURATION, MAX_UI_DURATION = 2, 14
 
 
 
57
 
58
 
59
  def snap_frames(seconds: float) -> int:
60
- """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
61
  frames = max(1, round(float(seconds) * FPS))
 
62
  while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
63
  frames += 1
 
64
  return frames
65
 
66
 
67
  def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
68
- """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
69
- from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
 
 
 
 
 
 
70
 
71
- MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
72
 
 
 
 
73
 
 
74
  OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "h3-outputs")
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  PIPE = None
77
  MANAGER = None
 
78
  LOAD_ERROR: str | None = None
79
  LOADED_IN: float | None = None
80
  LORA_STATUS: str | None = None
81
 
82
 
 
 
 
 
83
  def status() -> str:
84
  if LOAD_ERROR:
85
  return LOAD_ERROR
 
86
  if PIPE is None:
87
- return f"Loading `{MODEL_REPO}` (transformer + VAEs, 77.3 GB). Watch the Space logs."
 
 
 
 
88
  import h3_aoti
89
 
90
  return (
91
- f"Ready · transformer + VAEs **bfloat16, unquantized** · placement `{PLACEMENT}` · attention `{ATTENTION}` · "
92
- f"{h3_aoti.status()} · {LORA_STATUS or 'no LoRA'} · loaded in {LOADED_IN:.0f}s · "
 
 
 
 
93
  f"conditioner `{CONDITIONER_SPACE}`"
94
  )
95
 
96
 
 
 
 
 
97
  def load_models() -> str | None:
98
- """Load the denoising half at startup.
99
 
100
- `MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`, the two schedulers and `video_processor`,
101
- so `load_components` fetches exactly those subfolders — `text_encoder/` and `transformer_ref/` are never touched.
102
- Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a bfloat16 audio VAE decodes
103
- the soundtrack roughly 20 dB too quiet.
104
- """
105
  global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
106
 
107
  if PIPE is not None or LOAD_ERROR is not None:
108
  return LOAD_ERROR
109
 
110
  started = time.time()
 
111
  try:
112
  import torch
113
  from diffusers import ComponentsManager
@@ -115,142 +170,275 @@ def load_models() -> str | None:
115
  from h3_split_blocks import MiniMaxH3GeneratorBlocks
116
 
117
  lower_duration_floor()
 
118
  manager = ComponentsManager()
119
  blocks = MiniMaxH3GeneratorBlocks()
120
- print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
121
- pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
 
 
 
 
 
 
 
 
 
 
 
 
122
  pipe.load_components(dtype=torch.bfloat16)
123
 
124
- # Fold the 4-step Turbo LoRA into the bf16 weights before AoTI packages the blocks, so the compiled forward
125
- # reads weights that already carry the update. `H3_LORA=off` disables.
126
  import h3_lora
127
 
128
  LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
 
129
  if LORA_STATUS:
130
- print(f"[gen] {LORA_STATUS}", flush=True)
 
 
 
131
 
132
  pipe.transformer.set_attention_backend(ATTENTION)
133
 
134
- # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
135
- # worker. Off unless `H3_AOTI=1`.
136
  import h3_aoti
137
 
138
  h3_aoti.maybe_load(pipe.transformer)
139
 
140
  if PLACEMENT == "pack":
141
- # Scoped to the transformer. `spaces` packs every startup-resident CUDA tensor into a second on-disk copy,
142
- # and packing all 77.3 GB busts the 150 GB storage quota; the 61.7 GB transformer alone fits. The ~10 GB of
143
- # fp32 VAEs move on the first GPU call instead.
144
  pipe.transformer.to("cuda")
145
 
146
  if PLACEMENT == "offload":
147
  manager.enable_auto_cpu_offload(device="cuda")
148
  _arm_decode_hooks(pipe)
149
 
150
- PIPE, MANAGER = pipe, manager
 
151
  LOADED_IN = time.time() - started
152
- print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
 
 
 
 
 
153
  except Exception as error:
154
  traceback.print_exc()
155
- LOAD_ERROR = f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`"
 
 
 
 
 
 
156
  return LOAD_ERROR
157
 
158
 
 
 
 
 
159
  def _arm_decode_hooks(pipe):
160
- """Make the offload hooks fire for the two VAEs.
161
 
162
- `enable_auto_cpu_offload` wraps `forward`, and the decode blocks call `vae.decode(...)` directly, so the hook
163
- never runs and the VAE is still on the host when the latents arrive on the card.
164
- """
165
  for name in ("vae", "audio_vae"):
166
  module = getattr(pipe, name)
167
  inner = module.decode
168
 
169
- def armed(*args, _module=module, _decode=inner, **kwargs):
 
 
 
 
 
170
  hook = getattr(_module, "_hf_hook", None)
 
171
  if hook is not None:
172
  hook.pre_forward(_module)
 
173
  return _decode(*args, **kwargs)
174
 
175
  module.decode = armed
176
 
177
 
 
 
 
 
178
  @cache
179
  def conditioner():
180
- """The other half, over the gradio API. Used only when the caller's token could not be extracted; the booking is
181
- then billed to this Space's pod IP and its small shared quota."""
182
  from gradio_client import Client
183
 
184
  return Client(CONDITIONER_SPACE)
185
 
186
 
187
  def conditioner_client(ip_token):
188
- """A conditioner client billed to the caller. `LocalContext`-based token forwarding is not reliable in Server
189
- mode, so the `x-ip-token` header is extracted from the incoming request and passed explicitly (per the gradio
190
- ZeroGPU docs); a per-request Client is cheap next to a 45s encode."""
191
  if not ip_token:
192
  return conditioner()
 
193
  from gradio_client import Client
194
 
195
- return Client(CONDITIONER_SPACE, headers={"x-ip-token": ip_token})
 
 
 
 
196
 
 
 
 
 
 
 
 
 
 
 
197
 
198
- def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False, ip_token=None):
199
- """`/encode` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with the
200
- resolved `height` / `width` / `num_frames` in its metadata, plus the plan. `canvas` is the label."""
201
  from gradio_client import handle_file
202
  from safetensors import safe_open
203
 
204
  path, plan = conditioner_client(ip_token).predict(
205
  prompt=prompt,
206
- image_path=handle_file(image_path) if image_path else None,
207
- last_image_path=handle_file(last_image_path) if last_image_path else None,
 
 
 
 
 
 
 
 
208
  canvas=canvas,
209
  num_frames=num_frames,
210
  rewrite_prompt=bool(rewrite_prompt),
211
  api_name="/encode",
212
  )
 
213
  with safe_open(path, framework="pt") as handle:
214
  metadata = handle.metadata()
215
- return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- # Seconds of GPU one request needs, from the packed video rows it is about to denoise: linear in the rows for the
219
- # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
220
- _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
221
- # The two resident decoders and the mux, which scale with the output rather than with the step count.
222
- _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
223
- # `pack` mode: only the ~10 GB of VAEs move on a cold worker.
224
- _PLACEMENT_ALLOWANCE, _PAD = 12, 10
225
 
 
 
 
 
 
 
 
 
226
 
227
- def get_duration(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, lora="larry", *a, **k):
228
- height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
229
- latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
230
- patches = (height // 32) * (width // 32)
231
- rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
232
- denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
233
- decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
234
- return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
235
 
 
 
 
 
 
 
236
 
237
- @spaces.GPU(duration=get_duration, size=GPU_SIZE)
238
- def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed, lora="larry"):
239
- """The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
 
 
 
240
 
241
- Only the three generated outputs come back — a `@spaces.GPU` return crosses a process boundary by pickling, and
242
- the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
243
- """
244
- import torch
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  import h3_lora
247
 
248
- # Fold the requested LoRA in place (a no-op when the state already matches). AoTI blocks read the same
249
- # live storage, so the compiled forward carries the switch too.
250
- active_lora = h3_lora.set_active(PIPE.transformer, lora)
 
251
 
252
  if PLACEMENT == "lazy":
253
  PIPE.to("cuda")
 
254
  elif PLACEMENT == "pack":
255
  PIPE.vae.to("cuda")
256
  PIPE.audio_vae.to("cuda")
@@ -264,93 +452,278 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
264
  width=width,
265
  num_frames=num_frames,
266
  num_inference_steps=int(steps),
267
- generator=torch.Generator("cpu").manual_seed(int(seed)),
 
 
268
  )
269
- return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate"), active_lora
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- def _fit_keyframe(image_path, current_canvas):
273
- """Cover-crop an uploaded keyframe to the closest supported aspect ratio and pick that ratio's smallest
274
- (fastest) canvas, unless the caller already picked a matching ratio. Returns `(image_path, canvas_label)`."""
275
  from PIL import Image as _Image
276
 
277
  img = _Image.open(image_path)
 
278
  aspect = img.width / img.height
 
279
  fastest = {}
 
280
  for label, (h, w) in CANVASES.items():
281
  r = w / h
282
- if r not in fastest or w * h < fastest[r][1][0] * fastest[r][1][1]:
283
- fastest[r] = (label, (h, w))
284
- ratio = min(fastest, key=lambda r: abs(r - aspect))
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  label, (h, w) = fastest[ratio]
286
 
287
  cur_h, cur_w = CANVASES[current_canvas]
288
- if abs(cur_w / cur_h - aspect) <= abs(ratio - aspect):
 
 
 
289
  label = current_canvas
290
  h, w = cur_h, cur_w
291
 
292
  target = w / h
293
- if abs(img.width / img.height - target) > 1e-3:
 
 
 
 
294
  if img.width / img.height > target:
295
- new_w = int(img.height * target)
296
- left = (img.width - new_w) // 2
297
- img = img.crop((left, 0, left + new_w, img.height))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  else:
299
- new_h = int(img.width / target)
300
- top = (img.height - new_h) // 2
301
- img = img.crop((0, top, img.width, top + new_h))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  img.save(image_path)
 
303
  return image_path, label
304
 
305
 
306
- def _resolve_lora(lora, use_lora) -> str:
307
- """`lora` (`larry` / `lightx` / `off`) wins; the legacy `use_lora` bool maps onto `larry` / `off`."""
308
- if isinstance(lora, str) and lora in ("larry", "lightx", "off"):
 
 
 
 
 
 
 
 
 
 
 
 
309
  return lora
310
- return "larry" if use_lora else "off"
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
- def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=6, seed=42, upsample=False, use_lora=True, lora="", ip_token=None):
314
- """One request. `upsample`/`use_lora` keep their defaults so a positional API client that predates them is unaffected."""
315
  if LOAD_ERROR:
316
  raise Exception(LOAD_ERROR)
 
317
  if PIPE is None:
318
- raise Exception("The denoiser is still loading.")
 
 
 
319
  if not prompt or not prompt.strip():
320
- raise Exception("MiniMax-H3 always takes a prompt, keyframes or not.")
 
 
 
321
 
322
  from PIL import Image, ImageOps
323
-
324
  from diffusers.utils import encode_video
325
 
326
- lora = _resolve_lora(lora, use_lora)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
- # Server mode: keyframes arrive as FileData dicts, and the cover-crop / canvas-fit that used to be an upload
329
- # event in the Blocks UI runs here instead, so API callers get the same treatment.
330
- first = image_path["path"] if isinstance(image_path, dict) else image_path
331
- last = last_image_path["path"] if isinstance(last_image_path, dict) else last_image_path
332
  if first:
333
- first, canvas = _fit_keyframe(first, canvas)
 
 
 
 
334
  if last:
335
- last, canvas = _fit_keyframe(last, canvas)
 
 
 
336
 
337
- num_frames = snap_frames(duration)
 
 
 
 
 
 
 
 
 
 
338
 
339
  conditioned = time.time()
340
- prompt_embeds, text_token_tags, metadata, plan = encode_remote(
341
- prompt, first, last, canvas, num_frames, rewrite_prompt=upsample, ip_token=ip_token
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  )
343
- condition_seconds = time.time() - conditioned
344
- height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
345
- refined = plan.get("refined_prompt") or ""
 
346
 
347
  def keyframe(path):
348
- # The conditioning latents encoded here have to be of the image the conditioner looked at, which it prepares
349
- # exactly this way.
350
- return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
 
 
 
 
 
 
 
 
351
 
352
  started = time.time()
353
- frames, audio, sampling_rate, active_lora = _generate(
 
 
 
 
 
 
354
  prompt_embeds,
355
  text_token_tags,
356
  keyframe(first),
@@ -362,84 +735,330 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
362
  seed,
363
  lora,
364
  )
365
- generate_seconds = time.time() - started
366
 
367
- os.makedirs(OUTPUT_DIR, exist_ok=True)
368
- path = os.path.join(OUTPUT_DIR, f"h3-{int(time.time() * 1000)}.mp4")
369
- encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  report = (
372
- f"{width}x{height} · {num_frames} frames ({num_frames / FPS:.3f} s) · {int(steps)} steps · "
373
- f"conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens"
 
 
 
 
 
374
  f"{', upsampled' if refined else ''}) · "
375
- f"denoise + decode {generate_seconds:.0f}s ({generate_seconds / int(steps):.1f} s/step) · "
376
- f"turbo LoRA {active_lora} · seed {int(seed)}"
 
 
 
377
  )
378
- print(f"[gen] {report}", flush=True)
379
- return FileData(path=path), report, refined
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
 
383
  # ======================================================================
384
- # Server mode: Gradio's API engine (queue, SSE, concurrency, ZeroGPU,
385
- # gradio_client) under a fully custom studio frontend (index.html).
386
  # ======================================================================
387
- app = Server(title="MiniMax-H3 Studio")
388
 
 
 
 
 
 
 
 
 
389
 
390
  @app.api(name="generate")
391
- def _generate_api(prompt: str, image_path: FileData | None = None, last_image_path: FileData | None = None,
392
- canvas: str = DEFAULT_CANVAS, duration: float = 5, steps: int = 6, seed: float = 42,
393
- upsample: bool = False, use_lora: bool = True, lora: str = "", request: Request = None) -> tuple[FileData, str, str]:
394
- """Generate a video with a synchronized soundtrack. Returns (video, report, refined prompt).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
- `lora` selects the turbo LoRA: `larry` (default), `lightx`, or `off`. The legacy `use_lora` bool still works
397
- when `lora` is empty.
398
- """
399
- # `request` is injected by the event system, not an API input; its x-ip-token bills the conditioner to the caller.
400
- ip_token = request.headers.get("x-ip-token") if request is not None else None
401
- return generate(prompt, image_path, last_image_path, canvas, duration, steps, seed, upsample, use_lora, lora, ip_token=ip_token)
 
 
 
 
 
 
 
402
 
403
 
 
 
 
 
404
  @app.get("/status")
405
  def studio_status():
406
- """Polled by the frontend: is the denoiser ready, and the human-readable status line."""
407
- return {"ready": PIPE is not None and LOAD_ERROR is None, "status": status()}
 
 
 
 
 
 
 
 
408
 
 
 
 
409
 
410
- # NB: not `/config` — Gradio's own client-discovery route lives there and shadowing it breaks `@gradio/client`.
411
  @app.get("/studio-config")
412
  def studio_config():
413
- """The canvas table and slider ranges, so the frontend never hardcodes a label the backend would reject."""
 
414
  import h3_lora
415
 
416
- state = getattr(PIPE.transformer, "_lora_state", None) if PIPE is not None else None
417
- sets = state["sets"] if state else {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  return {
419
  "canvases": list(CANVASES),
420
  "default_canvas": DEFAULT_CANVAS,
421
  "min_duration": MIN_UI_DURATION,
422
  "max_duration": MAX_UI_DURATION,
423
- # The LoRA dropdown: value -> {label, suggested steps}.
424
  "loras": {
425
  **{
426
- name: {"label": spec["label"], "steps": {"larry": 6, "lightx": 4}.get(name, 6)}
 
 
 
 
 
 
 
 
 
427
  for name, spec in sets.items()
428
  },
429
- "off": {"label": "off (base model)", "steps": 28},
 
 
 
 
430
  },
431
- "default_lora": state["active"] if state else "off",
 
 
 
 
 
432
  }
433
 
434
 
435
- @app.get("/", response_class=HTMLResponse)
 
 
 
 
 
 
 
436
  def homepage():
437
- with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html"), encoding="utf-8") as f:
 
 
 
 
 
 
 
 
 
438
  return f.read()
439
 
440
 
 
 
 
 
441
  load_models()
442
 
 
 
 
 
 
443
  if __name__ == "__main__":
444
- # allowed_paths: the /gradio_api/file= route only serves whitelisted directories.
445
- app.launch(show_error=True, allowed_paths=[OUTPUT_DIR])
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ import json
6
  import os
7
+ import shutil
8
  import tempfile
9
  import time
10
  import traceback
11
  from functools import cache
12
 
13
+ # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda`
14
+ # so the 72 GiB load can happen at startup rather than on GPU time.
15
  import spaces
16
  from fastapi.responses import HTMLResponse
17
  from gradio import Request, Server
18
  from gradio.data_classes import FileData
19
 
20
+
21
  MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
22
+ CONDITIONER_SPACE = os.environ.get(
23
+ "H3_CONDITIONER",
24
+ "multimodalart/qwen3vl-conditioner",
25
+ )
26
+
27
+ # `pack` places the transformer at startup, `lazy` moves everything on the first
28
+ # GPU call, `offload` hands placement to ComponentsManager.enable_auto_cpu_offload.
29
  PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
30
+
31
+ # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool.
32
  ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
33
+
34
  GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
35
 
36
+
37
+ # Must stay identical to the conditioner's table.
38
  CANVASES = {
39
  # 16:9
40
  "960x544 · 16:9 fast": (544, 960),
 
42
  "1152x640 · 16:9": (640, 1152),
43
  "1280x704 · 16:9": (704, 1280),
44
  "1344x768 · 16:9 full": (768, 1344),
45
+
46
  # 9:16
47
  "544x960 · 9:16 fast": (960, 544),
48
  "640x1152 · 9:16": (1152, 640),
49
  "768x1344 · 9:16 full": (1344, 768),
50
+
51
  # 1:1
52
  "544x544 · 1:1 fast": (544, 544),
53
  "768x768 · 1:1 full": (768, 768),
54
+
55
  # 4:3 / 3:4
56
  "768x576 · 4:3 fast": (576, 768),
57
  "1024x768 · 4:3 full": (768, 1024),
58
  "576x768 · 3:4 fast": (768, 576),
59
  "768x1024 · 3:4 full": (1024, 768),
60
+
61
  # 21:9
62
  "1152x512 · 21:9 fast": (512, 1152),
63
  "1536x672 · 21:9 full": (672, 1536),
64
  }
65
+
66
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
67
+
68
+ FPS = 24
69
+ FRAMES_PER_CHUNK = 17
70
+ LATENTS_PER_CHUNK = 5
71
+
72
+ MIN_UI_DURATION = 2
73
+ MAX_UI_DURATION = 14
74
 
75
 
76
  def snap_frames(seconds: float) -> int:
77
+ """The frame count MiniMax-H3's video VAE can decode."""
78
  frames = max(1, round(float(seconds) * FPS))
79
+
80
  while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
81
  frames += 1
82
+
83
  return frames
84
 
85
 
86
  def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
87
+ """Allow the pipeline to generate below its normal 5-second floor."""
88
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import (
89
+ MiniMaxH3ModularPipeline,
90
+ )
91
+
92
+ MiniMaxH3ModularPipeline.min_duration = property(
93
+ lambda self: float(seconds)
94
+ )
95
 
 
96
 
97
+ # ----------------------------------------------------------------------
98
+ # OUTPUT DIRECTORIES
99
+ # ----------------------------------------------------------------------
100
 
101
+ # Temporary/public output used by Gradio to return the generated video.
102
  OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "h3-outputs")
103
 
104
+ # Persistent private bucket mounted in the Space.
105
+ #
106
+ # IMPORTANT:
107
+ # Do NOT add this directory to Gradio's `allowed_paths`.
108
+ # That prevents the private archive from being intentionally exposed through
109
+ # Gradio's file-serving route.
110
+ PRIVATE_OUTPUT_DIR = "/data/private-generations"
111
+
112
+
113
+ # ----------------------------------------------------------------------
114
+ # GLOBAL STATE
115
+ # ----------------------------------------------------------------------
116
+
117
  PIPE = None
118
  MANAGER = None
119
+
120
  LOAD_ERROR: str | None = None
121
  LOADED_IN: float | None = None
122
  LORA_STATUS: str | None = None
123
 
124
 
125
+ # ----------------------------------------------------------------------
126
+ # STATUS
127
+ # ----------------------------------------------------------------------
128
+
129
  def status() -> str:
130
  if LOAD_ERROR:
131
  return LOAD_ERROR
132
+
133
  if PIPE is None:
134
+ return (
135
+ f"Loading `{MODEL_REPO}` "
136
+ "(transformer + VAEs, 77.3 GB). Watch the Space logs."
137
+ )
138
+
139
  import h3_aoti
140
 
141
  return (
142
+ f"Ready · transformer + VAEs **bfloat16, unquantized** · "
143
+ f"placement `{PLACEMENT}` · "
144
+ f"attention `{ATTENTION}` · "
145
+ f"{h3_aoti.status()} · "
146
+ f"{LORA_STATUS or 'no LoRA'} · "
147
+ f"loaded in {LOADED_IN:.0f}s · "
148
  f"conditioner `{CONDITIONER_SPACE}`"
149
  )
150
 
151
 
152
+ # ----------------------------------------------------------------------
153
+ # MODEL LOADING
154
+ # ----------------------------------------------------------------------
155
+
156
  def load_models() -> str | None:
157
+ """Load the denoising half at startup."""
158
 
 
 
 
 
 
159
  global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
160
 
161
  if PIPE is not None or LOAD_ERROR is not None:
162
  return LOAD_ERROR
163
 
164
  started = time.time()
165
+
166
  try:
167
  import torch
168
  from diffusers import ComponentsManager
 
170
  from h3_split_blocks import MiniMaxH3GeneratorBlocks
171
 
172
  lower_duration_floor()
173
+
174
  manager = ComponentsManager()
175
  blocks = MiniMaxH3GeneratorBlocks()
176
+
177
+ print(
178
+ f"[gen] loading "
179
+ f"{[c.name for c in blocks.expected_components]} "
180
+ f"from {MODEL_REPO} ...",
181
+ flush=True,
182
+ )
183
+
184
+ pipe = blocks.init_pipeline(
185
+ MODEL_REPO,
186
+ components_manager=manager,
187
+ collection="h3",
188
+ )
189
+
190
  pipe.load_components(dtype=torch.bfloat16)
191
 
192
+ # Fold the Turbo LoRA into the bf16 weights before AoTI packages
193
+ # the blocks.
194
  import h3_lora
195
 
196
  LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
197
+
198
  if LORA_STATUS:
199
+ print(
200
+ f"[gen] {LORA_STATUS}",
201
+ flush=True,
202
+ )
203
 
204
  pipe.transformer.set_attention_backend(ATTENTION)
205
 
206
+ # AoTI package.
 
207
  import h3_aoti
208
 
209
  h3_aoti.maybe_load(pipe.transformer)
210
 
211
  if PLACEMENT == "pack":
212
+ # Only pack the transformer at startup.
 
 
213
  pipe.transformer.to("cuda")
214
 
215
  if PLACEMENT == "offload":
216
  manager.enable_auto_cpu_offload(device="cuda")
217
  _arm_decode_hooks(pipe)
218
 
219
+ PIPE = pipe
220
+ MANAGER = manager
221
  LOADED_IN = time.time() - started
222
+
223
+ print(
224
+ f"[gen] ready in {LOADED_IN:.0f}s",
225
+ flush=True,
226
+ )
227
+
228
  except Exception as error:
229
  traceback.print_exc()
230
+
231
+ LOAD_ERROR = (
232
+ f"**Loading `{MODEL_REPO}` failed** "
233
+ f"after {time.time() - started:.0f}s: "
234
+ f"`{type(error).__name__}: {error}`"
235
+ )
236
+
237
  return LOAD_ERROR
238
 
239
 
240
+ # ----------------------------------------------------------------------
241
+ # OFFLOAD HOOKS
242
+ # ----------------------------------------------------------------------
243
+
244
  def _arm_decode_hooks(pipe):
245
+ """Make the offload hooks fire for the two VAEs."""
246
 
 
 
 
247
  for name in ("vae", "audio_vae"):
248
  module = getattr(pipe, name)
249
  inner = module.decode
250
 
251
+ def armed(
252
+ *args,
253
+ _module=module,
254
+ _decode=inner,
255
+ **kwargs,
256
+ ):
257
  hook = getattr(_module, "_hf_hook", None)
258
+
259
  if hook is not None:
260
  hook.pre_forward(_module)
261
+
262
  return _decode(*args, **kwargs)
263
 
264
  module.decode = armed
265
 
266
 
267
+ # ----------------------------------------------------------------------
268
+ # CONDITIONER
269
+ # ----------------------------------------------------------------------
270
+
271
  @cache
272
  def conditioner():
273
+ """Fallback conditioner client."""
274
+
275
  from gradio_client import Client
276
 
277
  return Client(CONDITIONER_SPACE)
278
 
279
 
280
  def conditioner_client(ip_token):
281
+ """Create a conditioner client billed to the caller when possible."""
282
+
 
283
  if not ip_token:
284
  return conditioner()
285
+
286
  from gradio_client import Client
287
 
288
+ return Client(
289
+ CONDITIONER_SPACE,
290
+ headers={"x-ip-token": ip_token},
291
+ )
292
+
293
 
294
+ def encode_remote(
295
+ prompt,
296
+ image_path,
297
+ last_image_path,
298
+ canvas,
299
+ num_frames,
300
+ rewrite_prompt=False,
301
+ ip_token=None,
302
+ ):
303
+ """Encode prompt/keyframes through the conditioner Space."""
304
 
 
 
 
305
  from gradio_client import handle_file
306
  from safetensors import safe_open
307
 
308
  path, plan = conditioner_client(ip_token).predict(
309
  prompt=prompt,
310
+ image_path=(
311
+ handle_file(image_path)
312
+ if image_path
313
+ else None
314
+ ),
315
+ last_image_path=(
316
+ handle_file(last_image_path)
317
+ if last_image_path
318
+ else None
319
+ ),
320
  canvas=canvas,
321
  num_frames=num_frames,
322
  rewrite_prompt=bool(rewrite_prompt),
323
  api_name="/encode",
324
  )
325
+
326
  with safe_open(path, framework="pt") as handle:
327
  metadata = handle.metadata()
 
328
 
329
+ return (
330
+ handle.get_tensor("prompt_embeds"),
331
+ handle.get_tensor("text_token_tags"),
332
+ metadata,
333
+ plan,
334
+ )
335
+
336
+
337
+ # ----------------------------------------------------------------------
338
+ # GPU DURATION ESTIMATION
339
+ # ----------------------------------------------------------------------
340
+
341
+ _DUR_B = 1.1745e-4
342
+ _DUR_C = 3.8396e-9
343
+
344
+ _DECODE_BASE = 15
345
+ _DECODE_PER_DEFAULT_CANVAS = 15
346
+ _DEFAULT_CANVAS_PIXELS = 960 * 544 * 124
347
+
348
+ _PLACEMENT_ALLOWANCE = 12
349
+ _PAD = 10
350
+
351
+
352
+ def get_duration(
353
+ prompt_embeds,
354
+ text_token_tags,
355
+ image,
356
+ last_image,
357
+ height,
358
+ width,
359
+ num_frames,
360
+ steps,
361
+ seed,
362
+ lora="larry",
363
+ *a,
364
+ **k,
365
+ ):
366
+ height = int(height)
367
+ width = int(width)
368
+ num_frames = int(num_frames)
369
+ steps = int(steps)
370
+
371
+ latent_frames = (
372
+ (num_frames - LATENTS_PER_CHUNK)
373
+ // FRAMES_PER_CHUNK
374
+ * LATENTS_PER_CHUNK
375
+ + 2
376
+ )
377
 
378
+ patches = (height // 32) * (width // 32)
 
 
 
 
 
 
379
 
380
+ rows = (
381
+ latent_frames * patches
382
+ + (
383
+ int(image is not None)
384
+ + int(last_image is not None)
385
+ )
386
+ * patches
387
+ )
388
 
389
+ denoise = steps * (
390
+ _DUR_B * rows
391
+ + _DUR_C * rows**2
392
+ )
 
 
 
 
393
 
394
+ decode = (
395
+ _DECODE_BASE
396
+ + _DECODE_PER_DEFAULT_CANVAS
397
+ * (height * width * num_frames)
398
+ / _DEFAULT_CANVAS_PIXELS
399
+ )
400
 
401
+ return max(
402
+ 60,
403
+ int(denoise + decode)
404
+ + _PLACEMENT_ALLOWANCE
405
+ + _PAD,
406
+ )
407
 
 
 
 
 
408
 
409
+ # ----------------------------------------------------------------------
410
+ # GPU GENERATION
411
+ # ----------------------------------------------------------------------
412
+
413
+ @spaces.GPU(
414
+ duration=get_duration,
415
+ size=GPU_SIZE,
416
+ )
417
+ def _generate(
418
+ prompt_embeds,
419
+ text_token_tags,
420
+ image,
421
+ last_image,
422
+ height,
423
+ width,
424
+ num_frames,
425
+ steps,
426
+ seed,
427
+ lora="larry",
428
+ ):
429
+ """Run the denoise loop and decoders on GPU."""
430
+
431
+ import torch
432
  import h3_lora
433
 
434
+ active_lora = h3_lora.set_active(
435
+ PIPE.transformer,
436
+ lora,
437
+ )
438
 
439
  if PLACEMENT == "lazy":
440
  PIPE.to("cuda")
441
+
442
  elif PLACEMENT == "pack":
443
  PIPE.vae.to("cuda")
444
  PIPE.audio_vae.to("cuda")
 
452
  width=width,
453
  num_frames=num_frames,
454
  num_inference_steps=int(steps),
455
+ generator=torch.Generator(
456
+ "cpu"
457
+ ).manual_seed(int(seed)),
458
  )
 
459
 
460
+ return (
461
+ state.get("videos")[0],
462
+ state.get("audio")[0].cpu(),
463
+ state.get("sampling_rate"),
464
+ active_lora,
465
+ )
466
+
467
+
468
+ # ----------------------------------------------------------------------
469
+ # KEYFRAME FITTING
470
+ # ----------------------------------------------------------------------
471
+
472
+ def _fit_keyframe(
473
+ image_path,
474
+ current_canvas,
475
+ ):
476
+ """Cover-crop an uploaded keyframe to a supported aspect ratio."""
477
 
 
 
 
478
  from PIL import Image as _Image
479
 
480
  img = _Image.open(image_path)
481
+
482
  aspect = img.width / img.height
483
+
484
  fastest = {}
485
+
486
  for label, (h, w) in CANVASES.items():
487
  r = w / h
488
+
489
+ if (
490
+ r not in fastest
491
+ or w * h
492
+ < fastest[r][1][0] * fastest[r][1][1]
493
+ ):
494
+ fastest[r] = (
495
+ label,
496
+ (h, w),
497
+ )
498
+
499
+ ratio = min(
500
+ fastest,
501
+ key=lambda r: abs(r - aspect),
502
+ )
503
+
504
  label, (h, w) = fastest[ratio]
505
 
506
  cur_h, cur_w = CANVASES[current_canvas]
507
+
508
+ if abs(
509
+ cur_w / cur_h - aspect
510
+ ) <= abs(ratio - aspect):
511
  label = current_canvas
512
  h, w = cur_h, cur_w
513
 
514
  target = w / h
515
+
516
+ if abs(
517
+ img.width / img.height - target
518
+ ) > 1e-3:
519
+
520
  if img.width / img.height > target:
521
+ new_w = int(
522
+ img.height * target
523
+ )
524
+
525
+ left = (
526
+ img.width - new_w
527
+ ) // 2
528
+
529
+ img = img.crop(
530
+ (
531
+ left,
532
+ 0,
533
+ left + new_w,
534
+ img.height,
535
+ )
536
+ )
537
+
538
  else:
539
+ new_h = int(
540
+ img.width / target
541
+ )
542
+
543
+ top = (
544
+ img.height - new_h
545
+ ) // 2
546
+
547
+ img = img.crop(
548
+ (
549
+ 0,
550
+ top,
551
+ img.width,
552
+ top + new_h,
553
+ )
554
+ )
555
+
556
  img.save(image_path)
557
+
558
  return image_path, label
559
 
560
 
561
+ # ----------------------------------------------------------------------
562
+ # LORA
563
+ # ----------------------------------------------------------------------
564
+
565
+ def _resolve_lora(
566
+ lora,
567
+ use_lora,
568
+ ) -> str:
569
+ """Resolve the requested LoRA."""
570
+
571
+ if isinstance(lora, str) and lora in (
572
+ "larry",
573
+ "lightx",
574
+ "off",
575
+ ):
576
  return lora
 
577
 
578
+ return (
579
+ "larry"
580
+ if use_lora
581
+ else "off"
582
+ )
583
+
584
+
585
+ # ----------------------------------------------------------------------
586
+ # GENERATION FUNCTION
587
+ # ----------------------------------------------------------------------
588
+
589
+ def generate(
590
+ prompt,
591
+ image_path=None,
592
+ last_image_path=None,
593
+ canvas=DEFAULT_CANVAS,
594
+ duration=5,
595
+ steps=6,
596
+ seed=42,
597
+ upsample=False,
598
+ use_lora=True,
599
+ lora="",
600
+ ip_token=None,
601
+ ):
602
+ """Generate one video and archive a private copy."""
603
 
 
 
604
  if LOAD_ERROR:
605
  raise Exception(LOAD_ERROR)
606
+
607
  if PIPE is None:
608
+ raise Exception(
609
+ "The denoiser is still loading."
610
+ )
611
+
612
  if not prompt or not prompt.strip():
613
+ raise Exception(
614
+ "MiniMax-H3 always takes a prompt, "
615
+ "keyframes or not."
616
+ )
617
 
618
  from PIL import Image, ImageOps
 
619
  from diffusers.utils import encode_video
620
 
621
+ # Resolve LoRA.
622
+ lora = _resolve_lora(
623
+ lora,
624
+ use_lora,
625
+ )
626
+
627
+ # --------------------------------------------------------------
628
+ # Resolve uploaded keyframes.
629
+ # --------------------------------------------------------------
630
+
631
+ first = (
632
+ image_path["path"]
633
+ if isinstance(image_path, dict)
634
+ else image_path
635
+ )
636
+
637
+ last = (
638
+ last_image_path["path"]
639
+ if isinstance(last_image_path, dict)
640
+ else last_image_path
641
+ )
642
 
 
 
 
 
643
  if first:
644
+ first, canvas = _fit_keyframe(
645
+ first,
646
+ canvas,
647
+ )
648
+
649
  if last:
650
+ last, canvas = _fit_keyframe(
651
+ last,
652
+ canvas,
653
+ )
654
 
655
+ # --------------------------------------------------------------
656
+ # Calculate frame count.
657
+ # --------------------------------------------------------------
658
+
659
+ num_frames = snap_frames(
660
+ duration
661
+ )
662
+
663
+ # --------------------------------------------------------------
664
+ # Conditioner.
665
+ # --------------------------------------------------------------
666
 
667
  conditioned = time.time()
668
+
669
+ (
670
+ prompt_embeds,
671
+ text_token_tags,
672
+ metadata,
673
+ plan,
674
+ ) = encode_remote(
675
+ prompt,
676
+ first,
677
+ last,
678
+ canvas,
679
+ num_frames,
680
+ rewrite_prompt=upsample,
681
+ ip_token=ip_token,
682
+ )
683
+
684
+ condition_seconds = (
685
+ time.time() - conditioned
686
+ )
687
+
688
+ height, width, num_frames = (
689
+ int(metadata[key])
690
+ for key in (
691
+ "height",
692
+ "width",
693
+ "num_frames",
694
+ )
695
+ )
696
+
697
+ refined = (
698
+ plan.get("refined_prompt")
699
+ or ""
700
  )
701
+
702
+ # --------------------------------------------------------------
703
+ # Convert keyframe to RGB.
704
+ # --------------------------------------------------------------
705
 
706
  def keyframe(path):
707
+ return (
708
+ ImageOps.exif_transpose(
709
+ Image.open(path)
710
+ ).convert("RGB")
711
+ if path
712
+ else None
713
+ )
714
+
715
+ # --------------------------------------------------------------
716
+ # GPU generation.
717
+ # --------------------------------------------------------------
718
 
719
  started = time.time()
720
+
721
+ (
722
+ frames,
723
+ audio,
724
+ sampling_rate,
725
+ active_lora,
726
+ ) = _generate(
727
  prompt_embeds,
728
  text_token_tags,
729
  keyframe(first),
 
735
  seed,
736
  lora,
737
  )
 
738
 
739
+ generate_seconds = (
740
+ time.time() - started
741
+ )
742
+
743
+ # --------------------------------------------------------------
744
+ # Make sure both directories exist.
745
+ # --------------------------------------------------------------
746
+
747
+ os.makedirs(
748
+ OUTPUT_DIR,
749
+ exist_ok=True,
750
+ )
751
+
752
+ os.makedirs(
753
+ PRIVATE_OUTPUT_DIR,
754
+ exist_ok=True,
755
+ )
756
+
757
+ # --------------------------------------------------------------
758
+ # Unique generation ID.
759
+ # --------------------------------------------------------------
760
+
761
+ generation_id = (
762
+ f"h3-{int(time.time() * 1000)}"
763
+ )
764
+
765
+ # --------------------------------------------------------------
766
+ # PUBLIC / TEMPORARY OUTPUT
767
+ #
768
+ # This is the copy returned to the user.
769
+ # --------------------------------------------------------------
770
+
771
+ path = os.path.join(
772
+ OUTPUT_DIR,
773
+ f"{generation_id}.mp4",
774
+ )
775
+
776
+ encode_video(
777
+ frames,
778
+ fps=FPS,
779
+ output_path=path,
780
+ audio=audio,
781
+ audio_sample_rate=sampling_rate,
782
+ )
783
+
784
+ # --------------------------------------------------------------
785
+ # PRIVATE PERSISTENT BACKUP
786
+ #
787
+ # This copy goes into the mounted private bucket.
788
+ #
789
+ # IMPORTANT:
790
+ # We do NOT return this path to Gradio.
791
+ # --------------------------------------------------------------
792
+
793
+ private_path = os.path.join(
794
+ PRIVATE_OUTPUT_DIR,
795
+ f"{generation_id}.mp4",
796
+ )
797
+
798
+ shutil.copy2(
799
+ path,
800
+ private_path,
801
+ )
802
+
803
+ # --------------------------------------------------------------
804
+ # PRIVATE METADATA BACKUP
805
+ # --------------------------------------------------------------
806
+
807
+ metadata_path = os.path.join(
808
+ PRIVATE_OUTPUT_DIR,
809
+ f"{generation_id}.json",
810
+ )
811
+
812
+ metadata_record = {
813
+ "timestamp": time.time(),
814
+ "generation_id": generation_id,
815
+ "prompt": prompt,
816
+ "refined_prompt": refined,
817
+ "width": width,
818
+ "height": height,
819
+ "frames": num_frames,
820
+ "duration_seconds": (
821
+ num_frames / FPS
822
+ ),
823
+ "steps": int(steps),
824
+ "seed": int(seed),
825
+ "lora": active_lora,
826
+ "canvas": canvas,
827
+ "model_repo": MODEL_REPO,
828
+ "conditioner_space": CONDITIONER_SPACE,
829
+ }
830
+
831
+ with open(
832
+ metadata_path,
833
+ "w",
834
+ encoding="utf-8",
835
+ ) as metadata_file:
836
+ json.dump(
837
+ metadata_record,
838
+ metadata_file,
839
+ indent=2,
840
+ ensure_ascii=False,
841
+ )
842
+
843
+ # --------------------------------------------------------------
844
+ # Report shown to the user.
845
+ # --------------------------------------------------------------
846
 
847
  report = (
848
+ f"{width}x{height} · "
849
+ f"{num_frames} frames "
850
+ f"({num_frames / FPS:.3f} s) · "
851
+ f"{int(steps)} steps · "
852
+ f"conditioner "
853
+ f"{condition_seconds:.0f}s "
854
+ f"({plan['num_text_tokens']} tokens"
855
  f"{', upsampled' if refined else ''}) · "
856
+ f"denoise + decode "
857
+ f"{generate_seconds:.0f}s "
858
+ f"({generate_seconds / int(steps):.1f} s/step) · "
859
+ f"turbo LoRA {active_lora} · "
860
+ f"seed {int(seed)}"
861
  )
 
 
862
 
863
+ print(
864
+ f"[gen] {report}",
865
+ flush=True,
866
+ )
867
+
868
+ print(
869
+ f"[archive] private video: {private_path}",
870
+ flush=True,
871
+ )
872
+
873
+ print(
874
+ f"[archive] private metadata: {metadata_path}",
875
+ flush=True,
876
+ )
877
+
878
+ # Return ONLY the temporary/public copy.
879
+ return (
880
+ FileData(path=path),
881
+ report,
882
+ refined,
883
+ )
884
 
885
 
886
  # ======================================================================
887
+ # SERVER MODE
 
888
  # ======================================================================
 
889
 
890
+ app = Server(
891
+ title="MiniMax-H3 Studio"
892
+ )
893
+
894
+
895
+ # ----------------------------------------------------------------------
896
+ # GENERATE API
897
+ # ----------------------------------------------------------------------
898
 
899
  @app.api(name="generate")
900
+ def _generate_api(
901
+ prompt: str,
902
+ image_path: FileData | None = None,
903
+ last_image_path: FileData | None = None,
904
+ canvas: str = DEFAULT_CANVAS,
905
+ duration: float = 5,
906
+ steps: int = 6,
907
+ seed: float = 42,
908
+ upsample: bool = False,
909
+ use_lora: bool = True,
910
+ lora: str = "",
911
+ request: Request = None,
912
+ ) -> tuple[FileData, str, str]:
913
+
914
+ """Generate a video with synchronized soundtrack."""
915
+
916
+ # The request's x-ip-token bills the conditioner
917
+ # to the caller when available.
918
+ ip_token = (
919
+ request.headers.get("x-ip-token")
920
+ if request is not None
921
+ else None
922
+ )
923
 
924
+ return generate(
925
+ prompt,
926
+ image_path,
927
+ last_image_path,
928
+ canvas,
929
+ duration,
930
+ steps,
931
+ seed,
932
+ upsample,
933
+ use_lora,
934
+ lora,
935
+ ip_token=ip_token,
936
+ )
937
 
938
 
939
+ # ----------------------------------------------------------------------
940
+ # STATUS
941
+ # ----------------------------------------------------------------------
942
+
943
  @app.get("/status")
944
  def studio_status():
945
+ """Return model readiness."""
946
+
947
+ return {
948
+ "ready": (
949
+ PIPE is not None
950
+ and LOAD_ERROR is None
951
+ ),
952
+ "status": status(),
953
+ }
954
+
955
 
956
+ # ----------------------------------------------------------------------
957
+ # STUDIO CONFIG
958
+ # ----------------------------------------------------------------------
959
 
 
960
  @app.get("/studio-config")
961
  def studio_config():
962
+ """Return canvas and LoRA configuration."""
963
+
964
  import h3_lora
965
 
966
+ state = (
967
+ getattr(
968
+ PIPE.transformer,
969
+ "_lora_state",
970
+ None,
971
+ )
972
+ if PIPE is not None
973
+ else None
974
+ )
975
+
976
+ sets = (
977
+ state["sets"]
978
+ if state
979
+ else {}
980
+ )
981
+
982
  return {
983
  "canvases": list(CANVASES),
984
  "default_canvas": DEFAULT_CANVAS,
985
  "min_duration": MIN_UI_DURATION,
986
  "max_duration": MAX_UI_DURATION,
987
+
988
  "loras": {
989
  **{
990
+ name: {
991
+ "label": spec["label"],
992
+ "steps": {
993
+ "larry": 6,
994
+ "lightx": 4,
995
+ }.get(
996
+ name,
997
+ 6,
998
+ ),
999
+ }
1000
  for name, spec in sets.items()
1001
  },
1002
+
1003
+ "off": {
1004
+ "label": "off (base model)",
1005
+ "steps": 28,
1006
+ },
1007
  },
1008
+
1009
+ "default_lora": (
1010
+ state["active"]
1011
+ if state
1012
+ else "off"
1013
+ ),
1014
  }
1015
 
1016
 
1017
+ # ----------------------------------------------------------------------
1018
+ # HOMEPAGE
1019
+ # ----------------------------------------------------------------------
1020
+
1021
+ @app.get(
1022
+ "/",
1023
+ response_class=HTMLResponse,
1024
+ )
1025
  def homepage():
1026
+
1027
+ with open(
1028
+ os.path.join(
1029
+ os.path.dirname(
1030
+ os.path.abspath(__file__)
1031
+ ),
1032
+ "index.html",
1033
+ ),
1034
+ encoding="utf-8",
1035
+ ) as f:
1036
  return f.read()
1037
 
1038
 
1039
+ # ----------------------------------------------------------------------
1040
+ # LOAD MODELS
1041
+ # ----------------------------------------------------------------------
1042
+
1043
  load_models()
1044
 
1045
+
1046
+ # ----------------------------------------------------------------------
1047
+ # START SERVER
1048
+ # ----------------------------------------------------------------------
1049
+
1050
  if __name__ == "__main__":
1051
+
1052
+ # IMPORTANT:
1053
+ # Only OUTPUT_DIR is included here.
1054
+ #
1055
+ # DO NOT add PRIVATE_OUTPUT_DIR.
1056
+ #
1057
+ # This prevents the private archive from being intentionally exposed
1058
+ # through Gradio's /gradio_api/file= route.
1059
+ app.launch(
1060
+ show_error=True,
1061
+ allowed_paths=[
1062
+ OUTPUT_DIR
1063
+ ],
1064
+ )