Spaces:
Running on Zero
Running on Zero
| """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() | |
| 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 | |
| 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) | |
| 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) | |