202 lines
6.8 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
"""
route_shots.py — BC-006 逐镜路由分配器
读取分镜JSON调豆包AI为每镜分配最优生产路由
- STILL_HOLD → 单帧出图¥0
- LOCAL_MOTION → 出图+FFmpeg运镜¥0
- LAYERED_2_5D → 多层拆解+伪3D运镜¥0
- LOCAL_LIPSYNC → LivePortrait口型同步¥0
- AI_I2V → LTX 2.3 图生视频(唯一主力 · 本地 ¥0
用法:
python tools/route_shots.py <分镜JSON> [-o 输出JSON]
python tools/route_shots.py <分镜JSON> --dry-run
"""
import sys, os, json, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.doubao_chat import chat
ROUTE_DESCRIPTION = """
为每个镜头分配最优生产路由,可选路由类型及其含义:
STILL_HOLD: 静态画面/过渡镜头。角色无运动,仅背景。直接单帧出图即可。成本最低。
- 适合: 空镜、过渡、环境展示、静态对话
- 成本: ¥0 (仅出图电费)
LOCAL_MOTION: 画面有简单运动角色小幅度动作、镜头缓慢推拉摇移。出图后用FFmpeg做缩放/平移/旋转模拟运镜。
- 适合: 缓慢摇镜、推近拉远、轻微角色动作
- 成本: ¥0 (FFmpeg运镜)
LAYERED_2_5D: 画面有前后景分层需求。角色在画面中需要与背景分离做视差效果。
- 适合: 角色在前景有复杂动作但背景固定、需要2.5D伪3D效果
- 成本: ¥0 (分层合成)
LOCAL_LIPSYNC: 角色有说话/表情需求需要口型同步。用LivePortrait处理。
- 适合: 角色特写说话、重要表情表演
- 成本: ¥0 (LivePortrait本地)
AI_I2V: 画面有复杂运动角色全身运动、物体移动、镜头剧烈运动。必须用AI图生视频。
- 适合: 角色行走奔跑、打斗动作、物体运动、镜头跟随
- 成本: ¥0 (LTX 2.3 本地 · 唯一主力)
"""
def extract_json(text):
"""从AI回复提取JSON"""
if '```json' in text:
return text.split('```json')[1].split('```')[0].strip()
if '```' in text:
return text.split('```')[1].split('```')[0].strip()
maybe = text.strip()
if maybe.startswith('{') or maybe.startswith('['):
return maybe
return maybe
def route_shots(storyboard, dry_run=False):
"""为分镜中所有镜头分配路由"""
episode = storyboard.get('episode', 1)
shots = storyboard.get('shots', [])
if not shots:
print('❌ 分镜中没有镜头')
return None
# 构建提示词
shots_text = json.dumps([
{
"shot_number": s.get("shot_number"),
"description": s.get("description"),
"camera": s.get("camera"),
"type": s.get("type"),
"characters": s.get("characters", []),
"scenes": s.get("scenes", []),
"dialogue": s.get("dialogue"),
}
for s in shots
], ensure_ascii=False)
prompt = f"""你是一个短剧视频生产管线路由专家。请为以下短剧第{episode}集每个镜头分配最优生产路由。
路由规则:
{ROUTE_DESCRIPTION}
分配原则:
1. 优先使用低成本路由STILL_HOLD > LOCAL_MOTION > LAYERED_2_5D > LOCAL_LIPSYNC > AI_I2V
2. 只有确实需要复杂运动的镜头才用 AI_I2V
3. 有对话的镜头用 LOCAL_LIPSYNC
4. 角色有行走/奔跑/打斗等全身运动用 AI_I2V
5. 空镜/过渡/静态用 STILL_HOLD
输出JSON格式LIST按镜头顺序
[
{{
"shot_number": "S01",
"route": "STILL_HOLD",
"reason": "简短理由"
}},
...
]
分镜数据:
{shots_text}"""
if dry_run:
print('🧪 DRY RUN — 提示词:')
print('=' * 60)
print(prompt[:500])
print('...')
print('=' * 60)
return None
print('🚀 调豆包AI分配路由...')
result = chat(prompt, system="你是短剧生产管线路由优化专家输出纯JSON。", model="pro", temperature=0.2, max_tokens=4096)
if 'error' in result:
print('❌ API ERROR:', json.dumps(result, ensure_ascii=False, indent=2))
return None
content = result.get('content', '')
json_str = extract_json(content)
try:
routes = json.loads(json_str)
if isinstance(routes, dict) and 'shots' in routes:
routes = routes['shots']
print(f'✅ 路由分配完成: {len(routes)}')
return routes
except json.JSONDecodeError as e:
print(f'⚠️ JSON解析失败: {e}')
print('原始回复:', content[:500])
return None
def main():
parser = argparse.ArgumentParser(description='BC-006 逐镜路由分配器')
parser.add_argument('storyboard', help='分镜JSON文件路径')
parser.add_argument('-o', '--output', help='输出JSON路径 (默认: 覆盖原文件添加路由)')
parser.add_argument('--dry-run', action='store_true', help='只打印提示词不调API')
parser.add_argument('--summary', action='store_true', help='只统计各路由数量不调API')
args = parser.parse_args()
with open(args.storyboard, 'r', encoding='utf-8') as f:
storyboard = json.load(f)
shots = storyboard.get('shots', [])
if args.summary:
print(f'📖 分镜: {args.storyboard}')
print(f'🎬 共 {len(shots)}')
print()
print('🔍 各镜头类型分布:')
type_count = {}
for s in shots:
t = s.get('type', 'unknown')
type_count[t] = type_count.get(t, 0) + 1
for t, c in sorted(type_count.items()):
print(f' {t}: {c}')
return
routes = route_shots(shots, dry_run=args.dry_run)
if routes is None:
sys.exit(1)
# 合并路由到分镜
route_map = {}
for r in routes:
if isinstance(r, dict):
sn = r.get('shot_number', '')
route_map[sn] = r.get('route', 'STILL_HOLD')
for shot in shots:
sn = shot.get('shot_number', '')
if sn in route_map:
shot['route'] = route_map[sn]
else:
shot['route'] = 'STILL_HOLD' # 默认
# 统计
route_count = {}
for shot in shots:
r = shot.get('route', 'UNKNOWN')
route_count[r] = route_count.get(r, 0) + 1
print()
print('📊 路由分布:')
for r, c in sorted(route_count.items()):
cost = {'STILL_HOLD': '¥0', 'LOCAL_MOTION': '¥0', 'LAYERED_2_5D': '¥0',
'LOCAL_LIPSYNC': '¥0', 'AI_I2V': '¥0.02'}.get(r, '¥?')
print(f' {r:20s}: {c:2d}镜 ({cost})')
total_cost = route_count.get('AI_I2V', 0) * 0.02
print(f' {"总预估成本":20s}: ¥{total_cost:.2f}')
# 输出
out_path = args.output or args.storyboard
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(storyboard, f, ensure_ascii=False, indent=2)
print(f'✅ 已保存: {out_path}')
if __name__ == '__main__':
main()