lip-forcing / scripts /inference /_common.py
multimodalart's picture
multimodalart HF Staff
Initial Lip Forcing 14B streaming demo
9368ee7 verified
Raw
History Blame Contribute Delete
44.2 kB
"""Shared helpers for the inference scripts.
Everything that ``inference_segmentwise.py`` and ``inference_streaming.py``
have in common lives here: model/encoder loading, conditioning construction,
LatentSync face preprocessing + compositing, TAEHV decoder wrappers, audio
handling, and video I/O.
"""
import math
import os
import subprocess
import sys
import tempfile
import cv2
import librosa
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
# ---------------------------------------------------------------------------
# Path setup
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LIPFORCING_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
sys.path.insert(0, LIPFORCING_ROOT)
# Shared preprocessing/encode helpers (single source of truth, shared with training).
from lipforcing import preprocess as pp # noqa: E402
def _get_ffmpeg():
"""Return path to ffmpeg binary (system or imageio_ffmpeg fallback)."""
import shutil
path = shutil.which("ffmpeg")
if path:
return path
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except ImportError:
raise RuntimeError("ffmpeg not found. Install ffmpeg or pip install imageio-ffmpeg.")
# ===========================================================================
# TAEHV decoder wrappers
# ===========================================================================
class TAEHVDecoderWrapper:
"""Drop-in decode-only replacement that mimics WanVideoVAE.decode().
TAEHV convention:
- Input: diffusion-space latents (same space the denoiser works in).
No mean/std scaling is applied here — TAEHV was distilled to
consume these directly.
- Output: pixels in [0, 1], NTCHW layout.
WanVideoVAE convention:
- decode() returns pixels in [-1, 1], shape [1, 3, T_video, H, W] (NCTHW).
This wrapper converts TAEHV output to Wan's range/layout so downstream
code (decode_and_save, LatentSync path) works unchanged.
"""
def __init__(self, checkpoint_path, device):
from lipforcing.methods.reward.taehv import TAEHV
self.device = device
# trim_output=False: this wrapper trims frames itself to match Wan's convention.
self.taehv = TAEHV(checkpoint_path=checkpoint_path, trim_output=False).to(device, torch.float16).eval()
@torch.no_grad()
def decode(self, latents_list, device=None):
# latents_list: list of one [C=16, T_lat, H, W] tensor (matches WanVideoVAE.decode signature)
target_device = device if device is not None else self.device
lat = latents_list[0].to(target_device, dtype=torch.float16) # [16, T, H, W]
lat = lat.permute(1, 0, 2, 3).unsqueeze(0) # [1, T, 16, H, W] NTCHW
vid = self.taehv.decode_video(lat, parallel=False) # [1, T*4, 3, H', W'] in [0, 1]
# Front-trim is disabled here, so match Wan's
# temporal length convention: num_video = 1 + (num_latent - 1) * 4 = T_lat*4 - frames_to_trim.
vid = vid[:, self.taehv.frames_to_trim:] # [1, T_lat*4 - 3, 3, H', W']
vid = vid.mul(2).sub(1) # -> [-1, 1] (match Wan)
return vid.permute(0, 2, 1, 3, 4).float() # [1, 3, T_video, H', W'] NCTHW
@torch.no_grad()
def encode(self, videos_list, device=None):
"""Drop-in replacement for WanVideoVAE.encode().
Wan convention: input list of [3, T, H, W] in [-1, 1]; returns [N, 16, T_lat, H_lat, W_lat]
with T_lat = 1 + (T-1)//4 = ⌈T/4⌉.
TAEHV: wants NTCHW in [0, 1], its temporal compression is floor(T/4). We pad the
INPUT video to the next multiple of 4 so floor(T_pad/4) = ⌈T/4⌉, matching Wan's T_lat
naturally — no latent-side duplication needed.
"""
target_device = device if device is not None else self.device
outs = []
for vid in videos_list:
T = vid.shape[1]
T_pad = ((T + 3) // 4) * 4 # round up to multiple of 4
if T_pad > T:
# PREPEND copies of the first frame so TAEHV's latent 0 pools [f0,f0,f0,f0]
# = encoding of the static starting frame. This matches Wan's convention where
# latent 0 encodes frame 0 alone; latents i>0 encode groups of 4 consecutive frames.
pad = vid[:, :1].expand(-1, T_pad - T, -1, -1).contiguous()
vid = torch.cat([pad, vid], dim=1)
x = vid.to(target_device, dtype=torch.float16)
x = x.add(1).div(2) # [-1,1] -> [0,1]
x = x.permute(1, 0, 2, 3).unsqueeze(0) # [1, T_pad, 3, H, W] NTCHW
lat = self.taehv.encode_video(x, parallel=False, show_progress_bar=False) # [1, T_pad/4, 16, H', W']
lat = lat.permute(0, 2, 1, 3, 4).float() # [1, 16, T_pad/4, H', W']
outs.append(lat.squeeze(0)) # [16, T_pad/4, H', W']
return torch.stack(outs) # [N, 16, T_pad/4, H', W']
class StreamingTAEHVDecoderWrapper(TAEHVDecoderWrapper):
"""Drop-in decoder using StreamingTAEHV — feeds latents one at a time and
collects pixel frames as they emerge.
Same signature as TAEHVDecoderWrapper.decode() so downstream code works
unchanged; encode() is inherited (used when --taehv_encode is combined
with --taehv_streaming).
"""
def __init__(self, checkpoint_path, device):
from lipforcing.methods.reward.taehv import StreamingTAEHV
super().__init__(checkpoint_path, device) # sets self.taehv
self.streaming = StreamingTAEHV(self.taehv)
@torch.no_grad()
def decode(self, latents_list, device=None):
target_device = device if device is not None else self.device
self.streaming.reset()
lat = latents_list[0].to(target_device, dtype=torch.float16) # [16, T_lat, H, W]
lat = lat.permute(1, 0, 2, 3).unsqueeze(0) # [1, T_lat, 16, H, W] NTCHW
frames = []
for t in range(lat.shape[1]):
latent_t = lat[:, t:t+1] # [1, 1, 16, H, W]
frame = self.streaming.decode(latent_t)
while frame is not None:
frames.append(frame)
frame = self.streaming.decode()
for frame in self.streaming.flush_decoder():
frames.append(frame)
# Stack [N1CHW, ...] → [1, T, C, H, W] NTCHW, convert to NCTHW [-1, 1]
vid = torch.cat(frames, dim=1) # [1, T, 3, H', W']
vid = vid.mul(2).sub(1) # [0,1] → [-1,1]
return vid.permute(0, 2, 1, 3, 4).float() # [1, 3, T, H', W']
# ===========================================================================
# Model loading functions
# ===========================================================================
def load_vae(vae_path, device):
"""Load the Wan 2.1 Video VAE.
Returns:
WanVideoVAE instance in eval mode on *device*.
"""
from OmniAvatar.models.wan_video_vae import WanVideoVAE
vae = WanVideoVAE(z_dim=16)
print(f"Loading VAE from {vae_path} ...")
state_dict = torch.load(vae_path, map_location="cpu", weights_only=False)
# Handle both 'model.xxx' prefixed and flat key formats
if any(k.startswith("model.") for k in state_dict):
# Already has model. prefix — load directly into WanVideoVAE
vae.load_state_dict(state_dict, strict=True)
elif "model_state" in state_dict:
# CivitAI format: state_dict['model_state'] with flat keys
converter = WanVideoVAE.state_dict_converter()
converted = converter.from_civitai(state_dict)
vae.load_state_dict(converted, strict=True)
else:
# Flat keys — add 'model.' prefix
prefixed = {"model." + k: v for k, v in state_dict.items()}
vae.load_state_dict(prefixed, strict=True)
vae = vae.to(device=device)
vae.eval()
return vae
def load_wav2vec(wav2vec_path, device):
"""Load wav2vec2-base-960h model and feature extractor.
Returns:
(wav2vec_model, wav2vec_extractor) — model in eval/float32 on *device*.
"""
from transformers import Wav2Vec2FeatureExtractor
from OmniAvatar.models.wav2vec import Wav2VecModel
print(f"Loading Wav2Vec2 from {wav2vec_path} ...")
extractor = Wav2Vec2FeatureExtractor.from_pretrained(wav2vec_path)
model = Wav2VecModel.from_pretrained(wav2vec_path, attn_implementation="eager")
# Freeze feature extractor (CNN) — must stay float32
model.feature_extractor.requires_grad_(False)
model = model.to(device).float()
model.eval()
return model, extractor
def load_or_encode_text(args, device, dtype):
"""Get text embeddings — either from file or by encoding the prompt.
Returns:
text_embeds: [1, 512, 4096] tensor on *device* in *dtype*.
"""
if args.text_embeds_path is not None:
print(f"Loading text embeddings from {args.text_embeds_path} ...")
data = torch.load(args.text_embeds_path, map_location="cpu", weights_only=False)
if isinstance(data, dict):
# Handle dict formats: {'context': tensor} or {'text_emb': tensor}
for key in ("context", "text_emb", "prompt_emb"):
if key in data:
text_embeds = data[key]
break
else:
# Take first tensor value
text_embeds = next(iter(data.values()))
else:
text_embeds = data
# Ensure shape [1, 512, 4096]
if text_embeds.dim() == 2:
text_embeds = text_embeds.unsqueeze(0)
assert text_embeds.shape == (1, 512, 4096), (
f"Expected text_embeds shape [1, 512, 4096], got {text_embeds.shape}"
)
return text_embeds.to(device=device, dtype=dtype)
elif args.text_encoder_path is not None:
print(f"Loading T5 text encoder from {args.text_encoder_path} ...")
from OmniAvatar.models.wan_video_text_encoder import WanTextEncoder
from OmniAvatar.prompters.wan_prompter import WanPrompter
# Load text encoder
text_encoder = WanTextEncoder()
te_state = torch.load(args.text_encoder_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(device).eval()
# Set up prompter; resolve the tokenizer (handles the google/umt5-xxl subdir layout)
tokenizer_path = pp._resolve_tokenizer_path(args.text_encoder_path)
prompter = WanPrompter(tokenizer_path=tokenizer_path, text_len=512)
prompter.fetch_models(text_encoder=text_encoder)
# Encode
with torch.no_grad():
text_embeds = prompter.encode_prompt(
args.prompt, positive=True, device=device
)
# Ensure shape [1, 512, 4096]
if text_embeds.dim() == 2:
text_embeds = text_embeds.unsqueeze(0)
# Cleanup to free VRAM
del text_encoder, prompter
torch.cuda.empty_cache()
return text_embeds.to(dtype=dtype)
else:
raise ValueError(
"Must provide either --text_embeds_path or --text_encoder_path "
"to obtain text embeddings."
)
# ===========================================================================
# Input preprocessing functions
# ===========================================================================
def resolve_audio(audio_path=None, video_path=None, args=None):
"""Determine the audio source path.
Accepts explicit *audio_path* / *video_path* for batch mode, or falls
back to reading from *args* for single-sample backward-compatibility.
Returns:
(audio_path, tmp_path_or_None) — tmp_path is set when a temp file
was created and must be cleaned up later.
"""
if audio_path is None and args is not None:
audio_path = getattr(args, "audio_path", None)
if video_path is None and args is not None:
video_path = getattr(args, "video_path", None)
if audio_path is not None:
return audio_path, None
if video_path is None:
raise ValueError("resolve_audio: need either audio_path or video_path")
# Extract audio from video
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_path = tmp.name
tmp.close()
cmd = [
_get_ffmpeg(), "-y", "-loglevel", "error", "-nostdin",
"-i", video_path,
"-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
tmp_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"ffmpeg audio extraction failed:\n{result.stderr}"
)
print(f"Extracted audio to {tmp_path}")
return tmp_path, tmp_path
def get_audio_duration(audio_path):
"""Get audio duration in seconds.
Returns:
float — duration in seconds.
"""
# Use librosa instead of ffprobe (ffprobe may not be installed)
duration = librosa.get_duration(filename=audio_path)
return duration
def compute_generation_length(audio_path, override_frames, chunk_size, fps,
min_latent_frames=0):
"""Compute generation length in both latent and video frames.
The VAE temporal compression is: num_latent = 1 + (num_video - 1) // 4.
We round DOWN num_latent to the nearest multiple of chunk_size so the AR
loop produces complete chunks.
If ``min_latent_frames`` > 0 and the audio-derived num_latent is shorter,
we pad up to ``min_latent_frames``: audio zero-pads via
wav2vec; video frames are ping-pong extended in adjust_video_length.
Args:
audio_path: path to audio file (for duration)
override_frames: explicit num_latent_frames (or None)
chunk_size: AR chunk size in latent frames
fps: video frames per second
min_latent_frames: floor on num_latent; 0 disables padding.
Returns:
(num_latent_frames, num_video_frames)
"""
duration = get_audio_duration(audio_path)
num_video_raw = int(duration * fps) # floor
num_latent_raw = 1 + (num_video_raw - 1) // 4
if override_frames is not None:
num_latent = override_frames
if num_latent % chunk_size != 0:
raise ValueError(
f"--num_latent_frames ({num_latent}) must be a multiple of "
f"chunk_size ({chunk_size})"
)
else:
# Round DOWN to multiple of chunk_size
num_latent = (num_latent_raw // chunk_size) * chunk_size
num_latent = max(num_latent, chunk_size) # at least one chunk
if min_latent_frames and num_latent < min_latent_frames:
print(f" Audio too short ({duration:.2f}s → {num_latent} latent frames), "
f"padding to {min_latent_frames}")
num_latent = min_latent_frames
# Inverse: num_video = 1 + (num_latent - 1) * 4
num_video = 1 + (num_latent - 1) * 4
print(f"Generation length: {num_latent} latent frames, {num_video} video frames")
return num_latent, num_video
def load_video_frames(video_path, max_frames=None):
"""Load video frames as [N, H, W, 3] uint8 numpy array.
Validates that frames are 512x512.
Args:
video_path: path to video file
max_frames: if set, read at most this many frames
Returns:
frames: [N, H, W, 3] uint8 numpy array
"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"Cannot open video: {video_path}")
frames = []
checked_size = False
while True:
if max_frames is not None and len(frames) >= max_frames:
break
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if not checked_size:
h, w = frame.shape[:2]
if h != 512 or w != 512:
cap.release()
raise ValueError(
f"Video must be 512x512, got {w}x{h}. "
"Resize the input, or drop --skip_preprocessing so the "
"face-alignment pipeline handles arbitrary resolutions."
)
checked_size = True
frames.append(frame)
cap.release()
if len(frames) == 0:
raise RuntimeError(f"Could not read any frames from {video_path}")
return np.stack(frames, axis=0) # [N, H, W, 3] uint8
def pingpong_indices(n, target):
"""Frame indices that extend a length-*n* sequence to *target* via ping-pong.
Plays forward then backward then forward: 0,1,...,n-1,n-2,...,1,0,1,...
For n == 1 this is all zeros; for n >= target it clips to range(target).
This is the "rewind" extension used when the audio-driven generation length
exceeds the reference video: the mouth syncs over the looped/rewound video
instead of freezing on the last frame. The cycle is independent of *target*,
so a longer index list is always a prefix-superset of a shorter one.
"""
if n >= target:
return list(range(target))
if n == 1:
return [0] * target
cycle = list(range(n)) + list(range(n - 2, 0, -1))
indices = []
while len(indices) < target:
indices.extend(cycle)
return indices[:target]
def adjust_video_length(frames_np, target):
"""Adjust video to exactly *target* frames via ping-pong extension or clipping.
Args:
frames_np: [N, H, W, 3] uint8
target: desired number of frames
Returns:
[target, H, W, 3] uint8
"""
n = len(frames_np)
if n >= target:
return frames_np[:target]
return frames_np[pingpong_indices(n, target)]
def load_and_adjust_video(video_path, num_video_frames):
"""Load video and adjust to exactly *num_video_frames* frames.
Returns:
[num_video_frames, H, W, 3] uint8 numpy array.
"""
frames = load_video_frames(video_path)
return adjust_video_length(frames, num_video_frames)
def frames_to_tensor(frames_np):
"""Convert [N, H, W, 3] uint8 numpy → [1, 3, N, H, W] float tensor in [-1, 1].
Normalizes to [-1, 1] and reorders to [1, 3, N, H, W].
"""
t = torch.from_numpy(frames_np).float() / 255.0 # [N, H, W, 3] in [0, 1]
t = t.permute(0, 3, 1, 2) # [N, 3, H, W]
t = t * 2.0 - 1.0 # [-1, 1]
t = t.unsqueeze(0).permute(0, 2, 1, 3, 4) # [1, 3, N, H, W]
return t
def load_latentsync_mask(mask_path, latent_h, latent_w):
"""Load LatentSync mask and resize to latent resolution.
Returns:
[H_lat, W_lat] float tensor. 1=keep, 0=mask (LatentSync convention).
"""
mask_img = Image.open(mask_path).convert("L")
mask_arr = np.array(mask_img).astype(np.float32) / 255.0 # 1=keep, 0=mask
mask_t = torch.from_numpy(mask_arr).unsqueeze(0).unsqueeze(0) # [1, 1, H, W]
mask_resized = F.interpolate(
mask_t, size=(latent_h, latent_w), mode="bilinear", align_corners=False
)
return (mask_resized > 0.5).float().squeeze(0).squeeze(0) # [H_lat, W_lat]
def apply_spatial_mask(video_tensor, mask_np, mask_all_frames=True):
"""Apply LatentSync spatial mask to a normalized video tensor.
Matches training convention: normalize first (already done), then mask.
Masked region becomes 0.0 in [-1,1] space (mid-gray).
Args:
video_tensor: [1, 3, N, H, W] float in [-1, 1]
mask_np: [H, W] float32, 1=keep, 0=mask (LatentSync convention)
mask_all_frames: if True, mask ALL frames including frame 0
Returns:
masked_tensor: [1, 3, N, H, W] float in [-1, 1]
"""
mask_t = torch.from_numpy(mask_np).float() # [H, W]
mask_t = mask_t[None, None, None, :, :] # [1, 1, 1, H, W]
masked = video_tensor.clone()
if mask_all_frames:
masked *= mask_t
else:
masked[:, :, 1:, :, :] *= mask_t
return masked
def encode_reference_video(vae, video_frames_np, mask_path, device, dtype):
"""Encode reference video through VAE (both unmasked and masked).
Args:
vae: WanVideoVAE instance
video_frames_np: [N, H, W, 3] uint8
mask_path: path to LatentSync mask
device: torch device
dtype: torch dtype
Returns:
(ref_latent, masked_latents, ref_sequence, latent_mask) where:
- ref_latent: [1, 16, 1, H_lat, W_lat] — first frame latent
- masked_latents: [1, 16, T_lat, H_lat, W_lat] — spatially masked
- ref_sequence: [1, 16, T_lat, H_lat, W_lat] — unmasked full video
- latent_mask: [H_lat, W_lat] float (LatentSync convention)
"""
H, W = 512, 512
# Convert to tensor
video_tensor = frames_to_tensor(video_frames_np) # [1, 3, N, H, W]
# Load pixel-level mask — use cv2 bilinear (INTER_LINEAR) resize to match
# the latent-mask preprocessing.
mask_img = Image.open(mask_path).convert("L")
mask_np = np.array(mask_img).astype(np.float32) / 255.0
if mask_np.shape[0] != H or mask_np.shape[1] != W:
mask_np = cv2.resize(mask_np, (W, H), interpolation=cv2.INTER_LINEAR)
mask_pixel_binary = (mask_np > 0.5).astype(np.float32)
# Apply spatial mask (all frames)
masked_video_tensor = apply_spatial_mask(video_tensor, mask_pixel_binary, mask_all_frames=True)
# VAE encode. Wan VAE runs in bf16 (cast temporarily); TAEHV runs in fp16 natively.
is_taehv = isinstance(vae, TAEHVDecoderWrapper)
N = video_tensor.shape[2]
if is_taehv:
# TAEHV batch-encodes all frames at once and OOMs on long clips, so long
# videos are encoded in GRID-ALIGNED chunks: the first chunk is 81 frames
# (1 + 20x4 latents, matching Wan's ``1 + 4x`` temporal grid) and every
# later chunk is 80 frames (20 four-frame groups). Chunking at these
# boundaries keeps the latent<->frame alignment exact. Naive fixed-size
# chunking must NOT be used here: it emits one extra "first-frame" latent
# per chunk, progressively time-shifting the conditioning against the
# audio/rollout timeline (~3 frames per 81-frame chunk).
FIRST, REST = 81, 80
def _encode(vt):
if N <= FIRST:
return vae.encode([vt[0]], device=device)
out = [vae.encode([vt[:, :, :FIRST][0]], device=device)]
for s in range(FIRST, N, REST):
e = min(s + REST, N)
out.append(vae.encode([vt[:, :, s:e][0]], device=device))
return torch.cat(out, dim=2)
with torch.no_grad():
source_latents = _encode(video_tensor)
masked_latents = _encode(masked_video_tensor)
else:
# The Wan VAE processes time sequentially with an internal feature
# cache, so the full clip is encoded in ONE pass regardless of length
# (matching the original research pipeline). Do not chunk this path:
# naive chunk boundaries break the ``1 + 4x`` latent grid and
# progressively desynchronize the conditioning on long videos.
original_dtype = next(vae.parameters()).dtype
vae.to(dtype=torch.bfloat16)
video_tensor = video_tensor.to(dtype=torch.bfloat16)
masked_video_tensor = masked_video_tensor.to(dtype=torch.bfloat16)
with torch.no_grad():
source_latents = vae.encode([video_tensor[0]], device=device)
masked_latents = vae.encode([masked_video_tensor[0]], device=device)
vae.to(dtype=original_dtype)
ref_latent = source_latents[:, :, :1].to(dtype=dtype) # [1, 16, 1, H_lat, W_lat]
ref_sequence = source_latents.to(dtype=dtype) # [1, 16, T_lat, H_lat, W_lat]
masked_latents = masked_latents.to(dtype=dtype)
H_lat, W_lat = ref_latent.shape[3], ref_latent.shape[4]
latent_mask = load_latentsync_mask(mask_path, H_lat, W_lat).to(device=device, dtype=dtype)
return ref_latent, masked_latents, ref_sequence, latent_mask
def encode_audio(wav2vec_model, wav2vec_extractor, audio_path, num_video_frames, device):
"""Encode audio to wav2vec2 features matching OmniAvatar's encode_audio.
Encodes at the FULL audio's natural frame count, then slices to
num_video_frames. This preserves the temporal grid that the model was
trained on.
Args:
wav2vec_model: Wav2VecModel instance (on device, float32)
wav2vec_extractor: Wav2Vec2FeatureExtractor
audio_path: path to audio file
num_video_frames: number of video frames to produce embeddings for
device: torch device
Returns:
audio_emb: [1, num_video_frames, 10752] float tensor
"""
wav2vec_sr = 16000 # Wav2Vec2 native sample rate
fps = 25 # OmniAvatar default
audio, sr = librosa.load(audio_path, sr=wav2vec_sr)
input_values = np.squeeze(
wav2vec_extractor(audio, sampling_rate=wav2vec_sr).input_values
)
input_values = torch.from_numpy(input_values).float().to(device=device)
input_values = input_values.unsqueeze(0)
# Compute the full audio's natural frame count
samples_per_frame = wav2vec_sr // fps # 640 at 16kHz/25fps
total_audio_frames = math.ceil(input_values.shape[1] / samples_per_frame)
total_audio_frames = max(total_audio_frames, num_video_frames) # at least num_frames
# Pad to align with total_audio_frames
target_samples = total_audio_frames * samples_per_frame
if input_values.shape[1] < target_samples:
input_values = F.pad(input_values, (0, target_samples - input_values.shape[1]))
# Encode at the full audio length, then slice to num_video_frames.
with torch.no_grad():
hidden_states = wav2vec_model(
input_values, seq_len=total_audio_frames, output_hidden_states=True
)
audio_emb = hidden_states.last_hidden_state
for hs in hidden_states.hidden_states:
audio_emb = torch.cat((audio_emb, hs), -1)
# audio_emb: [1, total_audio_frames, 10752]
# Slice to num_video_frames (matches training: full_emb[:num_training_frames])
audio_emb = audio_emb[:, :num_video_frames, :]
return audio_emb # [1, num_video_frames, 10752]
def build_condition(vae, wav2vec_model, wav2vec_extractor, video_frames_np,
audio_path, text_embeds, mask_path, num_video_frames,
num_latent_frames, device, dtype):
"""Build the full conditioning dictionary for the causal model.
Args:
vae: WanVideoVAE
wav2vec_model: Wav2VecModel
wav2vec_extractor: Wav2Vec2FeatureExtractor
video_frames_np: [N, H, W, 3] uint8
audio_path: path to audio file
text_embeds: [1, 512, 4096] tensor
mask_path: path to LatentSync mask
num_video_frames: total video frames
num_latent_frames: total latent frames
device: torch device
dtype: torch dtype
Returns:
dict with keys: text_embeds, audio_emb, ref_latent, mask,
masked_video, ref_sequence
"""
print("Encoding audio ...")
audio_emb = encode_audio(
wav2vec_model, wav2vec_extractor, audio_path, num_video_frames, device
)
audio_emb = audio_emb.to(dtype=dtype)
print("Encoding reference video ...")
ref_latent, masked_latents, ref_sequence, latent_mask = encode_reference_video(
vae, video_frames_np, mask_path, device, dtype
)
return {
"text_embeds": text_embeds,
"audio_emb": audio_emb,
"ref_latent": ref_latent.to(device=device, dtype=dtype),
"mask": latent_mask.to(device=device),
"masked_video": masked_latents.to(device=device, dtype=dtype),
"ref_sequence": ref_sequence.to(device=device, dtype=dtype),
}
def build_condition_from_precomputed(precomputed_dir, mask_path, num_latent_frames, device, dtype):
"""Build conditioning dict from pre-computed .pt files (exact training format).
This bypasses VAE/Wav2Vec encoding and uses the same tensors the model was
trained on, enabling direct comparison.
"""
print(f"Loading precomputed tensors from {precomputed_dir} ...")
# VAE latents (input + masked)
vae_data = torch.load(
os.path.join(precomputed_dir, "vae_latents_mask_all.pt"),
map_location="cpu", weights_only=False,
)
input_latents = vae_data["input_latents"].to(dtype=dtype) # [16, T, H, W]
masked_latents = vae_data["masked_latents"].to(dtype=dtype)
# ref_latent = first frame of input video
ref_latent = input_latents[:, :1].unsqueeze(0) # [1, 16, 1, H, W]
# Slice to num_latent_frames
input_latents = input_latents[:, :num_latent_frames].unsqueeze(0) # [1, 16, T, H, W]
masked_latents = masked_latents[:, :num_latent_frames].unsqueeze(0)
# ref_sequence (from separate file)
ref_path = os.path.join(precomputed_dir, "ref_latents.pt")
if os.path.exists(ref_path):
ref_data = torch.load(ref_path, map_location="cpu", weights_only=False)
ref_seq_key = "ref_sequence_latents" if "ref_sequence_latents" in ref_data else list(ref_data.keys())[0]
ref_sequence = ref_data[ref_seq_key].to(dtype=dtype)[:, :num_latent_frames].unsqueeze(0)
else:
print(" Warning: ref_latents.pt not found, using input_latents as ref_sequence")
ref_sequence = input_latents
# Audio (video-frame-rate)
audio_data = torch.load(
os.path.join(precomputed_dir, "audio_emb_omniavatar.pt"),
map_location="cpu", weights_only=False,
)
audio_emb = audio_data["audio_emb"] if isinstance(audio_data, dict) else audio_data
# Training slices to num_video_frames = 1 + (num_latent - 1) * 4
num_video_frames = 1 + (num_latent_frames - 1) * 4
audio_emb = audio_emb[:num_video_frames].unsqueeze(0).to(dtype=dtype) # [1, V, 10752]
print(f" audio_emb: {audio_emb.shape} (sliced to {num_video_frames} video frames)")
# Text
text_data = torch.load(
os.path.join(precomputed_dir, "text_emb.pt"),
map_location="cpu", weights_only=False,
)
if isinstance(text_data, dict):
text_embeds = next(v for v in text_data.values() if isinstance(v, torch.Tensor))
else:
text_embeds = text_data
if text_embeds.dim() == 2:
text_embeds = text_embeds.unsqueeze(0)
text_embeds = text_embeds.to(dtype=dtype)
# Mask
H_lat, W_lat = ref_latent.shape[3], ref_latent.shape[4]
latent_mask = load_latentsync_mask(mask_path, H_lat, W_lat)
print(f" ref_latent: {ref_latent.shape}, masked_video: {masked_latents.shape}")
print(f" ref_sequence: {ref_sequence.shape}, mask: {latent_mask.shape}")
return {
"text_embeds": text_embeds.to(device),
"audio_emb": audio_emb.to(device),
"ref_latent": ref_latent.to(device),
"mask": latent_mask.to(device=device, dtype=dtype),
"masked_video": masked_latents.to(device),
"ref_sequence": ref_sequence.to(device),
}
# ===========================================================================
# LatentSync preprocessing / compositing
# ===========================================================================
def load_image_processor(mask_path, device):
"""Load LatentSync ImageProcessor for face detection and alignment.
Initializes the LatentSync ImageProcessor:
- mask_image loaded via load_fixed_mask (uses caller-specified path)
- insightface_root anchored to the repo root so inference can run from any
working directory (insightface auto-downloads buffalo_l there on first use)
- device passed as string for InsightFace compatibility
"""
import os as _os
_os.environ.setdefault("ORT_DISABLE_THREAD_AFFINITY", "1")
from OmniAvatar.utils.latentsync.image_processor import ImageProcessor, load_fixed_mask
print("Loading LatentSync ImageProcessor ...")
device_str = str(device) if isinstance(device, torch.device) else device
mask_tensor = load_fixed_mask(512, mask_image_path=mask_path) if mask_path else None
processor = ImageProcessor(
resolution=512,
device=device_str,
mask_image=mask_tensor,
insightface_root=os.path.join(LIPFORCING_ROOT, "checkpoints", "auxiliary"),
)
return processor
def preprocess_with_latentsync(video_path, image_processor, face_detection_cache_dir=None, num_frames=81):
"""Detect faces, align to 512x512 via affine transform.
When *face_detection_cache_dir* is set, detection results are cached there
(``<video_stem>_face_cache.pt``) and reused on later runs over the same
video; ``None`` disables caching.
"""
if not os.path.exists(video_path):
print(f"[LatentSync] WARNING: Video not found: {video_path}")
return None
try:
video_basename = os.path.splitext(os.path.basename(video_path))[0]
if video_basename in ("sub_clip", "video"):
video_stem = os.path.basename(os.path.dirname(video_path))
else:
video_stem = video_basename
face_cache_path = (
os.path.join(face_detection_cache_dir, f"{video_stem}_face_cache.pt")
if face_detection_cache_dir else None
)
face_cache_loaded = False
original_frames = None
if face_cache_path and os.path.isfile(face_cache_path):
try:
face_cache = torch.load(face_cache_path, weights_only=False)
cached_frames = face_cache.get("num_frames")
if cached_frames is None:
cached_frames = len(face_cache.get("aligned_faces", []))
# Reuse only if resolution matches, the cache was built with the
# ping-pong padding (not the legacy last-frame freeze), and it is
# long enough for this request. The ping-pong cycle is target-
# independent, so a longer cache is a valid prefix-superset.
cache_ok = (
face_cache.get("resolution") == image_processor.resolution
and face_cache.get("pad_mode") == "pingpong"
and cached_frames >= num_frames
)
if cache_ok:
boxes = face_cache["boxes"]
affine_matrices = face_cache["affine_matrices"]
aligned_faces = face_cache["aligned_faces"]
detection_failures = []
face_cache_loaded = True
print(f"[LatentSync] Loaded face cache: {face_cache_path}")
else:
print(f"[LatentSync] Cache stale "
f"(res={face_cache.get('resolution')}/{image_processor.resolution}, "
f"pad={face_cache.get('pad_mode')}, "
f"frames={cached_frames}/{num_frames}), recomputing...")
except Exception as e:
print(f"[LatentSync] Cache corrupt ({e}), recomputing...")
os.remove(face_cache_path)
if not face_cache_loaded:
cap = cv2.VideoCapture(video_path)
frames = []
for _ in range(num_frames):
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frames.append(frame)
cap.release()
if len(frames) < 5:
print(f"[LatentSync] Too few frames ({len(frames)}) in {video_path}")
return None
if len(frames) < num_frames:
# Ping-pong (rewind) extend to the audio-driven length instead of
# freezing the last frame, so face detection and the VAE
# conditioning run over the rewound video.
frames = [frames[i] for i in pingpong_indices(len(frames), num_frames)]
original_frames = np.stack(frames, axis=0)
boxes = []
affine_matrices = []
aligned_faces = []
detection_failures = []
# Reset temporal smoothing bias for new video
image_processor.restorer.p_bias = None
for i, frame in enumerate(frames):
try:
face, box, affine_matrix = image_processor.affine_transform(frame)
boxes.append(box)
affine_matrices.append(affine_matrix)
aligned_faces.append(face)
except RuntimeError as e:
print(f"[LatentSync] Face detection failed for frame {i}: {e}")
boxes.append(None)
affine_matrices.append(None)
detection_failures.append(i)
if detection_failures:
print(f"[LatentSync] Face detection failed for {len(detection_failures)} frames, skipping")
return None
if face_cache_path:
os.makedirs(face_detection_cache_dir, exist_ok=True)
torch.save({
"aligned_faces": aligned_faces,
"boxes": boxes,
"affine_matrices": affine_matrices,
"resolution": image_processor.resolution,
"num_frames": len(original_frames),
"pad_mode": "pingpong",
}, face_cache_path)
print(f"[LatentSync] Saved face cache: {face_cache_path}")
return {
"video_path": video_path,
"original_frames": original_frames,
"num_frames": num_frames,
"aligned_faces": aligned_faces,
"boxes": boxes,
"affine_matrices": affine_matrices,
"detection_failures": detection_failures if not face_cache_loaded else [],
}
except Exception as e:
print(f"[LatentSync] Preprocessing failed for {video_path}: {e}")
import traceback
traceback.print_exc()
return None
def composite_with_latentsync_float(generated_float, latentsync_metadata, image_processor,
use_mouth_only_compositing=False, frame_offset=0):
"""Composite generated faces back onto original video, staying in float space.
Keeps the model output in float space (no uint8 quantization) before
compositing for maximum precision.
Args:
generated_float: [T, C, H, W] float tensor in [0, 1]
frame_offset: offset into the metadata arrays (for per-chunk streaming)
"""
import torchvision.transforms.functional as TF_v
original_frames = latentsync_metadata["original_frames"]
if original_frames is None:
video_path = latentsync_metadata["video_path"]
num_frames = latentsync_metadata.get("num_frames", 81)
cap = cv2.VideoCapture(video_path)
frames = []
for _ in range(num_frames):
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frames.append(frame)
cap.release()
if len(frames) < num_frames:
# Ping-pong (rewind) extend to match the conditioning frames so the
# composite background tracks the same rewound video.
frames = [frames[i] for i in pingpong_indices(len(frames), num_frames)]
original_frames = np.stack(frames, axis=0)
boxes = latentsync_metadata["boxes"]
affine_matrices = latentsync_metadata["affine_matrices"]
detection_failures = latentsync_metadata.get("detection_failures", [])
aligned_faces = latentsync_metadata.get("aligned_faces", None)
composite_frames = []
for i in range(generated_float.shape[0]):
gi = i + frame_offset
if gi >= len(original_frames):
break
if gi in detection_failures or boxes[gi] is None:
composite_frames.append(original_frames[gi])
continue
face = generated_float[i] # [C, H, W] float [0,1]
if use_mouth_only_compositing and aligned_faces is not None:
mouth_mask = image_processor.mask_image.float()
original_aligned_float = aligned_faces[gi].float() / 255.0
face = face * (1 - mouth_mask) + original_aligned_float * mouth_mask
x1, y1, x2, y2 = boxes[gi]
height = int(y2 - y1)
width = int(x2 - x1)
face_resized = TF_v.resize(
face, size=[height, width],
interpolation=TF_v.InterpolationMode.BICUBIC, antialias=True,
)
face_resized = face_resized * 2.0 - 1.0
try:
restored_frame = image_processor.restorer.restore_img(
original_frames[gi], face_resized, affine_matrices[gi]
)
composite_frames.append(restored_frame)
except Exception as e:
print(f"[LatentSync] Restoration failed for frame {gi}: {e}")
composite_frames.append(original_frames[gi])
return np.stack(composite_frames)
def save_frames_as_video(frames_np, output_path, fps=25):
"""Save [N, H, W, 3] uint8 numpy array as mp4 video.
Encodes with libx264 at CRF 13 and macro_block_size=None.
"""
import imageio
writer = imageio.get_writer(
output_path, fps=fps, codec='libx264',
macro_block_size=None,
ffmpeg_params=["-crf", "13"],
ffmpeg_log_level="error",
)
for frame in frames_np:
writer.append_data(frame)
writer.close()
def mux_video_with_audio(video_path, audio_path, output_path, duration_s=None):
"""Mux silent video with audio via ffmpeg."""
cmd = [
_get_ffmpeg(), "-y", "-loglevel", "error", "-nostdin",
"-i", video_path, "-i", audio_path,
"-map", "0:v:0", "-map", "1:a:0",
"-c:v", "libx264", "-crf", "18",
"-c:a", "aac", "-q:v", "0", "-q:a", "0",
]
if duration_s is not None:
cmd.extend(["-t", f"{duration_s:.4f}"])
cmd.append(output_path)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg mux failed: {result.stderr}")
# ===========================================================================
# Batch enumeration
# ===========================================================================
def enumerate_samples(args):
"""Yield (name, video_path, audio_path, precomputed_dir) per sample.
Batch mode (--input_dir): one sample per subdir containing sub_clip.mp4 +
audio.wav; training-style precomputed tensors are picked up automatically
when present. Single-sample mode: --video_path (+ optional --audio_path /
--precomputed_dir where the script supports it).
"""
if args.input_dir is not None:
for entry in sorted(os.listdir(args.input_dir)):
sample_dir = os.path.join(args.input_dir, entry)
if not os.path.isdir(sample_dir):
continue
video_path = os.path.join(sample_dir, "sub_clip.mp4")
if not os.path.isfile(video_path):
continue
audio_path = os.path.join(sample_dir, "audio.wav")
if not os.path.isfile(audio_path):
print(f"[Skip] No audio.wav in {sample_dir}")
continue
precomputed = sample_dir if os.path.isfile(
os.path.join(sample_dir, "vae_latents_mask_all.pt")
) else None
yield entry, video_path, audio_path, precomputed
else:
name = os.path.splitext(os.path.basename(args.video_path))[0]
yield name, args.video_path, args.audio_path, getattr(args, "precomputed_dir", None)