dagloop5 commited on
Commit
027bb7f
·
verified ·
1 Parent(s): 1a8274b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +699 -700
app.py CHANGED
@@ -1,701 +1,700 @@
1
- """MiniMax-H3 `ref2va`, split deployment — the denoising half.
2
-
3
- This Space holds the `transformer_ref` partition and the two autoencoders, unquantized bfloat16. Text encoding runs in
4
- [`qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner), which this one calls over the
5
- gradio API for every request; `reference_encoder` stays here, next to the autoencoders it runs.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- import os
12
- import tempfile
13
- import time
14
- import traceback
15
- from functools import cache
16
-
17
- # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
18
- # startup rather than on GPU time.
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", "mpasila/qwen3vl-conditioner")
24
- # `lazy` moves all 72.16 GiB onto the card on the first GPU call and leaves it there; `offload` hands placement to
25
- # `ComponentsManager.enable_auto_cpu_offload`. Startup placement is not an option here — see `load_models`.
26
- PLACEMENT = os.environ.get("H3_PLACEMENT", "lazy").lower()
27
- # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
28
- # flash-attention 3 is sm90-only and this card is sm120 (the `zero-a10g` flavour name is legacy).
29
- ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
30
- GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
31
- # Bounds on what `get_duration` may reserve. The pool reserves whatever number it is given, so a flat ceiling for every
32
- # request is what makes an account hit "too many ZeroGPU credits allocated to running tasks".
33
- MIN_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MIN", "120"))
34
- MAX_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MAX", "1500"))
35
-
36
- # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
37
- # is rejected there and surfaces as a failure here.
38
- CANVASES = {
39
- # 16:9
40
- "960x544 · 16:9 fast": (544, 960),
41
- "1024x576 · 16:9 fast": (576, 1024),
42
- "1152x640 · 16:9": (640, 1152),
43
- "1280x704 · 16:9": (704, 1280),
44
- "1344x768 · 16:9 full": (768, 1344),
45
- # 9:16
46
- "544x960 · 9:16 fast": (960, 544),
47
- "640x1152 · 9:16": (1152, 640),
48
- "768x1344 · 9:16 full": (1344, 768),
49
- # 1:1
50
- "544x544 · 1:1 fast": (544, 544),
51
- "768x768 · 1:1 full": (768, 768),
52
- # 4:3 / 3:4
53
- "768x576 · 4:3 fast": (576, 768),
54
- "1024x768 · 4:3 full": (768, 1024),
55
- "576x768 · 3:4 fast": (768, 576),
56
- "768x1024 · 3:4 full": (1024, 768),
57
- # 3:2 / 2:3
58
- "864x576 · 3:2 fast": (576, 864),
59
- "1152x768 · 3:2 full": (768, 1152),
60
- "576x864 · 2:3 fast": (864, 576),
61
- "768x1152 · 2:3 full": (1152, 768),
62
- # 21:9
63
- "1152x512 · 21:9 fast": (512, 1152),
64
- "1536x672 · 21:9 full": (672, 1536),
65
- }
66
- DEFAULT_CANVAS = "960x544 · 16:9 fast"
67
- FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
68
- # It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
69
- # 15.083 s, and is refused. 14 is the last whole second that survives the snap.
70
- MAX_UI_DURATION = 14
71
- MIN_DURATION = 2
72
- # A reference video shorter than 2 s gives the model almost no motion to read.
73
- MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
74
- # `MINIMAX_H3_MAX_REFERENCE_IMAGES`. The slots are built up front and revealed one at a time, because a demo asking
75
- # for two subjects should not open with nine boxes.
76
- MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
77
-
78
- MIN_STEPS = 4
79
-
80
- # Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
81
- # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
82
- STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
83
- # The lazy 72.16 GiB `PIPE.to("cuda")` a cold worker pays inside its first GPU call; every request carries it, because
84
- # nothing here knows whether the worker it lands on is cold.
85
- PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
86
- AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
87
- REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
88
- DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
89
-
90
-
91
- def snap_frames(seconds: float) -> int:
92
- """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
93
- frames = max(1, round(float(seconds) * FPS))
94
- while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
95
- frames += 1
96
- return frames
97
-
98
-
99
- def lower_duration_floor(seconds: float = MIN_DURATION) -> None:
100
- """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
101
- from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
102
-
103
- MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
104
-
105
-
106
- def video_latent_frames(num_frames: int) -> int:
107
- """`17 * n + 5` frames become `5 * n + 2` video latents."""
108
- return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2
109
-
110
-
111
- def target_rows(height: int, width: int, num_frames: int) -> int:
112
- """The generated rows of the packed sequence: video patched `(1, 2, 2)`, plus two audio rows per latent."""
113
- video = video_latent_frames(num_frames) * (height // CANVAS_MULTIPLE) * (width // CANVAS_MULTIPLE)
114
- return video + round(num_frames / FPS * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
115
-
116
-
117
- def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
118
- """The rows the reference blocks add, from metadata alone — no decode.
119
-
120
- An image is resized to a 2048 pixel short edge and encoded as a single frame; a video is put on the canvas *its
121
- own* aspect ratio resolves to, truncated to the generated frame count and snapped **down** to a `17 * n + 5` the
122
- VAE encodes without padding; a soundtrack contributes two rows per 1/40 s.
123
- """
124
- from PIL import Image
125
-
126
- from diffusers.modular_pipelines.minimax_h3.modular_pipeline import resolve_canvas_size
127
-
128
- rows = 0
129
- for kind, path in references:
130
- if kind == "image":
131
- width, height = Image.open(path).size
132
- scale = REFERENCE_IMAGE_SHORT_EDGE / min(width, height)
133
- resolved = [
134
- max(CANVAS_MULTIPLE, round(edge * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
135
- for edge in (height, width)
136
- ]
137
- rows += (resolved[0] // CANVAS_MULTIPLE) * (resolved[1] // CANVAS_MULTIPLE)
138
- continue
139
-
140
- video_seconds, audio_seconds = probe(path)
141
- if kind == "video" and video_seconds is not None:
142
- import av
143
-
144
- with av.open(path) as container:
145
- stream = container.streams.video[0]
146
- source_height, source_width = stream.height, stream.width
147
- canvas_height, canvas_width = resolve_canvas_size(source_width, source_height, CANVAS_MULTIPLE)
148
- frames = min(round(video_seconds * FPS), num_frames)
149
- snapped = max(1, (frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) * FRAMES_PER_CHUNK + LATENTS_PER_CHUNK
150
- rows += (
151
- video_latent_frames(snapped)
152
- * (canvas_height // CANVAS_MULTIPLE)
153
- * (canvas_width // CANVAS_MULTIPLE)
154
- )
155
- if audio_seconds is not None:
156
- seconds = min(audio_seconds, num_frames / FPS)
157
- rows += round(seconds * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
158
- return rows
159
-
160
-
161
- def get_duration(
162
- prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, **_
163
- ):
164
- """Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
165
- tolerates the `gr.Progress` `spaces` injects."""
166
- sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
167
- height, width, num_frames
168
- )
169
- denoise = int(steps) * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY
170
- # The two reference encoders ahead of the loop, and the two decoders plus the mux after it. Both scale with what
171
- # they are handed rather than with the step count.
172
- encode = 5 + reference_rows(references, num_frames) * 1e-3
173
- decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
174
- total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10
175
- duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
176
- print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
177
- return duration
178
-
179
-
180
- PIPE = None
181
- MANAGER = None
182
- LOAD_ERROR: str | None = None
183
-
184
-
185
- def load_models() -> str | None:
186
- """Load the denoising half at startup, but *not* onto the card.
187
-
188
- `MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`, the two schedulers and
189
- `video_processor`, so `load_components` fetches exactly those subfolders — `text_encoder/` and the `transformer/`
190
- partition are never touched. Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a
191
- bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
192
-
193
- Nothing moves onto the card here, for storage rather than memory: `spaces`' startup `torch.pack()` writes every
194
- startup-resident CUDA tensor to a second copy on disk, and 77.3 GB of weights plus its pack busts the 150 GB quota
195
- (`OSError: [Errno 28] No space left on device` out of `os.posix_fallocate`, mid-pack).
196
- """
197
- global PIPE, MANAGER, LOAD_ERROR
198
-
199
- if PIPE is not None or LOAD_ERROR is not None:
200
- return LOAD_ERROR
201
-
202
- started = time.time()
203
- try:
204
- import torch
205
- from diffusers import ComponentsManager
206
-
207
- from h3_split_blocks import MiniMaxH3Ref2VAGeneratorBlocks
208
-
209
- lower_duration_floor()
210
- manager = ComponentsManager()
211
- blocks = MiniMaxH3Ref2VAGeneratorBlocks()
212
- print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
213
- pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
214
- pipe.load_components(dtype=torch.bfloat16)
215
-
216
- # Both VAEs first, and explicitly. `set_attention_backend` also sets the registry's *global* backend, which
217
- # every processor that was not stamped falls through to, and the float32 audio VAE has no cuDNN kernel:
218
- # `RuntimeError: No available kernel. Aborting execution.` in its causal encoder attention, which only a
219
- # reference soundtrack ever reaches.
220
- pipe.vae.set_attention_backend("native")
221
- pipe.audio_vae.set_attention_backend("native")
222
- pipe.transformer_ref.set_attention_backend(ATTENTION)
223
-
224
- # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
225
- # worker. Off unless `H3_AOTI=1`. It is the *same* package the `transformer/` partition runs — the two configs
226
- # are identical field for field and the compiled code carries no weights of either.
227
- import h3_aoti
228
-
229
- h3_aoti.maybe_load(pipe.transformer_ref)
230
-
231
- if PLACEMENT == "offload":
232
- manager.enable_auto_cpu_offload(device="cuda")
233
- _arm_decode_hooks(pipe)
234
-
235
- PIPE, MANAGER = pipe, manager
236
- print(f"[ref2va] ready in {time.time() - started:.0f}s", flush=True)
237
- except Exception as error:
238
- traceback.print_exc()
239
- LOAD_ERROR = (
240
- f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
241
- f"`{type(error).__name__}: {error}`"
242
- )
243
- return LOAD_ERROR
244
-
245
-
246
- def _arm_decode_hooks(pipe):
247
- """Make the offload hooks fire for the two VAEs.
248
-
249
- `enable_auto_cpu_offload` wraps `forward`, and the reference-encoder and decode blocks call `vae.encode/decode(...)`
250
- directly, so the hook never runs and the VAE is still on the host when the latents arrive on the card.
251
- """
252
- for name in ("vae", "audio_vae"):
253
- module = getattr(pipe, name)
254
- for method in ("encode", "decode"):
255
- inner = getattr(module, method)
256
-
257
- def armed(*args, _module=module, _inner=inner, **kwargs):
258
- hook = getattr(_module, "_hf_hook", None)
259
- if hook is not None:
260
- hook.pre_forward(_module)
261
- return _inner(*args, **kwargs)
262
-
263
- setattr(module, method, armed)
264
-
265
-
266
-
267
- @cache
268
- def conditioner():
269
- """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
270
- conditioner's booking is billed to whoever asked for the video."""
271
- from gradio_client import Client
272
-
273
- return Client(CONDITIONER_SPACE)
274
-
275
-
276
- def probe(path: str) -> tuple[float | None, float | None]:
277
- """`(video seconds, audio seconds)` of a media file, either being `None` when the stream is absent."""
278
- import av
279
-
280
- def seconds(stream, container):
281
- if stream.duration is not None and stream.time_base is not None:
282
- return float(stream.duration * stream.time_base)
283
- return None if container.duration is None else container.duration / av.time_base
284
-
285
- with av.open(path) as container:
286
- video = seconds(container.streams.video[0], container) if container.streams.video else None
287
- audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
288
- return video, audio
289
-
290
-
291
- def _media_dimensions(path: str) -> tuple[int, int]:
292
- """`(width, height)` of an image or a video file, from its first stream."""
293
- from PIL import Image
294
-
295
- try:
296
- with Image.open(path) as image:
297
- return image.size
298
- except Exception:
299
- pass
300
- import av
301
-
302
- with av.open(path) as container:
303
- stream = container.streams.video[0]
304
- return stream.width, stream.height
305
-
306
-
307
- def closest_canvas(path: str | None) -> str | None:
308
- """The canvas label whose aspect ratio is closest to a media file's, or `None` when the file is
309
- missing or unreadable."""
310
- if not path:
311
- return None
312
- try:
313
- width, height = _media_dimensions(path)
314
- except Exception:
315
- return None
316
- if not width or not height:
317
- return None
318
- target = width / height
319
- return min(CANVASES, key=lambda label: abs(CANVASES[label][1] / CANVASES[label][0] - target))
320
-
321
-
322
- def auto_canvas(path):
323
- """Set the canvas to the closest aspect ratio of an uploaded image or video."""
324
- label = closest_canvas(path)
325
- return gr.update(value=label) if label else gr.update()
326
-
327
-
328
- def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
329
- """The `(kind, path)` references of a request, **in the order the model reads them**.
330
-
331
- That order numbers the labels of MiniMax-H3's prompt presentation and advances the shared audio/video rotary clock,
332
- so the same references in a different order are a different request.
333
- """
334
- ordered = [("image", path) for path in image_paths if path]
335
- if audio_path:
336
- ordered.append(("audio", audio_path))
337
- if video_path:
338
- ordered.append(("video", video_path))
339
- return ordered
340
-
341
-
342
- def build_references(references: list[tuple[str, str]]):
343
- """The `(kind, path)` references of a request as decoded reference dataclasses, in packed order. `from_file` brings
344
- the rates along: a video its own frame rate and soundtrack, a clip its sample rate."""
345
- from diffusers.modular_pipelines.minimax_h3 import (
346
- MiniMaxH3AudioReference,
347
- MiniMaxH3ImageReference,
348
- MiniMaxH3VideoReference,
349
- )
350
-
351
- classes = {"image": MiniMaxH3ImageReference, "video": MiniMaxH3VideoReference, "audio": MiniMaxH3AudioReference}
352
- return [classes[kind].from_file(path) for kind, path in references]
353
-
354
-
355
- def audio_bearing(references: list[tuple[str, str]]) -> list[tuple[str, float]]:
356
- """The references that carry a waveform, and how long it is. A video reference brings its own soundtrack."""
357
- carried = []
358
- for kind, path in references:
359
- if kind == "image":
360
- continue
361
- _, audio_seconds = probe(path)
362
- if audio_seconds is not None:
363
- carried.append((kind, audio_seconds))
364
- return carried
365
-
366
-
367
- def duration_controls(audio_path, video_path, match: bool):
368
- """Show the duration slider unless a single soundtrack can set it, which is when MiniMax-H3 lets it be left out."""
369
- try:
370
- carried = audio_bearing(collect([], audio_path, video_path))
371
- except Exception:
372
- carried = []
373
- # Exactly one soundtrack, long enough to be a duration MiniMax-H3 generates; anything else is ambiguous or out of
374
- # range and the slider stays.
375
- derivable = len(carried) == 1 and MIN_DURATION <= snap_frames(carried[0][1]) / FPS <= MAX_REFERENCE_VIDEO
376
- return gr.update(visible=derivable), gr.update(visible=not (derivable and match))
377
-
378
-
379
- def check(prompt: str, references: list[tuple[str, str]]) -> None:
380
- """The model's own rules, before anything is uploaded or a card is allocated."""
381
- if not prompt or not prompt.strip():
382
- raise gr.Error("MiniMax-H3 always takes a prompt, references or not.")
383
- if not references:
384
- raise gr.Error("Add at least one reference — an image or a video for the model to condition on.")
385
- if {kind for kind, _ in references} == {"audio"}:
386
- raise gr.Error("An audio reference needs an image or a video alongside it; it cannot go on its own.")
387
- for kind, path in references:
388
- if kind != "video":
389
- continue
390
- video_seconds, _ = probe(path)
391
- if video_seconds is None:
392
- raise gr.Error("That reference video has no video stream. Drop it in the audio slot instead.")
393
- if not MIN_REFERENCE_VIDEO <= video_seconds <= MAX_REFERENCE_VIDEO:
394
- raise gr.Error(
395
- f"The reference video is {video_seconds:.1f} s. Use a clip between "
396
- f"{MIN_REFERENCE_VIDEO:g} and {MAX_REFERENCE_VIDEO:g} seconds."
397
- )
398
-
399
-
400
- def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
401
- """`/encode_ref2va` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with
402
- the resolved `height` / `width` / `num_frames` in its metadata, plus the plan.
403
-
404
- `canvas` is the label. `media` and `kinds` are parallel and ordered, and the references go over because `ref2va`'s
405
- presentation puts a vision block in front of the prompt for every image and every merged video frame pair.
406
- """
407
- from gradio_client import handle_file
408
- from safetensors import safe_open
409
-
410
- path, plan = conditioner().predict(
411
- prompt=prompt,
412
- media=[handle_file(path) for _, path in references],
413
- kinds=",".join(kind for kind, _ in references),
414
- canvas=canvas,
415
- num_frames=num_frames,
416
- rewrite_prompt=bool(rewrite_prompt),
417
- api_name="/encode_ref2va",
418
- )
419
- with safe_open(path, framework="pt") as handle:
420
- return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
421
-
422
-
423
- @spaces.GPU(duration=get_duration, size=GPU_SIZE)
424
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
425
- def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=()):
426
- """The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
427
- References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
428
- argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
429
- the full `PipelineState` still holds the packed latents and the rotary grid on the card.
430
- The adapters are attached here rather than in the caller: `spaces` runs this body in its own worker, so the
431
- transformer the request sees is the one that has to carry them.
432
- """
433
- import torch
434
- if PLACEMENT == "lazy":
435
- PIPE.to("cuda")
436
- apply_loras(PIPE.transformer_ref, loras or ())
437
- state = PIPE(
438
- prompt_embeds=prompt_embeds.to("cuda"),
439
- text_token_tags=text_token_tags,
440
- references=build_references(references),
441
- height=height,
442
- width=width,
443
- num_frames=num_frames,
444
- num_inference_steps=int(steps),
445
- generator=torch.Generator("cpu").manual_seed(int(seed)),
446
- )
447
- return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
448
-
449
-
450
- def generate(
451
- prompt,
452
- image_1=None,
453
- audio_path=None,
454
- video_path=None,
455
- canvas=DEFAULT_CANVAS,
456
- image_2=None,
457
- image_3=None,
458
- image_4=None,
459
- image_5=None,
460
- image_6=None,
461
- image_7=None,
462
- image_8=None,
463
- image_9=None,
464
- match=True,
465
- duration=5,
466
- steps=28,
467
- seed=42,
468
- upsample=False,
469
- progress=gr.Progress(track_tqdm=True),
470
- ):
471
- """One request."""
472
- if LOAD_ERROR:
473
- raise gr.Error(LOAD_ERROR)
474
- if PIPE is None:
475
- raise gr.Error("The denoiser is still loading.")
476
-
477
- from diffusers.utils import encode_video
478
-
479
- images = [image_1, image_2, image_3, image_4, image_5, image_6, image_7, image_8, image_9]
480
- references = collect(images, audio_path, video_path)
481
- check(prompt, references)
482
-
483
- # `0` is "leave it to the references" over the wire, which MiniMax-H3 accepts when exactly one of them carries a
484
- # soundtrack. The conditioner resolves it either way and this Space pins whatever comes back.
485
- derivable = len(audio_bearing(references)) == 1
486
- requested = 0 if (match and derivable) else snap_frames(duration)
487
-
488
- progress(0.0, desc="Upsampling the prompt ..." if upsample else "Reading the prompt and references ...")
489
- conditioned = time.time()
490
- try:
491
- prompt_embeds, text_token_tags, metadata, plan = encode_remote(
492
- prompt, references, canvas, requested, rewrite_prompt=upsample
493
- )
494
- except gr.Error:
495
- raise
496
- except Exception as error:
497
- # gradio only puts the exception *type* on the wire, so the useful half of a conditioner-side failure is in
498
- # that Space's logs.
499
- traceback.print_exc()
500
- raise gr.Error(
501
- f"The conditioner ({CONDITIONER_SPACE}) failed with `{type(error).__name__}: {error}`. "
502
- "Its logs carry the full traceback."
503
- ) from error
504
- condition_seconds = time.time() - conditioned
505
- height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
506
- refined = plan.get("refined_prompt") or ""
507
-
508
- progress(0.1, desc=f"Generating {num_frames / FPS:.1f} s at {width}x{height} ...")
509
- started = time.time()
510
- frames, audio, sampling_rate = _generate(
511
- prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed
512
- )
513
- generate_seconds = time.time() - started
514
-
515
- directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
516
- os.makedirs(directory, exist_ok=True)
517
- path = os.path.join(directory, f"h3-ref2va-{int(time.time() * 1000)}.mp4")
518
- encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
519
-
520
- print(
521
- f"[ref2va] {[kind for kind, _ in references]} · `{width}x{height}`, {num_frames} frames "
522
- f"({num_frames / FPS:.3f} s), {int(steps)} steps · conditioner {condition_seconds:.0f}s "
523
- f"({plan['num_text_tokens']} tokens{', upsampled' if refined else ''}) · "
524
- f"denoise + decode {generate_seconds:.0f}s "
525
- f"({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}",
526
- flush=True,
527
- )
528
- return path, refined, gr.update(visible=bool(refined))
529
-
530
-
531
- # ----------------------------------------------------------------------------------------------------------------
532
- # Settings file
533
- # ----------------------------------------------------------------------------------------------------------------
534
- # Everything typed rather than uploaded, so a session can be picked up where it was left off. The references
535
- # themselves are deliberately left out: gradio hands them over as paths into a per-session temporary directory that
536
- # is gone by the next visit, so a saved path would restore as a dead file rather than as the image.
537
-
538
- SETTINGS_VERSION = 1
539
- SETTINGS_KEYS = ["prompt", "upsample", "canvas", "match", "duration", "steps", "seed"]
540
-
541
-
542
- def save_settings(*values):
543
- """Write the current controls to a `.json` and reveal it for download."""
544
- payload = {"version": SETTINGS_VERSION, "saved": time.strftime("%Y-%m-%d %H:%M:%S")}
545
- payload.update(dict(zip(SETTINGS_KEYS, values)))
546
-
547
- directory = os.path.join(tempfile.gettempdir(), "h3-settings")
548
- os.makedirs(directory, exist_ok=True)
549
- path = os.path.join(directory, f"h3-settings-{int(time.time())}.json")
550
- with open(path, "w", encoding="utf-8") as handle:
551
- json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
552
- return gr.update(value=path, visible=True)
553
-
554
-
555
- def load_settings(path):
556
- """Restore the controls from a `.json`. A key the file does not carry leaves its control alone, so a settings
557
- file written by an older version of this Space still loads."""
558
- if not path:
559
- return [gr.update() for _ in SETTINGS_KEYS]
560
- try:
561
- with open(path, encoding="utf-8") as handle:
562
- payload = json.load(handle)
563
- except Exception as error:
564
- raise gr.Error(f"Файлът с настройки не се чете: `{type(error).__name__}: {error}`")
565
- if not isinstance(payload, dict):
566
- raise gr.Error("Това не е файл с настройки на този Space.")
567
-
568
- updates = []
569
- for key in SETTINGS_KEYS:
570
- value = payload.get(key)
571
- # An unknown canvas label would be rejected by the conditioner, which is the wrong place to find out.
572
- if value is None or (key == "canvas" and value not in CANVASES):
573
- updates.append(gr.update())
574
- else:
575
- updates.append(gr.update(value=value))
576
- return updates
577
-
578
-
579
- load_models()
580
-
581
- INTRO = """# MiniMax-H3 Reference
582
-
583
- <div align="center">
584
- <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
585
- <a href="https://www.minimax.io/blog/minimax-h3" target="_blank" rel="noopener"><strong>[ blog ]</strong></a> &nbsp;
586
- <a href="https://huggingface.co/spaces/multimodalart/minimax-h3" target="_blank" rel="noopener"><strong>[ text / image to video ]</strong></a>
587
- </div>
588
-
589
- **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
590
- fully synchronized soundtrack (ambience, foley, speech). Bring your own subject, voice or camera move as a
591
- reference.
592
- """
593
-
594
- CSS = """
595
- .main.fillable { max-width: 1250px !important; }
596
- .dark .gradio-container { color: var(--body-text-color); }
597
- """
598
-
599
- with gr.Blocks(title="MiniMax-H3 Reference Custom Lora") as demo:
600
- gr.Markdown(INTRO)
601
-
602
- with gr.Row():
603
- with gr.Column():
604
- prompt = gr.Textbox(
605
- label="Prompt",
606
- lines=3,
607
- value="The character walks through a neon-lit street in the rain, humming to themselves",
608
- )
609
- upsample = gr.Checkbox(label="Upsample prompt", value=False)
610
- # One tab per modality, in the order the model reads them. A reference left in a tab that is not the open
611
- # one is still part of the request.
612
- with gr.Tabs():
613
- with gr.Tab("Images"):
614
- # One `gr.Row`, so gradio splits the width evenly and wraps at `min_width` rather than leaving a
615
- # hole where a hidden slot used to be.
616
- with gr.Row():
617
- images = [
618
- gr.Image(
619
- label="Subject, style or scene",
620
- type="filepath",
621
- min_width=180,
622
- # Fixed, so a row that wraps to a single slot stays the size of a full one.
623
- height=210,
624
- visible=index < OPEN_IMAGE_SLOTS,
625
- )
626
- for index in range(MAX_IMAGE_SLOTS)
627
- ]
628
- add_image = gr.Button("+ Add another image", size="sm", variant="secondary")
629
- with gr.Tab("Audio"):
630
- audio = gr.Audio(label="A voice or a piece of music", type="filepath")
631
- with gr.Tab("Video"):
632
- video = gr.Video(label="Motion & camera, 2–15 s. Its soundtrack comes along.")
633
- run = gr.Button("Generate", variant="primary")
634
- with gr.Accordion("Advanced options", open=False):
635
- canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
636
- match = gr.Checkbox(label="Match the reference soundtrack", value=True, visible=False)
637
- duration = gr.Slider(
638
- label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
639
- )
640
- steps = gr.Slider(label="Steps", minimum=MIN_STEPS, maximum=40, step=1, value=28)
641
- seed = gr.Number(label="Seed", value=42, precision=0)
642
-
643
- with gr.Accordion("Settings file", open=False):
644
- gr.Markdown(SETTINGS_HELP)
645
- save = gr.Button("Save settings to .json", size="sm")
646
- settings_download = gr.File(label="Your settings", visible=False, interactive=False)
647
- settings_upload = gr.File(
648
- label="Load a settings .json", file_types=[".json"], type="filepath"
649
- )
650
-
651
- with gr.Column():
652
- result = gr.Video(label="Video + soundtrack")
653
- # An output, so it can be revealed only for a request that asked for a rewrite.
654
- with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
655
- upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
656
-
657
- open_slots = gr.State(OPEN_IMAGE_SLOTS)
658
-
659
- def reveal_image_slot(open_count):
660
- open_count = min(open_count + 1, MAX_IMAGE_SLOTS)
661
- return [
662
- open_count,
663
- *[gr.update(visible=index < open_count) for index in range(MAX_IMAGE_SLOTS)],
664
- gr.update(visible=open_count < MAX_IMAGE_SLOTS),
665
- ]
666
-
667
- add_image.click(reveal_image_slot, open_slots, [open_slots, *images, add_image], api_name=False)
668
-
669
- for control in (audio, video, match):
670
- control.change(
671
- duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
672
- )
673
-
674
- # Auto-select the canvas whose aspect ratio is closest to an uploaded image or video.
675
- for image in images:
676
- image.change(auto_canvas, image, canvas, show_progress="hidden", api_name=False)
677
- video.change(auto_canvas, video, canvas, show_progress="hidden", api_name=False)
678
-
679
- # `reference, strength, reference, strength, ...`, which is how `generate` unpacks them.
680
- lora_inputs = [field for pair in zip(lora_references, lora_scales) for field in pair]
681
- lora_upload.upload(_fill_lora_slots, [lora_upload, *lora_references], lora_references, api_name=False)
682
- lora_preset_add.click(
683
- _add_preset_lora,
684
- [lora_preset, *lora_references, *lora_scales],
685
- [*lora_references, *lora_scales, steps],
686
- api_name=False,
687
- )
688
-
689
- # Same order as `SETTINGS_KEYS`.
690
- settings_fields = [prompt, upsample, canvas, match, duration, steps, seed]
691
- save.click(save_settings, settings_fields, settings_download, api_name=False)
692
- settings_upload.upload(load_settings, settings_upload, settings_fields, api_name=False)
693
- # Same order as `generate`'s signature: the five leading columns first, then the remaining image slots.
694
- request = [
695
- prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample
696
- ]
697
- run.click(generate, request, [result, upsampled, upsampled_panel], api_name="generate")
698
-
699
-
700
- if __name__ == "__main__":
701
- demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS)
 
