multimodalart HF Staff commited on
Commit
581a2f4
·
verified ·
1 Parent(s): d380f84

PlagueKind V1.5 workflow: linear_quadratic sigmas, RCAS, FILM, split unquantized deployment

Browse files
Files changed (12) hide show
  1. README.md +89 -8
  2. app.py +444 -165
  3. examples/first.png +0 -0
  4. examples/last.png +0 -0
  5. film_net.py +270 -0
  6. h3_aoti.py +307 -0
  7. h3_local_conditioner.py +0 -174
  8. h3_nvfp4.py +0 -964
  9. h3_split_blocks.py +6 -6
  10. packages.txt +1 -1
  11. pk_workflow.py +217 -0
  12. requirements.txt +16 -7
README.md CHANGED
@@ -1,18 +1,99 @@
1
  ---
2
- title: MiniMax-H3
3
- emoji: 🎬
4
  colorFrom: gray
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
  app_file: app.py
9
- short_description: MiniMax-H3 video generation with synchronized audio
10
- python_version: "3.12"
11
  startup_duration_timeout: 1h
 
12
  ---
13
 
14
- # MiniMax-H3
15
 
16
- MiniMax-H3 is a 33B parameter state-of-the-art video generation model that produces video and a fully synchronized soundtrack (ambience, foley, speech). This Space runs the FL2VA variant with Int8 weight-only quantization on ZeroGPU.
 
 
 
17
 
