feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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()