#!/usr/bin/env python3 """ AVE v2.0 — AudioScore Video Engine CLI 版本: v1.1 | 更新: 2026-05-06 用法: python main.py voice --text "你好" --output test.wav python main.py material --search "sunset beach" python main.py parse --script demo.txt python main.py compose --voice voice.wav --clips clip1.mp4 clip2.mp4 python main.py generate --script demo.txt python main.py emotion-test --text "你好世界" """ import argparse import os import sys # 确保能找到 scripts 下的模块 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) from lib.config import get_api_config, load_config from lib.logger import get_logger logger = get_logger("cli") def main(): parser = argparse.ArgumentParser(description="AVE v2.0 — 自动化视频编排引擎") sub = parser.add_subparsers(dest="command", required=True) # ── voice ── p_voice = sub.add_parser("voice", help="人声合成") p_voice.add_argument("--text", required=True, help="合成文本") p_voice.add_argument("--output", default="output.wav", help="输出路径") p_voice.add_argument("--provider", default="aliyun", choices=["aliyun", "volcano"], help="TTS 服务商") p_voice.add_argument("--emotion", default="normal", help="情绪 (normal/happy/sad/angry)") # ── material ── p_mat = sub.add_parser("material", help="素材搜索") p_mat.add_argument("--search", required=True, help="搜索关键词") p_mat.add_argument("--count", type=int, default=3, help="数量") p_mat.add_argument("--orientation", default="portrait", help="portrait/landscape") # ── parse ── p_parse = sub.add_parser("parse", help="文案→导演脚本") p_parse.add_argument("--script", required=True, help="文案文件路径") p_parse.add_argument("--style", default="knowledge_lecture", help="视频风格") p_parse.add_argument("--no-llm", action="store_true", help="不使用 LLM,手动模板") # ── compose ── p_comp = sub.add_parser("compose", help="视频合成") p_comp.add_argument("--voice", required=True, help="人声 WAV 路径") p_comp.add_argument("--clips", nargs="+", required=True, help="素材视频路径列表") p_comp.add_argument("--bgm", default=None, help="BGM 路径 (可选)") p_comp.add_argument("--output", default="final.mp4", help="输出路径") p_comp.add_argument("--resolution", default="1080x1920", help="分辨率") # ── anchors ── p_anc = sub.add_parser("anchors", help="锚点提取") p_anc.add_argument("--voice", required=True, help="人声 WAV 路径") p_anc.add_argument("--bgm", default=None, help="BGM 路径 (可选)") p_anc.add_argument("--output", default="anchors.json", help="输出路径") # ── generate ── p_gen = sub.add_parser("generate", help="全链路生成") p_gen.add_argument("--script", required=True, help="文案文件路径") p_gen.add_argument("--style", default="knowledge_lecture", help="视频风格") p_gen.add_argument("--output", default="final.mp4", help="输出路径") p_gen.add_argument("--clips-per-segment", type=int, default=2, help="每段搜索素材数 (默认2)") p_gen.add_argument("--bgm", default=None, help="BGM 路径或情感类型 (可选)") p_gen.add_argument("--subtitles", action="store_true", default=True, help="叠加字幕 (默认开启)") p_gen.add_argument("--anchor-transitions", action="store_true", default=False, help="锚点驱动画面切换 (在静音处自动切素材+淡变)") p_gen.add_argument("--duck", action="store_true", default=False, help="BGM 音量避让 (说话压低BGM, 间隙恢复)") # ── emotion-test ── p_emo = sub.add_parser("emotion-test", help="情绪参数测试") p_emo.add_argument("--text", required=True, help="测试文本") p_emo.add_argument("--output-dir", default="/tmp/ave_emotion_test", help="输出目录") p_emo.add_argument("--emotions", default=None, help="逗号分隔的情绪列表 (默认全部)") # ── bgm ── p_bgm = sub.add_parser("bgm", help="生成背景音乐") p_bgm.add_argument("--mood", default="normal", help="情绪 (calm/soothing/happy/excited/sad/mystery/professional/normal/funny/inspiring)") p_bgm.add_argument("--duration", type=float, default=60, help="时长(秒)") p_bgm.add_argument("--output", default="/tmp/ave_bgm.wav", help="输出路径") p_bgm.add_argument("--pixabay-key", default=None, help="Pixabay API Key (可选)") p_bgm.add_argument("--use-mlx", action="store_true", help="尝试 mlx-audiocraft AI 生成 (需安装)") # ── digital-human ── p_dh = sub.add_parser("digital-human", help="生成数字人视频 (Wan2.2)") p_dh.add_argument("--image", required=True, help="头像图片路径") p_dh.add_argument("--text", default="关注我,一起聆听世界", help="口播文案") p_dh.add_argument("--output", default="/tmp/ave_digital_human.mp4", help="输出路径") p_dh.add_argument("--resolution", default="480P", choices=["480P", "720P"], help="分辨率") # ── speed-ramp ── p_sr = sub.add_parser("speed-ramp", help="变速卡点 (缓变速度曲线)") p_sr.add_argument("--input", required=True, help="输入视频路径") p_sr.add_argument("--output", default="/tmp/ave_speed_ramp.mp4", help="输出路径") p_sr.add_argument("--speed", type=float, default=1.0, help="匀速倍率 (默认1.0, 不启用curve时使用)") p_sr.add_argument("--curve", default="", help="缓变曲线, 逗号分隔 (例: 0.7,1.0,1.3)") p_sr.add_argument("--segments", type=int, default=6, help="缓变分段数 (默认6)") p_sr.add_argument("--no-audio", action="store_true", help="关闭音频变速") # ── beat-sync ── p_bs = sub.add_parser("beat-sync", help="卡点视频 (BGM节拍驱动画面切换)") p_bs.add_argument("--bgm", required=True, help="BGM 音频路径") p_bs.add_argument("--clips", nargs="*", default=[], help="素材路径列表 (可选)") p_bs.add_argument("--search", default="", help="Pexels 搜索关键词 (素材不够时补充)") p_bs.add_argument("--output", default="/tmp/ave_beat_sync.mp4", help="输出路径") p_bs.add_argument("--group-size", type=int, default=4, help="每组节拍数 (默认4)") p_bs.add_argument("--texts", nargs="*", default=[], help="每段叠加的文字 (可选)") p_bs.add_argument("--resolution", default="1080x1920", help="分辨率") # ── hybrid ── p_hy = sub.add_parser("hybrid", help="口播+卡点融合 (人声锚点+BGM能量变速)") p_hy.add_argument("--voice", required=True, help="人声 WAV 路径") p_hy.add_argument("--bgm", required=True, help="BGM 音频路径") p_hy.add_argument("--clips", nargs="*", default=[], help="素材路径列表 (可选)") p_hy.add_argument("--search", default="", help="Pexels 搜索关键词 (素材不够时补充)") p_hy.add_argument("--output", default="/tmp/ave_hybrid.mp4", help="输出路径") p_hy.add_argument("--texts", nargs="*", default=[], help="每段字幕文字 (可选)") p_hy.add_argument("--resolution", default="1080x1920", help="分辨率") p_hy.add_argument("--no-speed-ramp", action="store_true", help="关闭能量驱动变速") p_hy.add_argument("--base-speed", type=float, default=1.0, help="基础速度 (默认1.0)") p_hy.add_argument("--high-speed", type=float, default=1.5, help="高能段速度 (默认1.5)") p_hy.add_argument("--low-speed", type=float, default=0.7, help="低能段速度 (默认0.7)") p_hy.add_argument("--group-size", type=int, default=4, help="BGM 节拍分组 (默认4)") p_hy.add_argument("--min-silence", type=float, default=0.15, help="最小静音时长 (默认0.15s)") # ── character-sheet ── p_cs = sub.add_parser("character-sheet", help="定妆照 Grid Method 生成") p_cs.add_argument("--desc", required=True, help="角色描述") p_cs.add_argument("--output", default="", help="输出路径") p_cs.add_argument("--lang", default="zh", choices=["zh", "en"], help="语言") p_cs.add_argument("--extract", action="store_true", help="裁出 6 张单格图") p_cs.add_argument("--save", default="", help="保存到角色库 (角色名)") p_cs.add_argument("--list", action="store_true", help="列出已保存角色") p_cs.add_argument("--force", action="store_true", help="强制重新生成") # ── lipsync ── p_ls = sub.add_parser("lipsync", help="Kling LipSync 唇形同步") p_ls.add_argument("--mode", required=True, choices=["audio-to-video", "text-to-video"], help="模式") p_ls.add_argument("--video", required=True, help="输入视频路径") p_ls.add_argument("--audio", default="", help="输入音频 (audio-to-video)") p_ls.add_argument("--text", default="", help="口播文本 (text-to-video, ≤120字)") p_ls.add_argument("--voice-id", default="default", help="音色ID (text-to-video)") p_ls.add_argument("--output", default="", help="输出路径") p_ls.add_argument("--force", action="store_true", help="强制重新生成") # ── story ── p_st = sub.add_parser("story", help="角色叙事故事线 (多场景角色一致性)") p_st.add_argument("--script", required=True, help="导演脚本 YAML 路径") p_st.add_argument("--character", default="", help="角色名 (从角色库加载描述块)") p_st.add_argument("--block", default="", help="角色描述块 (可选, 避免从角色库加载)") p_st.add_argument("--lang", default="en", choices=["en", "zh"], help="Prompt 语言") p_st.add_argument("--model", default="turbo", choices=["turbo", "pro"], help="Kling 模型") p_st.add_argument("--duration", type=int, default=5, choices=[5, 10], help="每场景视频时长(秒)") p_st.add_argument("--output-dir", default="", help="输出目录 (默认自动生成)") p_st.add_argument("--seed", type=int, default=42, help="固定 seed (角色一致性)") p_st.add_argument("--force", action="store_true", help="强制重新生成") p_st.add_argument("--dry-run", action="store_true", help="仅展示场景分解结果,不生成视频") # ── dashboard ── p_db = sub.add_parser("dashboard", help="启动 Dashboard 后端") p_db.add_argument("--port", type=int, default=9988, help="端口 (默认 9988)") p_db.add_argument("--no-reload", action="store_true", help="关闭热重载") # ── asset (素材资产管理) ── p_as = sub.add_parser("asset", help="素材资产管理 (索引/搜索/清理)") p_as.add_argument("action", choices=["scan", "list", "tags", "stats", "cleanup", "disk"], help="操作") p_as.add_argument("--keyword", default="", help="搜索关键字") p_as.add_argument("--type", dest="asset_type", default="", help="筛选类型 video/audio/image") p_as.add_argument("--source", default="", help="筛选来源 (materials/kling/...)") p_as.add_argument("--tag", default="", help="筛选标签") p_as.add_argument("--limit", type=int, default=30, help="返回条数") p_as.add_argument("--days", type=int, default=30, help="清理: N 天前的孤立素材") p_as.add_argument("--dry-run", action="store_true", help="清理: 仅预览不删除") p_as.add_argument("--production", type=int, default=0, help="关联 production ID") p_as.add_argument("--force-rescan", action="store_true", help="强制全量重新扫描") p_as.add_argument("--show-meta", action="store_true", help="显示元数据详情") # ── video-factory ── p_vf = sub.add_parser("video-factory", help="视频工厂 (统一生产入口)") p_vf.add_argument("--strategy", required=True, choices=["口播", "卡点", "数字人", "口播+卡点", "故事", "status"], help="生产策略") # 通用参数 p_vf.add_argument("--output", default="", help="输出路径") # 口播参数 p_vf.add_argument("--script", default="", help="脚本文件路径 (口播)") p_vf.add_argument("--clips-per-segment", type=int, default=2, help="每段素材数") p_vf.add_argument("--bgm", default=None, help="BGM 路径") p_vf.add_argument("--no-duck", action="store_true", help="关闭BGM避让") p_vf.add_argument("--no-anchor", action="store_true", help="关闭锚点切换") p_vf.add_argument("--no-subtitles", action="store_true", help="关闭字幕") # 卡点参数 p_vf.add_argument("--search", default="", help="Pexels搜索词 (卡点)") p_vf.add_argument("--clips", nargs="*", default=[], help="素材列表 (卡点)") p_vf.add_argument("--group-size", type=int, default=4, help="节拍分组 (卡点)") p_vf.add_argument("--texts", nargs="*", default=[], help="叠加文字 (卡点)") # 数字人参数 p_vf.add_argument("--image", default="", help="头像图片 (数字人)") p_vf.add_argument("--text", default="", help="文案 (数字人)") p_vf.add_argument("--resolution", default="480P", choices=["480P", "720P"], help="分辨率") p_vf.add_argument("--mode", default="对口型", choices=["对口型", "动作模仿"], help="数字人模式") p_vf.add_argument("--video", default=None, help="参考视频 (动作模仿)") # 故事参数 p_vf.add_argument("--character", default="", help="角色名 (故事策略)") p_vf.add_argument("--block", default="", help="角色描述块 (故事策略)") p_vf.add_argument("--story-model", default="turbo", choices=["turbo", "pro"], help="Kling 模型 (故事策略)") p_vf.add_argument("--story-duration", type=int, default=5, choices=[5, 10], help="每场景时长 (故事策略)") p_vf.add_argument("--story-lang", default="en", choices=["en", "zh"], help="Prompt 语言 (故事策略)") p_vf.add_argument("--seed", type=int, default=42, help="固定 seed (故事策略)") p_vf.add_argument("--story-dry-run", action="store_true", help="仅展示场景分解 (故事策略)") args = parser.parse_args() # ─── dispatch ─── if args.command == "voice": cfg = load_config() if args.provider == "aliyun": from voice_synthesizer.aliyun import synthesize ak = cfg.get("aliyun", {}).get("api_key", "") vid = cfg.get("aliyun", {}).get("voice_id", "") synthesize(args.text, args.output, api_key=ak, voice_id=vid, emotion=args.emotion) else: from voice_synthesizer.volcano import synthesize vcfg = cfg.get("volcano", {}) synthesize(args.text, args.output, access_token=vcfg.get("access_token", ""), app_id=vcfg.get("app_id", ""), access_key_id=vcfg.get("access_key_id", ""), secret_access_key=vcfg.get("secret_access_key", "")) elif args.command == "material": cfg = load_config() api_key = cfg.get("pexels", {}).get("api_key", "") from material_producer.pexels.search import search_videos results = search_videos(args.search, args.count, api_key=api_key, orientation=args.orientation) for i, r in enumerate(results, 1): print(f" {i}. {r['path']} ({r['duration']}s)") elif args.command == "parse": from director_parser.parser import parse_script out = parse_script(args.script, style=args.style, use_llm=not args.no_llm) print(f"✅ 导演脚本已生成: {out}") elif args.command == "compose": from composer.ffmpeg import compose_video, mix_audio, create_subtitles # 先混音 mixed = mix_audio(args.voice, args.bgm) if args.bgm else args.voice # 合成视频 compose_video(args.clips, mixed, args.output, resolution=args.resolution) elif args.command == "anchors": from anchor_extractor.extractor import extract_anchors extract_anchors(args.voice, args.bgm, args.output) elif args.command == "emotion-test": from voice_synthesizer.aliyun import synthesize cfg = load_config() ak = cfg.get("aliyun", {}).get("api_key", "") vid = cfg.get("aliyun", {}).get("voice_id", "") emotions = args.emotions.split(",") if args.emotions else [ "normal", "happy", "sad", "angry", "soothing", "excited", "mystery", "professional" ] out_dir = args.output_dir os.makedirs(out_dir, exist_ok=True) print(f"[AVE] 情绪测试: {args.text!r}") print(f" 输出目录: {out_dir}") print(f" 情绪列表: {emotions}") print() results = [] for emo in emotions: out_path = os.path.join(out_dir, f"emotion_{emo}.wav") try: synthesize(args.text, out_path, api_key=ak, voice_id=vid, emotion=emo, speed=1.0) size_kb = os.path.getsize(out_path) // 1024 print(f" ✅ {emo:15s} → {out_path} ({size_kb}KB)") results.append((emo, out_path, None)) except Exception as e: print(f" ❌ {emo:15s} → 失败: {e}") results.append((emo, None, str(e))) print() print("=" * 50) print("情绪测试结果汇总") print("=" * 50) for emo, path, err in results: status = "✅" if path else "❌" print(f" {status} {emo:15s} {'OK' if path else err}") print() print(f"提示: 用 ffplay /tmp/ave_emotion_test/emotion_*.wav 试听对比") elif args.command == "bgm": from bgm_generator.suno import generate_bgm, get_available_moods print(f"[AVE] BGM 生成: mood={args.mood}, duration={args.duration}s") print(f" 可用情绪: {get_available_moods()}") # 从配置读取 Pixabay Key (如果有) cfg = load_config() pixabay_key = args.pixabay_key or cfg.get("pixabay", {}).get("api_key", "") out = generate_bgm( mood=args.mood, duration=args.duration, output=args.output, pixabay_key=pixabay_key, use_mlx=args.use_mlx, ) size_kb = os.path.getsize(out) // 1024 print(f"\n✅ BGM 生成: {out} ({size_kb}KB)") print(f" 试听: ffplay {out}") elif args.command == "digital-human": from voice_synthesizer.aliyun import synthesize from material_producer.wan2_2.wan2_2 import generate_digital_human cfg = load_config() ak = cfg.get("aliyun", {}).get("api_key", "") vid = cfg.get("aliyun", {}).get("voice_id", "") # 合成人声 (≤20s) audio_path = "/tmp/ave_dh_voice.wav" print(f"[AVE] 数字人生成: {args.text}") print(" 第1步: 合成人声...") synthesize(args.text, audio_path, api_key=ak, voice_id=vid, emotion="normal") # 生成数字人 print(" 第2步: 生成数字人 (Wan2.2, ~5-10分钟)...") result = generate_digital_human( args.image, audio_path, ak, output_path=args.output, resolution=args.resolution, text=args.text, ) import os as _os size_mb = _os.path.getsize(result) / 1024 / 1024 print(f"\n✅ 数字人完成: {result} ({size_mb:.1f}MB)") elif args.command == "speed-ramp": from composer.speed_ramp import speed_ramp curve = None if args.curve: curve = [float(x.strip()) for x in args.curve.split(",")] print(f"[AVE] 变速卡点: {args.input}") if curve: print(f" 曲线: {'→'.join(f'{c:.2f}x' for c in curve)}, {args.segments}段") else: print(f" 匀速: {args.speed:.2f}x") result = speed_ramp( input_path=args.input, output_path=args.output, speed=args.speed, curve=curve, segments=args.segments, keep_audio=not args.no_audio, ) import os as _os sz_mb = _os.path.getsize(result) / 1024 / 1024 print(f"\n✅ 变速完成: {result} ({sz_mb:.0f}MB)") elif args.command == "beat-sync": from composer.beat_sync import compose_beat_sync cfg = load_config() pexels_key = cfg.get("pexels", {}).get("api_key", "") print(f"[AVE] Beat-Sync 卡点视频") print(f" BGM: {args.bgm}") print(f" 素材: {len(args.clips)} 个 + 搜索 '{args.search}'") print(f" 节拍分组: {args.group_size}拍/组") if args.texts: print(f" 文字: {len(args.texts)} 段") compose_beat_sync( bgm_path=args.bgm, output_path=args.output, material_clips=args.clips or None, group_size=args.group_size, resolution=args.resolution, texts=args.texts or None, pexels_api_key=pexels_key, pexels_search=args.search, ) import os as _os sz_mb = _os.path.getsize(args.output) / 1024 / 1024 print(f"\n✅ Beat-Sync 完成: {args.output} ({sz_mb:.0f}MB)") elif args.command == "hybrid": from composer.hybrid import compose_hybrid cfg = load_config() pexels_key = cfg.get("pexels", {}).get("api_key", "") print(f"[AVE] 口播+卡点融合") print(f" 人声: {args.voice}") print(f" BGM: {args.bgm}") print(f" 素材: {len(args.clips)} 个 + 搜索 '{args.search}'") print(f" 变速: {'关闭' if args.no_speed_ramp else f'开启 ({args.low_speed:.1f}~{args.high_speed:.1f}x)'}") compose_hybrid( voice_path=args.voice, bgm_path=args.bgm, output_path=args.output, material_clips=args.clips or None, resolution=args.resolution, texts=args.texts or None, pexels_api_key=pexels_key, pexels_search=args.search, enable_speed_ramp=not args.no_speed_ramp, base_speed=args.base_speed, high_speed=args.high_speed, low_speed=args.low_speed, group_size=args.group_size, min_silence_sec=args.min_silence, ) import os as _os sz_mb = _os.path.getsize(args.output) / 1024 / 1024 print(f"\n✅ 融合完成: {args.output} ({sz_mb:.0f}MB)") elif args.command == "character-sheet": from character_sheet import generate_grid, save_character, list_characters if args.list: chars = list_characters() if not chars: print("暂无已保存角色") else: print(f"已保存角色 ({len(chars)}):") for c in chars: print(f" {c['name']:20s} → {c.get('grid_path', '无网格图')}") return result = generate_grid( desc=args.desc, output_path=args.output, lang=args.lang, extract_cells=args.extract, force=args.force, ) print(f"\n✅ 定妆照: {result['grid']}") if result.get("cells"): for c in result["cells"]: print(f" 单格: {c}") import hashlib save_name = args.save or f"char_{hashlib.md5(args.desc.encode()).hexdigest()[:8]}" save_character(save_name, args.desc, result["grid"]) print(f"✅ 角色已保存: {save_name}") elif args.command == "lipsync": from composer.lipsync import lipsync_audio_to_video, lipsync_text_to_video if args.mode == "audio-to-video": if not args.audio: print("❌ audio-to-video 模式需要 --audio 参数") return out = lipsync_audio_to_video( video_path=args.video, audio_path=args.audio, output_path=args.output, force=args.force, ) elif args.mode == "text-to-video": if not args.text: print("❌ text-to-video 模式需要 --text 参数") return out = lipsync_text_to_video( video_path=args.video, text=args.text, voice_id=args.voice_id, output_path=args.output, force=args.force, ) print(f"\n✅ LipSync 完成: {out}") elif args.command == "story": from story_director.scene_planner import plan_scenes, export_scenes from story_director.temporal_bridge import enrich_scenes_with_bridges from story_director.batch_generator import run_story_pipeline cfg = load_config() # 加载角色描述块 character_block = args.block if not character_block and args.character: from character_sheet import load_character char = load_character(args.character) if char: character_block = char.get("description", "") print(f" 从角色库加载 '{args.character}': {character_block[:40]}...") else: print(f" ⚠️ 角色 '{args.character}' 未找到, 使用空描述块") print(f"🎬 AVE 故事线: {args.script}") print(f" 角色: {args.character or '(无)'}") print(f" Seed: {args.seed}") # Step 1: 场景分解 print("\n[1/3] 场景分解...") scenes = plan_scenes( script_path=args.script, character_name=args.character or None, character_block=character_block, lang=args.lang, ) print(f" → {len(scenes)} 个场景") # Step 2: 桥接 print("\n[2/3] 构建过渡桥接...") enriched = enrich_scenes_with_bridges(scenes, character_block=character_block) tmp_scenes_path = f"/tmp/ave_story_scenes_{int(time.time())}.json" export_scenes(scenes, tmp_scenes_path, character_block=character_block, lang=args.lang, seed=args.seed) print(f" → 导出 {tmp_scenes_path}") if args.dry_run: print("\n⏸️ Dry-run 模式 — 仅展示场景分解:") for s in enriched: bridge = s.get("bridge_to_next", {}) print(f"\n Scene {s['scene_id']}: {s.get('duration_sec', '?')}s " f"char={s.get('character_ref', 'none')} " f"trans={bridge.get('type', '-') if bridge else '-'}") print(f" Prompt: {s.get('prompt', '')[:80]}...") return # Step 3: 批量生成 print("\n[3/3] 批量 Kling 生成...") result = run_story_pipeline( scenes_path=tmp_scenes_path, output_dir=args.output_dir, model=args.model, duration=args.duration, force=args.force, ) print(f"\n✅ 故事线完成") print(f" 输出目录: {result['output_dir']}") print(f" 拼接脚本: {result['concat_script']}") for r in result["results"]: icon = "✅" if r["status"] == "success" else "💾" if r["status"] == "cached" else "❌" print(f" {icon} Scene {r['scene_id']:2d}: {r.get('path', 'N/A')}") from lib.cost_tracker import get_tracker get_tracker().summary() elif args.command == "dashboard": # 系统 Dashborad 已迁移到 05_tools/10_dashboard/ # 添加路径并启动 _dash_dir = Path(__file__).resolve().parent.parent.parent / "10_dashboard" if str(_dash_dir) not in sys.path: sys.path.insert(0, str(_dash_dir)) import uvicorn port = args.port reload = not args.no_reload print(f"📊 系统监控面板") print(f" → 前端页面: http://localhost:{port}") print(f" → 插件列表: http://localhost:{port}/api/plugins") print(f" → API 总览: http://localhost:{port}/api/summary") print(f" → API 生产: http://localhost:{port}/api/productions") print(f" → API 资产: http://localhost:{port}/api/assets") print(f" → API 文档: http://localhost:{port}/docs") uvicorn.run("app:app", host="0.0.0.0", port=port, reload=reload) elif args.command == "asset": from asset_manager.index import AssetIndex from asset_manager.cache import CacheManager from asset_manager.tags import AssetSearch action = args.action if action == "scan": cm = CacheManager() if args.force_rescan: logger.info("强制全量扫描...") result = cm.scan_all(production_id=args.production) else: logger.info("增量扫描...") result = cm.scan_new(force_rescan=False) print(f"\n 扫描完成: 新增 {result.get('total_new', 0)} 个素材") if "per_dir" in result: for d, n in result["per_dir"].items(): if n > 0: print(f" [{d}] +{n}") elif action == "list": srch = AssetSearch() tags = [args.tag] if args.tag else None results = srch.search( keyword=args.keyword, asset_type=args.asset_type or None, source=args.source or None, tags=tags, limit=args.limit, ) srch.print_results(results, show_meta=args.show_meta) elif action == "tags": srch = AssetSearch() srch.print_tag_cloud() elif action == "stats": idx = AssetIndex() idx.print_summary() elif action == "cleanup": cm = CacheManager() result = cm.cleanup(days=args.days, dry_run=args.dry_run) if args.dry_run: print(f"\n 预览: 将清理 {result['deleted']} 个文件, " f"释放 {result['freed_bytes']/1024/1024:.1f} MB") for f in result["files"][:10]: print(f" {f}") if len(result["files"]) > 10: print(f" ... 还有 {len(result['files'])-10} 个") else: print(f"\n 已清理: {result['deleted']} 个文件, " f"释放 {result['freed_bytes']/1024/1024:.1f} MB") elif action == "disk": cm = CacheManager() cm.print_disk_usage() elif args.command == "video-factory": from video_factory import run_oral, run_beat, run_digital_human, run_hybrid, run_story, show_status strategy = args.strategy output = args.output or { "口播": "/tmp/ave_oral.mp4", "卡点": "/tmp/ave_beat.mp4", "数字人": "/tmp/ave_dh.mp4", "口播+卡点": "/tmp/ave_hybrid.mp4", "故事": "/tmp/ave_story.mp4", }.get(strategy, "/tmp/ave_factory.mp4") if strategy == "status": show_status() return print(f"\n🎬 AVE 视频工厂 → 策略: {strategy}") print("=" * 40) if strategy == "口播": if not args.script: print("❌ 口播策略需要 --script 参数") return run_oral( script=args.script, output=output, clips_per_segment=args.clips_per_segment, bgm=args.bgm, duck=not args.no_duck, anchor=not args.no_anchor, subtitles=not args.no_subtitles, ) elif strategy == "卡点": if not args.bgm: print("❌ 卡点策略需要 --bgm 参数") return run_beat( bgm=args.bgm, output=output, search=args.search, group_size=args.group_size, clips=args.clips, texts=args.texts, ) elif strategy == "数字人": if not args.image: print("❌ 数字人策略需要 --image 参数") return if not args.text and args.mode == "对口型": print("❌ 对口型模式需要 --text 参数") return run_digital_human( image=args.image, text=args.text, output=output, resolution=args.resolution, mode=args.mode, video=args.video, ) elif strategy == "口播+卡点": if not args.bgm or not args.script: print("❌ 口播+卡点策略需要 --bgm 和 --script 参数") return # 先合成人声 from voice_synthesizer.aliyun import synthesize cfg = load_config() ak = cfg.get("aliyun", {}).get("api_key", "") vid = cfg.get("aliyun", {}).get("voice_id", "") voice_path = "/tmp/ave_factory_hybrid_voice.wav" with open(args.script, encoding="utf-8") as f: text = f.read() print(" [1/3] 合成人声...") synthesize(text, voice_path, api_key=ak, voice_id=vid) run_hybrid( voice=voice_path, bgm=args.bgm, output=output, search=args.search, group_size=args.group_size, clips=args.clips, texts=args.texts, enable_speed_ramp=not args.no_speed_ramp, base_speed=args.base_speed, high_speed=args.high_speed, low_speed=args.low_speed, min_silence=args.min_silence, ) elif strategy == "故事": if not args.script: print("❌ 故事策略需要 --script 参数") return run_story( script=args.script, output=output, character=args.character, block=args.block, story_model=args.story_model, story_duration=args.story_duration, story_lang=args.story_lang, seed=args.seed, dry_run=args.story_dry_run, force=getattr(args, 'force', False), ) # 显示费用汇总 from lib.cost_tracker import get_tracker get_tracker().summary() elif args.command == "generate": cfg = load_config() print(f"[AVE] 全链路生成: {args.script}") print(" 第1步: 读取脚本...") import yaml # 如果输入是 YAML 直接读,否则用 LLM 解析 if args.script.endswith((".yaml", ".yml")): with open(args.script, encoding="utf-8") as f: script_data = yaml.safe_load(f) script_path = args.script print(f" 已编译 YAML,直接读取") else: from director_parser.parser import parse_script script_path = parse_script(args.script, style=args.style) with open(script_path, encoding="utf-8") as f: script_data = yaml.safe_load(f) segments = script_data.get("segments", []) total_duration = sum(s.get("duration_sec", 10) for s in segments) print(f" 共 {len(segments)} 段, 预计 {total_duration}s") print(" 第3步: 合成人声 (带字级时间戳)...") from voice_synthesizer.aliyun import synthesize_with_timestamps ak = cfg.get("aliyun", {}).get("api_key", "") vid = cfg.get("aliyun", {}).get("voice_id", "") text_all = "\n".join(s["text"] for s in segments) # 用一个全局情绪 (取第一个 segment 的或默认) default_emoji = segments[0].get("voice_emotion", "normal") if segments else "normal" voice_path, word_ts = synthesize_with_timestamps( text_all, "/tmp/ave_voice.wav", api_key=ak, voice_id=vid, emotion=default_emoji, ) print(f" 字级时间戳: {len(word_ts)} 个字") print(" 第4步: 搜索素材 (每段2个)...") from material_producer.pexels.search import search_videos mat_cfg = cfg.get("pexels", {}) all_clips = [] # [(path, duration, segment_id), ...] for seg in segments: seg_id = seg.get("id", "?") keyword = seg.get("material", {}).get("search", "") if not keyword: continue clips = search_videos( keyword, count=args.clips_per_segment, api_key=mat_cfg.get("api_key", ""), orientation="portrait", ) for c in clips: all_clips.append((c["path"], c["duration"], seg_id)) print(f" 段{seg_id}: {keyword[:30]!r} → {len(clips)} 个素材") if not all_clips: print(" ⚠️ 无素材,跳过合成") return clip_paths = [c[0] for c in all_clips] print(f" 共 {len(clip_paths)} 个素材片段") print(" 第5步: 混音 (人声+BGM)...") from composer.ffmpeg import mix_audio, duck_bgm bgm_path = args.bgm if args.bgm and os.path.exists(args.bgm) else None if bgm_path: if args.duck: print(f" BGM: {bgm_path} (音量避让: 说话0.15→间隙0.50, {len(word_ts)} 个字)") ducked_bgm = duck_bgm(voice_path, bgm_path, "/tmp/ave_ducked_bgm.wav", word_timestamps=word_ts) mixed_audio = mix_audio(voice_path, ducked_bgm, bgm_volume=1.0) else: print(f" BGM: {bgm_path} (音量 0.35)") mixed_audio = mix_audio(voice_path, bgm_path, bgm_volume=0.35) else: print(" 无 BGM") mixed_audio = voice_path print(" 第6步: 合成视频...") from composer.ffmpeg import compose_video, create_subtitles, segment_render, concat_segments, _get_media_duration # 字幕: 用字级时间戳计算每段精确起止 subtitles_path = None if args.subtitles and segments and word_ts: # 拼接原始文字(含 \n 分隔符) text_all = "\n".join(s["text"] for s in segments) # CosyVoice 返回的字符级时间戳不含 \n,清理后匹配 text_clean = text_all.replace("\n", "") full_text_from_ts = "".join(w["text"] for w in word_ts) # 计算每个字的累积字符位置 char_positions = [] acc = 0 for w in word_ts: char_positions.append(acc) acc += len(w["text"]) cursor = 0 # 当前段在 text_clean 中的起始搜索位置 for seg in segments: seg_text = seg["text"] pos = cursor # 默认从上一段结束位置开始 char_end = pos + len(seg_text) seg_words = [] try: found = text_clean.index(seg_text, cursor) pos = found char_end = found + len(seg_text) except ValueError: # 匹配失败说明 CosyVoice 返回文本与原始不精确对齐 # 回退到按比例估算(基于 cursor 位置) pass # 找到该段文字范围内的字级时间戳 seg_words = [w for i, w in enumerate(word_ts) if char_positions[i] >= pos and char_positions[i] + len(w["text"]) <= char_end] if seg_words: seg["start_sec"] = seg_words[0]["begin_time"] / 1000.0 seg["end_sec"] = seg_words[-1]["end_time"] / 1000.0 else: # 无匹配字,按文字比例估算 total_chars = max(len(text_clean), 1) total_ms = word_ts[-1]["end_time"] seg["start_sec"] = (pos / total_chars) * total_ms / 1000.0 seg["end_sec"] = (char_end / total_chars) * total_ms / 1000.0 cursor = char_end subtitles_path = "/tmp/ave_subtitles.ass" create_subtitles(segments, subtitles_path, resolution=(1080, 1920)) print(f" 字幕 (精确): {subtitles_path} ({len(segments)} 段, {len(word_ts)} 字)") # 分段渲染: 3分钟以上自动分段 audio_duration = _get_media_duration(mixed_audio) if audio_duration > 180: print(f" 视频较长 ({audio_duration:.0f}s > 180s),自动分段渲染...") import tempfile seg_dir = tempfile.mkdtemp(prefix="ave_seg_") seg_files = segment_render( clip_paths, mixed_audio, output_dir=seg_dir, subtitles_path=subtitles_path, ) if len(seg_files) > 1: concat_segments(seg_files, args.output) import shutil shutil.rmtree(seg_dir, ignore_errors=True) print(f" 分段拼接完成: {args.output}") else: import shutil shutil.move(seg_files[0], args.output) shutil.rmtree(seg_dir, ignore_errors=True) else: # 锚点驱动模式 if args.anchor_transitions: print(" 锚点驱动画面切换...") from anchor_extractor.extractor import get_silence_periods from composer.ffmpeg import compose_with_anchors silences = get_silence_periods(mixed_audio, min_silence_sec=0.1) print(f" 检测到 {len(silences)} 个静音段 (过渡点)") compose_with_anchors( clip_paths, mixed_audio, args.output, silence_periods=silences, total_duration=audio_duration, subtitles_path=subtitles_path, ) else: compose_video(clip_paths, mixed_audio, args.output, resolution="1080x1920", subtitles_path=subtitles_path, ken_burns=True) print(f"\n✅ 生成完成: {args.output}") clip_total = sum(c[1] for c in all_clips) print(f" 素材总时长: {clip_total:.1f}s / 人声: {total_duration}s") if __name__ == "__main__": main()