cang-ying/engines/ep01_shot03_production.py

648 lines
24 KiB
Python
Raw Normal View History

#!/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"))
DEFAULT_PYTHON = "/Users/bingshuolingdianyuanhe/.workbuddy/binaries/python/envs/default/bin/python3"
def resolve_python_bin():
"""Pick the Python that has the video AI dependencies installed."""
candidates = [
os.environ.get("PYTHON_BIN"),
os.environ.get("PYTHON"),
DEFAULT_PYTHON,
sys.executable,
"python3",
]
for candidate in candidates:
if not candidate:
continue
candidate_path = Path(candidate)
if candidate_path.is_absolute() and not candidate_path.exists():
continue
return str(candidate)
return sys.executable
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.python_bin = resolve_python_bin()
# 确保输出目录存在
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}")
print(f" Python: {self.python_bin}")
def _load_config(self):
"""加载 E1-SHOT03 配置,从剧本原文+分镜文件读取真实台词"""
script_file = PROJECT_ROOT.parent / "动态漫:《付费才能修仙?我的宗门全免费》.md"
mapping_file = self.config_dir / "EP01-SHOT01-06-MAPPING.hdlp"
storyboard_file = PROJECT_ROOT / "data" / "ep01-storyboard.json"
config = {
"shot_id": "E1-SHOT03",
"prompt": "",
"duration": 3,
"resolution": "720p",
"character": "CHAR-003-SuBai",
"props": ["PROP-TDZ-PLAQUE"],
"env": "ENV-002-Baizonghui",
"dialogue": "",
"emotion": "苏白·大声·自信"
}
# 1. 从剧本原文读取真实台词
if script_file.exists():
with open(script_file, "r", encoding="utf-8") as f:
script_text = f.read()
# 匹配 SHOT03 对应的剧本段落
import re
# 找"苏白(大声):"后面的台词
dialogue_matches = re.findall(r'苏白[(][^)]*[)]\s*[:]\s*(.+?)(?:\n|$)', script_text)
if dialogue_matches:
config["dialogue"] = dialogue_matches[0] # 第一句:未来的天下第一宗!天道宗开门收徒啦!
print(f" 📖 剧本台词(第1句): {config['dialogue']}")
# 拼接多句台词
if len(dialogue_matches) >= 2:
config["dialogue_all"] = dialogue_matches
print(f" 📖 全部{len(dialogue_matches)}句: {' | '.join(dialogue_matches[:3])}")
# 2. 从分镜文件读取镜头信息
if storyboard_file.exists():
with open(storyboard_file, "r", encoding="utf-8") as f:
storyboard = json.load(f)
for shot in storyboard.get("shots", []):
if shot.get("shotId") == "E1-SHOT03":
config["action"] = shot.get("action", "")
config["camera"] = shot.get("camera", "")
config["emotion"] = shot.get("emotionMarker", config["emotion"])
config["duration"] = shot.get("duration", config["duration"])
config["sourceLine"] = shot.get("sourceLine", "")
print(f" 🎬 分镜: {config.get('action', '')[:60]}")
break
# 3. 从HLDP映射文件读取镜头参数不覆盖剧本台词
SCRIPT_PROTECTED = {"dialogue", "dialogue_all", "action", "camera", "emotion", "sourceLine", "duration"}
if mapping_file.exists():
with open(mapping_file, "r", encoding="utf-8") as f:
mapping_content = f.read()
if "E1-SHOT03" in mapping_content:
lines = mapping_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(":")
key = key.strip()
if key not in SCRIPT_PROTECTED:
config[key] = val.strip()
# 构造提示词
if not config.get("prompt"):
action = config.get("action", "苏白站在天道宗牌匾下自信喊话")
dialogue = config.get("dialogue", "未来的天下第一宗!")
config["prompt"] = f"{action},大声说:{dialogue}"
print(f" ✅ 配置已加载 (来源: 剧本原文 + 分镜 + 映射)")
print(f" 台词: {config['dialogue'][:50]}")
print(f" 情感: {config['emotion']}")
print(f" 时长: {config['duration']}s")
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(
[
self.python_bin, 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 = [
self.python_bin, str(PROJECT_ROOT / "engines" / "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 = [self.python_bin, 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 = [
self.python_bin, str(PROJECT_ROOT / "engines" / "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 = [
self.python_bin, str(PROJECT_ROOT / "engines" / "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"
srt_path = self.output_dir / "dialogue.srt"
self._write_single_line_srt(srt_path, self.config["dialogue"], float(self.config.get("duration", 3)))
# 调用 subtitle-renderer.py
cmd = [
self.python_bin, str(PROJECT_ROOT / "engines" / "subtitle-renderer.py"),
"--srt", str(srt_path),
"--video", str(video_path),
"--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 = [
self.python_bin, str(PROJECT_ROOT / "engines" / "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 = [
self.python_bin, str(PROJECT_ROOT / "engines" / "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 _write_single_line_srt(self, srt_path, text, duration):
"""Write a one-line SRT from the locked source dialogue."""
duration_ms = max(500, int(duration * 1000))
end_seconds = duration_ms // 1000
end_ms = duration_ms % 1000
srt_path.parent.mkdir(parents=True, exist_ok=True)
content = (
"1\n"
f"00:00:00,000 --> 00:00:{end_seconds:02d},{end_ms:03d}\n"
f"{text}\n"
)
with open(srt_path, "w", encoding="utf-8") as f:
f.write(content)
return srt_path
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()