1
+ """MiniMax-H3 `ref2va`, split deployment — the denoising half.
2
+
3
+ This Space holds the `transformer_ref` partition and the two autoencoders, unquantized bfloat16. Text encoding runs in
4
+ [`qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner), which this one calls over the
5
+ gradio API for every request; `reference_encoder` stays here, next to the autoencoders it runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ import time
14
+ import traceback
15
+ from functools import cache
16
+
17
+ # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
18
+ # startup rather than on GPU time.
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", "mpasila/qwen3vl-conditioner")
24
+ # `lazy` moves all 72.16 GiB onto the card on the first GPU call and leaves it there; `offload` hands placement to
25
+ # `ComponentsManager.enable_auto_cpu_offload`. Startup placement is not an option here — see `load_models`.
26
+ PLACEMENT = os.environ.get("H3_PLACEMENT", "lazy").lower()
27
+ # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
28
+ # flash-attention 3 is sm90-only and this card is sm120 (the `zero-a10g` flavour name is legacy).
29
+ ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
30
+ GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
31
+ # Bounds on what `get_duration` may reserve. The pool reserves whatever number it is given, so a flat ceiling for every
32
+ # request is what makes an account hit "too many ZeroGPU credits allocated to running tasks".
33
+ MIN_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MIN", "120"))
34
+ MAX_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MAX", "1500"))
35
+
36
+ # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
37
+ # is rejected there and surfaces as a failure here.
38
+ CANVASES = {
39
+ # 16:9
40
+ "960x544 · 16:9 fast": (544, 960),
41
+ "1024x576 · 16:9 fast": (576, 1024),
42
+ "1152x640 · 16:9": (640, 1152),
43
+ "1280x704 · 16:9": (704, 1280),
44
+ "1344x768 · 16:9 full": (768, 1344),
45
+ # 9:16
46
+ "544x960 · 9:16 fast": (960, 544),
47
+ "640x1152 · 9:16": (1152, 640),
48
+ "768x1344 · 9:16 full": (1344, 768),
49
+ # 1:1
50
+ "544x544 · 1:1 fast": (544, 544),
51
+ "768x768 · 1:1 full": (768, 768),
52
+ # 4:3 / 3:4
53
+ "768x576 · 4:3 fast": (576, 768),
54
+ "1024x768 · 4:3 full": (768, 1024),
55
+ "576x768 · 3:4 fast": (768, 576),
56
+ "768x1024 · 3:4 full": (1024, 768),
57
+ # 3:2 / 2:3
58
+ "864x576 · 3:2 fast": (576, 864),
59
+ "1152x768 · 3:2 full": (768, 1152),
60
+ "576x864 · 2:3 fast": (864, 576),
61
+ "768x1152 · 2:3 full": (1152, 768),
62
+ # 21:9
63
+ "1152x512 · 21:9 fast": (512, 1152),
64
+ "1536x672 · 21:9 full": (672, 1536),
65
+ }
66
+ DEFAULT_CANVAS = "960x544 · 16:9 fast"
67
+ FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
68
+ # It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
69
+ # 15.083 s, and is refused. 14 is the last whole second that survives the snap.
70
+ MAX_UI_DURATION = 14
71
+ MIN_DURATION = 2
72
+ # A reference video shorter than 2 s gives the model almost no motion to read.
73
+ MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
74
+ # `MINIMAX_H3_MAX_REFERENCE_IMAGES`. The slots are built up front and revealed one at a time, because a demo asking
75
+ # for two subjects should not open with nine boxes.
76
+ MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
77
+
78
+ MIN_STEPS = 4
79
+
80
+ # Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
81
+ # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
82
+ STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
83
+ # The lazy 72.16 GiB `PIPE.to("cuda")` a cold worker pays inside its first GPU call; every request carries it, because
84
+ # nothing here knows whether the worker it lands on is cold.
85
+ PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
86
+ AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
87
+ REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
88
+ DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
89
+
90
+
91
+ def snap_frames(seconds: float) -> int:
92
+ """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
93
+ frames = max(1, round(float(seconds) * FPS))
94
+ while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
95
+ frames += 1
96
+ return frames
97
+
98
+
99
+ def lower_duration_floor(seconds: float = MIN_DURATION) -> None:
100
+ """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
101
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
102
+
103
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
104
+
105
+
106
+ def video_latent_frames(num_frames: int) -> int:
107
+ """`17 * n + 5` frames become `5 * n + 2` video latents."""
108
+ return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2
109
+
110
+
111
+ def target_rows(height: int, width: int, num_frames: int) -> int:
112
+ """The generated rows of the packed sequence: video patched `(1, 2, 2)`, plus two audio rows per latent."""
113
+ video = video_latent_frames(num_frames) * (height // CANVAS_MULTIPLE) * (width // CANVAS_MULTIPLE)
114
+ return video + round(num_frames / FPS * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
115
+
116
+
117
+ def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
118
+ """The rows the reference blocks add, from metadata alone — no decode.
119
+
120
+ An image is resized to a 2048 pixel short edge and encoded as a single frame; a video is put on the canvas *its
121
+ own* aspect ratio resolves to, truncated to the generated frame count and snapped **down** to a `17 * n + 5` the
122
+ VAE encodes without padding; a soundtrack contributes two rows per 1/40 s.
123
+ """
124
+ from PIL import Image
125
+
126
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import resolve_canvas_size
127
+
128
+ rows = 0
129
+ for kind, path in references:
130
+ if kind == "image":
131
+ width, height = Image.open(path).size
132
+ scale = REFERENCE_IMAGE_SHORT_EDGE / min(width, height)
133
+ resolved = [
134
+ max(CANVAS_MULTIPLE, round(edge * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
135
+ for edge in (height, width)
136
+ ]
137
+ rows += (resolved[0] // CANVAS_MULTIPLE) * (resolved[1] // CANVAS_MULTIPLE)
138
+ continue
139
+
140
+ video_seconds, audio_seconds = probe(path)
141
+ if kind == "video" and video_seconds is not None:
142
+ import av
143
+
144
+ with av.open(path) as container:
145
+ stream = container.streams.video[0]
146
+ source_height, source_width = stream.height, stream.width
147
+ canvas_height, canvas_width = resolve_canvas_size(source_width, source_height, CANVAS_MULTIPLE)
148
+ frames = min(round(video_seconds * FPS), num_frames)
149
+ snapped = max(1, (frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) * FRAMES_PER_CHUNK + LATENTS_PER_CHUNK
150
+ rows += (
151
+ video_latent_frames(snapped)
152
+ * (canvas_height // CANVAS_MULTIPLE)
153
+ * (canvas_width // CANVAS_MULTIPLE)
154
+ )
155
+ if audio_seconds is not None:
156
+ seconds = min(audio_seconds, num_frames / FPS)
157
+ rows += round(seconds * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
158
+ return rows
159
+
160
+
161
+ def get_duration(
162
+ prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, **_
163
+ ):
164
+ """Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
165
+ tolerates the `gr.Progress` `spaces` injects."""
166
+ sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
167
+ height, width, num_frames
168
+ )
169
+ denoise = int(steps) * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY
170
+ # The two reference encoders ahead of the loop, and the two decoders plus the mux after it. Both scale with what
171
+ # they are handed rather than with the step count.
172
+ encode = 5 + reference_rows(references, num_frames) * 1e-3
173
+ decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
174
+ total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10
175
+ duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
176
+ print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
177
+ return duration
178
+
179
+
180
+ PIPE = None
181
+ MANAGER = None
182
+ LOAD_ERROR: str | None = None
183
+
184
+
185
+ def load_models() -> str | None:
186
+ """Load the denoising half at startup, but *not* onto the card.
187
+
188
+ `MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`, the two schedulers and
189
+ `video_processor`, so `load_components` fetches exactly those subfolders — `text_encoder/` and the `transformer/`
190
+ partition are never touched. Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a
191
+ bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
192
+
193
+ Nothing moves onto the card here, for storage rather than memory: `spaces`' startup `torch.pack()` writes every
194
+ startup-resident CUDA tensor to a second copy on disk, and 77.3 GB of weights plus its pack busts the 150 GB quota
195
+ (`OSError: [Errno 28] No space left on device` out of `os.posix_fallocate`, mid-pack).
196
+ """
197
+ global PIPE, MANAGER, LOAD_ERROR
198
+
199
+ if PIPE is not None or LOAD_ERROR is not None:
200
+ return LOAD_ERROR
201
+
202
+ started = time.time()
203
+ try:
204
+ import torch
205
+ from diffusers import ComponentsManager
206
+
207
+ from h3_split_blocks import MiniMaxH3Ref2VAGeneratorBlocks
208
+
209
+ lower_duration_floor()
210
+ manager = ComponentsManager()
211
+ blocks = MiniMaxH3Ref2VAGeneratorBlocks()
212
+ print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
213
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
214
+ pipe.load_components(dtype=torch.bfloat16)
215
+
216
+ # Both VAEs first, and explicitly. `set_attention_backend` also sets the registry's *global* backend, which
217
+ # every processor that was not stamped falls through to, and the float32 audio VAE has no cuDNN kernel:
218
+ # `RuntimeError: No available kernel. Aborting execution.` in its causal encoder attention, which only a
219
+ # reference soundtrack ever reaches.
220
+ pipe.vae.set_attention_backend("native")
221
+ pipe.audio_vae.set_attention_backend("native")
222
+ pipe.transformer_ref.set_attention_backend(ATTENTION)
223
+
224
+ # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
225
+ # worker. Off unless `H3_AOTI=1`. It is the *same* package the `transformer/` partition runs — the two configs
226
+ # are identical field for field and the compiled code carries no weights of either.
227
+ import h3_aoti
228
+
229
+ h3_aoti.maybe_load(pipe.transformer_ref)
230
+
231
+ if PLACEMENT == "offload":
232
+ manager.enable_auto_cpu_offload(device="cuda")
233
+ _arm_decode_hooks(pipe)
234
+
235
+ PIPE, MANAGER = pipe, manager
236
+ print(f"[ref2va] ready in {time.time() - started:.0f}s", flush=True)
237
+ except Exception as error:
238
+ traceback.print_exc()
239
+ LOAD_ERROR = (
240
+ f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
241
+ f"`{type(error).__name__}: {error}`"
242
+ )
243
+ return LOAD_ERROR
244
+
245
+
246
+ def _arm_decode_hooks(pipe):
247
+ """Make the offload hooks fire for the two VAEs.
248
+
249
+ `enable_auto_cpu_offload` wraps `forward`, and the reference-encoder and decode blocks call `vae.encode/decode(...)`
250
+ directly, so the hook never runs and the VAE is still on the host when the latents arrive on the card.
251
+ """
252
+ for name in ("vae", "audio_vae"):
253
+ module = getattr(pipe, name)
254
+ for method in ("encode", "decode"):
255
+ inner = getattr(module, method)
256
+
257
+ def armed(*args, _module=module, _inner=inner, **kwargs):
258
+ hook = getattr(_module, "_hf_hook", None)
259
+ if hook is not None:
260
+ hook.pre_forward(_module)
261
+ return _inner(*args, **kwargs)
262
+
263
+ setattr(module, method, armed)
264
+
265
+
266
+
267
+ @cache
268
+ def conditioner():
269
+ """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
270
+ conditioner's booking is billed to whoever asked for the video."""
271
+ from gradio_client import Client
272
+
273
+ return Client(CONDITIONER_SPACE)
274
+
275
+
276
+ def probe(path: str) -> tuple[float | None, float | None]:
277
+ """`(video seconds, audio seconds)` of a media file, either being `None` when the stream is absent."""
278
+ import av
279
+
280
+ def seconds(stream, container):
281
+ if stream.duration is not None and stream.time_base is not None:
282
+ return float(stream.duration * stream.time_base)
283
+ return None if container.duration is None else container.duration / av.time_base
284
+
285
+ with av.open(path) as container:
286
+ video = seconds(container.streams.video[0], container) if container.streams.video else None
287
+ audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
288
+ return video, audio
289
+
290
+
291
+ def _media_dimensions(path: str) -> tuple[int, int]:
292
+ """`(width, height)` of an image or a video file, from its first stream."""
293
+ from PIL import Image
294
+
295
+ try:
296
+ with Image.open(path) as image:
297
+ return image.size
298
+ except Exception:
299
+ pass
300
+ import av
301
+
302
+ with av.open(path) as container:
303
+ stream = container.streams.video[0]
304
+ return stream.width, stream.height
305
+
306
+
307
+ def closest_canvas(path: str | None) -> str | None:
308
+ """The canvas label whose aspect ratio is closest to a media file's, or `None` when the file is
309
+ missing or unreadable."""
310
+ if not path:
311
+ return None
312
+ try:
313
+ width, height = _media_dimensions(path)
314
+ except Exception:
315
+ return None
316
+ if not width or not height:
317
+ return None
318
+ target = width / height
319
+ return min(CANVASES, key=lambda label: abs(CANVASES[label][1] / CANVASES[label][0] - target))
320
+
321
+
322
+ def auto_canvas(path):
323
+ """Set the canvas to the closest aspect ratio of an uploaded image or video."""
324
+ label = closest_canvas(path)
325
+ return gr.update(value=label) if label else gr.update()
326
+
327
+
328
+ def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
329
+ """The `(kind, path)` references of a request, **in the order the model reads them**.
330
+
331
+ That order numbers the labels of MiniMax-H3's prompt presentation and advances the shared audio/video rotary clock,
332
+ so the same references in a different order are a different request.
333
+ """
334
+ ordered = [("image", path) for path in image_paths if path]
335
+ if audio_path:
336
+ ordered.append(("audio", audio_path))
337
+ if video_path:
338
+ ordered.append(("video", video_path))
339
+ return ordered
340
+
341
+
342
+ def build_references(references: list[tuple[str, str]]):
343
+ """The `(kind, path)` references of a request as decoded reference dataclasses, in packed order. `from_file` brings
344
+ the rates along: a video its own frame rate and soundtrack, a clip its sample rate."""
345
+ from diffusers.modular_pipelines.minimax_h3 import (
346
+ MiniMaxH3AudioReference,
347
+ MiniMaxH3ImageReference,
348
+ MiniMaxH3VideoReference,
349
+ )
350
+
351
+ classes = {"image": MiniMaxH3ImageReference, "video": MiniMaxH3VideoReference, "audio": MiniMaxH3AudioReference}
352
+ return [classes[kind].from_file(path) for kind, path in references]
353
+
354
+
355
+ def audio_bearing(references: list[tuple[str, str]]) -> list[tuple[str, float]]:
356
+ """The references that carry a waveform, and how long it is. A video reference brings its own soundtrack."""
357
+ carried = []
358
+ for kind, path in references:
359
+ if kind == "image":
360
+ continue
361
+ _, audio_seconds = probe(path)
362
+ if audio_seconds is not None:
363
+ carried.append((kind, audio_seconds))
364
+ return carried
365
+
366
+
367
+ def duration_controls(audio_path, video_path, match: bool):
368
+ """Show the duration slider unless a single soundtrack can set it, which is when MiniMax-H3 lets it be left out."""
369
+ try:
370
+ carried = audio_bearing(collect([], audio_path, video_path))
371
+ except Exception:
372
+ carried = []
373
+ # Exactly one soundtrack, long enough to be a duration MiniMax-H3 generates; anything else is ambiguous or out of
374
+ # range and the slider stays.
375
+ derivable = len(carried) == 1 and MIN_DURATION <= snap_frames(carried[0][1]) / FPS <= MAX_REFERENCE_VIDEO
376
+ return gr.update(visible=derivable), gr.update(visible=not (derivable and match))
377
+
378
+
379
+ def check(prompt: str, references: list[tuple[str, str]]) -> None:
380
+ """The model's own rules, before anything is uploaded or a card is allocated."""
381
+ if not prompt or not prompt.strip():
382
+ raise gr.Error("MiniMax-H3 always takes a prompt, references or not.")
383
+ if not references:
384
+ raise gr.Error("Add at least one reference — an image or a video for the model to condition on.")
385
+ if {kind for kind, _ in references} == {"audio"}:
386
+ raise gr.Error("An audio reference needs an image or a video alongside it; it cannot go on its own.")
387
+ for kind, path in references:
388
+ if kind != "video":
389
+ continue
390
+ video_seconds, _ = probe(path)
391
+ if video_seconds is None:
392
+ raise gr.Error("That reference video has no video stream. Drop it in the audio slot instead.")
393
+ if not MIN_REFERENCE_VIDEO <= video_seconds <= MAX_REFERENCE_VIDEO:
394
+ raise gr.Error(
395
+ f"The reference video is {video_seconds:.1f} s. Use a clip between "
396
+ f"{MIN_REFERENCE_VIDEO:g} and {MAX_REFERENCE_VIDEO:g} seconds."
397
+ )
398
+
399
+
400
+ def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
401
+ """`/encode_ref2va` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with
402
+ the resolved `height` / `width` / `num_frames` in its metadata, plus the plan.
403
+
404
+ `canvas` is the label. `media` and `kinds` are parallel and ordered, and the references go over because `ref2va`'s
405
+ presentation puts a vision block in front of the prompt for every image and every merged video frame pair.
406
+ """
407
+ from gradio_client import handle_file
408
+ from safetensors import safe_open
409
+
410
+ path, plan = conditioner().predict(
411
+ prompt=prompt,
412
+ media=[handle_file(path) for _, path in references],
413
+ kinds=",".join(kind for kind, _ in references),
414
+ canvas=canvas,
415
+ num_frames=num_frames,
416
+ rewrite_prompt=bool(rewrite_prompt),
417
+ api_name="/encode_ref2va",
418
+ )
419
+ with safe_open(path, framework="pt") as handle:
420
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
421
+
422
+
 
