mpasila commited on
Commit
e7c3a50
·
verified ·
1 Parent(s): 9c9e9f5

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +898 -837
app.py CHANGED
@@ -1,837 +1,898 @@
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", "multimodalart/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
- # 21:9
58
- "1152x512 · 21:9 fast": (512, 1152),
59
- "1536x672 · 21:9 full": (672, 1536),
60
- }
61
- DEFAULT_CANVAS = "960x544 · 16:9 fast"
62
- FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
63
- # It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
64
- # 15.083 s, and is refused. 14 is the last whole second that survives the snap.
65
- MAX_UI_DURATION = 14
66
- MIN_DURATION = 2
67
- # A reference video shorter than 2 s gives the model almost no motion to read.
68
- MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
69
- # `MINIMAX_H3_MAX_REFERENCE_IMAGES`. The slots are built up front and revealed one at a time, because a demo asking
70
- # for two subjects should not open with nine boxes.
71
- MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
72
-
73
- # How many LoRA slots the UI offers, and the range each strength slider covers.
74
- LORA_SLOTS = 3
75
- LORA_MIN_SCALE, LORA_MAX_SCALE = -2.0, 2.0
76
-
77
- # Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
78
- # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
79
- STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
80
- # The lazy 72.16 GiB `PIPE.to("cuda")` a cold worker pays inside its first GPU call; every request carries it, because
81
- # nothing here knows whether the worker it lands on is cold.
82
- PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
83
- AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
84
- REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
85
- DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
86
- # Reading one adapter off local disk and injecting it across the 33B transformer's linear layers.
87
- LORA_ALLOWANCE = 12
88
-
89
-
90
- def snap_frames(seconds: float) -> int:
91
- """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
92
- frames = max(1, round(float(seconds) * FPS))
93
- while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
94
- frames += 1
95
- return frames
96
-
97
-
98
- def lower_duration_floor(seconds: float = MIN_DURATION) -> None:
99
- """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
100
- from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
101
-
102
- MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
103
-
104
-
105
- def video_latent_frames(num_frames: int) -> int:
106
- """`17 * n + 5` frames become `5 * n + 2` video latents."""
107
- return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2
108
-
109
-
110
- def target_rows(height: int, width: int, num_frames: int) -> int:
111
- """The generated rows of the packed sequence: video patched `(1, 2, 2)`, plus two audio rows per latent."""
112
- video = video_latent_frames(num_frames) * (height // CANVAS_MULTIPLE) * (width // CANVAS_MULTIPLE)
113
- return video + round(num_frames / FPS * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
114
-
115
-
116
- def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
117
- """The rows the reference blocks add, from metadata alone — no decode.
118
-
119
- An image is resized to a 2048 pixel short edge and encoded as a single frame; a video is put on the canvas *its
120
- own* aspect ratio resolves to, truncated to the generated frame count and snapped **down** to a `17 * n + 5` the
121
- VAE encodes without padding; a soundtrack contributes two rows per 1/40 s.
122
- """
123
- from PIL import Image
124
-
125
- from diffusers.modular_pipelines.minimax_h3.modular_pipeline import resolve_canvas_size
126
-
127
- rows = 0
128
- for kind, path in references:
129
- if kind == "image":
130
- width, height = Image.open(path).size
131
- scale = REFERENCE_IMAGE_SHORT_EDGE / min(width, height)
132
- resolved = [
133
- max(CANVAS_MULTIPLE, round(edge * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
134
- for edge in (height, width)
135
- ]
136
- rows += (resolved[0] // CANVAS_MULTIPLE) * (resolved[1] // CANVAS_MULTIPLE)
137
- continue
138
-
139
- video_seconds, audio_seconds = probe(path)
140
- if kind == "video" and video_seconds is not None:
141
- import av
142
-
143
- with av.open(path) as container:
144
- stream = container.streams.video[0]
145
- source_height, source_width = stream.height, stream.width
146
- canvas_height, canvas_width = resolve_canvas_size(source_width, source_height, CANVAS_MULTIPLE)
147
- frames = min(round(video_seconds * FPS), num_frames)
148
- snapped = max(1, (frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) * FRAMES_PER_CHUNK + LATENTS_PER_CHUNK
149
- rows += (
150
- video_latent_frames(snapped)
151
- * (canvas_height // CANVAS_MULTIPLE)
152
- * (canvas_width // CANVAS_MULTIPLE)
153
- )
154
- if audio_seconds is not None:
155
- seconds = min(audio_seconds, num_frames / FPS)
156
- rows += round(seconds * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
157
- return rows
158
-
159
-
160
- def get_duration(
161
- prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=(), **_
162
- ):
163
- """Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
164
- tolerates the `gr.Progress` `spaces` injects."""
165
- sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
166
- height, width, num_frames
167
- )
168
- denoise = int(steps) * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY
169
- # The two reference encoders ahead of the loop, and the two decoders plus the mux after it. Both scale with what
170
- # they are handed rather than with the step count.
171
- encode = 5 + reference_rows(references, num_frames) * 1e-3
172
- decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
173
- total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10 + LORA_ALLOWANCE * len(loras or ())
174
- duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
175
- print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
176
- return duration
177
-
178
-
179
- PIPE = None
180
- MANAGER = None
181
- LOAD_ERROR: str | None = None
182
-
183
-
184
- def load_models() -> str | None:
185
- """Load the denoising half at startup, but *not* onto the card.
186
-
187
- `MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`, the two schedulers and
188
- `video_processor`, so `load_components` fetches exactly those subfolders — `text_encoder/` and the `transformer/`
189
- partition are never touched. Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a
190
- bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
191
-
192
- Nothing moves onto the card here, for storage rather than memory: `spaces`' startup `torch.pack()` writes every
193
- startup-resident CUDA tensor to a second copy on disk, and 77.3 GB of weights plus its pack busts the 150 GB quota
194
- (`OSError: [Errno 28] No space left on device` out of `os.posix_fallocate`, mid-pack).
195
- """
196
- global PIPE, MANAGER, LOAD_ERROR
197
-
198
- if PIPE is not None or LOAD_ERROR is not None:
199
- return LOAD_ERROR
200
-
201
- started = time.time()
202
- try:
203
- import torch
204
- from diffusers import ComponentsManager
205
-
206
- from h3_split_blocks import MiniMaxH3Ref2VAGeneratorBlocks
207
-
208
- lower_duration_floor()
209
- manager = ComponentsManager()
210
- blocks = MiniMaxH3Ref2VAGeneratorBlocks()
211
- print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
212
- pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
213
- pipe.load_components(dtype=torch.bfloat16)
214
-
215
- # Both VAEs first, and explicitly. `set_attention_backend` also sets the registry's *global* backend, which
216
- # every processor that was not stamped falls through to, and the float32 audio VAE has no cuDNN kernel:
217
- # `RuntimeError: No available kernel. Aborting execution.` in its causal encoder attention, which only a
218
- # reference soundtrack ever reaches.
219
- pipe.vae.set_attention_backend("native")
220
- pipe.audio_vae.set_attention_backend("native")
221
- pipe.transformer_ref.set_attention_backend(ATTENTION)
222
-
223
- # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
224
- # worker. Off unless `H3_AOTI=1`. It is the *same* package the `transformer/` partition runs — the two configs
225
- # are identical field for field and the compiled code carries no weights of either.
226
- import h3_aoti
227
-
228
- h3_aoti.maybe_load(pipe.transformer_ref)
229
-
230
- if PLACEMENT == "offload":
231
- manager.enable_auto_cpu_offload(device="cuda")
232
- _arm_decode_hooks(pipe)
233
-
234
- PIPE, MANAGER = pipe, manager
235
- print(f"[ref2va] ready in {time.time() - started:.0f}s", flush=True)
236
- except Exception as error:
237
- traceback.print_exc()
238
- LOAD_ERROR = (
239
- f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
240
- f"`{type(error).__name__}: {error}`"
241
- )
242
- return LOAD_ERROR
243
-
244
-
245
- def _arm_decode_hooks(pipe):
246
- """Make the offload hooks fire for the two VAEs.
247
-
248
- `enable_auto_cpu_offload` wraps `forward`, and the reference-encoder and decode blocks call `vae.encode/decode(...)`
249
- directly, so the hook never runs and the VAE is still on the host when the latents arrive on the card.
250
- """
251
- for name in ("vae", "audio_vae"):
252
- module = getattr(pipe, name)
253
- for method in ("encode", "decode"):
254
- inner = getattr(module, method)
255
-
256
- def armed(*args, _module=module, _inner=inner, **kwargs):
257
- hook = getattr(_module, "_hf_hook", None)
258
- if hook is not None:
259
- hook.pre_forward(_module)
260
- return _inner(*args, **kwargs)
261
-
262
- setattr(module, method, armed)
263
-
264
-
265
- # ----------------------------------------------------------------------------------------------------------------
266
- # LoRA
267
- # ----------------------------------------------------------------------------------------------------------------
268
- # There is no `MiniMaxH3LoraLoaderMixin` in the diffusers integration, so adapters are attached at the *model* level,
269
- # through the `PeftAdapterMixin` the transformer carries. That is the whole API this needs: `load_lora_adapter` for
270
- # each file and one `set_adapters` call to give them their strengths. Here the model is `transformer_ref`, so the
271
- # adapters have to be trained against the `transformer_ref/` partition — a `transformer/` adapter is a different
272
- # partition and will not match.
273
-
274
-
275
- def _hub_url_parts(url: str) -> tuple[str, str]:
276
- """Split a huggingface.co `blob`/`resolve` URL into its repo id and the file path inside it."""
277
- from urllib.parse import unquote, urlparse
278
-
279
- parts = unquote(urlparse(url).path).strip("/").split("/")
280
- if len(parts) < 5 or parts[2] not in ("resolve", "blob"):
281
- raise gr.Error(f"Не разпознавам този адрес като файл в Hugging Face: `{url}`")
282
- return "/".join(parts[:2]), "/".join(parts[4:])
283
-
284
-
285
- def resolve_lora(reference: str) -> str:
286
- """Turn what the user typed into a local `.safetensors` path.
287
-
288
- Accepts a local path, a huggingface.co file URL, `owner/repo/path/to/file.safetensors`, or a bare `owner/repo`
289
- whose single `.safetensors` is then picked for them. Runs outside the GPU call, so the download costs no GPU time.
290
- """
291
- from huggingface_hub import hf_hub_download, list_repo_files
292
-
293
- reference = (reference or "").strip()
294
- if not reference:
295
- return ""
296
- if os.path.exists(reference):
297
- return reference
298
- if reference.startswith(("http://", "https://")):
299
- repo_id, filename = _hub_url_parts(reference)
300
- return hf_hub_download(repo_id, filename)
301
-
302
- parts = [part for part in reference.split("/") if part]
303
- if len(parts) > 2 and parts[-1].endswith(".safetensors"):
304
- return hf_hub_download("/".join(parts[:2]), "/".join(parts[2:]))
305
- if len(parts) != 2:
306
- raise gr.Error(
307
- f"`{reference}` не е нито съществуващ файл, нито `автор/хранилище`, нито адрес към Hugging Face."
308
- )
309
-
310
- candidates = [name for name in list_repo_files(reference) if name.endswith(".safetensors")]
311
- if not candidates:
312
- raise gr.Error(f"В `{reference}` няма `.safetensors` файл.")
313
- if len(candidates) > 1:
314
- preferred = [name for name in candidates if "lora" in name.lower()]
315
- if len(preferred) != 1:
316
- listed = ", ".join(f"`{name}`" for name in sorted(candidates)[:8])
317
- raise gr.Error(f"`{reference}` съдържа няколко файла. Напиши `{reference}/име.safetensors`. Има: {listed}")
318
- candidates = preferred
319
- return hf_hub_download(reference, candidates[0])
320
-
321
-
322
- def _lora_prefix(state_dict) -> str | None:
323
- """The prefix `load_lora_adapter` has to strip before the keys match the transformer's own module names."""
324
- key = next(iter(state_dict))
325
- for prefix in ("model.diffusion_model", "diffusion_model", "transformer_ref", "transformer"):
326
- if key.startswith(f"{prefix}."):
327
- return prefix
328
- return None
329
-
330
-
331
- def apply_loras(transformer, loras) -> list[str]:
332
- """Attach `loras` (local path, strength) to `transformer` and give each its strength, replacing whatever was on it.
333
-
334
- Every adapter already on the model is removed first, so a request is never affected by the one before it — which
335
- matters when a worker is reused rather than forked fresh.
336
- """
337
- import torch
338
-
339
- from safetensors.torch import load_file
340
-
341
- for name in list(getattr(transformer, "peft_config", None) or {}):
342
- transformer.delete_adapters(name)
343
-
344
- names, scales = [], []
345
- for index, (path, scale) in enumerate(loras):
346
- state_dict = load_file(path)
347
- name = f"lora{index}"
348
- transformer.load_lora_adapter(state_dict, adapter_name=name, prefix=_lora_prefix(state_dict))
349
- names.append(name)
350
- scales.append(float(scale))
351
-
352
- if not names:
353
- return []
354
-
355
- # PEFT builds the new layers on its own default device/dtype; the base weights are the truth here, under either
356
- # placement mode (`offload` keeps them on the host and moves whole modules by hook).
357
- base = next(param for key, param in transformer.named_parameters() if ".lora_" not in key)
358
- with torch.no_grad():
359
- for key, param in transformer.named_parameters():
360
- if ".lora_" in key and (param.device != base.device or param.dtype != base.dtype):
361
- param.data = param.data.to(device=base.device, dtype=base.dtype)
362
-
363
- transformer.set_adapters(names, scales)
364
- return names
365
-
366
-
367
- def collect_loras(lora_fields, progress) -> tuple[list[tuple[str, float]], list[str]]:
368
- """Resolve the UI's `reference, strength, reference, strength, ...` into `(local path, strength)` pairs.
369
-
370
- Resolved before the booking: a download that happens inside `@spaces.GPU` is billed as GPU time.
371
- """
372
- loras, labels = [], []
373
- for reference, scale in zip(lora_fields[::2], lora_fields[1::2]):
374
- reference = (reference or "").strip()
375
- if not reference or abs(float(scale)) < 1e-6:
376
- continue
377
- progress(0.0, desc=f"Fetching LoRA {reference} ...")
378
- loras.append((resolve_lora(reference), float(scale)))
379
- labels.append(f"{os.path.basename(reference)} @ {float(scale):g}")
380
- if loras and os.environ.get("H3_AOTI") == "1":
381
- raise gr.Error("LoRA не може да се приложи върху AoTI компилиран трансформър. Изключи `H3_AOTI`.")
382
- return loras, labels
383
-
384
-
385
- @cache
386
- def conditioner():
387
- """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
388
- conditioner's booking is billed to whoever asked for the video."""
389
- from gradio_client import Client
390
-
391
- return Client(CONDITIONER_SPACE)
392
-
393
-
394
- def probe(path: str) -> tuple[float | None, float | None]:
395
- """`(video seconds, audio seconds)` of a media file, either being `None` when the stream is absent."""
396
- import av
397
-
398
- def seconds(stream, container):
399
- if stream.duration is not None and stream.time_base is not None:
400
- return float(stream.duration * stream.time_base)
401
- return None if container.duration is None else container.duration / av.time_base
402
-
403
- with av.open(path) as container:
404
- video = seconds(container.streams.video[0], container) if container.streams.video else None
405
- audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
406
- return video, audio
407
-
408
-
409
- def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
410
- """The `(kind, path)` references of a request, **in the order the model reads them**.
411
-
412
- That order numbers the labels of MiniMax-H3's prompt presentation and advances the shared audio/video rotary clock,
413
- so the same references in a different order are a different request.
414
- """
415
- ordered = [("image", path) for path in image_paths if path]
416
- if audio_path:
417
- ordered.append(("audio", audio_path))
418
- if video_path:
419
- ordered.append(("video", video_path))
420
- return ordered
421
-
422
-
423
- def build_references(references: list[tuple[str, str]]):
424
- """The `(kind, path)` references of a request as decoded reference dataclasses, in packed order. `from_file` brings
425
- the rates along: a video its own frame rate and soundtrack, a clip its sample rate."""
426
- from diffusers.modular_pipelines.minimax_h3 import (
427
- MiniMaxH3AudioReference,
428
- MiniMaxH3ImageReference,
429
- MiniMaxH3VideoReference,
430
- )
431
-
432
- classes = {"image": MiniMaxH3ImageReference, "video": MiniMaxH3VideoReference, "audio": MiniMaxH3AudioReference}
433
- return [classes[kind].from_file(path) for kind, path in references]
434
-
435
-
436
- def audio_bearing(references: list[tuple[str, str]]) -> list[tuple[str, float]]:
437
- """The references that carry a waveform, and how long it is. A video reference brings its own soundtrack."""
438
- carried = []
439
- for kind, path in references:
440
- if kind == "image":
441
- continue
442
- _, audio_seconds = probe(path)
443
- if audio_seconds is not None:
444
- carried.append((kind, audio_seconds))
445
- return carried
446
-
447
-
448
- def duration_controls(audio_path, video_path, match: bool):
449
- """Show the duration slider unless a single soundtrack can set it, which is when MiniMax-H3 lets it be left out."""
450
- try:
451
- carried = audio_bearing(collect([], audio_path, video_path))
452
- except Exception:
453
- carried = []
454
- # Exactly one soundtrack, long enough to be a duration MiniMax-H3 generates; anything else is ambiguous or out of
455
- # range and the slider stays.
456
- derivable = len(carried) == 1 and MIN_DURATION <= snap_frames(carried[0][1]) / FPS <= MAX_REFERENCE_VIDEO
457
- return gr.update(visible=derivable), gr.update(visible=not (derivable and match))
458
-
459
-
460
- def check(prompt: str, references: list[tuple[str, str]]) -> None:
461
- """The model's own rules, before anything is uploaded or a card is allocated."""
462
- if not prompt or not prompt.strip():
463
- raise gr.Error("MiniMax-H3 always takes a prompt, references or not.")
464
- if not references:
465
- raise gr.Error("Add at least one reference — an image or a video for the model to condition on.")
466
- if {kind for kind, _ in references} == {"audio"}:
467
- raise gr.Error("An audio reference needs an image or a video alongside it; it cannot go on its own.")
468
- for kind, path in references:
469
- if kind != "video":
470
- continue
471
- video_seconds, _ = probe(path)
472
- if video_seconds is None:
473
- raise gr.Error("That reference video has no video stream. Drop it in the audio slot instead.")
474
- if not MIN_REFERENCE_VIDEO <= video_seconds <= MAX_REFERENCE_VIDEO:
475
- raise gr.Error(
476
- f"The reference video is {video_seconds:.1f} s. Use a clip between "
477
- f"{MIN_REFERENCE_VIDEO:g} and {MAX_REFERENCE_VIDEO:g} seconds."
478
- )
479
-
480
-
481
- def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
482
- """`/encode_ref2va` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with
483
- the resolved `height` / `width` / `num_frames` in its metadata, plus the plan.
484
-
485
- `canvas` is the label. `media` and `kinds` are parallel and ordered, and the references go over because `ref2va`'s
486
- presentation puts a vision block in front of the prompt for every image and every merged video frame pair.
487
- """
488
- from gradio_client import handle_file
489
- from safetensors import safe_open
490
-
491
- path, plan = conditioner().predict(
492
- prompt=prompt,
493
- media=[handle_file(path) for _, path in references],
494
- kinds=",".join(kind for kind, _ in references),
495
- canvas=canvas,
496
- num_frames=num_frames,
497
- rewrite_prompt=bool(rewrite_prompt),
498
- api_name="/encode_ref2va",
499
- )
500
- with safe_open(path, framework="pt") as handle:
501
- return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
502
-
503
-
504
- @spaces.GPU(duration=get_duration, size=GPU_SIZE)
505
- def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=()):
506
- """The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
507
-
508
- References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
509
- argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
510
- the full `PipelineState` still holds the packed latents and the rotary grid on the card.
511
-
512
- The adapters are attached here rather than in the caller: `spaces` runs this body in its own worker, so the
513
- transformer the request sees is the one that has to carry them.
514
- """
515
- import torch
516
-
517
- if PLACEMENT == "lazy":
518
- PIPE.to("cuda")
519
-
520
- apply_loras(PIPE.transformer_ref, loras or ())
521
-
522
- state = PIPE(
523
- prompt_embeds=prompt_embeds.to("cuda"),
524
- text_token_tags=text_token_tags,
525
- references=build_references(references),
526
- height=height,
527
- width=width,
528
- num_frames=num_frames,
529
- num_inference_steps=int(steps),
530
- generator=torch.Generator("cpu").manual_seed(int(seed)),
531
- )
532
- return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
533
-
534
-
535
- def generate(
536
- # Every parameter after `prompt` has a default, and the newest ones sit at the end, so a positional API client
537
- # written against an older signature keeps working.
538
- prompt,
539
- image_1=None,
540
- audio_path=None,
541
- video_path=None,
542
- canvas=DEFAULT_CANVAS,
543
- image_2=None,
544
- image_3=None,
545
- image_4=None,
546
- image_5=None,
547
- image_6=None,
548
- image_7=None,
549
- image_8=None,
550
- image_9=None,
551
- match=True,
552
- duration=5,
553
- steps=28,
554
- seed=42,
555
- upsample=False,
556
- *lora_fields,
557
- progress=gr.Progress(track_tqdm=True),
558
- ):
559
- """One request. The LoRA fields are last and default to empty, so a positional API client that predates them is
560
- unaffected. `lora_fields` arrives as `reference, strength, reference, strength, ...`."""
561
- if LOAD_ERROR:
562
- raise gr.Error(LOAD_ERROR)
563
- if PIPE is None:
564
- raise gr.Error("The denoiser is still loading.")
565
-
566
- from diffusers.utils import encode_video
567
-
568
- images = [image_1, image_2, image_3, image_4, image_5, image_6, image_7, image_8, image_9]
569
- references = collect(images, audio_path, video_path)
570
- check(prompt, references)
571
-
572
- # `0` is "leave it to the references" over the wire, which MiniMax-H3 accepts when exactly one of them carries a
573
- # soundtrack. The conditioner resolves it either way and this Space pins whatever comes back.
574
- derivable = len(audio_bearing(references)) == 1
575
- requested = 0 if (match and derivable) else snap_frames(duration)
576
-
577
- loras, lora_labels = collect_loras(lora_fields, progress)
578
-
579
- progress(0.0, desc="Upsampling the prompt ..." if upsample else "Reading the prompt and references ...")
580
- conditioned = time.time()
581
- try:
582
- prompt_embeds, text_token_tags, metadata, plan = encode_remote(
583
- prompt, references, canvas, requested, rewrite_prompt=upsample
584
- )
585
- except gr.Error:
586
- raise
587
- except Exception as error:
588
- # gradio only puts the exception *type* on the wire, so the useful half of a conditioner-side failure is in
589
- # that Space's logs.
590
- traceback.print_exc()
591
- raise gr.Error(
592
- f"The conditioner ({CONDITIONER_SPACE}) failed with `{type(error).__name__}: {error}`. "
593
- "Its logs carry the full traceback."
594
- ) from error
595
- condition_seconds = time.time() - conditioned
596
- height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
597
- refined = plan.get("refined_prompt") or ""
598
-
599
- progress(0.1, desc=f"Generating {num_frames / FPS:.1f} s at {width}x{height} ...")
600
- started = time.time()
601
- frames, audio, sampling_rate = _generate(
602
- prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras
603
- )
604
- generate_seconds = time.time() - started
605
-
606
- directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
607
- os.makedirs(directory, exist_ok=True)
608
- path = os.path.join(directory, f"h3-ref2va-{int(time.time() * 1000)}.mp4")
609
- encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
610
-
611
- print(
612
- f"[ref2va] {[kind for kind, _ in references]} · `{width}x{height}`, {num_frames} frames "
613
- f"({num_frames / FPS:.3f} s), {int(steps)} steps · conditioner {condition_seconds:.0f}s "
614
- f"({plan['num_text_tokens']} tokens{', upsampled' if refined else ''}) · "
615
- f"denoise + decode {generate_seconds:.0f}s "
616
- f"({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}"
617
- f"{' · LoRA ' + ', '.join(lora_labels) if lora_labels else ''}",
618
- flush=True,
619
- )
620
- return path, refined, gr.update(visible=bool(refined))
621
-
622
-
623
- # ----------------------------------------------------------------------------------------------------------------
624
- # Settings file
625
- # ----------------------------------------------------------------------------------------------------------------
626
- # Everything typed rather than uploaded, so a session can be picked up where it was left off. The references
627
- # themselves are deliberately left out: gradio hands them over as paths into a per-session temporary directory that
628
- # is gone by the next visit, so a saved path would restore as a dead file rather than as the image.
629
-
630
- SETTINGS_VERSION = 1
631
- SETTINGS_KEYS = (
632
- ["prompt", "upsample", "canvas", "match", "duration", "steps", "seed"]
633
- + [f"lora_{slot + 1}" for slot in range(LORA_SLOTS)]
634
- + [f"lora_{slot + 1}_scale" for slot in range(LORA_SLOTS)]
635
- )
636
-
637
-
638
- def save_settings(*values):
639
- """Write the current controls to a `.json` and reveal it for download."""
640
- payload = {"version": SETTINGS_VERSION, "saved": time.strftime("%Y-%m-%d %H:%M:%S")}
641
- payload.update(dict(zip(SETTINGS_KEYS, values)))
642
-
643
- directory = os.path.join(tempfile.gettempdir(), "h3-settings")
644
- os.makedirs(directory, exist_ok=True)
645
- path = os.path.join(directory, f"h3-settings-{int(time.time())}.json")
646
- with open(path, "w", encoding="utf-8") as handle:
647
- json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
648
- return gr.update(value=path, visible=True)
649
-
650
-
651
- def load_settings(path):
652
- """Restore the controls from a `.json`. A key the file does not carry leaves its control alone, so a settings
653
- file written by an older version of this Space still loads."""
654
- if not path:
655
- return [gr.update() for _ in SETTINGS_KEYS]
656
- try:
657
- with open(path, encoding="utf-8") as handle:
658
- payload = json.load(handle)
659
- except Exception as error:
660
- raise gr.Error(f"Файлът с настройки не се чете: `{type(error).__name__}: {error}`")
661
- if not isinstance(payload, dict):
662
- raise gr.Error("Това не е файл с настройки на този Space.")
663
-
664
- updates = []
665
- for key in SETTINGS_KEYS:
666
- value = payload.get(key)
667
- # An unknown canvas label would be rejected by the conditioner, which is the wrong place to find out.
668
- if value is None or (key == "canvas" and value not in CANVASES):
669
- updates.append(gr.update())
670
- else:
671
- updates.append(gr.update(value=value))
672
- return updates
673
-
674
-
675
- def _fill_lora_slots(files, *current):
676
- """Drop `.safetensors` files on the uploader and their paths land in the first free slots, so a local adapter
677
- needs no typing at all."""
678
- slots = list(current)
679
- for path in files or []:
680
- for index, value in enumerate(slots):
681
- if not (value or "").strip():
682
- slots[index] = path
683
- break
684
- return [gr.update(value=value) for value in slots]
685
-
686
-
687
- load_models()
688
-
689
- INTRO = """# MiniMax-H3 Reference Custom Lora
690
-
691
- <div align="center">
692
- <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
693
- <a href="https://www.minimax.io/blog/minimax-h3" target="_blank" rel="noopener"><strong>[ blog ]</strong></a> &nbsp;
694
- <a href="https://huggingface.co/spaces/multimodalart/minimax-h3" target="_blank" rel="noopener"><strong>[ text / image to video ]</strong></a>
695
- </div>
696
-
697
- **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
698
- fully synchronized soundtrack (ambience, foley, speech). Bring your own subject, voice or camera move as a
699
- reference.
700
- """
701
-
702
- LORA_HELP = """Each slot takes a Hugging Face repo (`owner/repo`), a file inside one
703
- (`owner/repo/name.safetensors`), a file URL, or a local path — or just drop the files below. A strength of `0`
704
- switches a slot off without clearing it. Adapters have to be trained against the `transformer_ref/` partition.
705
- """
706
-
707
- SETTINGS_HELP = """Saves the prompt, the canvas, the sliders and the LoRA slots — everything typed rather than
708
- uploaded. Images, audio and video are not saved: gradio keeps them in a temporary folder that is gone by the next
709
- visit, so a saved path would come back as a dead file.
710
- """
711
-
712
- CSS = """
713
- .main.fillable { max-width: 1250px !important; }
714
- .dark .gradio-container { color: var(--body-text-color); }
715
- """
716
-
717
- with gr.Blocks(title="MiniMax-H3 Reference Custom Lora") as demo:
718
- gr.Markdown(INTRO)
719
-
720
- with gr.Row():
721
- with gr.Column():
722
- prompt = gr.Textbox(
723
- label="Prompt",
724
- lines=3,
725
- value="The character walks through a neon-lit street in the rain, humming to themselves",
726
- )
727
- upsample = gr.Checkbox(label="Upsample prompt", value=False)
728
- # One tab per modality, in the order the model reads them. A reference left in a tab that is not the open
729
- # one is still part of the request.
730
- with gr.Tabs():
731
- with gr.Tab("Images"):
732
- # One `gr.Row`, so gradio splits the width evenly and wraps at `min_width` rather than leaving a
733
- # hole where a hidden slot used to be.
734
- with gr.Row():
735
- images = [
736
- gr.Image(
737
- label="Subject, style or scene",
738
- type="filepath",
739
- min_width=180,
740
- # Fixed, so a row that wraps to a single slot stays the size of a full one.
741
- height=210,
742
- visible=index < OPEN_IMAGE_SLOTS,
743
- )
744
- for index in range(MAX_IMAGE_SLOTS)
745
- ]
746
- add_image = gr.Button("+ Add another image", size="sm", variant="secondary")
747
- with gr.Tab("Audio"):
748
- audio = gr.Audio(label="A voice or a piece of music", type="filepath")
749
- with gr.Tab("Video"):
750
- video = gr.Video(label="Motion & camera, 2–15 s. Its soundtrack comes along.")
751
- run = gr.Button("Generate", variant="primary")
752
-
753
- with gr.Accordion("LoRA", open=False):
754
- gr.Markdown(LORA_HELP)
755
- lora_references, lora_scales = [], []
756
- for slot in range(LORA_SLOTS):
757
- with gr.Row():
758
- lora_references.append(
759
- gr.Textbox(label=f"LoRA {slot + 1}", placeholder="owner/repo", scale=3)
760
- )
761
- lora_scales.append(
762
- gr.Slider(
763
- label="Strength",
764
- minimum=LORA_MIN_SCALE,
765
- maximum=LORA_MAX_SCALE,
766
- step=0.05,
767
- value=1.0,
768
- scale=2,
769
- )
770
- )
771
- lora_upload = gr.File(
772
- label="Drop .safetensors here to fill the slots",
773
- file_count="multiple",
774
- file_types=[".safetensors"],
775
- type="filepath",
776
- )
777
-
778
- with gr.Accordion("Advanced options", open=False):
779
- canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
780
- match = gr.Checkbox(label="Match the reference soundtrack", value=True, visible=False)
781
- duration = gr.Slider(
782
- label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
783
- )
784
- steps = gr.Slider(label="Steps", minimum=10, maximum=40, step=1, value=28)
785
- seed = gr.Number(label="Seed", value=42, precision=0)
786
-
787
- with gr.Accordion("Settings file", open=False):
788
- gr.Markdown(SETTINGS_HELP)
789
- save = gr.Button("Save settings to .json", size="sm")
790
- settings_download = gr.File(label="Your settings", visible=False, interactive=False)
791
- settings_upload = gr.File(
792
- label="Load a settings .json", file_types=[".json"], type="filepath"
793
- )
794
-
795
- with gr.Column():
796
- result = gr.Video(label="Video + soundtrack")
797
- # An output, so it can be revealed only for a request that asked for a rewrite.
798
- with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
799
- upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
800
-
801
- open_slots = gr.State(OPEN_IMAGE_SLOTS)
802
-
803
- def reveal_image_slot(open_count):
804
- open_count = min(open_count + 1, MAX_IMAGE_SLOTS)
805
- return [
806
- open_count,
807
- *[gr.update(visible=index < open_count) for index in range(MAX_IMAGE_SLOTS)],
808
- gr.update(visible=open_count < MAX_IMAGE_SLOTS),
809
- ]
810
-
811
- add_image.click(reveal_image_slot, open_slots, [open_slots, *images, add_image], api_name=False)
812
-
813
- for control in (audio, video, match):
814
- control.change(
815
- duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
816
- )
817
-
818
- # `reference, strength, reference, strength, ...`, which is how `generate` unpacks them.
819
- lora_inputs = [field for pair in zip(lora_references, lora_scales) for field in pair]
820
- lora_upload.upload(_fill_lora_slots, [lora_upload, *lora_references], lora_references, api_name=False)
821
-
822
- # Same order as `SETTINGS_KEYS`.
823
- settings_fields = [prompt, upsample, canvas, match, duration, steps, seed, *lora_references, *lora_scales]
824
- save.click(save_settings, settings_fields, settings_download, api_name=False)
825
- settings_upload.upload(load_settings, settings_upload, settings_fields, api_name=False)
826
-
827
- # Same order as `generate`'s signature: the five leading columns first, then the remaining image slots, then the
828
- # LoRA fields the `*lora_fields` tail collects.
829
- request = [
830
- prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample, *lora_inputs
831
- ]
832
-
833
- run.click(generate, request, [result, upsampled, upsampled_panel], api_name="generate")
834
-
835
-
836
- if __name__ == "__main__":
837
- 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", "multimodalart/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
+ # 21:9
58
+ "1152x512 · 21:9 fast": (512, 1152),
59
+ "1536x672 · 21:9 full": (672, 1536),
60
+ }
61
+ DEFAULT_CANVAS = "960x544 · 16:9 fast"
62
+ FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
63
+ # It is the *snapped* frame count the ceiling has to hold for: 15 s is 360 frames, which rounds up to 362, i.e.
64
+ # 15.083 s, and is refused. 14 is the last whole second that survives the snap.
65
+ MAX_UI_DURATION = 14
66
+ MIN_DURATION = 2
67
+ # A reference video shorter than 2 s gives the model almost no motion to read.
68
+ MIN_REFERENCE_VIDEO, MAX_REFERENCE_VIDEO = 2.0, 15.0
69
+ # `MINIMAX_H3_MAX_REFERENCE_IMAGES`. The slots are built up front and revealed one at a time, because a demo asking
70
+ # for two subjects should not open with nine boxes.
71
+ MAX_IMAGE_SLOTS, OPEN_IMAGE_SLOTS = 9, 2
72
+
73
+ # How many LoRA slots the UI offers, and the range each strength slider covers.
74
+ LORA_SLOTS = 3
75
+ LORA_MIN_SCALE, LORA_MAX_SCALE = -2.0, 2.0
76
+
77
+ # Pre-wired Turbo LoRAs from `larryvrh/MiniMax-H3-Turbo-Lora`: a few-step distillation that renders joint video +
78
+ # soundtrack in 4–8 steps instead of the usual ~20. Each entry is `(repo reference, recommended steps, blurb)`. The
79
+ # reference is the `owner/repo/filename.safetensors` form `resolve_lora` accepts, so it downloads on first use and is
80
+ # cached by `huggingface_hub` thereafter nothing is bundled in this Space.
81
+ LORA_PRESETS = {
82
+ "Turbo v4 (step 600) · 6–8 steps": (
83
+ "larryvrh/MiniMax-H3-Turbo-Lora/minimax_h3_turbo_v4_step600.safetensors",
84
+ 8,
85
+ "Recommended for most work. Strong static / small-motion, good micro-detail, no over-sharpening. "
86
+ "Use 6–8 steps; 4 steps can smear on heavy motion.",
87
+ ),
88
+ "Turbo v1 (ckpt 850) · 4 steps": (
89
+ "larryvrh/MiniMax-H3-Turbo-Lora/minimax_h3_turbo_4step_ckpt850.safetensors",
90
+ 4,
91
+ "The friendlier pick for 4-step heavy / fast motion, where v4 can trail. Over-sharpens at higher step counts, "
92
+ "so keep it at 4 steps.",
93
+ ),
94
+ }
95
+ # The lowest step count the model's own schedulers accept; the Turbo LoRAs are tuned for 4.
96
+ MIN_STEPS = 4
97
+
98
+ # Seconds of GPU one request needs, from the packed sequence it is about to denoise: linear in the rows for the
99
+ # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
100
+ STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3
101
+ # The lazy 72.16 GiB `PIPE.to("cuda")` a cold worker pays inside its first GPU call; every request carries it, because
102
+ # nothing here knows whether the worker it lands on is cold.
103
+ PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90"))
104
+ AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2
105
+ REFERENCE_IMAGE_SHORT_EDGE, CANVAS_MULTIPLE = 2048, 32
106
+ DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124
107
+ # Reading one adapter off local disk and injecting it across the 33B transformer's linear layers.
108
+ LORA_ALLOWANCE = 12
109
+
110
+
111
+ def snap_frames(seconds: float) -> int:
112
+ """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
113
+ frames = max(1, round(float(seconds) * FPS))
114
+ while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
115
+ frames += 1
116
+ return frames
117
+
118
+
119
+ def lower_duration_floor(seconds: float = MIN_DURATION) -> None:
120
+ """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
121
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
122
+
123
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
124
+
125
+
126
+ def video_latent_frames(num_frames: int) -> int:
127
+ """`17 * n + 5` frames become `5 * n + 2` video latents."""
128
+ return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2
129
+
130
+
131
+ def target_rows(height: int, width: int, num_frames: int) -> int:
132
+ """The generated rows of the packed sequence: video patched `(1, 2, 2)`, plus two audio rows per latent."""
133
+ video = video_latent_frames(num_frames) * (height // CANVAS_MULTIPLE) * (width // CANVAS_MULTIPLE)
134
+ return video + round(num_frames / FPS * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
135
+
136
+
137
+ def reference_rows(references: list[tuple[str, str]], num_frames: int) -> int:
138
+ """The rows the reference blocks add, from metadata alone — no decode.
139
+
140
+ An image is resized to a 2048 pixel short edge and encoded as a single frame; a video is put on the canvas *its
141
+ own* aspect ratio resolves to, truncated to the generated frame count and snapped **down** to a `17 * n + 5` the
142
+ VAE encodes without padding; a soundtrack contributes two rows per 1/40 s.
143
+ """
144
+ from PIL import Image
145
+
146
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import resolve_canvas_size
147
+
148
+ rows = 0
149
+ for kind, path in references:
150
+ if kind == "image":
151
+ width, height = Image.open(path).size
152
+ scale = REFERENCE_IMAGE_SHORT_EDGE / min(width, height)
153
+ resolved = [
154
+ max(CANVAS_MULTIPLE, round(edge * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
155
+ for edge in (height, width)
156
+ ]
157
+ rows += (resolved[0] // CANVAS_MULTIPLE) * (resolved[1] // CANVAS_MULTIPLE)
158
+ continue
159
+
160
+ video_seconds, audio_seconds = probe(path)
161
+ if kind == "video" and video_seconds is not None:
162
+ import av
163
+
164
+ with av.open(path) as container:
165
+ stream = container.streams.video[0]
166
+ source_height, source_width = stream.height, stream.width
167
+ canvas_height, canvas_width = resolve_canvas_size(source_width, source_height, CANVAS_MULTIPLE)
168
+ frames = min(round(video_seconds * FPS), num_frames)
169
+ snapped = max(1, (frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) * FRAMES_PER_CHUNK + LATENTS_PER_CHUNK
170
+ rows += (
171
+ video_latent_frames(snapped)
172
+ * (canvas_height // CANVAS_MULTIPLE)
173
+ * (canvas_width // CANVAS_MULTIPLE)
174
+ )
175
+ if audio_seconds is not None:
176
+ seconds = min(audio_seconds, num_frames / FPS)
177
+ rows += round(seconds * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS
178
+ return rows
179
+
180
+
181
+ def get_duration(
182
+ prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=(), **_
183
+ ):
184
+ """Seconds of GPU to reserve for one request. Takes the arguments of the `@spaces.GPU` function it decorates, and
185
+ tolerates the `gr.Progress` `spaces` injects."""
186
+ sequence = int(text_token_tags.shape[0]) + reference_rows(references, num_frames) + target_rows(
187
+ height, width, num_frames
188
+ )
189
+ denoise = int(steps) * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY
190
+ # The two reference encoders ahead of the loop, and the two decoders plus the mux after it. Both scale with what
191
+ # they are handed rather than with the step count.
192
+ encode = 5 + reference_rows(references, num_frames) * 1e-3
193
+ decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS
194
+ total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10 + LORA_ALLOWANCE * len(loras or ())
195
+ duration = max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total)))
196
+ print(f"[ref2va] S={sequence} -> reserving {duration}s ({denoise:.0f}s of denoise at {steps} steps)", flush=True)
197
+ return duration
198
+
199
+
200
+ PIPE = None
201
+ MANAGER = None
202
+ LOAD_ERROR: str | None = None
203
+
204
+
205
+ def load_models() -> str | None:
206
+ """Load the denoising half at startup, but *not* onto the card.
207
+
208
+ `MiniMaxH3Ref2VAGeneratorBlocks` declares `transformer_ref`, `vae`, `audio_vae`, the two schedulers and
209
+ `video_processor`, so `load_components` fetches exactly those subfolders — `text_encoder/` and the `transformer/`
210
+ partition are never touched. Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a
211
+ bfloat16 audio VAE decodes the soundtrack roughly 20 dB too quiet.
212
+
213
+ Nothing moves onto the card here, for storage rather than memory: `spaces`' startup `torch.pack()` writes every
214
+ startup-resident CUDA tensor to a second copy on disk, and 77.3 GB of weights plus its pack busts the 150 GB quota
215
+ (`OSError: [Errno 28] No space left on device` out of `os.posix_fallocate`, mid-pack).
216
+ """
217
+ global PIPE, MANAGER, LOAD_ERROR
218
+
219
+ if PIPE is not None or LOAD_ERROR is not None:
220
+ return LOAD_ERROR
221
+
222
+ started = time.time()
223
+ try:
224
+ import torch
225
+ from diffusers import ComponentsManager
226
+
227
+ from h3_split_blocks import MiniMaxH3Ref2VAGeneratorBlocks
228
+
229
+ lower_duration_floor()
230
+ manager = ComponentsManager()
231
+ blocks = MiniMaxH3Ref2VAGeneratorBlocks()
232
+ print(f"[ref2va] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
233
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
234
+ pipe.load_components(dtype=torch.bfloat16)
235
+
236
+ # Both VAEs first, and explicitly. `set_attention_backend` also sets the registry's *global* backend, which
237
+ # every processor that was not stamped falls through to, and the float32 audio VAE has no cuDNN kernel:
238
+ # `RuntimeError: No available kernel. Aborting execution.` in its causal encoder attention, which only a
239
+ # reference soundtrack ever reaches.
240
+ pipe.vae.set_attention_backend("native")
241
+ pipe.audio_vae.set_attention_backend("native")
242
+ pipe.transformer_ref.set_attention_backend(ATTENTION)
243
+
244
+ # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
245
+ # worker. Off unless `H3_AOTI=1`. It is the *same* package the `transformer/` partition runs — the two configs
246
+ # are identical field for field and the compiled code carries no weights of either.
247
+ import h3_aoti
248
+
249
+ h3_aoti.maybe_load(pipe.transformer_ref)
250
+
251
+ if PLACEMENT == "offload":
252
+ manager.enable_auto_cpu_offload(device="cuda")
253
+ _arm_decode_hooks(pipe)
254
+
255
+ PIPE, MANAGER = pipe, manager
256
+ print(f"[ref2va] ready in {time.time() - started:.0f}s", flush=True)
257
+ except Exception as error:
258
+ traceback.print_exc()
259
+ LOAD_ERROR = (
260
+ f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
261
+ f"`{type(error).__name__}: {error}`"
262
+ )
263
+ return LOAD_ERROR
264
+
265
+
266
+ def _arm_decode_hooks(pipe):
267
+ """Make the offload hooks fire for the two VAEs.
268
+
269
+ `enable_auto_cpu_offload` wraps `forward`, and the reference-encoder and decode blocks call `vae.encode/decode(...)`
270
+ directly, so the hook never runs and the VAE is still on the host when the latents arrive on the card.
271
+ """
272
+ for name in ("vae", "audio_vae"):
273
+ module = getattr(pipe, name)
274
+ for method in ("encode", "decode"):
275
+ inner = getattr(module, method)
276
+
277
+ def armed(*args, _module=module, _inner=inner, **kwargs):
278
+ hook = getattr(_module, "_hf_hook", None)
279
+ if hook is not None:
280
+ hook.pre_forward(_module)
281
+ return _inner(*args, **kwargs)
282
+
283
+ setattr(module, method, armed)
284
+
285
+
286
+ # ----------------------------------------------------------------------------------------------------------------
287
+ # LoRA
288
+ # ----------------------------------------------------------------------------------------------------------------
289
+ # There is no `MiniMaxH3LoraLoaderMixin` in the diffusers integration, so adapters are attached at the *model* level,
290
+ # through the `PeftAdapterMixin` the transformer carries. That is the whole API this needs: `load_lora_adapter` for
291
+ # each file and one `set_adapters` call to give them their strengths. Here the model is `transformer_ref`, so the
292
+ # adapters have to be trained against the `transformer_ref/` partition — a `transformer/` adapter is a different
293
+ # partition and will not match.
294
+
295
+
296
+ def _hub_url_parts(url: str) -> tuple[str, str]:
297
+ """Split a huggingface.co `blob`/`resolve` URL into its repo id and the file path inside it."""
298
+ from urllib.parse import unquote, urlparse
299
+
300
+ parts = unquote(urlparse(url).path).strip("/").split("/")
301
+ if len(parts) < 5 or parts[2] not in ("resolve", "blob"):
302
+ raise gr.Error(f"Не разпознавам този адрес като файл в Hugging Face: `{url}`")
303
+ return "/".join(parts[:2]), "/".join(parts[4:])
304
+
305
+
306
+ def resolve_lora(reference: str) -> str:
307
+ """Turn what the user typed into a local `.safetensors` path.
308
+
309
+ Accepts a local path, a huggingface.co file URL, `owner/repo/path/to/file.safetensors`, or a bare `owner/repo`
310
+ whose single `.safetensors` is then picked for them. Runs outside the GPU call, so the download costs no GPU time.
311
+ """
312
+ from huggingface_hub import hf_hub_download, list_repo_files
313
+
314
+ reference = (reference or "").strip()
315
+ if not reference:
316
+ return ""
317
+ if os.path.exists(reference):
318
+ return reference
319
+ if reference.startswith(("http://", "https://")):
320
+ repo_id, filename = _hub_url_parts(reference)
321
+ return hf_hub_download(repo_id, filename)
322
+
323
+ parts = [part for part in reference.split("/") if part]
324
+ if len(parts) > 2 and parts[-1].endswith(".safetensors"):
325
+ return hf_hub_download("/".join(parts[:2]), "/".join(parts[2:]))
326
+ if len(parts) != 2:
327
+ raise gr.Error(
328
+ f"`{reference}` не е нито съществуващ файл, нито `автор/хранилище`, нито адрес към Hugging Face."
329
+ )
330
+
331
+ candidates = [name for name in list_repo_files(reference) if name.endswith(".safetensors")]
332
+ if not candidates:
333
+ raise gr.Error(f"В `{reference}` няма `.safetensors` файл.")
334
+ if len(candidates) > 1:
335
+ preferred = [name for name in candidates if "lora" in name.lower()]
336
+ if len(preferred) != 1:
337
+ listed = ", ".join(f"`{name}`" for name in sorted(candidates)[:8])
338
+ raise gr.Error(f"`{reference}` съдържа няколко файла. Напиши `{reference}/име.safetensors`. Има: {listed}")
339
+ candidates = preferred
340
+ return hf_hub_download(reference, candidates[0])
341
+
342
+
343
+ def _lora_prefix(state_dict) -> str | None:
344
+ """The prefix `load_lora_adapter` has to strip before the keys match the transformer's own module names."""
345
+ key = next(iter(state_dict))
346
+ for prefix in ("model.diffusion_model", "diffusion_model", "transformer_ref", "transformer"):
347
+ if key.startswith(f"{prefix}."):
348
+ return prefix
349
+ return None
350
+
351
+
352
+ def apply_loras(transformer, loras) -> list[str]:
353
+ """Attach `loras` (local path, strength) to `transformer` and give each its strength, replacing whatever was on it.
354
+
355
+ Every adapter already on the model is removed first, so a request is never affected by the one before it — which
356
+ matters when a worker is reused rather than forked fresh.
357
+ """
358
+ import torch
359
+
360
+ from safetensors.torch import load_file
361
+
362
+ for name in list(getattr(transformer, "peft_config", None) or {}):
363
+ transformer.delete_adapters(name)
364
+
365
+ names, scales = [], []
366
+ for index, (path, scale) in enumerate(loras):
367
+ state_dict = load_file(path)
368
+ name = f"lora{index}"
369
+ transformer.load_lora_adapter(state_dict, adapter_name=name, prefix=_lora_prefix(state_dict))
370
+ names.append(name)
371
+ scales.append(float(scale))
372
+
373
+ if not names:
374
+ return []
375
+
376
+ # PEFT builds the new layers on its own default device/dtype; the base weights are the truth here, under either
377
+ # placement mode (`offload` keeps them on the host and moves whole modules by hook).
378
+ base = next(param for key, param in transformer.named_parameters() if ".lora_" not in key)
379
+ with torch.no_grad():
380
+ for key, param in transformer.named_parameters():
381
+ if ".lora_" in key and (param.device != base.device or param.dtype != base.dtype):
382
+ param.data = param.data.to(device=base.device, dtype=base.dtype)
383
+
384
+ transformer.set_adapters(names, scales)
385
+ return names
386
+
387
+
388
+ def collect_loras(lora_fields, progress) -> tuple[list[tuple[str, float]], list[str]]:
389
+ """Resolve the UI's `reference, strength, reference, strength, ...` into `(local path, strength)` pairs.
390
+
391
+ Resolved before the booking: a download that happens inside `@spaces.GPU` is billed as GPU time.
392
+ """
393
+ loras, labels = [], []
394
+ for reference, scale in zip(lora_fields[::2], lora_fields[1::2]):
395
+ reference = (reference or "").strip()
396
+ if not reference or abs(float(scale)) < 1e-6:
397
+ continue
398
+ progress(0.0, desc=f"Fetching LoRA {reference} ...")
399
+ loras.append((resolve_lora(reference), float(scale)))
400
+ labels.append(f"{os.path.basename(reference)} @ {float(scale):g}")
401
+ if loras and os.environ.get("H3_AOTI") == "1":
402
+ raise gr.Error("LoRA не може да се приложи върху AoTI компилиран трансформър. Изключи `H3_AOTI`.")
403
+ return loras, labels
404
+
405
+
406
+ @cache
407
+ def conditioner():
408
+ """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
409
+ conditioner's booking is billed to whoever asked for the video."""
410
+ from gradio_client import Client
411
+
412
+ return Client(CONDITIONER_SPACE)
413
+
414
+
415
+ def probe(path: str) -> tuple[float | None, float | None]:
416
+ """`(video seconds, audio seconds)` of a media file, either being `None` when the stream is absent."""
417
+ import av
418
+
419
+ def seconds(stream, container):
420
+ if stream.duration is not None and stream.time_base is not None:
421
+ return float(stream.duration * stream.time_base)
422
+ return None if container.duration is None else container.duration / av.time_base
423
+
424
+ with av.open(path) as container:
425
+ video = seconds(container.streams.video[0], container) if container.streams.video else None
426
+ audio = seconds(container.streams.audio[0], container) if container.streams.audio else None
427
+ return video, audio
428
+
429
+
430
+ def collect(image_paths, audio_path, video_path) -> list[tuple[str, str]]:
431
+ """The `(kind, path)` references of a request, **in the order the model reads them**.
432
+
433
+ That order numbers the labels of MiniMax-H3's prompt presentation and advances the shared audio/video rotary clock,
434
+ so the same references in a different order are a different request.
435
+ """
436
+ ordered = [("image", path) for path in image_paths if path]
437
+ if audio_path:
438
+ ordered.append(("audio", audio_path))
439
+ if video_path:
440
+ ordered.append(("video", video_path))
441
+ return ordered
442
+
443
+
444
+ def build_references(references: list[tuple[str, str]]):
445
+ """The `(kind, path)` references of a request as decoded reference dataclasses, in packed order. `from_file` brings
446
+ the rates along: a video its own frame rate and soundtrack, a clip its sample rate."""
447
+ from diffusers.modular_pipelines.minimax_h3 import (
448
+ MiniMaxH3AudioReference,
449
+ MiniMaxH3ImageReference,
450
+ MiniMaxH3VideoReference,
451
+ )
452
+
453
+ classes = {"image": MiniMaxH3ImageReference, "video": MiniMaxH3VideoReference, "audio": MiniMaxH3AudioReference}
454
+ return [classes[kind].from_file(path) for kind, path in references]
455
+
456
+
457
+ def audio_bearing(references: list[tuple[str, str]]) -> list[tuple[str, float]]:
458
+ """The references that carry a waveform, and how long it is. A video reference brings its own soundtrack."""
459
+ carried = []
460
+ for kind, path in references:
461
+ if kind == "image":
462
+ continue
463
+ _, audio_seconds = probe(path)
464
+ if audio_seconds is not None:
465
+ carried.append((kind, audio_seconds))
466
+ return carried
467
+
468
+
469
+ def duration_controls(audio_path, video_path, match: bool):
470
+ """Show the duration slider unless a single soundtrack can set it, which is when MiniMax-H3 lets it be left out."""
471
+ try:
472
+ carried = audio_bearing(collect([], audio_path, video_path))
473
+ except Exception:
474
+ carried = []
475
+ # Exactly one soundtrack, long enough to be a duration MiniMax-H3 generates; anything else is ambiguous or out of
476
+ # range and the slider stays.
477
+ derivable = len(carried) == 1 and MIN_DURATION <= snap_frames(carried[0][1]) / FPS <= MAX_REFERENCE_VIDEO
478
+ return gr.update(visible=derivable), gr.update(visible=not (derivable and match))
479
+
480
+
481
+ def check(prompt: str, references: list[tuple[str, str]]) -> None:
482
+ """The model's own rules, before anything is uploaded or a card is allocated."""
483
+ if not prompt or not prompt.strip():
484
+ raise gr.Error("MiniMax-H3 always takes a prompt, references or not.")
485
+ if not references:
486
+ raise gr.Error("Add at least one reference an image or a video for the model to condition on.")
487
+ if {kind for kind, _ in references} == {"audio"}:
488
+ raise gr.Error("An audio reference needs an image or a video alongside it; it cannot go on its own.")
489
+ for kind, path in references:
490
+ if kind != "video":
491
+ continue
492
+ video_seconds, _ = probe(path)
493
+ if video_seconds is None:
494
+ raise gr.Error("That reference video has no video stream. Drop it in the audio slot instead.")
495
+ if not MIN_REFERENCE_VIDEO <= video_seconds <= MAX_REFERENCE_VIDEO:
496
+ raise gr.Error(
497
+ f"The reference video is {video_seconds:.1f} s. Use a clip between "
498
+ f"{MIN_REFERENCE_VIDEO:g} and {MAX_REFERENCE_VIDEO:g} seconds."
499
+ )
500
+
501
+
502
+ def encode_remote(prompt, references, canvas, num_frames, rewrite_prompt=False):
503
+ """`/encode_ref2va` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with
504
+ the resolved `height` / `width` / `num_frames` in its metadata, plus the plan.
505
+
506
+ `canvas` is the label. `media` and `kinds` are parallel and ordered, and the references go over because `ref2va`'s
507
+ presentation puts a vision block in front of the prompt for every image and every merged video frame pair.
508
+ """
509
+ from gradio_client import handle_file
510
+ from safetensors import safe_open
511
+
512
+ path, plan = conditioner().predict(
513
+ prompt=prompt,
514
+ media=[handle_file(path) for _, path in references],
515
+ kinds=",".join(kind for kind, _ in references),
516
+ canvas=canvas,
517
+ num_frames=num_frames,
518
+ rewrite_prompt=bool(rewrite_prompt),
519
+ api_name="/encode_ref2va",
520
+ )
521
+ with safe_open(path, framework="pt") as handle:
522
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
523
+
524
+
525
+ @spaces.GPU(duration=get_duration, size=GPU_SIZE)
526
+ def _generate(prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras=()):
527
+ """The only thing on GPU time: the two reference encoders, the packed-sequence denoise loop and the decoders.
528
+
529
+ References cross as paths and are decoded here; only the three generated outputs come back. A `@spaces.GPU`
530
+ argument crosses a process boundary by pickling, a 5 s 1344x768 reference video is 370 MB of expanded frames, and
531
+ the full `PipelineState` still holds the packed latents and the rotary grid on the card.
532
+
533
+ The adapters are attached here rather than in the caller: `spaces` runs this body in its own worker, so the
534
+ transformer the request sees is the one that has to carry them.
535
+ """
536
+ import torch
537
+
538
+ if PLACEMENT == "lazy":
539
+ PIPE.to("cuda")
540
+
541
+ apply_loras(PIPE.transformer_ref, loras or ())
542
+
543
+ state = PIPE(
544
+ prompt_embeds=prompt_embeds.to("cuda"),
545
+ text_token_tags=text_token_tags,
546
+ references=build_references(references),
547
+ height=height,
548
+ width=width,
549
+ num_frames=num_frames,
550
+ num_inference_steps=int(steps),
551
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
552
+ )
553
+ return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
554
+
555
+
556
+ def generate(
557
+ # Every parameter after `prompt` has a default, and the newest ones sit at the end, so a positional API client
558
+ # written against an older signature keeps working.
559
+ prompt,
560
+ image_1=None,
561
+ audio_path=None,
562
+ video_path=None,
563
+ canvas=DEFAULT_CANVAS,
564
+ image_2=None,
565
+ image_3=None,
566
+ image_4=None,
567
+ image_5=None,
568
+ image_6=None,
569
+ image_7=None,
570
+ image_8=None,
571
+ image_9=None,
572
+ match=True,
573
+ duration=5,
574
+ steps=28,
575
+ seed=42,
576
+ upsample=False,
577
+ *lora_fields,
578
+ progress=gr.Progress(track_tqdm=True),
579
+ ):
580
+ """One request. The LoRA fields are last and default to empty, so a positional API client that predates them is
581
+ unaffected. `lora_fields` arrives as `reference, strength, reference, strength, ...`."""
582
+ if LOAD_ERROR:
583
+ raise gr.Error(LOAD_ERROR)
584
+ if PIPE is None:
585
+ raise gr.Error("The denoiser is still loading.")
586
+
587
+ from diffusers.utils import encode_video
588
+
589
+ images = [image_1, image_2, image_3, image_4, image_5, image_6, image_7, image_8, image_9]
590
+ references = collect(images, audio_path, video_path)
591
+ check(prompt, references)
592
+
593
+ # `0` is "leave it to the references" over the wire, which MiniMax-H3 accepts when exactly one of them carries a
594
+ # soundtrack. The conditioner resolves it either way and this Space pins whatever comes back.
595
+ derivable = len(audio_bearing(references)) == 1
596
+ requested = 0 if (match and derivable) else snap_frames(duration)
597
+
598
+ loras, lora_labels = collect_loras(lora_fields, progress)
599
+
600
+ progress(0.0, desc="Upsampling the prompt ..." if upsample else "Reading the prompt and references ...")
601
+ conditioned = time.time()
602
+ try:
603
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(
604
+ prompt, references, canvas, requested, rewrite_prompt=upsample
605
+ )
606
+ except gr.Error:
607
+ raise
608
+ except Exception as error:
609
+ # gradio only puts the exception *type* on the wire, so the useful half of a conditioner-side failure is in
610
+ # that Space's logs.
611
+ traceback.print_exc()
612
+ raise gr.Error(
613
+ f"The conditioner ({CONDITIONER_SPACE}) failed with `{type(error).__name__}: {error}`. "
614
+ "Its logs carry the full traceback."
615
+ ) from error
616
+ condition_seconds = time.time() - conditioned
617
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
618
+ refined = plan.get("refined_prompt") or ""
619
+
620
+ progress(0.1, desc=f"Generating {num_frames / FPS:.1f} s at {width}x{height} ...")
621
+ started = time.time()
622
+ frames, audio, sampling_rate = _generate(
623
+ prompt_embeds, text_token_tags, references, height, width, num_frames, steps, seed, loras
624
+ )
625
+ generate_seconds = time.time() - started
626
+
627
+ directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
628
+ os.makedirs(directory, exist_ok=True)
629
+ path = os.path.join(directory, f"h3-ref2va-{int(time.time() * 1000)}.mp4")
630
+ encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
631
+
632
+ print(
633
+ f"[ref2va] {[kind for kind, _ in references]} · `{width}x{height}`, {num_frames} frames "
634
+ f"({num_frames / FPS:.3f} s), {int(steps)} steps · conditioner {condition_seconds:.0f}s "
635
+ f"({plan['num_text_tokens']} tokens{', upsampled' if refined else ''}) · "
636
+ f"denoise + decode {generate_seconds:.0f}s "
637
+ f"({generate_seconds / int(steps):.1f} s/step) · seed {int(seed)}"
638
+ f"{' · LoRA ' + ', '.join(lora_labels) if lora_labels else ''}",
639
+ flush=True,
640
+ )
641
+ return path, refined, gr.update(visible=bool(refined))
642
+
643
+
644
+ # ----------------------------------------------------------------------------------------------------------------
645
+ # Settings file
646
+ # ----------------------------------------------------------------------------------------------------------------
647
+ # Everything typed rather than uploaded, so a session can be picked up where it was left off. The references
648
+ # themselves are deliberately left out: gradio hands them over as paths into a per-session temporary directory that
649
+ # is gone by the next visit, so a saved path would restore as a dead file rather than as the image.
650
+
651
+ SETTINGS_VERSION = 1
652
+ SETTINGS_KEYS = (
653
+ ["prompt", "upsample", "canvas", "match", "duration", "steps", "seed"]
654
+ + [f"lora_{slot + 1}" for slot in range(LORA_SLOTS)]
655
+ + [f"lora_{slot + 1}_scale" for slot in range(LORA_SLOTS)]
656
+ )
657
+
658
+
659
+ def save_settings(*values):
660
+ """Write the current controls to a `.json` and reveal it for download."""
661
+ payload = {"version": SETTINGS_VERSION, "saved": time.strftime("%Y-%m-%d %H:%M:%S")}
662
+ payload.update(dict(zip(SETTINGS_KEYS, values)))
663
+
664
+ directory = os.path.join(tempfile.gettempdir(), "h3-settings")
665
+ os.makedirs(directory, exist_ok=True)
666
+ path = os.path.join(directory, f"h3-settings-{int(time.time())}.json")
667
+ with open(path, "w", encoding="utf-8") as handle:
668
+ json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
669
+ return gr.update(value=path, visible=True)
670
+
671
+
672
+ def load_settings(path):
673
+ """Restore the controls from a `.json`. A key the file does not carry leaves its control alone, so a settings
674
+ file written by an older version of this Space still loads."""
675
+ if not path:
676
+ return [gr.update() for _ in SETTINGS_KEYS]
677
+ try:
678
+ with open(path, encoding="utf-8") as handle:
679
+ payload = json.load(handle)
680
+ except Exception as error:
681
+ raise gr.Error(f"Файлът с настройки не се чете: `{type(error).__name__}: {error}`")
682
+ if not isinstance(payload, dict):
683
+ raise gr.Error("Това не е файл с настройки на този Space.")
684
+
685
+ updates = []
686
+ for key in SETTINGS_KEYS:
687
+ value = payload.get(key)
688
+ # An unknown canvas label would be rejected by the conditioner, which is the wrong place to find out.
689
+ if value is None or (key == "canvas" and value not in CANVASES):
690
+ updates.append(gr.update())
691
+ else:
692
+ updates.append(gr.update(value=value))
693
+ return updates
694
+
695
+
696
+ def _fill_lora_slots(files, *current):
697
+ """Drop `.safetensors` files on the uploader and their paths land in the first free slots, so a local adapter
698
+ needs no typing at all."""
699
+ slots = list(current)
700
+ for path in files or []:
701
+ for index, value in enumerate(slots):
702
+ if not (value or "").strip():
703
+ slots[index] = path
704
+ break
705
+ return [gr.update(value=value) for value in slots]
706
+
707
+
708
+ def _add_preset_lora(preset, *current):
709
+ """Fill the first free LoRA slot with a preset adapter, set its strength to 1.0 and move the steps slider to the
710
+ preset's recommended count.
711
+
712
+ The Turbo presets are tuned for a specific step range, so the steps slider is moved along with the slot — it is the
713
+ one output beyond the LoRA fields. A slot already holding the same reference is a no-op, so the button can be
714
+ pressed twice without duplicating, and a full set of slots is left untouched.
715
+ """
716
+ reference, steps, _ = LORA_PRESETS[preset]
717
+ slots = list(current[:LORA_SLOTS])
718
+ scales = list(current[LORA_SLOTS:])
719
+ if reference not in [(value or "").strip() for value in slots]:
720
+ for index, value in enumerate(slots):
721
+ if not (value or "").strip():
722
+ slots[index] = reference
723
+ scales[index] = 1.0
724
+ break
725
+ return [*slots, *scales, steps]
726
+
727
+
728
+ load_models()
729
+
730
+ INTRO = """# MiniMax-H3 Reference Custom Lora
731
+
732
+ <div align="center">
733
+ <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
734
+ <a href="https://www.minimax.io/blog/minimax-h3" target="_blank" rel="noopener"><strong>[ blog ]</strong></a> &nbsp;
735
+ <a href="https://huggingface.co/spaces/multimodalart/minimax-h3" target="_blank" rel="noopener"><strong>[ text / image to video ]</strong></a>
736
+ </div>
737
+
738
+ **MiniMax-H3** is a 33B parameter state of the art video generation model that produces video and a
739
+ fully synchronized soundtrack (ambience, foley, speech). Bring your own subject, voice or camera move as a
740
+ reference.
741
+ """
742
+
743
+ LORA_HELP = """Each slot takes a Hugging Face repo (`owner/repo`), a file inside one
744
+ (`owner/repo/name.safetensors`), a file URL, or a local path — or just drop the files below. A strength of `0`
745
+ switches a slot off without clearing it. Adapters have to be trained against the `transformer_ref/` partition.
746
+
747
+ **Turbo LoRA presets** — from
748
+ [`larryvrh/MiniMax-H3-Turbo-Lora`](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora), a few-step distillation that
749
+ renders joint video + soundtrack in **4–8 steps** instead of the usual ~20 (a ~5× speedup). Pick one from the dropdown
750
+ and **Add to a free slot** to fill a slot, set its strength to `1.0` and move the steps slider to the recommended
751
+ count. Keep strength at `1.0`; only nudge it if a specific clip misbehaves (smear → up, over-sharp → down).
752
+ """
753
+
754
+ SETTINGS_HELP = """Saves the prompt, the canvas, the sliders and the LoRA slots — everything typed rather than
755
+ uploaded. Images, audio and video are not saved: gradio keeps them in a temporary folder that is gone by the next
756
+ visit, so a saved path would come back as a dead file.
757
+ """
758
+
759
+ CSS = """
760
+ .main.fillable { max-width: 1250px !important; }
761
+ .dark .gradio-container { color: var(--body-text-color); }
762
+ """
763
+
764
+ with gr.Blocks(title="MiniMax-H3 Reference Custom Lora") as demo:
765
+ gr.Markdown(INTRO)
766
+
767
+ with gr.Row():
768
+ with gr.Column():
769
+ prompt = gr.Textbox(
770
+ label="Prompt",
771
+ lines=3,
772
+ value="The character walks through a neon-lit street in the rain, humming to themselves",
773
+ )
774
+ upsample = gr.Checkbox(label="Upsample prompt", value=False)
775
+ # One tab per modality, in the order the model reads them. A reference left in a tab that is not the open
776
+ # one is still part of the request.
777
+ with gr.Tabs():
778
+ with gr.Tab("Images"):
779
+ # One `gr.Row`, so gradio splits the width evenly and wraps at `min_width` rather than leaving a
780
+ # hole where a hidden slot used to be.
781
+ with gr.Row():
782
+ images = [
783
+ gr.Image(
784
+ label="Subject, style or scene",
785
+ type="filepath",
786
+ min_width=180,
787
+ # Fixed, so a row that wraps to a single slot stays the size of a full one.
788
+ height=210,
789
+ visible=index < OPEN_IMAGE_SLOTS,
790
+ )
791
+ for index in range(MAX_IMAGE_SLOTS)
792
+ ]
793
+ add_image = gr.Button("+ Add another image", size="sm", variant="secondary")
794
+ with gr.Tab("Audio"):
795
+ audio = gr.Audio(label="A voice or a piece of music", type="filepath")
796
+ with gr.Tab("Video"):
797
+ video = gr.Video(label="Motion & camera, 2–15 s. Its soundtrack comes along.")
798
+ run = gr.Button("Generate", variant="primary")
799
+
800
+ with gr.Accordion("LoRA", open=False):
801
+ gr.Markdown(LORA_HELP)
802
+ with gr.Row():
803
+ lora_preset = gr.Dropdown(
804
+ label="Turbo LoRA presets",
805
+ choices=list(LORA_PRESETS),
806
+ value=list(LORA_PRESETS)[0],
807
+ scale=4,
808
+ )
809
+ lora_preset_add = gr.Button("Add to a free slot", size="sm", variant="secondary", scale=1)
810
+ lora_references, lora_scales = [], []
811
+ for slot in range(LORA_SLOTS):
812
+ with gr.Row():
813
+ lora_references.append(
814
+ gr.Textbox(label=f"LoRA {slot + 1}", placeholder="owner/repo", scale=3)
815
+ )
816
+ lora_scales.append(
817
+ gr.Slider(
818
+ label="Strength",
819
+ minimum=LORA_MIN_SCALE,
820
+ maximum=LORA_MAX_SCALE,
821
+ step=0.05,
822
+ value=1.0,
823
+ scale=2,
824
+ )
825
+ )
826
+ lora_upload = gr.File(
827
+ label="Drop .safetensors here to fill the slots",
828
+ file_count="multiple",
829
+ file_types=[".safetensors"],
830
+ type="filepath",
831
+ )
832
+
833
+ with gr.Accordion("Advanced options", open=False):
834
+ canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
835
+ match = gr.Checkbox(label="Match the reference soundtrack", value=True, visible=False)
836
+ duration = gr.Slider(
837
+ label="Duration (s)", minimum=MIN_DURATION, maximum=MAX_UI_DURATION, step=1, value=5
838
+ )
839
+ steps = gr.Slider(label="Steps", minimum=MIN_STEPS, maximum=40, step=1, value=28)
840
+ seed = gr.Number(label="Seed", value=42, precision=0)
841
+
842
+ with gr.Accordion("Settings file", open=False):
843
+ gr.Markdown(SETTINGS_HELP)
844
+ save = gr.Button("Save settings to .json", size="sm")
845
+ settings_download = gr.File(label="Your settings", visible=False, interactive=False)
846
+ settings_upload = gr.File(
847
+ label="Load a settings .json", file_types=[".json"], type="filepath"
848
+ )
849
+
850
+ with gr.Column():
851
+ result = gr.Video(label="Video + soundtrack")
852
+ # An output, so it can be revealed only for a request that asked for a rewrite.
853
+ with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
854
+ upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
855
+
856
+ open_slots = gr.State(OPEN_IMAGE_SLOTS)
857
+
858
+ def reveal_image_slot(open_count):
859
+ open_count = min(open_count + 1, MAX_IMAGE_SLOTS)
860
+ return [
861
+ open_count,
862
+ *[gr.update(visible=index < open_count) for index in range(MAX_IMAGE_SLOTS)],
863
+ gr.update(visible=open_count < MAX_IMAGE_SLOTS),
864
+ ]
865
+
866
+ add_image.click(reveal_image_slot, open_slots, [open_slots, *images, add_image], api_name=False)
867
+
868
+ for control in (audio, video, match):
869
+ control.change(
870
+ duration_controls, [audio, video, match], [match, duration], show_progress="hidden", api_name=False
871
+ )
872
+
873
+ # `reference, strength, reference, strength, ...`, which is how `generate` unpacks them.
874
+ lora_inputs = [field for pair in zip(lora_references, lora_scales) for field in pair]
875
+ lora_upload.upload(_fill_lora_slots, [lora_upload, *lora_references], lora_references, api_name=False)
876
+ lora_preset_add.click(
877
+ _add_preset_lora,
878
+ [lora_preset, *lora_references, *lora_scales],
879
+ [*lora_references, *lora_scales, steps],
880
+ api_name=False,
881
+ )
882
+
883
+ # Same order as `SETTINGS_KEYS`.
884
+ settings_fields = [prompt, upsample, canvas, match, duration, steps, seed, *lora_references, *lora_scales]
885
+ save.click(save_settings, settings_fields, settings_download, api_name=False)
886
+ settings_upload.upload(load_settings, settings_upload, settings_fields, api_name=False)
887
+
888
+ # Same order as `generate`'s signature: the five leading columns first, then the remaining image slots, then the
889
+ # LoRA fields the `*lora_fields` tail collects.
890
+ request = [
891
+ prompt, images[0], audio, video, canvas, *images[1:], match, duration, steps, seed, upsample, *lora_inputs
892
+ ]
893
+
894
+ run.click(generate, request, [result, upsampled, upsampled_panel], api_name="generate")
895
+
896
+
897
+ if __name__ == "__main__":
898
+ demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS)