Files
digitalsamba__claude-code-v…/tools/sadtalker.py
T
Conal Mullan ac9f7cb156 Add Modal as recommended cloud GPU provider (#8)
* Add Modal as alternative cloud GPU provider (Phase 1)

RunPod has been unreliable — Modal provides faster cold starts and
more robust infrastructure. This adds Modal support for qwen3_tts
and flux2 behind a new --cloud runpod|modal flag (default: runpod).

New files:
- tools/cloud_gpu.py: shared provider abstraction with cost estimation
- docker/modal-qwen3-tts/app.py: Modal deployment for Qwen3-TTS
- docker/modal-flux2/app.py: Modal deployment for FLUX.2 Klein 4B

Modified:
- tools/qwen3_tts.py: --cloud flag, routes through cloud_gpu
- tools/flux2.py: --cloud flag, routes through cloud_gpu
- tools/config.py: Modal config getters
- _internal/toolkit-registry.json: Modal provider section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Migrate all cloud GPU tools to shared abstraction (Phase 2)

Extract duplicated RunPod submit/poll and R2 file transfer code into
shared modules. All 6 cloud GPU tools now route through cloud_gpu.py
and file_transfer.py, reducing ~1,150 lines of duplication.

Changes:
- Create tools/file_transfer.py with shared R2 upload/download + fallback
- Migrate image_edit, music_gen, upscale, sadtalker to call_cloud_endpoint()
- Add --cloud runpod|modal flag to all cloud GPU tools
- Deprecate --runpod flag in upscale.py (alias for --cloud runpod)
- Add --cloud passthrough to voiceover.py for Qwen3-TTS
- Backport file_transfer.py to qwen3_tts.py and flux2.py
- Remove dead legacy submit/poll code from Phase 1 tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add Modal apps for upscale, image-edit, music-gen, and sadtalker (Phase 3)

Create Modal deployment files for all remaining cloud GPU tools,
giving each tool --cloud modal support alongside RunPod.

New Modal apps:
- docker/modal-upscale/app.py — RealESRGAN (3 models, face enhance)
- docker/modal-image-edit/app.py — Qwen-Image-Edit (A100 for large model)
- docker/modal-music-gen/app.py — ACE-Step 1.5 (text2music, cover, extract)
- docker/modal-sadtalker/app.py — SadTalker (subprocess, audio chunking)

Deploy any app with: modal deploy docker/modal-{tool}/app.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix Modal app build issues discovered during deployment

- modal-upscale: deployed successfully (no changes needed)
- modal-image-edit: deployed successfully (no changes needed)
- modal-music-gen: deployed successfully (no changes needed)
- modal-sadtalker: fix pip install order (numpy<2 before basicsr),
  add mkdir for /root/.cache/torch before unzipping hub models

All 4 new endpoints deployed and URLs saved to .env.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix sadtalker Modal app: numpy-torch bridge and basicsr compat

Root cause: Modal's pip mirror serves generic torch (cu121) which doesn't
detect numpy at install time, causing "Numpy is not available" at runtime.
Also basicsr imports removed torchvision.transforms.functional_tensor.

Fixes:
- Use torch 2.4.0 (handles numpy 1.x/2.x correctly)
- Patch basicsr's deprecated functional_tensor import
- Add debug logging for numpy-torch bridge in @modal.enter()

Tested: claude_avatar.png + test_modal.mp3 → 4.4s video in 130s (cold start)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Track Modal cloud GPU integration in ROADMAP.md

Add Modal provider section to Phase 3 with checklist of completed and
remaining items. Add modal skill to status table as beta.

Remaining: docs/modal-setup.md, --setup --cloud modal automation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add Modal docs, propainter app, and cloud GPU quick start

- Create docs/modal-setup.md with motivation, quick start, cold start
  times, monitoring, and architecture overview
- Add propainter (dewatermark) Modal app for video inpainting
- Update CLAUDE.md with cloud GPU providers quick start section
- Update registry with dewatermark Modal endpoint
- Update ROADMAP with docs tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add /setup wizard, verify script, and migrate dewatermark to cloud_gpu

- New /setup command: interactive 6-phase onboarding wizard for new users
  (prerequisites, cloud GPU provider, R2 file transfer, tool deployment,
  voice config, verification)
- New verify_setup.py: checks all prerequisites, env vars, deployed apps,
  and optionally runs smoke tests (--test flag)
- Migrate dewatermark.py to cloud_gpu.py: replace ~400 lines of inline
  RunPod code with shared abstractions, add --cloud runpod|modal flag
- Add dewatermark to Modal env vars and GPU cost tables in cloud_gpu.py
- Rewrite .env.example: all 22 env vars organized by service, Modal
  section added, references /setup
- Update README, CLAUDE.md, getting-started.md, video.md to reference
  /setup as the primary onboarding path
- Register setup command and verify_setup tool in toolkit-registry.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Restructure README: quick start first, eliminate overlap

- Move quick start to top of file, right after title
- Consolidate 5 setup sub-sections into tight 3-line flow
- Remove "What is this?" section (merged into intro line)
- Update author note: recommend Modal over RunPod, point to /setup
- Update tool examples to use --cloud modal
- Rename "Pre-built Docker Images" to "Cloud GPU" with both providers
- Add /setup to commands table
- Add Modal setup doc to documentation links
- Net -56 lines while adding more useful content

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rewrite modal-setup.md: Modal as recommended provider, add R2 and /setup refs

- Frame Modal as recommended (not "alternative"), explain $30/mo free tier
- Add account creation steps with signup link
- Add Cloudflare R2 section (was completely missing)
- Reference /setup as the automated path throughout
- Add verify_setup.py to monitoring section
- Update cold start times from actual testing (dewatermark was missing)
- Move RunPod to "Alternative" section at bottom
- Add tool usage examples with --cloud modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix Cloud GPU section: Modal builds its own images, not GHCR

Modal apps deploy from docker/modal-*/app.py — Modal builds containers
from the Image definition, it doesn't pull GHCR images. GHCR images are
only used by RunPod. Restructured table to focus on tools and costs,
clarified the two provider paths below.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Reframe 'what's free' around open-source models, not just pricing

The toolkit's value prop is deploying capable open-source AI models
to your own cloud GPU — not just that some services have free tiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Clarify Modal free tier capacity: a few 5-minute videos per month

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix R2 region, SadTalker timeouts, and tool path guidance

- file_transfer.py: Add region_name="auto" for R2 client — fixes
  InvalidRegionName error when boto3 inherits AWS config region
- modal-sadtalker/app.py: Bump subprocess timeout 600s→1800s and
  app timeout 900s→1800s — 60s audio needs ~10min processing
- sadtalker.py: Bump client timeout multiplier 4x→12x to match
  actual processing time observed on Modal A10G
- CLAUDE.md: Add guidance to always invoke tools from toolkit root
  directory (not from project subdirectories)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix lib/transitions barrel: remove @remotion/transitions re-exports

The barrel re-exported from @remotion/transitions which breaks when
projects outside lib/ import from it — webpack can't resolve the
package from lib/'s directory. Now the barrel only exports custom
transitions (glitch, lightLeak, etc). Projects import TransitionSeries,
linearTiming, fade etc directly from @remotion/transitions.

Also add webpack resolve.modules override to all 3 template configs
as a belt-and-suspenders fix for other lib/ imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add OffthreadVideo guidance: never use raw <video> in Remotion

Remotion requires <OffthreadVideo> for frame-accurate rendering.
Raw <video> tags won't render correctly during export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add OpenClaw skill for autonomous video creation (alpha)

Self-contained SKILL.md with the complete workflow for an OpenClaw bot
to create videos autonomously: setup verification, project creation,
asset generation (TTS, images, music, talking heads), timing sync,
review, and rendering. Includes all the gotchas we discovered during
the toolkit-intro video project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add full setup flow to OpenClaw skill

The skill now includes Modal CLI install, tool deployment, endpoint URL
capture, .env configuration, R2 setup, and verification — everything a
bot needs to go from zero to ready. Removed the hard env var requirement
from metadata since the skill handles setup itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Harden OpenClaw skill for autonomous use

- Hardcode toolkit path with cd before every command block
- Fix speaker list (was wrong names) and add missing tones
- Add quick-test verification step in setup
- Add --break-system-packages for Debian/managed Python
- Add error recovery table for common failures
- Repeat cd to toolkit root before every command (LLM session memory lesson)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:32:15 +00:00

834 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Generate talking head videos using SadTalker.
Animates a static portrait image to match audio, creating a realistic
talking head video suitable for explainer videos, avatars, and narration.
Usage:
# Basic usage
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --output talking.mp4
# With options
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --still --output talking.mp4
# Higher resolution (512px)
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --size 512 --output talking.mp4
# Setup endpoint
python tools/sadtalker.py --setup
Setup:
1. Create account at runpod.io
2. Run: python tools/sadtalker.py --setup
3. Or manually deploy docker/runpod-sadtalker/ and add endpoint ID to .env
Cost:
- ~$0.04 per 30 seconds of video
- ~$0.27 per 3 minutes of video
- Uses RTX 4090 ($0.00074/sec) by default
"""
import argparse
import base64
import json
import os
import sys
from pathlib import Path
import requests
sys.path.insert(0, str(Path(__file__).parent))
from file_transfer import (
upload_to_storage, download_from_r2, delete_from_r2,
download_from_url, get_r2_payload_config,
)
# Docker image for RunPod endpoint
SADTALKER_DOCKER_IMAGE = "ghcr.io/conalmullan/video-toolkit-sadtalker:latest"
SADTALKER_TEMPLATE_NAME = "video-toolkit-sadtalker"
SADTALKER_ENDPOINT_NAME = "video-toolkit-sadtalker"
# Processing time estimate: ~4 minutes per minute of audio + buffer
PROCESSING_TIME_MULTIPLIER = 12 # ~10x processing time per second of audio + margin
PROCESSING_TIME_BUFFER = 180 # 3 minute buffer for cold start, upload, etc.
def get_audio_duration(audio_path: str) -> float | None:
"""Get audio duration in seconds using ffprobe."""
import subprocess
try:
result = subprocess.run(
[
"ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", audio_path
],
capture_output=True,
text=True,
)
if result.returncode == 0:
return float(result.stdout.strip())
except Exception:
pass
return None
def calculate_timeout(audio_duration: float) -> int:
"""Calculate appropriate timeout based on audio duration.
~4 minutes processing per minute of audio, plus buffer for cold start.
"""
processing_time = audio_duration * PROCESSING_TIME_MULTIPLIER
return int(processing_time + PROCESSING_TIME_BUFFER)
def retrieve_job_result(
job_id: str,
output_path: str,
verbose: bool = True,
) -> dict:
"""Retrieve results from a completed RunPod job.
Use this if a previous run timed out but the job completed on the server.
RunPod-specific (job retrieval is a RunPod concept).
"""
from cloud_gpu import get_provider_config
config = get_provider_config("runpod", "sadtalker")
api_key = config.get("api_key")
endpoint_id = config.get("endpoint_id")
if not api_key:
return {"error": "RUNPOD_API_KEY not set"}
if not endpoint_id:
return {"error": "RUNPOD_SADTALKER_ENDPOINT_ID not set"}
if verbose:
print(f"Retrieving job: {job_id}", file=sys.stderr)
url = f"https://api.runpod.ai/v2/{endpoint_id}/status/{job_id}"
try:
response = requests.get(
url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
if response.status_code != 200:
return {"error": f"Failed to get job status: HTTP {response.status_code}"}
data = response.json()
status = data.get("status")
if verbose:
print(f" Status: {status}", file=sys.stderr)
if status != "COMPLETED":
return {"error": f"Job not completed. Status: {status}"}
output = data.get("output", {})
if output.get("error"):
return {"error": output["error"]}
video_url = output.get("video_url") or output.get("r2_url")
if not video_url:
return {"error": "No video_url in job output. Job may have failed."}
if verbose:
print(f" Downloading from: {video_url[:80]}...", file=sys.stderr)
if not download_from_url(video_url, output_path, verbose=verbose):
return {"error": "Failed to download video"}
return {
"output": output_path,
"job_id": job_id,
"duration_seconds": output.get("duration_seconds"),
"chunks_processed": output.get("chunks_processed"),
}
except Exception as e:
return {"error": f"Failed to retrieve job: {e}"}
def process_with_cloud(
image_path: str,
audio_path: str,
output_path: str,
still_mode: bool = False,
enhancer: str = "gfpgan",
preprocess: str = "crop",
size: int = 256,
expression_scale: float = 1.0,
pose_style: int = 0,
timeout: int = 600,
verbose: bool = True,
cloud: str = "runpod",
) -> dict:
"""Process image+audio using cloud GPU endpoint."""
r2_keys_to_cleanup = []
if verbose:
print(f"Cloud provider: {cloud}", file=sys.stderr)
# Auto-calculate timeout if not specified
if timeout <= 0:
audio_duration = get_audio_duration(audio_path)
if audio_duration:
timeout = calculate_timeout(audio_duration)
if verbose:
print(f"Audio duration: {audio_duration:.1f}s, timeout: {timeout}s", file=sys.stderr)
else:
timeout = 900 # Default 15 minutes
if verbose:
print(f"Could not determine audio duration, using default timeout: {timeout}s", file=sys.stderr)
# Upload image
image_url, image_r2_key = upload_to_storage(image_path, "sadtalker/input")
if not image_url:
return {"error": "Failed to upload image"}
if image_r2_key:
r2_keys_to_cleanup.append(image_r2_key)
# Upload audio
audio_url, audio_r2_key = upload_to_storage(audio_path, "sadtalker/input")
if not audio_url:
return {"error": "Failed to upload audio"}
if audio_r2_key:
r2_keys_to_cleanup.append(audio_r2_key)
# Build payload
if verbose:
print(f"Submitting job (size={size}, enhancer={enhancer})...", file=sys.stderr)
payload = {
"input": {
"image_url": image_url,
"audio_url": audio_url,
"still_mode": still_mode,
"enhancer": enhancer,
"preprocess": preprocess,
"size": size,
"expression_scale": expression_scale,
"pose_style": pose_style,
}
}
r2_payload = get_r2_payload_config()
if r2_payload:
payload["input"]["r2"] = r2_payload
else:
print("Warning: R2 not configured. Video will be returned as base64.", file=sys.stderr)
# Call cloud GPU endpoint
from cloud_gpu import call_cloud_endpoint
result, elapsed = call_cloud_endpoint(
provider=cloud,
payload=payload,
tool_name="sadtalker",
timeout=timeout,
progress_label="Generating talking head",
verbose=verbose,
)
if isinstance(result, dict) and result.get("error"):
return {"error": result["error"]}
# Download result
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
downloaded = False
output_r2_key = result.get("r2_key") if isinstance(result, dict) else None
output_url = result.get("video_url") if isinstance(result, dict) else None
if output_r2_key:
if verbose:
print(f"Downloading result from R2...", file=sys.stderr)
downloaded = download_from_r2(output_r2_key, output_path)
if downloaded:
r2_keys_to_cleanup.append(output_r2_key)
if verbose:
size_kb = Path(output_path).stat().st_size // 1024
print(f" Downloaded: {output_path} ({size_kb}KB)", file=sys.stderr)
if not downloaded and output_url:
downloaded = download_from_url(output_url, output_path, verbose=verbose)
if not downloaded:
# Try base64 fallback
video_base64 = result.get("video_base64")
if video_base64:
Path(output_path).write_bytes(base64.b64decode(video_base64))
downloaded = True
if verbose:
size_kb = Path(output_path).stat().st_size // 1024
print(f" Decoded from base64: {output_path} ({size_kb}KB)", file=sys.stderr)
if not downloaded:
return {"error": f"No video in result: {list(result.keys()) if isinstance(result, dict) else result}"}
# Cleanup R2 objects
for key in r2_keys_to_cleanup:
delete_from_r2(key)
return {
"success": True,
"output": output_path,
"processing_time_seconds": round(elapsed, 2),
"duration_seconds": result.get("duration_seconds"),
"chunks_processed": result.get("chunks_processed"),
}
# =============================================================================
# RunPod Setup (GraphQL API)
# =============================================================================
RUNPOD_GRAPHQL_URL = "https://api.runpod.io/graphql"
def runpod_graphql_query(api_key: str, query: str, variables: dict | None = None) -> dict:
"""Execute a GraphQL query against RunPod API."""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
payload = {"query": query}
if variables:
payload["variables"] = variables
response = requests.post(
RUNPOD_GRAPHQL_URL,
json=payload,
headers=headers,
timeout=30,
)
if response.status_code != 200:
raise Exception(f"GraphQL request failed: HTTP {response.status_code}: {response.text}")
data = response.json()
if "errors" in data:
raise Exception(f"GraphQL errors: {data['errors']}")
return data.get("data", {})
def list_runpod_templates(api_key: str) -> list[dict]:
"""List all user templates."""
query = """
query {
myself {
podTemplates {
id
name
imageName
isServerless
}
}
}
"""
data = runpod_graphql_query(api_key, query)
templates = data.get("myself", {}).get("podTemplates", [])
return [t for t in templates if t.get("isServerless")]
def find_sadtalker_template(api_key: str) -> dict | None:
"""Find existing SadTalker template."""
templates = list_runpod_templates(api_key)
for t in templates:
if t.get("name") == SADTALKER_TEMPLATE_NAME:
return t
if t.get("imageName") == SADTALKER_DOCKER_IMAGE:
return t
return None
def create_runpod_template(api_key: str, verbose: bool = True) -> dict:
"""Create a serverless template for SadTalker."""
if verbose:
print(f"Creating template '{SADTALKER_TEMPLATE_NAME}'...")
mutation = """
mutation SaveTemplate($input: SaveTemplateInput!) {
saveTemplate(input: $input) {
id
name
imageName
isServerless
}
}
"""
variables = {
"input": {
"name": SADTALKER_TEMPLATE_NAME,
"imageName": SADTALKER_DOCKER_IMAGE,
"isServerless": True,
"containerDiskInGb": 20,
"volumeInGb": 0,
"dockerArgs": "",
"env": [],
}
}
data = runpod_graphql_query(api_key, mutation, variables)
template = data.get("saveTemplate")
if not template or not template.get("id"):
raise Exception(f"Failed to create template: {data}")
if verbose:
print(f" Template created: {template['id']}")
return template
def list_runpod_endpoints(api_key: str) -> list[dict]:
"""List all user endpoints."""
query = """
query {
myself {
endpoints {
id
name
templateId
gpuIds
workersMin
workersMax
idleTimeout
}
}
}
"""
data = runpod_graphql_query(api_key, query)
return data.get("myself", {}).get("endpoints", [])
def find_sadtalker_endpoint(api_key: str, template_id: str) -> dict | None:
"""Find existing SadTalker endpoint."""
endpoints = list_runpod_endpoints(api_key)
for e in endpoints:
if e.get("name") == SADTALKER_ENDPOINT_NAME:
return e
if e.get("templateId") == template_id:
return e
return None
def create_runpod_endpoint(
api_key: str,
template_id: str,
gpu_id: str = "AMPERE_24",
verbose: bool = True,
) -> dict:
"""Create a serverless endpoint for SadTalker."""
if verbose:
print(f"Creating endpoint '{SADTALKER_ENDPOINT_NAME}'...")
mutation = """
mutation SaveEndpoint($input: EndpointInput!) {
saveEndpoint(input: $input) {
id
name
templateId
gpuIds
workersMin
workersMax
idleTimeout
}
}
"""
variables = {
"input": {
"name": SADTALKER_ENDPOINT_NAME,
"templateId": template_id,
"gpuIds": gpu_id,
"workersMin": 0,
"workersMax": 1,
"idleTimeout": 5,
"scalerType": "QUEUE_DELAY",
"scalerValue": 4,
}
}
data = runpod_graphql_query(api_key, mutation, variables)
endpoint = data.get("saveEndpoint")
if not endpoint or not endpoint.get("id"):
raise Exception(f"Failed to create endpoint: {data}")
if verbose:
print(f" Endpoint created: {endpoint['id']}")
return endpoint
def save_endpoint_to_env(endpoint_id: str, verbose: bool = True) -> bool:
"""Save endpoint ID to .env file."""
sys.path.insert(0, str(Path(__file__).parent))
try:
from config import find_workspace_root
env_path = find_workspace_root() / ".env"
except ImportError:
env_path = Path(__file__).parent.parent / ".env"
if verbose:
print(f"Saving endpoint ID to {env_path}...")
env_content = ""
if env_path.exists():
env_content = env_path.read_text()
lines = env_content.split("\n")
updated = False
new_lines = []
for line in lines:
if line.startswith("RUNPOD_SADTALKER_ENDPOINT_ID="):
new_lines.append(f"RUNPOD_SADTALKER_ENDPOINT_ID={endpoint_id}")
updated = True
else:
new_lines.append(line)
if not updated:
if new_lines and new_lines[-1].strip():
new_lines.append("")
new_lines.append(f"RUNPOD_SADTALKER_ENDPOINT_ID={endpoint_id}")
env_path.write_text("\n".join(new_lines))
if verbose:
print(f" Saved: RUNPOD_SADTALKER_ENDPOINT_ID={endpoint_id}")
return True
def setup_runpod(gpu_id: str = "AMPERE_24", verbose: bool = True) -> dict:
"""Set up RunPod endpoint for SadTalker."""
result = {
"success": False,
"template_id": None,
"endpoint_id": None,
"created_template": False,
"created_endpoint": False,
}
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv("RUNPOD_API_KEY")
if not api_key:
result["error"] = "RUNPOD_API_KEY not set. Add to .env file first."
return result
if verbose:
print("=" * 60)
print("RunPod Setup (SadTalker Talking Head Generator)")
print("=" * 60)
print(f"Docker Image: {SADTALKER_DOCKER_IMAGE}")
print(f"GPU Type: {gpu_id}")
print()
try:
if verbose:
print("[1/3] Checking for existing template...")
template = find_sadtalker_template(api_key)
if template:
if verbose:
print(f" Found existing template: {template['id']}")
result["template_id"] = template["id"]
else:
template = create_runpod_template(api_key, verbose=verbose)
result["template_id"] = template["id"]
result["created_template"] = True
if verbose:
print("[2/3] Checking for existing endpoint...")
endpoint = find_sadtalker_endpoint(api_key, result["template_id"])
if endpoint:
if verbose:
print(f" Found existing endpoint: {endpoint['id']}")
result["endpoint_id"] = endpoint["id"]
else:
endpoint = create_runpod_endpoint(
api_key,
result["template_id"],
gpu_id=gpu_id,
verbose=verbose,
)
result["endpoint_id"] = endpoint["id"]
result["created_endpoint"] = True
if verbose:
print("[3/3] Saving configuration...")
save_endpoint_to_env(result["endpoint_id"], verbose=verbose)
result["success"] = True
if verbose:
print()
print("=" * 60)
print("Setup Complete!")
print("=" * 60)
print(f"Template ID: {result['template_id']}")
print(f"Endpoint ID: {result['endpoint_id']}")
print()
print("You can now run:")
print(" python tools/sadtalker.py --image avatar.png --audio voice.mp3 --output talking.mp4")
print()
except Exception as e:
result["error"] = str(e)
if verbose:
print(f"Error: {e}", file=sys.stderr)
return result
def parse_args():
parser = argparse.ArgumentParser(
description="Generate talking head videos using SadTalker",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic usage
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --output talking.mp4
# Less head movement (good for serious content)
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --still --output talking.mp4
# Higher resolution (512px)
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --size 512 --output talking.mp4
# No face enhancement (faster)
python tools/sadtalker.py --image avatar.png --audio voiceover.mp3 --no-enhance --output talking.mp4
# Setup RunPod endpoint (first-time)
python tools/sadtalker.py --setup
""",
)
parser.add_argument(
"--image", "-i",
type=str,
help="Input portrait image (face should be centered, 30-70%% of frame)",
)
parser.add_argument(
"--audio", "-a",
type=str,
help="Input audio file (speech to animate)",
)
parser.add_argument(
"--output", "-o",
type=str,
help="Output video file path",
)
parser.add_argument(
"--still",
action="store_true",
help="Less head movement (good for serious/professional content)",
)
parser.add_argument(
"--no-enhance",
action="store_true",
help="Skip GFPGAN face enhancement (faster but lower quality)",
)
parser.add_argument(
"--preprocess",
type=str,
default="crop",
choices=["crop", "resize", "full"],
help="Face preprocessing: crop (default), resize, or full",
)
parser.add_argument(
"--size",
type=int,
default=256,
choices=[256, 512],
help="Output resolution: 256 (default) or 512",
)
parser.add_argument(
"--expression-scale",
type=float,
default=1.0,
help="Expression intensity (default: 1.0)",
)
parser.add_argument(
"--pose-style",
type=int,
default=0,
help="Pose variation 0-45 (default: 0, try 45 for natural movement)",
)
# Presets for common use cases
parser.add_argument(
"--preset",
type=str,
choices=["default", "natural", "expressive", "professional", "fullbody"],
help="Use a preset configuration (overrides other settings)",
)
# Cloud GPU options
parser.add_argument(
"--cloud",
type=str,
default="runpod",
choices=["runpod", "modal"],
help="Cloud GPU provider (default: runpod)",
)
parser.add_argument(
"--timeout",
type=int,
default=0,
help="Job timeout in seconds (default: auto-calculated from audio duration)",
)
parser.add_argument(
"--retrieve",
type=str,
metavar="JOB_ID",
help="Retrieve results from a completed RunPod job (e.g., if previous run timed out)",
)
parser.add_argument(
"--setup",
action="store_true",
help="Set up RunPod endpoint automatically",
)
parser.add_argument(
"--setup-gpu",
type=str,
default="AMPERE_24",
choices=["AMPERE_16", "AMPERE_24", "ADA_24", "AMPERE_48"],
help="GPU type for RunPod endpoint (default: AMPERE_24)",
)
# Output options
parser.add_argument(
"--json",
action="store_true",
help="Output result as JSON",
)
return parser.parse_args()
def main():
args = parse_args()
verbose = not args.json
# Handle --setup
if args.setup:
result = setup_runpod(gpu_id=args.setup_gpu, verbose=verbose)
if args.json:
print(json.dumps(result, indent=2))
if result.get("error"):
sys.exit(1)
sys.exit(0)
# Handle --retrieve (download results from a previous job)
if args.retrieve:
if not args.output:
print("Error: --output is required with --retrieve", file=sys.stderr)
sys.exit(1)
result = retrieve_job_result(args.retrieve, args.output, verbose=verbose)
if args.json:
print(json.dumps(result, indent=2))
if result.get("error"):
sys.exit(1)
sys.exit(0)
# Validate required arguments
if not args.image:
print("Error: --image is required", file=sys.stderr)
sys.exit(1)
if not args.audio:
print("Error: --audio is required", file=sys.stderr)
sys.exit(1)
if not args.output:
print("Error: --output is required", file=sys.stderr)
sys.exit(1)
# Check input files exist
if not Path(args.image).exists():
print(f"Error: Image file not found: {args.image}", file=sys.stderr)
sys.exit(1)
if not Path(args.audio).exists():
print(f"Error: Audio file not found: {args.audio}", file=sys.stderr)
sys.exit(1)
# Apply presets
presets = {
"default": {}, # Use CLI defaults
"natural": {"pose_style": 45, "expression_scale": 1.0},
"expressive": {"pose_style": 45, "expression_scale": 1.3},
"professional": {"still": True, "expression_scale": 0.8},
"fullbody": {"still": True, "preprocess": "full"},
}
# Start with CLI args
still_mode = args.still
enhancer = "none" if args.no_enhance else "gfpgan"
preprocess = args.preprocess
size = args.size
expression_scale = args.expression_scale
pose_style = args.pose_style
# Override with preset if specified
if args.preset and args.preset in presets:
preset = presets[args.preset]
if "still" in preset:
still_mode = preset["still"]
if "expression_scale" in preset:
expression_scale = preset["expression_scale"]
if "pose_style" in preset:
pose_style = preset["pose_style"]
if "preprocess" in preset:
preprocess = preset["preprocess"]
if verbose:
print(f"Using preset '{args.preset}': {preset}")
if verbose:
print("Generating talking head video with SadTalker...")
result = process_with_cloud(
image_path=args.image,
audio_path=args.audio,
output_path=args.output,
still_mode=still_mode,
enhancer=enhancer,
preprocess=preprocess,
size=size,
expression_scale=expression_scale,
pose_style=pose_style,
timeout=args.timeout,
verbose=verbose,
cloud=args.cloud,
)
if result.get("error"):
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
duration = result.get("duration_seconds", 0)
chunks = result.get("chunks_processed", 1)
print(f"Generated: {result['output']}")
print(f" Duration: {duration:.1f}s ({chunks} chunk{'s' if chunks > 1 else ''})")
print(f" Processing time: {result.get('processing_time_seconds', 0):.1f}s")
if __name__ == "__main__":
main()