feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,931 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user