#!/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()