multimodalart HF Staff commited on
Commit
da11654
·
verified ·
1 Parent(s): 2b72a4d

MiniMax-H3 Wushu Action LoRA demo

Browse files
Files changed (5) hide show
  1. README.md +62 -5
  2. app.py +406 -0
  3. h3_lora.py +167 -0
  4. h3_split_blocks.py +138 -0
  5. requirements.txt +24 -0
README.md CHANGED
@@ -1,13 +1,70 @@
1
  ---
2
- title: Wushu Action H3 Lora Demo
3
- emoji: 🐢
4
  colorFrom: blue
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MiniMax-H3 Wushu Action LoRA
3
+ emoji: 🥋
4
  colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.24.0
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Martial-arts action video with a MiniMax-H3 LoRA
11
+ python_version: "3.12"
12
+ startup_duration_timeout: 30m
13
+ suggested_hardware: zero-a10g
14
+ models:
15
+ - MiniMaxAI/MiniMax-H3
16
+ - Jojocodex/minimax-h3-wushu-action-lora
17
+ - Comfy-Org/MiniMax-H3
18
  ---
19
 
20
+ # 武打动作 · MiniMax-H3 Wushu Action LoRA
21
+
22
+ Generate short **martial-arts / kung-fu action** clips — punches, kicks, combination forms, staff technique — with
23
+ [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) and the
24
+ [Wushu Action LoRA](https://huggingface.co/Jojocodex/minimax-h3-wushu-action-lora). Every clip comes back with
25
+ H3's native synchronized stereo soundtrack.
26
+
27
+ ## What it does
28
+
29
+ The LoRA was trained with ai-toolkit on **455 curated wushu clips** (2000 steps, rank 16, resolution 512,
30
+ 90 frames @ 24 fps) to teach MiniMax-H3 human martial-arts motion — the four technique families its captions
31
+ cover are 拳法 (punches), 腿法 (kicks), 综合套路 (combination forms) and 棍法 (staff).
32
+
33
+ It has **no trigger token**: the model card activates it with a natural-language action description
34
+ (*"a kung fu practitioner executing a spinning kick"*). The demo's **technique family** picker appends the
35
+ caption wording of the family you choose to your prompt, so a request lands inside the distribution the LoRA
36
+ was trained on. The default duration is **3.75 s = 90 frames**, exactly the clip length it saw during training.
37
+
38
+ ## How it works
39
+
40
+ This Space is the **denoising half** of a split MiniMax-H3 deployment:
41
+
42
+ - The 62 GiB Qwen3-VL text encoder runs in the
43
+ [conditioner Space](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner), called over the gradio API;
44
+ `prompt_embeds` + `text_token_tags` is the whole wire format between the halves.
45
+ - This Space loads the 61.7 GiB transformer + 10.4 GiB VAEs and runs the denoising loop and the video/audio decode
46
+ on the GPU (ZeroGPU, `xlarge` booking — the transformer does not fit the 48 GB default).
47
+ - The wushu action LoRA (`wushu_action_h3_lora_v4_2000_pruned.safetensors`, 416 keys over 208 modules) is **folded**
48
+ into the transformer's bf16 weights at startup, at the top of the card's recommended 0.8–1.0 strength range.
49
+ - The [Comfy-Org Turbo LoRA](https://huggingface.co/Comfy-Org/MiniMax-H3) (4-step) is folded on top — the LoRA card
50
+ states the pruned file (with `adaln_proj` removed) is Turbo-compatible.
51
+
52
+ ## Usage
53
+
54
+ Describe the action — *"a fighter executing a spinning staff technique"*, *"a practitioner demonstrating a fast
55
+ flurry of punches in continuous motion"* — optionally pick a technique family, and click **Generate**.
56
+
57
+ ## LoRA details
58
+
59
+ | Field | Value |
60
+ |---|---|
61
+ | Base model | MiniMax-H3 (FL2VA) |
62
+ | Rank | 16 |
63
+ | Training | ai-toolkit, 2000 steps, 455 wushu clips |
64
+ | Resolution | 512 |
65
+ | Clip length | 90 frames @ 24 fps (3.75 s) |
66
+ | Recommended strength | 0.8–1.0 (folded at 1.0) |
67
+ | Turbo compatibility | Yes (pruned file, `adaln_proj` removed) |
68
+
69
+ The LoRA card notes this is the **v4** checkpoint, captioned with a generic template; a per-technique captioned
70
+ **v5** is in progress.
app.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMax-H3 Wushu Action LoRA — text-to-video demo for the martial-arts / kung-fu motion LoRA.
2
+
3
+ This Space is the denoising half of a split MiniMax-H3 deployment:
4
+ - The 62 GiB Qwen3-VL text encoder runs in the conditioner Space (`multimodalart/qwen3vl-conditioner`),
5
+ called over the gradio API for each request.
6
+ - This Space loads the 61.7 GiB transformer + 10.4 GiB VAEs (77.3 GB total) and runs the denoising loop
7
+ and the video + audio decode on the GPU.
8
+ - The Jojocodex wushu action LoRA (rank 16, `_pruned`) is folded into the transformer weights at startup,
9
+ adding human martial-arts motion: punches, kicks, combination forms, staff technique.
10
+ - The Comfy-Org MiniMax-H3 Turbo LoRA (4-step) is folded on top, which the LoRA card says is supported
11
+ because the `_pruned` file has its `adaln_proj` rows removed.
12
+
13
+ The LoRA was trained with ai-toolkit on 455 curated wushu clips at 90 frames / 24 fps — so the demo's default
14
+ duration is 3.75 s, which is exactly the 90-frame window it saw.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import tempfile
21
+ import time
22
+ import traceback
23
+ from functools import cache
24
+
25
+ # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 72 GiB load can happen
26
+ # at startup rather than on GPU time.
27
+ import spaces
28
+ import gradio as gr
29
+
30
+ # --- Configuration ---
31
+ MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3")
32
+ CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
33
+ LORA_REPO = "Jojocodex/minimax-h3-wushu-action-lora"
34
+
35
+ # `pack` places the transformer at startup, `lazy` moves everything on the first GPU call.
36
+ PLACEMENT = os.environ.get("H3_PLACEMENT", "pack").lower()
37
+ # cuDNN's fused attention is 10-20% faster than the SDPA default on this pool.
38
+ ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
39
+ # The transformer alone is 61.7 GiB, so the 48 GB `large` booking cannot hold it.
40
+ GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
41
+
42
+ # --- Canvas definitions (labels are the wire contract with the conditioner) ---
43
+ CANVASES = {
44
+ # 16:9
45
+ "960x544 · 16:9 fast": (544, 960),
46
+ "1024x576 · 16:9 fast": (576, 1024),
47
+ "1152x640 · 16:9": (640, 1152),
48
+ "1280x704 · 16:9": (704, 1280),
49
+ "1344x768 · 16:9 full": (768, 1344),
50
+ # 9:16
51
+ "544x960 · 9:16 fast": (960, 544),
52
+ "640x1152 · 9:16": (1152, 640),
53
+ "768x1344 · 9:16 full": (1344, 768),
54
+ # 1:1
55
+ "544x544 · 1:1 fast": (544, 544),
56
+ "768x768 · 1:1 full": (768, 768),
57
+ # 4:3 / 3:4
58
+ "768x576 · 4:3 fast": (576, 768),
59
+ "1024x768 · 4:3 full": (768, 1024),
60
+ "576x768 · 3:4 fast": (768, 576),
61
+ "768x1024 · 3:4 full": (1024, 768),
62
+ # 21:9
63
+ "1152x512 · 21:9 fast": (512, 1152),
64
+ "1536x672 · 21:9 full": (672, 1536),
65
+ }
66
+ DEFAULT_CANVAS = "960x544 · 16:9 fast"
67
+
68
+ FPS = 24
69
+ FRAMES_PER_CHUNK = 17
70
+ LATENTS_PER_CHUNK = 5
71
+
72
+ MIN_UI_DURATION = 2.0
73
+ MAX_UI_DURATION = 14.0
74
+ # 3.75 s == 90 frames == 17 * 5 + 5, the clip length the LoRA was trained on.
75
+ TRAINED_DURATION = 3.75
76
+
77
+ DEFAULT_STEPS = 4
78
+ DEFAULT_SEED = 42
79
+
80
+ # The LoRA has no literal trigger token: the card says to activate it with a natural-language *action* description,
81
+ # and lists the four technique families it was captioned around. Each cue below is appended to the user's prompt so
82
+ # a request lands inside the family the user picked, in the wording the training captions used.
83
+ TECHNIQUES: dict[str, str] = {
84
+ "Free-form (no cue)": "",
85
+ "拳法 · Punches": "fist and punch techniques, fast hand strikes in continuous motion",
86
+ "腿法 · Kicks": "kicking techniques, high leg strikes and spinning kicks",
87
+ "综合套路 · Combination forms": "a continuous martial arts form, combination techniques flowing one into the next",
88
+ "棍法 · Staff": "staff technique, spinning and striking with a long staff",
89
+ }
90
+ DEFAULT_TECHNIQUE = "Free-form (no cue)"
91
+
92
+
93
+ def snap_frames(seconds: float) -> int:
94
+ """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
95
+ frames = max(1, round(float(seconds) * FPS))
96
+ while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
97
+ frames += 1
98
+ return frames
99
+
100
+
101
+ def compose_prompt(prompt: str, technique: str = DEFAULT_TECHNIQUE) -> str:
102
+ """The prompt the model is actually conditioned on: the request plus the technique-family cue."""
103
+ prompt = (prompt or "").strip()
104
+ cue = TECHNIQUES.get(technique or DEFAULT_TECHNIQUE, "")
105
+ if not cue:
106
+ return prompt
107
+ return f"{prompt.rstrip('.,;')}, {cue}"
108
+
109
+
110
+ def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
111
+ """Let the pipeline generate below its 5 s floor — the LoRA's own clips are 3.75 s."""
112
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
113
+
114
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
115
+
116
+
117
+ # --- Global state ---
118
+ PIPE = None
119
+ MANAGER = None
120
+ LOAD_ERROR: str | None = None
121
+ LOADED_IN: float | None = None
122
+ LORA_STATUS: str | None = None
123
+
124
+
125
+ def status() -> str:
126
+ if LOAD_ERROR:
127
+ return LOAD_ERROR
128
+ if PIPE is None:
129
+ return f"Loading `{MODEL_REPO}` (transformer + VAEs, 77.3 GB). Watch the Space logs."
130
+ return (
131
+ f"Ready · transformer + VAEs **bfloat16** · "
132
+ f"placement `{PLACEMENT}` · "
133
+ f"attention `{ATTENTION}` · "
134
+ f"{LORA_STATUS or 'no LoRA'} · "
135
+ f"loaded in {LOADED_IN:.0f}s · "
136
+ f"conditioner `{CONDITIONER_SPACE}`"
137
+ )
138
+
139
+
140
+ def load_models() -> str | None:
141
+ """Load the denoising half at startup."""
142
+ global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, LORA_STATUS
143
+
144
+ if PIPE is not None or LOAD_ERROR is not None:
145
+ return LOAD_ERROR
146
+
147
+ started = time.time()
148
+ try:
149
+ import torch
150
+ from diffusers import ComponentsManager
151
+
152
+ from h3_split_blocks import MiniMaxH3GeneratorBlocks
153
+
154
+ lower_duration_floor()
155
+ manager = ComponentsManager()
156
+ blocks = MiniMaxH3GeneratorBlocks()
157
+
158
+ print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
159
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
160
+ pipe.load_components(dtype=torch.bfloat16, trust_remote_code=True)
161
+
162
+ # Fold the wushu action LoRA (+ the Turbo LoRA) into the bf16 weights.
163
+ import h3_lora
164
+
165
+ LORA_STATUS = h3_lora.apply_lora(pipe.transformer)
166
+ if LORA_STATUS:
167
+ print(f"[gen] {LORA_STATUS}", flush=True)
168
+
169
+ pipe.transformer.set_attention_backend(ATTENTION)
170
+
171
+ if PLACEMENT == "pack":
172
+ pipe.transformer.to("cuda")
173
+
174
+ PIPE = pipe
175
+ MANAGER = manager
176
+ LOADED_IN = time.time() - started
177
+ print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
178
+ except Exception as error:
179
+ traceback.print_exc()
180
+ LOAD_ERROR = (
181
+ f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
182
+ f"`{type(error).__name__}: {error}`"
183
+ )
184
+ return LOAD_ERROR
185
+
186
+
187
+ @cache
188
+ def conditioner():
189
+ """The other half, over the gradio API."""
190
+ from gradio_client import Client
191
+
192
+ return Client(CONDITIONER_SPACE)
193
+
194
+
195
+ def encode_remote(prompt, canvas, num_frames):
196
+ """`/encode` on the conditioner Space — text only, this LoRA is text-to-video."""
197
+ from safetensors import safe_open
198
+
199
+ path, plan = conditioner().predict(
200
+ prompt=prompt,
201
+ image_path=None,
202
+ last_image_path=None,
203
+ canvas=canvas,
204
+ num_frames=num_frames,
205
+ rewrite_prompt=False,
206
+ api_name="/encode",
207
+ )
208
+ with safe_open(path, framework="pt") as handle:
209
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
210
+
211
+
212
+ # --- GPU duration estimation (fit on this pool: denoise is quadratic in the packed sequence length) ---
213
+ _DUR_B = 1.1745e-4
214
+ _DUR_C = 3.8396e-9
215
+ _DECODE_BASE = 15
216
+ _DECODE_PER_DEFAULT_CANVAS = 15
217
+ _DEFAULT_CANVAS_PIXELS = 960 * 544 * 124
218
+ _PLACEMENT_ALLOWANCE = 12
219
+ _PAD = 10
220
+
221
+
222
+ def get_duration(prompt_embeds, text_token_tags, height, width, num_frames, steps, seed, *a, **k):
223
+ height, width, num_frames, steps = int(height), int(width), int(num_frames), int(steps)
224
+
225
+ latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
226
+ rows = latent_frames * (height // 32) * (width // 32)
227
+ denoise = steps * (_DUR_B * rows + _DUR_C * rows**2)
228
+ decode = _DECODE_BASE + _DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / _DEFAULT_CANVAS_PIXELS
229
+ return max(60, int(denoise + decode) + _PLACEMENT_ALLOWANCE + _PAD)
230
+
231
+
232
+ @spaces.GPU(duration=get_duration, size=GPU_SIZE)
233
+ def _generate(prompt_embeds, text_token_tags, height, width, num_frames, steps, seed):
234
+ """The only thing on GPU time: the packed-sequence denoise loop and the two decoders."""
235
+ import torch
236
+
237
+ if PLACEMENT == "lazy":
238
+ PIPE.to("cuda")
239
+ elif PLACEMENT == "pack":
240
+ PIPE.vae.to("cuda")
241
+ PIPE.audio_vae.to("cuda")
242
+
243
+ state = PIPE(
244
+ prompt_embeds=prompt_embeds.to("cuda"),
245
+ text_token_tags=text_token_tags,
246
+ image=None,
247
+ last_image=None,
248
+ height=int(height),
249
+ width=int(width),
250
+ num_frames=int(num_frames),
251
+ num_inference_steps=int(steps),
252
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
253
+ )
254
+ return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
255
+
256
+
257
+ def generate(
258
+ prompt,
259
+ technique=DEFAULT_TECHNIQUE,
260
+ canvas=DEFAULT_CANVAS,
261
+ duration=TRAINED_DURATION,
262
+ steps=DEFAULT_STEPS,
263
+ seed=DEFAULT_SEED,
264
+ progress=gr.Progress(track_tqdm=True),
265
+ ):
266
+ """Generate a martial-arts action clip with the MiniMax-H3 wushu action LoRA.
267
+
268
+ Parameters:
269
+ prompt: What the fighter does, e.g. "a kung fu practitioner executing a spinning kick"
270
+ technique: Technique family cue appended to the prompt (punches / kicks / forms / staff)
271
+ canvas: Output resolution and aspect ratio
272
+ duration: Clip length in seconds, snapped to the 17n+5 frames the video VAE decodes
273
+ steps: Denoising steps (4 with the Turbo LoRA folded in)
274
+ seed: Random seed for reproducibility
275
+ """
276
+ if LOAD_ERROR:
277
+ raise gr.Error(LOAD_ERROR)
278
+ if PIPE is None:
279
+ raise gr.Error("The denoiser is still loading. Please wait a moment and try again.")
280
+ if not prompt or not prompt.strip():
281
+ raise gr.Error("Please describe the martial-arts action you want, e.g. 'a fighter throws a spinning kick'.")
282
+
283
+ from diffusers.utils import encode_video
284
+
285
+ full_prompt = compose_prompt(prompt, technique)
286
+ num_frames = snap_frames(duration)
287
+
288
+ progress(0.0, desc=f"Conditioning on {CONDITIONER_SPACE} ...")
289
+ conditioned = time.time()
290
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(full_prompt, canvas, num_frames)
291
+ condition_seconds = time.time() - conditioned
292
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
293
+
294
+ progress(0.1, desc=f"Denoising {int(steps)} steps at {width}x{height}, {num_frames} frames ...")
295
+ started = time.time()
296
+ frames, audio, sampling_rate = _generate(
297
+ prompt_embeds, text_token_tags, height, width, num_frames, steps, seed
298
+ )
299
+ generate_seconds = time.time() - started
300
+
301
+ directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
302
+ os.makedirs(directory, exist_ok=True)
303
+ path = os.path.join(directory, f"h3-wushu-{int(time.time() * 1000)}.mp4")
304
+ encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
305
+
306
+ report = (
307
+ f"**Prompt sent to the model:** {full_prompt}\n\n"
308
+ f"`{width}x{height}`, {num_frames} frames ({num_frames / FPS:.2f} s), {int(steps)} steps · "
309
+ f"seed {int(seed)} · conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens) · "
310
+ f"denoise + decode {generate_seconds:.0f}s ({generate_seconds / max(int(steps), 1):.1f} s/step)"
311
+ )
312
+ print(f"[gen] {report}", flush=True)
313
+ return path, report
314
+
315
+
316
+ # --- Load models at startup ---
317
+ load_models()
318
+
319
+ INTRO = """# 武打动作 · MiniMax-H3 Wushu Action LoRA
320
+
321
+ <div>
322
+ <a href="https://huggingface.co/Jojocodex/minimax-h3-wushu-action-lora" target="_blank" rel="noopener"><strong>[ LoRA ]</strong></a> &nbsp;
323
+ <a href="https://huggingface.co/MiniMaxAI/MiniMax-H3" target="_blank" rel="noopener"><strong>[ base model ]</strong></a> &nbsp;
324
+ <a href="https://huggingface.co/Comfy-Org/MiniMax-H3" target="_blank" rel="noopener"><strong>[ ComfyUI weights ]</strong></a>
325
+ </div>
326
+
327
+ Generate short **martial-arts / kung-fu action** clips — punches, kicks, forms, staff work — with
328
+ [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) and the
329
+ [Wushu Action LoRA](https://huggingface.co/Jojocodex/minimax-h3-wushu-action-lora), trained on 455 curated wushu
330
+ clips at 90 frames / 24 fps. The video comes back with H3's native synchronized soundtrack.
331
+
332
+ The LoRA has **no trigger token** — it activates on the action description itself. Describe the strike, then pick a
333
+ technique family to steer it toward the wording its captions used. Generation takes roughly a minute.
334
+ """
335
+
336
+ CSS = """
337
+ .main.fillable {max-width: 1250px !important}
338
+ .dark .gradio-container { color: var(--body-text-color); }
339
+ """
340
+
341
+ with gr.Blocks(title="MiniMax-H3 Wushu Action LoRA") as demo:
342
+ gr.Markdown(INTRO)
343
+
344
+ with gr.Row(equal_height=True):
345
+ with gr.Column():
346
+ prompt = gr.Textbox(
347
+ label="Action",
348
+ lines=3,
349
+ placeholder="e.g. 'a kung fu practitioner executing a spinning kick on a temple courtyard'",
350
+ value="a martial artist performing punches and kicks in fast combat",
351
+ )
352
+ technique = gr.Radio(
353
+ label="Technique family",
354
+ info="Appended to the prompt in the wording the LoRA's captions used.",
355
+ choices=list(TECHNIQUES),
356
+ value=DEFAULT_TECHNIQUE,
357
+ )
358
+ run = gr.Button("Generate", variant="primary", size="lg")
359
+ with gr.Accordion("Advanced options", open=False):
360
+ canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
361
+ duration = gr.Slider(
362
+ label="Duration (s)",
363
+ info="Snapped up to the next 17n+5 frames. The LoRA was trained at 90 frames (3.75 s).",
364
+ minimum=MIN_UI_DURATION,
365
+ maximum=MAX_UI_DURATION,
366
+ step=0.25,
367
+ value=TRAINED_DURATION,
368
+ )
369
+ steps = gr.Slider(
370
+ label="Steps",
371
+ info="4 is enough with the Turbo LoRA folded in.",
372
+ minimum=4,
373
+ maximum=30,
374
+ step=1,
375
+ value=DEFAULT_STEPS,
376
+ )
377
+ seed = gr.Number(label="Seed", value=DEFAULT_SEED, precision=0)
378
+
379
+ with gr.Column():
380
+ video = gr.Video(label="Video + soundtrack", autoplay=True)
381
+ report = gr.Markdown()
382
+
383
+ gr.Examples(
384
+ examples=[
385
+ ["a martial artist performing punches and kicks in fast combat", "Free-form (no cue)"],
386
+ ["a kung fu practitioner executing a spinning kick", "腿法 · Kicks"],
387
+ ["two fighters exchanging strikes in an intense fight", "Free-form (no cue)"],
388
+ ["a martial artist performing a powerful roundhouse kick, with explosive force", "腿法 · Kicks"],
389
+ ["a practitioner demonstrating a fast flurry of punches in continuous motion", "拳法 · Punches"],
390
+ ["a fighter executing a spinning staff technique", "棍法 · Staff"],
391
+ ["a wushu athlete running through a form in a courtyard at dawn", "综合套路 · Combination forms"],
392
+ ],
393
+ inputs=[prompt, technique],
394
+ outputs=[video, report],
395
+ fn=generate,
396
+ cache_examples=True,
397
+ cache_mode="lazy",
398
+ )
399
+
400
+ inputs = [prompt, technique, canvas, duration, steps, seed]
401
+ run.click(generate, inputs, [video, report], api_name="generate")
402
+ prompt.submit(generate, inputs, [video, report], show_api=False)
403
+
404
+
405
+ if __name__ == "__main__":
406
+ demo.launch(show_error=True, theme=gr.themes.Citrus(), css=CSS, max_threads=1000)
h3_lora.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wushu Action LoRA support for the diffusers MiniMax-H3 transformer.
2
+
3
+ The Jojocodex wushu-action LoRA (`Jojocodex/minimax-h3-wushu-action-lora`) targets the ComfyUI reference module
4
+ tree — `diffusion_model.blocks.N.attn.qkv_proj`, `diffusion_model.blocks.N.mlp.fc1`, etc. — with rank 16 and no
5
+ alpha metadata (so scale = 1.0, matching the convention where alpha == rank). The shipped `_pruned` file carries
6
+ 416 keys over 208 base modules (52 transformer blocks x {attn.qkv_proj, attn.out_proj, mlp.fc1, mlp.fc2}, the two
7
+ `token_refiner` blocks included) and has its `adaln_proj` rows removed (`__metadata__: {"adaln_pruned": "true"}`),
8
+ which is what makes it stackable with the Turbo acceleration LoRA.
9
+
10
+ The LoRA is applied by folding `scale * (lora_B @ lora_A)` into the bf16 weights rather than as runtime wrappers:
11
+ folding costs nothing per request and keeps the transformer a plain `nn.Module` for the ZeroGPU startup packing.
12
+ Deltas are computed in float32 and round once on the way back into bf16.
13
+
14
+ The diffusers conversion transforms:
15
+ - strip the `diffusion_model.` prefix the ai-toolkit export uses
16
+ - fused-QKV row thirds onto `attn.to_q/k/v`
17
+ - `SwiGLU` gate/value swap onto `ff.net.0.proj`
18
+ - `fc2` -> `ff.net.2`, `attn.out_proj` -> `attn.to_out.0`
19
+ - `blocks.` -> `transformer_blocks.`
20
+ - `token_refiner.blocks.` -> `token_refiner.refiner_blocks.`
21
+ - `final_layer.adaln_proj.linear` -> `norm_out.linear` (not present in the pruned file)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+
28
+ import torch
29
+
30
+ # --- Wushu Action LoRA config ---
31
+ # The model card tells ComfyUI users to take the `_pruned` file; it is also the Turbo-compatible one.
32
+ WUSHU_LORA_REPO = os.environ.get("WUSHU_LORA_REPO", "Jojocodex/minimax-h3-wushu-action-lora")
33
+ WUSHU_LORA_FILE = os.environ.get("WUSHU_LORA_FILE", "wushu_action_h3_lora_v4_2000_pruned.safetensors")
34
+ # The card recommends strength 0.8~1.0 for the ComfyUI LoraLoader; folded at the top of that range.
35
+ WUSHU_LORA_STRENGTH = float(os.environ.get("WUSHU_LORA_STRENGTH", "1.0"))
36
+
37
+ # --- Turbo LoRA config (optional acceleration) ---
38
+ TURBO_LORA_REPO = os.environ.get("TURBO_LORA_REPO", "Comfy-Org/MiniMax-H3")
39
+ TURBO_LORA_FILE = os.environ.get(
40
+ "TURBO_LORA_FILE", "loras/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors"
41
+ )
42
+ TURBO_LORA_STRENGTH = float(os.environ.get("TURBO_LORA_STRENGTH", "1.0"))
43
+
44
+ USE_TURBO = os.environ.get("USE_TURBO", "1").lower() not in ("0", "off", "none", "false")
45
+
46
+
47
+ def _targets(name: str, b: torch.Tensor, inner_dim: int) -> list[tuple[str, torch.Tensor]]:
48
+ """Map one reference-tree base name and its `lora_B` onto diffusers parameter key + row-transformed B."""
49
+ name = name.removeprefix("diffusion_model.")
50
+
51
+ if name.startswith("token_refiner.blocks."):
52
+ target = name.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1)
53
+ elif name.startswith("blocks."):
54
+ target = name.replace("blocks.", "transformer_blocks.", 1)
55
+ else:
56
+ target = name
57
+ target = target.replace("final_layer.adaln_proj.linear", "norm_out.linear")
58
+
59
+ if target.endswith(".attn.qkv_proj"):
60
+ prefix = target.removesuffix("qkv_proj")
61
+ return [
62
+ (f"{prefix}to_{kind}.weight", part.contiguous())
63
+ for kind, part in zip(("q", "k", "v"), b.split(inner_dim, dim=0))
64
+ ]
65
+ if target.endswith(".mlp.fc1"):
66
+ gate, value = b.chunk(2, dim=0)
67
+ return [(target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous())]
68
+ if target.endswith(".mlp.fc2"):
69
+ return [(target.replace(".mlp.fc2", ".ff.net.2") + ".weight", b)]
70
+ if target.endswith(".attn.out_proj"):
71
+ return [(target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", b)]
72
+ # `adaln_proj.linear` (block-level and the final `norm_out.linear`): identical row layout on both sides.
73
+ return [(target + ".weight", b)]
74
+
75
+
76
+ def _load_wushu_lora(inner_dim: int) -> dict:
77
+ """Load the Jojocodex wushu action LoRA from the Hub."""
78
+ from huggingface_hub import hf_hub_download
79
+ from safetensors.torch import load_file
80
+
81
+ lora = load_file(hf_hub_download(WUSHU_LORA_REPO, WUSHU_LORA_FILE))
82
+ bases = sorted({key.rsplit(".lora_", 1)[0] for key in lora})
83
+ entries = []
84
+ for name in bases:
85
+ a = lora[f"{name}.lora_A.weight"]
86
+ b = lora[f"{name}.lora_B.weight"]
87
+ entries.extend((key, a, b_part) for key, b_part in _targets(name, b, inner_dim))
88
+ return {
89
+ "label": f"{WUSHU_LORA_REPO}/{WUSHU_LORA_FILE}",
90
+ "scale": WUSHU_LORA_STRENGTH, # alpha == rank, so the base scale is 1
91
+ "entries": entries,
92
+ }
93
+
94
+
95
+ def _load_turbo_lora(inner_dim: int) -> dict:
96
+ """Load the MiniMax-H3 Turbo LoRA from Comfy-Org for 4-step accelerated inference.
97
+
98
+ The Comfy-Org Turbo LoRA uses the kohya format with explicit `.alpha` keys per LoRA layer, so each entry's
99
+ scale is `alpha / rank`, applied to `B` before the fused-QKV split.
100
+ """
101
+ from huggingface_hub import hf_hub_download
102
+ from safetensors.torch import load_file
103
+
104
+ lora = load_file(hf_hub_download(TURBO_LORA_REPO, TURBO_LORA_FILE))
105
+
106
+ all_keys = list(lora.keys())
107
+ bases = sorted({key.rsplit(".lora_", 1)[0] if ".lora_" in key else key.rsplit(".alpha", 1)[0] for key in all_keys})
108
+
109
+ entries = []
110
+ for name in bases:
111
+ a = lora[f"{name}.lora_A.weight"]
112
+ b = lora[f"{name}.lora_B.weight"]
113
+ alpha_key = f"{name}.alpha"
114
+ if alpha_key in lora:
115
+ scale = float(lora[alpha_key]) / a.shape[0]
116
+ else:
117
+ scale = 1.0
118
+ b_scaled = b * (scale * TURBO_LORA_STRENGTH)
119
+ entries.extend((key, a, b_part) for key, b_part in _targets(name, b_scaled, inner_dim))
120
+ return {
121
+ "label": f"{TURBO_LORA_REPO}/{TURBO_LORA_FILE}",
122
+ "scale": 1.0, # scale already applied per-entry above
123
+ "entries": entries,
124
+ }
125
+
126
+
127
+ def _apply(entries, params, sign: float) -> None:
128
+ """Fold (sign * scale * (B @ A)) into each target parameter in place."""
129
+ for key, a, b in entries:
130
+ param = params.get(key)
131
+ if param is None:
132
+ raise KeyError(f"LoRA target `{key}` not found in the transformer")
133
+ delta = sign * (b.to(torch.float32) @ a.to(torch.float32))
134
+ param.data = (param.data.float() + delta.to(param.device)).to(param.dtype)
135
+
136
+
137
+ def apply_lora(transformer) -> str | None:
138
+ """Fold the wushu action LoRA — and, when enabled, the Turbo LoRA — into the transformer weights.
139
+
140
+ Returns a status line, or `None` when nothing could be folded.
141
+ """
142
+ inner_dim = transformer.config.num_attention_heads * transformer.config.attention_head_dim
143
+ params = dict(transformer.named_parameters())
144
+
145
+ loaded = []
146
+
147
+ try:
148
+ wushu = _load_wushu_lora(inner_dim)
149
+ _apply(wushu["entries"], params, wushu["scale"])
150
+ loaded.append(f"wushu-action ({wushu['label']}, {len(wushu['entries'])} weights, scale={wushu['scale']})")
151
+ print(f"[lora] wushu action LoRA folded: {len(wushu['entries'])} weights", flush=True)
152
+ except Exception as error:
153
+ print(f"[lora] WARNING: failed to load the wushu action LoRA: {error}", flush=True)
154
+
155
+ if USE_TURBO:
156
+ try:
157
+ turbo = _load_turbo_lora(inner_dim)
158
+ _apply(turbo["entries"], params, turbo["scale"])
159
+ loaded.append(f"turbo ({turbo['label']}, {len(turbo['entries'])} weights)")
160
+ print(f"[lora] turbo LoRA folded: {len(turbo['entries'])} weights", flush=True)
161
+ except Exception as error:
162
+ print(f"[lora] WARNING: failed to load the turbo LoRA: {error}", flush=True)
163
+
164
+ if not loaded:
165
+ return None
166
+
167
+ return "LoRAs folded: " + " + ".join(loaded)
h3_split_blocks.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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` /
9
+ `num_frames`, which the generating half pins rather than re-deriving.
10
+
11
+ Two things the blocks leave to the caller: a keyframe reaches them EXIF-transposed and in RGB, and the `t2va` / `fl2va`
12
+ frame count is aligned to `17 * n + 5` before the call, since that arithmetic lives on the denoising side of the cut.
13
+ """
14
+
15
+ from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep
16
+ from diffusers.modular_pipelines.minimax_h3.decoders import MiniMaxH3AfterDenoiseStep
17
+ from diffusers.modular_pipelines.minimax_h3.encoders import (
18
+ MiniMaxH3Ref2VAReferenceEncoderStep,
19
+ MiniMaxH3Ref2VATextEncoderStep,
20
+ MiniMaxH3TextEncoderStep,
21
+ )
22
+ from diffusers.modular_pipelines.minimax_h3.modular_blocks_minimax_h3 import (
23
+ MiniMaxH3AutoKeyframeVaeEncoderStep,
24
+ MiniMaxH3AutoResizeStep,
25
+ MiniMaxH3CoreDenoiseStep,
26
+ MiniMaxH3DecodeStep,
27
+ MiniMaxH3Ref2VACoreDenoiseStep,
28
+ _generation_outputs,
29
+ )
30
+ from diffusers.modular_pipelines.modular_pipeline import SequentialPipelineBlocks
31
+ from diffusers.modular_pipelines.modular_pipeline_utils import OutputParam
32
+
33
+
34
+ def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
35
+ """The wire format of the split. `num_frames` is declared by the `ref2va` half alone, whose setup resolves one."""
36
+ return [
37
+ OutputParam.template("prompt_embeds"),
38
+ OutputParam("text_token_tags", description="The per-row modality tag of every row of `prompt_embeds`."),
39
+ OutputParam("height", type_hint=int, description="Resolved height of the generated video in pixels."),
40
+ OutputParam("width", type_hint=int, description="Resolved width of the generated video in pixels."),
41
+ *(
42
+ [OutputParam("num_frames", type_hint=int, description="Resolved number of frames, of the form 17 * n + 5.")]
43
+ if num_frames
44
+ else []
45
+ ),
46
+ ]
47
+
48
+
49
+ class MiniMaxH3ConditionerBlocks(SequentialPipelineBlocks):
50
+ """The conditioner half of a split MiniMax-H3: the keyframes on the canvas plus the Qwen3-VL read at layer 50."""
51
+
52
+ model_name = "minimax-h3"
53
+ block_classes = [MiniMaxH3AutoResizeStep, MiniMaxH3TextEncoderStep]
54
+ block_names = ["resize", "text_encoder"]
55
+
56
+ @property
57
+ def description(self):
58
+ return (
59
+ "The conditioner half of a split MiniMax-H3 deployment: puts the keyframes onto the target canvas and "
60
+ "encodes MiniMax-H3's presentation of the request into the `prompt_embeds` / `text_token_tags` pair the "
61
+ "denoising half consumes. The frame count is the caller's to align."
62
+ )
63
+
64
+ @property
65
+ def outputs(self):
66
+ return _wire_outputs(num_frames=False)
67
+
68
+
69
+ class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
70
+ """The denoising half of a split MiniMax-H3: `MiniMaxH3Blocks` with its `text_encoder` step removed."""
71
+
72
+ model_name = "minimax-h3"
73
+ block_classes = [
74
+ MiniMaxH3AutoResizeStep,
75
+ MiniMaxH3AutoKeyframeVaeEncoderStep,
76
+ MiniMaxH3CoreDenoiseStep,
77
+ MiniMaxH3AfterDenoiseStep,
78
+ MiniMaxH3DecodeStep,
79
+ ]
80
+ block_names = ["resize", "vae_encoder", "denoise", "after_denoise", "decode"]
81
+
82
+ @property
83
+ def description(self):
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
91
+ def outputs(self):
92
+ return _generation_outputs()
93
+
94
+
95
+ class MiniMaxH3Ref2VAConditionerBlocks(SequentialPipelineBlocks):
96
+ """The conditioner half of a split `ref2va`: the resolved plan plus the Qwen3-VL read at its 50th layer."""
97
+
98
+ model_name = "minimax-h3"
99
+ block_classes = [MiniMaxH3Ref2VASetupStep, MiniMaxH3Ref2VATextEncoderStep]
100
+ block_names = ["setup", "text_encoder"]
101
+
102
+ @property
103
+ def description(self):
104
+ return (
105
+ "The conditioner half of a split MiniMax-H3 `ref2va` deployment: resolves the request plan (canvas, frame "
106
+ "count, references normalized onto MiniMax-H3's own rates and resolutions) and encodes MiniMax-H3's "
107
+ "presentation of it into the `prompt_embeds` / `text_token_tags` pair the denoising half consumes."
108
+ )
109
+
110
+ @property
111
+ def outputs(self):
112
+ return _wire_outputs()
113
+
114
+
115
+ class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks):
116
+ """The denoising half of a split `ref2va`: the `ref2va` branch with its `text_encoder` step removed."""
117
+
118
+ model_name = "minimax-h3"
119
+ block_classes = [
120
+ MiniMaxH3Ref2VASetupStep,
121
+ MiniMaxH3Ref2VAReferenceEncoderStep,
122
+ MiniMaxH3Ref2VACoreDenoiseStep,
123
+ MiniMaxH3AfterDenoiseStep,
124
+ MiniMaxH3DecodeStep,
125
+ ]
126
+ block_names = ["setup", "reference_encoder", "denoise", "after_denoise", "decode"]
127
+
128
+ @property
129
+ def description(self):
130
+ return (
131
+ "The denoising half of a split MiniMax-H3 `ref2va` deployment: the `ref2va` branch of `MiniMaxH3Blocks` "
132
+ "without its text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the "
133
+ "62.14 GiB Qwen3-VL conditioner is never loaded here. The transformer is the `transformer_ref` partition."
134
+ )
135
+
136
+ @property
137
+ def outputs(self):
138
+ return _generation_outputs()
requirements.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # diffusers is installed from the canonical MiniMax-H3 pull request,
2
+ # https://github.com/huggingface/diffusers/pull/14371 ("Minimax h3 follow up (review & refactor)"), pinned to a
3
+ # **commit** rather than to its `minimax-h3-refactor` branch: the PR is a WIP and its head moves, and this Space's
4
+ # blocks subclass its block classes. Re-pin — and re-check `h3_split_blocks.py` against the block names of the new
5
+ # head — whenever the PR updates.
6
+ #
7
+ # 665f578278365ea4a3318cb8c9b66ce6c01204b9 = refs/pull/14371/head at the time of this deploy
8
+ --extra-index-url https://download.pytorch.org/whl/cu130
9
+ diffusers @ git+https://github.com/huggingface/diffusers.git@665f578278365ea4a3318cb8c9b66ce6c01204b9
10
+ torch==2.11.0
11
+ torchvision==0.26.0
12
+ # The Qwen3-VL processor decides the vision patch count, so a different minor changes the conditioning.
13
+ transformers==5.8.0
14
+ accelerate==1.14.0
15
+ # diffusers pins <2.
16
+ huggingface-hub==1.24.0
17
+ # No `kernels` pin on purpose: the Hub attention backends want `kernels>=0.12.3`, and that version breaks
18
+ # transformers 5.8.0 at import.
19
+ # PyAV muxes the generated soundtrack onto the frames (`encode_video`).
20
+ av
21
+ pillow
22
+ numpy
23
+ requests
24
+ safetensors>=0.8.0