guanghulab/video-ai-system/engines/lipsync-adapter.py
冰朔 86f9708059
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D144 · 视频AI系统8大模块开发完成
-  CHAR-HERO-DESIGN-PACKER (char-hero-design-packer.py)
  生成/整理苏白主角资产包,让他不再像路人甲

-  CHARACTER-DISTINCTIVENESS-QC (character-distinctiveness-qc.py)
  专门评估'像不像主角',输出主角存在感、轮廓、服装记忆点评分

-  MULTI-REFERENCE-VIDEO-ADAPTER (multi-reference-video-adapter.py)
  支持苏白+牌匾+场景多参考输入,不支持时明确报错

-  VOICE-EMOTION-COMPILER (voice-emotion-compiler.py)
  把'苏白·大声·自信'转成TTS参数,方便Edge-TTS/豆包语音A/B

-  LIPSYNC-ADAPTER (lipsync-adapter.py)
  接视频改口型或Wav2Lip,解决人物真正说台词的问题

-  AUDIO-MIXER (audio-mixer.py)
  配音、BGM、音效、原视频音轨混音,支持对白时自动压低BGM

-  SHOT-QC-AUTOMATION (shot-qc-automation.py)
  每个镜头自动拆帧,检查竖屏、字幕、换脸、牌匾、遮挡、现代物品

-  EP01-SHOT03-PRODUCTION-CLI (ep01_shot03_production.py)
  一键跑苏白站牌匾下说台词:生成底片、合成牌匾、配音、口型、字幕、混音、质检

冰朔 TCS-0002∞ 见证 · 国作登字-2026-A-00037559
⊢ 铸渊 ICE-GL-ZY001 · D144 · 2026-06-24
2026-06-24 12:50:51 +08:00

