| """MiniMax-H3 Talking Avatar — minimal single-purpose Space. |
| |
| Only two inputs: an image (the character) and an audio file (the voice). One "Generate" button produces a |
| lip-synced talking-avatar video. Everything else (multi-image references, video references, canvas choice, |
| duration, steps, seed, prompt upsampling) is fixed to sane defaults internally — there is nothing else to |
| configure in the UI on purpose. |
| |
| This keeps the split-deployment architecture of the original multimodalart/minimax-h3-reference Space: the |
| 33B model is ~196 GiB in bf16, far past what a single ZeroGPU worker can hold, so text encoding (the 62 GiB |
| Qwen3-VL half) runs on a separate Space (`multimodalart/qwen3vl-conditioner`) over the Gradio API, and only |
| the denoising half (`transformer_ref` + the two autoencoders) loads here. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import random |
| import tempfile |
| import time |
| import traceback |
|
|
| import spaces |
| import gradio as gr |
|
|
| MODEL_REPO = os.environ.get("H3_MODEL_REPO", "MiniMaxAI/MiniMax-H3") |
| CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner") |
| ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn") |
| GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge") |
| MIN_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MIN", "120")) |
| MAX_GPU_DURATION = int(os.environ.get("H3_GPU_DURATION_MAX", "1500")) |
| PLACEMENT_ALLOWANCE = int(os.environ.get("H3_PLACEMENT_ALLOWANCE", "90")) |
|
|
| |
| |
| CANVASES = { |
| "960x544 · 16:9 fast": (544, 960), |
| "1024x576 · 16:9 fast": (576, 1024), |
| "1152x640 · 16:9": (640, 1152), |
| "1280x704 · 16:9": (704, 1280), |
| "1344x768 · 16:9 full": (768, 1344), |
| "544x960 · 9:16 fast": (960, 544), |
| "640x1152 · 9:16": (1152, 640), |
| "768x1344 · 9:16 full": (1344, 768), |
| "544x544 · 1:1 fast": (544, 544), |
| "768x768 · 1:1 full": (768, 768), |
| "768x576 · 4:3 fast": (576, 768), |
| "1024x768 · 4:3 full": (768, 1024), |
| "576x768 · 3:4 fast": (768, 576), |
| "768x1024 · 3:4 full": (1024, 768), |
| "1152x512 · 21:9 fast": (512, 1152), |
| "1536x672 · 21:9 full": (672, 1536), |
| } |
| |
| _FULL_TIER = {label for label in CANVASES if "full" in label} |
|
|
|
|
| def pick_canvas(image_path: str) -> str: |
| """The canvas whose aspect ratio is closest to the uploaded image's own, so the framing isn't cropped/zoomed.""" |
| from PIL import Image |
|
|
| width, height = Image.open(image_path).size |
| image_ratio = width / height |
|
|
| def score(label): |
| canvas_height, canvas_width = CANVASES[label] |
| ratio_diff = abs((canvas_width / canvas_height) - image_ratio) |
| return (ratio_diff, 0 if label in _FULL_TIER else 1) |
|
|
| return min(CANVASES, key=score) |
| FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5 |
| AUDIO_LATENTS_PER_SECOND, AUDIO_CHANNELS = 40, 2 |
| CANVAS_MULTIPLE = 32 |
| STEPS = 28 |
| DEFAULT_PROMPT = ( |
| "The character speaks to camera in a quiet room, lips matching every word. " |
| "Static camera, no zoom, no pan, no dolly movement. Keep the exact same framing, " |
| "composition and distance from the subject as the reference image throughout the entire video." |
| ) |
| STEP_LINEAR, STEP_QUADRATIC, SAFETY = 1.1745e-4, 3.8396e-9, 1.3 |
| DECODE_BASE, DECODE_PER_DEFAULT_CANVAS, DEFAULT_CANVAS_PIXELS = 15, 25, 960 * 544 * 124 |
| REFERENCE_IMAGE_SHORT_EDGE = 2048 |
|
|
|
|
| def snap_frames(seconds: float) -> int: |
| frames = max(1, round(float(seconds) * FPS)) |
| while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK: |
| frames += 1 |
| return frames |
|
|
|
|
| def lower_duration_floor(seconds: float = 2.0) -> None: |
| from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline |
|
|
| MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds)) |
|
|
|
|
| def video_latent_frames(num_frames: int) -> int: |
| return 5 * ((num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK) + 2 |
|
|
|
|
| def target_rows(height: int, width: int, num_frames: int) -> int: |
| video = video_latent_frames(num_frames) * (height // CANVAS_MULTIPLE) * (width // CANVAS_MULTIPLE) |
| return video + round(num_frames / FPS * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS |
|
|
|
|
| def reference_rows(image_path: str, audio_seconds: float | None, num_frames: int) -> int: |
| from PIL import Image |
|
|
| width, height = Image.open(image_path).size |
| scale = REFERENCE_IMAGE_SHORT_EDGE / min(width, height) |
| resolved = [max(CANVAS_MULTIPLE, round(edge * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE) for edge in (height, width)] |
| rows = (resolved[0] // CANVAS_MULTIPLE) * (resolved[1] // CANVAS_MULTIPLE) |
| if audio_seconds is not None: |
| rows += round(min(audio_seconds, num_frames / FPS) * AUDIO_LATENTS_PER_SECOND) * AUDIO_CHANNELS |
| return rows |
|
|
|
|
| def get_duration(prompt_embeds, text_token_tags, image_path, audio_seconds, audio_path, height, width, num_frames, seed, **_): |
| sequence = int(text_token_tags.shape[0]) + reference_rows(image_path, audio_seconds, num_frames) + target_rows( |
| height, width, num_frames |
| ) |
| denoise = STEPS * (STEP_LINEAR * sequence + STEP_QUADRATIC * sequence**2) * SAFETY |
| encode = 5 + reference_rows(image_path, audio_seconds, num_frames) * 1e-3 |
| decode = DECODE_BASE + DECODE_PER_DEFAULT_CANVAS * (height * width * num_frames) / DEFAULT_CANVAS_PIXELS |
| total = PLACEMENT_ALLOWANCE + encode + denoise + decode + 10 |
| return max(MIN_GPU_DURATION, min(MAX_GPU_DURATION, int(total))) |
|
|
|
|
| PIPE = None |
| MANAGER = None |
| LOAD_ERROR: str | None = None |
|
|
|
|
| def load_models() -> str | None: |
| """Load the denoising half (transformer_ref + both autoencoders) at startup, off the GPU.""" |
| global PIPE, MANAGER, LOAD_ERROR |
| if PIPE is not None or LOAD_ERROR is not None: |
| return LOAD_ERROR |
|
|
| started = time.time() |
| try: |
| import torch |
| from diffusers import ComponentsManager |
| from diffusers.modular_pipelines.minimax_h3.before_encoder import MiniMaxH3Ref2VASetupStep |
| from diffusers.modular_pipelines.minimax_h3.decoders import MiniMaxH3AfterDenoiseStep |
| from diffusers.modular_pipelines.minimax_h3.encoders import MiniMaxH3Ref2VAReferenceEncoderStep |
| from diffusers.modular_pipelines.minimax_h3.modular_blocks_minimax_h3 import ( |
| MiniMaxH3DecodeStep, |
| MiniMaxH3Ref2VACoreDenoiseStep, |
| _generation_outputs, |
| ) |
| from diffusers.modular_pipelines.modular_pipeline import SequentialPipelineBlocks |
|
|
| class MiniMaxH3Ref2VAGeneratorBlocks(SequentialPipelineBlocks): |
| """Denoising half of split ref2va: no text_encoder step, prompt_embeds/text_token_tags come in as inputs.""" |
|
|
| model_name = "minimax-h3" |
| block_classes = [ |
| MiniMaxH3Ref2VASetupStep, |
| MiniMaxH3Ref2VAReferenceEncoderStep, |
| MiniMaxH3Ref2VACoreDenoiseStep, |
| MiniMaxH3AfterDenoiseStep, |
| MiniMaxH3DecodeStep, |
| ] |
| block_names = ["setup", "reference_encoder", "denoise", "after_denoise", "decode"] |
|
|
| @property |
| def outputs(self): |
| return _generation_outputs() |
|
|
| lower_duration_floor() |
| manager = ComponentsManager() |
| blocks = MiniMaxH3Ref2VAGeneratorBlocks() |
| pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3") |
| pipe.load_components(dtype=torch.bfloat16) |
|
|
| pipe.vae.set_attention_backend("native") |
| pipe.audio_vae.set_attention_backend("native") |
| pipe.transformer_ref.set_attention_backend(ATTENTION) |
|
|
| PIPE, MANAGER = pipe, manager |
| print(f"[avatar] ready in {time.time() - started:.0f}s", flush=True) |
| except Exception as error: |
| traceback.print_exc() |
| LOAD_ERROR = f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: `{type(error).__name__}: {error}`" |
| return LOAD_ERROR |
|
|
|
|
| def probe(path: str) -> tuple[float | None, float | None]: |
| import av |
|
|
| def seconds(stream, container): |
| if stream.duration is not None and stream.time_base is not None: |
| return float(stream.duration * stream.time_base) |
| return None if container.duration is None else container.duration / av.time_base |
|
|
| with av.open(path) as container: |
| video = seconds(container.streams.video[0], container) if container.streams.video else None |
| audio = seconds(container.streams.audio[0], container) if container.streams.audio else None |
| return video, audio |
|
|
|
|
| def build_references(image_path: str, audio_path: str): |
| from diffusers.modular_pipelines.minimax_h3 import MiniMaxH3AudioReference, MiniMaxH3ImageReference |
|
|
| return [MiniMaxH3ImageReference.from_file(image_path), MiniMaxH3AudioReference.from_file(audio_path)] |
|
|
|
|
| def encode_remote(prompt, image_path, audio_path, canvas, num_frames): |
| from gradio_client import Client, handle_file |
| from safetensors import safe_open |
|
|
| client = Client(CONDITIONER_SPACE) |
| path, plan = client.predict( |
| prompt=prompt, |
| media=[handle_file(image_path), handle_file(audio_path)], |
| kinds="image,audio", |
| canvas=canvas, |
| num_frames=num_frames, |
| rewrite_prompt=False, |
| api_name="/encode_ref2va", |
| ) |
| with safe_open(path, framework="pt") as handle: |
| return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata() |
|
|
|
|
| @spaces.GPU(duration=get_duration, size=GPU_SIZE) |
| def _generate(prompt_embeds, text_token_tags, image_path, audio_seconds, audio_path, height, width, num_frames, seed): |
| import torch |
|
|
| PIPE.to("cuda") |
| state = PIPE( |
| prompt_embeds=prompt_embeds.to("cuda"), |
| text_token_tags=text_token_tags, |
| references=build_references(image_path, audio_path), |
| height=height, |
| width=width, |
| num_frames=num_frames, |
| num_inference_steps=STEPS, |
| generator=torch.Generator("cpu").manual_seed(int(seed)), |
| ) |
| return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate") |
|
|
|
|
| def generate(image_path, audio_path, progress=gr.Progress(track_tqdm=True)): |
| if LOAD_ERROR: |
| raise gr.Error(LOAD_ERROR) |
| if PIPE is None: |
| raise gr.Error("The model is still loading, please try again in a moment.") |
| if not image_path: |
| raise gr.Error("Upload a portrait image.") |
| if not audio_path: |
| raise gr.Error("Upload an audio clip (the voice).") |
|
|
| from diffusers.utils import encode_video |
|
|
| _, audio_seconds = probe(audio_path) |
| if audio_seconds is None: |
| raise gr.Error("That file has no audio track.") |
|
|
| |
| progress(0.0, desc="Reading the image and audio ...") |
| canvas = pick_canvas(image_path) |
| prompt_embeds, text_token_tags, metadata = encode_remote(DEFAULT_PROMPT, image_path, audio_path, canvas, 0) |
| height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames")) |
|
|
| seed = random.randint(0, 2**31 - 1) |
| progress(0.15, desc=f"Generating {num_frames / FPS:.1f}s talking avatar ...") |
| frames, audio, sampling_rate = _generate( |
| prompt_embeds, text_token_tags, image_path, audio_seconds, audio_path, height, width, num_frames, seed |
| ) |
|
|
| directory = os.path.join(tempfile.gettempdir(), "h3-avatar") |
| os.makedirs(directory, exist_ok=True) |
| out_path = os.path.join(directory, f"avatar-{int(time.time() * 1000)}.mp4") |
| encode_video(frames, fps=FPS, output_path=out_path, audio=audio, audio_sample_rate=sampling_rate) |
| return out_path |
|
|
|
|
| load_models() |
|
|
| CSS = """ |
| .main.fillable { max-width: 720px !important; } |
| """ |
|
|
| with gr.Blocks(title="Talking Avatar") as demo: |
| gr.Markdown("# Talking Avatar\nUpload a portrait and a voice clip, then press Generate.") |
| image = gr.Image(label="Portrait image", type="filepath", height=280) |
| audio = gr.Audio(label="Voice", type="filepath") |
| run = gr.Button("Generate", variant="primary") |
| result = gr.Video(label="Talking avatar") |
|
|
| run.click(generate, [image, audio], result, api_name="generate") |
|
|
| if __name__ == "__main__": |
| demo.launch(show_error=True, css=CSS) |