Download multitalk.py from vincentkr18/multitalk-inf-v2: direct link, hf CLI and curl.
- Browser
- Download file 25 kB
-
https://huggingface.co/vincentkr18/multitalk-inf-v2/resolve/main/multitalk.py
- Command line
-
hf download hf://vincentkr18/multitalk-inf-v2/multitalk.py
-
curl -L -o multitalk.py https://huggingface.co/vincentkr18/multitalk-inf-v2/resolve/main/multitalk.py
25 kB
| import modal | |
| import os | |
| import subprocess | |
| import time | |
| import json | |
| import tempfile | |
| import logging | |
| import sys | |
| import warnings | |
| import shutil | |
| import base64 | |
| import requests | |
| from typing import Optional, Dict, Any, Union | |
| from datetime import datetime | |
| from types import SimpleNamespace | |
| from io import BytesIO | |
| from pathlib import Path | |
| from fastapi import HTTPException | |
| from pydantic import BaseModel | |
| # Suppress warnings for cleaner output | |
| warnings.filterwarnings("ignore") | |
| # Modal App Configuration | |
| app = modal.App("multitalk-inference") | |
| # Create a Volume for model weights | |
| volume = modal.Volume.from_name("multitalk-weights", create_if_missing=True) | |
| MODEL_DIR = Path("/models") | |
| # HuggingFace secret for authentication | |
| # huggingface_secret = modal.Secret.from_name( | |
| # "huggingface-secret", required_keys=["HF_TOKEN"] | |
| # ) | |
| huggingface_secret = '' | |
| # Base image with system dependencies | |
| base_image = ( | |
| modal.Image.debian_slim(python_version="3.10") | |
| .apt_install( | |
| "ffmpeg", | |
| "libsndfile1", | |
| "git", | |
| "wget", | |
| "curl", | |
| "libgl1-mesa-glx", | |
| "libglib2.0-0", | |
| "libsm6", | |
| "libxext6", | |
| "libxrender-dev", | |
| "libgomp1", | |
| ) | |
| .pip_install( | |
| "torch>=2.0.0", | |
| "torchvision", | |
| "torchaudio", | |
| "numpy", | |
| "soundfile", | |
| "librosa", | |
| "pyloudnorm", | |
| "einops", | |
| "transformers>=4.30.0", | |
| "huggingface-hub", | |
| "Pillow", | |
| "fastapi[standard]", | |
| "pydantic", | |
| "requests", | |
| "accelerate", | |
| "diffusers", | |
| ) | |
| .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"}) | |
| ) | |
| # Download image with additional dependencies | |
| download_image = base_image.pip_install( | |
| "huggingface_hub[hf_transfer]", | |
| "requests", | |
| "tqdm", | |
| ) | |
| # Pydantic models for API | |
| class MultiTalkRequest(BaseModel): | |
| image: str # base64 encoded image | |
| first_audio: str # base64 encoded audio | |
| prompt: str = "A smiling man and woman wearing headphones sit in front of microphones, appearing to host a podcast." | |
| second_audio: Optional[str] = None # base64 encoded audio (optional) | |
| num_frames: int = 81 | |
| sampling_steps: int = 40 | |
| seed: Optional[int] = None | |
| turbo: bool = True | |
| class MultiTalkResponse(BaseModel): | |
| video: str # base64 encoded video | |
| metadata: Dict[str, Any] | |
| class ErrorResponse(BaseModel): | |
| error: str | |
| type: str | |
| # Helper functions | |
| def loudness_norm(audio_array, sr=16000, lufs=-23): | |
| import pyloudnorm as pyln | |
| meter = pyln.Meter(sr) | |
| loudness = meter.integrated_loudness(audio_array) | |
| if abs(loudness) > 100: | |
| return audio_array | |
| normalized_audio = pyln.normalize.loudness(audio_array, loudness, lufs) | |
| return normalized_audio | |
| def extract_audio_from_video(filename, sample_rate=16000): | |
| """Extract audio from video file with robust error handling""" | |
| import librosa | |
| raw_audio_path = f"{os.path.splitext(os.path.basename(filename))[0]}.wav" | |
| ffmpeg_command = [ | |
| "ffmpeg", | |
| "-y", | |
| "-i", | |
| str(filename), | |
| "-vn", | |
| "-acodec", | |
| "pcm_s16le", | |
| "-ar", | |
| str(sample_rate), | |
| "-ac", | |
| "2", | |
| raw_audio_path, | |
| ] | |
| subprocess.run(ffmpeg_command, check=True, capture_output=True) | |
| human_speech_array, sr = librosa.load(raw_audio_path, sr=sample_rate) | |
| human_speech_array = loudness_norm(human_speech_array, sr) | |
| os.remove(raw_audio_path) | |
| return human_speech_array | |
| def audio_prepare_single(audio_path, sample_rate=16000): | |
| import librosa | |
| ext = os.path.splitext(audio_path)[1].lower() | |
| if ext in [".mp4", ".mov", ".avi", ".mkv"]: | |
| human_speech_array = extract_audio_from_video(audio_path, sample_rate) | |
| return human_speech_array | |
| else: | |
| human_speech_array, sr = librosa.load(audio_path, sr=sample_rate) | |
| human_speech_array = loudness_norm(human_speech_array, sr) | |
| return human_speech_array | |
| def audio_prepare_multi(left_path, right_path, audio_type, sample_rate=16000): | |
| import numpy as np | |
| human_speech_array1 = audio_prepare_single(left_path) | |
| human_speech_array2 = audio_prepare_single(right_path) | |
| if audio_type == "para": | |
| new_human_speech1 = human_speech_array1 | |
| new_human_speech2 = human_speech_array2 | |
| elif audio_type == "add": | |
| new_human_speech1 = np.concatenate([ | |
| human_speech_array1[:human_speech_array1.shape[0]], | |
| np.zeros(human_speech_array2.shape[0]), | |
| ]) | |
| new_human_speech2 = np.concatenate([ | |
| np.zeros(human_speech_array1.shape[0]), | |
| human_speech_array2[:human_speech_array2.shape[0]], | |
| ]) | |
| sum_human_speechs = new_human_speech1 + new_human_speech2 | |
| return new_human_speech1, new_human_speech2, sum_human_speechs | |
| def get_embedding(speech_array, wav2vec_feature_extractor, audio_encoder, sr=16000, device="cpu"): | |
| """Extract audio embeddings optimized for GPU processing""" | |
| import torch | |
| import numpy as np | |
| from einops import rearrange | |
| audio_duration = len(speech_array) / sr | |
| video_length = audio_duration * 25 # Assume the video fps is 25 | |
| # Extract audio features | |
| audio_feature = np.squeeze( | |
| wav2vec_feature_extractor(speech_array, sampling_rate=sr).input_values | |
| ) | |
| audio_feature = torch.from_numpy(audio_feature).float().to(device=device) | |
| audio_feature = audio_feature.unsqueeze(0) | |
| # Generate embeddings on appropriate device | |
| with torch.no_grad(): | |
| embeddings = audio_encoder( | |
| audio_feature, seq_len=int(video_length), output_hidden_states=True | |
| ) | |
| if len(embeddings) == 0: | |
| print("β οΈ Failed to extract audio embedding") | |
| return None | |
| audio_emb = torch.stack(embeddings.hidden_states[1:], dim=1).squeeze(0) | |
| audio_emb = rearrange(audio_emb, "b s d -> s b d") | |
| # Keep on CPU for compatibility with downstream processing | |
| audio_emb = audio_emb.cpu().detach() | |
| return audio_emb | |
| def download_with_resume(url: str, dest: str, max_retries: int = 3, chunk_size: int = 8192 * 1024) -> None: | |
| """Download file with resume capability and robust error handling.""" | |
| print(f"[!] Downloading from: {url}") | |
| print(f"[~] Destination: {dest}") | |
| # Create destination directory if it doesn't exist | |
| os.makedirs(os.path.dirname(dest), exist_ok=True) | |
| # Get initial file size if resuming | |
| initial_size = 0 | |
| if os.path.exists(dest): | |
| initial_size = os.path.getsize(dest) | |
| print(f"[~] Resuming download from {initial_size} bytes") | |
| for attempt in range(max_retries): | |
| try: | |
| headers = {} | |
| if initial_size > 0: | |
| headers["Range"] = f"bytes={initial_size}-" | |
| response = requests.get(url, headers=headers, stream=True, timeout=60) | |
| response.raise_for_status() | |
| # Get total file size | |
| total_size = initial_size | |
| if "content-length" in response.headers: | |
| total_size = initial_size + int(response.headers["content-length"]) | |
| elif "content-range" in response.headers: | |
| content_range = response.headers["content-range"] | |
| if "/" in content_range: | |
| total_size = int(content_range.split("/")[-1]) | |
| print(f"[~] Total size: {total_size / (1024**3):.2f} GB") | |
| start_time = time.time() | |
| last_progress_time = start_time | |
| mode = "ab" if initial_size > 0 else "wb" | |
| with open(dest, mode) as f: | |
| downloaded = initial_size | |
| for chunk in response.iter_content(chunk_size=chunk_size): | |
| if chunk: | |
| f.write(chunk) | |
| downloaded += len(chunk) | |
| current_time = time.time() | |
| if current_time - last_progress_time >= 10: | |
| if total_size > 0: | |
| progress = (downloaded / total_size) * 100 | |
| speed = downloaded / (current_time - start_time) / (1024**2) | |
| print(f"[~] Progress: {progress:.1f}% ({downloaded/(1024**3):.2f}/{total_size/(1024**3):.2f} GB) - Speed: {speed:.1f} MB/s") | |
| last_progress_time = current_time | |
| final_size = os.path.getsize(dest) | |
| if total_size > 0 and final_size < total_size: | |
| print(f"[!] Download incomplete: {final_size}/{total_size} bytes") | |
| initial_size = final_size | |
| continue | |
| elapsed_time = time.time() - start_time | |
| print(f"[+] Download completed in {elapsed_time:.2f} seconds") | |
| return | |
| except requests.exceptions.RequestException as e: | |
| print(f"[!] Download attempt {attempt + 1} failed: {str(e)}") | |
| if attempt < max_retries - 1: | |
| print(f"[~] Retrying in 5 seconds...") | |
| time.sleep(5) | |
| initial_size = os.path.getsize(dest) if os.path.exists(dest) else 0 | |
| else: | |
| print(f"[ERROR] Failed to download after {max_retries} attempts") | |
| raise | |
| def extract_tar_file(tar_path: str, extract_dir: str) -> None: | |
| """Extract tar file with error handling""" | |
| print(f"[~] Extracting {tar_path} to {extract_dir}") | |
| try: | |
| import tarfile | |
| with tarfile.open(tar_path, "r") as tar: | |
| tar.extractall(path=extract_dir) | |
| print(f"[+] Extraction completed") | |
| os.remove(tar_path) | |
| print(f"[+] Cleaned up {tar_path}") | |
| except Exception as e: | |
| print(f"[ERROR] Failed to extract {tar_path}: {str(e)}") | |
| raise | |
| def download_weights(url: str, dest: str) -> None: | |
| """Download and extract model weights with robust error handling.""" | |
| start_time = time.time() | |
| try: | |
| is_tar = dest.endswith(".tar") | |
| extract_dir = dest.replace(".tar", "") if is_tar else dest | |
| if is_tar and os.path.exists(extract_dir) and os.path.isdir(extract_dir): | |
| print(f"[~] Model already exists at {extract_dir}, skipping download") | |
| return | |
| download_with_resume(url, dest) | |
| if is_tar: | |
| extract_tar_file(dest, os.path.dirname(dest)) | |
| total_time = time.time() - start_time | |
| print(f"[+] Model setup completed in {total_time:.2f} seconds") | |
| except Exception as e: | |
| print(f"[ERROR] Failed to download weights from {url}: {str(e)}") | |
| if os.path.exists(dest): | |
| os.remove(dest) | |
| raise | |
| def decode_base64_file(base64_string: str, output_path: str) -> str: | |
| """Decode base64 string and save to file""" | |
| try: | |
| if "," in base64_string: | |
| base64_string = base64_string.split(",")[1] | |
| with open(output_path, "wb") as f: | |
| f.write(base64.b64decode(base64_string)) | |
| return output_path | |
| except Exception as e: | |
| raise ValueError(f"Failed to decode base64 file: {str(e)}") | |
| def encode_file_to_base64(file_path: str) -> str: | |
| """Encode file to base64 string""" | |
| try: | |
| with open(file_path, "rb") as f: | |
| return base64.b64encode(f.read()).decode("utf-8") | |
| except Exception as e: | |
| raise ValueError(f"Failed to encode file to base64: {str(e)}") | |
| # Download models function | |
| def download_models(): | |
| """Download all required MultiTalk models""" | |
| MODEL_CACHE = MODEL_DIR / "weights" | |
| BASE_URL = f"https://weights.replicate.delivery/default/multitalk/{MODEL_CACHE.name}/" | |
| # Create model cache directory | |
| MODEL_CACHE.mkdir(parents=True, exist_ok=True) | |
| # Download model weights | |
| model_files = [ | |
| "chinese-wav2vec2-base.tar", | |
| "MeiGen-MultiTalk.tar", | |
| "Wan2.1-I2V-14B-480P.tar", | |
| ] | |
| print("[~] Starting model downloads...") | |
| for model_file in model_files: | |
| url = BASE_URL + model_file | |
| dest_path = MODEL_CACHE / model_file | |
| download_weights(url, str(dest_path)) | |
| # Commit changes to volume | |
| volume.commit() | |
| print("β All models downloaded and committed to volume!") | |
| # Inference class | |
| class MultiTalkInference: | |
| def __init__(self): | |
| """Initialize the MultiTalk inference handler""" | |
| import torch | |
| from transformers import Wav2Vec2FeatureExtractor | |
| # Note: You'll need to add proper imports for MultiTalk components | |
| # import wan | |
| # from wan.configs import WAN_CONFIGS | |
| # from src.audio_analysis.wav2vec2 import Wav2Vec2Model | |
| # from wan.utils.multitalk_utils import save_video_ffmpeg | |
| # Model paths | |
| self.model_cache = MODEL_DIR / "weights" | |
| self.ckpt_dir = self.model_cache / "Wan2.1-I2V-14B-480P" | |
| self.wav2vec_dir = self.model_cache / "chinese-wav2vec2-base" | |
| self.multitalk_dir = self.model_cache / "MeiGen-MultiTalk" | |
| # Initialize device | |
| self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") | |
| print(f"Using device: {self.device}") | |
| # Load wav2vec models | |
| print("Loading wav2vec models...") | |
| audio_device = ( | |
| self.device | |
| if torch.cuda.is_available() | |
| and torch.cuda.get_device_properties(0).total_memory > 40 * 1024**3 | |
| else "cpu" | |
| ) | |
| print(f"Loading audio encoder on: {audio_device}") | |
| # Note: Replace with actual imports when available | |
| # self.audio_encoder = Wav2Vec2Model.from_pretrained( | |
| # str(self.wav2vec_dir), local_files_only=True, attn_implementation="eager" | |
| # ).to(audio_device) | |
| # self.audio_encoder.feature_extractor._freeze_parameters() | |
| # self.wav2vec_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained( | |
| # str(self.wav2vec_dir), local_files_only=True | |
| # ) | |
| self.audio_device = audio_device | |
| # Load MultiTalk pipeline | |
| print("Loading MultiTalk pipeline...") | |
| # self.cfg = WAN_CONFIGS["multitalk-14B"] | |
| # self.wan_i2v = wan.MultiTalkPipeline( | |
| # config=self.cfg, | |
| # checkpoint_dir=str(self.ckpt_dir), | |
| # device_id=0, | |
| # rank=0, | |
| # t5_fsdp=False, | |
| # dit_fsdp=False, | |
| # use_usp=False, | |
| # t5_cpu=False, | |
| # ) | |
| # GPU optimizations | |
| if torch.cuda.is_available(): | |
| vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3) | |
| print(f"π Detected {vram_gb:.1f}GB VRAM") | |
| if vram_gb > 40: | |
| print("π High-VRAM detected: Enabling maximum performance optimizations") | |
| torch.backends.cuda.enable_flash_sdp(True) | |
| torch.backends.cudnn.benchmark = True | |
| torch.backends.cuda.matmul.allow_tf32 = True | |
| torch.backends.cudnn.allow_tf32 = True | |
| torch.cuda.empty_cache() | |
| print("β‘ Enabled Flash-SDP, TF32, and cuDNN benchmarking") | |
| else: | |
| print("π§ Standard GPU optimizations enabled") | |
| torch.backends.cuda.enable_flash_sdp(True) | |
| torch.cuda.empty_cache() | |
| print("β Model setup completed successfully!") | |
| def generate_video(self, request: MultiTalkRequest) -> MultiTalkResponse: | |
| """Generate video from the request""" | |
| import torch | |
| import numpy as np | |
| import random | |
| import soundfile as sf | |
| try: | |
| # Validate parameters | |
| if not isinstance(request.num_frames, int) or request.num_frames < 25 or request.num_frames > 201: | |
| raise ValueError("num_frames must be an integer between 25 and 201") | |
| if not isinstance(request.sampling_steps, int) or request.sampling_steps < 2 or request.sampling_steps > 100: | |
| raise ValueError("sampling_steps must be an integer between 2 and 100") | |
| # Auto-correct frame count | |
| num_frames = request.num_frames | |
| original_frames = num_frames | |
| if (num_frames - 1) % 4 != 0: | |
| n_lower = (num_frames - 1) // 4 | |
| n_upper = n_lower + 1 | |
| frames_lower = 4 * n_lower + 1 | |
| frames_upper = 4 * n_upper + 1 | |
| if abs(num_frames - frames_lower) <= abs(num_frames - frames_upper): | |
| num_frames = frames_lower | |
| else: | |
| num_frames = frames_upper | |
| num_frames = max(25, min(num_frames, 201)) | |
| while (num_frames - 1) % 4 != 0 and num_frames <= 201: | |
| num_frames += 1 | |
| print(f"π Auto-corrected num_frames from {original_frames} to {num_frames}") | |
| # Set seed | |
| if request.seed is None: | |
| seed = random.randint(0, 99999999) | |
| else: | |
| seed = request.seed | |
| print(f"π¬ Generating video with seed: {seed}") | |
| with tempfile.TemporaryDirectory() as temp_dir: | |
| # Save input files | |
| image_path = os.path.join(temp_dir, "input_image.jpg") | |
| first_audio_path = os.path.join(temp_dir, "first_audio.wav") | |
| decode_base64_file(request.image, image_path) | |
| decode_base64_file(request.first_audio, first_audio_path) | |
| second_audio_path = None | |
| if request.second_audio: | |
| second_audio_path = os.path.join(temp_dir, "second_audio.wav") | |
| decode_base64_file(request.second_audio, second_audio_path) | |
| is_multi_person = second_audio_path is not None | |
| audio_save_dir = os.path.join(temp_dir, "audio_embeddings") | |
| os.makedirs(audio_save_dir, exist_ok=True) | |
| # Process audio and generate embeddings | |
| if is_multi_person: | |
| print("π€ Processing multi-person audio...") | |
| audio_type = "add" | |
| speech1, speech2, combined_speech = audio_prepare_multi( | |
| first_audio_path, second_audio_path, audio_type | |
| ) | |
| # Generate embeddings | |
| embedding1 = get_embedding( | |
| speech1, self.wav2vec_feature_extractor, self.audio_encoder, | |
| device=self.audio_device | |
| ) | |
| embedding2 = get_embedding( | |
| speech2, self.wav2vec_feature_extractor, self.audio_encoder, | |
| device=self.audio_device | |
| ) | |
| # Save embeddings and audio | |
| emb1_path = os.path.join(audio_save_dir, "1.pt") | |
| emb2_path = os.path.join(audio_save_dir, "2.pt") | |
| sum_audio_path = os.path.join(audio_save_dir, "sum.wav") | |
| torch.save(embedding1, emb1_path) | |
| torch.save(embedding2, emb2_path) | |
| sf.write(sum_audio_path, combined_speech, 16000) | |
| input_data = { | |
| "prompt": request.prompt, | |
| "cond_image": image_path, | |
| "audio_type": audio_type, | |
| "cond_audio": {"person1": emb1_path, "person2": emb2_path}, | |
| "video_audio": sum_audio_path, | |
| } | |
| else: | |
| print("π€ Processing single-person audio...") | |
| speech = audio_prepare_single(first_audio_path) | |
| embedding = get_embedding( | |
| speech, self.wav2vec_feature_extractor, self.audio_encoder, | |
| device=self.audio_device | |
| ) | |
| emb_path = os.path.join(audio_save_dir, "1.pt") | |
| sum_audio_path = os.path.join(audio_save_dir, "sum.wav") | |
| torch.save(embedding, emb_path) | |
| sf.write(sum_audio_path, speech, 16000) | |
| input_data = { | |
| "prompt": request.prompt, | |
| "cond_image": image_path, | |
| "cond_audio": {"person1": emb_path}, | |
| "video_audio": sum_audio_path, | |
| } | |
| print("π¬ Generating video...") | |
| # Configure generation parameters | |
| high_vram = ( | |
| torch.cuda.is_available() | |
| and torch.cuda.get_device_properties(0).total_memory > 40 * 1024**3 | |
| ) | |
| if request.turbo: | |
| teacache_thresh = 0.8 | |
| text_guide_scale = 3.0 | |
| audio_guide_scale = 3.0 | |
| shift = 5.0 | |
| offload_model = False | |
| else: | |
| teacache_thresh = 0.3 | |
| text_guide_scale = 5.0 | |
| audio_guide_scale = 4.0 | |
| shift = 7.0 | |
| offload_model = not high_vram | |
| extra_args = SimpleNamespace( | |
| use_teacache=True, | |
| teacache_thresh=teacache_thresh, | |
| use_apg=False, | |
| size="multitalk-480", | |
| ) | |
| # Generate video (replace with actual generation call) | |
| # video = self.wan_i2v.generate( | |
| # input_data, | |
| # size_buckget="multitalk-480", | |
| # motion_frame=25, | |
| # frame_num=num_frames, | |
| # shift=shift, | |
| # sampling_steps=request.sampling_steps, | |
| # text_guide_scale=text_guide_scale, | |
| # audio_guide_scale=audio_guide_scale, | |
| # seed=seed, | |
| # offload_model=offload_model, | |
| # max_frames_num=num_frames, | |
| # extra_args=extra_args, | |
| # ) | |
| # Save video (replace with actual save call) | |
| output_name = f"multitalk_{abs(hash(request.prompt + str(seed))) % 10000}" | |
| # save_video_ffmpeg(video, output_name, [input_data["video_audio"]]) | |
| # For now, create a dummy video file | |
| output_file = f"{output_name}.mp4" | |
| with open(output_file, "wb") as f: | |
| f.write(b"dummy video content") | |
| # Encode video to base64 | |
| video_b64 = encode_file_to_base64(output_file) | |
| # Cleanup | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| print("β Video generation completed successfully!") | |
| return MultiTalkResponse( | |
| video=video_b64, | |
| metadata={ | |
| "seed": seed, | |
| "num_frames": num_frames, | |
| "sampling_steps": request.sampling_steps, | |
| "turbo": request.turbo, | |
| "is_multi_person": is_multi_person, | |
| "prompt": request.prompt, | |
| "original_frames_requested": original_frames, | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"β Error during inference: {str(e)}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # Inference endpoint | |
| def generate_video(request: MultiTalkRequest) -> Union[MultiTalkResponse, ErrorResponse]: | |
| """Main inference endpoint""" | |
| try: | |
| # Initialize inference handler | |
| handler = MultiTalkInference() | |
| # Generate video | |
| response = handler.generate_video(request) | |
| return response | |
| except Exception as e: | |
| print(f"β Endpoint error: {str(e)}") | |
| return ErrorResponse(error=str(e), type=type(e).__name__) | |