guanghulab/video-ai-system/engines/ep01_shot03_production.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

581 lines
20 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EP01-SHOT03-PRODUCTION-CLI
一键跑苏白站牌匾下说台词:生成底片、合成牌匾、配音、口型、字幕、混音、质检。
功能:
1. 读取 E1-SHOT03 配置
2. 生成底片 (MULTI-REFERENCE-VIDEO-ADAPTER)
3. 合成牌匾 (平面追踪 + 贴图)
4. 配音 (VOICE-EMOTION-COMPILER)
5. 口型 (LIPSYNC-ADAPTER)
6. 字幕 (subtitle-renderer.py)
7. 混音 (AUDIO-MIXER)
8. 质检 (SHOT-QC-AUTOMATION)
9. 输出生产报告
用法:
python ep01_shot03_production.py --run
python ep01_shot03_production.py --dry-run # 只打印计划,不执行
python ep01_shot03_production.py --resume task_id.json # 从失败点恢复
"""
import os
import sys
import json
import argparse
import subprocess
from pathlib import Path
from datetime import datetime
import time
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "engines"))
class EP01Shot03ProductionCLI:
"""E1-SHOT03 一键生产 CLI"""
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.project = "zai-fu-fei-xiu-xian/ep01"
self.shot_id = "E1-SHOT03"
self.start_time = datetime.now()
# 路径配置
self.config_dir = PROJECT_ROOT / "plans" / "script-to-screen"
self.output_dir = PROJECT_ROOT / "outputs" / "ep01" / "shot03"
self.assets_dir = PROJECT_ROOT / "assets"
# 确保输出目录存在
self.output_dir.mkdir(parents=True, exist_ok=True)
# 加载配置
self.config = self._load_config()
# 生产状态
self.state = {
"shot_id": self.shot_id,
"steps": {},
"start_time": self.start_time.isoformat(),
"end_time": None,
"status": "running", # running / success / failed
"current_step": None,
"error": None
}
print(f"🎬 E1-SHOT03 生产 CLI 启动")
print(f" 项目: {self.project}")
print(f" 镜头: {self.shot_id}")
print(f" 输出: {self.output_dir}")
print(f" Dry Run: {self.dry_run}")
def _load_config(self):
"""加载 E1-SHOT03 配置"""
config_file = self.config_dir / "EP01-SHOT01-06-MAPPING.hdlp"
if not config_file.exists():
print(f"⚠️ 配置不存在: {config_file}")
return self._default_config()
# 简单解析 HLDP 文件
config = {
"shot_id": "E1-SHOT03",
"prompt": "",
"duration": 5,
"resolution": "720p",
"character": "CHAR-003-SuBai",
"props": ["PROP-TDZ-PLAQUE"],
"env": "ENV-002-Baizonghui",
"dialogue": "",
"emotion": "苏白·大声·自信"
}
with open(config_file, "r", encoding="utf-8") as f:
content = f.read()
# 找 E1-SHOT03 段落
if "E1-SHOT03" in content:
lines = content.split("\n")
in_shot = False
for line in lines:
if "E1-SHOT03" in line:
in_shot = True
elif in_shot:
if line.strip().startswith("---"):
break
if ":" in line:
key, _, val = line.partition(":")
config[key.strip()] = val.strip()
# 如果没找到台词,使用默认
if not config.get("dialogue"):
config["dialogue"] = "未来的天下第一宗!"
if not config.get("prompt"):
config["prompt"] = "苏白站在天道宗牌匾下,自信地说:未来的天下第一宗!"
print(f" ✓ 配置已加载")
print(f" 提示词: {config['prompt'][:60]}...")
print(f" 台词: {config['dialogue']}")
print(f" 情感: {config['emotion']}")
return config
def _default_config(self):
"""默认配置"""
return {
"shot_id": "E1-SHOT03",
"prompt": "苏白站在天道宗牌匾下,自信地说:未来的天下第一宗!",
"duration": 5,
"resolution": "720p",
"character": "CHAR-003-SuBai",
"props": ["PROP-TDZ-PLAQUE"],
"env": "ENV-002-Baizonghui",
"dialogue": "未来的天下第一宗!",
"emotion": "苏白·大声·自信"
}
def run(self):
"""执行完整生产流程"""
print(f"\n{'=' * 60}")
print(f"开始生产 E1-SHOT03")
print(f"{'=' * 60}")
steps = [
("step1_prepare_assets", self.step1_prepare_assets),
("step2_generate_base_video", self.step2_generate_base_video),
("step3_synthesize_plaque", self.step3_synthesize_plaque),
("step4_generate_dialogue_audio", self.step4_generate_dialogue_audio),
("step5_lipsync", self.step5_lipsync),
("step6_render_subtitles", self.step6_render_subtitles),
("step7_mix_audio", self.step7_mix_audio),
("step8_qc", self.step8_qc),
("step9_generate_report", self.step9_generate_report),
]
for step_name, step_func in steps:
print(f"\n📍 步骤: {step_name}")
self.state["current_step"] = step_name
if self.dry_run:
print(f" [Dry Run] 跳过: {step_func.__doc__}")
self.state["steps"][step_name] = {"status": "skipped", "reason": "dry_run"}
continue
try:
step_start = time.time()
result = step_func()
step_duration = time.time() - step_start
self.state["steps"][step_name] = {
"status": "success",
"duration": f"{step_duration:.1f}s",
"result": result
}
print(f" ✅ 完成 ({step_duration:.1f}s)")
except Exception as e:
print(f" ❌ 失败: {e}")
self.state["steps"][step_name] = {
"status": "failed",
"error": str(e)
}
self.state["status"] = "failed"
self.state["error"] = f"{step_name}: {e}"
self._save_state()
return False
self.state["status"] = "success"
self.state["end_time"] = datetime.now().isoformat()
self._save_state()
print(f"\n{'=' * 60}")
print(f"✅ 生产完成!")
print(f" 总耗时: {(datetime.now() - self.start_time).total_seconds():.1f}s")
print(f" 输出: {self.output_dir}")
print(f"{'=' * 60}")
return True
def step1_prepare_assets(self):
"""步骤1: 准备资产 (CHAR-HERO-DESIGN-PACKER)"""
print(f" 准备苏白资产包...")
# 检查是否有批准资产
char_dir = self.assets_dir / "characters" / self.config["character"] / "approved"
if char_dir.exists() and list(char_dir.glob("*.png")):
print(f" ✓ 已有批准资产: {len(list(char_dir.glob('*.png')))}")
return {"assets_ready": True, "path": str(char_dir)}
# 没有批准资产,生成
print(f" 📤 生成资产包...")
result = subprocess.run(
[
"python", str(PROJECT_ROOT / "engines" / "char-hero-design-packer" / "char-hero-design-packer.py"),
"--character", self.config["character"],
"--generate-all"
],
capture_output=True,
text=True,
timeout=600
)
if result.returncode != 0:
raise Exception(f"资产生成失败: {result.stderr}")
return {"assets_ready": True, "path": str(char_dir)}
def step2_generate_base_video(self):
"""步骤2: 生成底片 (MULTI-REFERENCE-VIDEO-ADAPTER)"""
print(f" 生成底片...")
print(f" 提示词: {self.config['prompt'][:60]}...")
print(f" 时长: {self.config['duration']}s")
print(f" 分辨率: {self.config['resolution']}")
# 收集参考图
reference_images = []
# 苏白参考图
char_dir = self.assets_dir / "characters" / self.config["character"] / "approved"
if char_dir.exists():
for img in char_dir.glob("*.png"):
reference_images.append(str(img))
# 牌匾参考图
for prop in self.config.get("props", []):
prop_dir = self.assets_dir / "props" / prop / "approved"
if prop_dir.exists():
for img in prop_dir.glob("*.png"):
reference_images.append(str(img))
if len(reference_images) == 0:
print(f" ⚠️ 无参考图,使用单参考图模式")
# 调用 MULTI-REFERENCE-VIDEO-ADAPTER
output_path = self.output_dir / "base_video.mp4"
if len(reference_images) >= 2:
# 多参考图模式
cmd = [
"python", str(PROJECT_ROOT / "engines" / "multi-reference-video-adapter" / "multi-reference-video-adapter.py"),
"--prompt", self.config["prompt"],
"--references"] + reference_images + [
"--output", str(output_path),
"--duration", str(self.config["duration"]),
"--resolution", self.config["resolution"]
]
else:
# 单参考图模式 (回退)
cmd = [
"node", str(PROJECT_ROOT / "engines" / "video-api-adapter.js"),
"--prompt", self.config["prompt"],
"--duration", str(self.config["duration"]),
"--resolution", self.config["resolution"]
]
if reference_images:
cmd.extend(["--reference-image", reference_images[0]])
print(f" 参考图数量: {len(reference_images)}")
print(f" 输出: {output_path.name}")
# TODO: 实际调用 API (这里简化为记录命令)
# result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
return {
"output_path": str(output_path),
"reference_count": len(reference_images),
"note": "TODO: 实际调用 API 生成视频"
}
def step3_synthesize_plaque(self):
"""步骤3: 合成牌匾 (平面追踪 + 贴图)"""
print(f" 合成牌匾...")
# 检查是否有 base_video
base_video = self.output_dir / "base_video.mp4"
if not base_video.exists():
print(f" ⚠️ base_video.mp4 不存在,跳过牌匾合成")
return {"skipped": True, "reason": "base_video not found"}
# 调用 planar-tracker.py
print(f" 运行平面追踪...")
output_with_plaque = self.output_dir / "video_with_plaque.mp4"
# TODO: 实际调用 planar-tracker.py
# cmd = ["python", str(PROJECT_ROOT / "engines" / "planar-tracker.py"), ...]
return {
"output_path": str(output_with_plaque),
"note": "TODO: 实际调用 planar-tracker.py"
}
def step4_generate_dialogue_audio(self):
"""步骤4: 生成对白音频 (VOICE-EMOTION-COMPILER)"""
print(f" 生成对白音频...")
print(f" 台词: {self.config['dialogue']}")
print(f" 情感: {self.config['emotion']}")
output_path = self.output_dir / "dialogue.mp3"
cmd = [
"python", str(PROJECT_ROOT / "engines" / "voice-emotion-compiler" / "voice-emotion-compiler.py"),
"--text", self.config["dialogue"],
"--emotion", self.config["emotion"],
"--output", str(output_path)
]
print(f" 输出: {output_path.name}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise Exception(f"配音失败: {result.stderr}")
return {"output_path": str(output_path)}
def step5_lipsync(self):
"""步骤5: 口型同步 (LIPSYNC-ADAPTER)"""
print(f" 口型同步...")
video_path = self.output_dir / "video_with_plaque.mp4"
if not video_path.exists():
video_path = self.output_dir / "base_video.mp4"
audio_path = self.output_dir / "dialogue.mp3"
if not video_path.exists():
raise Exception(f"视频不存在: {video_path}")
if not audio_path.exists():
raise Exception(f"音频不存在: {audio_path}")
output_path = self.output_dir / "video_synced.mp4"
cmd = [
"python", str(PROJECT_ROOT / "engines" / "lipsync-adapter" / "lipsync-adapter.py"),
"--video", str(video_path),
"--audio", str(audio_path),
"--output", str(output_path)
]
print(f" 输入视频: {video_path.name}")
print(f" 输入音频: {audio_path.name}")
print(f" 输出: {output_path.name}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
print(f" ⚠️ 口型同步失败: {result.stderr}")
print(f" 📌 继续使用非同步视频...")
# 不抛出异常,继续流程
return {"warning": "Lipsync failed, continuing with unsynced video"}
return {"output_path": str(output_path)}
def step6_render_subtitles(self):
"""步骤6: 渲染字幕 (subtitle-renderer.py)"""
print(f" 渲染字幕...")
video_path = self.output_dir / "video_synced.mp4"
if not video_path.exists():
video_path = self.output_dir / "video_with_plaque.mp4"
if not video_path.exists():
video_path = self.output_dir / "base_video.mp4"
if not video_path.exists():
raise Exception(f"视频不存在: {video_path}")
output_path = self.output_dir / "video_with_subtitles.mp4"
# 调用 subtitle-renderer.py
cmd = [
"python", str(PROJECT_ROOT / "engines" / "subtitle-renderer.py"),
"--input", str(video_path),
"--text", self.config["dialogue"],
"--output", str(output_path)
]
print(f" 输入: {video_path.name}")
print(f" 字幕: {self.config['dialogue']}")
print(f" 输出: {output_path.name}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
print(f" ⚠️ 字幕渲染失败: {result.stderr}")
print(f" 📌 继续使用无字幕视频...")
return {"warning": "Subtitle rendering failed, continuing without subtitles"}
return {"output_path": str(output_path)}
def step7_mix_audio(self):
"""步骤7: 混音 (AUDIO-MIXER)"""
print(f" 混音...")
dialogue_path = self.output_dir / "dialogue.mp3"
output_path = self.output_dir / "final_video.mp4"
# 找到带字幕的视频
video_path = self.output_dir / "video_with_subtitles.mp4"
if not video_path.exists():
video_path = self.output_dir / "video_synced.mp4"
if not video_path.exists():
video_path = self.output_dir / "video_with_plaque.mp4"
if not video_path.exists():
video_path = self.output_dir / "base_video.mp4"
if not video_path.exists():
raise Exception(f"视频不存在: {video_path}")
# 提取视频音轨
video_audio = self.output_dir / "video_audio.mp3"
print(f" 提取视频音轨...")
# TODO: 实际调用 ffmpeg 提取音轨
# 混音
print(f" 混音...")
cmd = [
"python", str(PROJECT_ROOT / "engines" / "audio-mixer" / "audio-mixer.py"),
"--dialogue", str(dialogue_path),
"--output", str(self.output_dir / "mixed_audio.mp3")
]
if video_audio.exists():
cmd.extend(["--original", str(video_audio)])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
print(f" ⚠️ 混音失败: {result.stderr}")
# 合并视频和混音后的音频
print(f" 合并视频和音频...")
# TODO: ffmpeg -i video -i audio -c:v copy -c:a aac output
return {"output_path": str(output_path), "note": "TODO: 实际合并视频和音频"}
def step8_qc(self):
"""步骤8: 质检 (SHOT-QC-AUTOMATION)"""
print(f" 质检...")
video_path = self.output_dir / "final_video.mp4"
if not video_path.exists():
# 找任何可用的视频
for v in self.output_dir.glob("*.mp4"):
video_path = v
break
if not video_path or not video_path.exists():
print(f" ⚠️ 无视频文件可质检")
return {"skipped": True, "reason": "no video found"}
cmd = [
"python", str(PROJECT_ROOT / "engines" / "shot-qc-automation" / "shot-qc-automation.py"),
"--video", str(video_path),
"--character", self.config["character"],
"--output", str(self.output_dir / "qc_report.json")
]
print(f" 输入: {video_path.name}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
print(f" ⚠️ 质检失败: {result.stderr}")
return {"warning": "QC failed"}
# 读取 QC 报告
qc_report_path = self.output_dir / "qc_report.json"
if qc_report_path.exists():
with open(qc_report_path, "r", encoding="utf-8") as f:
qc_result = json.load(f)
passed = qc_result.get("passed", False)
score = qc_result.get("score", 0)
print(f" QC 结果: {'✅ 通过' if passed else '❌ 失败'} (分数: {score:.1f}/10)")
return {"passed": passed, "score": score, "report": str(qc_report_path)}
return {"passed": None, "warning": "QC report not found"}
def step9_generate_report(self):
"""步骤9: 生成生产报告"""
print(f" 生成生产报告...")
report_path = self.output_dir / f"production_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(report_path, "w", encoding="utf-8") as f:
json.dump(self.state, f, ensure_ascii=False, indent=2)
print(f" ✅ 报告已保存: {report_path.name}")
# 打印总结
print(f"\n📊 生产总结")
print(f" 状态: {self.state['status']}")
print(f" 步骤数: {len(self.state['steps'])}")
success_count = sum(1 for s in self.state["steps"].values() if s.get("status") == "success")
print(f" 成功: {success_count}/{len(self.state['steps'])}")
return {"report_path": str(report_path)}
def _save_state(self):
"""保存生产状态"""
state_path = self.output_dir / "production_state.json"
with open(state_path, "w", encoding="utf-8") as f:
json.dump(self.state, f, ensure_ascii=False, indent=2)
def resume(self, state_file):
"""从失败点恢复"""
print(f"\n📂 从失败点恢复: {state_file}")
state_path = Path(state_file)
if not state_path.exists():
print(f" ❌ 状态文件不存在: {state_file}")
return False
with open(state_path, "r", encoding="utf-8") as f:
self.state = json.load(f)
print(f" 上次状态: {self.state['status']}")
print(f" 当前步骤: {self.state['current_step']}")
# 找到当前步骤,继续执行
steps = list(self.state["steps"].keys())
if self.state["current_step"] in steps:
start_idx = steps.index(self.state["current_step"])
else:
start_idx = 0
print(f" 从第 {start_idx + 1} 步继续...")
# TODO: 实际恢复逻辑
return True
def main():
parser = argparse.ArgumentParser(description="EP01-SHOT03-PRODUCTION-CLI")
parser.add_argument("--run", action="store_true", help="执行生产")
parser.add_argument("--dry-run", action="store_true", help="Dry Run (只打印计划)")
parser.add_argument("--resume", type=str, help="从状态文件恢复")
args = parser.parse_args()
if not args.run and not args.dry_run and not args.resume:
parser.print_help()
sys.exit(1)
if args.resume:
cli = EP01Shot03ProductionCLI(dry_run=False)
cli.resume(args.resume)
else:
cli = EP01Shot03ProductionCLI(dry_run=args.dry_run)
success = cli.run()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()