"""Wav2Lip Free API v3 - using official Rudrabha model code. Simplest possible wrapper around the proven Wav2Lip inference pipeline. """ import os, shutil, subprocess, sys, warnings from pathlib import Path warnings.filterwarnings("ignore") sys.path.insert(0, str(Path(__file__).parent)) WORK = Path("/data/wav2lip_app") HF_TOKEN = os.environ.get("HF_TOKEN") or None def ensure_setup(): from huggingface_hub import hf_hub_download code_dir = WORK / "Wav2Lip" code_dir.mkdir(parents=True, exist_ok=True) target = code_dir / "wav2lip.pth" if target.exists() and target.stat().st_size > 100_000_000: print(f"[setup] cached: {target} ({target.stat().st_size//1024//1024}MB)") else: print("[setup] downloading wav2lip.pth from Nekochu/Wav2Lip (~436MB, first run only)...") d = hf_hub_download(repo_id="Nekochu/Wav2Lip", filename="wav2lip.pth", local_dir=str(code_dir), token=HF_TOKEN) sp = Path(d) if str(sp) != str(target): shutil.copy2(sp, target) print(f" [setup] downloaded: {target}") return code_dir def detect_face_mediapipe(image): import mediapipe as mp import numpy as np mp_face = mp.solutions.face_detection fd = mp_face.FaceDetection(min_detection_confidence=0.5) res = fd.process(image) if not res.detections: return None d = res.detections[0].location_data.relative_bounding_box h, w = image.shape[:2] x1 = max(0, int(d.xmin * w)) y1 = max(0, int(d.ymin * h)) x2 = min(w, int((d.xmin + d.width) * w)) y2 = min(h, int((d.ymin + d.height) * h)) return (x1, y1, x2, y2) def load_audio_mel(audio_path): """Generate mel spectrogram chunks matching Wav2Lip requirements.""" import librosa import numpy as np wav, _ = librosa.load(audio_path, sr=16000) wav = np.concatenate([np.zeros(6400), wav]) mel = librosa.feature.melspectrogram(y=wav, sr=16000, n_fft=800, win_length=800, hop_length=200, n_mels=80) mel = 20 * np.log10(np.maximum(1e-5, mel)) mel = np.maximum(mel, mel.max() - 8) mel = (mel + 4) / 4 mel_chunks = [] i = 0 while i + 16 <= mel.shape[1]: mel_chunks.append(mel[:, i:i+16]) i += 5 if not mel_chunks: mel_chunks = [np.pad(mel, ((0,0),(0,16-mel.shape[1])), mode='constant')] return np.array(mel_chunks) def run_inference(code_dir, face_path, audio_path, output_path): import cv2, numpy as np, torch, mediapipe as mp sys.path.insert(0, str(code_dir)) # 修复:模型在 app.py 同目录的 models/ 下,不在 code_dir app_dir = Path(__file__).parent if str(app_dir) not in sys.path: sys.path.insert(0, str(app_dir)) from models import Wav2Lip from models import Wav2Lip as Wav2Lip_class # Load model (cached) global _MODEL if _MODEL is None: print("[infer] loading model (first call)...") _MODEL = Wav2Lip_class() ckpt = torch.load(code_dir / "wav2lip.pth", map_location="cpu", weights_only=False) sd = ckpt.get("state_dict", ckpt) # Remove DataParallel 'module.' prefix if present sd = {k.replace("module.", ""): v for k, v in sd.items()} _MODEL.load_state_dict(sd, strict=False) _MODEL.eval() print("[infer] model loaded") # Read face if face_path.lower().endswith((".png", ".jpg", ".jpeg")): img = cv2.imread(face_path) mel_chunks = load_audio_mel(audio_path) n_frames = max(125, len(mel_chunks) + 5) frames = [img] * n_frames fps = 25 else: cap = cv2.VideoCapture(face_path) fps = cap.get(cv2.CAP_PROP_FPS) or 25 frames = [] while True: ret, fr = cap.read() if not ret: break frames.append(fr) cap.release() mel_chunks = load_audio_mel(audio_path) # Limit frames to mel chunks + 5 max_frames = len(mel_chunks) + 5 if len(frames) > max_frames: frames = frames[:max_frames] while len(frames) < len(mel_chunks) + 5: frames.append(frames[-1]) # Detect face in first frame box = detect_face_mediapipe(frames[0]) if box is None: raise RuntimeError("No face detected in input") x1, y1, x2, y2 = box y2 = min(frames[0].shape[0], y2 + int((y2 - y1) * 0.2)) IMG_SIZE = 96 out_frames = [] BATCH = 4 for i in range(0, len(mel_chunks), BATCH): batch_end = min(i + BATCH, len(mel_chunks)) actual_batch = batch_end - i if actual_batch <= 0: break # Get mel batch (always BATCH size for tensor consistency) if actual_batch < BATCH: mel_batch = np.zeros((BATCH, 80, 16), dtype=np.float32) mel_batch[:actual_batch] = mel_chunks[i:batch_end] else: mel_batch = mel_chunks[i:batch_end] # Get face batch (RGB float, 0-1) face_batch = [] for j in range(BATCH): fi = i + j if fi >= len(frames): face = frames[-1] else: face = frames[fi] face_crop = face[y1:y2, x1:x2] if face_crop.size == 0: face_crop = frames[0][y1:y2, x1:x2] face_resized = cv2.resize(face_crop, (IMG_SIZE, IMG_SIZE)) face_resized = face_resized.astype(np.float32) / 255.0 face_resized = face_resized[..., ::-1].copy() # BGR->RGB face_batch.append(face_resized) face_batch = np.array(face_batch) # (BATCH, 96, 96, 3) # Wav2Lip expects 6 channels: [face with bottom-half masked, face] img_masked = face_batch.copy() img_masked[:, IMG_SIZE//2:] = 0 # zero bottom half (where mouth is) face_batch_6ch = np.concatenate((img_masked, face_batch), axis=3) # (BATCH, 96, 96, 6) face_t = torch.from_numpy(face_batch_6ch).float().permute(0, 3, 1, 2) mel_t = torch.from_numpy(mel_batch).float().unsqueeze(1) with torch.no_grad(): pred = _MODEL(mel_t, face_t) pred = pred.cpu().numpy().transpose(0, 2, 3, 1) pred = np.clip(pred * 255, 0, 255).astype(np.uint8) pred = pred[..., ::-1].copy() # RGB->BGR for j in range(actual_batch): fi = i + j out = frames[fi].copy() ph, pw = pred[j].shape[:2] try: if (y2 - y1) != ph or (x2 - x1) != pw: p_resized = cv2.resize(pred[j], (x2 - x1, y2 - y1)) else: p_resized = pred[j] out[y1:y2, x1:x2] = p_resized out_frames.append(out) except Exception: out_frames.append(frames[fi].copy()) print(f"[infer] generated {len(out_frames)} frames") # Write video h, w = out_frames[0].shape[:2] tmp_out = "/tmp/wav2lip_out.mp4" fourcc = cv2.VideoWriter_fourcc(*"mp4v") vw = cv2.VideoWriter(tmp_out, fourcc, 25, (w, h)) for f in out_frames: vw.write(f) vw.release() # Mux audio subprocess.run([ "ffmpeg", "-y", "-i", tmp_out, "-i", audio_path, "-c:v", "libx264", "-c:a", "aac", "-shortest", output_path ], capture_output=True) if os.path.exists(tmp_out): os.remove(tmp_out) print(f"[infer] done: {output_path}") _MODEL = None # Initialize at startup print("[init] downloading weights...") CODE_DIR = ensure_setup() print(f"[init] ready") from fastapi import FastAPI, UploadFile, File, HTTPException from fastapi.responses import FileResponse import uvicorn app = FastAPI(title="Wav2Lip Free API v3", version="3.0") @app.get("/") def root(): return { "name": "Wav2Lip Free API v3", "license": "Non-commercial only (LRS2 dataset)", "endpoints": {"GET /health": "health check", "POST /lipsync": "multipart face+audio -> MP4"}, "speed": "~30-90s per 1s of input video (CPU free tier)", } @app.get("/health") def health(): return {"status": "ok"} @app.post("/lipsync") async def lipsync(face: UploadFile = File(...), audio: UploadFile = File(...)): face_path = f"/tmp/in_face_{os.getpid()}.png" audio_path = f"/tmp/in_audio_{os.getpid()}.mp3" output_path = "/tmp/wav2lip_result.mp4" for p in [face_path, audio_path, output_path]: if os.path.exists(p): os.remove(p) with open(face_path, "wb") as f: f.write(await face.read()) with open(audio_path, "wb") as f: f.write(await audio.read()) try: run_inference(CODE_DIR, face_path, audio_path, output_path) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: for p in [face_path, audio_path]: if os.path.exists(p): os.remove(p) if not os.path.exists(output_path): raise HTTPException(status_code=500, detail="No output produced") return FileResponse(output_path, media_type="video/mp4", filename="lipsync.mp4") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=7860)