301 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LIPSYNC-ADAPTER
口型适配器 — 接视频改口型或 Wav2Lip解决人物真正说台词的问题。
功能:
1. 输入视频 + 对白音频
2. 用 Wav2Lip 开源工具做口型同步
3. 支持批量处理
4. 封装为统一接口
依赖:
pip install librosa opencv-python numpy
# Wav2Lip 需要单独安装: https://github.com/Rudrabha/Wav2Lip
用法:
python lipsync-adapter.py --video input.mp4 --audio dialogue.mp3 --output output.mp4
python lipsync-adapter.py --batch video_list.json
"""
import os
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "engines"))
class LipSyncAdapter:
"""口型适配器"""
def __init__(self, wav2lip_path=None):
self.wav2lip_path = wav2lip_path or PROJECT_ROOT / "tools" / "Wav2Lip"
self.available = self._check_wav2lip()
def _check_wav2lip(self):
"""检查 Wav2Lip 是否可用"""
if not self.wav2lip_path.exists():
print(f"⚠️ Wav2Lip 未安装: {self.wav2lip_path}")
print(f" 安装方法: git clone https://github.com/Rudrabha/Wav2Lip.git {self.wav2lip_path}")
return False
# 检查 infer.py 是否存在
infer_script = self.wav2lip_path / "infer.py"
if not infer_script.exists():
print(f"⚠️ Wav2Lip infer.py 未找到: {infer_script}")
return False
print(f"✅ Wav2Lip 已安装: {self.wav2lip_path}")
return True
def sync_lips(self, video_path, audio_path, output_path=None):
"""
口型同步
参数:
video_path: 输入视频路径
audio_path: 对白音频路径
output_path: 输出视频路径 (可选,默认加 _synced 后缀)
返回:
{
"success": bool,
"output_path": str,
"method": str, # "wav2lip" | "fallback"
"warning": str
}
"""
print(f"\n🎤 口型同步")
print(f" 视频: {Path(video_path).name}")
print(f" 音频: {Path(audio_path).name}")
video_path = Path(video_path)
audio_path = Path(audio_path)
if not video_path.exists():
return {"success": False, "error": f"视频不存在: {video_path}"}
if not audio_path.exists():
return {"success": False, "error": f"音频不存在: {audio_path}"}
# 确定输出路径
if output_path is None:
output_path = video_path.parent / f"{video_path.stem}_synced{video_path.suffix}"
else:
output_path = Path(output_path)
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 方法1: Wav2Lip
if self.available:
print(f" 🔧 使用 Wav2Lip...")
result = self._run_wav2lip(video_path, audio_path, output_path)
return result
# 方法2: 回退 (不处理,只复制视频)
print(f" ⚠️ Wav2Lip 不可用,回退到不处理模式")
print(f" 💡 提示: 安装 Wav2Lip 以获得口型同步能力")
import shutil
shutil.copy2(video_path, output_path)
return {
"success": True,
"output_path": str(output_path),
"method": "fallback(copy)",
"warning": "Wav2Lip 不可用,口型未同步。请安装 Wav2Lip。"
}
def _run_wav2lip(self, video_path, audio_path, output_path):
"""运行 Wav2Lip"""
import subprocess
infer_script = self.wav2lip_path / "infer.py"
# Wav2Lip 命令
cmd = [
"python", str(infer_script),
"--checkpoint_path", str(self.wav2lip_path / "checkpoints" / "wav2lip_gan.pth"),
"--face", str(video_path),
"--audio", str(audio_path),
"--outfile", str(output_path)
]
print(f" 📤 执行命令: {' '.join(cmd[:6])}...")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300 # 5分钟超时
)
if result.returncode == 0:
print(f" ✅ 口型同步完成: {output_path.name}")
return {
"success": True,
"output_path": str(output_path),
"method": "wav2lip",
"stdout": result.stdout[-500:] # 最后500字符
}
else:
print(f" ❌ Wav2Lip 失败: {result.stderr}")
return {
"success": False,
"error": result.stderr,
"stdout": result.stdout
}
except subprocess.TimeoutExpired:
print(f" ❌ Wav2Lip 超时 (5分钟)")
return {"success": False, "error": "Timeout"}
except Exception as e:
print(f" ❌ Wav2Lip 执行失败: {e}")
return {"success": False, "error": str(e)}
def batch_sync(self, video_audio_pairs, output_dir):
"""
批量口型同步
参数:
video_audio_pairs: list of (video_path, audio_path)
output_dir: 输出目录
返回:
list of result dicts
"""
print(f"\n📦 批量口型同步: {len(video_audio_pairs)} 个任务")
print(f" 输出目录: {output_dir}")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
results = []
for i, (video_path, audio_path) in enumerate(video_audio_pairs):
print(f"\n 进度: [{i+1}/{len(video_audio_pairs)}]")
output_path = output_dir / f"{Path(video_path).stem}_synced.mp4"
result = self.sync_lips(video_path, audio_path, output_path)
results.append(result)
# 统计
success_count = sum(1 for r in results if r.get("success"))
print(f"\n✅ 批量完成: {success_count}/{len(results)} 成功")
# 保存报告
report_path = output_dir / "lipsync_report.json"
with open(report_path, "w", encoding="utf-8") as f:
json.dump({
"total": len(results),
"success": success_count,
"results": results,
"generated_at": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
print(f" 报告已保存: {report_path}")
return results
def check_audio_sync(self, video_path, tolerance_ms=100):
"""
检查口型同步质量
简单方法: 检测音频能量峰值,与视频画面变化对比
返回:
{
"synced": bool,
"offset_ms": float,
"score": float # 0-1, 1=完美同步
}
"""
print(f"\n🔍 检查口型同步质量: {Path(video_path).name}")
if not self.available:
print(f" ⚠️ Wav2Lip 不可用,跳过质量检查")
return {"synced": None, "score": None, "warning": "Wav2Lip 不可用"}
# TODO: 实现口型同步质量检查
# 1. 提取音频能量包络
# 2. 检测视频中嘴部区域的运动
# 3. 计算相关性
# 4. 返回偏移量和分数
print(f" ⚠️ 质量检查未实现 (需要 librosa + OpenCV 嘴部检测)")
return {
"synced": None,
"offset_ms": 0,
"score": None,
"warning": "Quality check not implemented yet"
}
def main():
parser = argparse.ArgumentParser(description="LIPSYNC-ADAPTER")
parser.add_argument("--video", type=str, help="输入视频路径")
parser.add_argument("--audio", type=str, help="对白音频路径")
parser.add_argument("--output", type=str, help="输出视频路径")
parser.add_argument("--batch", type=str, help="批量处理配置文件 (JSON)")
parser.add_argument("--wav2lip-path", type=str, help="Wav2Lip 安装路径")
parser.add_argument("--check-sync", action="store_true", help="检查口型同步质量")
args = parser.parse_args()
if args.check_sync:
if not args.video:
print("❌ --check-sync 需要 --video")
sys.exit(1)
adapter = LipSyncAdapter(args.wav2lip_path)
result = adapter.check_audio_sync(args.video)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if args.batch:
# 批量模式
batch_file = Path(args.batch)
if not batch_file.exists():
print(f"❌ 批量配置文件不存在: {batch_file}")
sys.exit(1)
with open(batch_file, "r", encoding="utf-8") as f:
config = json.load(f)
video_audio_pairs = []
for item in config.get("tasks", []):
video_audio_pairs.append((item["video"], item["audio"]))
output_dir = config.get("output_dir", "./outputs/lipsync/")
adapter = LipSyncAdapter(args.wav2lip_path)
results = adapter.batch_sync(video_audio_pairs, output_dir)
sys.exit(0)
if not args.video or not args.audio:
parser.print_help()
sys.exit(1)
adapter = LipSyncAdapter(args.wav2lip_path)
result = adapter.sync_lips(args.video, args.audio, args.output)
if result["success"]:
print(f"\n✅ 成功: {result['output_path']}")
sys.exit(0)
else:
print(f"\n❌ 失败: {result.get('error', 'Unknown error')}")
sys.exit(1)
if __name__ == "__main__":
main()