Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/workflows.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1377 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工作流引擎 — 可视化 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),
}