mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-authored-by: Hanzo Dev <[email protected]>
232 lines
7.4 KiB
Python
232 lines
7.4 KiB
Python
"""Lip-sync tool for dubbing and localization.
|
|
|
|
Syncs lip movements in a video to match a different audio track using
|
|
Wav2Lip or MuseTalk models. Primary use case: replace original speech
|
|
with translated audio and make the speaker's lips match.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
ToolResult,
|
|
ToolRuntime,
|
|
ToolStability,
|
|
ToolStatus,
|
|
ToolTier,
|
|
)
|
|
|
|
|
|
# Model checkpoint filenames by variant
|
|
MODEL_CHECKPOINTS = {
|
|
"wav2lip": "wav2lip.pth",
|
|
"wav2lip_gan": "wav2lip_gan.pth",
|
|
}
|
|
|
|
|
|
class LipSync(BaseTool):
|
|
name = "lip_sync"
|
|
version = "0.1.0"
|
|
tier = ToolTier.GENERATE
|
|
capability = "avatar"
|
|
provider = "wav2lip"
|
|
stability = ToolStability.EXPERIMENTAL
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.STOCHASTIC
|
|
runtime = ToolRuntime.LOCAL_GPU
|
|
|
|
dependencies = ["python:torch", "cmd:ffmpeg"]
|
|
install_instructions = (
|
|
"Option 1: pip install wav2lip (if available)\n"
|
|
"Option 2: Clone https://github.com/Rudrabha/Wav2Lip and set WAV2LIP_PATH env var\n"
|
|
"Requires: PyTorch with CUDA, ffmpeg"
|
|
)
|
|
|
|
agent_skills = ["ffmpeg"]
|
|
|
|
capabilities = [
|
|
"lip_sync",
|
|
"audio_video_alignment",
|
|
"dubbing_support",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["video_path", "audio_path"],
|
|
"properties": {
|
|
"video_path": {
|
|
"type": "string",
|
|
"description": "Path to source video with face",
|
|
},
|
|
"audio_path": {
|
|
"type": "string",
|
|
"description": "Path to audio track to sync lips to",
|
|
},
|
|
"output_path": {
|
|
"type": "string",
|
|
"description": "Output video path (defaults to {stem}_lipsync.mp4)",
|
|
},
|
|
"model": {
|
|
"type": "string",
|
|
"enum": ["wav2lip", "wav2lip_gan"],
|
|
"default": "wav2lip",
|
|
"description": "Model variant (gan = higher quality but slower)",
|
|
},
|
|
"face_padding": {
|
|
"type": "array",
|
|
"items": {"type": "integer"},
|
|
"minItems": 4,
|
|
"maxItems": 4,
|
|
"default": [0, 10, 0, 0],
|
|
"description": "Padding around face crop: [top, bottom, left, right]",
|
|
},
|
|
"resize_factor": {
|
|
"type": "integer",
|
|
"default": 1,
|
|
"description": "Downscale factor for faster processing",
|
|
},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(
|
|
cpu_cores=2, ram_mb=4096, vram_mb=4096, disk_mb=2000
|
|
)
|
|
idempotency_key_fields = ["video_path", "audio_path", "model", "face_padding", "resize_factor"]
|
|
side_effects = ["writes lip-synced video to output_path"]
|
|
user_visible_verification = [
|
|
"Watch output video to verify lip movements match the new audio",
|
|
"Check face region for visual artifacts or jitter",
|
|
]
|
|
|
|
def get_status(self) -> ToolStatus:
|
|
"""Check Wav2Lip availability via env var or Python import."""
|
|
# Check WAV2LIP_PATH environment variable
|
|
wav2lip_path = os.environ.get("WAV2LIP_PATH")
|
|
if wav2lip_path and Path(wav2lip_path).is_dir():
|
|
return ToolStatus.AVAILABLE
|
|
|
|
# Fallback: try importing wav2lip as a Python package
|
|
try:
|
|
import wav2lip # noqa: F401
|
|
return ToolStatus.AVAILABLE
|
|
except ImportError:
|
|
pass
|
|
|
|
return ToolStatus.UNAVAILABLE
|
|
|
|
def estimate_cost(self, inputs: dict[str, Any]) -> float:
|
|
return 0.0 # local GPU, free
|
|
|
|
def _resolve_wav2lip_dir(self) -> Path | None:
|
|
"""Locate the Wav2Lip installation directory."""
|
|
wav2lip_path = os.environ.get("WAV2LIP_PATH")
|
|
if wav2lip_path:
|
|
p = Path(wav2lip_path)
|
|
if p.is_dir():
|
|
return p
|
|
|
|
# Fallback: check if wav2lip is importable and find its location
|
|
try:
|
|
import wav2lip
|
|
return Path(wav2lip.__file__).parent
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
|
|
return None
|
|
|
|
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
|
if self.get_status() != ToolStatus.AVAILABLE:
|
|
return ToolResult(
|
|
success=False,
|
|
error="Wav2Lip not available. " + self.install_instructions,
|
|
)
|
|
|
|
video_path = Path(inputs["video_path"])
|
|
audio_path = Path(inputs["audio_path"])
|
|
|
|
if not video_path.exists():
|
|
return ToolResult(success=False, error=f"Video not found: {video_path}")
|
|
if not audio_path.exists():
|
|
return ToolResult(success=False, error=f"Audio not found: {audio_path}")
|
|
|
|
output_path = Path(
|
|
inputs.get("output_path", str(video_path.with_stem(f"{video_path.stem}_lipsync")))
|
|
)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
model_variant = inputs.get("model", "wav2lip")
|
|
face_padding = inputs.get("face_padding", [0, 10, 0, 0])
|
|
resize_factor = inputs.get("resize_factor", 1)
|
|
|
|
wav2lip_dir = self._resolve_wav2lip_dir()
|
|
if wav2lip_dir is None:
|
|
return ToolResult(
|
|
success=False,
|
|
error="Could not locate Wav2Lip directory. " + self.install_instructions,
|
|
)
|
|
|
|
checkpoint = wav2lip_dir / "checkpoints" / MODEL_CHECKPOINTS[model_variant]
|
|
if not checkpoint.exists():
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Model checkpoint not found: {checkpoint}",
|
|
)
|
|
|
|
inference_script = wav2lip_dir / "inference.py"
|
|
if not inference_script.exists():
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Inference script not found: {inference_script}",
|
|
)
|
|
|
|
start = time.time()
|
|
|
|
cmd = [
|
|
"python", str(inference_script),
|
|
"--checkpoint_path", str(checkpoint),
|
|
"--face", str(video_path),
|
|
"--audio", str(audio_path),
|
|
"--outfile", str(output_path),
|
|
"--pads", *[str(p) for p in face_padding],
|
|
"--resize_factor", str(resize_factor),
|
|
]
|
|
|
|
try:
|
|
self.run_command(cmd, timeout=600, cwd=wav2lip_dir)
|
|
except subprocess.TimeoutExpired:
|
|
return ToolResult(success=False, error="Lip-sync timed out after 600 seconds")
|
|
except Exception as e:
|
|
return ToolResult(success=False, error=f"Wav2Lip inference failed: {e}")
|
|
|
|
if not output_path.exists():
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Inference completed but output file missing: {output_path}",
|
|
)
|
|
|
|
elapsed = time.time() - start
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"video_input": str(video_path),
|
|
"audio_input": str(audio_path),
|
|
"output": str(output_path),
|
|
"model": model_variant,
|
|
"resize_factor": resize_factor,
|
|
"face_padding": face_padding,
|
|
},
|
|
artifacts=[str(output_path)],
|
|
duration_seconds=round(elapsed, 2),
|
|
model=model_variant,
|
|
)
|