18
- Based on [Plaguekind/Minimax-H3](https://huggingface.co/Plaguekind/Minimax-H3) (ComfyUI workflow wrapper) and [MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) (original model).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PlagueKind MiniMax H3
3
+ emoji: 🦊
4
  colorFrom: gray
5
  colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.20.0
8
  app_file: app.py
9
+ short_description: The PlagueKind V1.5 workflow for MiniMax-H3 video + audio
10
+ license: gpl-3.0
11
  startup_duration_timeout: 1h
12
+ suggested_hardware: zero-a10g
13
  ---
14
 
15
+ # `Plaguekind/Minimax-H3` — the V1.5 workflow, as a Space
16
 
17
+ [`Plaguekind/Minimax-H3`](https://huggingface.co/Plaguekind/Minimax-H3) ships **no weights**. It is a ComfyUI graph
18
+ (`PlagueKind-MinimaxH3-V1.5.json`) over [`Comfy-Org/MiniMax-H3`](https://huggingface.co/Comfy-Org/MiniMax-H3), and
19
+ everything it contributes is in the sampling and the post chain. So what this Space reproduces is the *graph*, on the
20
+ [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3) diffusers checkpoint.
21
 
22
+ **MiniMax-H3** is a 33B-parameter single-stream omni DiT that denoises video and a synchronized stereo soundtrack —
23
+ ambience, foley, speech — as one packed sequence, in one pass. Text-to-video, first frame, last frame, or both.
24
+
25
+ ## What the workflow changes
26
+
27
+ | ComfyUI node | widget | here |
28
+ |---|---|---|
29
+ | `KSamplerSelect` | `euler` | MiniMax-H3's only sampler; the checkpoint is CFG-distilled, so one forward per step and no negative prompt |
30
+ | `BasicScheduler` | `linear_quadratic`, 15 steps, denoise 1.0 | **Sigma schedule** / **Steps** — `pk_workflow.linear_quadratic_sigmas` |
31
+ | `MiniMaxH3ImageToVideo` | prompt, first/last frame | **Prompt** / **First frame** / **Last frame** |
32
+ | `UnifiedResizeImageMask` ("Target Dimension") | longer side 1344 | **Target dimension** |
33
+ | `ImageSharpenKJ` | `rcas`, 0.3 | **RCAS sharpening** — `pk_workflow.rcas` |
34
+ | `FrameInterpolate` + `FrameInterpolationModelLoader` | `film_net_fp16.safetensors`, multiplier 2 | **FILM frame interpolation** — `pk_workflow.interpolate` |
35
+ | `CreateVideo` | fps `24 * 2` | 48 fps output |
36
+ | `ComfyMathExpression` | `max(5, round(a*24)) + (5 - (… % 17)) % 17` | **Duration** snapped to `17n + 5` frames |
37
+ | `RTXVideoSuperResolution` | 2x, `ULTRA` | **not reproduced** |
38
+ | `PathchSageAttentionKJ` | `sageattn_qk_int8_pv_fp8_cuda++` | cuDNN fused attention |
39
+
40
+ The sigma schedule is the part that changes the pixels most, and the part that is easy to get subtly wrong.
41
+ `linear_quadratic` is Mochi's schedule, ported from `comfy/samplers.py`: half the steps crawl through the first 2.5 %
42
+ of the trajectory and the rest sprint the remaining 97.5 %, which is why PlagueKind's 15 steps hold up against ~28 of
43
+ MiniMax-H3's native grid.
44
+
45
+ Transplanting it into `diffusers` exactly needs one observation. MiniMax-H3 carries **two** rectified-flow schedules
46
+ per request, `shift = 12` for the video rows and `shift = 3` for the audio rows. `diffusers` builds both from one
47
+ `linspace(1, 0, steps)` base grid; ComfyUI instead samples the *video* schedule and derives the audio one in closed
48
+ form (`comfy/ldm/minimax/model.py::time_shift_sigma`). The two agree, because the exponential shift is a bijection of
49
+ the base grid that fixes both 0 and 1 — so handing `MiniMaxH3Scheduler.set_timesteps` the `linear_quadratic` grid for
50
+ the video stream and `time_shift_sigma(grid, 12, 3)` for the audio stream is the ComfyUI path, not an approximation of
51
+ it.
52
+
53
+ ### Two deliberate deviations
54
+
55
+ - **`RTXVideoSuperResolution`** is NVIDIA's NGX super-resolution, shipped as a driver-level Windows/RTX component with
56
+ no Linux Python path. The workflow's 2x upscale is therefore missing; pick a larger **Target dimension** instead of
57
+ upscaling a small one.
58
+ - **SageAttention**'s `qk_int8_pv_fp8_cuda++` kernel is not built for this pool's sm120 cards. Attention runs cuDNN's
59
+ fused kernel, which is both the fastest available here and the numerically faithful choice — SageAttention is a
60
+ quantized approximation of it.
61
+
62
+ ### One upgrade
63
+
64
+ The workflow loads `minimax_h3_fl2va_pruned_int8_convrot.safetensors` and a `qwen3vl_32b_…_int8_convrot` text encoder
65
+ because that is what fits a consumer card. This Space runs both **unquantized bfloat16**.
66
+
67
+ ## Why the deployment is split
68
+
69
+ MiniMax-H3 at bfloat16 is 195.9 GiB, and a Space is evicted above 150 GB of storage. So the halves live apart:
70
+
71
+ - **this Space** — the 61.73 GiB transformer and the two autoencoders (10.43 GiB, float32: a bfloat16 audio VAE
72
+ decodes the soundtrack about 20 dB too quiet). 77.3 GB downloaded.
73
+ - **[`multimodalart/qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner)** — the
74
+ 62.14 GiB Qwen3-VL text encoder, called over the gradio API once per request. `prompt_embeds` +
75
+ `text_token_tags` in a safetensors file is the whole wire format. `gradio_client` forwards the caller's own ZeroGPU
76
+ token, so that booking is billed to whoever asked for the video.
77
+
78
+ `H3_PLACEMENT=pack` moves only the transformer to CUDA at startup: `spaces` packs every startup-resident CUDA tensor
79
+ into a second on-disk copy, and packing all 77.3 GB busts the storage quota while the 61.7 GB transformer alone fits.
80
+ The VAEs move on the first GPU call. `H3_GPU_SIZE=xlarge` is required — `large` does not fit. `H3_AOTI=1` loads
81
+ [`multimodalart/minimax-h3-aoti`](https://huggingface.co/multimodalart/minimax-h3-aoti), one ahead-of-time-compiled
82
+ transformer block serving all 50, which removes roughly 0.5 s/step.
83
+
84
+ ## Files
85
+
86
+ | file | |
87
+ |---|---|
88
+ | `app.py` | the demo |
89
+ | `pk_workflow.py` | the workflow's own parts: `linear_quadratic` sigmas, RCAS, FILM |
90
+ | `film_net.py` | FILM, vendored from ComfyUI (GPL-3.0) |
91
+ | `h3_split_blocks.py` | the modular blocks that skip the text encoder |
92
+ | `h3_aoti.py` | the AoTI package loader |
93
+
94
+ ## License
95
+
96
+ The demo code is GPL-3.0, because `film_net.py` is vendored from [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
97
+ and `pk_workflow.py` ports kernels from ComfyUI and
98
+ [ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes), both GPL-3.0. The workflow itself is MIT; the
99
+ `MiniMaxAI/MiniMax-H3` weights carry their own license.
app.py CHANGED
@@ -1,27 +1,42 @@
1
- """MiniMax-H3 video generation with synchronized audio FL2VA (text / first-last-frame to video+audio).
2
 
3
- Uses the pruned NVFP4 transformer and local truncated NVFP4-AWQ Qwen3-VL conditioner for compact
4
- downloads (~42 GB total vs 119 GB BF16), fitting in ZeroGPU xlarge (96 GB VRAM).
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import os
10
-
11
- # Allocator config for memory pressure (video DiTs have large transient allocations)
12
- os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
13
-
14
- import spaces # MUST come before torch / any CUDA-touching import
15
- import gradio as gr
16
-
17
  import tempfile
18
  import time
19
  import traceback
 
20
 
21
- MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
22
- GPU_SIZE = "xlarge"
 
 
 
 
23
 
24
- # Canvas presets matching the original model's supported resolutions
 
 
 
 
 
 
 
 
 
 
25
  CANVASES = {
26
  # 16:9
27
  "960x544 · 16:9 fast": (544, 960),
@@ -45,13 +60,32 @@ CANVASES = {
45
  "1152x512 · 21:9 fast": (512, 1152),
46
  "1536x672 · 21:9 full": (672, 1536),
47
  }
 
 
48
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
 
49
  FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
50
- MIN_UI_DURATION, MAX_UI_DURATION = 2, 10
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
  def snap_frames(seconds: float) -> int:
54
- """The frame count MiniMax-H3's video VAE can decode: the next 17*n+5 at 24 fps."""
 
 
 
 
55
  frames = max(1, round(float(seconds) * FPS))
56
  while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
57
  frames += 1
@@ -59,27 +93,43 @@ def snap_frames(seconds: float) -> int:
59
 
60
 
61
  def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
62
- """Let the pipeline generate below its 5 s floor."""
63
  from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
64
 
65
  MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
66
 
67
 
68
  PIPE = None
69
- COND_PIPE = None
70
- COND_ERROR: str | None = None
71
  LOAD_ERROR: str | None = None
72
  LOADED_IN: float | None = None
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def load_models() -> str | None:
76
- """Load the compact generator and local truncated conditioner at startup.
77
 
78
- Uses MiniMaxH3GeneratorBlocks (only VAEs + schedulers + video_processor from MiniMaxAI/MiniMax-H3,
79
- ~10 GB) plus the pruned NVFP4 transformer from lilcheaty/MiniMax-H3-NVFP4 (~16 GB) and the local
80
- NVFP4-AWQ Qwen3-VL conditioner from Comfy-Org/MiniMax-H3 (~16 GB). Total download: ~42 GB.
 
81
  """
82
- global PIPE, COND_PIPE, COND_ERROR, LOAD_ERROR, LOADED_IN
83
 
84
  if PIPE is not None or LOAD_ERROR is not None:
85
  return LOAD_ERROR
@@ -96,166 +146,286 @@ def load_models() -> str | None:
96
  blocks = MiniMaxH3GeneratorBlocks()
97
  print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
98
  pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
 
 
99
 
100
- # Load only VAEs, schedulers, and video_processor from the main repo (~10 GB)
101
- pipe.load_components(
102
- names=["vae", "audio_vae", "scheduler", "audio_scheduler", "video_processor"],
103
- dtype=torch.bfloat16,
104
- )
105
-
106
- # Load the pruned NVFP4 transformer from the separate checkpoint repo
107
- from h3_nvfp4 import load_transformer
108
 
109
- pipe.update_components(transformer=load_transformer())
110
- pipe.transformer.set_attention_backend("_native_cudnn")
111
 
112
- # Load the local truncated NVFP4-AWQ Qwen3-VL conditioner
113
- try:
114
- from h3_local_conditioner import load_local_conditioner
115
- from h3_split_blocks import MiniMaxH3ConditionerBlocks
116
-
117
- print("[cond] loading the local truncated NVFP4-AWQ conditioner ...", flush=True)
118
- text_encoder, tokenizer, processor = load_local_conditioner()
119
- cond_pipe = MiniMaxH3ConditionerBlocks().init_pipeline(MODEL_REPO)
120
- cond_pipe.update_components(
121
- text_encoder=text_encoder,
122
- tokenizer=tokenizer,
123
- processor=processor,
124
- )
125
- except Exception as error:
126
- traceback.print_exc()
127
- COND_ERROR = f"{type(error).__name__}: {error}"
128
- print(f"[cond] local load failed ({COND_ERROR})", flush=True)
129
 
130
- PIPE, COND_PIPE = pipe, cond_pipe
131
  LOADED_IN = time.time() - started
132
  print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
133
  except Exception as error:
134
  traceback.print_exc()
135
- LOAD_ERROR = f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`"
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  return LOAD_ERROR
137
 
138
 
139
- def status() -> str:
140
- if LOAD_ERROR:
141
- return LOAD_ERROR
142
- if PIPE is None:
143
- return "Loading pruned NVFP4 transformer + local NVFP4 conditioner + full-precision VAEs (~42 GB). Watch the Space logs."
144
- import h3_nvfp4
145
- engine_status = h3_nvfp4.status()
146
- cond_status = "local NVFP4-AWQ" if COND_PIPE is not None else f"unavailable ({COND_ERROR})"
147
- return f"Ready · {engine_status} · VAEs full precision · loaded in {LOADED_IN:.0f}s · conditioner {cond_status}"
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
- # Duration estimation
 
 
151
  _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
 
 
152
  _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
 
 
 
 
153
  _PLACEMENT_ALLOWANCE, _PAD = 12, 10
154
 
155
 
156
- def get_duration(prompt, image, last_image, height, width, num_frames, steps, seed, *a, **k):
157
- """Estimate GPU seconds needed for this request."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
 
159
  latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
160
  patches = (height // 32) * (width // 32)
161
- rows = latent_frames * patches + (int(image is not None) + int(last_image is not None)) * patches
 
162
  denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
 
163
  decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
164
- local_conditioning = 20
165
- return max(60, int(denoise + decode) + local_conditioning + _PLACEMENT_ALLOWANCE + _PAD)
 
 
166
 
167
 
168
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
169
- def generate(prompt, image, last_image, height, width, num_frames, steps, seed,
170
- acceleration="Balanced", progress=gr.Progress(track_tqdm=True)):
171
- """Generate video with synchronized audio from text and optional keyframes."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  import torch
173
 
174
- # Move models to CUDA on each cold worker
175
- if COND_PIPE is not None:
176
- COND_PIPE.text_encoder.to("cuda")
177
- PIPE.to("cuda")
178
 
179
- # Local conditioning
180
- condition_state = COND_PIPE(
181
- prompt=prompt,
182
- image=image,
183
- last_image=last_image,
184
- height=int(height),
185
- width=int(width),
186
- )
187
- prompt_embeds = condition_state.get("prompt_embeds")
188
- text_token_tags = condition_state.get("text_token_tags")
189
 
190
- begin_request = getattr(PIPE.transformer, "begin_request", None)
191
- end_request = getattr(PIPE.transformer, "end_request", None)
192
- if begin_request is not None:
193
- begin_request(int(steps), acceleration)
194
- try:
195
- with torch.inference_mode():
196
- state = PIPE(
197
- prompt_embeds=prompt_embeds.to("cuda", non_blocking=True),
198
- text_token_tags=text_token_tags,
199
- image=image,
200
- last_image=last_image,
201
- height=int(height),
202
- width=int(width),
203
- num_frames=int(num_frames),
204
- num_inference_steps=int(steps),
205
- generator=torch.Generator("cpu").manual_seed(int(seed)),
206
- )
207
- finally:
208
- if end_request is not None:
209
- end_request()
210
 
211
- from diffusers.utils import encode_video
 
 
 
 
 
212
 
213
- videos = state.get("videos")
214
- audio = state.get("audio")
215
- sampling_rate = state.get("sampling_rate")
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
- directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  os.makedirs(directory, exist_ok=True)
219
- path = os.path.join(directory, f"h3-{int(time.time() * 1000)}.mp4")
220
- encode_video(
221
- videos[0],
222
- fps=FPS,
223
- output_path=path,
224
- audio=audio[0].cpu(),
225
- audio_sample_rate=sampling_rate,
226
- )
227
- return path
228
-
229
-
230
- def run_generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS,
231
- duration=5, steps=28, seed=42, acceleration="Balanced",
232
- progress=gr.Progress(track_tqdm=True)):
233
- """Handle a generation request from the Gradio UI."""
 
 
 
 
 
 
 
234
  if LOAD_ERROR:
235
  raise gr.Error(LOAD_ERROR)
236
  if PIPE is None:
237
- raise gr.Error("The model is still loading. Please wait a moment and try again.")
238
  if not prompt or not prompt.strip():
239
  raise gr.Error("MiniMax-H3 always takes a prompt, keyframes or not.")
240
 
241
  from PIL import Image, ImageOps
242
 
 
 
 
243
  num_frames = snap_frames(duration)
244
- height, width = CANVASES[canvas]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
  def keyframe(path):
 
 
247
  return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
248
 
249
- first_frame = keyframe(image_path) if image_path else None
250
- final_frame = keyframe(last_image_path) if last_image_path else None
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- progress(0.05, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
253
- path = generate(prompt, first_frame, final_frame, height, width, num_frames, steps, seed, acceleration, progress)
254
- return path
 
 
 
 
 
 
 
 
 
 
255
 
256
 
257
  def _fit_keyframe(image_path, current_canvas):
258
- """Cover-crop an uploaded keyframe to the closest supported aspect ratio."""
 
 
259
  if not image_path:
260
  return gr.update(), gr.update()
261
  from PIL import Image as _Image
@@ -292,74 +462,183 @@ def _fit_keyframe(image_path, current_canvas):
292
 
293
  load_models()
294
 
295
- INTRO = """# MiniMax-H3
296
 
297
  <div align="center">
 
298
  <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
299
- <a href="https://www.minimax.io/blog/minimax-h3" target="_blank" rel="noopener"><strong>[ blog ]</strong></a> &nbsp;
300
- <a href="https://huggingface.co/Plaguekind/Minimax-H3" target="_blank" rel="noopener"><strong>[ ComfyUI weights ]</strong></a>
301
  </div>
302
 
303
- **MiniMax-H3** is a 33B parameter state-of-the-art video generation model that produces video and a
304
- fully synchronized soundtrack (ambience, foley, speech). Supports text-to-video and first/last-frame-to-video.
 
 
305
  """
306
 
307
  CSS = """
308
  .main.fillable {max-width: 1250px !important}
309
  .dark .gradio-container { color: var(--body-text-color); }
 
310
  """
311
 
312
- with gr.Blocks(title="MiniMax-H3") as demo:
313
  gr.Markdown(INTRO)
 
314
 
315
  with gr.Row():
316
  with gr.Column():
317
  prompt = gr.Textbox(
318
  label="Prompt",
319
  lines=3,
320
- value="A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot",
 
 
 
 
 
 
321
  )
322
  with gr.Row():
323
- image = gr.Image(label="First frame (optional)", type="filepath")
324
- last_image = gr.Image(label="Last frame (optional)", type="filepath")
325
  run = gr.Button("Generate", variant="primary")
 
326
  with gr.Accordion("Advanced options", open=False):
327
- canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
328
- duration = gr.Slider(label="Duration (s)", minimum=MIN_UI_DURATION, maximum=MAX_UI_DURATION, step=1, value=5)
329
- acceleration = gr.Radio(
330
- label="Acceleration",
331
- choices=["Balanced", "Exact"],
332
- value="Balanced",
333
- info="Balanced uses adaptive step reuse; Exact evaluates every step.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  )
335
- steps = gr.Slider(label="Steps", minimum=10, maximum=40, step=1, value=28)
336
  seed = gr.Number(label="Seed", value=42, precision=0)
 
 
 
 
 
337
 
338
  with gr.Column():
339
  video = gr.Video(label="Video + soundtrack")
340
-
341
- image.upload(_fit_keyframe, [image, canvas], [image, canvas])
342
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  gr.Examples(
 
344
  examples=[
345
- ["A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot", None, None, "960x544 · 16:9 fast"],
346
- ["A busy night market, neon signs reflecting in puddles, sizzling street food", None, None, "544x960 · 9:16 fast"],
347
- ["A cellist playing a slow melody in an empty concert hall", None, None, "544x544 · 1:1 fast"],
348
  ],
349
- inputs=[prompt, image, last_image, canvas],
350
- outputs=[video],
351
- fn=run_generate,
352
  cache_examples=True,
353
  cache_mode="lazy",
354
  )
355
-
356
- run.click(
357
- run_generate,
358
- [prompt, image, last_image, canvas, duration, steps, seed, acceleration],
359
- [video],
360
- api_name="generate",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  )
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
  if __name__ == "__main__":
365
- demo.queue().launch(show_error=True, theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
 
1
+ """`Plaguekind/Minimax-H3` the PlagueKind V1.5 ComfyUI workflow for MiniMax-H3, as a Space.
2
 
3
+ The candidate repository holds no weights: it is a ComfyUI graph over `Comfy-Org/MiniMax-H3`, so what is
4
+ reproduced here is the *graph*, on the `MiniMaxAI/MiniMax-H3` diffusers checkpoint. See `pk_workflow.py` for the
5
+ node-by-node mapping; the short version is euler + `linear_quadratic` at 15 steps, FSR RCAS sharpening at 0.3, and
6
+ FILM 2x frame interpolation to 48 fps.
7
+
8
+ Deployment is the split one the unquantized MiniMax-H3 needs: 195.9 GiB of bfloat16 does not fit under a Space's
9
+ 150 GB storage quota, so the 62.14 GiB Qwen3-VL text encoder runs in a separate Space
10
+ (`multimodalart/qwen3vl-conditioner`) that this one calls per request, and this Space holds the 61.73 GiB
11
+ transformer and the two autoencoders. `prompt_embeds` + `text_token_tags` is the whole wire format.
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import os
 
 
 
 
 
 
 
17
  import tempfile
18
  import time
19
  import traceback
20
+ from functools import cache
21
 
22
+ # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen at
23
+ # startup rather than on GPU time.
24
+ import spaces
25
+ import gradio as gr
26
+
27
+ import pk_workflow as pk
28
 
29
+ MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
30
+ CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
31
+ # `pack` places the transformer at startup, `lazy` moves everything on the first GPU call.
32
+ PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
33
+ # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed. It is
34
+ # also the closest available stand-in for the workflow's SageAttention patch, which is a sm90 build.
35
+ ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
36
+ GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
37
+
38
+ # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not
39
+ # know is rejected there and surfaces as a failure here. This is the workflow's "Target Dimension" node.
40
  CANVASES = {
41
  # 16:9
42
  "960x544 · 16:9 fast": (544, 960),
 
60
  "1152x512 · 21:9 fast": (512, 1152),
61
  "1536x672 · 21:9 full": (672, 1536),
62
  }
63
+ # PlagueKind's V1.5 note: "FFLF is unreliable at res above 640". 960x544 keeps the short edge under that and is the
64
+ # canvas where the AoTI package pays most, so it is the default; the full 768 short edge is one dropdown away.
65
  DEFAULT_CANVAS = "960x544 · 16:9 fast"
66
+
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.
70
+ MIN_UI_DURATION, MAX_UI_DURATION = 2, 14
71
+
72
+ SCHEDULES = {
73
+ "linear_quadratic · PlagueKind": "linear_quadratic",
74
+ "native · shift 12": "native",
75
+ }
76
+ DEFAULT_SCHEDULE = "linear_quadratic · PlagueKind"
77
+ INTERPOLATION = {"off · 24 fps": 1, "2x · 48 fps (PlagueKind)": 2, "4x · 96 fps": 4}
78
+ DEFAULT_INTERPOLATION = "2x · 48 fps (PlagueKind)"
79
+ DEFAULT_SHARPEN = 0.3
80
+ DEFAULT_STEPS = 15
81
 
82
 
83
  def snap_frames(seconds: float) -> int:
84
+ """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps.
85
+
86
+ Identical to the workflow's `ComfyMathExpression`,
87
+ `max(5, round(a*24)) + (5 - (max(5, round(a*24)) % 17)) % 17` — 5 s is 124 frames, i.e. 5.167 s.
88
+ """
89
  frames = max(1, round(float(seconds) * FPS))
90
  while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
91
  frames += 1
 
93
 
94
 
95
  def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
96
+ """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
97
  from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
98
 
99
  MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
100
 
101
 
102
  PIPE = None
103
+ FILM = None
104
+ FILM_ERROR: str | None = None
105
  LOAD_ERROR: str | None = None
106
  LOADED_IN: float | None = None
107
 
108
 
109
+ def status() -> str:
110
+ if LOAD_ERROR:
111
+ return LOAD_ERROR
112
+ if PIPE is None:
113
+ return f"Loading `{MODEL_REPO}` (transformer + VAEs, 77.3 GB). Watch the Space logs."
114
+ import h3_aoti
115
+
116
+ film = "FILM **ready**" if FILM is not None else f"FILM **off** ({FILM_ERROR})"
117
+ return (
118
+ f"Ready · transformer + VAEs **bfloat16, unquantized** · placement `{PLACEMENT}` · attention "
119
+ f"`{ATTENTION}` · {h3_aoti.status()} · {film} · loaded in {LOADED_IN:.0f}s · conditioner "
120
+ f"`{CONDITIONER_SPACE}`"
121
+ )
122
+
123
+
124
  def load_models() -> str | None:
125
+ """Load the denoising half at startup, plus FILM.
126
 
127
+ `MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`, the two schedulers and `video_processor`,
128
+ so `load_components` fetches exactly those subfolders `text_encoder/` and `transformer_ref/` are never
129
+ touched. Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a bfloat16 audio
130
+ VAE decodes the soundtrack roughly 20 dB too quiet.
131
  """
132
+ global PIPE, FILM, FILM_ERROR, LOAD_ERROR, LOADED_IN
133
 
134
  if PIPE is not None or LOAD_ERROR is not None:
135
  return LOAD_ERROR
 
146
  blocks = MiniMaxH3GeneratorBlocks()
147
  print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
148
  pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
149
+ pipe.load_components(dtype=torch.bfloat16)
150
+ pipe.transformer.set_attention_backend(ATTENTION)
151
 
152
+ # Still startup, still free: an AoTI package carries no weights and opens its archive lazily inside the GPU
153
+ # worker.
154
+ import h3_aoti
 
 
 
 
 
155
 
156
+ h3_aoti.maybe_load(pipe.transformer)
 
157
 
158
+ if PLACEMENT == "pack":
159
+ # Scoped to the transformer. `spaces` packs every startup-resident CUDA tensor into a second on-disk
160
+ # copy, and packing all 77.3 GB busts the 150 GB storage quota; the 61.7 GB transformer alone fits. The
161
+ # ~10 GB of fp32 VAEs move on the first GPU call instead.
162
+ pipe.transformer.to("cuda")
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
+ PIPE = pipe
165
  LOADED_IN = time.time() - started
166
  print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
167
  except Exception as error:
168
  traceback.print_exc()
169
+ LOAD_ERROR = (
170
+ f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
171
+ f"`{type(error).__name__}: {error}`"
172
+ )
173
+ return LOAD_ERROR
174
+
175
+ # 69 MB of post-processing, and the demo is still a demo without it, so a failure here is not fatal.
176
+ try:
177
+ FILM = pk.load_film()
178
+ print("[gen] FILM loaded", flush=True)
179
+ except Exception as error:
180
+ FILM_ERROR = f"{type(error).__name__}: {error}"
181
+ print(f"[gen] FILM unavailable ({FILM_ERROR}); frame interpolation disabled", flush=True)
182
+
183
  return LOAD_ERROR
184
 
185
 
186
+ @cache
187
+ def conditioner():
188
+ """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so
189
+ the conditioner's booking is billed to whoever asked for the video."""
190
+ from gradio_client import Client
 
 
 
 
191
 
192
+ return Client(CONDITIONER_SPACE)
193
+
194
+
195
+ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False):
196
+ """`/encode` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with the
197
+ resolved `height` / `width` / `num_frames` in its metadata, plus the plan. `canvas` is the label."""
198
+ from gradio_client import handle_file
199
+ from safetensors import safe_open
200
+
201
+ path, plan = conditioner().predict(
202
+ prompt=prompt,
203
+ image_path=handle_file(image_path) if image_path else None,
204
+ last_image_path=handle_file(last_image_path) if last_image_path else None,
205
+ canvas=canvas,
206
+ num_frames=num_frames,
207
+ rewrite_prompt=bool(rewrite_prompt),
208
+ api_name="/encode",
209
+ )
210
+ with safe_open(path, framework="pt") as handle:
211
+ metadata = handle.metadata()
212
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
213
 
214
+
215
+ # Seconds of GPU one request needs, from the packed video rows it is about to denoise: linear in the rows for the
216
+ # matmuls, quadratic for the attention, against the AoTI block package this Space runs.
217
  _DUR_B, _DUR_C = 1.1745e-4, 3.8396e-9
218
+ # The two resident decoders, the post chain and the mux, which scale with the output rather than with the step
219
+ # count. `_DEFAULT_CANVAS_PIXELS` is 960x544x124, the default request.
220
  _DECODE_BASE, _DECODE_PER_DEFAULT_CANVAS, _DEFAULT_CANVAS_PIXELS = 15, 15, 960 * 544 * 124
221
+ # FILM, per *emitted* intermediate frame at the default canvas. Re-measured against the live Space; a 2x pass over
222
+ # 124 frames at 960x544 is 123 of them.
223
+ _FILM_PER_FRAME = 0.16
224
+ # `pack` mode: only the ~10 GB of VAEs move on a cold worker.
225
  _PLACEMENT_ALLOWANCE, _PAD = 12, 10
226
 
227
 
228
+ def get_duration(
229
+ prompt_embeds,
230
+ text_token_tags,
231
+ first_frame,
232
+ last_frame,
233
+ height,
234
+ width,
235
+ num_frames,
236
+ steps,
237
+ schedule,
238
+ sharpen,
239
+ multiplier,
240
+ seed,
241
+ *a,
242
+ **k,
243
+ ):
244
  height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
245
+ multiplier = max(1, int(multiplier))
246
  latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
247
  patches = (height // 32) * (width // 32)
248
+ keyframes = int(first_frame is not None) + int(last_frame is not None)
249
+ rows = latent_frames * patches + keyframes * patches
250
  denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
251
+ pixel_ratio = (height * width) / (960 * 544)
252
  decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
253
+ film = 0.0
254
+ if multiplier > 1 and FILM is not None:
255
+ film = (num_frames - 1) * (multiplier - 1) * _FILM_PER_FRAME * pixel_ratio
256
+ return max(60, int(denoise + decode + film) + _PLACEMENT_ALLOWANCE + _PAD)
257
 
258
 
259
  @spaces.GPU(duration=get_duration, size=GPU_SIZE)
260
+ def _generate(
261
+ prompt_embeds,
262
+ text_token_tags,
263
+ first_frame,
264
+ last_frame,
265
+ height,
266
+ width,
267
+ num_frames,
268
+ steps,
269
+ schedule,
270
+ sharpen,
271
+ multiplier,
272
+ seed,
273
+ ):
274
+ """The only thing on GPU time: the denoise loop, the two decoders and the workflow's post chain.
275
+
276
+ The mp4 is muxed here rather than in the caller: a `@spaces.GPU` return crosses a process boundary by pickling,
277
+ and a 2x-interpolated 124-frame clip is several hundred MB of frames against a few MB of h264.
278
+ """
279
  import torch
280
 
281
+ from diffusers.utils import encode_video
 
 
 
282
 
283
+ global FILM
 
 
 
 
 
 
 
 
 
284
 
285
+ if PLACEMENT == "lazy":
286
+ PIPE.to("cuda")
287
+ elif PLACEMENT == "pack":
288
+ PIPE.vae.to("cuda")
289
+ PIPE.audio_vae.to("cuda")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
+ steps = int(steps)
292
+ multiplier = max(1, int(multiplier))
293
+ linear_quadratic = schedule == "linear_quadratic"
294
+ # `linear_quadratic` hands `set_timesteps` a finished `steps + 1` sigma grid, so it runs `steps` forwards. The
295
+ # native grid counts its terminal zero as one of `num_inference_steps`, so it needs one more to match.
296
+ requested_steps = steps if linear_quadratic else steps + 1
297
 
298
+ started = time.time()
299
+ with pk.use_linear_quadratic(PIPE, steps, enabled=linear_quadratic):
300
+ state = PIPE(
301
+ prompt_embeds=prompt_embeds.to("cuda"),
302
+ text_token_tags=text_token_tags,
303
+ image=first_frame,
304
+ last_image=last_frame,
305
+ height=height,
306
+ width=width,
307
+ num_frames=num_frames,
308
+ num_inference_steps=requested_steps,
309
+ output_type="pt",
310
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
311
+ )
312
+ denoised = time.time() - started
313
 
314
+ video = state.get("videos")[0] # (frames, 3, H, W), float in [0, 1], on the card
315
+ audio = state.get("audio")[0].cpu()
316
+ sampling_rate = state.get("sampling_rate")
317
+ del state
318
+ # The post chain runs on the allocator the denoise loop just left fragmented (78.5 GiB at the full canvas), and
319
+ # RCAS and FILM both want a few contiguous gigabytes.
320
+ torch.cuda.empty_cache()
321
+
322
+ post = time.time()
323
+ video = pk.rcas(video, float(sharpen))
324
+ if multiplier > 1:
325
+ if FILM is None:
326
+ multiplier = 1
327
+ else:
328
+ FILM = FILM.to("cuda")
329
+ video = pk.interpolate(FILM, video, multiplier)
330
+ fps = FPS * multiplier
331
+ frames = (video.permute(0, 2, 3, 1).float() * 255.0).round_().clamp_(0, 255).to(torch.uint8).cpu()
332
+ del video
333
+ post_seconds = time.time() - post
334
+
335
+ directory = os.path.join(tempfile.gettempdir(), "pk-h3-outputs")
336
  os.makedirs(directory, exist_ok=True)
337
+ path = os.path.join(directory, f"pk-h3-{int(time.time() * 1000)}.mp4")
338
+ encode_video(frames, fps=fps, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
339
+
340
+ return path, denoised, post_seconds, int(frames.shape[0]), fps, multiplier
341
+
342
+
343
+ def generate(
344
+ prompt,
345
+ canvas=DEFAULT_CANVAS,
346
+ first_frame=None,
347
+ last_frame=None,
348
+ duration=5,
349
+ steps=DEFAULT_STEPS,
350
+ schedule=DEFAULT_SCHEDULE,
351
+ sharpen=DEFAULT_SHARPEN,
352
+ interpolation=DEFAULT_INTERPOLATION,
353
+ seed=42,
354
+ upsample=False,
355
+ progress=gr.Progress(track_tqdm=True),
356
+ ):
357
+ """One request through the PlagueKind graph. Every parameter but the prompt carries the default its UI
358
+ component carries, so an example that fills only `prompt` (and `canvas`) behaves exactly like the button."""
359
  if LOAD_ERROR:
360
  raise gr.Error(LOAD_ERROR)
361
  if PIPE is None:
362
+ raise gr.Error("The denoiser is still loading.")
363
  if not prompt or not prompt.strip():
364
  raise gr.Error("MiniMax-H3 always takes a prompt, keyframes or not.")
365
 
366
  from PIL import Image, ImageOps
367
 
368
+ canvas = canvas or DEFAULT_CANVAS
369
+ schedule_key = SCHEDULES.get(schedule, "linear_quadratic")
370
+ multiplier = INTERPOLATION.get(interpolation, 2)
371
  num_frames = snap_frames(duration)
372
+
373
+ progress(
374
+ 0.0,
375
+ desc=(
376
+ f"Upsampling the prompt on {CONDITIONER_SPACE} ..."
377
+ if upsample
378
+ else f"Conditioning on {CONDITIONER_SPACE} ..."
379
+ ),
380
+ )
381
+ conditioned = time.time()
382
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(
383
+ prompt, first_frame, last_frame, canvas, num_frames, rewrite_prompt=upsample
384
+ )
385
+ condition_seconds = time.time() - conditioned
386
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
387
+ refined = plan.get("refined_prompt") or ""
388
 
389
  def keyframe(path):
390
+ # The conditioning latents encoded here have to be of the image the conditioner looked at, which it
391
+ # prepares exactly this way.
392
  return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
393
 
394
+ progress(0.1, desc=f"Denoising {int(steps)} steps at {width}x{height}, {num_frames} frames ...")
395
+ path, denoise_seconds, post_seconds, out_frames, fps, multiplier = _generate(
396
+ prompt_embeds,
397
+ text_token_tags,
398
+ keyframe(first_frame),
399
+ keyframe(last_frame),
400
+ height,
401
+ width,
402
+ num_frames,
403
+ int(steps),
404
+ schedule_key,
405
+ float(sharpen),
406
+ multiplier,
407
+ int(seed),
408
+ )
409
 
410
+ post = [f"RCAS {float(sharpen):.2f}" if float(sharpen) > 0 else "no sharpening"]
411
+ post.append(f"FILM {multiplier}x -> {fps} fps" if multiplier > 1 else f"{fps} fps")
412
+ report = (
413
+ f"`{width}x{height}`, {num_frames} frames ({num_frames / FPS:.3f} s) -> {out_frames} frames at {fps} fps · "
414
+ f"{int(steps)} steps of `{schedule_key}` · {' · '.join(post)} · seed {int(seed)}\n\n"
415
+ f"conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens"
416
+ f"{', upsampled' if refined else ''}) · denoise + decode {denoise_seconds:.0f}s "
417
+ f"({denoise_seconds / max(1, int(steps)):.1f} s/step) · post {post_seconds:.0f}s"
418
+ )
419
+ if refined:
420
+ report += f"\n\n**Upsampled prompt**\n\n{refined}"
421
+ print(f"[gen] {report}", flush=True)
422
+ return path, report
423
 
424
 
425
  def _fit_keyframe(image_path, current_canvas):
426
+ """Cover-crop an uploaded keyframe to the closest supported aspect ratio and select that ratio's smallest
427
+ (fastest) canvas, unless the user already picked a matching ratio. The workflow's "Target Dimension" node does
428
+ the same job by hand."""
429
  if not image_path:
430
  return gr.update(), gr.update()
431
  from PIL import Image as _Image
 
462
 
463
  load_models()
464
 
465
+ INTRO = """# PlagueKind · MiniMax-H3
466
 
467
  <div align="center">
468
+ <a href="https://huggingface.co/Plaguekind/Minimax-H3" target="_blank" rel="noopener"><strong>[ workflow ]</strong></a> &nbsp;
469
  <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
470
+ <a href="https://github.com/PlagueKind/Comfyui-PlagueKind-Nodes" target="_blank" rel="noopener"><strong>[ nodes ]</strong></a>
 
471
  </div>
472
 
473
+ **MiniMax-H3** is a 33B parameter video generation model that produces video and a fully synchronized soundtrack
474
+ (ambience, foley, speech) in one pass. **PlagueKind's V1.5 workflow** is a tuning of it: euler on a
475
+ `linear_quadratic` sigma grid at 15 steps, FSR **RCAS** sharpening at 0.3, and **FILM** 2x frame interpolation to
476
+ 48 fps. Text-to-video, first frame, last frame, or both.
477
  """
478
 
479
  CSS = """
480
  .main.fillable {max-width: 1250px !important}
481
  .dark .gradio-container { color: var(--body-text-color); }
482
+ .status p {font-size: 0.8rem; opacity: 0.65; text-align: center;}
483
  """
484
 
485
+ with gr.Blocks(title="PlagueKind · MiniMax-H3", theme=gr.themes.Citrus(), css=CSS) as demo:
486
  gr.Markdown(INTRO)
487
+ gr.Markdown(status(), elem_classes="status")
488
 
489
  with gr.Row():
490
  with gr.Column():
491
  prompt = gr.Textbox(
492
  label="Prompt",
493
  lines=3,
494
+ value=(
495
+ "A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot, "
496
+ "distant birdsong"
497
+ ),
498
+ )
499
+ canvas = gr.Dropdown(
500
+ label="Target dimension", choices=list(CANVASES), value=DEFAULT_CANVAS
501
  )
502
  with gr.Row():
503
+ first_frame = gr.Image(label="First frame (optional)", type="filepath")
504
+ last_frame = gr.Image(label="Last frame (optional)", type="filepath")
505
  run = gr.Button("Generate", variant="primary")
506
+
507
  with gr.Accordion("Advanced options", open=False):
508
+ duration = gr.Slider(
509
+ label="Duration (s)",
510
+ minimum=MIN_UI_DURATION,
511
+ maximum=MAX_UI_DURATION,
512
+ step=1,
513
+ value=5,
514
+ )
515
+ steps = gr.Slider(
516
+ label="Steps",
517
+ minimum=8,
518
+ maximum=40,
519
+ step=1,
520
+ value=DEFAULT_STEPS,
521
+ info="PlagueKind: 15-20 on the linear_quadratic grid.",
522
+ )
523
+ schedule = gr.Dropdown(
524
+ label="Sigma schedule",
525
+ choices=list(SCHEDULES),
526
+ value=DEFAULT_SCHEDULE,
527
+ info="`linear_quadratic` front-loads half the steps into the first 2.5% of the trajectory.",
528
+ )
529
+ sharpen = gr.Slider(
530
+ label="RCAS sharpening",
531
+ minimum=0.0,
532
+ maximum=1.0,
533
+ step=0.05,
534
+ value=DEFAULT_SHARPEN,
535
+ info="FidelityFX Robust Contrast Adaptive Sharpening. PlagueKind: 0.3 is very natural.",
536
+ )
537
+ interpolation = gr.Dropdown(
538
+ label="FILM frame interpolation",
539
+ choices=list(INTERPOLATION),
540
+ value=DEFAULT_INTERPOLATION,
541
+ info="MiniMax-H3 generates 24 fps; FILM synthesizes the frames in between.",
542
  )
 
543
  seed = gr.Number(label="Seed", value=42, precision=0)
544
+ upsample = gr.Checkbox(
545
+ label="Upsample prompt",
546
+ value=False,
547
+ info="Rewrite the prompt on the conditioner Space first, MiniMax's Context-IR style.",
548
+ )
549
 
550
  with gr.Column():
551
  video = gr.Video(label="Video + soundtrack")
552
+ report = gr.Markdown()
553
+
554
+ first_frame.upload(_fit_keyframe, [first_frame, canvas], [first_frame, canvas])
555
+ last_frame.upload(_fit_keyframe, [last_frame, canvas], [last_frame, canvas])
556
+
557
+ controls = [
558
+ prompt,
559
+ canvas,
560
+ first_frame,
561
+ last_frame,
562
+ duration,
563
+ steps,
564
+ schedule,
565
+ sharpen,
566
+ interpolation,
567
+ seed,
568
+ upsample,
569
+ ]
570
+
571
+ # Two blocks rather than one with empty keyframe cells: the positional order below matches `generate`'s
572
+ # signature, so each block fills a prefix of it and the defaults cover the rest.
573
  gr.Examples(
574
+ label="Text to video",
575
  examples=[
576
+ ["A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot", DEFAULT_CANVAS],
577
+ ["A busy night market, neon signs reflecting in puddles, sizzling street food", "544x960 · 9:16 fast"],
578
+ ["A cellist playing a slow melody in an empty concert hall", "544x544 · 1:1 fast"],
579
  ],
580
+ inputs=[prompt, canvas],
581
+ outputs=[video, report],
582
+ fn=generate,
583
  cache_examples=True,
584
  cache_mode="lazy",
585
  )
586
+ gr.Examples(
587
+ label="First and last frame",
588
+ examples=[
589
+ [
590
+ "A slow seamless camera move from the first view to the last, wind in the pines",
591
+ "1152x640 · 16:9",
592
+ "examples/first.png",
593
+ "examples/last.png",
594
+ ],
595
+ [
596
+ "The fox looks around, then trots deeper into the forest",
597
+ "1152x640 · 16:9",
598
+ "examples/first.png",
599
+ "examples/first.png",
600
+ ],
601
+ ],
602
+ inputs=[prompt, canvas, first_frame, last_frame],
603
+ outputs=[video, report],
604
+ fn=generate,
605
+ cache_examples=True,
606
+ cache_mode="lazy",
607
  )
608
 
609
+ with gr.Accordion("What this workflow changes, and what it cannot", open=False):
610
+ gr.Markdown(
611
+ """
612
+ `Plaguekind/Minimax-H3` ships no weights — it is a ComfyUI graph over `Comfy-Org/MiniMax-H3`. Its nodes map onto
613
+ this Space as:
614
+
615
+ | ComfyUI node | widget | here |
616
+ |---|---|---|
617
+ | `KSamplerSelect` | `euler` | MiniMax-H3's only sampler; the checkpoint is CFG-distilled, so one forward per step and no negative prompt |
618
+ | `BasicScheduler` | `linear_quadratic`, 15 steps | **Sigma schedule** / **Steps** |
619
+ | `MiniMaxH3ImageToVideo` | prompt, first/last frame | **Prompt** / **First frame** / **Last frame** |
620
+ | `UnifiedResizeImageMask` ("Target Dimension") | 1344x768 | **Target dimension** |
621
+ | `ImageSharpenKJ` | `rcas`, 0.3 | **RCAS sharpening** |
622
+ | `FrameInterpolate` + `film_net_fp16` | multiplier 2 | **FILM frame interpolation** |
623
+ | `CreateVideo` | fps `24 * 2` | 48 fps output |
624
+ | `RTXVideoSuperResolution` | 2x `ULTRA` | **not reproduced** |
625
+ | `PathchSageAttentionKJ` | `sageattn_qk_int8_pv_fp8_cuda++` | cuDNN fused attention |
626
+
627
+ Two deliberate deviations. `RTXVideoSuperResolution` is NVIDIA's NGX super-resolution, shipped as a driver-level
628
+ Windows/RTX component with no Linux Python path, so the 2x upscale is missing — pick a larger **Target dimension**
629
+ instead of upscaling a small one. And SageAttention's `qk_int8_pv_fp8_cuda++` kernel is not built for this pool's
630
+ sm120 cards, so attention runs cuDNN's fused kernel, which is the fastest available here and is numerically the
631
+ faithful one (SageAttention is a quantized approximation).
632
+
633
+ One upgrade: the workflow loads `minimax_h3_fl2va_pruned_int8_convrot.safetensors` and a
634
+ `qwen3vl_32b_..._int8_convrot` text encoder because that is what fits a consumer card. This Space runs both
635
+ **unquantized bfloat16**, off `MiniMaxAI/MiniMax-H3`, with the 62 GiB text encoder in a
636
+ [second Space](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner).
637
+ """
638
+ )
639
+
640
+ run.click(generate, controls, [video, report], api_name="generate")
641
+
642
 
643
  if __name__ == "__main__":
644
+ demo.launch(show_error=True)
examples/first.png ADDED
examples/last.png ADDED
film_net.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FILM: Frame Interpolation for Large Motion (ECCV 2022).
2
+
3
+ Vendored verbatim from ComfyUI (`comfy_extras/frame_interpolation_models/film_net.py`,
4
+ https://github.com/comfyanonymous/ComfyUI, GPL-3.0) apart from the two lines below: ComfyUI's
5
+ `comfy.ops.disable_weight_init` is only `torch.nn` with the parameter initialisers turned into no-ops, and this Space
6
+ loads a checkpoint over every parameter anyway, so plain `torch.nn` is a drop-in.
7
+
8
+ This is the `FrameInterpolate` half of the PlagueKind workflow, which runs `film_net_fp16.safetensors`
9
+ (`Comfy-Org/frame_interpolation`) at multiplier 2 to take MiniMax-H3's 24 fps output to 48 fps.
10
+
11
+ Because of this file the Space as a whole is GPL-3.0.
12
+ """
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+ ops = nn
19
+
20
+
21
+ class FilmConv2d(nn.Module):
22
+ """Conv2d with optional LeakyReLU and FILM-style padding."""
23
+
24
+ def __init__(self, in_channels, out_channels, size, activation=True, device=None, dtype=None, operations=ops):
25
+ super().__init__()
26
+ self.even_pad = not size % 2
27
+ self.conv = operations.Conv2d(in_channels, out_channels, kernel_size=size, padding=size // 2 if size % 2 else 0, device=device, dtype=dtype)
28
+ self.activation = nn.LeakyReLU(0.2) if activation else None
29
+
30
+ def forward(self, x):
31
+ if self.even_pad:
32
+ x = F.pad(x, (0, 1, 0, 1))
33
+ x = self.conv(x)
34
+ if self.activation is not None:
35
+ x = self.activation(x)
36
+ return x
37
+
38
+
39
+ def _warp_core(image, flow, grid_x, grid_y):
40
+ dtype = image.dtype
41
+ H, W = flow.shape[2], flow.shape[3]
42
+ dx = flow[:, 0].float() / (W * 0.5)
43
+ dy = flow[:, 1].float() / (H * 0.5)
44
+ grid = torch.stack([grid_x[None, None, :] + dx, grid_y[None, :, None] + dy], dim=3)
45
+ return F.grid_sample(image.float(), grid, mode="bilinear", padding_mode="border", align_corners=False).to(dtype)
46
+
47
+
48
+ def build_image_pyramid(image, pyramid_levels):
49
+ pyramid = [image]
50
+ for _ in range(1, pyramid_levels):
51
+ image = F.avg_pool2d(image, 2, 2)
52
+ pyramid.append(image)
53
+ return pyramid
54
+
55
+
56
+ def flow_pyramid_synthesis(residual_pyramid):
57
+ flow = residual_pyramid[-1]
58
+ flow_pyramid = [flow]
59
+ for residual_flow in residual_pyramid[:-1][::-1]:
60
+ flow = F.interpolate(flow, size=residual_flow.shape[2:4], mode="bilinear", scale_factor=None).mul_(2).add_(residual_flow)
61
+ flow_pyramid.append(flow)
62
+ flow_pyramid.reverse()
63
+ return flow_pyramid
64
+
65
+
66
+ def multiply_pyramid(pyramid, scalar):
67
+ return [image * scalar[:, None, None, None] for image in pyramid]
68
+
69
+
70
+ def pyramid_warp(feature_pyramid, flow_pyramid, warp_fn):
71
+ return [warp_fn(features, flow) for features, flow in zip(feature_pyramid, flow_pyramid)]
72
+
73
+
74
+ def concatenate_pyramids(pyramid1, pyramid2):
75
+ return [torch.cat([f1, f2], dim=1) for f1, f2 in zip(pyramid1, pyramid2)]
76
+
77
+
78
+ class SubTreeExtractor(nn.Module):
79
+ def __init__(self, in_channels=3, channels=64, n_layers=4, device=None, dtype=None, operations=ops):
80
+ super().__init__()
81
+ convs = []
82
+ for i in range(n_layers):
83
+ out_ch = channels << i
84
+ convs.append(nn.Sequential(
85
+ FilmConv2d(in_channels, out_ch, 3, device=device, dtype=dtype, operations=operations),
86
+ FilmConv2d(out_ch, out_ch, 3, device=device, dtype=dtype, operations=operations)))
87
+ in_channels = out_ch
88
+ self.convs = nn.ModuleList(convs)
89
+
90
+ def forward(self, image, n):
91
+ head = image
92
+ pyramid = []
93
+ for i, layer in enumerate(self.convs):
94
+ head = layer(head)
95
+ pyramid.append(head)
96
+ if i < n - 1:
97
+ head = F.avg_pool2d(head, 2, 2)
98
+ return pyramid
99
+
100
+
101
+ class FeatureExtractor(nn.Module):
102
+ def __init__(self, in_channels=3, channels=64, sub_levels=4, device=None, dtype=None, operations=ops):
103
+ super().__init__()
104
+ self.extract_sublevels = SubTreeExtractor(in_channels, channels, sub_levels, device=device, dtype=dtype, operations=operations)
105
+ self.sub_levels = sub_levels
106
+
107
+ def forward(self, image_pyramid):
108
+ sub_pyramids = [self.extract_sublevels(image_pyramid[i], min(len(image_pyramid) - i, self.sub_levels))
109
+ for i in range(len(image_pyramid))]
110
+ feature_pyramid = []
111
+ for i in range(len(image_pyramid)):
112
+ features = sub_pyramids[i][0]
113
+ for j in range(1, self.sub_levels):
114
+ if j <= i:
115
+ features = torch.cat([features, sub_pyramids[i - j][j]], dim=1)
116
+ feature_pyramid.append(features)
117
+ # Free sub-pyramids no longer needed by future levels
118
+ if i >= self.sub_levels - 1:
119
+ sub_pyramids[i - self.sub_levels + 1] = None
120
+ return feature_pyramid
121
+
122
+
123
+ class FlowEstimator(nn.Module):
124
+ def __init__(self, in_channels, num_convs, num_filters, device=None, dtype=None, operations=ops):
125
+ super().__init__()
126
+ self._convs = nn.ModuleList()
127
+ for _ in range(num_convs):
128
+ self._convs.append(FilmConv2d(in_channels, num_filters, 3, device=device, dtype=dtype, operations=operations))
129
+ in_channels = num_filters
130
+ self._convs.append(FilmConv2d(in_channels, num_filters // 2, 1, device=device, dtype=dtype, operations=operations))
131
+ self._convs.append(FilmConv2d(num_filters // 2, 2, 1, activation=False, device=device, dtype=dtype, operations=operations))
132
+
133
+ def forward(self, features_a, features_b):
134
+ net = torch.cat([features_a, features_b], dim=1)
135
+ for conv in self._convs:
136
+ net = conv(net)
137
+ return net
138
+
139
+
140
+ class PyramidFlowEstimator(nn.Module):
141
+ def __init__(self, filters=64, flow_convs=(3, 3, 3, 3), flow_filters=(32, 64, 128, 256), device=None, dtype=None, operations=ops):
142
+ super().__init__()
143
+ in_channels = filters << 1
144
+ predictors = []
145
+ for i in range(len(flow_convs)):
146
+ predictors.append(FlowEstimator(in_channels, flow_convs[i], flow_filters[i], device=device, dtype=dtype, operations=operations))
147
+ in_channels += filters << (i + 2)
148
+ self._predictor = predictors[-1]
149
+ self._predictors = nn.ModuleList(predictors[:-1][::-1])
150
+
151
+ def forward(self, feature_pyramid_a, feature_pyramid_b, warp_fn):
152
+ levels = len(feature_pyramid_a)
153
+ v = self._predictor(feature_pyramid_a[-1], feature_pyramid_b[-1])
154
+ residuals = [v]
155
+ # Coarse-to-fine: shared predictor for deep levels, then specialized predictors for fine levels
156
+ steps = [(i, self._predictor) for i in range(levels - 2, len(self._predictors) - 1, -1)]
157
+ steps += [(len(self._predictors) - 1 - k, p) for k, p in enumerate(self._predictors)]
158
+ for i, predictor in steps:
159
+ v = F.interpolate(v, size=feature_pyramid_a[i].shape[2:4], mode="bilinear").mul_(2)
160
+ v_residual = predictor(feature_pyramid_a[i], warp_fn(feature_pyramid_b[i], v))
161
+ residuals.append(v_residual)
162
+ v = v.add_(v_residual)
163
+ residuals.reverse()
164
+ return residuals
165
+
166
+
167
+ def _get_fusion_channels(level, filters):
168
+ # Per direction: multi-scale features + RGB image (3ch) + flow (2ch), doubled for both directions
169
+ return (sum(filters << i for i in range(level)) + 3 + 2) * 2
170
+
171
+
172
+ class Fusion(nn.Module):
173
+ def __init__(self, n_layers=4, specialized_layers=3, filters=64, device=None, dtype=None, operations=ops):
174
+ super().__init__()
175
+ self.output_conv = operations.Conv2d(filters, 3, kernel_size=1, device=device, dtype=dtype)
176
+ self.convs = nn.ModuleList()
177
+ in_channels = _get_fusion_channels(n_layers, filters)
178
+ increase = 0
179
+ for i in range(n_layers)[::-1]:
180
+ num_filters = (filters << i) if i < specialized_layers else (filters << specialized_layers)
181
+ self.convs.append(nn.ModuleList([
182
+ FilmConv2d(in_channels, num_filters, 2, activation=False, device=device, dtype=dtype, operations=operations),
183
+ FilmConv2d(in_channels + (increase or num_filters), num_filters, 3, device=device, dtype=dtype, operations=operations),
184
+ FilmConv2d(num_filters, num_filters, 3, device=device, dtype=dtype, operations=operations)]))
185
+ in_channels = num_filters
186
+ increase = _get_fusion_channels(i, filters) - num_filters // 2
187
+
188
+ def forward(self, pyramid):
189
+ net = pyramid[-1]
190
+ for k, layers in enumerate(self.convs):
191
+ i = len(self.convs) - 1 - k
192
+ net = layers[0](F.interpolate(net, size=pyramid[i].shape[2:4], mode="nearest"))
193
+ net = layers[2](layers[1](torch.cat([pyramid[i], net], dim=1)))
194
+ return self.output_conv(net)
195
+
196
+
197
+ class FILMNet(nn.Module):
198
+ def __init__(self, pyramid_levels=7, fusion_pyramid_levels=5, specialized_levels=3, sub_levels=4,
199
+ filters=64, flow_convs=(3, 3, 3, 3), flow_filters=(32, 64, 128, 256), device=None, dtype=None, operations=ops):
200
+ super().__init__()
201
+ self.pyramid_levels = pyramid_levels
202
+ self.fusion_pyramid_levels = fusion_pyramid_levels
203
+ self.extract = FeatureExtractor(3, filters, sub_levels, device=device, dtype=dtype, operations=operations)
204
+ self.predict_flow = PyramidFlowEstimator(filters, flow_convs, flow_filters, device=device, dtype=dtype, operations=operations)
205
+ self.fuse = Fusion(sub_levels, specialized_levels, filters, device=device, dtype=dtype, operations=operations)
206
+ self._warp_grids = {}
207
+
208
+ def get_dtype(self):
209
+ return self.extract.extract_sublevels.convs[0][0].conv.weight.dtype
210
+
211
+ def memory_used_forward(self, shape, dtype):
212
+ return 1700 * shape[1] * shape[2] * dtype.itemsize
213
+
214
+ def _build_warp_grids(self, H, W, device):
215
+ """Pre-compute warp grids for all pyramid levels."""
216
+ if (H, W) in self._warp_grids:
217
+ return
218
+ self._warp_grids = {} # clear old resolution grids to prevent memory leaks
219
+ for _ in range(self.pyramid_levels):
220
+ self._warp_grids[(H, W)] = (
221
+ torch.linspace(-(1 - 1 / W), 1 - 1 / W, W, dtype=torch.float32, device=device),
222
+ torch.linspace(-(1 - 1 / H), 1 - 1 / H, H, dtype=torch.float32, device=device),
223
+ )
224
+ H, W = H // 2, W // 2
225
+
226
+ def warp(self, image, flow):
227
+ grid_x, grid_y = self._warp_grids[(flow.shape[2], flow.shape[3])]
228
+ return _warp_core(image, flow, grid_x, grid_y)
229
+
230
+ def extract_features(self, img):
231
+ """Extract image and feature pyramids for a single frame. Can be cached across pairs."""
232
+ image_pyramid = build_image_pyramid(img, self.pyramid_levels)
233
+ feature_pyramid = self.extract(image_pyramid)
234
+ return image_pyramid, feature_pyramid
235
+
236
+ def forward(self, img0, img1, timestep=0.5, cache=None):
237
+ # FILM uses a scalar timestep per batch element (spatially-varying timesteps not supported)
238
+ t = timestep.mean(dim=(1, 2, 3)).item() if isinstance(timestep, torch.Tensor) else timestep
239
+ return self.forward_multi_timestep(img0, img1, [t], cache=cache)
240
+
241
+ def forward_multi_timestep(self, img0, img1, timesteps, cache=None):
242
+ """Compute flow once, synthesize at multiple timesteps. Expects batch=1 inputs."""
243
+ self._build_warp_grids(img0.shape[2], img0.shape[3], img0.device)
244
+
245
+ image_pyr0, feat_pyr0 = cache["img0"] if cache and "img0" in cache else self.extract_features(img0)
246
+ image_pyr1, feat_pyr1 = cache["img1"] if cache and "img1" in cache else self.extract_features(img1)
247
+
248
+ fwd_flow = flow_pyramid_synthesis(self.predict_flow(feat_pyr0, feat_pyr1, self.warp))[:self.fusion_pyramid_levels]
249
+ bwd_flow = flow_pyramid_synthesis(self.predict_flow(feat_pyr1, feat_pyr0, self.warp))[:self.fusion_pyramid_levels]
250
+
251
+ # Build warp targets and free full pyramids (only first fpl levels needed from here)
252
+ fpl = self.fusion_pyramid_levels
253
+ p2w = [concatenate_pyramids(image_pyr0[:fpl], feat_pyr0[:fpl]),
254
+ concatenate_pyramids(image_pyr1[:fpl], feat_pyr1[:fpl])]
255
+ del image_pyr0, image_pyr1, feat_pyr0, feat_pyr1
256
+
257
+ results = []
258
+ dt_tensors = torch.tensor(timesteps, device=img0.device, dtype=img0.dtype)
259
+ for idx in range(len(timesteps)):
260
+ batch_dt = dt_tensors[idx:idx + 1]
261
+ bwd_scaled = multiply_pyramid(bwd_flow, batch_dt)
262
+ fwd_scaled = multiply_pyramid(fwd_flow, 1 - batch_dt)
263
+ fwd_warped = pyramid_warp(p2w[0], bwd_scaled, self.warp)
264
+ bwd_warped = pyramid_warp(p2w[1], fwd_scaled, self.warp)
265
+ aligned = [torch.cat([fw, bw, bf, ff], dim=1)
266
+ for fw, bw, bf, ff in zip(fwd_warped, bwd_warped, bwd_scaled, fwd_scaled)]
267
+ del fwd_warped, bwd_warped, bwd_scaled, fwd_scaled
268
+ results.append(self.fuse(aligned))
269
+ del aligned
270
+ return torch.cat(results, dim=0)
h3_aoti.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ZeroGPU AoTI for MiniMax-H3: one compiled `MiniMaxH3TransformerBlock` package, reused by all 50 blocks.
2
+
3
+ Shared byte-identically by every MiniMax-H3 Space. A Space only calls `maybe_load()`; the rest is the build path.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ AOTI = os.environ.get("H3_AOTI", "0") == "1"
12
+ AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
13
+ AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
14
+ # A package is valid for exactly one `<width>/torch<X.Y>/sm<cc>/<shape>`, and a mismatched one segfaults rather than
15
+ # raising, so `maybe_load` refuses anything but this key.
16
+ AOTI_KEY = os.environ.get("H3_AOTI_KEY", "bf16/torch2.11/sm120/dynamic")
17
+ # `dynamic` is the sequence dimension: `build_packed_sequence` pads nothing, so `S` moves with the prompt as well as
18
+ # the canvas and a static package would serve one prompt length.
19
+ AOTI_SHAPE = os.environ.get("H3_AOTI_SHAPE", "dynamic")
20
+ AOTI_DURATION = int(os.environ.get("H3_AOTI_DURATION", "1500"))
21
+
22
+ # Where a step spends its time. `MiniMaxH3TokenRefinerBlock` is also repeated but runs a handful of text rows.
23
+ BLOCK_CONTAINER = "transformer_blocks"
24
+
25
+ # Height of the AdaLN table baked into the package. `temb` grows from 1 row (step 0, both streams at one noise level)
26
+ # to 2 (from step 1, sigmas diverged), and the block gathers from `3 * rows`, so the row count is part of the compiled
27
+ # shape and is pinned by padding on both sides of the compile. Must match the package's `H3_AOTI_TEMB_ROWS`.
28
+ TEMB_ROWS = int(os.environ.get("H3_AOTI_TEMB_ROWS", "4"))
29
+
30
+ _LOADED: set[int] = set()
31
+
32
+
33
+ def pad_temb(temb, rows: int = TEMB_ROWS):
34
+ """Grow `temb` to exactly `rows` timestep rows by repeating its last one."""
35
+ present = temb.shape[0]
36
+ if present == rows:
37
+ return temb
38
+ if present > rows:
39
+ raise RuntimeError(
40
+ f"{present} distinct timesteps, but this AoTI package holds at most {rows}. "
41
+ f"Recompile with H3_AOTI_TEMB_ROWS>={present}."
42
+ )
43
+ import torch
44
+
45
+ return torch.cat([temb, temb[-1:].expand(rows - present, *temb.shape[1:])], dim=0)
46
+
47
+
48
+ def width() -> str:
49
+ """Which transformer these artifacts belong to: `bf16`, `fp8`, `nvfp4`, ..."""
50
+ if explicit := os.environ.get("H3_WIDTH"):
51
+ return explicit.lower()
52
+ try:
53
+ import h3_core
54
+
55
+ return h3_core.WIDTH
56
+ except Exception:
57
+ return "bf16"
58
+
59
+
60
+ def artifact_key() -> str | None:
61
+ """`<width>/torch<X.Y>/sm<cc>/<shape>` of the card this process is on, or `None` when there is no CUDA."""
62
+ try:
63
+ import torch
64
+
65
+ torch_version = ".".join(torch.__version__.split(".")[:2])
66
+ major, minor = torch.cuda.get_device_capability()
67
+ except Exception:
68
+ return None
69
+ return f"{width()}/torch{torch_version}/sm{major}{minor}/{AOTI_SHAPE}"
70
+
71
+
72
+ def status() -> str:
73
+ return (
74
+ f"AoTI **on** · `{AOTI_REPO}` ({AOTI_REPO_TYPE}) · shape `{AOTI_SHAPE}`"
75
+ if AOTI
76
+ else "AoTI **off** (`H3_AOTI=1` to load compiled blocks)"
77
+ )
78
+
79
+
80
+ def patch_blocks(transformer, package_dir) -> None:
81
+ """Point all 50 blocks at the one compiled package, binding each block's own weights on its first call.
82
+
83
+ `spaces.aoti_load_from_package_dir` with two changes. Weights are read on the first forward rather than at patch
84
+ time, because this runs at startup and `Module.to` later rebinds `param.data` to fresh CUDA tensors. And `temb` is
85
+ padded to the height the package was exported with — see `TEMB_ROWS`.
86
+ """
87
+ from spaces.zero.torch.aoti import LazyAOTIModel, _shallow_clone_module
88
+ from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
89
+
90
+ # `LazyAOTIModel` binds constants by name and silently keeps what it cannot match, which is a SIGSEGV rather than
91
+ # an error. The patch resolves anonymous names through the compile side's sidecar and raises if it still cannot.
92
+ try:
93
+ from spaces_constant_binding_patch import apply_spaces_constant_binding_patch
94
+
95
+ apply_spaces_constant_binding_patch()
96
+ except ImportError:
97
+ print("[h3-aoti] spaces_constant_binding_patch.py is missing; an unbindable constant would segfault", flush=True)
98
+
99
+ model = LazyAOTIModel(Path(package_dir) / "submodules" / BLOCK_CONTAINER / "package.pt2")
100
+
101
+ for block in getattr(transformer, BLOCK_CONTAINER):
102
+ bound: dict = {}
103
+
104
+ def forward(hidden_states, temb, *rest, _block=block, _bound=bound):
105
+ first = not _bound
106
+ if first:
107
+ clone = _shallow_clone_module(_block)
108
+ unwrap_tensor_subclass_parameters(clone)
109
+ _bound["weights"] = clone.state_dict()
110
+ return model(_bound["weights"], first, hidden_states, pad_temb(temb), *rest)
111
+
112
+ block.forward = forward
113
+ print(f"[h3-aoti] {len(getattr(transformer, BLOCK_CONTAINER))} blocks patched (temb padded to {TEMB_ROWS})", flush=True)
114
+
115
+
116
+ def maybe_load(transformer) -> None:
117
+ """Patch the block stack with its compiled package, or leave it eager. Safe to call at **startup**.
118
+
119
+ Off unless `H3_AOTI=1`, and anything that does not line up — another card, another torch, no `spaces` AoTI
120
+ helpers, no published package — falls back to eager with one line rather than raising or segfaulting. Nothing here
121
+ touches a GPU: the download is CPU work and the `.pt2` is not opened until the first forward.
122
+ """
123
+ if not AOTI or id(transformer) in _LOADED:
124
+ return
125
+
126
+ key = artifact_key()
127
+ if key is None:
128
+ print("[h3-aoti] no CUDA device visible; running eager", flush=True)
129
+ return
130
+ if key != AOTI_KEY:
131
+ print(f"[h3-aoti] this card wants `{key}`, only `{AOTI_KEY}` is published; running eager", flush=True)
132
+ return
133
+
134
+ try:
135
+ from huggingface_hub import snapshot_download
136
+ from spaces.zero.torch.aoti import LazyAOTIModel # noqa: F401
137
+ except Exception as error:
138
+ print(f"[h3-aoti] no AoTI loader here ({type(error).__name__}: {error}); running eager", flush=True)
139
+ return
140
+
141
+ print(f"[h3-aoti] loading {AOTI_REPO}:{key} ...", flush=True)
142
+ try:
143
+ local = snapshot_download(repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE, allow_patterns=f"{key}/package/*")
144
+ except Exception as error:
145
+ print(f"[h3-aoti] {AOTI_REPO}:{key} unreachable ({type(error).__name__}: {error}); running eager", flush=True)
146
+ return
147
+ package_dir = Path(local) / key / "package"
148
+ if not package_dir.is_dir():
149
+ print(f"[h3-aoti] no package at `{AOTI_REPO}:{key}/package`; running eager", flush=True)
150
+ return
151
+
152
+ patch_blocks(transformer, package_dir)
153
+ _LOADED.add(id(transformer))
154
+ print(f"[h3-aoti] compiled blocks in place (temb padded to {TEMB_ROWS} rows)", flush=True)
155
+
156
+
157
+ def export_block(pipe, height: int, width: int, num_frames: int, prompt: str):
158
+ """Capture one block call out of a real request and export it with a dynamic sequence dimension.
159
+
160
+ Runs on the GPU, after the transformer has been quantized and moved there: a package compiled for one
161
+ quantization mode is meaningless for another.
162
+ """
163
+ import torch
164
+ import spaces
165
+
166
+ import h3_core as h3
167
+
168
+ transformer = h3.transformer_of(pipe)
169
+ blocks = getattr(transformer, BLOCK_CONTAINER)
170
+
171
+ # Keep the widest `temb` over a short real run rather than `spaces.aoti_capture`'s first call, which is the
172
+ # 1-row one — see `TEMB_ROWS`.
173
+ original_forward = blocks[0].forward
174
+ widest = {"args": (), "kwargs": {}, "rows": -1}
175
+ seen = []
176
+
177
+ def recording(*args, **kwargs):
178
+ rows = int(args[1].shape[0]) if len(args) > 1 and hasattr(args[1], "shape") else -1
179
+ seen.append(rows)
180
+ if rows > widest["rows"]:
181
+ widest.update(args=args, kwargs=kwargs, rows=rows)
182
+ return original_forward(*args, **kwargs)
183
+
184
+ blocks[0].forward = recording
185
+ try:
186
+ pipe(
187
+ prompt=prompt,
188
+ height=height,
189
+ width=width,
190
+ num_frames=num_frames,
191
+ num_inference_steps=int(os.environ.get("H3_AOTI_CAPTURE_STEPS", "4")),
192
+ generator=torch.Generator("cpu").manual_seed(42),
193
+ )
194
+ finally:
195
+ blocks[0].forward = original_forward
196
+ call = type("Captured", (), widest)
197
+ if not call.args:
198
+ raise RuntimeError("Nothing was captured — the transformer block was never called.")
199
+ print(f"[h3-aoti] temb rows seen: {sorted(set(seen))}; exporting with {TEMB_ROWS} (padded)", flush=True)
200
+
201
+ # `block(hidden_states, temb, adaln_indices, rotary_emb, attention_mask)`, `attention_mask` being `None` for the
202
+ # padless sequences these pipelines build. Only the sequence is dynamic: `torch.export` specializes size-1
203
+ # dimensions unconditionally, so a `Dim` on `temb`'s rows cannot be expressed at all.
204
+ if AOTI_SHAPE == "dynamic":
205
+ sequence = torch.export.Dim("sequence", min=2048, max=262144)
206
+ dynamic_shapes = ({1: sequence}, None, {0: sequence}, ({0: sequence}, {0: sequence}), None)
207
+ dynamic_shapes = dynamic_shapes[: len(call.args)]
208
+ else:
209
+ dynamic_shapes = None
210
+
211
+ args = (call.args[0], pad_temb(call.args[1]), *call.args[2:])
212
+
213
+ # Export the **live** block, non-strict. A shallow clone under non-strict tracing lifts every weight twice — once
214
+ # named, once as an anonymous `CONSTANT_TENSOR` aliasing it — and the loader binds by name, so the compiled block
215
+ # dereferences constants nobody set. The clone is only for flattening tensor-subclass parameters, which inductor's
216
+ # constant handling cannot wrap back into a `Parameter`, and it needs `strict=True`.
217
+ from spaces.zero.torch.aoti import _shallow_clone_module
218
+ from torch._functorch._aot_autograd.subclass_parametrization import unwrap_tensor_subclass_parameters
219
+
220
+ subclassed = sorted({type(p).__name__ for p in blocks[0].parameters()} - {"Parameter"})
221
+ if subclassed:
222
+ block = _shallow_clone_module(blocks[0])
223
+ unwrap_tensor_subclass_parameters(block)
224
+ strict = True
225
+ print(f"[h3-aoti] tensor-subclass parameters {subclassed}: exporting a flattened clone, strict=True", flush=True)
226
+ else:
227
+ block = blocks[0]
228
+ strict = False
229
+ print("[h3-aoti] plain parameters: exporting the live block, non-strict", flush=True)
230
+
231
+ # `torch.export` only gives a lifted tensor a real FQN when it is a registered parameter or buffer; a plain
232
+ # attribute becomes an anonymous constant the loader can never match. Only ever on the clone, since this
233
+ # re-registers attributes and the live block is what the eager path runs.
234
+ if block is not blocks[0]:
235
+ try:
236
+ from spaces_constant_binding_patch import register_loose_tensors
237
+
238
+ if loose := register_loose_tensors(block):
239
+ print(f"[h3-aoti] re-registered {len(loose)} loose tensors as buffers: {loose[:6]}", flush=True)
240
+ except ImportError:
241
+ pass
242
+
243
+ print(f"[h3-aoti] exporting {type(blocks[0]).__name__}, shapes={AOTI_SHAPE}, strict={strict} ...", flush=True)
244
+ try:
245
+ exported = torch.export.export(block, args, call.kwargs or None, dynamic_shapes=dynamic_shapes, strict=strict)
246
+ except Exception as error:
247
+ if not strict:
248
+ raise
249
+ print(f"[h3-aoti] strict export failed ({type(error).__name__}: {error}); retrying non-strict", flush=True)
250
+ exported = torch.export.export(block, args, call.kwargs or None, dynamic_shapes=dynamic_shapes)
251
+
252
+ anonymous = [
253
+ spec.target for spec in exported.graph_signature.input_specs if spec.kind.name == "CONSTANT_TENSOR"
254
+ ]
255
+ if anonymous:
256
+ print(
257
+ f"[h3-aoti] WARNING {len(anonymous)} constants lifted anonymously: {anonymous[:6]}. The loader binds by "
258
+ f"name, so `compile_and_save` writes the alias sidecar and `patch_blocks` raises rather than segfaulting.",
259
+ flush=True,
260
+ )
261
+ return exported
262
+
263
+
264
+ def compile_and_save(exported_program, destination: str | os.PathLike[str]) -> Path:
265
+ """Inductor-compile the exported block into `<destination>/package/submodules/transformer_blocks/package.pt2`.
266
+
267
+ That layout is what `aoti_load_from_package_dir` walks, resolving the submodule name to the transformer's
268
+ `transformer_blocks` `ModuleList` and patching every block in it with this one package.
269
+ """
270
+ import spaces
271
+
272
+ package_dir = Path(destination) / "package"
273
+ print("[h3-aoti] inductor compile (minutes) ...", flush=True)
274
+ spaces.aoti_compile_and_save(package_dir, exported_program, submodule=BLOCK_CONTAINER)
275
+
276
+ # The compiled artifact drops a constant's FQN when the export lifted it anonymously; the `ExportedProgram` still
277
+ # has the real names, so record the mapping for the loader while it is available.
278
+ try:
279
+ from spaces_constant_binding_patch import write_constant_aliases
280
+
281
+ if sidecar := write_constant_aliases(package_dir, exported_program, submodule=BLOCK_CONTAINER):
282
+ print(f"[h3-aoti] constant alias sidecar written: {sidecar.name}", flush=True)
283
+ except ImportError:
284
+ pass
285
+
286
+ files = sorted(str(path.relative_to(package_dir)) for path in package_dir.rglob("*") if path.is_file())
287
+ print(f"[h3-aoti] package written: {files}", flush=True)
288
+ return package_dir
289
+
290
+
291
+ def upload(package_dir: str | os.PathLike[str], key: str) -> str:
292
+ """Push the package under its `<width>/torch<X.Y>/sm<cc>/<shape>` key. CPU work — never inside GPU time."""
293
+ from huggingface_hub import HfApi
294
+
295
+ token = os.environ.get("HF_TOKEN")
296
+ if not token:
297
+ raise RuntimeError("`HF_TOKEN` is needed to push the AoTI package.")
298
+ api = HfApi(token=token)
299
+ api.create_repo(repo_id=AOTI_REPO, repo_type=AOTI_REPO_TYPE, private=False, exist_ok=True)
300
+ api.upload_folder(
301
+ folder_path=str(package_dir),
302
+ path_in_repo=f"{key}/package",
303
+ repo_id=AOTI_REPO,
304
+ repo_type=AOTI_REPO_TYPE,
305
+ commit_message=f"AoTI package for {key}",
306
+ )
307
+ return f"https://huggingface.co/{'datasets/' if AOTI_REPO_TYPE == 'dataset' else ''}{AOTI_REPO}/tree/main/{key}"
h3_local_conditioner.py DELETED
@@ -1,174 +0,0 @@
1
- """Local, truncated Qwen3-VL conditioner for MiniMax-H3.
2
-
3
- The canonical diffusers checkpoint stores all 64 language layers plus the LM head in BF16 (66.7 GB), although H3
4
- only reads the unnormalized state after layer 50. ComfyUI's Apache-2.0 conversion removes the unused tail and head,
5
- keeps the vision tower in BF16, and stores the 50 language layers as NVFP4-AWQ. This adapter loads that single
6
- 15.7 GB file directly into Transformers' Qwen3-VL architecture and exposes the tiny contract used by diffusers.
7
-
8
- No ComfyUI application or server is launched. Preprocessing remains Transformers' canonical Qwen3-VL processor.
9
- By default the checkpoint's quality-oriented weight-only policy is honored: compact NVFP4-AWQ weights are
10
- dequantized one layer at a time for BF16 GEMMs. Native W4A4 is available as an aggressive opt-in.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import copy
16
- import os
17
- from types import SimpleNamespace
18
-
19
- import torch
20
- import torch.nn as nn
21
-
22
- from h3_nvfp4 import H3Linear
23
-
24
-
25
- CONDITIONER_REPO = os.environ.get("H3_LOCAL_CONDITIONER_REPO", "Comfy-Org/MiniMax-H3")
26
- CONDITIONER_FILE = os.environ.get(
27
- "H3_LOCAL_CONDITIONER_FILE",
28
- "text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
29
- )
30
- SOURCE_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
31
- LAYERS = 50
32
- NATIVE_NVFP4 = os.environ.get("H3_CONDITIONER_NATIVE_NVFP4", "0") == "1"
33
-
34
-
35
- class QuantizedEmbedding(nn.Module):
36
- """Row-wise INT8 token lookup without dequantizing the 1.56 GB BF16 vocabulary table."""
37
-
38
- def __init__(self, handle, prefix: str):
39
- super().__init__()
40
- self.register_buffer("weight", handle.get_tensor(f"{prefix}.weight"))
41
- self.register_buffer("scale", handle.get_tensor(f"{prefix}.weight_scale").float())
42
-
43
- def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
44
- flat = input_ids.reshape(-1)
45
- values = self.weight.index_select(0, flat).reshape(*input_ids.shape, self.weight.shape[1])
46
- scales = self.scale.index_select(0, flat).reshape(*input_ids.shape, 1)
47
- return values.to(torch.bfloat16).mul_(scales.to(torch.bfloat16))
48
-
49
-
50
- class Layer50Backbone(nn.Module):
51
- """Avoid retaining 50 intermediate tensors merely to satisfy diffusers' hidden-state indexing API."""
52
-
53
- def __init__(self, core: nn.Module):
54
- super().__init__()
55
- self.core = core
56
-
57
- def forward(self, *args, **kwargs):
58
- kwargs.pop("output_hidden_states", None)
59
- kwargs.pop("return_dict", None)
60
- kwargs["use_cache"] = False
61
- output = self.core(*args, **kwargs)
62
- # get_qwen3vl_prompt_embeds asks for hidden_states[50]. The first 50 entries need not be materialized.
63
- return SimpleNamespace(hidden_states=(None,) * LAYERS + (output.last_hidden_state,))
64
-
65
-
66
- class LocalH3Conditioner(nn.Module):
67
- """The subset of Qwen3VLForConditionalGeneration that MiniMax-H3 actually calls."""
68
-
69
- def __init__(self, core: nn.Module, source_config):
70
- super().__init__()
71
- public_config = copy.deepcopy(source_config)
72
- # Diffusers rejects a nominally 50-layer model because a normal last_hidden_state is post-norm. This adapter
73
- # removes the final norm and returns the raw 50th-layer state, so advertise index 50 as available explicitly.
74
- public_config.text_config.num_hidden_layers = LAYERS + 1
75
- self.config = public_config
76
- self.model = Layer50Backbone(core)
77
-
78
- @property
79
- def dtype(self) -> torch.dtype:
80
- return torch.bfloat16
81
-
82
- @property
83
- def device(self) -> torch.device:
84
- return self.model.core.visual.patch_embed.proj.weight.device
85
-
86
-
87
- def _target_name(checkpoint_name: str) -> str:
88
- if checkpoint_name.startswith("model.layers."):
89
- return "language_model.layers." + checkpoint_name.removeprefix("model.layers.")
90
- if checkpoint_name.startswith("visual."):
91
- return checkpoint_name
92
- raise KeyError(checkpoint_name)
93
-
94
-
95
- def _build_core(handle):
96
- from accelerate import init_empty_weights
97
- from transformers import Qwen3VLConfig
98
- from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLModel
99
-
100
- config = Qwen3VLConfig.from_pretrained(SOURCE_REPO, subfolder="text_encoder")
101
- config.text_config.num_hidden_layers = LAYERS
102
- config.text_config.use_cache = False
103
- config.text_config._attn_implementation = "sdpa"
104
- config.vision_config._attn_implementation = "sdpa"
105
-
106
- with init_empty_weights(include_buffers=False):
107
- core = Qwen3VLModel(config)
108
-
109
- keys = set(handle.keys())
110
- embedding_prefix = "model.embed_tokens"
111
- core.language_model.embed_tokens = QuantizedEmbedding(handle, embedding_prefix)
112
- consumed = {
113
- key for key in keys if key == f"{embedding_prefix}.comfy_quant" or key.startswith(f"{embedding_prefix}.weight")
114
- }
115
-
116
- quantized_prefixes = sorted(
117
- key.removesuffix(".comfy_quant")
118
- for key in keys
119
- if key.startswith("model.layers.") and key.endswith(".comfy_quant")
120
- )
121
- if len(quantized_prefixes) != LAYERS * 7:
122
- raise RuntimeError(f"Expected {LAYERS * 7} quantized language linears, found {len(quantized_prefixes)}.")
123
-
124
- for source_prefix in quantized_prefixes:
125
- target_prefix = _target_name(source_prefix)
126
- parent_name, child_name = target_prefix.rsplit(".", 1)
127
- parent = core.get_submodule(parent_name)
128
- original = getattr(parent, child_name)
129
- linear = H3Linear(original.in_features, original.out_features, bias=original.bias is not None)
130
- linear.load(handle, source_prefix)
131
- if NATIVE_NVFP4:
132
- linear.full_precision_mm = False
133
- setattr(parent, child_name, linear)
134
- consumed.update(key for key in keys if key.startswith(f"{source_prefix}."))
135
-
136
- # MiniMax-H3 consumes the raw output of layer 49. The released Comfy checkpoint intentionally has no final norm.
137
- core.language_model.norm = nn.Identity()
138
-
139
- plain_state = {}
140
- for source_name in sorted(keys - consumed):
141
- if source_name.startswith("visual.") or source_name.startswith("model.layers."):
142
- plain_state[_target_name(source_name)] = handle.get_tensor(source_name)
143
- consumed.add(source_name)
144
-
145
- unknown = keys - consumed
146
- if unknown:
147
- raise RuntimeError(f"Unhandled local-conditioner tensors: {sorted(unknown)[:12]}")
148
-
149
- core.load_state_dict(plain_state, strict=False, assign=True)
150
- meta = [name for name, value in core.named_parameters() if value.is_meta]
151
- if meta:
152
- raise RuntimeError(f"Local conditioner still has uninitialized parameters: {meta[:12]}")
153
- core.eval()
154
- return core, config
155
-
156
-
157
- def load_local_conditioner():
158
- from huggingface_hub import hf_hub_download
159
- from safetensors import safe_open
160
- from transformers import Qwen3VLProcessor
161
-
162
- path = hf_hub_download(CONDITIONER_REPO, CONDITIONER_FILE)
163
- with safe_open(path, framework="pt", device="cpu") as handle:
164
- core, config = _build_core(handle)
165
-
166
- processor = Qwen3VLProcessor.from_pretrained(SOURCE_REPO, subfolder="text_encoder")
167
- model = LocalH3Conditioner(core, config).eval()
168
- print(f"[h3-cond] loaded local layer-50 conditioner {CONDITIONER_REPO}/{CONDITIONER_FILE}", flush=True)
169
- return model, processor.tokenizer, processor
170
-
171
-
172
- def status() -> str:
173
- compute = "native W4A4" if NATIVE_NVFP4 else "BF16 GEMM"
174
- return f"local layer-50 Qwen3-VL NVFP4-AWQ weights / {compute} · `{CONDITIONER_REPO}`"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
h3_nvfp4.py DELETED
@@ -1,964 +0,0 @@
1
- """Blackwell-native MiniMax-H3 transformer for the pruned ComfyUI NVFP4 checkpoint.
2
-
3
- The public diffusers checkpoint spends 13.04B of its 33.12B parameters on per-block
4
- AdaLN projections. ComfyUI's pruned checkpoint replaces those projections with an
5
- interpolated 1025-point timestep curve, fuses Q/K/V, and stores the four large linear
6
- layers in every block as NVFP4. This adapter keeps diffusers' packed-sequence contract
7
- so the rest of the split Space (schedulers, VAEs and remote conditioner) stays unchanged.
8
-
9
- The kernel/layout conventions follow ComfyUI's Apache-2.0 implementation:
10
- https://github.com/Comfy-Org/ComfyUI/blob/master/comfy/ldm/minimax/model.py
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import json
16
- import math
17
- import os
18
- from types import SimpleNamespace
19
-
20
- import torch
21
- import torch.nn as nn
22
- import torch.nn.functional as F
23
- import comfy_kitchen as kitchen
24
- from comfy_kitchen.tensor import QuantizedTensor, TensorCoreNVFP4Layout
25
- from diffusers.models.attention_dispatch import dispatch_attention_fn
26
-
27
- try:
28
- import triton
29
- import triton.language as tl
30
- except ImportError: # PyTorch CUDA wheels include Triton; retain a portable fallback for source inspection/tests.
31
- triton = None
32
- tl = None
33
-
34
-
35
- NVFP4_REPO = os.environ.get("H3_NVFP4_REPO", "lilcheaty/MiniMax-H3-NVFP4")
36
- NVFP4_FILE = os.environ.get("H3_NVFP4_FILE", "minimax_h3_fl2va_pruned_nvfp4.safetensors")
37
-
38
- HIDDEN = 5376
39
- HEADS = 56
40
- HEAD_DIM = 128
41
- FFN = 14336
42
- TEXT_DIM = 5120
43
- TIME_DIM = 8
44
- VIDEO_DIM = 24 * 1 * 2 * 2
45
- AUDIO_DIM = 32
46
- LAYERS = 50
47
- REFINER_LAYERS = 2
48
- EPS = 1e-5
49
-
50
- # EasyCache is the conservative profile. The Ultra Fast profile uses a bounded linear residual forecast:
51
- # three exact warmup evaluations, at most three forecasts in a row, and two exact tail evaluations. Unlike blind
52
- # output reuse, forecasting follows the local denoising trajectory while making the amount of saved work predictable.
53
- EASYCACHE_THRESHOLD = max(0.0, float(os.environ.get("H3_EASYCACHE_THRESHOLD", "0.10")))
54
- EASYCACHE_START = min(1.0, max(0.0, float(os.environ.get("H3_EASYCACHE_START", "0.15"))))
55
- EASYCACHE_END = min(1.0, max(EASYCACHE_START, float(os.environ.get("H3_EASYCACHE_END", "0.95"))))
56
- EASYCACHE_SUBSAMPLE = max(1, int(os.environ.get("H3_EASYCACHE_SUBSAMPLE", "8")))
57
- FIRST_BLOCK_THRESHOLD = max(0.0, float(os.environ.get("H3_FIRST_BLOCK_THRESHOLD", "0.08")))
58
- FIRST_BLOCK_DENSE_START = max(1, int(os.environ.get("H3_FIRST_BLOCK_DENSE_START", "3")))
59
- FIRST_BLOCK_DENSE_END = max(1, int(os.environ.get("H3_FIRST_BLOCK_DENSE_END", "2")))
60
- FORECAST_BLEND = min(1.0, max(0.0, float(os.environ.get("H3_FORECAST_BLEND", "0.65"))))
61
- FUSED_ADALN = os.environ.get("H3_FUSED_ADALN", "0") == "1" and triton is not None
62
- SOL_ATTN = os.environ.get("H3_SOL_ATTN", "1") == "1"
63
- SOL_ATTN_TAU = float(os.environ.get("H3_SOL_ATTN_TAU", "1.0"))
64
- SOL_ATTN_DENSE_STEPS = max(0, int(os.environ.get("H3_SOL_ATTN_DENSE_STEPS", "10")))
65
- SOL_ATTN_DENSE_LAYERS = max(0, int(os.environ.get("H3_SOL_ATTN_DENSE_LAYERS", "2")))
66
- SOL_ATTN_MIN_TOKENS = max(0, int(os.environ.get("H3_SOL_ATTN_MIN_TOKENS", "8192")))
67
-
68
-
69
- if triton is not None:
70
-
71
- @triton.jit
72
- def _adaln_modulate_kernel(
73
- x, shift, scale, row_ids, elements: tl.constexpr, hidden: tl.constexpr, modulation_stride: tl.constexpr
74
- ):
75
- offsets = tl.program_id(0) * 256 + tl.arange(0, 256)
76
- mask = offsets < elements
77
- columns = offsets % hidden
78
- rows = offsets // hidden
79
- modulation_rows = tl.load(row_ids + rows, mask=mask, other=0)
80
- modulation_offsets = modulation_rows * modulation_stride + columns
81
- values = tl.load(x + offsets, mask=mask)
82
- shifts = tl.load(shift + modulation_offsets, mask=mask)
83
- scales = tl.load(scale + modulation_offsets, mask=mask)
84
- tl.store(x + offsets, values * (1.0 + scales) + shifts, mask=mask)
85
-
86
- @triton.jit
87
- def _adaln_gate_kernel(
88
- x, update, gate, row_ids, elements: tl.constexpr, hidden: tl.constexpr, modulation_stride: tl.constexpr
89
- ):
90
- offsets = tl.program_id(0) * 256 + tl.arange(0, 256)
91
- mask = offsets < elements
92
- columns = offsets % hidden
93
- rows = offsets // hidden
94
- modulation_rows = tl.load(row_ids + rows, mask=mask, other=0)
95
- gates = tl.load(gate + modulation_rows * modulation_stride + columns, mask=mask)
96
- values = tl.load(x + offsets, mask=mask)
97
- updates = tl.load(update + offsets, mask=mask)
98
- tl.store(x + offsets, values + updates * gates, mask=mask)
99
-
100
-
101
- class H3StepCache:
102
- """ComfyUI EasyCache-style adaptive reuse of a complete H3 denoising result.
103
-
104
- This caches the model residual, not the generated video. A request with a new prompt, seed, canvas or keyframe
105
- starts from an empty cache. Decisions use a sparse sample of generated video latent rows, while the reused
106
- residual contains every video and audio row so their joint denoising trajectory stays coupled.
107
- """
108
-
109
- def __init__(self):
110
- self.total_steps = 0
111
- self.step = 0
112
- self.skipped = 0
113
- self.profile = "balanced"
114
- self.consecutive_skips = 0
115
- self.last_actual_step = None
116
- self.previous_input = None
117
- self.previous_output = None
118
- self.previous_output_norm = None
119
- self.relative_rate = None
120
- self.accumulated_change = None
121
- self.video_residual = None
122
- self.audio_residual = None
123
- self.video_residual_slope = None
124
- self.audio_residual_slope = None
125
- self.pending_input = None
126
- self.pending_input_change = None
127
- self.pending_track = False
128
- self.head_residual = None
129
- self.tail_residual = None
130
- self.first_block_output = None
131
-
132
- def begin(self, total_steps: int | None, profile: str = "balanced") -> None:
133
- self.__init__()
134
- self.total_steps = max(0, int(total_steps or 0))
135
- self.profile = str(profile or "balanced").lower()
136
-
137
- @property
138
- def enabled(self) -> bool:
139
- return self.profile != "exact" and self.total_steps > 2
140
-
141
- def _forecast(self, video_input, audio_input):
142
- distance = max(1, self.step - int(self.last_actual_step or 0))
143
- video_residual = self.video_residual
144
- audio_residual = self.audio_residual
145
- if self.video_residual_slope is not None:
146
- video_residual = video_residual + self.video_residual_slope * (distance * FORECAST_BLEND)
147
- audio_residual = audio_residual + self.audio_residual_slope * (distance * FORECAST_BLEND)
148
- self.skipped += 1
149
- self.consecutive_skips += 1
150
- self.step += 1
151
- return video_input + video_residual, audio_input + audio_residual
152
-
153
- def try_reuse(self, video_input, audio_input, condition_rows: int):
154
- self.pending_input = None
155
- self.pending_input_change = None
156
- self.pending_track = False
157
- if not self.enabled:
158
- return None
159
-
160
- # Balanced uses NVIDIA's H3 FirstBlockCache below, after block 0 has produced a high-signal residual.
161
- # Only the deliberately aggressive Ultra profile forecasts a whole transformer call before block 0.
162
- if not self.profile.startswith("ultra"):
163
- return None
164
-
165
- # Ultra Fast is deliberately bounded: no more than three forecasts can separate exact transformer calls, and
166
- # the high-noise warmup plus low-noise tail remain exact. At the default 16 steps this executes 7 full DiT
167
- # evaluations instead of 16 while still sampling the original 16-step scheduler trajectory.
168
- if self.profile.startswith("ultra"):
169
- can_forecast = (
170
- self.step >= 3
171
- and self.step < self.total_steps - 2
172
- and self.consecutive_skips < 3
173
- and self.last_actual_step is not None
174
- and self.video_residual is not None
175
- and self.audio_residual is not None
176
- and self.video_residual.shape == video_input.shape
177
- and self.audio_residual.shape == audio_input.shape
178
- )
179
- if can_forecast:
180
- return self._forecast(video_input, audio_input)
181
- return None
182
-
183
- if EASYCACHE_THRESHOLD <= 0.0:
184
- return None
185
-
186
- end_step = math.floor(self.total_steps * EASYCACHE_END)
187
- if self.step >= end_step:
188
- return None
189
-
190
- # Condition latents are static. Excluding them makes the change estimate reflect the generated trajectory.
191
- sampled_input = video_input[0, condition_rows::EASYCACHE_SUBSAMPLE].detach().float()
192
- self.pending_input = sampled_input
193
- self.pending_track = True
194
- if self.previous_input is not None:
195
- self.pending_input_change = (sampled_input - self.previous_input).abs().mean()
196
-
197
- start_step = math.ceil(self.total_steps * EASYCACHE_START)
198
- can_reuse = (
199
- self.step >= start_step
200
- and self.pending_input_change is not None
201
- and self.relative_rate is not None
202
- and self.previous_output_norm is not None
203
- and self.video_residual is not None
204
- and self.audio_residual is not None
205
- and self.video_residual.shape == video_input.shape
206
- and self.audio_residual.shape == audio_input.shape
207
- )
208
- if not can_reuse:
209
- return None
210
-
211
- estimated_change = self.relative_rate * self.pending_input_change
212
- estimated_change = estimated_change / self.previous_output_norm.clamp_min(1e-6)
213
- accumulated = estimated_change if self.accumulated_change is None else self.accumulated_change + estimated_change
214
- if bool((accumulated < EASYCACHE_THRESHOLD).item()):
215
- self.accumulated_change = accumulated
216
- self.skipped += 1
217
- self.step += 1
218
- return video_input + self.video_residual, audio_input + self.audio_residual
219
- return None
220
-
221
- def first_block_decision(self, block_input: torch.Tensor, block_output: torch.Tensor) -> bool:
222
- """Return True when blocks 1..49 can reuse their previous joint residual.
223
-
224
- This is the single-GPU equivalent of NVIDIA Sol-Engine's H3 FirstBlockCache at threshold 0.08. The first
225
- block is always evaluated. Its normalized residual change is a much stronger predictor than raw latent
226
- motion, while the cached tail residual still covers the complete text/video/audio packed sequence.
227
- """
228
- if not self.enabled or self.profile.startswith("ultra"):
229
- return False
230
- if FIRST_BLOCK_THRESHOLD <= 0.0:
231
- self.first_block_output = block_output.detach().clone()
232
- return False
233
-
234
- keep_dense = self.step < FIRST_BLOCK_DENSE_START or self.step >= self.total_steps - FIRST_BLOCK_DENSE_END
235
- residual = block_output - block_input
236
- reusable = (
237
- not keep_dense
238
- and self.head_residual is not None
239
- and self.tail_residual is not None
240
- and self.tail_residual.shape == block_output.shape
241
- )
242
- should_reuse = False
243
- if reusable:
244
- difference = (residual - self.head_residual).abs().mean()
245
- reference = self.head_residual.abs().mean().clamp_min(1e-8)
246
- should_reuse = bool(((difference / reference) <= FIRST_BLOCK_THRESHOLD).item())
247
-
248
- if should_reuse:
249
- self.skipped += 1
250
- self.consecutive_skips += 1
251
- self.step += 1
252
- return True
253
-
254
- # This engine's residual/gate operations update `packed` in place. Preserve the head output before later
255
- # blocks mutate the same storage; diffusers' reference blocks are out-of-place and do not need this clone.
256
- self.first_block_output = block_output.detach().clone()
257
- self.head_residual = residual.detach()
258
- return False
259
-
260
- def update_first_block_tail(self, final_block_output: torch.Tensor) -> None:
261
- if self.first_block_output is None:
262
- return
263
- self.tail_residual = (final_block_output - self.first_block_output).detach()
264
- self.last_actual_step = self.step
265
- self.consecutive_skips = 0
266
- self.step += 1
267
- self.first_block_output = None
268
-
269
- def update(self, video_input, audio_input, video_output, audio_output, condition_rows: int) -> None:
270
- # Balanced's clock and state are updated at the block-stack boundary by FirstBlockCache.
271
- if self.enabled and not self.profile.startswith("ultra"):
272
- return
273
- if self.pending_track:
274
- sampled_output = video_output[0, condition_rows::EASYCACHE_SUBSAMPLE].detach().float()
275
- if self.previous_output is not None and self.pending_input_change is not None:
276
- output_change = (sampled_output - self.previous_output).abs().mean()
277
- self.relative_rate = output_change / self.pending_input_change.clamp_min(1e-6)
278
- self.previous_input = self.pending_input.clone()
279
- self.previous_output = sampled_output.clone()
280
- self.previous_output_norm = sampled_output.abs().mean()
281
- if not self.profile.startswith("ultra"):
282
- self.video_residual = (video_output - video_input).detach()
283
- self.audio_residual = (audio_output - audio_input).detach()
284
- self.accumulated_change = None
285
- if self.profile.startswith("ultra"):
286
- new_video_residual = (video_output - video_input).detach()
287
- new_audio_residual = (audio_output - audio_input).detach()
288
- if self.video_residual is not None and self.last_actual_step is not None:
289
- gap = max(1, self.step - self.last_actual_step)
290
- self.video_residual_slope = (new_video_residual - self.video_residual) / gap
291
- self.audio_residual_slope = (new_audio_residual - self.audio_residual) / gap
292
- self.video_residual = new_video_residual
293
- self.audio_residual = new_audio_residual
294
- self.last_actual_step = self.step
295
- self.consecutive_skips = 0
296
- self.step += 1
297
- self.pending_input = None
298
- self.pending_input_change = None
299
- self.pending_track = False
300
-
301
- def finish(self) -> dict:
302
- stats = {
303
- "steps": self.step,
304
- "computed": max(0, self.step - self.skipped),
305
- "forecasted": self.skipped,
306
- "profile": self.profile,
307
- }
308
- if self.enabled and self.step:
309
- computed = max(1, self.step - self.skipped)
310
- print(
311
- f"[h3-nvfp4] adaptive step cache skipped {self.skipped}/{self.step} transformer evaluations "
312
- f"({self.step / computed:.2f}x denoiser-work reduction)",
313
- flush=True,
314
- )
315
- self.begin(None)
316
- return stats
317
-
318
-
319
- class H3SolAttention:
320
- """NVIDIA Sol-Attn policy adapted to H3's single-GPU packed attention.
321
-
322
- The packed prefix (text, conditioning video and generated audio) remains an exact KV sink and its query rows are
323
- recomputed densely. Only target-video query/key interactions become sparse, after ten dense denoising steps and
324
- outside the first two transformer blocks. Any unavailable/JIT-failing backend falls back to cuDNN for the request.
325
- """
326
-
327
- def __init__(self):
328
- self.enabled = SOL_ATTN
329
- self.step = 0
330
- self.video_start = 0
331
- self.sparse_calls = 0
332
- self.dense_calls = 0
333
- self.failure = None
334
-
335
- def begin(self):
336
- self.step = 0
337
- self.video_start = 0
338
- self.sparse_calls = 0
339
- self.dense_calls = 0
340
- self.failure = None
341
-
342
- def observe(self, video_indices: torch.Tensor, sequence: int, step: int) -> None:
343
- self.step = int(step)
344
- if not self.video_start:
345
- deltas = video_indices[1:] - video_indices[:-1]
346
- breaks = (deltas != 1).nonzero().flatten()
347
- start = int(breaks[-1]) + 1 if len(breaks) else 0
348
- self.video_start = int(video_indices[start]) if video_indices.numel() else sequence
349
-
350
- def __call__(self, query, key, value, layer: int):
351
- tokens = int(query.shape[1])
352
- if (
353
- not self.enabled
354
- or self.failure is not None
355
- or self.step < SOL_ATTN_DENSE_STEPS
356
- or layer < SOL_ATTN_DENSE_LAYERS
357
- or tokens < SOL_ATTN_MIN_TOKENS
358
- or not 0 < self.video_start < tokens
359
- ):
360
- self.dense_calls += 1
361
- return None
362
- try:
363
- from sol_attn import sol_attn
364
-
365
- q, k, v = (tensor.contiguous() for tensor in (query, key, value))
366
- attended = sol_attn(
367
- q,
368
- k,
369
- v,
370
- tau=SOL_ATTN_TAU,
371
- thresh_type="diag",
372
- kv_splits=1,
373
- sink_start=0,
374
- sink_tokens=self.video_start,
375
- )
376
- # An exact KV sink does not make the prefix's own queries dense. H3 jointly generates audio in that
377
- # prefix, so reproduce those rows with exact attention as NVIDIA's H3 integration does.
378
- prefix = self.video_start
379
- dense_prefix = F.scaled_dot_product_attention(
380
- q[:, :prefix].transpose(1, 2),
381
- k.transpose(1, 2),
382
- v.transpose(1, 2),
383
- dropout_p=0.0,
384
- is_causal=False,
385
- ).transpose(1, 2)
386
- attended[:, :prefix] = dense_prefix
387
- self.sparse_calls += 1
388
- return attended
389
- except Exception as error:
390
- self.failure = f"{type(error).__name__}: {error}"
391
- print(f"[h3-sol-attn] falling back to dense attention: {self.failure}", flush=True)
392
- self.dense_calls += 1
393
- return None
394
-
395
-
396
- def _quant_config(handle, prefix: str) -> dict | None:
397
- key = f"{prefix}.comfy_quant"
398
- if key not in handle.keys():
399
- return None
400
- return json.loads(handle.get_tensor(key).numpy().tobytes())
401
-
402
-
403
- class H3Linear(nn.Module):
404
- """A plain or comfy-kitchen NVFP4 linear, selected by checkpoint metadata."""
405
-
406
- def __init__(
407
- self,
408
- in_features: int,
409
- out_features: int,
410
- bias: bool = False,
411
- compute_dtype: torch.dtype | None = None,
412
- ):
413
- super().__init__()
414
- self.in_features = in_features
415
- self.out_features = out_features
416
- self.compute_dtype = compute_dtype
417
- self.register_parameter("weight", None)
418
- self.register_parameter("bias", None)
419
- self.register_buffer("input_scale", None)
420
- self.register_buffer("pre_quant_scale", None)
421
- self.quantized = False
422
- self.full_precision_mm = False
423
-
424
- def load(self, handle, prefix: str) -> None:
425
- config = _quant_config(handle, prefix)
426
- weight = handle.get_tensor(f"{prefix}.weight")
427
-
428
- if config is None:
429
- self.weight = nn.Parameter(
430
- weight if self.compute_dtype is None else weight.to(self.compute_dtype), requires_grad=False
431
- )
432
- elif config.get("format") == "nvfp4":
433
- block_scale = handle.get_tensor(f"{prefix}.weight_scale")
434
- if block_scale.dtype == torch.uint8:
435
- block_scale = block_scale.view(torch.float8_e4m3fn)
436
- tensor_scale = handle.get_tensor(f"{prefix}.weight_scale_2").float()
437
- params = TensorCoreNVFP4Layout.Params(
438
- scale=tensor_scale,
439
- block_scale=block_scale,
440
- orig_dtype=torch.bfloat16,
441
- orig_shape=(self.out_features, self.in_features),
442
- )
443
- quantized = QuantizedTensor(weight.to(torch.uint8), "TensorCoreNVFP4Layout", params)
444
- self.weight = nn.Parameter(quantized, requires_grad=False)
445
- self.quantized = True
446
- self.full_precision_mm = bool(config.get("full_precision_matrix_mult", False))
447
- for name in ("input_scale", "pre_quant_scale"):
448
- key = f"{prefix}.{name}"
449
- if key in handle.keys():
450
- setattr(self, name, handle.get_tensor(key))
451
- else:
452
- raise ValueError(f"Unsupported quantization on {prefix}: {config}")
453
-
454
- bias_key = f"{prefix}.bias"
455
- if bias_key in handle.keys():
456
- bias = handle.get_tensor(bias_key)
457
- self.bias = nn.Parameter(
458
- bias if self.compute_dtype is None else bias.to(self.compute_dtype), requires_grad=False
459
- )
460
-
461
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
462
- if self.pre_quant_scale is not None:
463
- hidden_states = hidden_states * self.pre_quant_scale.to(
464
- device=hidden_states.device, dtype=hidden_states.dtype
465
- )
466
- if not self.quantized:
467
- hidden_states = hidden_states.to(self.weight.dtype)
468
- return F.linear(
469
- hidden_states,
470
- self.weight,
471
- self.bias,
472
- )
473
-
474
- if self.full_precision_mm:
475
- # Some AWQ checkpoints use NVFP4 as a compact weight format but deliberately retain BF16 activations and
476
- # GEMMs. Dequantization is layer-local, so residency stays compact without adding activation error.
477
- weight = self.weight.dequantize().to(hidden_states.dtype)
478
- return F.linear(hidden_states, weight, None if self.bias is None else self.bias.to(hidden_states.dtype))
479
-
480
- shape = hidden_states.shape
481
- flat = hidden_states.reshape(-1, shape[-1])
482
- scale = None if self.input_scale is None else self.input_scale.to(flat.device)
483
- quantized_input = QuantizedTensor.from_float(flat, "TensorCoreNVFP4Layout", scale=scale)
484
- output = F.linear(
485
- quantized_input,
486
- self.weight,
487
- None if self.bias is None else self.bias.to(hidden_states.dtype),
488
- )
489
- return output.reshape(*shape[:-1], self.out_features)
490
-
491
-
492
- class H3RMSNorm(nn.Module):
493
- def __init__(self, width: int, eps: float = EPS):
494
- super().__init__()
495
- self.width = width
496
- self.eps = eps
497
- self.register_parameter("weight", None)
498
-
499
- def load(self, handle, prefix: str) -> None:
500
- self.weight = nn.Parameter(handle.get_tensor(f"{prefix}.weight"), requires_grad=False)
501
-
502
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
503
- return F.rms_norm(
504
- hidden_states,
505
- (self.width,),
506
- self.weight,
507
- self.eps,
508
- )
509
-
510
-
511
- class H3Attention(nn.Module):
512
- def __init__(self):
513
- super().__init__()
514
- self.qkv_proj = H3Linear(HIDDEN, 3 * HEADS * HEAD_DIM)
515
- self.q_norm = H3RMSNorm(HEAD_DIM)
516
- self.k_norm = H3RMSNorm(HEAD_DIM)
517
- self.out_proj = H3Linear(HEADS * HEAD_DIM, HIDDEN)
518
-
519
- def load(self, handle, prefix: str) -> None:
520
- self.qkv_proj.load(handle, f"{prefix}.qkv_proj")
521
- self.q_norm.load(handle, f"{prefix}.q_norm")
522
- self.k_norm.load(handle, f"{prefix}.k_norm")
523
- self.out_proj.load(handle, f"{prefix}.out_proj")
524
-
525
- def forward(self, hidden_states, rope_table, backend: str, sparse=None, layer: int = -1):
526
- sequence = hidden_states.shape[0]
527
- qkv = self.qkv_proj(hidden_states)
528
- query, key, value = qkv.split(HEADS * HEAD_DIM, dim=-1)
529
- query = query.view(1, sequence, HEADS, HEAD_DIM)
530
- key = key.view(1, sequence, HEADS, HEAD_DIM)
531
- value = value.view(1, sequence, HEADS, HEAD_DIM)
532
-
533
- # One in-place kernel replaces Q RMSNorm, K RMSNorm and both partial RoPE applications.
534
- kitchen.rms_rope_split_half_(
535
- query,
536
- key,
537
- rope_table,
538
- self.q_norm.weight,
539
- self.k_norm.weight,
540
- epsilon=self.q_norm.eps,
541
- rot_dim=rope_table.shape[-3] * 2,
542
- )
543
- attended = sparse(query, key, value, layer) if sparse is not None else None
544
- if attended is None:
545
- attended = dispatch_attention_fn(
546
- query,
547
- key,
548
- value,
549
- attn_mask=None,
550
- dropout_p=0.0,
551
- is_causal=False,
552
- backend=backend,
553
- )
554
- return self.out_proj(attended.reshape(sequence, HEADS * HEAD_DIM))
555
-
556
-
557
- class H3MLP(nn.Module):
558
- def __init__(self):
559
- super().__init__()
560
- self.fc1 = H3Linear(HIDDEN, 2 * FFN)
561
- self.fc2 = H3Linear(FFN, HIDDEN)
562
-
563
- def load(self, handle, prefix: str) -> None:
564
- self.fc1.load(handle, f"{prefix}.fc1")
565
- self.fc2.load(handle, f"{prefix}.fc2")
566
-
567
- def forward(self, hidden_states):
568
- gate, up = self.fc1(hidden_states).chunk(2, dim=-1)
569
- return self.fc2(F.silu(gate).mul_(up))
570
-
571
-
572
- class H3RefinerBlock(nn.Module):
573
- def __init__(self):
574
- super().__init__()
575
- self.norm1 = H3RMSNorm(HIDDEN)
576
- self.attn = H3Attention()
577
- self.norm2 = H3RMSNorm(HIDDEN)
578
- self.mlp = H3MLP()
579
-
580
- def load(self, handle, prefix: str) -> None:
581
- self.norm1.load(handle, f"{prefix}.norm1")
582
- self.attn.load(handle, f"{prefix}.attn")
583
- self.norm2.load(handle, f"{prefix}.norm2")
584
- self.mlp.load(handle, f"{prefix}.mlp")
585
-
586
-
587
- class H3AdaLN(nn.Module):
588
- def __init__(self, expand: int, modalities: int):
589
- super().__init__()
590
- self.expand = expand
591
- self.modalities = modalities
592
- # Curve checkpoints deliberately evaluate interpolation and modulation projection in FP32. Expanding the
593
- # checkpoint's tiny FP16 [*, 8] matrices once at load avoids 51 request-step casts.
594
- self.linear = H3Linear(
595
- TIME_DIM, expand * HIDDEN * modalities, bias=True, compute_dtype=torch.float32
596
- )
597
-
598
- def load(self, handle, prefix: str) -> None:
599
- self.linear.load(handle, f"{prefix}.linear")
600
-
601
- def forward(self, time_embedding, output_dtype=None):
602
- projected = self.linear(time_embedding)
603
- if output_dtype is not None:
604
- # One contiguous conversion is numerically identical to converting the six chunk views independently,
605
- # and removes five CUDA launches from every one of the 50 blocks.
606
- projected = projected.to(output_dtype)
607
- projected = projected.view(-1, self.expand * HIDDEN)
608
- return projected.chunk(self.expand, dim=-1)
609
-
610
-
611
- class H3Block(nn.Module):
612
- def __init__(self):
613
- super().__init__()
614
- self.norm1 = H3RMSNorm(HIDDEN)
615
- self.attn = H3Attention()
616
- self.norm2 = H3RMSNorm(HIDDEN)
617
- self.mlp = H3MLP()
618
- self.adaln_proj = H3AdaLN(6, 3)
619
-
620
- def load(self, handle, prefix: str) -> None:
621
- self.norm1.load(handle, f"{prefix}.norm1")
622
- self.attn.load(handle, f"{prefix}.attn")
623
- self.norm2.load(handle, f"{prefix}.norm2")
624
- self.mlp.load(handle, f"{prefix}.mlp")
625
- self.adaln_proj.load(handle, f"{prefix}.adaln_proj")
626
-
627
-
628
- class H3FinalLayer(nn.Module):
629
- def __init__(self):
630
- super().__init__()
631
- self.norm = H3RMSNorm(HIDDEN)
632
- self.adaln_proj = H3AdaLN(2, 1)
633
- self.video_out = H3Linear(HIDDEN, VIDEO_DIM, bias=True, compute_dtype=torch.float32)
634
- self.audio_out = H3Linear(HIDDEN, AUDIO_DIM, bias=True, compute_dtype=torch.float32)
635
-
636
- def load(self, handle, prefix: str) -> None:
637
- self.norm.load(handle, f"{prefix}.norm")
638
- self.adaln_proj.load(handle, f"{prefix}.adaln_proj")
639
- self.video_out.load(handle, f"{prefix}.video_out")
640
- self.audio_out.load(handle, f"{prefix}.audio_out")
641
-
642
-
643
- class H3NVFP4Transformer(nn.Module):
644
- """Diffusers-compatible H3 transformer backed by fused comfy-kitchen NVFP4 kernels."""
645
-
646
- def __init__(self):
647
- super().__init__()
648
- # The modular pipeline reads these values through the diffusers component config rather than inspecting the
649
- # module itself. Keep the public transformer contract even though this lean adapter is not a ConfigMixin.
650
- self.config = SimpleNamespace(
651
- patch_size=(1, 2, 2),
652
- in_channels=24,
653
- audio_in_channels=AUDIO_DIM,
654
- text_dim=TEXT_DIM,
655
- )
656
- self.video_patch_proj = H3Linear(VIDEO_DIM, HIDDEN, bias=True, compute_dtype=torch.float32)
657
- self.audio_patch_proj = H3Linear(AUDIO_DIM, HIDDEN, bias=True, compute_dtype=torch.float32)
658
- self.condition_proj = H3Linear(TEXT_DIM, HIDDEN, bias=True)
659
- self.token_refiner = nn.ModuleList([H3RefinerBlock() for _ in range(REFINER_LAYERS)])
660
- self.token_refiner_norm = H3RMSNorm(HIDDEN)
661
- self.blocks = nn.ModuleList([H3Block() for _ in range(LAYERS)])
662
- self.final_layer = H3FinalLayer()
663
- self.register_buffer("adaln_t_table", None)
664
- self.register_buffer("rope_inv_freq", None)
665
- self.attention_backend = "_native_cudnn"
666
- self._text_cache = None
667
- self._rope_cache = None
668
- self._segment_cache = None
669
- self._condition_video_rows = None
670
- self._condition_video_embedding = None
671
- self._output_indices = None
672
- self._generated_rows = None
673
- self._step_cache = H3StepCache()
674
- self._sol_attention = H3SolAttention()
675
-
676
- @property
677
- def dtype(self) -> torch.dtype:
678
- """Match ModelMixin's placement contract used by ModularPipeline.to()."""
679
- return self.condition_proj.weight.dtype
680
-
681
- @property
682
- def device(self) -> torch.device:
683
- return self.adaln_t_table.device
684
-
685
- def load(self, path: str) -> None:
686
- from safetensors import safe_open
687
-
688
- with safe_open(path, framework="pt", device="cpu") as handle:
689
- self.video_patch_proj.load(handle, "video_patch_proj")
690
- self.audio_patch_proj.load(handle, "audio_patch_proj")
691
- self.condition_proj.load(handle, "condition_proj")
692
- for index, block in enumerate(self.token_refiner):
693
- block.load(handle, f"token_refiner.blocks.{index}")
694
- self.token_refiner_norm.load(handle, "token_refiner.final_norm")
695
- for index, block in enumerate(self.blocks):
696
- block.load(handle, f"blocks.{index}")
697
- self.final_layer.load(handle, "final_layer")
698
- self.adaln_t_table = handle.get_tensor("adaln_t_table")
699
- self.rope_inv_freq = handle.get_tensor("rope.inv_freq")
700
- # Every loaded tensor is already a frozen Parameter (or a buffer). Avoid mutating the quantized tensor
701
- # subclass through a redundant requires_grad_ dispatch.
702
- self.eval()
703
-
704
- def set_attention_backend(self, backend: str) -> None:
705
- self.attention_backend = backend
706
-
707
- def begin_request(self, total_steps: int | None = None, profile: str = "balanced") -> None:
708
- self._text_cache = None
709
- self._rope_cache = None
710
- self._segment_cache = None
711
- self._condition_video_rows = None
712
- self._condition_video_embedding = None
713
- self._output_indices = None
714
- self._generated_rows = None
715
- self._step_cache.begin(total_steps, profile)
716
- self._sol_attention.begin()
717
-
718
- def end_request(self) -> dict:
719
- stats = self._step_cache.finish()
720
- stats["sol_sparse_calls"] = self._sol_attention.sparse_calls
721
- stats["sol_dense_calls"] = self._sol_attention.dense_calls
722
- stats["sol_failure"] = self._sol_attention.failure
723
- self._text_cache = None
724
- self._rope_cache = None
725
- self._segment_cache = None
726
- self._condition_video_rows = None
727
- self._condition_video_embedding = None
728
- self._output_indices = None
729
- self._generated_rows = None
730
- return stats
731
-
732
- def _refine_text(self, text_states: torch.Tensor) -> torch.Tensor:
733
- key = (text_states.data_ptr(), tuple(text_states.shape), text_states.device)
734
- if self._text_cache is not None and self._text_cache[0] == key:
735
- return self._text_cache[1]
736
- hidden = self.condition_proj(text_states)
737
- # Text is tiny compared with the video sequence; use the same fused QKV path with an identity RoPE omitted.
738
- for block in self.token_refiner:
739
- residual = hidden
740
- normalized = block.norm1(hidden)
741
- qkv = block.attn.qkv_proj(normalized)
742
- query, key_states, value = qkv.split(HEADS * HEAD_DIM, dim=-1)
743
- query = block.attn.q_norm(query.view(1, -1, HEADS, HEAD_DIM))
744
- key_states = block.attn.k_norm(key_states.view(1, -1, HEADS, HEAD_DIM))
745
- value = value.view(1, -1, HEADS, HEAD_DIM)
746
- attended = dispatch_attention_fn(
747
- query,
748
- key_states,
749
- value,
750
- attn_mask=None,
751
- dropout_p=0.0,
752
- is_causal=False,
753
- backend=self.attention_backend,
754
- ).reshape(-1, HEADS * HEAD_DIM)
755
- hidden = residual + block.attn.out_proj(attended)
756
- hidden = hidden + block.mlp(block.norm2(hidden))
757
- hidden = self.token_refiner_norm(hidden)
758
- self._text_cache = (key, hidden)
759
- return hidden
760
-
761
- def _rope(self, position_ids: torch.Tensor, dtype: torch.dtype) -> torch.Tensor:
762
- key = (position_ids.data_ptr(), tuple(position_ids.shape), position_ids.device, dtype)
763
- if self._rope_cache is not None and self._rope_cache[0] == key:
764
- return self._rope_cache[1]
765
- positions = position_ids.to(torch.float32)
766
- frequencies = positions.unsqueeze(-1) * self.rope_inv_freq.to(position_ids.device).view(1, 1, -1)
767
- temporal, height, width = frequencies.unbind(dim=1)
768
- angles = torch.cat((temporal, height, width), dim=-1)
769
- cosine, sine = angles.cos(), angles.sin()
770
- table = torch.stack((cosine, -sine, sine, cosine), dim=-1)
771
- table = table.reshape(1, position_ids.shape[0], 1, angles.shape[-1], 2, 2).to(dtype)
772
- self._rope_cache = (key, table)
773
- return table
774
-
775
- def _time_embedding(self, timestep: torch.Tensor) -> torch.Tensor:
776
- table = self.adaln_t_table.to(timestep.device)
777
- position = timestep.float().clamp(0.0, 1.0) * (table.shape[0] - 1)
778
- lower = position.floor().long().clamp(max=table.shape[0] - 2)
779
- return torch.lerp(table[lower], table[lower + 1], (position - lower).unsqueeze(1))
780
-
781
- def _segments(self, indices: torch.Tensor):
782
- if self._segment_cache is None:
783
- host = indices.detach().cpu()
784
- changes = (host[1:] != host[:-1]).nonzero().flatten().add(1).tolist()
785
- bounds = [0, *changes, len(host)]
786
- # Python row ids avoid indexing modulation tensors with CUDA scalar tensors in every block.
787
- self._segment_cache = [
788
- (start, stop, int(host[start])) for start, stop in zip(bounds[:-1], bounds[1:])
789
- ]
790
- return self._segment_cache
791
-
792
- def _video_layout(self, video_indices: torch.Tensor) -> int:
793
- """Number of leading, static keyframe-patch rows in the video latent tensor."""
794
- if self._condition_video_rows is None:
795
- host = video_indices.detach().cpu()
796
- discontinuities = (host[1:] - host[:-1] != 1).nonzero().flatten()
797
- self._condition_video_rows = int(discontinuities[0]) + 1 if len(discontinuities) else 0
798
- return self._condition_video_rows
799
-
800
- def _project_video(self, hidden_states: torch.Tensor, condition_rows: int, dtype: torch.dtype) -> torch.Tensor:
801
- source = hidden_states[0]
802
- if condition_rows == 0:
803
- return self.video_patch_proj(source.float()).to(dtype)
804
- if self._condition_video_embedding is None:
805
- self._condition_video_embedding = self.video_patch_proj(source[:condition_rows].float()).to(dtype)
806
- generated = self.video_patch_proj(source[condition_rows:].float()).to(dtype)
807
- return torch.cat((self._condition_video_embedding, generated), dim=0)
808
-
809
- @staticmethod
810
- def _modulate(hidden, shift, scale, row_ids, segments):
811
- if FUSED_ADALN and hidden.is_cuda and hidden.is_contiguous():
812
- _adaln_modulate_kernel[(triton.cdiv(hidden.numel(), 256),)](
813
- hidden, shift, scale, row_ids, hidden.numel(), HIDDEN, shift.stride(0), num_warps=4
814
- )
815
- return hidden
816
- for start, stop, row in segments:
817
- hidden[start:stop].mul_(1.0 + scale[row]).add_(shift[row])
818
- return hidden
819
-
820
- @staticmethod
821
- def _gate(hidden, update, gate, row_ids, segments):
822
- if FUSED_ADALN and hidden.is_cuda and hidden.is_contiguous() and update.is_contiguous():
823
- _adaln_gate_kernel[(triton.cdiv(hidden.numel(), 256),)](
824
- hidden, update, gate, row_ids, hidden.numel(), HIDDEN, gate.stride(0), num_warps=4
825
- )
826
- return hidden
827
- for start, stop, row in segments:
828
- hidden[start:stop].addcmul_(update[start:stop], gate[row])
829
- return hidden
830
-
831
- def forward(
832
- self,
833
- hidden_states,
834
- audio_hidden_states,
835
- encoder_hidden_states,
836
- timestep,
837
- timestep_indices,
838
- token_tags,
839
- position_ids,
840
- video_indices,
841
- audio_indices,
842
- text_indices,
843
- attention_kwargs=None,
844
- return_dict=True,
845
- ):
846
- from diffusers.models.transformers.transformer_minimax_h3 import MiniMaxH3TransformerOutput
847
-
848
- if hidden_states.shape[0] != 1:
849
- raise ValueError("The NVFP4 MiniMax-H3 engine supports batch size 1.")
850
-
851
- condition_rows = self._video_layout(video_indices)
852
- reused = self._step_cache.try_reuse(hidden_states, audio_hidden_states, condition_rows)
853
- if reused is not None:
854
- video_output, audio_output = reused
855
- if not return_dict:
856
- return video_output, audio_output
857
- return MiniMaxH3TransformerOutput(sample=video_output, audio_sample=audio_output)
858
-
859
- text = self._refine_text(encoder_hidden_states[0].to(torch.bfloat16))
860
- video = self._project_video(hidden_states, condition_rows, text.dtype)
861
- audio = self.audio_patch_proj(audio_hidden_states[0].float()).to(text.dtype)
862
- # Text, video and audio indices partition the packed sequence, so initialization would only add a full HBM
863
- # write before the three index copies overwrite every row.
864
- packed = text.new_empty((position_ids.shape[0], HIDDEN))
865
- packed.index_copy_(0, text_indices, text)
866
- packed.index_copy_(0, video_indices, video)
867
- packed.index_copy_(0, audio_indices, audio)
868
-
869
- time_embedding = self._time_embedding(timestep)
870
- adaln_indices = timestep_indices * 3 + token_tags.clamp(min=0)
871
- segments = self._segments(adaln_indices)
872
- rope = self._rope(position_ids, packed.dtype)
873
- use_sol_attention = self._step_cache.profile != "exact" and self._sol_attention.enabled
874
- self._sol_attention.observe(video_indices, packed.shape[0], self._step_cache.step)
875
-
876
- reused_tail = False
877
- for layer, block in enumerate(self.blocks):
878
- if layer == 0:
879
- # Block 0 writes its residual updates in place, so retain the pre-block value for the official FBC
880
- # signal `(head_output - head_input)`.
881
- block_input = packed.detach().clone()
882
- # One conversion per small modulation table, rather than one conversion per sequence segment.
883
- modulations = block.adaln_proj(time_embedding, packed.dtype)
884
- shift_attn, scale_attn, gate_attn, shift_mlp, scale_mlp, gate_mlp = modulations
885
- normalized = self._modulate(block.norm1(packed), shift_attn, scale_attn, adaln_indices, segments)
886
- packed = self._gate(
887
- packed,
888
- block.attn(
889
- normalized,
890
- rope,
891
- self.attention_backend,
892
- self._sol_attention if use_sol_attention else None,
893
- layer,
894
- ),
895
- gate_attn,
896
- adaln_indices,
897
- segments,
898
- )
899
- normalized = self._modulate(block.norm2(packed), shift_mlp, scale_mlp, adaln_indices, segments)
900
- packed = self._gate(packed, block.mlp(normalized), gate_mlp, adaln_indices, segments)
901
-
902
- if layer == 0:
903
- if self._step_cache.first_block_decision(block_input, packed):
904
- packed = packed + self._step_cache.tail_residual
905
- reused_tail = True
906
- break
907
- if layer == len(self.blocks) - 1 and not reused_tail:
908
- self._step_cache.update_first_block_tail(packed)
909
-
910
- shift, scale = self.final_layer.adaln_proj(time_embedding)
911
-
912
- # Keyframe output rows are discarded by the scheduler. Avoid their FP32 output projection and put zeros in
913
- # those unused slots to retain the pipeline's expected tensor shape.
914
- generated_video_indices = video_indices[condition_rows:]
915
- if self._output_indices is None:
916
- self._generated_rows = generated_video_indices.shape[0]
917
- self._output_indices = torch.cat((generated_video_indices, audio_indices))
918
- generated_rows = self._generated_rows
919
- normalized_output = self.final_layer.norm(packed.index_select(0, self._output_indices))
920
- video_times = timestep_indices.index_select(0, generated_video_indices)
921
- video_hidden = normalized_output[:generated_rows]
922
- video_hidden = video_hidden * (1.0 + scale.index_select(0, video_times)) + shift.index_select(0, video_times)
923
- generated_video_output = self.final_layer.video_out(video_hidden.float())
924
- if condition_rows:
925
- video_output = generated_video_output.new_zeros((1, hidden_states.shape[1], VIDEO_DIM))
926
- video_output[0, condition_rows:] = generated_video_output
927
- else:
928
- video_output = generated_video_output.unsqueeze(0)
929
-
930
- audio_times = timestep_indices.index_select(0, audio_indices)
931
- audio_hidden = normalized_output[generated_rows:]
932
- audio_hidden = audio_hidden * (1.0 + scale.index_select(0, audio_times)) + shift.index_select(0, audio_times)
933
- audio_output = self.final_layer.audio_out(audio_hidden.float()).unsqueeze(0)
934
-
935
- self._step_cache.update(
936
- hidden_states,
937
- audio_hidden_states,
938
- video_output,
939
- audio_output,
940
- condition_rows,
941
- )
942
-
943
- if not return_dict:
944
- return video_output, audio_output
945
- return MiniMaxH3TransformerOutput(sample=video_output, audio_sample=audio_output)
946
-
947
-
948
- def load_transformer() -> H3NVFP4Transformer:
949
- if torch.version.cuda is None or int(torch.version.cuda.split(".")[0]) < 13:
950
- raise RuntimeError("NVFP4 requires the CUDA 13 PyTorch build.")
951
- from huggingface_hub import hf_hub_download
952
-
953
- path = hf_hub_download(repo_id=NVFP4_REPO, filename=NVFP4_FILE)
954
- transformer = H3NVFP4Transformer()
955
- transformer.load(path)
956
- print(f"[h3-nvfp4] loaded {NVFP4_REPO}/{NVFP4_FILE}", flush=True)
957
- return transformer
958
-
959
-
960
- def status() -> str:
961
- return (
962
- f"NVFP4 · linear residual forecast {FORECAST_BLEND:g} / adaptive cache {EASYCACHE_THRESHOLD:g} · "
963
- f"pruned AdaLN curve · fused QKV/QK-norm/RoPE · `{NVFP4_REPO}`"
964
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
h3_split_blocks.py CHANGED
@@ -1,8 +1,8 @@
1
- """Composable conditioner and generator halves of MiniMax-H3, for both checkpoint partitions.
2
 
3
- The blocks cut `MiniMaxH3Blocks` at its `text_encoder` step. They can run in separate Spaces with `prompt_embeds` and
4
- `text_token_tags` as a wire format, or sequentially in one GPU worker when a compact local conditioner fits beside
5
- the generator.
6
 
7
  `resize` / `setup` run on **both** sides: they own no pretrained component, and each half needs the canvas and the
8
  prepared keyframes or normalized references. Both conditioner halves also return the resolved `height` / `width` /
@@ -84,7 +84,7 @@ class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
84
  return (
85
  "The denoising half of a split MiniMax-H3 deployment: the `t2va` / `fl2va` branch of `MiniMaxH3Blocks` "
86
  "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
87
- "conditioner is supplied by the caller or by the preceding local conditioner half."
88
  )
89
 
90
  @property
@@ -139,7 +139,7 @@ class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
139
  return (
140
  "The denoising half of a split MiniMax-H3 `ref2va` deployment: the `ref2va` branch of `MiniMaxH3Blocks` "
141
  "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
142
- "conditioner is supplied by the caller or preceding local half. The transformer is the `transformer_ref` partition."
143
  )
144
 
145
  @property
 
1
+ """The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
2
 
3
+ MiniMax-H3 is 195.9 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage, so `MiniMaxH3Blocks` is cut
4
+ at its `text_encoder` step: the 62.14 GiB Qwen3-VL runs in the conditioner Space, everything else in a generator
5
+ Space, and `prompt_embeds` + `text_token_tags` is the whole wire format between them.
6
 
7
  `resize` / `setup` run on **both** sides: they own no pretrained component, and each half needs the canvas and the
8
  prepared keyframes or normalized references. Both conditioner halves also return the resolved `height` / `width` /
 
84
  return (
85
  "The denoising half of a split MiniMax-H3 deployment: the `t2va` / `fl2va` branch of `MiniMaxH3Blocks` "
86
  "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
87
+ "62.14 GiB Qwen3-VL conditioner is never loaded here."
88
  )
89
 
90
  @property
 
139
  return (
140
  "The denoising half of a split MiniMax-H3 `ref2va` deployment: the `ref2va` branch of `MiniMaxH3Blocks` "
141
  "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
142
+ "62.14 GiB Qwen3-VL conditioner is never loaded here. The transformer is the `transformer_ref` partition."
143
  )
144
 
145
  @property
packages.txt CHANGED
@@ -1 +1 @@
1
- ffmpeg
 
1
+ ffmpeg
pk_workflow.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The three things that make `Plaguekind/Minimax-H3` a *workflow* rather than just MiniMax-H3.
2
+
3
+ `Plaguekind/Minimax-H3` ships no weights: it is a ComfyUI graph
4
+ (`PlagueKind-MinimaxH3-V1.5.json`) over `Comfy-Org/MiniMax-H3`, and everything it contributes is in the
5
+ sampling and the post chain. Read off the graph, that is:
6
+
7
+ | ComfyUI node | widget | here |
8
+ |---|---|---|
9
+ | `KSamplerSelect` | `euler` | MiniMax-H3's only sampler; the checkpoint is CFG-distilled, one forward per step |
10
+ | `BasicScheduler` | `linear_quadratic`, 15 steps, denoise 1.0 | `linear_quadratic_sigmas` |
11
+ | `ImageSharpenKJ` | `rcas`, 0.3 | `rcas` |
12
+ | `FrameInterpolate` + `FrameInterpolationModelLoader` | `film_net_fp16.safetensors`, multiplier 2 | `interpolate` |
13
+ | `CreateVideo` | fps `24 * 2` | 48 fps out |
14
+ | `RTXVideoSuperResolution` | 2x, `ULTRA` | **not portable** — NVIDIA NGX, Windows/RTX driver only |
15
+
16
+ The sigma schedule is the one that changes the pixels most, and the one that is easy to get subtly wrong.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import torch
22
+
23
+
24
+ # ----------------------------------------------------------------------------------------------------------------
25
+ # BasicScheduler(linear_quadratic)
26
+ # ----------------------------------------------------------------------------------------------------------------
27
+ # MiniMax-H3 carries two rectified-flow schedules per request, `shift = 12` for the video rows and `shift = 3` for
28
+ # the audio rows. diffusers builds both from one `linspace(1, 0, steps)` base grid; ComfyUI instead samples the
29
+ # *video* schedule and derives the audio one from it in closed form
30
+ # (`comfy/ldm/minimax/model.py::time_shift_sigma`). The two agree, because the shift is a bijection of the base
31
+ # grid — which is what lets a schedule chosen in ComfyUI's video-sigma space be transplanted here exactly.
32
+ #
33
+ # `linear_quadratic` is Mochi's schedule (`comfy/samplers.py::linear_quadratic_schedule`) and it does **not** go
34
+ # through the model's shift at all: it is `sigma_max = 1.0` scaled, so the grid PlagueKind's 15 steps actually run
35
+ # is this one verbatim, in the video stream, with the audio stream shifted off it.
36
+ VIDEO_SHIFT = 12.0
37
+ AUDIO_SHIFT = 3.0
38
+
39
+
40
+ def linear_quadratic_sigmas(
41
+ steps: int, threshold_noise: float = 0.025, linear_steps: int | None = None
42
+ ) -> torch.Tensor:
43
+ """ComfyUI's `linear_quadratic` sigma grid, in MiniMax-H3's video-sigma space.
44
+
45
+ Ported from `comfy/samplers.py::linear_quadratic_schedule` (itself from Mochi), with
46
+ `model_sampling.sigma_max == 1.0`, which is what a rectified-flow model has. Returns `steps + 1` strictly
47
+ decreasing sigmas from exactly 1.0 to exactly 0.0, so it drives `steps` forwards — ComfyUI's step count, not
48
+ diffusers' (where the terminal zero is one of the `num_inference_steps`).
49
+
50
+ Half the steps crawl through the first 2.5% of the trajectory and the rest sprint the remaining 97.5%: it is a
51
+ front-loaded schedule, which is why 15 steps of it hold up against ~28 of the native grid.
52
+ """
53
+ steps = int(steps)
54
+ if steps < 2:
55
+ return torch.tensor([1.0, 0.0], dtype=torch.float32)
56
+ if linear_steps is None:
57
+ linear_steps = steps // 2
58
+
59
+ linear = [i * threshold_noise / linear_steps for i in range(linear_steps)]
60
+ threshold_noise_step_diff = linear_steps - threshold_noise * steps
61
+ quadratic_steps = steps - linear_steps
62
+ quadratic_coef = threshold_noise_step_diff / (linear_steps * quadratic_steps**2)
63
+ linear_coef = threshold_noise / linear_steps - 2 * threshold_noise_step_diff / (quadratic_steps**2)
64
+ const = quadratic_coef * (linear_steps**2)
65
+ quadratic = [quadratic_coef * (i**2) + linear_coef * i + const for i in range(linear_steps, steps)]
66
+
67
+ schedule = linear + quadratic + [1.0]
68
+ return torch.tensor([1.0 - value for value in schedule], dtype=torch.float32)
69
+
70
+
71
+ def time_shift_sigma(sigma: torch.Tensor, from_shift: float, to_shift: float) -> torch.Tensor:
72
+ """Move a sigma between two exponential shifts of the same base grid.
73
+
74
+ `comfy/ldm/minimax/model.py::time_shift_sigma`: invert `sigma = s*b / (1 + (s-1)*b)` back to the base grid `b`,
75
+ then re-apply the other shift. Monotonic, and it fixes both 0.0 and 1.0, so a strictly decreasing schedule that
76
+ ends at zero stays one.
77
+ """
78
+ if from_shift == to_shift:
79
+ return sigma
80
+ base = sigma / (from_shift + sigma * (1.0 - from_shift))
81
+ return to_shift * base / (1.0 + (to_shift - 1.0) * base)
82
+
83
+
84
+ class use_linear_quadratic:
85
+ """Force MiniMax-H3's two schedulers onto the `linear_quadratic` grid for one pipeline call.
86
+
87
+ A context manager rather than a pipeline-block subclass on purpose: `MiniMaxH3Scheduler.set_timesteps` already
88
+ takes a fully-formed `sigmas=` schedule as public API, so nothing here reaches into the modular blocks, and the
89
+ override lives and dies inside one request.
90
+ """
91
+
92
+ def __init__(self, pipe, steps: int, threshold_noise: float = 0.025, enabled: bool = True):
93
+ self.schedulers = [pipe.scheduler, pipe.audio_scheduler] if enabled else []
94
+ self.steps = int(steps)
95
+ self.threshold_noise = float(threshold_noise)
96
+
97
+ def __enter__(self):
98
+ video_sigmas = linear_quadratic_sigmas(self.steps, self.threshold_noise)
99
+ for scheduler in self.schedulers:
100
+ sigmas = time_shift_sigma(video_sigmas, VIDEO_SHIFT, float(scheduler.shift))
101
+ unbound = type(scheduler).set_timesteps
102
+
103
+ def forced(num_inference_steps=None, device=None, sigmas=None, _s=scheduler, _grid=sigmas, _f=unbound):
104
+ return _f(_s, None, device, _grid)
105
+
106
+ scheduler.set_timesteps = forced
107
+ return self
108
+
109
+ def __exit__(self, *_):
110
+ for scheduler in self.schedulers:
111
+ scheduler.__dict__.pop("set_timesteps", None)
112
+ return False
113
+
114
+
115
+ # ----------------------------------------------------------------------------------------------------------------
116
+ # ImageSharpenKJ(rcas, 0.3)
117
+ # ----------------------------------------------------------------------------------------------------------------
118
+ def rcas(video: torch.Tensor, strength: float, chunk: int = 16) -> torch.Tensor:
119
+ """AMD FidelityFX **RCAS** — Robust Contrast Adaptive Sharpening — on `(frames, 3, H, W)` in `[0, 1]`.
120
+
121
+ The FidelityFX kernel, which is what `ImageSharpenKJ`'s `rcas` mode is: a 5-tap cross, a sharpening lobe whose
122
+ strength is limited per pixel so the ring it would create cannot leave `[0, 1]`, and a renormalised blend.
123
+
124
+ lobe = clamp(attenuation * min over channels of max(-min / 4*max, -(1 - max) / 4*(1 - min)), -0.1875, 0)
125
+ out = (center + lobe * (n + s + e + w)) / (1 + 4 * lobe)
126
+
127
+ `lobe` is negative, so the neighbours are subtracted: a high-pass with a headroom-aware gain, which is why it
128
+ sharpens MiniMax-H3's slightly soft VAE output without haloing it. PlagueKind's 0.3 is the strength; the note in
129
+ the workflow calls it "very natural" and that matches — the lobe clamp caps it well below a visible ring.
130
+
131
+ Batched over `chunk` frames at a time rather than ComfyUI's one, and written back in place: the clip is already
132
+ resident on the card, but this runs immediately after the denoise loop's allocation peak, and a whole-clip pass at
133
+ the full 1344x768x124 would ask the allocator for ~8 GB of intermediates at exactly the wrong moment.
134
+ """
135
+ if strength <= 0:
136
+ return video
137
+
138
+ frames, _, height, width = video.shape
139
+ strength = float(strength)
140
+ for start in range(0, frames, chunk):
141
+ center = video[start : start + chunk]
142
+ padded = torch.nn.functional.pad(center, (1, 1, 1, 1), mode="reflect")
143
+ north = padded[:, :, 0:height, 1 : width + 1]
144
+ south = padded[:, :, 2 : height + 2, 1 : width + 1]
145
+ west = padded[:, :, 1 : height + 1, 0:width]
146
+ east = padded[:, :, 1 : height + 1, 2 : width + 2]
147
+
148
+ low = torch.minimum(torch.minimum(torch.minimum(torch.minimum(north, south), west), east), center)
149
+ high = torch.maximum(torch.maximum(torch.maximum(torch.maximum(north, south), west), east), center)
150
+
151
+ hit_min = -low / (high * 4.0 + 1e-6)
152
+ hit_max = -(1.0 - high) / ((1.0 - low) * 4.0 + 1e-6)
153
+ lobe = torch.maximum(hit_min, hit_max).amin(dim=1, keepdim=True)
154
+ lobe = (lobe * strength).clamp_(-0.1875, 0.0)
155
+ del low, high, hit_min, hit_max
156
+
157
+ neighbours = north + south + east + west
158
+ center.copy_(((center + lobe * neighbours) / (1.0 + 4.0 * lobe)).clamp_(0.0, 1.0))
159
+ return video
160
+
161
+
162
+ # ----------------------------------------------------------------------------------------------------------------
163
+ # FrameInterpolate(film_net_fp16, multiplier=2)
164
+ # ----------------------------------------------------------------------------------------------------------------
165
+ FILM_REPO = "Comfy-Org/frame_interpolation"
166
+ FILM_FILE = "frame_interpolation/film_net_fp16.safetensors"
167
+
168
+
169
+ def load_film():
170
+ """FILM, off the same checkpoint the workflow names. CPU work; `None` on any failure, and the caller skips."""
171
+ from huggingface_hub import hf_hub_download
172
+ from safetensors.torch import load_file
173
+
174
+ from film_net import FILMNet
175
+
176
+ path = hf_hub_download(FILM_REPO, FILM_FILE)
177
+ model = FILMNet()
178
+ model.load_state_dict(load_file(path))
179
+ return model.eval().to(torch.float16)
180
+
181
+
182
+ @torch.no_grad()
183
+ def interpolate(model, video: torch.Tensor, multiplier: int = 2) -> torch.Tensor:
184
+ """`multiplier`x frame interpolation of `(frames, 3, H, W)` in `[0, 1]`, FILM, on the card.
185
+
186
+ Mirrors ComfyUI's `FrameInterpolate`: one pass per adjacent pair, the flow computed once per pair and reused for
187
+ every intermediate timestep (`forward_multi_timestep`), and the feature pyramid of frame `i + 1` carried over as
188
+ frame `i` of the next pair — which halves the feature extractions. Output length is
189
+ `(frames - 1) * multiplier + 1`, i.e. 24 fps in, `24 * multiplier` fps out.
190
+ """
191
+ frames = video.shape[0]
192
+ if model is None or frames < 2 or multiplier < 2:
193
+ return video
194
+
195
+ dtype = torch.float16
196
+ timesteps = [t / multiplier for t in range(1, multiplier)]
197
+ # float16, not the input's float32: the buffer is the largest allocation of the whole post chain (a 2x pass over
198
+ # 124 frames at 1344x768 is 247 of them) and it happens right after the denoise loop's peak.
199
+ out = torch.empty(((frames - 1) * multiplier + 1, *video.shape[1:]), dtype=dtype, device=video.device)
200
+ out[0] = video[0]
201
+ cursor = 1
202
+
203
+ cache: dict = {}
204
+ for index in range(frames - 1):
205
+ first = video[index : index + 1].to(dtype)
206
+ second = video[index + 1 : index + 2].to(dtype)
207
+ cache["img0"] = cache.pop("next") if "next" in cache else model.extract_features(first)
208
+ cache["img1"] = model.extract_features(second)
209
+ cache["next"] = cache["img1"]
210
+
211
+ middles = model.forward_multi_timestep(first, second, timesteps, cache=cache)
212
+ out[cursor : cursor + len(timesteps)] = middles.to(video.dtype).clamp_(0.0, 1.0)
213
+ cursor += len(timesteps)
214
+ out[cursor] = video[index + 1]
215
+ cursor += 1
216
+
217
+ return out
requirements.txt CHANGED
@@ -1,5 +1,10 @@
1
- # diffusers is installed from the canonical MiniMax-H3 PR
2
- # https://github.com/huggingface/diffusers/pull/14371 ("Minimax h3 follow up (review & refactor)")
 
 
 
 
 
3
  --extra-index-url https://download.pytorch.org/whl/cu130
4
  diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
5
  torch==2.11.0
@@ -7,11 +12,15 @@ torchvision==0.26.0
7
  # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
8
  transformers==5.8.0
9
  accelerate==1.14.0
10
- # Blackwell-native NVFP4 GEMMs and the fused Q/K RMSNorm + split-half RoPE kernel used by h3_nvfp4.py.
11
- # CUDA 13 is mandatory: older builds emulate this path and are slower than BF16.
12
- comfy-kitchen==0.2.26
13
- # PyAV muxes the generated soundtrack onto the frames (encode_video)
 
 
 
14
  av
15
  pillow
16
  numpy
17
- safetensors>=0.8.0
 
 
1
+ # `diffusers` is installed from the canonical MiniMax-H3 pull request,
2
+ # https://github.com/huggingface/diffusers/pull/14371 ("Minimax h3 follow up (review & refactor)"), pinned to a
3
+ # **commit** rather than to its `minimax-h3-refactor` branch: the PR is a WIP and its head moves, and this Space's
4
+ # blocks subclass its block classes. Re-pin — and re-check `h3_split_blocks.py` against the block names of the new
5
+ # head — whenever the PR updates.
6
+ #
7
+ # 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
8
  --extra-index-url https://download.pytorch.org/whl/cu130
9
  diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
10
  torch==2.11.0
 
12
  # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
13
  transformers==5.8.0
14
  accelerate==1.14.0
15
+ # diffusers pins <2.
16
+ huggingface-hub==1.24.0
17
+ gradio==6.20.0
18
+ spaces==0.51.1
19
+ # No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
20
+ # transformers 5.8.0 at import.
21
+ # PyAV muxes the generated soundtrack onto the frames (`encode_video`).
22
  av
23
  pillow
24
  numpy
25
+ requests
26
+ safetensors>=0.8.0