Upload folder using huggingface_hub
Browse files- README.md +82 -0
- generate.py +480 -0
- minimax_h3_turbo_4step.safetensors +3 -0
- minimax_h3_turbo_4step_ema.safetensors +3 -0
- requirements.txt +10 -0
README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
base_model: Comfy-Org/MiniMax-H3
|
| 4 |
+
tags:
|
| 5 |
+
- text-to-video
|
| 6 |
+
- text-to-audio
|
| 7 |
+
- audio-video
|
| 8 |
+
- lora
|
| 9 |
+
- minimax-h3
|
| 10 |
+
pipeline_tag: text-to-video
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# MiniMax-H3 Turbo LoRA — 4-step audio-video generation (early preview)
|
| 14 |
+
|
| 15 |
+
A LoRA for [MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3) that renders
|
| 16 |
+
joint **video + synchronized stereo audio** in **4 sampling steps** instead of
|
| 17 |
+
the usual ~20 — roughly a 5× speedup in sampling wall-clock.
|
| 18 |
+
|
| 19 |
+
> ⚠️ **This is a demo / preview checkpoint, not a finished model.** It is an
|
| 20 |
+
> early snapshot from an in-progress run — under-trained, the time-averaged
|
| 21 |
+
> (EMA) variant hasn't matured, and overall quality is **nowhere near what a
|
| 22 |
+
> full run will produce**. What it *does* already show is a clear improvement
|
| 23 |
+
> over the base model at 4 steps: sharper detail and cleaner, better-synced
|
| 24 |
+
> audio than the base gives you in the same 4 steps. Treat it as a taste of the
|
| 25 |
+
> direction, not the destination.
|
| 26 |
+
|
| 27 |
+
Two weight files are included:
|
| 28 |
+
|
| 29 |
+
| file | notes |
|
| 30 |
+
|---|---|
|
| 31 |
+
| `minimax_h3_turbo_4step.safetensors` | trained weights — crisper, holds up better on fast motion |
|
| 32 |
+
| `minimax_h3_turbo_4step_ema.safetensors` | time-averaged weights — smoother, but **immature at this checkpoint** (EMA hasn't warmed up), so it can look soft |
|
| 33 |
+
|
| 34 |
+
Both are bf16, ~744 MB, apply as a standard low-rank update
|
| 35 |
+
(`W_eff = W + lora_B @ lora_A`, alpha = rank so no extra scaling).
|
| 36 |
+
|
| 37 |
+
## Quick start
|
| 38 |
+
|
| 39 |
+
The base model, VAEs and text encoder come from the official MiniMax-H3 release,
|
| 40 |
+
and the model definitions live in ComfyUI, so you need a ComfyUI checkout:
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
# 1. ComfyUI (pinned to the commit these weights were validated against)
|
| 44 |
+
git clone https://github.com/comfyanonymous/ComfyUI
|
| 45 |
+
cd ComfyUI && git checkout 14b05228cef127ce529bc0c08660770d4af3e9a8
|
| 46 |
+
pip install -r requirements.txt && cd ..
|
| 47 |
+
|
| 48 |
+
# 2. this release
|
| 49 |
+
pip install -r requirements.txt # torch, safetensors, imageio-ffmpeg, ...
|
| 50 |
+
|
| 51 |
+
# 3. base weights (from Comfy-Org/MiniMax-H3): the bf16 DiT, the int8 Qwen3-VL
|
| 52 |
+
# text encoder, and the video + audio VAEs, into a models/ tree.
|
| 53 |
+
|
| 54 |
+
# 4. generate
|
| 55 |
+
python generate.py \
|
| 56 |
+
--comfyui ./ComfyUI \
|
| 57 |
+
--base models/diffusion_models/minimax_h3_fl2va_bf16.safetensors \
|
| 58 |
+
--lora minimax_h3_turbo_4step.safetensors \
|
| 59 |
+
--te models/text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors \
|
| 60 |
+
--video-vae models/vae/minimax_h3_video_vae_fp16.safetensors \
|
| 61 |
+
--audio-vae models/vae/minimax_h3_audio_vae_fp32.safetensors \
|
| 62 |
+
--prompt "A corgi in a chef hat flipping a pancake, sizzling sounds and a cheerful bark." \
|
| 63 |
+
--width 1344 --height 768 --frames 124 --out corgi.mp4
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
`generate.py` is a single self-contained file: it loads the base DiT and merges
|
| 67 |
+
the LoRA, encodes the prompt with Qwen3-VL, runs 4-step sampling on the model's
|
| 68 |
+
native dual video/audio schedule, decodes both streams and muxes an mp4.
|
| 69 |
+
|
| 70 |
+
## Notes
|
| 71 |
+
|
| 72 |
+
- **Resolution / duration**: width and height are multiples of 16 (the canvas is
|
| 73 |
+
32-based); the short edge is typically 768. Frame count is at 24 fps and snaps
|
| 74 |
+
up to the model's 17·k+5 grid (124 ≈ 5 s). The validated range is ~124–362
|
| 75 |
+
frames (~5–15 s).
|
| 76 |
+
- **VRAM**: the base model is large (~33 B). `--offload-adaln` keeps the biggest
|
| 77 |
+
timestep-conditioned weights in CPU fp32, saving ~13 GB; an 80 GB GPU runs
|
| 78 |
+
comfortably, smaller cards can try with offload on.
|
| 79 |
+
- **Steps**: 4 is the design point. `--steps 8` is a little cleaner if you have
|
| 80 |
+
the budget; the schedule is the same either way.
|
| 81 |
+
- **Audio**: the model emits 32 kHz stereo aligned to the video; the two streams
|
| 82 |
+
ride different flow schedules and `generate.py` integrates each on its own.
|
generate.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniMax-H3 Turbo LoRA — 4-step text-to-audio-video generation.
|
| 2 |
+
|
| 3 |
+
A lightweight LoRA that lets MiniMax-H3 render joint video + stereo audio in
|
| 4 |
+
**4 sampling steps** instead of the usual ~20, at a fraction of the wall-clock
|
| 5 |
+
cost. This single file is a self-contained generator: it loads the base H3 DiT
|
| 6 |
+
plus this LoRA, encodes the prompt with the Qwen3-VL text encoder, runs the
|
| 7 |
+
model's native dual-schedule sampler for 4 steps, decodes both streams and muxes
|
| 8 |
+
a playable mp4.
|
| 9 |
+
|
| 10 |
+
The audio stream runs on its own shifted flow schedule (video shift 12, audio
|
| 11 |
+
shift 3); each stream is integrated on its own clock, which is the schedule
|
| 12 |
+
semantics MiniMax-H3 was designed around. That is the only non-obvious part of
|
| 13 |
+
sampling — everything else is a plain Euler flow sampler.
|
| 14 |
+
|
| 15 |
+
Dependencies (see requirements.txt), plus a ComfyUI checkout for the H3 model /
|
| 16 |
+
VAE / text-encoder module definitions:
|
| 17 |
+
|
| 18 |
+
git clone https://github.com/comfyanonymous/ComfyUI
|
| 19 |
+
cd ComfyUI && git checkout 14b05228cef127ce529bc0c08660770d4af3e9a8
|
| 20 |
+
|
| 21 |
+
Base weights come from the official MiniMax-H3 release
|
| 22 |
+
(Comfy-Org/MiniMax-H3 on the Hugging Face Hub): the bf16 DiT, the int8 Qwen3-VL
|
| 23 |
+
text encoder, and the video + audio VAEs.
|
| 24 |
+
|
| 25 |
+
Usage:
|
| 26 |
+
python generate.py \
|
| 27 |
+
--comfyui /path/to/ComfyUI \
|
| 28 |
+
--base models/diffusion_models/minimax_h3_fl2va_bf16.safetensors \
|
| 29 |
+
--lora minimax_h3_turbo_4step.safetensors \
|
| 30 |
+
--te models/text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors \
|
| 31 |
+
--video-vae models/vae/minimax_h3_video_vae_fp16.safetensors \
|
| 32 |
+
--audio-vae models/vae/minimax_h3_audio_vae_fp32.safetensors \
|
| 33 |
+
--prompt "A corgi in a tiny chef hat flipping a pancake, sizzling sounds." \
|
| 34 |
+
--width 1344 --height 768 --frames 124 --out corgi.mp4
|
| 35 |
+
|
| 36 |
+
`minimax_h3_turbo_4step.safetensors` is the trained LoRA; the accompanying
|
| 37 |
+
`minimax_h3_turbo_4step_ema.safetensors` is a time-averaged variant — try both,
|
| 38 |
+
the trained one tends to be crisper on fast motion, the averaged one smoother.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
import argparse
|
| 42 |
+
import math
|
| 43 |
+
import os
|
| 44 |
+
import subprocess
|
| 45 |
+
import sys
|
| 46 |
+
import time
|
| 47 |
+
import wave
|
| 48 |
+
|
| 49 |
+
import torch
|
| 50 |
+
import torch.nn.functional as F
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def log(msg):
|
| 54 |
+
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ======================================================================
|
| 58 |
+
# Flow schedule (video shift 12 / audio shift 3, closed-form dual mapping)
|
| 59 |
+
# ======================================================================
|
| 60 |
+
SHIFT_VIDEO = 12.0
|
| 61 |
+
SHIFT_AUDIO = 3.0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def shift_sigma(u, shift):
|
| 65 |
+
return shift * u / (1.0 + (shift - 1.0) * u)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def time_shift_sigma(sigma, from_shift, to_shift):
|
| 69 |
+
base = sigma / (from_shift + sigma * (1.0 - from_shift))
|
| 70 |
+
return to_shift * base / (1.0 + (to_shift - 1.0) * base)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def time_shift_slope(sigma, from_shift, to_shift):
|
| 74 |
+
base = sigma / (from_shift + sigma * (1.0 - from_shift))
|
| 75 |
+
return (to_shift * (1.0 + (from_shift - 1.0) * base) ** 2) / (
|
| 76 |
+
from_shift * (1.0 + (to_shift - 1.0) * base) ** 2)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def timesteps(n, shift=SHIFT_VIDEO):
|
| 80 |
+
"""n-step video sigma grid: ts[0]=1 (pure noise) > ... > ts[n]=0."""
|
| 81 |
+
return [shift_sigma(1.0 - i / n, shift) for i in range(n + 1)]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def audio_sigma(sigma_v):
|
| 85 |
+
return time_shift_sigma(sigma_v, SHIFT_VIDEO, SHIFT_AUDIO)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def audio_slope(sigma_v):
|
| 89 |
+
return time_shift_slope(sigma_v, SHIFT_VIDEO, SHIFT_AUDIO)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@torch.no_grad()
|
| 93 |
+
def sample(vfn, xv, xa, ts):
|
| 94 |
+
"""4-step Euler on the joint flow. The model returns the audio velocity
|
| 95 |
+
already scaled by d(sigma_a)/d(sigma_v), so video steps on its own sigma
|
| 96 |
+
delta while audio steps on its own schedule's delta (recovering the raw
|
| 97 |
+
audio velocity by dividing out the slope). This dual-clock stepping is the
|
| 98 |
+
schedule MiniMax-H3 expects; a single flat step on the video clock would
|
| 99 |
+
over/under-shoot the audio stream badly at 4 steps.
|
| 100 |
+
"""
|
| 101 |
+
for i in range(len(ts) - 1):
|
| 102 |
+
ov, oa = vfn(xv, xa, ts[i])
|
| 103 |
+
hv = ts[i + 1] - ts[i]
|
| 104 |
+
sl = audio_slope(max(ts[i], 1e-6))
|
| 105 |
+
ha = audio_sigma(ts[i + 1]) - audio_sigma(ts[i])
|
| 106 |
+
xv = xv + hv * ov
|
| 107 |
+
xa = xa + ha * (oa / sl)
|
| 108 |
+
return xv, xa
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ======================================================================
|
| 112 |
+
# Functional forward (out-of-place, mirrors the reference module math)
|
| 113 |
+
# ======================================================================
|
| 114 |
+
def _rms(x, weight, eps):
|
| 115 |
+
return F.rms_norm(x, (x.shape[-1],), weight, eps)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _attn(attn, x, rope_cos, rope_sin):
|
| 119 |
+
s = x.shape[0]
|
| 120 |
+
heads, hd = attn.heads, attn.head_dim
|
| 121 |
+
q, k, v = attn.qkv_proj(x).split(heads * hd, dim=-1)
|
| 122 |
+
q = _rms(q.view(s, heads, hd), attn.q_norm.weight, attn.q_norm.eps)
|
| 123 |
+
k = _rms(k.view(s, heads, hd), attn.k_norm.weight, attn.k_norm.eps)
|
| 124 |
+
v = v.view(s, heads, hd)
|
| 125 |
+
if rope_cos is not None:
|
| 126 |
+
c, si = rope_cos[:, None, :], rope_sin[:, None, :]
|
| 127 |
+
|
| 128 |
+
def rot(t):
|
| 129 |
+
t96 = t[..., :96].float()
|
| 130 |
+
x1, x2 = t96[..., :48], t96[..., 48:]
|
| 131 |
+
return torch.cat([(x1 * c - x2 * si).to(t.dtype),
|
| 132 |
+
(x1 * si + x2 * c).to(t.dtype),
|
| 133 |
+
t[..., 96:]], dim=-1)
|
| 134 |
+
|
| 135 |
+
q, k = rot(q), rot(k)
|
| 136 |
+
q, k, v = (t.transpose(0, 1).unsqueeze(0) for t in (q, k, v))
|
| 137 |
+
out = F.scaled_dot_product_attention(q, k, v)
|
| 138 |
+
return attn.out_proj(out.squeeze(0).transpose(0, 1).reshape(s, heads * hd))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _mlp(mlp, x):
|
| 142 |
+
x1, x2 = mlp.fc1(x).chunk(2, dim=-1)
|
| 143 |
+
return mlp.fc2(F.silu(x1) * x2)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _refiner(refiner, x):
|
| 147 |
+
for blk in refiner.blocks:
|
| 148 |
+
x = x + _attn(blk.attn, _rms(x, blk.norm1.weight, blk.norm1.eps),
|
| 149 |
+
None, None)
|
| 150 |
+
x = x + _mlp(blk.mlp, _rms(x, blk.norm2.weight, blk.norm2.eps))
|
| 151 |
+
return _rms(x, refiner.final_norm.weight, refiner.final_norm.eps)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _apply_mod(h, shift, scale, segments):
|
| 155 |
+
parts = []
|
| 156 |
+
for a, b, row in segments:
|
| 157 |
+
parts.append(h[a:b] * (1.0 + scale[row].to(h.dtype)) + shift[row].to(h.dtype))
|
| 158 |
+
return torch.cat(parts)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _apply_gate(x, gate, other, segments):
|
| 162 |
+
parts = []
|
| 163 |
+
for a, b, row in segments:
|
| 164 |
+
parts.append(x[a:b] + other[a:b] * gate[row].to(x.dtype))
|
| 165 |
+
return torch.cat(parts)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _block(blk, h, mods, segments, rope_cos, rope_sin):
|
| 169 |
+
sh_msa, sc_msa, g_msa, sh_mlp, sc_mlp, g_mlp = mods.unbind(dim=1)
|
| 170 |
+
hn = _apply_mod(_rms(h, blk.norm1.weight, blk.norm1.eps), sh_msa, sc_msa, segments)
|
| 171 |
+
h = _apply_gate(h, g_msa, _attn(blk.attn, hn, rope_cos, rope_sin), segments)
|
| 172 |
+
hn = _apply_mod(_rms(h, blk.norm2.weight, blk.norm2.eps), sh_mlp, sc_mlp, segments)
|
| 173 |
+
return _apply_gate(h, g_mlp, _mlp(blk.mlp, hn), segments)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ======================================================================
|
| 177 |
+
# Model load + LoRA merge
|
| 178 |
+
# ======================================================================
|
| 179 |
+
def load_model(comfyui, base_path, lora_path, device, offload_adaln):
|
| 180 |
+
import comfy.ldm.minimax.model as h3ref
|
| 181 |
+
import comfy.ops
|
| 182 |
+
import comfy.utils
|
| 183 |
+
from safetensors.torch import load_file
|
| 184 |
+
|
| 185 |
+
log(f"loading base DiT: {base_path}")
|
| 186 |
+
sd = comfy.utils.load_torch_file(base_path)
|
| 187 |
+
model = h3ref.MiniMaxH3Model(dtype=torch.bfloat16, device="cpu",
|
| 188 |
+
operations=comfy.ops.disable_weight_init)
|
| 189 |
+
missing, unexpected = model.load_state_dict(sd, strict=True, assign=True)
|
| 190 |
+
assert not missing and not unexpected, (missing[:3], unexpected[:3])
|
| 191 |
+
model.requires_grad_(False)
|
| 192 |
+
model.eval()
|
| 193 |
+
|
| 194 |
+
for i, blk in enumerate(model.blocks):
|
| 195 |
+
blk.to(device)
|
| 196 |
+
for mod in (model.token_refiner, model.final_layer, model.condition_proj,
|
| 197 |
+
model.video_patch_proj, model.audio_patch_proj,
|
| 198 |
+
model.time_embedder, model.rope):
|
| 199 |
+
mod.to(device)
|
| 200 |
+
|
| 201 |
+
log(f"merging LoRA: {lora_path}")
|
| 202 |
+
lora = load_file(lora_path)
|
| 203 |
+
mods = sorted({k.rsplit(".lora_", 1)[0] for k in lora})
|
| 204 |
+
for name in mods:
|
| 205 |
+
lin = model.get_submodule(name)
|
| 206 |
+
a = lora[name + ".lora_A.weight"].to(device, torch.float32)
|
| 207 |
+
b = lora[name + ".lora_B.weight"].to(device, torch.float32)
|
| 208 |
+
# every module is injected with alpha == rank, so scale is 1.0
|
| 209 |
+
delta = (b @ a).to(lin.weight.dtype)
|
| 210 |
+
lin.weight.data = lin.weight.data.to(device) + delta
|
| 211 |
+
log(f"merged {len(mods)} modules")
|
| 212 |
+
|
| 213 |
+
if offload_adaln:
|
| 214 |
+
# The per-layer adaLN projection is huge (2688 -> 96768) but depends only
|
| 215 |
+
# on the timestep, of which there are a handful per denoise. Keep it in
|
| 216 |
+
# CPU fp32 to save ~13 GB of VRAM; the matmul is cheap at 4 steps.
|
| 217 |
+
for blk in model.blocks:
|
| 218 |
+
lin = blk.adaln_proj.linear
|
| 219 |
+
lin.weight.data = lin.weight.data.float().cpu()
|
| 220 |
+
lin.bias.data = lin.bias.data.float().cpu()
|
| 221 |
+
return model, h3ref
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
VISUAL_COND_T = 0.999
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def timestep_rows(model, sigma_v):
|
| 228 |
+
sigma_v = float(max(sigma_v, 1e-6))
|
| 229 |
+
t_v = 1.0 - sigma_v
|
| 230 |
+
t_a = 1.0 - time_shift_sigma(sigma_v, model.sigma_shift_video,
|
| 231 |
+
model.sigma_shift_audio)
|
| 232 |
+
seg_t = {"text": t_v, "video": t_v, "audio": t_a}
|
| 233 |
+
unique_t = sorted({t_v, t_a})
|
| 234 |
+
return seg_t, unique_t, {t: i for i, t in enumerate(unique_t)}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def adaln_mods(model, unique_t, device, offload, cache):
|
| 238 |
+
key = tuple(round(t, 9) for t in unique_t)
|
| 239 |
+
if key in cache:
|
| 240 |
+
return cache[key]
|
| 241 |
+
ts = torch.tensor(unique_t, dtype=torch.float32, device=device)
|
| 242 |
+
with torch.no_grad():
|
| 243 |
+
temb = model.time_embedder(ts).float() # [M, 2688] GPU
|
| 244 |
+
si = F.silu(temb)
|
| 245 |
+
si = si.cpu() if offload else si.to(torch.bfloat16)
|
| 246 |
+
outs = torch.stack([F.linear(si, b.adaln_proj.linear.weight,
|
| 247 |
+
b.adaln_proj.linear.bias)
|
| 248 |
+
for b in model.blocks]) # [50, M, 96768]
|
| 249 |
+
M, H = len(unique_t), model.hidden_size
|
| 250 |
+
mods = outs.view(len(model.blocks), M, 3, 6, H).reshape(
|
| 251 |
+
len(model.blocks), M * 3, 6, H).to(device, torch.bfloat16)
|
| 252 |
+
temb_bf = temb.to(torch.bfloat16)
|
| 253 |
+
cache[key] = (mods, temb_bf)
|
| 254 |
+
return mods, temb_bf
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class Prepared:
|
| 258 |
+
"""Static packed-sequence structure for one (text_len, shape) signature."""
|
| 259 |
+
|
| 260 |
+
def __init__(self, model, h3ref, text_len, video_shape, audio_t, tags,
|
| 261 |
+
device):
|
| 262 |
+
_, _, lt, lh, lw = video_shape
|
| 263 |
+
self.video_shape = tuple(video_shape)
|
| 264 |
+
self.lat_pad = ((lh + 1) // 2 * 2, (lw + 1) // 2 * 2)
|
| 265 |
+
self.layout = h3ref.PackedLayout(text_len, lt, *self.lat_pad, audio_t)
|
| 266 |
+
pos = self.layout.position_ids.to(torch.float32).to(device)
|
| 267 |
+
inv = model.rope.inv_freq.to(device)
|
| 268 |
+
ang = (pos.unsqueeze(-1) * inv.view(1, 1, -1)).flatten(1)
|
| 269 |
+
self.rope_cos, self.rope_sin = torch.cos(ang), torch.sin(ang)
|
| 270 |
+
|
| 271 |
+
segs = []
|
| 272 |
+
for a, b, kind in self.layout.segments:
|
| 273 |
+
if kind == "text" and tags is not None:
|
| 274 |
+
tg = tags.view(-1).tolist()
|
| 275 |
+
run = 0
|
| 276 |
+
for i in range(1, b - a + 1):
|
| 277 |
+
if i == b - a or tg[i] != tg[run]:
|
| 278 |
+
segs.append((a + run, a + i, int(tg[run]), kind))
|
| 279 |
+
run = i
|
| 280 |
+
else:
|
| 281 |
+
tag = {"text": 1, "video": 0, "audio": 2}[kind]
|
| 282 |
+
segs.append((a, b, tag, kind))
|
| 283 |
+
self.seg_template = segs
|
| 284 |
+
(self.video_seg,) = [(a, b) for a, b, k in self.layout.segments if k == "video"]
|
| 285 |
+
(self.audio_seg,) = [(a, b) for a, b, k in self.layout.segments if k == "audio"]
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@torch.no_grad()
|
| 289 |
+
def forward(model, h3ref, prep, video_x, audio_x, sigma_v, context, device,
|
| 290 |
+
offload, cache):
|
| 291 |
+
"""One denoise evaluation in the sigma_v domain. Returns
|
| 292 |
+
(video_velocity, audio_velocity * slope), matching what the sampler wants."""
|
| 293 |
+
import comfy.ldm.common_dit
|
| 294 |
+
video_x = comfy.ldm.common_dit.pad_to_patch_size(video_x, model.patch_size)
|
| 295 |
+
orig_t, orig_h, orig_w = prep.video_shape[2:]
|
| 296 |
+
|
| 297 |
+
sigma_v = float(max(sigma_v, 1e-6))
|
| 298 |
+
seg_t, unique_t, t_row = timestep_rows(model, sigma_v)
|
| 299 |
+
segments = [(a, b, t_row[seg_t[k]] * 3 + tag)
|
| 300 |
+
for a, b, tag, k in prep.seg_template]
|
| 301 |
+
|
| 302 |
+
base_mods, t_emb = adaln_mods(model, unique_t, device, offload, cache)
|
| 303 |
+
silu_temb = F.silu(t_emb)
|
| 304 |
+
|
| 305 |
+
video_rows = h3ref.patchify_video(video_x.to(torch.float32), model.patch_size)
|
| 306 |
+
audio_rows = h3ref.pack_audio(audio_x.to(torch.float32))
|
| 307 |
+
video_embed = model.video_patch_proj(video_rows).to(torch.bfloat16)
|
| 308 |
+
audio_embed = model.audio_patch_proj(audio_rows).to(torch.bfloat16)
|
| 309 |
+
|
| 310 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 311 |
+
text_states = context[0]
|
| 312 |
+
if text_states.shape[-1] != model.hidden_size:
|
| 313 |
+
text_states = _refiner(model.token_refiner,
|
| 314 |
+
model.condition_proj(text_states))
|
| 315 |
+
pieces = []
|
| 316 |
+
for a, b, kind in prep.layout.segments:
|
| 317 |
+
if kind == "text":
|
| 318 |
+
pieces.append(text_states)
|
| 319 |
+
elif kind == "video":
|
| 320 |
+
pieces.append(video_embed)
|
| 321 |
+
else:
|
| 322 |
+
pieces.append(audio_embed)
|
| 323 |
+
h = torch.cat(pieces)
|
| 324 |
+
for i, blk in enumerate(model.blocks):
|
| 325 |
+
h = _block(blk, h, base_mods[i], segments,
|
| 326 |
+
prep.rope_cos, prep.rope_sin)
|
| 327 |
+
|
| 328 |
+
fl = model.final_layer
|
| 329 |
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 330 |
+
f_mod = fl.adaln_proj.linear(F.silu(t_emb))
|
| 331 |
+
f_shift, f_scale = f_mod.view(len(unique_t), 2, model.hidden_size).unbind(1)
|
| 332 |
+
(va, vb), (aa, ab) = prep.video_seg, prep.audio_seg
|
| 333 |
+
vrow, arow = t_row[seg_t["video"]], t_row[seg_t["audio"]]
|
| 334 |
+
hn = _rms(h, fl.norm.weight, fl.norm.eps)
|
| 335 |
+
hv = (hn[va:vb] * (1.0 + f_scale[vrow]) + f_shift[vrow]).to(torch.float32)
|
| 336 |
+
ha = (hn[aa:ab] * (1.0 + f_scale[arow]) + f_shift[arow]).to(torch.float32)
|
| 337 |
+
v_rows, a_rows = fl.video_out(hv), fl.audio_out(ha)
|
| 338 |
+
|
| 339 |
+
lt = video_x.shape[2]
|
| 340 |
+
video_out = h3ref.unpatchify_video(v_rows, lt, prep.lat_pad[0] // 2,
|
| 341 |
+
prep.lat_pad[1] // 2, model.latents_dim,
|
| 342 |
+
model.patch_size)[:, :, :orig_t, :orig_h, :orig_w]
|
| 343 |
+
audio_out = h3ref.unpack_audio(a_rows)
|
| 344 |
+
slope_a = time_shift_slope(sigma_v, model.sigma_shift_video,
|
| 345 |
+
model.sigma_shift_audio)
|
| 346 |
+
return -video_out.to(video_x.dtype), (-slope_a) * audio_out.to(audio_x.dtype)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ======================================================================
|
| 350 |
+
# Text encode / decode / mux
|
| 351 |
+
# ======================================================================
|
| 352 |
+
def encode_prompt(comfyui, te_path, prompt, device):
|
| 353 |
+
import comfy.model_management
|
| 354 |
+
import comfy.sd
|
| 355 |
+
log(f"loading text encoder: {te_path}")
|
| 356 |
+
clip = comfy.sd.load_clip([te_path], clip_type=comfy.sd.CLIPType.MINIMAX)
|
| 357 |
+
cond = clip.encode_from_tokens_scheduled(clip.tokenize(prompt))
|
| 358 |
+
ca, ex = cond[0][0], cond[0][1]
|
| 359 |
+
tags = ex.get("minimax_token_tags")
|
| 360 |
+
ctx = ca.to(device, torch.bfloat16)
|
| 361 |
+
tags = tags.to(device) if torch.is_tensor(tags) else tags
|
| 362 |
+
del clip
|
| 363 |
+
comfy.model_management.unload_all_models()
|
| 364 |
+
comfy.model_management.soft_empty_cache()
|
| 365 |
+
return ctx, tags
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _write_wav(path, waveform, sr):
|
| 369 |
+
w = waveform.detach().cpu().float()
|
| 370 |
+
if w.ndim == 3:
|
| 371 |
+
w = w[0]
|
| 372 |
+
w = w.clamp(-1.0, 1.0)
|
| 373 |
+
ch = w.shape[0]
|
| 374 |
+
pcm = (w.transpose(0, 1).contiguous().numpy() * 32767.0).astype("<i2")
|
| 375 |
+
with wave.open(path, "wb") as f:
|
| 376 |
+
f.setnchannels(ch)
|
| 377 |
+
f.setsampwidth(2)
|
| 378 |
+
f.setframerate(int(sr))
|
| 379 |
+
f.writeframes(pcm.tobytes())
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def save_mp4(images, waveform, sr, fps, out_path):
|
| 383 |
+
import imageio.v2 as imageio
|
| 384 |
+
import imageio_ffmpeg
|
| 385 |
+
frames = images.detach().cpu().float().clamp(0, 1).mul(255).round().to(
|
| 386 |
+
torch.uint8).numpy()
|
| 387 |
+
tv, ta = out_path + ".v.mp4", out_path + ".a.wav"
|
| 388 |
+
writer = imageio.get_writer(tv, fps=fps, codec="libx264", quality=8,
|
| 389 |
+
pixelformat="yuv420p", macro_block_size=1,
|
| 390 |
+
ffmpeg_log_level="error")
|
| 391 |
+
for fr in frames:
|
| 392 |
+
writer.append_data(fr)
|
| 393 |
+
writer.close()
|
| 394 |
+
_write_wav(ta, waveform, sr)
|
| 395 |
+
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
|
| 396 |
+
subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-i", tv, "-i", ta,
|
| 397 |
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest",
|
| 398 |
+
out_path], check=True)
|
| 399 |
+
os.remove(tv)
|
| 400 |
+
os.remove(ta)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# ======================================================================
|
| 404 |
+
# Main
|
| 405 |
+
# ======================================================================
|
| 406 |
+
def main():
|
| 407 |
+
ap = argparse.ArgumentParser(description="MiniMax-H3 Turbo LoRA 4-step generator")
|
| 408 |
+
ap.add_argument("--comfyui", required=True, help="path to a ComfyUI checkout @14b05228")
|
| 409 |
+
ap.add_argument("--base", required=True, help="H3 bf16 DiT safetensors")
|
| 410 |
+
ap.add_argument("--lora", required=True, help="turbo LoRA safetensors")
|
| 411 |
+
ap.add_argument("--te", required=True, help="Qwen3-VL text encoder safetensors")
|
| 412 |
+
ap.add_argument("--video-vae", required=True)
|
| 413 |
+
ap.add_argument("--audio-vae", required=True)
|
| 414 |
+
ap.add_argument("--prompt", required=True)
|
| 415 |
+
ap.add_argument("--out", default="out.mp4")
|
| 416 |
+
ap.add_argument("--width", type=int, default=1344, help="multiple of 16 (canvas is 32-based)")
|
| 417 |
+
ap.add_argument("--height", type=int, default=768)
|
| 418 |
+
ap.add_argument("--frames", type=int, default=124, help="24 fps; snaps to the 17k+5 grid")
|
| 419 |
+
ap.add_argument("--steps", type=int, default=4)
|
| 420 |
+
ap.add_argument("--seed", type=int, default=42)
|
| 421 |
+
ap.add_argument("--offload-adaln", action="store_true",
|
| 422 |
+
help="keep the timestep-projection weights in CPU fp32 (saves ~13GB VRAM)")
|
| 423 |
+
args = ap.parse_args()
|
| 424 |
+
sys.path.insert(0, args.comfyui) # ComfyUI supplies the H3 module definitions
|
| 425 |
+
|
| 426 |
+
dev = "cuda"
|
| 427 |
+
frames = args.frames
|
| 428 |
+
while frames % 17 != 5:
|
| 429 |
+
frames += 1
|
| 430 |
+
lt = (frames - 5) // 17 * 5 + 2
|
| 431 |
+
lh, lw = args.height // 16, args.width // 16
|
| 432 |
+
audio_t = round(frames / 24 * 40)
|
| 433 |
+
v_shape, a_shape = (1, 24, lt, lh, lw), (1, 32, 2, audio_t)
|
| 434 |
+
ts = timesteps(args.steps)
|
| 435 |
+
log(f"{args.width}x{args.height}x{frames}f ({frames/24:.1f}s) -> "
|
| 436 |
+
f"video{v_shape} audio{a_shape}; {args.steps}-step grid "
|
| 437 |
+
f"{['%.3f' % t for t in ts]}")
|
| 438 |
+
|
| 439 |
+
ctx, tags = encode_prompt(args.comfyui, args.te, args.prompt, dev)
|
| 440 |
+
model, h3ref = load_model(args.comfyui, args.base, args.lora, dev,
|
| 441 |
+
args.offload_adaln)
|
| 442 |
+
prep = Prepared(model, h3ref, ctx.shape[1], v_shape, audio_t, tags, dev)
|
| 443 |
+
|
| 444 |
+
g = torch.Generator(dev).manual_seed(args.seed)
|
| 445 |
+
ga = torch.Generator(dev).manual_seed(args.seed + 1)
|
| 446 |
+
nv = torch.randn(v_shape, generator=g, device=dev, dtype=torch.bfloat16)
|
| 447 |
+
na = torch.randn(a_shape, generator=ga, device=dev, dtype=torch.bfloat16)
|
| 448 |
+
|
| 449 |
+
cache = {}
|
| 450 |
+
|
| 451 |
+
def vfn(xv, xa, sv):
|
| 452 |
+
return forward(model, h3ref, prep, xv, xa, sv, ctx, dev,
|
| 453 |
+
args.offload_adaln, cache)
|
| 454 |
+
|
| 455 |
+
log("sampling ...")
|
| 456 |
+
t0 = time.time()
|
| 457 |
+
with torch.inference_mode():
|
| 458 |
+
zv, za = sample(vfn, nv, na, ts)
|
| 459 |
+
log(f"sampled in {time.time()-t0:.1f}s")
|
| 460 |
+
|
| 461 |
+
import comfy.sd
|
| 462 |
+
import comfy.utils
|
| 463 |
+
video_vae = comfy.sd.VAE(sd=comfy.utils.load_torch_file(args.video_vae))
|
| 464 |
+
audio_vae = comfy.sd.VAE(sd=comfy.utils.load_torch_file(args.audio_vae))
|
| 465 |
+
with torch.inference_mode():
|
| 466 |
+
images = video_vae.decode(zv.float())
|
| 467 |
+
if images.ndim == 5:
|
| 468 |
+
images = images.reshape(-1, *images.shape[-3:])
|
| 469 |
+
waveform = audio_vae.decode(za.float()).movedim(-1, 1)
|
| 470 |
+
std = torch.std(waveform, dim=[1, 2], keepdim=True) * 5.0
|
| 471 |
+
std[std < 1.0] = 1.0
|
| 472 |
+
waveform = waveform / std
|
| 473 |
+
sr = getattr(audio_vae, "audio_sample_rate_output",
|
| 474 |
+
getattr(audio_vae, "audio_sample_rate", 44100))
|
| 475 |
+
save_mp4(images, waveform, sr, 24, args.out)
|
| 476 |
+
log(f"done -> {args.out} ({os.path.getsize(args.out)/2**20:.1f}MB)")
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
if __name__ == "__main__":
|
| 480 |
+
main()
|
minimax_h3_turbo_4step.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c468c61ebf715699b5a710fed016654e4a244195ce2e1c4f9f84dc63f82da905
|
| 3 |
+
size 779849872
|
minimax_h3_turbo_4step_ema.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8d645b67e606874e9179b277cea721c1f1e75830532fcc2206e23353cb33edc5
|
| 3 |
+
size 779849872
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MiniMax-H3 Turbo LoRA 4-step generator.
|
| 2 |
+
# The base model / VAE / text-encoder module definitions come from a ComfyUI
|
| 3 |
+
# checkout (see generate.py header): git clone + checkout 14b05228, then install
|
| 4 |
+
# ComfyUI's own requirements. The packages below are what generate.py itself
|
| 5 |
+
# needs on top of that.
|
| 6 |
+
torch>=2.4
|
| 7 |
+
safetensors>=0.4
|
| 8 |
+
imageio>=2.34
|
| 9 |
+
imageio-ffmpeg>=0.5
|
| 10 |
+
numpy>=1.24
|