lip-forcing / app.py
multimodalart's picture
multimodalart HF Staff
Update app.py
fdb9a98 verified
Raw
History Blame Contribute Delete
14.4 kB
"""Lip Forcing — few-step autoregressive diffusion for real-time lip synchronization.
ZeroGPU Gradio demo for the released 14B student
(https://huggingface.co/JinhyukJang/lipforcing). Given a talking-head reference
video and a driving audio clip, it re-synchronizes the mouth to the audio using
the streaming per-chunk AR pipeline from the official repo
(scripts/inference/inference_streaming.py), reproduced 1:1 here.
"""
import os
# Allocator: the streaming AR loop has transient spikes (VAE encode/decode of
# 512x512 chunks + KV cache). expandable segments avoids fragmentation OOMs.
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("ORT_DISABLE_THREAD_AFFINITY", "1")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
import spaces # noqa: E402 — must precede torch / CUDA-touching imports
import sys
import types
import tempfile
import traceback
import numpy as np
import torch
import gradio as gr
from PIL import Image
from huggingface_hub import hf_hub_download, snapshot_download
# The inference scripts import their helpers as top-level modules
# (`from _common import ...`), so make scripts/inference importable that way.
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, REPO_ROOT)
sys.path.insert(0, os.path.join(REPO_ROOT, "scripts", "inference"))
# ---------------------------------------------------------------------------
# Weights (downloaded once at startup into the HF cache)
# ---------------------------------------------------------------------------
print("Downloading weights ...", flush=True)
CKPT_PATH = hf_hub_download("JinhyukJang/lipforcing", "lipforcing_14b.pth")
WAN_REPO = "Wan-AI/Wan2.1-T2V-14B"
VAE_PATH = hf_hub_download(WAN_REPO, "Wan2.1_VAE.pth")
T5_PATH = hf_hub_download(WAN_REPO, "models_t5_umt5-xxl-enc-bf16.pth")
# UMT5 tokenizer (lives under google/umt5-xxl/ inside the Wan repo)
for _f in (
"google/umt5-xxl/special_tokens_map.json",
"google/umt5-xxl/spiece.model",
"google/umt5-xxl/tokenizer.json",
"google/umt5-xxl/tokenizer_config.json",
):
hf_hub_download(WAN_REPO, _f)
# T5_PATH's parent dir now also holds google/umt5-xxl/* (same snapshot dir).
WAV2VEC_DIR = snapshot_download("facebook/wav2vec2-base-960h")
# TAEW tiny streaming decoder + LatentSync mouth mask.
# taew2_1.pth lives in the taehv GitHub repo; mask.png in the LatentSync repo.
import urllib.request # noqa: E402
_cache = os.path.join(tempfile.gettempdir(), "lipforcing_assets")
os.makedirs(_cache, exist_ok=True)
TAEHV_CKPT = os.path.join(_cache, "taew2_1.pth")
if not os.path.exists(TAEHV_CKPT):
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/madebyollin/taehv/main/taew2_1.pth",
TAEHV_CKPT,
)
MASK_PATH = os.path.join(_cache, "mask.png")
if not os.path.exists(MASK_PATH):
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/bytedance/LatentSync/main/latentsync/utils/mask.png",
MASK_PATH,
)
print("Weights downloaded.", flush=True)
DTYPE = torch.bfloat16
DEVICE = "cuda"
# ---------------------------------------------------------------------------
# Args shim — the loaders/helpers read attributes off an argparse-like object.
# We build one with the released 14B student's default (2-step t769) schedule.
# ---------------------------------------------------------------------------
def _make_args():
a = types.SimpleNamespace()
a.ckpt_path = CKPT_PATH
a.vae_path = VAE_PATH
a.wav2vec_path = WAV2VEC_DIR
a.mask_path = MASK_PATH
a.taehv_ckpt = TAEHV_CKPT
a.base_model_paths = None
a.omniavatar_ckpt_path = None
a.model_size = "14B"
a.merge_lora_post_load = True
a.text_embeds_path = None
a.text_encoder_path = None # text encoded once at startup (below)
a.prompt = "a person talking"
a.streaming_decoder = "streaming_taehv"
a.t_list = [0.999, 0.769, 0.0] # released 14B 2-step schedule
a.chunk_size = 3
a.num_latent_frames = None
a.min_latent_frames = 0
a.context_noise = 0.0
a.seed = 42
a.fps = 25.0
a.dtype = "bf16"
a.device = DEVICE
a.local_attn_size = 7
a.sink_size = 1
a.use_dynamic_rope = True
a.skip_preprocessing = False
a.face_cache_dir = None
a.composite_full_face = False
a.streamwise_encode = True
a.defer_composite = False
a.compile = False
a.input_dir = None
a.output_dir = None
a.video_path = None
a.audio_path = None
a.output_path = None
return a
ARGS = _make_args()
# ---------------------------------------------------------------------------
# Text embedding: encode the default prompt ONCE on CPU, then free the 11 GB
# UMT5-XXL encoder. This keeps the encoder off the GPU so peak VRAM stays low
# (~37 GB), per the model card's "48 GB cards work with precomputed embeddings".
# ---------------------------------------------------------------------------
def _precompute_text_embeds(prompt: str) -> torch.Tensor:
from OmniAvatar.models.wan_video_text_encoder import WanTextEncoder
from OmniAvatar.prompters.wan_prompter import WanPrompter
from lipforcing import preprocess as pp
print(f"Encoding text prompt on CPU: {prompt!r} ...", flush=True)
text_encoder = WanTextEncoder()
te_state = torch.load(T5_PATH, map_location="cpu", weights_only=False)
converter = WanTextEncoder.state_dict_converter()
te_state = converter.from_civitai(te_state)
text_encoder.load_state_dict(te_state, strict=True)
text_encoder = text_encoder.to("cpu").eval()
tokenizer_path = pp._resolve_tokenizer_path(T5_PATH)
prompter = WanPrompter(tokenizer_path=tokenizer_path, text_len=512)
prompter.fetch_models(text_encoder=text_encoder)
with torch.no_grad():
emb = prompter.encode_prompt(prompt, positive=True, device="cpu")
if emb.dim() == 2:
emb = emb.unsqueeze(0)
emb = emb.to(dtype=DTYPE).contiguous()
del text_encoder, prompter, te_state
import gc
gc.collect()
print(f"Text embeds: {tuple(emb.shape)}", flush=True)
return emb
TEXT_EMBEDS_CPU = _precompute_text_embeds(ARGS.prompt)
# ---------------------------------------------------------------------------
# Models — loaded at module scope, .to("cuda") intercepted by ZeroGPU.
# ---------------------------------------------------------------------------
print("Loading diffusion model (14B student) ...", flush=True)
from _loader import load_diffusion_model # noqa: E402
from _common import ( # noqa: E402
TAEHVDecoderWrapper, load_vae, load_wav2vec,
resolve_audio, compute_generation_length,
load_image_processor, preprocess_with_latentsync,
)
from inference_streaming import ( # noqa: E402
run_streaming_pipeline, build_condition_streamwise,
)
MODEL = load_diffusion_model(ARGS, DEVICE, DTYPE)
print("Loading Wan VAE ...", flush=True)
VAE = load_vae(ARGS.vae_path, DEVICE)
print("Loading TAEHV decoder ...", flush=True)
DECODER_VAE = TAEHVDecoderWrapper(ARGS.taehv_ckpt, DEVICE)
print("Loading Wav2Vec2 ...", flush=True)
WAV2VEC_MODEL, WAV2VEC_EXTRACTOR = load_wav2vec(ARGS.wav2vec_path, DEVICE)
# LatentSync face detector / aligner uses insightface + onnxruntime; those need
# a live GPU context, so it is initialized lazily inside the GPU call.
IMAGE_PROCESSOR = None
def _get_image_processor():
global IMAGE_PROCESSOR
if IMAGE_PROCESSOR is None:
IMAGE_PROCESSOR = load_image_processor(ARGS.mask_path, DEVICE)
return IMAGE_PROCESSOR
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
MAX_SECONDS = 8.0 # cap driving audio so a single call stays within GPU budget
def _estimate_duration(video_path, audio_path, *a, **k):
# 14B student: streaming AR + face detect/composite. Budget generously per
# second of (capped) audio, plus fixed preprocessing/warmup overhead.
# Measured ~330s for a 4s clip on first (cold) call incl. warmup; scale by
# audio length with a generous fixed base and cap at the audio limit.
base = 60.0
per_sec = 50.0
secs = MAX_SECONDS
try:
import librosa
secs = min(librosa.get_duration(path=audio_path), MAX_SECONDS)
except Exception:
pass
return int(base + per_sec * secs)
@spaces.GPU(duration=_estimate_duration)
def lip_sync(video_path: str, audio_path: str,
seed: int = 42) -> str:
"""Lip-sync a talking-head video to a driving audio clip.
Args:
video_path: reference talking-head video (any resolution; a single
clear front-facing face is detected, aligned to 512x512, and the
mouth region is regenerated to match the audio).
audio_path: driving speech audio; the output length follows the audio
(capped to keep a single request within the GPU budget).
seed: RNG seed for reproducibility.
Returns:
Path to the generated lip-synced mp4 (muxed with the driving audio).
"""
if not video_path:
raise gr.Error("Please provide a reference talking-head video.")
if not audio_path:
raise gr.Error("Please provide a driving audio clip.")
import imageio_ffmpeg
import subprocess
args = _make_args()
args.seed = int(seed)
args.video_path = video_path
args.audio_path = audio_path
torch.manual_seed(args.seed)
torch.cuda.manual_seed_all(args.seed)
image_processor = _get_image_processor()
# Cap audio length so runtime stays bounded.
ff = imageio_ffmpeg.get_ffmpeg_exe()
capped_audio = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
subprocess.run(
[ff, "-y", "-loglevel", "error", "-nostdin", "-i", audio_path,
"-t", str(MAX_SECONDS), "-ar", "16000", "-ac", "1", capped_audio],
check=True,
)
out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
tmp_audio = None
try:
used_audio, tmp_audio = resolve_audio(audio_path=capped_audio)
num_latent_frames, num_video_frames = compute_generation_length(
used_audio, args.num_latent_frames, args.chunk_size, args.fps,
min_latent_frames=args.min_latent_frames,
)
print("Face detection + 512x512 alignment ...", flush=True)
meta = preprocess_with_latentsync(
args.video_path, image_processor, args.face_cache_dir,
num_frames=num_video_frames,
)
if meta is None:
raise gr.Error(
"Face detection failed — please provide a video with a single, "
"clear, front-facing talking head."
)
aligned_faces = meta["aligned_faces"]
ref_frames_np = np.stack([
f.permute(1, 2, 0).numpy() if isinstance(f, torch.Tensor) else f
for f in aligned_faces[:num_video_frames]
], axis=0)
text_embeds = TEXT_EMBEDS_CPU.to(device=DEVICE, dtype=DTYPE)
condition, video_tensor, masked_video_tensor = build_condition_streamwise(
VAE, WAV2VEC_MODEL, WAV2VEC_EXTRACTOR,
ref_frames_np, used_audio, text_embeds, args.mask_path,
num_video_frames, num_latent_frames, DEVICE, DTYPE,
)
print("Running streaming pipeline ...", flush=True)
run_streaming_pipeline(
MODEL, DECODER_VAE, VAE, condition,
num_latent_frames, num_video_frames,
args, meta, image_processor,
used_audio, out_path, DEVICE, DTYPE,
video_tensor=video_tensor,
masked_video_tensor=masked_video_tensor,
)
except gr.Error:
raise
except Exception as e:
traceback.print_exc()
raise gr.Error(f"Inference failed: {e}")
finally:
MODEL.clear_caches()
torch.cuda.empty_cache()
if tmp_audio and os.path.exists(tmp_audio):
os.remove(tmp_audio)
if os.path.exists(capped_audio):
os.remove(capped_audio)
return out_path
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
DESCRIPTION = """
# Lip Forcing 🗣️
**Few-Step Autoregressive Diffusion for Real-time Lip Synchronization**  · 
14B student  · 
[Paper](https://arxiv.org/abs/2606.11180)  · 
[Project](https://cvlab-kaist.github.io/LipForcing/)  · 
[Code](https://github.com/cvlab-kaist/LipForcing)  · 
[Weights](https://huggingface.co/JinhyukJang/lipforcing)
Give it a **talking-head video** and a **driving audio** clip — it detects and aligns
the face, then regenerates the mouth to match the audio with a 2-step causal diffusion
student. Audio is capped to the first few seconds per run.
"""
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column():
video_in = gr.Video(label="Reference talking-head video", height=340)
audio_in = gr.Audio(label="Driving audio", type="filepath")
run_btn = gr.Button("Lip-sync", variant="primary")
with gr.Column():
video_out = gr.Video(label="Lip-synced result", height=340)
with gr.Accordion("Advanced settings", open=False):
seed = gr.Number(label="Seed", value=42, precision=0)
run_btn.click(
fn=lip_sync,
inputs=[video_in, audio_in, seed],
outputs=video_out,
api_name="lip_sync",
)
gr.Examples(
examples=[
["examples/example1_video.mp4", "examples/example1_audio.wav"],
["examples/example2_video.mp4", "examples/example2_audio.wav"],
],
inputs=[video_in, audio_in],
outputs=video_out,
fn=lip_sync,
cache_examples=True,
cache_mode="lazy",
)
if __name__ == "__main__":
demo.launch(mcp_server=True)