mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
3277ff4cdb
SoulX-FlashHead (Soul AI Lab, Apache 2.0, 1.3B) becomes the toolkit's default talking head generator. It preserves the input aspect ratio, so 16:9 presenter images come back 16:9 with no --preprocess workaround, and --size snaps to the model's latent grid while keeping the aspect. It is the default because identity holds over a long take. Segment-chained talking heads re-anchor each segment on the previous segment's output, which makes the failure absorbing rather than gradual. SoulX is trained with Oracle-Guided Bidirectional Distillation against exactly that, and it measures out: 97% of frame-zero sharpness at 70s, flat across all 72 segments, verified on two subjects at three resolutions. There is no short-render ceiling to design around, so per-scene generation is a choice rather than a workaround. ~$0.0024 per second of output against SadTalker's ~$0.0014, and ~6.4-7.9x realtime on A10G. Both figures are a floor: flash-attn and sageattention are optional here and neither is installed, while upstream's quoted FPS assumes one. Notes for whoever touches this next: - There is no resolution argument anywhere upstream. height/width live in a module-global loaded at import from a relative path, so the app chdirs into the repo root and mutates that global. Nothing validates the value either -- an off-grid size floors silently and renders wrong rather than raising, so _check_size guards it on both sides. - torch.compile is on upstream, costs ~600s per container and recompiles on every resolution change. scaledown_window is 600s so a batch at one size amortises it; SOULX_COMPILE=0 disables it for odd one-offs. - Weights live in a Modal Volume, not baked: redeploy after a code change is ~2.3s. Repo ref and both model revisions pinned by SHA, since volume weights are not tied to the image. - modal run cancels the call when its client dies (a sleeping laptop counts, and --detach does not save it). Renders are written to the soulx-out volume before being returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
14 KiB
Python
Executable File
354 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Generate talking head videos using SoulX-FlashHead.
|
|
|
|
The toolkit's default talking head. SoulX-FlashHead (Soul AI Lab, Apache 2.0,
|
|
1.3B) is trained with Oracle-Guided Bidirectional Distillation, which targets
|
|
the identity drift that limits segment-chained talking head models: measured at
|
|
97% of frame-zero sharpness at 70s where EchoMimicV3 fell to 50% on identical
|
|
input. There is no short-render ceiling to design around.
|
|
|
|
It preserves the input image's aspect ratio, so 16:9 presenter images come back
|
|
16:9 with no --preprocess workaround.
|
|
|
|
Usage:
|
|
# Basic -- aspect ratio follows the input image
|
|
uv run tools/soulx.py --image portrait.png --audio voiceover.mp3 --output talking.mp4
|
|
|
|
# NarratorPiP (16:9 in, 16:9 out)
|
|
uv run tools/soulx.py \
|
|
--image presenter_16x9.png --audio scene_01.mp3 \
|
|
--size 768 --output narrator.mp4
|
|
|
|
# Exact dimensions instead of a long edge
|
|
uv run tools/soulx.py -i p.png -a vo.mp3 --width 768 --height 432 -o n.mp4
|
|
|
|
# A/B against an existing SadTalker render of the same inputs
|
|
uv run tools/soulx.py -i p.png -a vo.mp3 -o new.mp4 --compare old.mp4
|
|
|
|
Setup:
|
|
uv sync --extra modal && uv run modal setup
|
|
uv run modal run docker/modal-soulx/app.py::populate_weights # one-off, 14.7GB
|
|
uv run modal deploy docker/modal-soulx/app.py
|
|
# then add the printed URL to .env:
|
|
MODAL_SOULX_ENDPOINT_URL=https://....modal.run
|
|
|
|
Cost:
|
|
Measured ~$0.0024 per second of output on A10G (~7.9x realtime steady
|
|
state), against SadTalker's ~$0.0014. The first call into a cold container
|
|
also pays ~600s of torch.compile, which is per-resolution -- so a batch of
|
|
per-scene clips at ONE size amortises it, and switching sizes per scene
|
|
does not. The tool prints the measured realtime factor.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from file_transfer import (
|
|
upload_to_storage, download_from_r2, r2_cleanup,
|
|
download_from_url, get_r2_payload_config,
|
|
)
|
|
|
|
# Wall-clock budget per second of audio. Generous because a cold container pays
|
|
# torch.compile (~600s) before the first chunk, on top of paging weights in.
|
|
PROCESSING_TIME_MULTIPLIER = 30
|
|
PROCESSING_TIME_BUFFER = 900
|
|
|
|
# Pixel dimensions must land on the model's latent grid: the Wan2.1 VAE has
|
|
# spatial stride 8 and the transformer patchifies 2x2. Nothing upstream
|
|
# validates this -- a bad size floors silently and desyncs the latent grid from
|
|
# the pixel grid -- so it is checked here as well as server-side.
|
|
GRID = 16
|
|
|
|
|
|
def get_audio_duration(audio_path: str) -> float | None:
|
|
"""Get audio duration in seconds using ffprobe."""
|
|
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:
|
|
return int(audio_duration * PROCESSING_TIME_MULTIPLIER + PROCESSING_TIME_BUFFER)
|
|
|
|
|
|
def check_size(height: int, width: int) -> str | None:
|
|
"""Return an error string if the requested size is off the latent grid."""
|
|
for name, v in (("height", height), ("width", width)):
|
|
if v % GRID:
|
|
return (f"--{name} {v} must be a multiple of {GRID}. "
|
|
f"Nearest: {v // GRID * GRID} or {(v // GRID + 1) * GRID}.")
|
|
return None
|
|
|
|
|
|
def build_comparison(new_video: str, old_video: str, output_path: str,
|
|
old_label: str = "previous", verbose: bool = True) -> str | None:
|
|
"""Stack two talking head renders side by side for eyeballing.
|
|
|
|
Labels each half so the pair stays readable once it is out of context.
|
|
Audio comes from the new clip.
|
|
"""
|
|
if verbose:
|
|
print(f"Building comparison: {old_video} | {new_video}", file=sys.stderr)
|
|
|
|
labelled = (
|
|
f"[0:v]scale=-2:720,pad=iw:ih+40:0:40:black,"
|
|
f"drawtext=text='{old_label}':x=10:y=8:fontsize=24:fontcolor=white[a];"
|
|
f"[1:v]scale=-2:720,pad=iw:ih+40:0:40:black,"
|
|
f"drawtext=text='SoulX-FlashHead':x=10:y=8:fontsize=24:fontcolor=white[b];"
|
|
f"[a][b]hstack=inputs=2[v]"
|
|
)
|
|
# drawtext needs a fontconfig default that not every ffmpeg build ships, so
|
|
# fall back to an unlabelled stack rather than losing the comparison.
|
|
plain = "[0:v]scale=-2:720[a];[1:v]scale=-2:720[b];[a][b]hstack=inputs=2[v]"
|
|
|
|
for filt in (labelled, plain):
|
|
cmd = [
|
|
"ffmpeg", "-y", "-i", old_video, "-i", new_video,
|
|
"-filter_complex", filt, "-map", "[v]", "-map", "1:a?",
|
|
"-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
|
|
"-c:a", "aac", output_path,
|
|
]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
if proc.returncode == 0:
|
|
if verbose:
|
|
note = "" if filt is labelled else " (unlabelled: drawtext unavailable)"
|
|
print(f" Comparison: {output_path}{note}", file=sys.stderr)
|
|
return output_path
|
|
|
|
print(f"Comparison render failed: {proc.stderr[-400:]}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def process_with_cloud(
|
|
image_path: str,
|
|
audio_path: str,
|
|
output_path: str,
|
|
size: int = 768,
|
|
height: int = 0,
|
|
width: int = 0,
|
|
seed: int = 42,
|
|
use_face_crop: bool = False,
|
|
timeout: int = 0,
|
|
verbose: bool = True,
|
|
cloud: str = "modal",
|
|
progress=None,
|
|
) -> dict:
|
|
"""Generate a talking head via the SoulX-FlashHead cloud endpoint."""
|
|
with r2_cleanup() as r2_keys_to_cleanup:
|
|
if verbose:
|
|
print(f"Cloud provider: {cloud}", file=sys.stderr)
|
|
|
|
if height and width:
|
|
err = check_size(height, width)
|
|
if err:
|
|
return {"error": err}
|
|
|
|
audio_duration = get_audio_duration(audio_path)
|
|
if timeout <= 0:
|
|
timeout = calculate_timeout(audio_duration) if audio_duration else 2400
|
|
|
|
if verbose and audio_duration:
|
|
# Pro emits 28 new frames per 33-frame chunk at 25fps.
|
|
chunks = max(1, -(-int(audio_duration * 25) // 28))
|
|
print(f"Audio: {audio_duration:.1f}s -> ~{chunks} chunk"
|
|
f"{'s' if chunks > 1 else ''}, timeout {timeout}s", file=sys.stderr)
|
|
|
|
image_url, image_r2_key = upload_to_storage(image_path, "soulx/input")
|
|
if not image_url:
|
|
return {"error": "Failed to upload image"}
|
|
if image_r2_key:
|
|
r2_keys_to_cleanup.append(image_r2_key)
|
|
|
|
audio_url, audio_r2_key = upload_to_storage(audio_path, "soulx/input")
|
|
if not audio_url:
|
|
return {"error": "Failed to upload audio"}
|
|
if audio_r2_key:
|
|
r2_keys_to_cleanup.append(audio_r2_key)
|
|
|
|
payload = {
|
|
"input": {
|
|
"image_url": image_url,
|
|
"audio_url": audio_url,
|
|
"seed": seed,
|
|
"use_face_crop": use_face_crop,
|
|
}
|
|
}
|
|
if height and width:
|
|
payload["input"]["height"] = height
|
|
payload["input"]["width"] = width
|
|
else:
|
|
payload["input"]["size"] = size
|
|
|
|
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)
|
|
|
|
from cloud_gpu import call_cloud_endpoint
|
|
|
|
result, elapsed = call_cloud_endpoint(
|
|
provider=cloud,
|
|
payload=payload,
|
|
tool_name="soulx",
|
|
timeout=timeout,
|
|
progress_label="Generating talking head",
|
|
verbose=verbose,
|
|
progress=progress,
|
|
)
|
|
|
|
if isinstance(result, dict) and result.get("error"):
|
|
return {"error": result["error"]}
|
|
|
|
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:
|
|
downloaded = download_from_r2(output_r2_key, output_path)
|
|
if downloaded:
|
|
r2_keys_to_cleanup.append(output_r2_key)
|
|
|
|
if not downloaded and output_url:
|
|
downloaded = download_from_url(output_url, output_path, verbose=verbose)
|
|
if downloaded and output_r2_key:
|
|
r2_keys_to_cleanup.append(output_r2_key)
|
|
|
|
if not downloaded:
|
|
video_base64 = result.get("video_base64") if isinstance(result, dict) else None
|
|
if video_base64:
|
|
Path(output_path).write_bytes(base64.b64decode(video_base64))
|
|
downloaded = True
|
|
|
|
if not downloaded:
|
|
keys = list(result.keys()) if isinstance(result, dict) else result
|
|
return {"error": f"No video in result: {keys}"}
|
|
|
|
if verbose:
|
|
size_kb = Path(output_path).stat().st_size // 1024
|
|
rtf = result.get("realtime_factor")
|
|
print(f" Downloaded: {output_path} ({size_kb}KB, "
|
|
f"{result.get('width')}x{result.get('height')}"
|
|
f"{f', {rtf}x realtime' if rtf else ''})", file=sys.stderr)
|
|
|
|
return {
|
|
"success": True,
|
|
"output": output_path,
|
|
"processing_time_seconds": round(elapsed, 2),
|
|
"duration_seconds": result.get("duration_seconds"),
|
|
"chunks": result.get("chunks"),
|
|
"width": result.get("width"),
|
|
"height": result.get("height"),
|
|
"realtime_factor": result.get("realtime_factor"),
|
|
"median_chunk_seconds": result.get("median_chunk_seconds"),
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate talking head videos with SoulX-FlashHead",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
|
|
io_group = parser.add_argument_group("Input/output")
|
|
io_group.add_argument("--image", "-i", required=True,
|
|
help="Portrait image (16:9 for NarratorPiP)")
|
|
io_group.add_argument("--audio", "-a", required=True, help="Driving audio file")
|
|
io_group.add_argument("--output", "-o", default="talking.mp4", help="Output video path")
|
|
io_group.add_argument("--compare", metavar="OLD_VIDEO",
|
|
help="Also write a side-by-side against an existing render")
|
|
io_group.add_argument("--compare-label", default="previous",
|
|
help="Label for the left half of --compare (default: previous)")
|
|
|
|
gen_group = parser.add_argument_group("Generation")
|
|
gen_group.add_argument("--size", type=int, default=768,
|
|
help="Target long edge; aspect follows the image (default: 768)")
|
|
gen_group.add_argument("--height", type=int, default=0,
|
|
help=f"Exact height, multiple of {GRID}. Use with --width")
|
|
gen_group.add_argument("--width", type=int, default=0,
|
|
help=f"Exact width, multiple of {GRID}. Use with --height")
|
|
gen_group.add_argument("--seed", type=int, default=42, help="Random seed")
|
|
gen_group.add_argument("--face-crop", action="store_true",
|
|
help="Upstream face detect+crop. Square-only, so it "
|
|
"discards a 16:9 framing -- rarely what you want")
|
|
|
|
out_group = parser.add_argument_group("Output control")
|
|
out_group.add_argument("--timeout", type=int, default=0,
|
|
help="Override auto-calculated timeout")
|
|
out_group.add_argument("--json", action="store_true", help="Output result as JSON")
|
|
out_group.add_argument("--quiet", "-q", action="store_true",
|
|
help="Suppress progress output")
|
|
out_group.add_argument("--cloud", default="modal", choices=["modal"],
|
|
help="Cloud provider (Modal only)")
|
|
|
|
args = parser.parse_args()
|
|
verbose = not args.quiet
|
|
|
|
if bool(args.height) != bool(args.width):
|
|
parser.error("--height and --width must be given together (or use --size)")
|
|
|
|
for path, what in ((args.image, "Image"), (args.audio, "Audio")):
|
|
if not Path(path).exists():
|
|
msg = f"{what} not found: {path}"
|
|
print(json.dumps({"error": msg}) if args.json else f"Error: {msg}",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
result = process_with_cloud(
|
|
image_path=args.image,
|
|
audio_path=args.audio,
|
|
output_path=args.output,
|
|
size=args.size,
|
|
height=args.height,
|
|
width=args.width,
|
|
seed=args.seed,
|
|
use_face_crop=args.face_crop,
|
|
timeout=args.timeout,
|
|
verbose=verbose,
|
|
cloud=args.cloud,
|
|
)
|
|
|
|
if result.get("success") and args.compare:
|
|
if Path(args.compare).exists():
|
|
comparison = build_comparison(
|
|
args.output, args.compare,
|
|
str(Path(args.output).with_name(Path(args.output).stem + "_compare.mp4")),
|
|
old_label=args.compare_label, verbose=verbose,
|
|
)
|
|
if comparison:
|
|
result["comparison"] = comparison
|
|
else:
|
|
print(f"Warning: --compare file not found: {args.compare}", file=sys.stderr)
|
|
|
|
if args.json:
|
|
print(json.dumps(result, indent=2))
|
|
elif result.get("error"):
|
|
print(f"Error: {result['error']}", file=sys.stderr)
|
|
else:
|
|
print(f"Generated: {result['output']}")
|
|
|
|
sys.exit(1 if result.get("error") else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|