移植自 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 带网关原话 / 正常返回)。
1377 lines
57 KiB
Python
1377 lines
57 KiB
Python
"""
|
||
工作流引擎 — 可视化 DAG 管线定义与执行
|
||
|
||
节点类型 (15种):
|
||
story_prototype → 剧本原型 (自然语言故事)
|
||
script → 脚本输入
|
||
script_adaptor → 剧本改编
|
||
strategy → 策略选择
|
||
tts → TTS 合成
|
||
model → AI 模型
|
||
material → 素材源
|
||
style → 风格预设
|
||
bgm → BGM 选择
|
||
character → 角色
|
||
audit_gate → 审计门
|
||
output → 输出
|
||
storyboard → 分镜规划
|
||
prompt_gen → 分镜提示词生成
|
||
subtitle → 字幕生成
|
||
"""
|
||
|
||
import os, json, time, uuid, threading, logging
|
||
from pathlib import Path
|
||
|
||
logger = logging.getLogger("workflows")
|
||
|
||
# ── 字符编排路径 ──
|
||
_AVE_DIR = Path(__file__).resolve().parent.parent / "09_ave" / "scripts"
|
||
_REGISTRY_PATH = _AVE_DIR / "character_registry" / "registry.yaml"
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 15 种节点类型定义
|
||
# ═══════════════════════════════════════════
|
||
|
||
NODE_DEFINITIONS = {
|
||
"story_prototype": {
|
||
"label": "剧本原型",
|
||
"icon": "📝",
|
||
"description": "用自然语言描述故事梗概,作为剧本创作起点",
|
||
"category": "flow",
|
||
"color": "#a855f7",
|
||
"default_config": {"genre": "mystery", "story_text": ""},
|
||
"options_map": {
|
||
"genre": ["mystery", "comedy", "sci_fi", "modern", "ancient", "fantasy"],
|
||
},
|
||
},
|
||
"script": {
|
||
"label": "脚本输入",
|
||
"icon": "📜",
|
||
"description": "编写或选择脚本(支持在线编辑)",
|
||
"category": "input",
|
||
"color": "#6366f1",
|
||
"default_config": {
|
||
"editor_mode": "inline", "content": "", "type": "yaml",
|
||
"parser": "auto", "path": "",
|
||
},
|
||
"options_map": {
|
||
"editor_mode": ["inline", "manual", "upload"],
|
||
"type": ["yaml", "json", "md"],
|
||
"parser": ["auto", "detailed", "simple"],
|
||
},
|
||
},
|
||
"script_adaptor": {
|
||
"label": "剧本改编",
|
||
"icon": "🔄",
|
||
"description": "将自然语言故事自动转换为结构化脚本",
|
||
"category": "core",
|
||
"color": "#22c55e",
|
||
"default_config": {"adapt_style": "detailed_adapt"},
|
||
"options_map": {
|
||
"adapt_style": ["detailed_adapt", "fast_extract", "dialogue_first"],
|
||
},
|
||
},
|
||
"strategy": {
|
||
"label": "策略选择",
|
||
"icon": "🎯",
|
||
"description": "选择生产策略",
|
||
"category": "core",
|
||
"color": "#22c55e",
|
||
"default_config": {"strategy": "auto"},
|
||
"options_map": {
|
||
"strategy": ["auto", "manual", "ai_generated"],
|
||
},
|
||
},
|
||
"tts": {
|
||
"label": "TTS 合成",
|
||
"icon": "🔊",
|
||
"description": "文本转语音(CosyVoice)",
|
||
"category": "audio",
|
||
"color": "#f59e0b",
|
||
"default_config": {
|
||
"speed": 1.0,
|
||
"pitch": 0.0,
|
||
},
|
||
"options_map": {
|
||
"voice_id": ["cosyvoice-v3.5-plus", "cosyvoice-v3.0-plus"],
|
||
"speed": "::number:0.5:2.0:0.1",
|
||
"pitch": "::number:-3.0:3.0:0.5",
|
||
},
|
||
},
|
||
"model": {
|
||
"label": "AI 模型",
|
||
"icon": "🤖",
|
||
"description": "选择 AI 生成模型",
|
||
"category": "generate",
|
||
"color": "#3b82f6",
|
||
"default_config": {
|
||
"provider": "kling", "model": "kling-v3",
|
||
"duration": 5, "seed": 42,
|
||
},
|
||
"options_map": {
|
||
"provider": ["kling", "jimeng", "wan"],
|
||
"model": ["kling-v3", "kling-v1", "wan2.1"],
|
||
"duration": "::number:2:30:1",
|
||
"seed": "::number:0:999999:1",
|
||
},
|
||
},
|
||
"material": {
|
||
"label": "素材源",
|
||
"icon": "🖼️",
|
||
"description": "选择素材来源",
|
||
"category": "material",
|
||
"color": "#ec4899",
|
||
"default_config": {
|
||
"source": "pexels", "search_keyword": "",
|
||
"count": 5, "orientation": "portrait",
|
||
},
|
||
"options_map": {
|
||
"source": ["pexels", "local", "upload"],
|
||
"orientation": ["portrait", "landscape", "square"],
|
||
"count": "::number:1:20:1",
|
||
},
|
||
},
|
||
"style": {
|
||
"label": "风格预设",
|
||
"icon": "🎨",
|
||
"description": "视觉风格预设",
|
||
"category": "visual",
|
||
"color": "#8b5cf6",
|
||
"default_config": {"style": "cinematic", "color_grade": "warm"},
|
||
"options_map": {
|
||
"style": ["cinematic", "hyper_realistic", "artistic", "anime", "vintage"],
|
||
"color_grade": ["warm", "cool", "neutral", "dramatic", "monochrome"],
|
||
},
|
||
},
|
||
"bgm": {
|
||
"label": "BGM 选择",
|
||
"icon": "🎵",
|
||
"description": "背景音乐选择(支持节拍检测与节奏匹配)",
|
||
"category": "audio",
|
||
"color": "#14b8a6",
|
||
"default_config": {
|
||
"mode": "manual", "mood": "",
|
||
"rhythm": "medium", "bpm": 120,
|
||
"volume": 0.7, "duck_enabled": True,
|
||
},
|
||
"options_map": {
|
||
"mode": ["manual", "auto", "ai_generated"],
|
||
"mood": ["happy", "sad", "epic", "calm", "tense", "upbeat",
|
||
"romantic", "inspiring", "healing"],
|
||
"rhythm": ["low", "medium", "high"],
|
||
"bpm": "::number:60:180:5",
|
||
"volume": "::number:0.0:1.0:0.05",
|
||
},
|
||
},
|
||
"character": {
|
||
"label": "角色",
|
||
"icon": "👤",
|
||
"description": "角色引用与定妆照(从注册表加载真实数据)",
|
||
"category": "core",
|
||
"color": "#f97316",
|
||
"default_config": {
|
||
"registry_name": "", "outfit_preset": "default",
|
||
"use_reference_images": True, "lip_sync": False,
|
||
},
|
||
"options_map": {
|
||
"outfit_preset": ["default", "running", "gym", "basketball"],
|
||
},
|
||
},
|
||
"audit_gate": {
|
||
"label": "审计门",
|
||
"icon": "🛡️",
|
||
"description": "人工审核确认后继续",
|
||
"category": "flow",
|
||
"color": "#ef4444",
|
||
"default_config": {
|
||
"message": "请确认此步骤结果",
|
||
"auto_approve": False, "timeout_minutes": 60,
|
||
},
|
||
"options_map": {
|
||
"timeout_minutes": "::number:1:480:5",
|
||
},
|
||
},
|
||
"output": {
|
||
"label": "输出",
|
||
"icon": "📦",
|
||
"description": "输出视频配置",
|
||
"category": "output",
|
||
"color": "#64748b",
|
||
"default_config": {
|
||
"format": "mp4", "resolution": "1080x1920",
|
||
"output_path": "", "auto_open": True,
|
||
},
|
||
"options_map": {
|
||
"format": ["mp4", "mov", "gif"],
|
||
"resolution": ["1080x1920", "1920x1080", "720x1280"],
|
||
},
|
||
},
|
||
"storyboard": {
|
||
"label": "分镜规划",
|
||
"icon": "🎬",
|
||
"description": "将脚本分解为场景级分镜(景别/运镜/转场/对白/动作)",
|
||
"category": "process",
|
||
"color": "#a855f7",
|
||
"default_config": {
|
||
"camera_style": "cinematic", "transition": "hard",
|
||
"scene_count": 6, "output_format": "json",
|
||
},
|
||
"options_map": {
|
||
"camera_style": ["cinematic", "dynamic", "static"],
|
||
"transition": ["hard", "fade_in", "fade_out", "dissolve", "slide", "cut"],
|
||
"scene_count": "::number:2:20:1",
|
||
"output_format": ["json", "yaml", "md"],
|
||
},
|
||
},
|
||
"prompt_gen": {
|
||
"label": "分镜提示词",
|
||
"icon": "✨",
|
||
"description": "将分镜+角色转换为每场景的视频生成提示词",
|
||
"category": "core",
|
||
"color": "#8b5cf6",
|
||
"default_config": {
|
||
"prompt_style": "detailed",
|
||
"quality": "standard",
|
||
"add_negative": True,
|
||
},
|
||
"options_map": {
|
||
"prompt_style": ["detailed", "simple", "stepwise"],
|
||
"quality": ["standard", "turbo", "quality"],
|
||
},
|
||
},
|
||
"subtitle": {
|
||
"label": "字幕生成",
|
||
"icon": "💬",
|
||
"description": "从对白文本生成时间轴字幕(SRT/ASS)",
|
||
"category": "output",
|
||
"color": "#06b6d4",
|
||
"default_config": {
|
||
"format": "srt", "position": "bottom",
|
||
"style": "white", "max_length": 18,
|
||
},
|
||
"options_map": {
|
||
"format": ["srt", "ass", "vtt"],
|
||
"position": ["bottom", "top"],
|
||
"style": ["white", "yellow", "cyan"],
|
||
"max_length": "::number:10:40:2",
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 6 个工作流模板
|
||
# ═══════════════════════════════════════════
|
||
|
||
WORKFLOW_TEMPLATES = {
|
||
"basic_dub": {
|
||
"name": "基础口播",
|
||
"cost": "¥2-5",
|
||
"description": "标准口播视频:脚本→TTS→素材→BGM→字幕→合成",
|
||
"nodes": [
|
||
{"id": "n1", "type": "script", "label": "脚本", "x": 40, "y": 160,
|
||
"config": {"editor_mode": "inline", "type": "yaml"}},
|
||
{"id": "n2", "type": "tts", "label": "TTS", "x": 300, "y": 160,
|
||
"config": {"speed": 1.0}},
|
||
{"id": "n3", "type": "material", "label": "素材搜索", "x": 300, "y": 40,
|
||
"config": {"source": "pexels", "count": 5, "orientation": "portrait"}},
|
||
{"id": "n4", "type": "bgm", "label": "BGM", "x": 300, "y": 280,
|
||
"config": {"mode": "manual", "volume": 0.7, "duck_enabled": True}},
|
||
{"id": "n5", "type": "output", "label": "视频输出", "x": 560, "y": 160,
|
||
"config": {"format": "mp4", "resolution": "1080x1920"}},
|
||
],
|
||
"edges": [
|
||
{"from": "n1", "to": "n2"},
|
||
{"from": "n1", "to": "n3"},
|
||
{"from": "n1", "to": "n4"},
|
||
{"from": "n2", "to": "n5"},
|
||
{"from": "n3", "to": "n5"},
|
||
{"from": "n4", "to": "n5"},
|
||
],
|
||
},
|
||
"beat_sync": {
|
||
"name": "卡点视频",
|
||
"cost": "¥1-3",
|
||
"description": "BGM→节拍检测→素材匹配→变速卡点合成",
|
||
"nodes": [
|
||
{"id": "n1", "type": "bgm", "label": "BGM 导入", "x": 40, "y": 160,
|
||
"config": {"mode": "auto", "volume": 0.8}},
|
||
{"id": "n2", "type": "style", "label": "卡点风格", "x": 240, "y": 160,
|
||
"config": {"style": "cinematic", "color_grade": "dramatic"}},
|
||
{"id": "n5", "type": "material", "label": "素材匹配", "x": 440, "y": 160,
|
||
"config": {"query": "城市 生活", "count": 5, "orientation": "portrait", "min_duration": 3}},
|
||
{"id": "n3", "type": "audit_gate", "label": "质量审核", "x": 640, "y": 160,
|
||
"config": {"message": "卡点效果是否符合预期?"}},
|
||
{"id": "n4", "type": "output", "label": "视频输出", "x": 840, "y": 160,
|
||
"config": {"format": "mp4", "resolution": "1080x1920", "compose_mode": "beat_sync"}},
|
||
],
|
||
"edges": [
|
||
{"from": "n1", "to": "n2"},
|
||
{"from": "n2", "to": "n5"},
|
||
{"from": "n5", "to": "n3"},
|
||
{"from": "n3", "to": "n4"},
|
||
],
|
||
},
|
||
"character_narrative": {
|
||
"name": "角色叙事",
|
||
"cost": "¥10-25",
|
||
"description": "定妆照→剧本→多场景→Kling批量→角色一致性拼接",
|
||
"nodes": [
|
||
{"id": "n1", "type": "story_prototype", "label": "剧本输入", "x": 40, "y": 260,
|
||
"config": {"genre": "mystery"}},
|
||
{"id": "n2", "type": "script_adaptor", "label": "脚本改编", "x": 260, "y": 260,
|
||
"config": {"adapt_style": "detailed_adapt"}},
|
||
{"id": "n3", "type": "character", "label": "角色引用", "x": 260, "y": 400,
|
||
"config": {"outfit_preset": "default", "use_reference_images": True}},
|
||
{"id": "n4", "type": "strategy", "label": "故事策略", "x": 260, "y": 120,
|
||
"config": {"strategy": "auto"}},
|
||
{"id": "n5", "type": "model", "label": "Kling 生成", "x": 500, "y": 260,
|
||
"config": {"provider": "kling", "model": "kling-v3", "duration": 5, "seed": 42}},
|
||
{"id": "n6", "type": "tts", "label": "旁白 TTS", "x": 500, "y": 100,
|
||
"config": {"speed": 1.0}},
|
||
{"id": "n7", "type": "audit_gate", "label": "生成审核", "x": 740, "y": 260,
|
||
"config": {"message": "所有场景已生成,确认拼接?"}},
|
||
{"id": "n8", "type": "output", "label": "最终输出", "x": 980, "y": 260,
|
||
"config": {"format": "mp4", "resolution": "1080x1920"}},
|
||
],
|
||
"edges": [
|
||
{"from": "n1", "to": "n2"},
|
||
{"from": "n2", "to": "n5"},
|
||
{"from": "n3", "to": "n5"},
|
||
{"from": "n4", "to": "n5"},
|
||
{"from": "n5", "to": "n7"},
|
||
{"from": "n2", "to": "n6"},
|
||
{"from": "n6", "to": "n7"},
|
||
{"from": "n7", "to": "n8"},
|
||
],
|
||
},
|
||
"digital_human": {
|
||
"name": "数字人口播",
|
||
"cost": "¥3-8",
|
||
"description": "角色照片+文案→TTS→数字人→口型同步→输出",
|
||
"nodes": [
|
||
{"id": "n1", "type": "script", "label": "文案输入", "x": 40, "y": 160,
|
||
"config": {"editor_mode": "inline"}},
|
||
{"id": "n2", "type": "tts", "label": "人声合成", "x": 260, "y": 160,
|
||
"config": {}},
|
||
{"id": "n3", "type": "character", "label": "数字人角色", "x": 260, "y": 40,
|
||
"config": {"lip_sync": True}},
|
||
{"id": "n5", "type": "storyboard", "label": "人物分镜", "x": 480, "y": 160,
|
||
"config": {"scene_count": 1}},
|
||
{"id": "n6", "type": "model", "label": "人物画面生成", "x": 700, "y": 160,
|
||
"config": {"provider": "kling", "duration": 5}},
|
||
{"id": "n4", "type": "output", "label": "数字人视频", "x": 920, "y": 160,
|
||
"config": {"format": "mp4", "resolution": "1080x1920"}},
|
||
],
|
||
"edges": [
|
||
{"from": "n1", "to": "n2"},
|
||
{"from": "n1", "to": "n5"},
|
||
{"from": "n5", "to": "n6"},
|
||
{"from": "n2", "to": "n4"},
|
||
{"from": "n6", "to": "n4"},
|
||
],
|
||
},
|
||
"hybrid_dub": {
|
||
"name": "口播+卡点混合",
|
||
"cost": "¥4-10",
|
||
"description": "人声锚点+BGM能量变速→帧锁定拼接",
|
||
"nodes": [
|
||
{"id": "n1", "type": "script", "label": "口播脚本", "x": 40, "y": 160,
|
||
"config": {"editor_mode": "inline"}},
|
||
{"id": "n2", "type": "tts", "label": "人声合成", "x": 260, "y": 160,
|
||
"config": {}},
|
||
{"id": "n3", "type": "style", "label": "混合风格", "x": 260, "y": 40,
|
||
"config": {"style": "cinematic", "color_grade": "dramatic"}},
|
||
{"id": "n5", "type": "material", "label": "画面素材", "x": 480, "y": 160,
|
||
"config": {"query": "生活 日常", "count": 4, "orientation": "portrait", "min_duration": 5}},
|
||
{"id": "n4", "type": "output", "label": "混合视频", "x": 700, "y": 160,
|
||
"config": {"format": "mp4", "resolution": "1080x1920", "compose_mode": "hybrid"}},
|
||
],
|
||
"edges": [
|
||
{"from": "n1", "to": "n2"},
|
||
{"from": "n1", "to": "n5"},
|
||
{"from": "n2", "to": "n4"},
|
||
{"from": "n5", "to": "n4"},
|
||
],
|
||
},
|
||
"short_drama": {
|
||
"name": "短剧创作",
|
||
"cost": "¥15-50",
|
||
"description": "故事原型→改编→脚本→角色→策略→分镜→提示词→视频+配音+配乐+字幕→审核→输出",
|
||
"nodes": [
|
||
{"id": "n1", "type": "story_prototype", "label": "故事输入", "x": 40, "y": 260,
|
||
"config": {"genre": "mystery"}},
|
||
{"id": "n2", "type": "script", "label": "脚本编辑", "x": 220, "y": 260,
|
||
"config": {"editor_mode": "manual", "type": "yaml"}},
|
||
{"id": "n3", "type": "character", "label": "角色设定", "x": 420, "y": 400,
|
||
"config": {"outfit_preset": "default"}},
|
||
{"id": "n4", "type": "strategy", "label": "短剧策略", "x": 420, "y": 260,
|
||
"config": {"strategy": "auto"}},
|
||
{"id": "n5", "type": "storyboard", "label": "分镜规划", "x": 600, "y": 160,
|
||
"config": {"camera_style": "cinematic", "scene_count": 6}},
|
||
{"id": "n6", "type": "prompt_gen", "label": "提示词生成", "x": 780, "y": 60,
|
||
"config": {"prompt_style": "detailed", "quality": "standard", "add_negative": True}},
|
||
{"id": "n7", "type": "model", "label": "场景视频", "x": 960, "y": 60,
|
||
"config": {"provider": "kling", "model": "kling-v3", "duration": 5}},
|
||
{"id": "n8", "type": "tts", "label": "角色配音", "x": 780, "y": 200,
|
||
"config": {}},
|
||
{"id": "n9", "type": "bgm", "label": "配乐", "x": 780, "y": 340,
|
||
"config": {"mode": "auto", "duck_enabled": True}},
|
||
{"id": "n10", "type": "subtitle", "label": "字幕", "x": 960, "y": 200,
|
||
"config": {"format": "srt", "position": "bottom", "style": "white"}},
|
||
{"id": "n11", "type": "audit_gate", "label": "成片审核", "x": 1140, "y": 200,
|
||
"config": {"message": "所有短剧场景已完成,确认合成?"}},
|
||
{"id": "n12", "type": "output", "label": "短剧成片", "x": 1320, "y": 200,
|
||
"config": {"format": "mp4", "resolution": "1080x1920"}},
|
||
],
|
||
"edges": [
|
||
{"from": "n1", "to": "n2"},
|
||
{"from": "n2", "to": "n4"},
|
||
{"from": "n3", "to": "n6"},
|
||
{"from": "n4", "to": "n5"},
|
||
{"from": "n5", "to": "n6"},
|
||
{"from": "n6", "to": "n7"},
|
||
{"from": "n4", "to": "n8"},
|
||
{"from": "n4", "to": "n9"},
|
||
{"from": "n2", "to": "n10"},
|
||
{"from": "n7", "to": "n11"},
|
||
{"from": "n8", "to": "n11"},
|
||
{"from": "n9", "to": "n11"},
|
||
{"from": "n10", "to": "n11"},
|
||
{"from": "n11", "to": "n12"},
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 角色注册表加载
|
||
# ═══════════════════════════════════════════
|
||
|
||
_registry_cache = {"data": None, "mtime": 0}
|
||
|
||
|
||
def load_character_registry():
|
||
"""加载角色注册表"""
|
||
if not _REGISTRY_PATH.exists():
|
||
return {"characters": {}, "active_character": ""}
|
||
try:
|
||
import yaml
|
||
mtime = _REGISTRY_PATH.stat().st_mtime
|
||
if _registry_cache["mtime"] >= mtime and _registry_cache["data"] is not None:
|
||
return _registry_cache["data"]
|
||
with open(_REGISTRY_PATH, "r", encoding="utf-8") as f:
|
||
data = yaml.safe_load(f) or {}
|
||
_registry_cache["data"] = data
|
||
_registry_cache["mtime"] = mtime
|
||
return data
|
||
except Exception as e:
|
||
logger.warning(f"加载角色注册表失败: {e}")
|
||
return {"characters": {}, "active_character": ""}
|
||
|
||
|
||
def get_character_list():
|
||
"""获取角色名称列表"""
|
||
data = load_character_registry()
|
||
chars = data.get("characters", {})
|
||
active = data.get("active_character", "")
|
||
return [{"name": name, "active": name == active} for name in chars]
|
||
|
||
|
||
def get_character_data(name: str) -> dict | None:
|
||
"""获取单个角色详情"""
|
||
data = load_character_registry()
|
||
return data.get("characters", {}).get(name)
|
||
|
||
|
||
def reload_character_registry():
|
||
"""强制重新加载注册表"""
|
||
_registry_cache["mtime"] = 0
|
||
return load_character_registry()
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# 节点 → 能力映射 + 能力实现(P2:让节点真正执行)
|
||
# ═══════════════════════════════════════════
|
||
|
||
# 有真实执行的能力节点(其余能力待接入 → 执行时明确 skipped 并说明)
|
||
NODE_TO_CAPABILITY = {
|
||
"script_adaptor": "script_generate",
|
||
"tts": "tts_generate",
|
||
"model": "text_to_video",
|
||
"material": "material_search",
|
||
"bgm": "music_select",
|
||
"storyboard": "storyboard_plan",
|
||
"prompt_gen": "prompt_gen",
|
||
"subtitle": "subtitle",
|
||
"output": "video_concat",
|
||
}
|
||
|
||
# 输入/参数类节点(无执行,值透传给下游)
|
||
PASSTHROUGH_NODES = {"script", "story_prototype", "strategy", "style", "character"}
|
||
|
||
|
||
def _ensure_ave_path() -> None:
|
||
"""把 09_ave/scripts 加入 sys.path(供能力实现 import)"""
|
||
import sys as _sys
|
||
if str(_AVE_DIR) not in _sys.path:
|
||
_sys.path.insert(0, str(_AVE_DIR))
|
||
|
||
|
||
def _agent_local_dir() -> Path:
|
||
return Path(os.environ.get("AGENT_LOCAL",
|
||
Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||
|
||
|
||
# ── 上游产物提取辅助 ──────────────────────────
|
||
|
||
def _iter_outputs(inputs: dict):
|
||
for v in (inputs or {}).values():
|
||
if isinstance(v, dict):
|
||
yield v
|
||
|
||
|
||
def _pick_text(inputs: dict) -> str:
|
||
for o in _iter_outputs(inputs):
|
||
for k in ("text", "content", "script", "story", "story_text"):
|
||
v = o.get(k)
|
||
if isinstance(v, str) and v.strip():
|
||
return v
|
||
return ""
|
||
|
||
|
||
def _find_storyboard(inputs: dict):
|
||
for o in _iter_outputs(inputs):
|
||
if isinstance(o.get("storyboard"), dict):
|
||
return o["storyboard"]
|
||
if o.get("shot_count") and o.get("shots"):
|
||
return o
|
||
return None
|
||
|
||
|
||
def _find_prompts(inputs: dict) -> dict:
|
||
for o in _iter_outputs(inputs):
|
||
if isinstance(o.get("prompts"), dict):
|
||
return o["prompts"]
|
||
return {}
|
||
|
||
|
||
def _find_audio(inputs: dict) -> str:
|
||
"""上游 TTS 音频路径"""
|
||
for o in _iter_outputs(inputs):
|
||
p = o.get("audio_path")
|
||
if isinstance(p, str) and p.endswith((".wav", ".mp3")) and Path(p).exists():
|
||
return p
|
||
return ""
|
||
|
||
|
||
def _find_bgm(inputs: dict) -> str:
|
||
"""上游 BGM 路径(bgm 节点返回相对 assets 的路径,需拼成绝对)"""
|
||
for o in _iter_outputs(inputs):
|
||
p = o.get("path") or o.get("bgm_path")
|
||
if isinstance(p, str) and p.endswith((".mp3", ".wav")):
|
||
if not os.path.isabs(p):
|
||
p = str(_agent_local_dir() / "tools" / "ave" / p)
|
||
if Path(p).exists():
|
||
return p
|
||
return ""
|
||
|
||
|
||
def _find_subtitle(inputs: dict) -> str:
|
||
"""上游字幕文件路径"""
|
||
for o in _iter_outputs(inputs):
|
||
p = o.get("subtitle_path")
|
||
if isinstance(p, str) and p.endswith((".ass", ".srt")) and Path(p).exists():
|
||
return p
|
||
return ""
|
||
|
||
|
||
def _find_clips(inputs: dict) -> list:
|
||
clips: list = []
|
||
for o in _iter_outputs(inputs):
|
||
for c in (o.get("clips") or []):
|
||
if isinstance(c, str) and c.endswith((".mp4", ".mov")) and c not in clips:
|
||
clips.append(c)
|
||
for item in (o.get("generated") or []):
|
||
p = (item or {}).get("path")
|
||
if p and p not in clips:
|
||
clips.append(p)
|
||
p = o.get("video_path")
|
||
if isinstance(p, str) and p.endswith((".mp4", ".mov")) and p not in clips:
|
||
clips.append(p)
|
||
return clips
|
||
|
||
|
||
# ── 能力实现(签名统一:node, cfg, inputs, ctx → 产物 dict)──
|
||
|
||
def _impl_storyboard_plan(node, cfg, inputs, ctx):
|
||
"""分镜规划:故事 → 统一分镜 Schema(用导演系统,默认 v5.2)"""
|
||
_ensure_ave_path()
|
||
from directors import default_director
|
||
story = (cfg.get("story_text") or cfg.get("content") or _pick_text(inputs) or "").strip()
|
||
if not story:
|
||
raise ValueError("分镜规划需要故事文本(上游未提供,且节点未填 story_text/content)")
|
||
d = default_director()
|
||
sb = d.generate(story, options={
|
||
"shot_count": int(cfg.get("scene_count") or 3),
|
||
"shot_duration": 5,
|
||
})
|
||
return {"storyboard": sb.to_dict(), "director_id": d.id,
|
||
"shot_count": len(sb.shots), "total_duration": sb.total_duration(),
|
||
"summary": sb.summary()}
|
||
|
||
|
||
def _impl_prompt_gen(node, cfg, inputs, ctx):
|
||
"""分镜提示词:分镜 → 每镜头提示词(六模块编译器)"""
|
||
_ensure_ave_path()
|
||
from composer.pipeline import StoryboardPipeline
|
||
from schemas import Storyboard
|
||
sb_data = _find_storyboard(inputs)
|
||
if not sb_data:
|
||
raise ValueError("提示词生成需要上游分镜(storyboard 节点)")
|
||
sb = Storyboard.from_dict(sb_data)
|
||
prompts = {s.shot_id: StoryboardPipeline.build_shot_prompt(s, sb) for s in sb.shots}
|
||
return {"prompts": prompts, "count": len(prompts),
|
||
"sample": next(iter(prompts.values()), "")}
|
||
|
||
|
||
def _impl_text_to_video(node, cfg, inputs, ctx):
|
||
"""视频生成:按分镜逐镜头调可灵生成(试跑默认限 3 镜头,可由 ctx.max_shots 调整)"""
|
||
_ensure_ave_path()
|
||
from capabilities.kling_video import KlingVideoClient
|
||
from composer.pipeline import StoryboardPipeline
|
||
from schemas import Storyboard
|
||
|
||
client = KlingVideoClient()
|
||
if not client.available:
|
||
raise ValueError("可灵未配置 api_key(去 Dashboard → 🔑 API 配置 填写)")
|
||
|
||
# 🛡️🛡️ 费用强制确认(后端硬拦截,防止任何入口绕过前端确认框)
|
||
# 任何调用可灵(真实扣费)的操作,必须显式带 confirm_cost=true
|
||
if os.environ.get("AVE_ALLOW_GENERATION", "1") == "0":
|
||
raise ValueError(
|
||
"⛔ 生成功能已被全局关闭(环境变量 AVE_ALLOW_GENERATION=0)。\n"
|
||
"如需恢复:删除该环境变量并重启 Dashboard。"
|
||
)
|
||
if not (ctx.get("confirm_cost") or cfg.get("confirm_cost")):
|
||
raise ValueError(
|
||
"⛔ 生成视频会**真实扣费**,需要显式确认才能执行。\n"
|
||
"· 页面上运行:会先弹费用预估确认框,确认即通过\n"
|
||
"· API 直接调用:必须传 \"confirm_cost\": true\n"
|
||
"(此保护用于防止误跑产生费用)"
|
||
)
|
||
|
||
out_dir = _agent_local_dir() / "runtime" / "ave" / "workflow" / ctx["run_id"] / node["id"]
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
sb_data = _find_storyboard(inputs)
|
||
generated: list = []
|
||
if sb_data:
|
||
sb = Storyboard.from_dict(sb_data)
|
||
prompts = _find_prompts(inputs) or {}
|
||
limit = int(ctx.get("max_shots") or 3)
|
||
# 🛡️ 费用硬保护:单次运行最多生成的镜头数(防误跑大量消耗)
|
||
HARD_CAP = int(os.environ.get("AVE_MAX_SHOTS_PER_RUN", "5"))
|
||
if limit > HARD_CAP:
|
||
raise ValueError(
|
||
f"单次最多生成 {HARD_CAP} 个镜头(当前请求 {limit} 个)—— "
|
||
f"这是费用保护;确需更多请分批执行,或设置环境变量 AVE_MAX_SHOTS_PER_RUN 后重启"
|
||
)
|
||
logger.info(" 💰 将调用可灵生成 %d 个镜头(约 %d 秒视频,按 ¥0.6/秒 估 ≈ ¥%.1f)",
|
||
min(limit, len(sb.shots)), min(limit, len(sb.shots)) * 5,
|
||
min(limit, len(sb.shots)) * 5 * 0.6)
|
||
for i, shot in enumerate(sb.shots[:limit], 1):
|
||
prompt = prompts.get(shot.shot_id) or StoryboardPipeline.build_shot_prompt(shot, sb)
|
||
dest = out_dir / f"shot_{i:02d}.mp4"
|
||
dur = "10" if (shot.duration or 5) > 7 else "5"
|
||
client.generate_video(prompt, dest, duration=dur,
|
||
aspect_ratio=StoryboardPipeline._aspect_ratio(sb))
|
||
generated.append({"shot_id": shot.shot_id, "path": str(dest)})
|
||
ctx.setdefault("logs", []).append(f"🎬 镜头 {shot.shot_id} 生成完成")
|
||
else:
|
||
prompt = (cfg.get("prompt") or _pick_text(inputs) or "").strip()
|
||
if not prompt:
|
||
raise ValueError("视频生成需要提示词(上游分镜 或 节点 prompt 配置)")
|
||
dest = out_dir / "shot_01.mp4"
|
||
client.generate_video(prompt, dest, duration=str(cfg.get("duration") or 5))
|
||
generated.append({"shot_id": "shot_01", "path": str(dest)})
|
||
|
||
return {"clips": [g["path"] for g in generated], "generated": generated,
|
||
"count": len(generated), "provider": cfg.get("provider") or "kling",
|
||
"model": cfg.get("model") or "kling-v3"}
|
||
|
||
|
||
def _impl_subtitle_generate(node, cfg, inputs, ctx):
|
||
"""字幕生成:分镜台词(按镜头时间轴)或纯文本(按句均分)→ ASS 字幕文件"""
|
||
_ensure_ave_path()
|
||
from composer.ffmpeg import create_subtitles
|
||
|
||
out_dir = _agent_local_dir() / "runtime" / "ave" / "workflow" / ctx["run_id"] / node["id"]
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
out = out_dir / "subtitles.ass"
|
||
|
||
# 分辨率(优先节点配置,其次分镜输出规格)
|
||
res = (cfg.get("resolution") or "").strip()
|
||
if not res:
|
||
sb0 = _find_storyboard(inputs)
|
||
try:
|
||
res = ((sb0 or {}).get("shots") or [{}])[0].get("output", {}).get("resolution") or ""
|
||
except Exception:
|
||
res = ""
|
||
try:
|
||
w, h = (int(x) for x in (res or "1080x1920").lower().split("x"))
|
||
except Exception:
|
||
w, h = 1080, 1920
|
||
|
||
segments: list = []
|
||
sb = _find_storyboard(inputs)
|
||
if sb:
|
||
t = 0.0
|
||
for s in (sb.get("shots") or []):
|
||
txt = (s.get("text") or s.get("subtitle") or "").strip()
|
||
dur = float(s.get("duration") or 5)
|
||
if txt:
|
||
segments.append({"text": txt, "start_sec": round(t, 2),
|
||
"end_sec": round(t + dur, 2),
|
||
"emotion": s.get("sentiment") or ""})
|
||
t += dur
|
||
# 兜底:纯文本 → 按句切分 + 按字数估算时长
|
||
if not segments:
|
||
text = (cfg.get("text") or _pick_text(inputs) or "").strip()
|
||
if text:
|
||
import re as _re
|
||
parts = [x.strip() for x in _re.split(r"[。!?!?\n]+", text) if x.strip()]
|
||
t = 0.0
|
||
for p in parts:
|
||
dur = max(1.5, round(len(p) / 5.0, 2)) # 约 5 字/秒
|
||
segments.append({"text": p, "start_sec": round(t, 2),
|
||
"end_sec": round(t + dur, 2)})
|
||
t += dur
|
||
if not segments:
|
||
raise ValueError("字幕生成需要上游分镜(含台词)或文本")
|
||
|
||
path = create_subtitles(segments, str(out), resolution=(w, h))
|
||
return {"subtitle_path": path, "segments": len(segments), "format": "ass",
|
||
"resolution": f"{w}x{h}"}
|
||
|
||
|
||
def _impl_music_select(node, cfg, inputs, ctx):
|
||
"""BGM 选择:**优先从资产库**(用户在「🎵 BGM 库」上传的音频)选,
|
||
其次回落到内置音乐库(`music_selector.BGMSelector`)
|
||
|
||
- 关键词 / 风格过滤(资产库按 name / mood / tags 匹配)
|
||
- **只返回音频文件真实存在**的条目(避免下游合成才发现缺失)
|
||
"""
|
||
_ensure_ave_path()
|
||
style = (cfg.get("style") or cfg.get("mood") or "").strip()
|
||
keyword = (cfg.get("keyword") or "").strip()
|
||
limit = int(cfg.get("count") or 5)
|
||
AUDIO_EXT = (".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg")
|
||
|
||
# ── ① 资产库优先(用户上传的 BGM)──
|
||
lib: list = []
|
||
try:
|
||
from assets_manager import AssetStore
|
||
st = AssetStore("bgm")
|
||
for m in st.list():
|
||
hay = str(m.get("name", "")) + str(m.get("tags", "")) + str(m.get("description", "")) + str(m.get("mood", ""))
|
||
if keyword and keyword not in hay:
|
||
continue
|
||
if style and style not in hay:
|
||
continue
|
||
d = Path(st.kind_dir(m["id"]))
|
||
cands = sorted(p for p in d.rglob("*") if p.suffix.lower() in AUDIO_EXT)
|
||
if not cands:
|
||
continue
|
||
lib.append({
|
||
"id": m["id"], "title": m.get("name") or m["id"],
|
||
"path": str(cands[0]), "file": str(cands[0]),
|
||
"style": m.get("mood") or "", "bpm": m.get("bpm"), "source": "资产库",
|
||
})
|
||
except Exception as e:
|
||
log.debug("读 BGM 资产库失败: %s", e)
|
||
|
||
if lib:
|
||
pick = lib[0]
|
||
log.info(" 🎵 BGM 选中(资产库): %s", pick.get("title"))
|
||
return {"bgm": pick, "path": pick["path"], "style": pick.get("style"),
|
||
"bpm": pick.get("bpm"), "count": len(lib), "candidates": lib[:limit],
|
||
"source": "资产库"}
|
||
|
||
# ── ② 回落:内置音乐库 ──
|
||
from music_selector import BGMSelector
|
||
sel = BGMSelector()
|
||
if keyword:
|
||
items = sel.search(keyword)
|
||
elif style:
|
||
items = sel.get_by_style(style)
|
||
else:
|
||
items = sel.get_trending(limit)
|
||
|
||
valid = []
|
||
for it in items:
|
||
f = (it.get("file") or "").strip()
|
||
if not f:
|
||
continue
|
||
p = Path(f) if os.path.isabs(f) else (_agent_local_dir() / "tools" / "ave" / f)
|
||
if p.exists():
|
||
valid.append({**it, "path": str(p)})
|
||
|
||
if not valid:
|
||
cnt = len(items)
|
||
raise ValueError(
|
||
(f"BGM 库未找到匹配(风格={style or '任意'} 关键词={keyword or '无'})" if not cnt
|
||
else f"BGM 库有 {cnt} 条元数据,但音频文件都不存在")
|
||
+ " → 到「🎵 BGM 库」上传音频(上传后会自动被选择器读到)"
|
||
)
|
||
pick = valid[0]
|
||
return {"bgm": pick, "path": pick.get("path") or pick.get("file"), "style": pick.get("style"),
|
||
"bpm": pick.get("bpm"), "count": len(valid), "candidates": valid[:limit],
|
||
"source": "内置音乐库"}
|
||
|
||
|
||
def _impl_material_search(node, cfg, inputs, ctx):
|
||
"""素材搜索:关键词 → 视频素材(Pexels,含中文自动翻译 + 3 轮回落策略)"""
|
||
_ensure_ave_path()
|
||
import yaml
|
||
from material_producer.pexels.search import search_videos
|
||
|
||
query = (cfg.get("query") or cfg.get("keywords") or _pick_text(inputs) or "").strip()
|
||
if not query:
|
||
raise ValueError("素材搜索需要关键词(节点 query 配置 或 上游文本)")
|
||
|
||
# 读 Pexels api_key
|
||
local = _agent_local_dir() / "tools" / "ave" / "config" / "local.yaml"
|
||
key = ""
|
||
if local.exists():
|
||
try:
|
||
d = yaml.safe_load(local.read_text(encoding="utf-8")) or {}
|
||
key = ((d.get("pexels") or {}).get("api_key") or "").strip()
|
||
except Exception:
|
||
pass
|
||
if not key:
|
||
raise ValueError("Pexels API Key 未配置 → Dashboard → 服务 → 🔑 API 配置 → 素材搜索(Pexels)")
|
||
|
||
videos = search_videos(
|
||
query,
|
||
count=int(cfg.get("count") or 3),
|
||
api_key=key,
|
||
min_duration=int(cfg.get("min_duration") or 5),
|
||
orientation=cfg.get("orientation") or "portrait",
|
||
)
|
||
if not videos:
|
||
raise ValueError(f"未搜到素材(关键词:{query})—— 可换个词或放宽时长/画幅")
|
||
return {"clips": [v["path"] for v in videos], "materials": videos,
|
||
"count": len(videos), "query": query, "provider": "pexels"}
|
||
|
||
|
||
def _impl_video_concat(node, cfg, inputs, ctx):
|
||
"""输出/合成:上游画面 + **人声 + BGM + 字幕** → 成片(先拼接去AI化,再混流)"""
|
||
_ensure_ave_path()
|
||
from composer.pipeline import StoryboardPipeline
|
||
from schemas import Storyboard
|
||
|
||
clips = _find_clips(inputs)
|
||
if not clips:
|
||
raise ValueError("输出节点需要上游视频片段(model 节点的产物)")
|
||
out_dir = _agent_local_dir() / "runtime" / "ave" / "workflow" / ctx["run_id"] / node["id"]
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
base = out_dir / "final.mp4"
|
||
|
||
# ⓪ 合成模式分支:beat_sync(卡点变速)| hybrid(口播+卡点)| 默认 simple(拼接+混流)
|
||
voice0 = _find_audio(inputs)
|
||
bgm0 = _find_bgm(inputs)
|
||
sub0 = _find_subtitle(inputs)
|
||
mode = (cfg.get("compose_mode") or "").strip()
|
||
resolution = str(cfg.get("resolution") or "1080x1920")
|
||
|
||
if mode == "beat_sync" and bgm0:
|
||
from composer.beat_sync import compose_beat_sync
|
||
out = out_dir / "final_beat.mp4"
|
||
compose_beat_sync(
|
||
bgm_path=bgm0, output_path=str(out), material_clips=clips,
|
||
resolution=resolution,
|
||
group_size=int(cfg.get("group_size") or 4),
|
||
enable_speed_ramp=bool(cfg.get("enable_speed_ramp", False)),
|
||
frame_locked=bool(cfg.get("frame_locked", True)),
|
||
)
|
||
return {"output": str(out), "clips": clips, "segments": len(clips),
|
||
"compose_mode": "beat_sync", "has_bgm": True,
|
||
"resolution": resolution, "format": "mp4"}
|
||
|
||
if mode == "hybrid" and bgm0 and voice0:
|
||
from composer.hybrid import compose_hybrid
|
||
out = out_dir / "final_hybrid.mp4"
|
||
compose_hybrid(
|
||
voice_path=voice0, bgm_path=bgm0, output_path=str(out),
|
||
material_clips=clips, resolution=resolution,
|
||
enable_speed_ramp=bool(cfg.get("enable_speed_ramp", True)),
|
||
group_size=int(cfg.get("group_size") or 4),
|
||
)
|
||
return {"output": str(out), "clips": clips, "segments": len(clips),
|
||
"compose_mode": "hybrid", "has_voice": True, "has_bgm": True,
|
||
"resolution": resolution, "format": "mp4"}
|
||
|
||
# ① 画面:拼接 + 去 AI 化
|
||
sb_data = _find_storyboard(inputs)
|
||
sb = Storyboard.from_dict(sb_data) if sb_data else None
|
||
pipe = StoryboardPipeline(work_dir=str(out_dir))
|
||
r = pipe.render_from_clips(clips, str(base), storyboard=sb, apply_deai=True)
|
||
out_path = r["output"]
|
||
|
||
# ② 混流:人声 / BGM / 字幕(任一存在则混流)
|
||
voice = voice0
|
||
bgm = bgm0
|
||
sub = sub0
|
||
muxed = False
|
||
if voice or bgm or sub:
|
||
mux_out = out_dir / "final_muxed.mp4"
|
||
try:
|
||
out_path = StoryboardPipeline.mux_final(
|
||
r["output"], str(mux_out),
|
||
voice_path=voice, bgm_path=bgm, subtitle_path=sub,
|
||
bgm_volume=float(cfg.get("bgm_volume") or 0.15),
|
||
burn_subtitle=bool(cfg.get("burn_subtitle", True)),
|
||
)
|
||
muxed = True
|
||
except Exception as e:
|
||
log.warning(" 混流失败,返回纯画面成片: %s", e)
|
||
ctx.setdefault("logs", []).append(f"⚠️ 混流失败(已返回纯画面成片): {e}")
|
||
|
||
return {**r, "output": out_path,
|
||
"resolution": cfg.get("resolution") or "1080x1920",
|
||
"format": cfg.get("format") or "mp4",
|
||
"has_voice": bool(voice), "has_bgm": bool(bgm),
|
||
"has_subtitle": bool(sub), "muxed": muxed}
|
||
|
||
|
||
def _impl_tts_generate(node, cfg, inputs, ctx):
|
||
"""语音合成:文本 → 音频(火山豆包 TTS / 阿里 CosyVoice)"""
|
||
_ensure_ave_path()
|
||
import yaml
|
||
|
||
text = (cfg.get("text") or _pick_text(inputs) or "").strip()
|
||
if not text:
|
||
raise ValueError("TTS 需要文本(上游透传 或 节点 text 配置)")
|
||
|
||
local_cfg: dict = {}
|
||
lp = _agent_local_dir() / "tools" / "ave" / "config" / "local.yaml"
|
||
if lp.exists():
|
||
try:
|
||
local_cfg = yaml.safe_load(lp.read_text(encoding="utf-8")) or {}
|
||
except Exception:
|
||
local_cfg = {}
|
||
|
||
v_cfg = local_cfg.get("volcano") or {}
|
||
a_cfg = local_cfg.get("aliyun") or {}
|
||
|
||
# provider 未指定时**自动选可用供应商**:火山(需 access_token)> 阿里 CosyVoice(需 api_key)
|
||
provider = str(cfg.get("provider") or "").lower()
|
||
if not provider or provider in ("auto", "default"):
|
||
if v_cfg.get("access_token"):
|
||
provider = "volcano"
|
||
elif a_cfg.get("api_key"):
|
||
provider = "aliyun"
|
||
else:
|
||
raise ValueError("没有可用的 TTS 供应商:火山需 access_token、阿里需 api_key(去「🔑 API 配置」填写)")
|
||
|
||
out_dir = _agent_local_dir() / "runtime" / "ave" / "workflow" / ctx["run_id"] / node["id"]
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
out = out_dir / "voice.wav"
|
||
|
||
if provider in ("volcano", "火山", "doubao"):
|
||
if not v_cfg.get("access_token"):
|
||
raise ValueError("火山 TTS 未配置 access_token(当前只有 AK/SK,"
|
||
"而代码里的 AK/SK 签名是简化版)→ 去火山控制台复制「访问令牌」填入,或改用阿里 CosyVoice")
|
||
from voice_synthesizer import volcano as vs
|
||
vs.synthesize(
|
||
text, str(out),
|
||
access_key_id=v_cfg.get("access_key_id", ""),
|
||
secret_access_key=v_cfg.get("secret_access_key", ""),
|
||
app_id=str(v_cfg.get("voice_app_id") or v_cfg.get("app_id") or ""),
|
||
access_token=v_cfg.get("access_token", ""),
|
||
voice_type=str(cfg.get("voice_id") or "BV001_V2"),
|
||
emotion=str(cfg.get("emotion") or "happy"),
|
||
speed=float(cfg.get("speed") or 1.0),
|
||
)
|
||
else:
|
||
if not a_cfg.get("api_key"):
|
||
raise ValueError("阿里语音未配置 api_key(去「🔑 API 配置」填写)")
|
||
from voice_synthesizer import aliyun as al
|
||
al.synthesize(
|
||
text, str(out),
|
||
api_key=a_cfg.get("api_key", ""),
|
||
# 阿里 CosyVoice 的 model 名带项目 ID,**以配置为准**(节点默认值可能是旧模型名)
|
||
voice_id=str(a_cfg.get("cosyvoice_model") or cfg.get("voice_id") or ""),
|
||
emotion=str(cfg.get("emotion") or "normal"),
|
||
speed=float(cfg.get("speed") or 1.0),
|
||
)
|
||
|
||
return {"audio_path": str(out), "text": text[:200], "provider": provider,
|
||
"char_count": len(text)}
|
||
|
||
|
||
def _impl_script_generate(node, cfg, inputs, ctx):
|
||
"""脚本生成:故事/主题 → 结构化口播脚本(段落 + 旁白 + 画面提示)"""
|
||
_ensure_ave_path()
|
||
from directors.llm import DirectorLLM
|
||
story = (cfg.get("story") or cfg.get("content") or _pick_text(inputs) or "").strip()
|
||
if not story:
|
||
raise ValueError("脚本生成需要故事/主题(上游未提供,且节点未填 story/content)")
|
||
llm = DirectorLLM()
|
||
if not llm.available:
|
||
raise ValueError("LLM 未配置(去 🔑 API 配置 填 DeepSeek key)")
|
||
seg_count = int(cfg.get("segments") or 4)
|
||
prompt = (
|
||
f"把下面内容改写成适合短视频的**口播脚本**,分成 {seg_count} 段。\n\n"
|
||
f"原始内容:\n{story[:1500]}\n\n"
|
||
"要求:\n"
|
||
"1. 每段 1~2 句,口语化自然,像真人说话(不要书面语)\n"
|
||
"2. 开头要有钩子(让人想继续看),结尾要有行动号召\n"
|
||
"3. 保留原始信息,不要编造\n"
|
||
"4. 每段配一句「画面提示」(这段画面拍什么)\n\n"
|
||
"严格输出 JSON(不要任何额外文字):\n"
|
||
'{"title": "标题", "segments": [{"text": "旁白", "visual": "画面提示"}]}'
|
||
)
|
||
raw = llm.chat(prompt, temperature=0.7, max_tokens=2000)
|
||
data = llm.parse_json(raw) or {}
|
||
segs = data.get("segments") or []
|
||
if not segs:
|
||
raise ValueError(f"LLM 未返回有效脚本(输出前 100 字:{str(raw)[:100]})")
|
||
full = "\n".join(str(s.get("text", "")) for s in segs)
|
||
return {"script": full, "text": full, "segments": segs,
|
||
"title": data.get("title") or "", "segment_count": len(segs)}
|
||
|
||
|
||
def _impl_person_swap(node, cfg, inputs, ctx):
|
||
"""人物置换:参考图(角色)+ 源视频 → 换人后的视频(百炼 wan2.2-animate-mix)"""
|
||
_ensure_ave_path()
|
||
if not ctx.get("confirm_cost"):
|
||
raise ValueError("人物置换会产生费用(按秒计费)—— 请在前端确认费用后重试")
|
||
from person_swap import api as ps
|
||
|
||
# 参考图:节点配置 或 从角色资产的参考图取
|
||
ref = (cfg.get("ref_image") or "").strip()
|
||
if not ref and cfg.get("character"):
|
||
try:
|
||
from assets_manager import AssetStore
|
||
st = AssetStore("characters")
|
||
item = st.get(cfg["character"]) or {}
|
||
refs = item.get("reference_images") or {}
|
||
fname = next((v for v in refs.values() if v), "")
|
||
if fname:
|
||
ref = str(Path(st.kind_dir(cfg["character"])) / fname)
|
||
except Exception:
|
||
pass
|
||
if not ref or not Path(ref).exists():
|
||
raise ValueError("人物置换需要参考图(节点 ref_image 配置,或选一个已有参考图的角色)")
|
||
|
||
clips = _find_clips(inputs)
|
||
if not clips:
|
||
raise ValueError("人物置换需要源视频(上游生成节点的产物)")
|
||
src = clips[0]
|
||
out_dir = _agent_local_dir() / "runtime" / "ave" / "workflow" / ctx["run_id"] / node["id"]
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
dest = out_dir / f"swap_{Path(src).stem}.mp4"
|
||
r = ps.run_swap(ref, src, str(dest))
|
||
return {"video_path": str(dest), "clips": [str(dest)],
|
||
"ref_image": ref, "source": src,
|
||
"provider": "百炼 wan2.2-animate-mix"}
|
||
|
||
|
||
CAP_IMPLS = {
|
||
"storyboard_plan": _impl_storyboard_plan,
|
||
"script_generate": _impl_script_generate,
|
||
"person_swap": _impl_person_swap,
|
||
"prompt_gen": _impl_prompt_gen,
|
||
"text_to_video": _impl_text_to_video,
|
||
"video_concat": _impl_video_concat,
|
||
"tts_generate": _impl_tts_generate,
|
||
"material_search": _impl_material_search,
|
||
"music_select": _impl_music_select,
|
||
"subtitle": _impl_subtitle_generate,
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════
|
||
# WorkflowRunner — DAG 执行引擎(P2:真执行)
|
||
# ═══════════════════════════════════════════
|
||
|
||
class WorkflowRunner:
|
||
"""工作流运行器(Kahn 拓扑排序 + 逐节点真执行 + 产物上下文 + 暂停/重做)"""
|
||
|
||
def __init__(self):
|
||
self._runs: dict[str, dict] = {}
|
||
|
||
def create_run(self, template_id: str, nodes: list, edges: list) -> str:
|
||
"""创建工作流运行"""
|
||
run_id = f"wf_{uuid.uuid4().hex[:12]}"
|
||
self._runs[run_id] = {
|
||
"run_id": run_id,
|
||
"template_id": template_id,
|
||
"nodes": nodes,
|
||
"edges": edges,
|
||
"status": "pending",
|
||
"created": time.time(),
|
||
"node_states": {n["id"]: "pending" for n in nodes},
|
||
"context": {}, # 节点产物(下游可引用)
|
||
"pause_node": None, # audit_gate 暂停位置
|
||
"error": None,
|
||
}
|
||
return run_id
|
||
|
||
def get_run(self, run_id: str) -> dict | None:
|
||
"""获取运行状态"""
|
||
return self._runs.get(run_id)
|
||
|
||
def list_runs(self) -> list:
|
||
return list(self._runs.values())
|
||
|
||
# ── 启动 / 恢复 / 重做 ────────────────────────
|
||
|
||
def start_run(self, run_id: str, max_shots: int = 3, from_node: str | None = None,
|
||
confirm_cost: bool = False):
|
||
"""异步启动工作流(from_node:只跑该节点及其下游;confirm_cost:允许真实扣费)"""
|
||
run = self._runs.get(run_id)
|
||
if not run:
|
||
return False
|
||
if max_shots:
|
||
run["max_shots"] = int(max_shots)
|
||
run["confirm_cost"] = bool(confirm_cost)
|
||
if from_node:
|
||
downstream = self._downstream_ids(run, from_node)
|
||
for nid in downstream:
|
||
run["node_states"][nid] = "pending"
|
||
run["status"] = "running"
|
||
run["error"] = None
|
||
threading.Thread(target=self._execute, args=(run,), daemon=True).start()
|
||
return True
|
||
|
||
def resume(self, run_id: str) -> bool:
|
||
"""audit_gate 审核通过 → 从暂停处继续(该节点视为完成)"""
|
||
run = self._runs.get(run_id)
|
||
if not run or run.get("status") != "paused":
|
||
return False
|
||
nid = run.get("pause_node")
|
||
if nid:
|
||
run["node_states"][nid] = "completed"
|
||
# 透传上游产物 + approved 标记(保证下游能拿到数据,不被审核门切断)
|
||
ctx_out: dict = {"approved": True}
|
||
for e in (run.get("edges") or []):
|
||
if e.get("to") == nid:
|
||
up = run["context"].get(e.get("from")) or {}
|
||
if isinstance(up, dict):
|
||
for k, v in up.items():
|
||
ctx_out.setdefault(k, v)
|
||
run["context"][nid] = ctx_out
|
||
run["pause_node"] = None
|
||
run["status"] = "running"
|
||
threading.Thread(target=self._execute, args=(run,), daemon=True).start()
|
||
return True
|
||
|
||
def cancel(self, run_id: str) -> bool:
|
||
"""请求停止运行中的流程(执行器在节点边界检查并退出)"""
|
||
run = self._runs.get(run_id)
|
||
if not run or run.get("status") not in ("running", "paused", "pending"):
|
||
return False
|
||
run["cancelled"] = True
|
||
run["status"] = "cancelled"
|
||
run["finished_at"] = time.time()
|
||
logger.info(" ⏹️ 工作流已请求停止: %s", run_id)
|
||
return True
|
||
|
||
def redo_node(self, run_id: str, node_id: str, max_shots: int = 3) -> bool:
|
||
"""重做某节点及其下游"""
|
||
run = self._runs.get(run_id)
|
||
if not run or node_id not in run["node_states"]:
|
||
return False
|
||
return self.start_run(run_id, max_shots=max_shots, from_node=node_id)
|
||
|
||
@staticmethod
|
||
def _downstream_ids(run: dict, node_id: str) -> list[str]:
|
||
"""某节点的所有下游(含自身)"""
|
||
adj: dict[str, list] = {}
|
||
for e in run.get("edges") or []:
|
||
adj.setdefault(e["from"], []).append(e["to"])
|
||
seen, stack = set(), [node_id]
|
||
while stack:
|
||
nid = stack.pop()
|
||
if nid in seen:
|
||
continue
|
||
seen.add(nid)
|
||
stack.extend(adj.get(nid, []))
|
||
return list(seen)
|
||
|
||
# ── 执行 ─────────────────────────────────────
|
||
|
||
def _execute(self, run: dict):
|
||
run_id = run["run_id"]
|
||
try:
|
||
ordered = self._kahn_sort(run["nodes"], run["edges"])
|
||
for n in ordered:
|
||
nid = n["id"]
|
||
# 用户请求停止 → 在节点边界退出
|
||
if run.get("cancelled"):
|
||
run["status"] = "cancelled"
|
||
logger.info(" ⏹️ 工作流 %s 已在节点边界停止", run_id)
|
||
return
|
||
# 已完成的节点跳过(支持 resume / 局部重跑)
|
||
if run["node_states"].get(nid) == "completed":
|
||
continue
|
||
run["node_states"][nid] = "running"
|
||
ctx = {
|
||
"run_id": run_id,
|
||
"max_shots": run.get("max_shots") or 3,
|
||
"confirm_cost": bool(run.get("confirm_cost")),
|
||
"config": self._merge_config(n),
|
||
"inputs": self._collect_inputs(run, n),
|
||
}
|
||
res = self._run_node(n, ctx)
|
||
state = res.get("status", "failed")
|
||
run["node_states"][nid] = state
|
||
out = res.get("output") or {}
|
||
if res.get("error"):
|
||
out = {**out, "_error": res["error"]}
|
||
run["context"][nid] = out
|
||
logger.info(" 工作流 %s 节点 %s(%s) → %s", run_id, nid, n.get("type"), state)
|
||
|
||
if state == "paused":
|
||
run["pause_node"] = nid
|
||
run["status"] = "paused"
|
||
logger.info(" ⏸️ 工作流 %s 在 %s 暂停(等待人工审核)", run_id, nid)
|
||
return
|
||
if state == "failed":
|
||
run["status"] = "failed"
|
||
run["error"] = f"节点 {nid}({n.get('type')})失败: {res.get('error')}"
|
||
return
|
||
run["status"] = "completed"
|
||
run["finished_at"] = time.time()
|
||
logger.info(" ✅ 工作流 %s 执行完成", run_id)
|
||
except Exception as e:
|
||
run["status"] = "failed"
|
||
run["error"] = str(e)[:300]
|
||
logger.error("工作流 %s 执行异常: %s", run_id, e)
|
||
|
||
def _merge_config(self, node: dict) -> dict:
|
||
"""合并配置:能力默认值 ← 节点 config"""
|
||
ntype = node.get("type")
|
||
defaults = dict(NODE_DEFINITIONS.get(ntype, {}).get("default_config") or {})
|
||
defaults.update(node.get("config") or {})
|
||
return defaults
|
||
|
||
def _collect_inputs(self, run: dict, node: dict) -> dict:
|
||
"""收集上游节点产物"""
|
||
parents = [e["from"] for e in (run.get("edges") or []) if e["to"] == node["id"]]
|
||
return {pid: run["context"].get(pid) or {} for pid in parents}
|
||
|
||
def _run_node(self, node: dict, ctx: dict) -> dict:
|
||
"""执行单个节点 → {"status": success|skipped|failed|paused, "output": dict, "error": str}"""
|
||
ntype = node.get("type")
|
||
cfg = ctx["config"]
|
||
|
||
# ① 输入/参数类:透传(把文本/配置传给下游)
|
||
if ntype in PASSTHROUGH_NODES:
|
||
out = {"passthrough": True, "config": cfg}
|
||
text = (cfg.get("content") or cfg.get("story_text") or _pick_text(ctx["inputs"]) or "").strip()
|
||
if text:
|
||
out["text"] = text
|
||
if ntype == "character" and cfg.get("registry_name"):
|
||
out["character"] = cfg["registry_name"]
|
||
return {"status": "success", "output": out}
|
||
|
||
# ② 审计门:暂停等人确认(auto_approve 时直接通过)
|
||
# ⚠️ 通过后必须**透传上游产物**,否则数据流断在审核门(下游拿不到文本/分镜)
|
||
if ntype == "audit_gate":
|
||
if cfg.get("auto_approve"):
|
||
out = {"approved": True, "auto": True}
|
||
for v in (ctx["inputs"] or {}).values():
|
||
if isinstance(v, dict):
|
||
for k, vv in v.items():
|
||
out.setdefault(k, vv)
|
||
return {"status": "success", "output": out}
|
||
return {"status": "paused",
|
||
"output": {"message": cfg.get("message") or "请确认后继续",
|
||
"timeout_minutes": cfg.get("timeout_minutes")}}
|
||
|
||
# ③ 能力节点
|
||
cap_id = NODE_TO_CAPABILITY.get(ntype)
|
||
if not cap_id:
|
||
return {"status": "skipped", "error": f"节点类型 {ntype} 未绑定能力"}
|
||
impl = CAP_IMPLS.get(cap_id)
|
||
if not impl:
|
||
return {"status": "skipped",
|
||
"error": f"能力「{cap_id}」的执行实现尚未接入(可在「🧪 能力试跑」确认配置)"}
|
||
try:
|
||
return {"status": "success", "output": impl(node, cfg, ctx["inputs"], ctx)}
|
||
except Exception as e:
|
||
return {"status": "failed", "error": str(e)[:300]}
|
||
|
||
@staticmethod
|
||
def _kahn_sort(nodes: list, edges: list) -> list:
|
||
"""Kahn 拓扑排序"""
|
||
node_map = {n["id"]: n for n in nodes}
|
||
in_deg = {n["id"]: 0 for n in nodes}
|
||
adj = {n["id"]: [] for n in nodes}
|
||
for e in edges:
|
||
f, t = e["from"], e["to"]
|
||
if f in node_map and t in node_map:
|
||
adj[f].append(t)
|
||
in_deg[t] = in_deg.get(t, 0) + 1
|
||
queue = [nid for nid, d in in_deg.items() if d == 0]
|
||
result = []
|
||
while queue:
|
||
nid = queue.pop(0)
|
||
if nid in node_map:
|
||
result.append(node_map[nid])
|
||
for neigh in adj.get(nid, []):
|
||
in_deg[neigh] -= 1
|
||
if in_deg[neigh] == 0:
|
||
queue.append(neigh)
|
||
return result
|
||
|
||
|
||
_singleton = None
|
||
|
||
|
||
def get_runner() -> WorkflowRunner:
|
||
"""获取 WorkflowRunner 单例"""
|
||
global _singleton
|
||
if _singleton is None:
|
||
_singleton = WorkflowRunner()
|
||
return _singleton
|
||
|
||
|
||
def get_node_categories() -> dict:
|
||
"""获取按分类组织的节点列表(前端 SPA 所需格式)"""
|
||
cat_map = [
|
||
("input", "输入"),
|
||
("core", "核心处理"),
|
||
("audio", "音频"),
|
||
("generate", "生成"),
|
||
("material", "素材"),
|
||
("visual", "视觉"),
|
||
("process", "流程处理"),
|
||
("flow", "流程控制"),
|
||
("output", "输出"),
|
||
]
|
||
_defs = {}
|
||
categories = []
|
||
for cat_key, cat_label in cat_map:
|
||
nodes_in_cat = [
|
||
nid for nid, ndef in NODE_DEFINITIONS.items()
|
||
if ndef.get("category") == cat_key
|
||
]
|
||
if nodes_in_cat:
|
||
for nid in nodes_in_cat:
|
||
_defs[nid] = NODE_DEFINITIONS[nid]
|
||
categories.append({
|
||
"key": cat_key,
|
||
"label": cat_label,
|
||
"nodes": nodes_in_cat,
|
||
})
|
||
return {
|
||
"categories": categories,
|
||
"_defs": _defs,
|
||
"total": len(NODE_DEFINITIONS),
|
||
}
|