mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
82181fb177
- Add constraints file to enforce numpy<2 across all pip installs - Patch numpy 2.0 deprecated APIs in SadTalker and dependencies - Fix trans_params ValueError with dtype=object - Fix output video detection (check direct files, not just subdirs) - Add verbose error logging to handler - Update PROGRESS.md with testing results and resume instructions Tested with short (15s) and long (2.3min) audio - chunking works. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
507 lines
16 KiB
Python
507 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
RunPod serverless handler for SadTalker.
|
|
Generates talking head videos from image + audio.
|
|
|
|
Input format:
|
|
{
|
|
"input": {
|
|
# Required - one of:
|
|
"image_url": str, # URL to download image
|
|
"image_base64": str, # Base64 encoded image
|
|
|
|
# Required - one of:
|
|
"audio_url": str, # URL to download audio
|
|
"audio_base64": str, # Base64 encoded audio
|
|
|
|
# Options
|
|
"still_mode": bool, # Less head movement (default: false)
|
|
"enhancer": str, # "gfpgan" (default) or "none"
|
|
"preprocess": str, # "crop", "resize", "full" (default: "crop")
|
|
"size": int, # Output resolution: 256 or 512 (default: 256)
|
|
"expression_scale": float, # Expression intensity (default: 1.0)
|
|
"pose_style": int, # Pose variation (0-45, default: 0)
|
|
|
|
# R2 config for result upload
|
|
"r2": {
|
|
"endpoint_url": str,
|
|
"access_key_id": str,
|
|
"secret_access_key": str,
|
|
"bucket_name": str
|
|
}
|
|
}
|
|
}
|
|
|
|
Output format:
|
|
{
|
|
"success": true,
|
|
"video_url": str, # Presigned R2 URL (if r2 config provided)
|
|
"video_base64": str, # Base64 encoded video (if no R2)
|
|
"r2_key": str, # R2 object key
|
|
"duration_seconds": float,
|
|
"chunks_processed": int,
|
|
"processing_time_seconds": float
|
|
}
|
|
|
|
Chunking:
|
|
- Audio >45s is split into chunks to prevent drift
|
|
- Each chunk processed independently
|
|
- Results concatenated with ffmpeg
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import runpod
|
|
import requests
|
|
|
|
# SadTalker paths
|
|
SADTALKER_DIR = Path("/app/SadTalker")
|
|
CHECKPOINT_DIR = SADTALKER_DIR / "checkpoints"
|
|
|
|
# Chunk size in seconds (to prevent drift)
|
|
CHUNK_DURATION = 45
|
|
|
|
|
|
def log(message: str) -> None:
|
|
"""Log message to stderr (visible in RunPod logs)."""
|
|
print(message, file=sys.stderr, flush=True)
|
|
|
|
|
|
def download_file(url: str, output_path: Path, timeout: int = 300) -> bool:
|
|
"""Download file from URL to local path."""
|
|
try:
|
|
log(f"Downloading from {url[:80]}...")
|
|
response = requests.get(url, stream=True, timeout=timeout)
|
|
response.raise_for_status()
|
|
|
|
with open(output_path, "wb") as f:
|
|
for chunk in response.iter_content(chunk_size=8192):
|
|
f.write(chunk)
|
|
|
|
log(f" Downloaded: {output_path.name} ({output_path.stat().st_size // 1024}KB)")
|
|
return True
|
|
except Exception as e:
|
|
log(f"Download error: {e}")
|
|
return False
|
|
|
|
|
|
def decode_base64_file(data: str, output_path: Path) -> bool:
|
|
"""Decode base64 data and write to file."""
|
|
try:
|
|
# Handle data URI prefix if present
|
|
if "," in data:
|
|
data = data.split(",", 1)[1]
|
|
|
|
decoded = base64.b64decode(data)
|
|
output_path.write_bytes(decoded)
|
|
log(f"Decoded base64 to {output_path.name} ({len(decoded) // 1024}KB)")
|
|
return True
|
|
except Exception as e:
|
|
log(f"Base64 decode error: {e}")
|
|
return False
|
|
|
|
|
|
def encode_file_base64(file_path: Path) -> str:
|
|
"""Encode file to base64 string."""
|
|
return base64.b64encode(file_path.read_bytes()).decode("utf-8")
|
|
|
|
|
|
def get_audio_duration(audio_path: Path) -> float:
|
|
"""Get audio duration in seconds using ffprobe."""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
str(audio_path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
return float(result.stdout.strip())
|
|
except Exception as e:
|
|
log(f"Error getting audio duration: {e}")
|
|
return 0.0
|
|
|
|
|
|
def split_audio_chunks(audio_path: Path, work_dir: Path, chunk_duration: int = CHUNK_DURATION) -> list[Path]:
|
|
"""Split audio into chunks to prevent drift."""
|
|
duration = get_audio_duration(audio_path)
|
|
log(f"Audio duration: {duration:.1f}s")
|
|
|
|
if duration <= chunk_duration:
|
|
return [audio_path]
|
|
|
|
chunks = []
|
|
start = 0.0
|
|
chunk_idx = 0
|
|
|
|
while start < duration:
|
|
chunk_path = work_dir / f"audio_chunk_{chunk_idx:03d}.wav"
|
|
end = min(start + chunk_duration, duration)
|
|
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y",
|
|
"-i", str(audio_path),
|
|
"-ss", str(start),
|
|
"-t", str(end - start),
|
|
"-c:a", "pcm_s16le",
|
|
str(chunk_path),
|
|
],
|
|
capture_output=True,
|
|
timeout=60,
|
|
)
|
|
|
|
if chunk_path.exists():
|
|
chunks.append(chunk_path)
|
|
log(f" Chunk {chunk_idx}: {start:.1f}s - {end:.1f}s")
|
|
|
|
start = end
|
|
chunk_idx += 1
|
|
|
|
log(f"Split into {len(chunks)} chunks")
|
|
return chunks
|
|
|
|
|
|
# Global to store last error for better reporting
|
|
_last_sadtalker_error = None
|
|
|
|
|
|
def run_sadtalker(
|
|
image_path: Path,
|
|
audio_path: Path,
|
|
output_dir: Path,
|
|
still_mode: bool = False,
|
|
enhancer: str = "gfpgan",
|
|
preprocess: str = "crop",
|
|
size: int = 256,
|
|
expression_scale: float = 1.0,
|
|
pose_style: int = 0,
|
|
) -> Optional[Path]:
|
|
"""Run SadTalker inference on a single image/audio pair."""
|
|
global _last_sadtalker_error
|
|
_last_sadtalker_error = None
|
|
|
|
# Verify checkpoints exist
|
|
if not CHECKPOINT_DIR.exists():
|
|
_last_sadtalker_error = f"Checkpoint dir not found: {CHECKPOINT_DIR}"
|
|
log(_last_sadtalker_error)
|
|
return None
|
|
|
|
bfm_dir = CHECKPOINT_DIR / "BFM_Fitting"
|
|
if not bfm_dir.exists():
|
|
_last_sadtalker_error = f"BFM_Fitting dir not found: {bfm_dir}"
|
|
log(_last_sadtalker_error)
|
|
return None
|
|
|
|
cmd = [
|
|
"python", str(SADTALKER_DIR / "inference.py"),
|
|
"--driven_audio", str(audio_path),
|
|
"--source_image", str(image_path),
|
|
"--result_dir", str(output_dir),
|
|
"--checkpoint_dir", str(CHECKPOINT_DIR),
|
|
"--size", str(size),
|
|
"--expression_scale", str(expression_scale),
|
|
"--pose_style", str(pose_style),
|
|
"--preprocess", preprocess,
|
|
]
|
|
|
|
if still_mode:
|
|
cmd.append("--still")
|
|
|
|
if enhancer != "none":
|
|
cmd.extend(["--enhancer", enhancer])
|
|
|
|
log(f"Running SadTalker: {' '.join(cmd[-6:])}")
|
|
log(f" Image: {image_path} exists={image_path.exists()}")
|
|
log(f" Audio: {audio_path} exists={audio_path.exists()}")
|
|
log(f" Checkpoints: {CHECKPOINT_DIR} contents={list(CHECKPOINT_DIR.iterdir())[:5]}")
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=str(SADTALKER_DIR),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600, # 10 min timeout per chunk
|
|
)
|
|
|
|
# Log stdout/stderr regardless of return code
|
|
if result.stdout:
|
|
log(f"SadTalker stdout: {result.stdout[-500:]}")
|
|
if result.stderr:
|
|
log(f"SadTalker stderr: {result.stderr[-1000:]}")
|
|
|
|
if result.returncode != 0:
|
|
_last_sadtalker_error = f"Exit code {result.returncode}: {result.stderr[-500:]}"
|
|
log(f"SadTalker failed: {_last_sadtalker_error}")
|
|
return None
|
|
|
|
# Debug: List all files in output directory
|
|
log(f"Output dir contents: {list(output_dir.iterdir())}")
|
|
for item in output_dir.iterdir():
|
|
log(f" Item: {item.name} (is_dir={item.is_dir()}, is_file={item.is_file()})")
|
|
if item.is_dir():
|
|
log(f" Subdir contents: {list(item.iterdir())}")
|
|
|
|
# Find output video - check both direct files and subdirectories
|
|
# SadTalker outputs like: output_000/2026_01_07_00.17.22.mp4 (direct)
|
|
# or: output_000/2026_01_07_00.17.22/input_image##input_audio_enhanced.mp4 (subdir)
|
|
|
|
# First check for mp4 directly in output_dir
|
|
for f in output_dir.glob("*.mp4"):
|
|
log(f"Found video (direct): {f}")
|
|
return f
|
|
|
|
# Then check subdirectories
|
|
for subdir in output_dir.iterdir():
|
|
if subdir.is_dir():
|
|
for f in subdir.glob("*.mp4"):
|
|
log(f"Found video (subdir): {f}")
|
|
return f
|
|
|
|
_last_sadtalker_error = f"No output video found. All files: {list(output_dir.rglob('*'))}"
|
|
log(_last_sadtalker_error)
|
|
return None
|
|
|
|
except subprocess.TimeoutExpired:
|
|
_last_sadtalker_error = "SadTalker timed out after 600s"
|
|
log(_last_sadtalker_error)
|
|
return None
|
|
except Exception as e:
|
|
_last_sadtalker_error = f"Exception: {e}"
|
|
log(f"SadTalker exception: {e}")
|
|
return None
|
|
|
|
|
|
def concatenate_videos(video_paths: list[Path], output_path: Path) -> bool:
|
|
"""Concatenate multiple videos using ffmpeg."""
|
|
if len(video_paths) == 1:
|
|
shutil.copy(video_paths[0], output_path)
|
|
return True
|
|
|
|
# Create concat file
|
|
concat_file = output_path.parent / "concat.txt"
|
|
with open(concat_file, "w") as f:
|
|
for vp in video_paths:
|
|
f.write(f"file '{vp}'\n")
|
|
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y",
|
|
"-f", "concat",
|
|
"-safe", "0",
|
|
"-i", str(concat_file),
|
|
"-c", "copy",
|
|
str(output_path),
|
|
],
|
|
capture_output=True,
|
|
timeout=120,
|
|
check=True,
|
|
)
|
|
return output_path.exists()
|
|
except Exception as e:
|
|
log(f"Concatenation error: {e}")
|
|
return False
|
|
|
|
|
|
def upload_to_r2(file_path: Path, job_id: str, r2_config: dict) -> tuple[Optional[str], Optional[str]]:
|
|
"""Upload video to Cloudflare R2 and return (presigned_url, object_key)."""
|
|
try:
|
|
import boto3
|
|
from botocore.config import Config
|
|
|
|
log("Uploading to R2...")
|
|
|
|
client = boto3.client(
|
|
"s3",
|
|
endpoint_url=r2_config["endpoint_url"],
|
|
aws_access_key_id=r2_config["access_key_id"],
|
|
aws_secret_access_key=r2_config["secret_access_key"],
|
|
config=Config(signature_version="s3v4"),
|
|
)
|
|
|
|
object_key = f"sadtalker/results/{job_id}_{uuid.uuid4().hex[:8]}.mp4"
|
|
|
|
client.upload_file(
|
|
str(file_path),
|
|
r2_config["bucket_name"],
|
|
object_key,
|
|
ExtraArgs={"ContentType": "video/mp4"},
|
|
)
|
|
|
|
presigned_url = client.generate_presigned_url(
|
|
"get_object",
|
|
Params={"Bucket": r2_config["bucket_name"], "Key": object_key},
|
|
ExpiresIn=7200,
|
|
)
|
|
|
|
log(f" R2 upload complete: {object_key}")
|
|
return presigned_url, object_key
|
|
except Exception as e:
|
|
log(f"Error uploading to R2: {e}")
|
|
return None, None
|
|
|
|
|
|
def handler(job: dict) -> dict:
|
|
"""Main RunPod handler for SadTalker."""
|
|
job_id = job.get("id", "unknown")
|
|
job_input = job.get("input", {})
|
|
start_time = time.time()
|
|
|
|
log(f"Job {job_id}: Starting SadTalker")
|
|
|
|
# Create temp working directory
|
|
work_dir = Path(tempfile.mkdtemp(prefix=f"sadtalker_{job_id}_"))
|
|
log(f"Working directory: {work_dir}")
|
|
|
|
try:
|
|
# Get image
|
|
image_path = work_dir / "input_image.png"
|
|
if job_input.get("image_url"):
|
|
if not download_file(job_input["image_url"], image_path):
|
|
return {"error": "Failed to download image"}
|
|
elif job_input.get("image_base64"):
|
|
if not decode_base64_file(job_input["image_base64"], image_path):
|
|
return {"error": "Failed to decode image"}
|
|
else:
|
|
return {"error": "Missing image_url or image_base64"}
|
|
|
|
# Get audio
|
|
audio_path = work_dir / "input_audio.wav"
|
|
if job_input.get("audio_url"):
|
|
if not download_file(job_input["audio_url"], audio_path):
|
|
return {"error": "Failed to download audio"}
|
|
elif job_input.get("audio_base64"):
|
|
if not decode_base64_file(job_input["audio_base64"], audio_path):
|
|
return {"error": "Failed to decode audio"}
|
|
else:
|
|
return {"error": "Missing audio_url or audio_base64"}
|
|
|
|
# Options
|
|
still_mode = job_input.get("still_mode", False)
|
|
enhancer = job_input.get("enhancer", "gfpgan")
|
|
preprocess = job_input.get("preprocess", "crop")
|
|
size = job_input.get("size", 256)
|
|
expression_scale = job_input.get("expression_scale", 1.0)
|
|
pose_style = job_input.get("pose_style", 0)
|
|
r2_config = job_input.get("r2")
|
|
|
|
# Split audio into chunks if needed
|
|
audio_chunks = split_audio_chunks(audio_path, work_dir)
|
|
total_duration = get_audio_duration(audio_path)
|
|
|
|
# Process each chunk
|
|
video_chunks = []
|
|
for i, chunk_path in enumerate(audio_chunks):
|
|
log(f"Processing chunk {i + 1}/{len(audio_chunks)}...")
|
|
chunk_output_dir = work_dir / f"output_{i:03d}"
|
|
chunk_output_dir.mkdir()
|
|
|
|
video_path = run_sadtalker(
|
|
image_path=image_path,
|
|
audio_path=chunk_path,
|
|
output_dir=chunk_output_dir,
|
|
still_mode=still_mode,
|
|
enhancer=enhancer,
|
|
preprocess=preprocess,
|
|
size=size,
|
|
expression_scale=expression_scale,
|
|
pose_style=pose_style,
|
|
)
|
|
|
|
if video_path:
|
|
video_chunks.append(video_path)
|
|
log(f" Chunk {i + 1} complete: {video_path.name}")
|
|
else:
|
|
error_detail = _last_sadtalker_error or "Unknown error"
|
|
return {"error": f"Failed to process chunk {i + 1}: {error_detail}"}
|
|
|
|
# Concatenate chunks
|
|
final_video = work_dir / "final_output.mp4"
|
|
if not concatenate_videos(video_chunks, final_video):
|
|
return {"error": "Failed to concatenate video chunks"}
|
|
|
|
log(f"Final video: {final_video} ({final_video.stat().st_size // 1024}KB)")
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
result = {
|
|
"success": True,
|
|
"duration_seconds": total_duration,
|
|
"chunks_processed": len(audio_chunks),
|
|
"processing_time_seconds": round(elapsed, 2),
|
|
}
|
|
|
|
# Upload to R2 if configured
|
|
if r2_config:
|
|
url, r2_key = upload_to_r2(final_video, job_id, r2_config)
|
|
if url:
|
|
result["video_url"] = url
|
|
result["r2_key"] = r2_key
|
|
else:
|
|
return {"error": "Failed to upload to R2"}
|
|
else:
|
|
# Return video as base64 (warning: large!)
|
|
result["video_base64"] = encode_file_base64(final_video)
|
|
log("Warning: Returning video as base64 (consider using R2 for large files)")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
log(f"Handler exception: {e}")
|
|
log(traceback.format_exc())
|
|
return {"error": f"Internal error: {str(e)}"}
|
|
finally:
|
|
# Cleanup temp files
|
|
try:
|
|
shutil.rmtree(work_dir, ignore_errors=True)
|
|
log("Cleaned up working directory")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# RunPod serverless entry point
|
|
if __name__ == "__main__":
|
|
log("Starting RunPod SadTalker handler...")
|
|
|
|
# Verify SadTalker is available
|
|
if not SADTALKER_DIR.exists():
|
|
log(f"ERROR: SadTalker not found at {SADTALKER_DIR}")
|
|
sys.exit(1)
|
|
|
|
if not CHECKPOINT_DIR.exists():
|
|
log(f"ERROR: Checkpoints not found at {CHECKPOINT_DIR}")
|
|
sys.exit(1)
|
|
|
|
log(f"SadTalker directory: {SADTALKER_DIR}")
|
|
log(f"Checkpoints: {list(CHECKPOINT_DIR.glob('*.pth*'))[:3]}...")
|
|
|
|
# Check CUDA
|
|
try:
|
|
import torch
|
|
if torch.cuda.is_available():
|
|
log(f"CUDA available: {torch.cuda.get_device_name(0)}")
|
|
else:
|
|
log("WARNING: CUDA not available!")
|
|
except ImportError:
|
|
log("Warning: torch not imported for CUDA check")
|
|
|
|
runpod.serverless.start({"handler": handler})
|