423
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
424
+ def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=()):
425
+ """The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
426
+ References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
427
+ argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
428
+ the full `PipelineState` still holds the packed latents and the rotary grid on the card.
429
+ The adapters are attached here rather than in the caller: `spaces` runs this body in its own worker, so the
430
+ transformer the request sees is the one that has to carry them.
431
+ """
432
+ import torch
433
+ if PLACEMENT == "lazy":
434
+ PIPE.to("cuda")
435
+ apply_loras(PIPE.transformer_ref, loras or ())
436
+ state = PIPE(
437
+ prompt_embeds=prompt_embeds.to("cuda"),
438
+ text_token_tags=text_token_tags,
439
+ references=build_references(references),
440
+ height=height,
441
+ width=width,
442
+ num_frames=num_frames,
443
+ num_inference_steps=int(steps),
444
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
445
+ )
446
+ return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
447
+
448
+
449
+ def generate(
450
+ prompt,
451
+ image_1=None,
452
+ audio_path=None,
453
+ video_path=None,
454
+ canvas=DEFAULT_CANVAS,
455
+ image_2=None,
456
+ image_3=None,
457
+ image_4=None,
458
+ image_5=None,
459
+ image_6=None,
460
+ image_7=None,
461
+ image_8=None,
462
+ image_9=None,
463
+ match=True,
464
+ duration=5,
465
+ steps=28,
466
+ seed=42,
467
+ upsample=False,
468
+ progress=gr.Progress(track_tqdm=True),
469
+ ):
470
+ """One request."""
471
+ if LOAD_ERROR:
472
+ raise gr.Error(LOAD_ERROR)
473
+ if PIPE is None:
474
+ raise gr.Error("The denoiser is still loading.")
475
+
476
+ from diffusers.utils import encode_video
477
+
478
+ images = [image_1, image_2, image_3, image_4, image_5, image_6, image_7, image_8, image_9]
479
+ references = collect(images, audio_path, video_path)
480
+ check(prompt, references)
481
+
482
+ # `0` is "leave it to the references" over the wire, which MiniMax-H3 accepts when exactly one of them carries a
483
+ # soundtrack. The conditioner resolves it either way and this Space pins whatever comes back.
484
+ derivable = len(audio_bearing(references)) == 1
485
+ requested = 0 if (match and derivable) else snap_frames(duration)
486
+
487
+ progress(0.0, desc="Upsampling the prompt ..." if upsample else "Reading the prompt and references ...")
488
+ conditioned = time.time()
489
+ try:
490
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(
491
+ prompt, references, canvas, requested, rewrite_prompt=upsample
492
+ )
493
+ except gr.Error:
494
+ raise
495
+ except Exception as error:
496
+ # gradio only puts the exception *type* on the wire, so the useful half of a conditioner-side failure is in
497
+ # that Space's logs.
498
+ traceback.print_exc()
499
+ raise gr.Error(
500
+ f"The conditioner ({CONDITIONER_SPACE}) failed with `{type(error).__name__}: {error}`. "
501
+ "Its logs carry the full traceback."
502
+ ) from error
503
+ condition_seconds = time.time() - conditioned
504
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
505
+ refined = plan.get("refined_prompt") or ""
506
+
507
+ progress(0.1, desc=f"Generating {num_frames / FPS:.1f} s at {width}x{height} ...")
508
+ started = time.time()
509
+ frames, audio, sampling_rate = _generate(
510
+ prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed
511
+ )
512
+ generate_seconds = time.time() - started
513
+
514
+ directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
515
+ os.makedirs(directory, exist_ok=True)
516
+ path = os.path.join(directory, f"h3-ref2va-{int(time.time() * 1000)}.mp4")
517
+ encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
518
+
519
+ print(
520
+ f"[ref2va] {[kind for kind, _ in references]} · `{width}x{height}`, {num_frames} frames "
521
+ f"({num_frames / FPS:.3f} s), {int(steps)} steps · conditioner {condition_seconds:.0f}s "
522
+ f"({plan['num_text_tokens']} tokens{', upsampled' if refined else ''}) · "
523
+ f"denoise + decode {generate_seconds:.0f}s "
524
+ f"({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}",
525
+ flush=True,
526
+ )
527
+ return path, refined, gr.update(visible=bool(refined))
528
+
529
+
530
+ # ----------------------------------------------------------------------------------------------------------------
531
+ # Settings file
532
+ # ----------------------------------------------------------------------------------------------------------------
533
+ # Everything typed rather than uploaded, so a session can be picked up where it was left off. The references
534
+ # themselves are deliberately left out: gradio hands them over as paths into a per-session temporary directory that
535
+ # is gone by the next visit, so a saved path would restore as a dead file rather than as the image.
536
+
537
+ SETTINGS_VERSION = 1
538
+ SETTINGS_KEYS = ["prompt", "upsample", "canvas", "match", "duration", "steps", "seed"]
539
+
540
+
541
+ def save_settings(*values):
542
+ """Write the current controls to a `.json` and reveal it for download."""
543
+ payload = {"version": SETTINGS_VERSION, "saved": time.strftime("%Y-%m-%d %H:%M:%S")}
544
+ payload.update(dict(zip(SETTINGS_KEYS, values)))
545
+
546
+ directory = os.path.join(tempfile.gettempdir(), "h3-settings")
547
+ os.makedirs(directory, exist_ok=True)
548
+ path = os.path.join(directory, f"h3-settings-{int(time.time())}.json")
549
+ with open(path, "w", encoding="utf-8") as handle:
550
+ json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
551
+ return gr.update(value=path, visible=True)
552
+
553
+
554
+ def load_settings(path):
555
+ """Restore the controls from a `.json`. A key the file does not carry leaves its control alone, so a settings
556
+ file written by an older version of this Space still loads."""
557
+ if not path:
558
+ return [gr.update() for _ in SETTINGS_KEYS]
559
+ try:
560
+ with open(path, encoding="utf-8") as handle:
561
+ payload = json.load(handle)
562
+ except Exception as error:
563
+ raise gr.Error(f"Файлът с настройки не се чете: `{type(error).__name__}: {error}`")
564
+ if not isinstance(payload, dict):
565
+ raise gr.Error("Това не е файл с настройки на този Space.")
566
+
567
+ updates = []
568
+ for key in SETTINGS_KEYS:
569
+ value = payload.get(key)
570
+ # An unknown canvas label would be rejected by the conditioner, which is the wrong place to find out.
571
+ if value is None or (key == "canvas" and value not in CANVASES):
572
+ updates.append(gr.update())
573
+ else:
574
+ updates.append(gr.update(value=value))
575
+ return updates
576
+
577
+
578
+ load_models()
579
+
580
+ INTRO = """# MiniMax-H3 Reference
581
+
582
+ <div align="center">
583
+ <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
584
+ <a href="https://www.minimax.io/blog/minimax-h3" target="_blank" rel="noopener"><strong>[ blog ]</strong></a> &nbsp;
585
+ <a href="https://huggingface.co/spaces/multimodalart/minimax-h3" target="_blank" rel="noopener"><strong>[ text / image to video ]</strong></a>
586
+ </div>
587
+
588
+ **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
589
+ fully synchronized soundtrack (ambience, foley, speech). Bring your own subject, voice or camera move as a
590
+ reference.
591
+ """
592
+
593
+ CSS = """
594
+ .main.fillable { max-width: 1250px !important; }
595
+ .dark .gradio-container { color: var(--body-text-color); }
596
+ """
597
+
598
+ with gr.Blocks(title="MiniMax-H3 Reference Custom Lora") as demo:
599
+ gr.Markdown(INTRO)
600
+
601
+ with gr.Row():
602
+ with gr.Column():
603
+ prompt = gr.Textbox(
604
+ label="Prompt",
605
+ lines=3,
606
+ value="The character walks through a neon-lit street in the rain, humming to themselves",
607
+ )
608
+ upsample = gr.Checkbox(label="Upsample prompt", value=False)
609
+ # One tab per modality, in the order the model reads them. A reference left in a tab that is not the open
610
+ # one is still part of the request.
611
+ with gr.Tabs():
612
+ with gr.Tab("Images"):
613
+ # One `gr.Row`, so gradio splits the width evenly and wraps at `min_width` rather than leaving a
614
+ # hole where a hidden slot used to be.
615
+ with gr.Row():
616
+ images = [
617
+ gr.Image(
618
+ label="Subject, style or scene",
619
+ type="filepath",
620
+ min_width=180,
621
+ # Fixed, so a row that wraps to a single slot stays the size of a full one.
622
+ height=210,
623
+ visible=index < OPEN_IMAGE_SLOTS,
624
+ )
625
+ for index in range(MAX_IMAGE_SLOTS)
626
+ ]
627
+ add_image = gr.Button("+ Add another image", size="sm", variant="secondary")
628
+ with gr.Tab("Audio"):
629
+ audio = gr.Audio(label="A voice or a piece of music", type="filepath")
630
+ with gr.Tab("Video"):
631
+ video = gr.Video(label="Motion & camera, 2–15 s. Its soundtrack comes along.")
632
+ run = gr.Button("Generate", variant="primary")
633
+ with gr.Accordion("Advanced options", open=False):
634
+ canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
635
+ match = gr.Checkbox(label="Match the reference soundtrack", value=True, visible=False)
636
+ duration = gr.Slider(
637
+ label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
638
+ )
639
+ steps = gr.Slider(label="Steps", minimum=MIN_STEPS, maximum=40, step=1, value=28)
640
+ seed = gr.Number(label="Seed", value=42, precision=0)
641
+
642
+ with gr.Accordion("Settings file", open=False):
643
+ gr.Markdown(SETTINGS_HELP)
644
+ save = gr.Button("Save settings to .json", size="sm")
645
+ settings_download = gr.File(label="Your settings", visible=False, interactive=False)
646
+ settings_upload = gr.File(
647
+ label="Load a settings .json", file_types=[".json"], type="filepath"
648
+ )
649
+
650
+ with gr.Column():
651
+ result = gr.Video(label="Video + soundtrack")
652
+ # An output, so it can be revealed only for a request that asked for a rewrite.
653
+ with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
654
+ upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
655
+
656
+ open_slots = gr.State(OPEN_IMAGE_SLOTS)
657
+
658
+ def reveal_image_slot(open_count):
659
+ open_count = min(open_count + 1, MAX_IMAGE_SLOTS)
660
+ return [
661
+ open_count,
662
+ *[gr.update(visible=index < open_count) for index in range(MAX_IMAGE_SLOTS)],
663
+ gr.update(visible=open_count < MAX_IMAGE_SLOTS),
664
+ ]
665
+
666
+ add_image.click(reveal_image_slot, open_slots, [open_slots, *images, add_image], api_name=False)
667
+
668
+ for control in (audio, video, match):
669
+ control.change(
670
+ duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
671
+ )
672
+
673
+ # Auto-select the canvas whose aspect ratio is closest to an uploaded image or video.
674
+ for image in images:
675
+ image.change(auto_canvas, image, canvas, show_progress="hidden", api_name=False)
676
+ video.change(auto_canvas, video, canvas, show_progress="hidden", api_name=False)
677
+
678
+ # `reference, strength, reference, strength, ...`, which is how `generate` unpacks them.
679
+ lora_inputs = [field for pair in zip(lora_references, lora_scales) for field in pair]
680
+ lora_upload.upload(_fill_lora_slots, [lora_upload, *lora_references], lora_references, api_name=False)
681
+ lora_preset_add.click(
682
+ _add_preset_lora,
683
+ [lora_preset, *lora_references, *lora_scales],
684
+ [*lora_references, *lora_scales, steps],
685
+ api_name=False,
686
+ )
687
+
688
+ # Same order as `SETTINGS_KEYS`.
689
+ settings_fields = [prompt, upsample, canvas, match, duration, steps, seed]
690
+ save.click(save_settings, settings_fields, settings_download, api_name=False)
691
+ settings_upload.upload(load_settings, settings_upload, settings_fields, api_name=False)
692
+ # Same order as `generate`'s signature: the five leading columns first, then the remaining image slots.
693
+ request = [
694
+ prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample
695
+ ]
696
+ run.click(generate, request, [result, upsampled, upsampled_panel], api_name="generate")
697
+
698
+
699
+ if __name__ == "__main__":
700
+ demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS)