""" AIBRUH/avatar-forcing — AvatarForcing Gradio Space One-step streaming talking avatar: portrait + audio → MP4 Built on: KlingAIResearch/AvatarForcing + Wan2.1-T2V-1.3B """ import os import sys import math import subprocess import tempfile import soundfile as sf import torch import torchaudio import gradio as gr from PIL import Image from einops import rearrange from torchvision import transforms from torchvision.transforms import InterpolationMode import torchvision.transforms.functional as F_tv import imageio from transformers import Wav2Vec2FeatureExtractor, Wav2Vec2Model from huggingface_hub import snapshot_download from omegaconf import OmegaConf from collections import OrderedDict # ── Paths ───────────────────────────────────────────────────────────────────── APP_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_DIR = os.path.join(APP_DIR, "AvatarForcing") WAN_DIR = os.path.join(REPO_DIR, "wan_models", "Wan2.1-T2V-1.3B") WAV2VEC_DIR = os.path.join(REPO_DIR, "wan_models", "wav2vec2-base-960h") CKPT_DIR = os.path.join(REPO_DIR, "checkpoints") # ── Step 1: Clone repo ──────────────────────────────────────────────────────── if not os.path.exists(os.path.join(REPO_DIR, "inference.py")): print("[1/4] Cloning AvatarForcing repo...") subprocess.run([ "git", "clone", "--depth=1", "https://github.com/KlingAIResearch/AvatarForcing.git", REPO_DIR ], check=True) else: print("[1/4] Repo already cloned.") sys.path.insert(0, REPO_DIR) os.makedirs(CKPT_DIR, exist_ok=True) os.makedirs(WAN_DIR, exist_ok=True) os.makedirs(WAV2VEC_DIR, exist_ok=True) # ── Step 2: Download models ─────────────────────────────────────────────────── print("[2/4] Downloading models (first boot only)...") if not os.path.exists(os.path.join(WAN_DIR, "config.json")): snapshot_download( "Wan-AI/Wan2.1-T2V-1.3B", local_dir=WAN_DIR, ignore_patterns=["*.msgpack", "flax_model*", "tf_model*", "rust_model*"], ) if not os.path.exists(os.path.join(WAV2VEC_DIR, "config.json")): snapshot_download("facebook/wav2vec2-base-960h", local_dir=WAV2VEC_DIR) if not os.path.exists(os.path.join(CKPT_DIR, "model.pt")): snapshot_download("lycui/AvatarForcing", local_dir=CKPT_DIR) print("[2/4] Models ready.") # ── Step 3: Load pipeline ───────────────────────────────────────────────────── # MUST chdir to REPO_DIR: wan_wrapper.py uses hardcoded relative paths like # "./wan_models/Wan2.1-T2V-1.3B/..." so CWD must be the repo root. os.chdir(REPO_DIR) print(f"[3/4] CWD → {REPO_DIR}") # Patch wan_wrapper.py: change text encoder from float32 → bfloat16 # This halves CPU RAM usage for the UMT5-XXL encoder (~10GB → ~5GB) _ww_path = os.path.join(REPO_DIR, "utils", "wan_wrapper.py") with open(_ww_path, "r") as _f: _ww_src = _f.read() _ww_patched = _ww_src.replace( "dtype=torch.float32,\n device=torch.device('cpu')", "dtype=torch.bfloat16,\n device=torch.device('cpu')", ) with open(_ww_path, "w") as _f: _f.write(_ww_patched) print("[3/4] Patched wan_wrapper.py: text encoder dtype float32 → bfloat16") print("[3/4] Loading AvatarForcing pipeline...") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") from pipeline import AvatarForcingInferencePipeline from utils.inject import _apply_lora config_path = os.path.join(REPO_DIR, "configs", "avatarforcing.yaml") default_path = os.path.join(REPO_DIR, "configs", "default_config.yaml") config = OmegaConf.merge(OmegaConf.load(default_path), OmegaConf.load(config_path)) # Override model paths to absolute so CWD doesn't matter config.data.wav2vec_path = WAV2VEC_DIR if hasattr(config, "model_kwargs") and hasattr(config.model_kwargs, "model_path"): config.model_kwargs.model_path = WAN_DIR # Build pipeline (loads Wan2.1-T2V-1.3B from WAN_DIR) pipeline = AvatarForcingInferencePipeline(config, device=device) # Load AvatarForcing DMD weights ckpt_path = os.path.join(CKPT_DIR, "model.pt") state_dict = torch.load(ckpt_path, map_location="cpu") pipeline.generator.model = _apply_lora(pipeline.generator.model, config["models"]["lora"]) pipeline.generator.load_state_dict(state_dict["generator"]) pipeline = pipeline.to(device=device, dtype=torch.bfloat16) pipeline.eval() print("[3/4] Pipeline loaded.") # ── Step 4: Audio encoder ───────────────────────────────────────────────────── print("[4/4] Loading Wav2Vec2 audio encoder...") wav2vec_extractor = Wav2Vec2FeatureExtractor.from_pretrained(WAV2VEC_DIR) wav2vec_model = Wav2Vec2Model.from_pretrained(WAV2VEC_DIR).eval().to(device) print("[4/4] Ready.") # ── Helpers ─────────────────────────────────────────────────────────────────── class ResizeKeepRatioArea16: def __init__(self, area_hw=(480, 832), div=16): self.A = area_hw[0] * area_hw[1] self.d = div def __call__(self, img): w, h = img.size s = min(1.0, math.sqrt(self.A / (h * w))) nh = max(self.d, int(h * s) // self.d * self.d) nw = max(self.d, int(w * s) // self.d * self.d) return F_tv.resize(img, (nh, nw), interpolation=InterpolationMode.BILINEAR, antialias=True) img_transform = transforms.Compose([ ResizeKeepRatioArea16((480, 832), 16), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), ]) def encode_audio(wav_path: str, num_frames: int, fps: int = 25) -> torch.Tensor: """Replicates TextImageAudioPairDataset audio processing.""" data, sr = sf.read(wav_path) if data.ndim > 1: data = data.mean(axis=1) data_t = torch.tensor(data, dtype=torch.float32) if sr != 16000: data_t = torchaudio.functional.resample(data_t, sr, 16000) teacher_len = num_frames * 4 + 80 max_audio_len = int(teacher_len * (16000 / fps)) data_t = data_t[:max_audio_len] inputs = wav2vec_extractor( data_t.numpy(), sampling_rate=16000, return_tensors="pt", padding=True ) inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): out = wav2vec_model(**inputs, output_hidden_states=True) last = out.last_hidden_state # [1, T, 768] # Concatenate hidden states as per dataset.py pattern hidden = [h for h in out.hidden_states[1:]] if out.hidden_states else [] if hidden: emb = torch.cat([last] + hidden, dim=-1) else: emb = last # Prepend zero frame (dataset.py padding) zero = torch.zeros(1, 1, emb.shape[-1], device=device) emb = torch.cat([zero, emb], dim=1) return emb # ── Inference ───────────────────────────────────────────────────────────────── def generate(portrait_path: str, audio_path: str, prompt: str, num_seconds: int) -> str: num_frames = num_seconds * 25 + 1 # 25 fps; must satisfy (frames-1) % 4 == 0 # Round to nearest valid value num_frames = ((num_frames - 1 + 3) // 4) * 4 + 1 # Image → latent img = Image.open(portrait_path).convert("RGB") img_t = img_transform(img).unsqueeze(0).unsqueeze(2).to(device=device, dtype=torch.bfloat16) initial_latent = pipeline.vae.encode_to_latent(img_t).to(device=device, dtype=torch.bfloat16) # Build conditioning tensor y (first-frame conditioning mask) img_lat = initial_latent.permute(0, 2, 1, 3, 4) total_frames = num_frames + 20 msk = torch.zeros_like(img_lat.repeat(1, 1, total_frames, 1, 1)[:, :1]) image_cat = img_lat.repeat(1, 1, total_frames, 1, 1) msk[:, :, 1:] = 1 y = torch.cat([image_cat, msk], dim=1) # Audio embeddings audio_emb = encode_audio(audio_path, num_frames=num_frames).to(device=device, dtype=torch.bfloat16) # Noise tensor h, w = initial_latent.shape[-2], initial_latent.shape[-1] noise = torch.randn((1, num_frames - 1, 16, h, w), device=device, dtype=torch.bfloat16) with torch.no_grad(): video = pipeline.inference_avatar_forcing( noise=noise, text_prompts=[prompt], audio_embeddings=audio_emb, y=y, return_latents=False, initial_latent=initial_latent, ) pipeline.vae.model.clear_cache() # Decode: [B, T, C, H, W] → list of [H, W, C] uint8 frames_np = (255.0 * rearrange(video[0], "t c h w -> t h w c")).cpu().numpy().astype("uint8") raw_path = tempfile.mktemp(suffix=".mp4") writer = imageio.get_writer(raw_path, fps=25, codec="libx264", quality=8) for frame in frames_np: writer.append_data(frame) writer.close() # Mux original audio track out_path = tempfile.mktemp(suffix=".mp4") subprocess.run([ "ffmpeg", "-y", "-i", raw_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", out_path, ], check=True, capture_output=True) os.unlink(raw_path) return out_path # ── Gradio UI ───────────────────────────────────────────────────────────────── with gr.Blocks(title="AvatarForcing · AIBRUH") as demo: gr.Markdown("## AvatarForcing · Streaming Talking Avatar\n*Portrait + Speech → Animated MP4 @ 25 FPS*") with gr.Row(): portrait_in = gr.Image(type="filepath", label="Portrait (JPG/PNG)") audio_in = gr.Audio(type="filepath", label="Speech Audio (WAV/MP3)") prompt_in = gr.Textbox( value="A photorealistic person speaking naturally, warm cinematic lighting, shallow depth of field, ultra detailed", label="Text Prompt", ) seconds_in = gr.Slider(1, 10, value=5, step=1, label="Duration (seconds)") btn = gr.Button("Generate Avatar Video", variant="primary") video_out = gr.Video(label="Amanda Speaking") btn.click( fn=generate, inputs=[portrait_in, audio_in, prompt_in, seconds_in], outputs=video_out, api_name="generate", ) if __name__ == "__main__": demo.launch()