Spaces:
Running on Zero
Running on Zero
Download app.py from dima0987654321/2NET-MotionEnhancerLoRA: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/spaces/dima0987654321/2NET-MotionEnhancerLoRA/resolve/main/app.py
- Command line
-
hf download hf://spaces/dima0987654321/2NET-MotionEnhancerLoRA/app.py
-
curl -L -o app.py https://huggingface.co/spaces/dima0987654321/2NET-MotionEnhancerLoRA/resolve/main/app.py
12.5 kB
| from __future__ import annotations | |
| import os | |
| import tempfile | |
| import time | |
| from functools import lru_cache | |
| import spaces | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import gradio as gr | |
| import torch | |
| from h3_split_blocks import MiniMaxH3GeneratorBlocks | |
| MODEL_REPO = "MiniMaxAI/MiniMax-H3" | |
| LORA_REPO = "rzgar/minimax-h3_fl2v_8Step_motion_enhancer" | |
| LORA_FILE = "minimax-h3_fl2v_8Step_motion_enhancer.safetensors" | |
| CONDITIONER_SPACE = "multimodalart/qwen3vl-conditioner" | |
| FPS = 24 | |
| STEPS = 8 | |
| OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "minimax-h3-motion-enhancer") | |
| os.makedirs(OUTPUT_DIR, exist_ok=True) | |
| CANVASES = { | |
| "960x544 路 16:9 fast": (544, 960), | |
| "1024x576 路 16:9 fast": (576, 1024), | |
| "544x960 路 9:16 fast": (960, 544), | |
| "544x544 路 1:1 fast": (544, 544), | |
| "768x576 路 4:3 fast": (576, 768), | |
| "576x768 路 3:4 fast": (768, 576), | |
| "1152x512 路 21:9 fast": (512, 1152), | |
| } | |
| DEFAULT_CANVAS = "960x544 路 16:9 fast" | |
| def _reorder_interleaved_qkv(weight: torch.Tensor, num_heads: int, head_dim: int) -> torch.Tensor: | |
| grouped = weight.reshape(num_heads, 3 * head_dim, *weight.shape[1:]) | |
| query, key, value = grouped.split(head_dim, dim=1) | |
| return torch.cat( | |
| [part.reshape(num_heads * head_dim, *weight.shape[1:]) for part in (query, key, value)], dim=0 | |
| ) | |
| def _lora_target_name(source_name: str) -> str: | |
| target = source_name.removeprefix("diffusion_model.") | |
| if target.startswith("token_refiner.blocks."): | |
| target = target.replace("token_refiner.blocks.", "token_refiner.refiner_blocks.", 1) | |
| elif target.startswith("blocks."): | |
| target = target.replace("blocks.", "transformer_blocks.", 1) | |
| return target.replace("final_layer.adaln_proj.linear", "norm_out.linear") | |
| def _lora_targets(name: str, b_weight: torch.Tensor, num_heads: int, head_dim: int): | |
| target = _lora_target_name(name) | |
| if target.endswith(".attn.qkv_proj"): | |
| prefix = target.removesuffix("qkv_proj") | |
| rows = _reorder_interleaved_qkv(b_weight, num_heads, head_dim) | |
| for kind, part in zip(("q", "k", "v"), rows.split(num_heads * head_dim, dim=0)): | |
| yield f"{prefix}to_{kind}.weight", part.contiguous() | |
| elif target.endswith(".mlp.fc1"): | |
| gate, value = b_weight.chunk(2, dim=0) | |
| yield target.replace(".mlp.fc1", ".ff.net.0.proj") + ".weight", torch.cat([value, gate]).contiguous() | |
| elif target.endswith(".mlp.fc2"): | |
| yield target.replace(".mlp.fc2", ".ff.net.2") + ".weight", b_weight | |
| elif target.endswith(".attn.out_proj"): | |
| yield target.replace(".attn.out_proj", ".attn.to_out.0") + ".weight", b_weight | |
| else: | |
| yield target + ".weight", b_weight | |
| def _apply_motion_lora(transformer) -> str: | |
| from huggingface_hub import hf_hub_download | |
| from safetensors.torch import load_file | |
| path = hf_hub_download(LORA_REPO, LORA_FILE) | |
| lora = load_file(path, device="cpu") | |
| suffix_a = ".lora_A.weight" | |
| suffix_b = ".lora_B.weight" | |
| bases = sorted({key[: -len(suffix_a)] for key in lora if key.endswith(suffix_a)}) | |
| if not bases: | |
| raise ValueError("The motion enhancer LoRA did not contain any lora_A/lora_B pairs") | |
| params = dict(transformer.named_parameters()) | |
| num_heads = transformer.config.num_attention_heads | |
| head_dim = transformer.config.attention_head_dim | |
| folded = 0 | |
| missed = [] | |
| with torch.no_grad(): | |
| for name in bases: | |
| a = lora[f"{name}{suffix_a}"] | |
| b = lora.get(f"{name}{suffix_b}") | |
| if b is None: | |
| raise ValueError(f"Missing LoRA B matrix for {name}") | |
| for key, b_part in _lora_targets(name, b, num_heads, head_dim): | |
| param = params.get(key) | |
| if param is None: | |
| missed.append(key) | |
| continue | |
| delta = b_part.float() @ a.float() | |
| if delta.shape != param.shape: | |
| raise ValueError(f"LoRA shape mismatch for {key}: {delta.shape} vs {param.shape}") | |
| param.data = (param.data.float() + delta.to(param.device)).to(param.dtype) | |
| folded += 1 | |
| if missed: | |
| raise ValueError(f"{len(missed)} LoRA targets did not match the H3 transformer, e.g. {missed[:3]}") | |
| return f"Motion enhancer loaded: {len(bases)} adapters, {folded} matrices" | |
| def _load_pipeline(): | |
| from diffusers import ComponentsManager | |
| manager = ComponentsManager() | |
| blocks = MiniMaxH3GeneratorBlocks() | |
| 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.set_attention_backend(os.environ.get("H3_ATTENTION", "_native_cudnn")) | |
| print(_apply_motion_lora(pipe.transformer), flush=True) | |
| # Module-scope placement lets ZeroGPU pack the weights once instead of per request. | |
| pipe.transformer.to("cuda") | |
| return pipe | |
| PIPE = _load_pipeline() | |
| def _conditioner_client(): | |
| from gradio_client import Client | |
| return Client(CONDITIONER_SPACE) | |
| def _snap_frames(seconds: float) -> int: | |
| frames = max(120, round(float(seconds) * FPS)) | |
| while frames % 17 != 5: | |
| frames += 1 | |
| return frames | |
| def _cover_crop(image_path: str, canvas: str) -> str: | |
| from PIL import Image, ImageOps | |
| height, width = CANVASES[canvas] | |
| with Image.open(image_path) as image: | |
| image = ImageOps.exif_transpose(image).convert("RGB") | |
| image = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS) | |
| output = os.path.join(OUTPUT_DIR, f"frame-{time.time_ns()}.png") | |
| image.save(output) | |
| return output | |
| def _condition(prompt: str, image_path: str, canvas: str, num_frames: int, rewrite_prompt: bool = False): | |
| """Encode the prompt through the same Qwen3-VL conditioner path used by FlashGen. | |
| When rewrite_prompt=True, the conditioner first rewrites the user's prompt into | |
| MiniMax-H3's structured shot description, then encodes that rewritten prompt. | |
| This is the "Upsample/Enhance prompt" behavior from the FlashGen Space. | |
| """ | |
| from gradio_client import handle_file | |
| from safetensors import safe_open | |
| def call(): | |
| return _conditioner_client().predict( | |
| prompt=prompt, | |
| image_path=handle_file(image_path), | |
| last_image_path=None, | |
| canvas=canvas, | |
| num_frames=num_frames, | |
| rewrite_prompt=bool(rewrite_prompt), | |
| api_name="/encode", | |
| ) | |
| try: | |
| path, plan = call() | |
| except Exception as first: | |
| # The conditioner can occasionally leave a stale Gradio client behind. | |
| # Retry once with a fresh client, matching the FlashGen implementation. | |
| print(f"[conditioner] retrying with a fresh client after: {first}", flush=True) | |
| _conditioner_client.cache_clear() | |
| path, plan = call() | |
| with safe_open(path, framework="pt") as handle: | |
| embeds = handle.get_tensor("prompt_embeds") | |
| tags = handle.get_tensor("text_token_tags") | |
| metadata = handle.metadata() | |
| return embeds, tags, metadata, plan | |
| def _generate_gpu(embeds, tags, image, height: int, width: int, num_frames: int, seed: int): | |
| """Run MiniMax-H3 denoising and return CPU video frames plus soundtrack.""" | |
| PIPE.vae.to("cuda") | |
| PIPE.audio_vae.to("cuda") | |
| pipe_state = PIPE( | |
| prompt_embeds=embeds.to("cuda"), | |
| text_token_tags=tags, | |
| image=image, | |
| height=int(height), | |
| width=int(width), | |
| num_frames=int(num_frames), | |
| num_inference_steps=STEPS + 1, | |
| generator=torch.Generator("cpu").manual_seed(int(seed)), | |
| ) | |
| frames = pipe_state.get("videos")[0] | |
| audio = pipe_state.get("audio")[0] | |
| if torch.is_tensor(frames): | |
| frames = frames.cpu() | |
| if torch.is_tensor(audio): | |
| audio = audio.cpu() | |
| return frames, audio, int(pipe_state.get("sampling_rate")) | |
| def generate( | |
| prompt: str, | |
| image_path: str | None, | |
| canvas: str, | |
| duration: float, | |
| seed: int, | |
| enhance_prompt: bool = False, | |
| progress=gr.Progress(track_tqdm=True), | |
| ): | |
| """Animate an uploaded image, optionally using H3's Qwen3-VL prompt enhancement.""" | |
| if not image_path: | |
| raise gr.Error("Upload a first frame before generating.") | |
| if not prompt or not prompt.strip(): | |
| raise gr.Error("Describe the motion you want to see.") | |
| height, width = CANVASES[canvas] | |
| num_frames = _snap_frames(duration) | |
| cropped_path = _cover_crop(image_path, canvas) | |
| from PIL import Image | |
| from diffusers.utils import encode_video | |
| progress( | |
| 0.05, | |
| desc="Enhancing prompt, then encoding..." if enhance_prompt else "Encoding the first frame...", | |
| ) | |
| embeds, tags, metadata, plan = _condition( | |
| prompt.strip(), cropped_path, canvas, num_frames, rewrite_prompt=bool(enhance_prompt) | |
| ) | |
| height = int(metadata.get("height", height)) | |
| width = int(metadata.get("width", width)) | |
| num_frames = int(metadata.get("num_frames", num_frames)) | |
| enhanced = (plan or {}).get("refined_prompt") or "" | |
| progress(0.15, desc=f"Generating {num_frames / FPS:.1f}s at {width}x{height} in {STEPS} steps") | |
| with Image.open(cropped_path) as image: | |
| frames, audio, sampling_rate = _generate_gpu( | |
| embeds, tags, image.convert("RGB"), height, width, num_frames, int(seed) | |
| ) | |
| output = os.path.join(OUTPUT_DIR, f"motion-enhanced-{time.time_ns()}.mp4") | |
| encode_video(frames, fps=FPS, output_path=output, audio=audio, audio_sample_rate=sampling_rate) | |
| status = ( | |
| f"{width}x{height} 路 {num_frames / FPS:.2f}s 路 {STEPS} denoising steps 路 " | |
| f"seed {int(seed)}" | |
| ) | |
| return output, status, enhanced, gr.update(visible=bool(enhanced)) | |
| DESCRIPTION = """ | |
| # MiniMax-H3 Motion Enhancer | |
| Upload a still image and describe the movement. The Space uses the **8-step Motion Enhancer LoRA** | |
| with MiniMax-H3 and synchronized audio. | |
| **Enhance / Upsample prompt** uses the same Qwen3-VL prompt-rewriting path as the MiniMax-H3 FlashGen | |
| Space: your short instruction is first expanded into a structured shot/action/sound description and | |
| then those rewritten embeddings are sent to H3. | |
| Use only images you own or have permission to animate. | |
| """ | |
| with gr.Blocks(title="MiniMax-H3 Motion Enhancer") as demo: | |
| gr.Markdown(DESCRIPTION) | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=1): | |
| image = gr.Image(label="First frame", type="filepath", sources=["upload", "webcam"]) | |
| prompt = gr.Textbox( | |
| label="Motion prompt", | |
| value="The person naturally performs the described action. Keep the same identity and appearance. Natural body motion, realistic facial expression, synchronized sound effects and speech if specified.", | |
| lines=5, | |
| placeholder="Describe the action, camera movement and sound...", | |
| ) | |
| enhance = gr.Checkbox( | |
| label="Enhance / Upsample prompt", | |
| value=False, | |
| info="Rewrite the prompt with H3's Qwen3-VL into a detailed structured shot description before generation.", | |
| ) | |
| with gr.Row(): | |
| canvas = gr.Dropdown(list(CANVASES), value=DEFAULT_CANVAS, label="Output canvas") | |
| duration = gr.Slider(5, 15, value=5, step=1, label="Duration (seconds)") | |
| seed = gr.Number(value=42, precision=0, label="Seed") | |
| generate_button = gr.Button("Generate motion", variant="primary", size="lg") | |
| with gr.Column(scale=1): | |
| video = gr.Video(label="Generated video + soundtrack", autoplay=True) | |
| status = gr.Markdown() | |
| with gr.Accordion("Upsampled prompt", open=False, visible=False) as enhanced_panel: | |
| enhanced_box = gr.Textbox(show_label=False, lines=10, interactive=False) | |
| outputs = [video, status, enhanced_box, enhanced_panel] | |
| generate_button.click( | |
| fn=generate, | |
| inputs=[prompt, image, canvas, duration, seed, enhance], | |
| outputs=outputs, | |
| api_name="generate", | |
| ) | |
| prompt.submit( | |
| fn=generate, | |
| inputs=[prompt, image, canvas, duration, seed, enhance], | |
| outputs=outputs, | |
| api_name="generate_submit", | |
| ) | |
| demo.queue(max_size=8).launch(mcp_server=True) | |