""" 工作流引擎 — 可视化 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), }