mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
ac9f7cb156
* 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>
834 lines
24 KiB
Python
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()
|