"""routes/ave_project.py — 导演工作室 · 统一项目对象(单一真相源) 问题:原先进程内各页面靠 localStorage 传参 (ave_creative_handoff / ave_dir_cast_v1 / ave_dir_suggest_v1 / ave_char_pending / ave_dir_cast_bind → 5 个 key 散在 4 个文件) → 清缓存就丢、各写各的、无版本、不可追溯 方案:一个项目对象贯穿全流程,存服务端 JSON(单一真相源) 所有阶段(创意/设定/剧本/分镜/关键帧/视频/后期)读写同一个对象 存储:{AGENT_LOCAL}/runtime/ave/projects/{pid}/project.json 参考业界: - Liang80mrer/AI-Animation-Studio:Modular Skill + Cross-Skill Inheritance (每阶段结构化输出、下游继承上游约束) - yfge/ai-video-studio:Timeline 为 SSOT,「把 AI 产出变成可追溯的生产资产」 """ import json import logging import shutil from datetime import datetime from pathlib import Path from fastapi import APIRouter, HTTPException log = logging.getLogger("dashboard.ave_project") router = APIRouter(prefix="/api/ave/project", tags=["ave-project"]) # ── 路径 ──────────────────────────────────────────────────── # ⚠️ 不要用 parents[N] 硬数层级(本项目已因此踩坑两次)—— # 用 _AGENT_SYNC 反推:agent-local 与 agent-sync 是**兄弟**目录 import os _AGENT_SYNC = Path(os.environ.get( "AGENT_SYNC", str(Path(__file__).resolve().parents[3]))) _AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(_AGENT_SYNC.parent / "agent-local"))) PROJ_ROOT = _AGENT_LOCAL / "runtime" / "ave" / "projects" _SB_DIR = _AGENT_LOCAL / "runtime" / "ave" / "storyboards" # 分镜表目录(独立于 project.json) PROJ_ROOT.mkdir(parents=True, exist_ok=True) # 流程阶段(顺序即界面标签顺序) STAGES = [ {"id": "creative", "label": "💡 创意", "desc": "参数选型 → 候选创意 → 选中"}, {"id": "setting", "label": "🌍 设定", "desc": "世界观 / 人物关系 / 基调"}, {"id": "creative_card", "label": "📋 创意卡", "desc": "创意 + 设定 → 想清楚的故事卡(剧本的依据)"}, {"id": "script", "label": "📝 剧本", "desc": "按段落写:剧情/动作/表情/情绪/画面重点"}, {"id": "shots", "label": "🎬 分镜", "desc": "导演风格 + 演员表 → 分镜表"}, {"id": "kf_plan", "label": "🖼 关键帧设计", "desc": "把镜头拆成 2-5 张连续关键帧(每张单独提示词)"}, {"id": "keyframes", "label": "🖼 关键帧", "desc": "按设计逐张生成/审图/采用"}, {"id": "videos", "label": "🎞 视频", "desc": "关键帧 → 视频片段"}, {"id": "post", "label": "🎙 后期", "desc": "去AI化 / 字幕 / 混音 / 成片"}, ] STAGE_IDS = [s["id"] for s in STAGES] # ★ 脚本模式(实读 AI-Animation-Studio 04/05 的权威分层) # 04 剧情脚本阶段必须先判断「这个脚本该用哪种模式驱动镜头」—— # 它与 shooting_style(怎么拍)是两件事,这一层决定「怎么拆镜头」 SCRIPT_MODES = [ {"id": "action", "label": "A 动作预演型", "driver": "动作系统", "formula": "形态 A → 形态 B → 形态 C → 最终大形态(每格必须升级)", "focus": "角色在做什么 / 动作系统变成什么 / 空间如何被改变 / 这格比上格强在哪", "avoid": "不要强行动作系统化、不要每格都大动作", "platforms": ["抖音", "B站"]}, {"id": "emotion", "label": "B 情绪表演型", "driver": "情绪变化", "formula": "平静 → 微小破绽 → 情绪被触发 → 表情管理失败 → 情绪落点", "focus": "眼神怎么变 / 呼吸怎么变 / 嘴角怎么变 / 身体距离 / 停顿在哪 / 何时破出来", "avoid": "不要硬塞复杂转场、不要每格都大动作", "platforms": ["小红书", "视频号"]}, {"id": "dialogue", "label": "C 对话 · 口播型", "driver": "信息节奏", "formula": "抛信息 → 反应 → 信息升级 → 情绪变化 → 结论/梗/钩子", "focus": "谁在说 / 谁在听 / 谁在反应 / 信息重点 / 镜头为什么切", "avoid": "不要所有镜头都两人站着讲话、不要像播新闻", "platforms": ["视频号"]}, {"id": "product", "label": "D 产品广告型", "driver": "痛点 → 机制 → 结果", "formula": "痛点 → 问题升级 → 解决方案出现 → 机制展示 → 结果变化 → 记忆点", "focus": "问题是什么 / 如何被放大 / 方案如何出现 / 机制如何可视化 / 结果如何对比", "avoid": "不要只拍产品很漂亮,必须拍『问题如何被解决』", "platforms": ["抖音", "小红书"]}, {"id": "science", "label": "E 科普解释型", "driver": "抽象概念可视化", "formula": "提出问题 → 原因可视化 → 过程展示 → 冲突或变化 → 结论", "focus": "概念是什么 / 如何变成角色或空间 / 因果关系 / 观众看懂了什么", "avoid": "不要像 PPT、不要像课堂、不要全是文字", "platforms": ["视频号", "B站"]}, {"id": "world", "label": "F 世界观展示型", "driver": "世界规则", "formula": "建立世界 → 进入空间 → 角色日常 → 世界规则 → 潜在冲突 → 系列钩子", "focus": "世界如何运作 / 角色如何生活 / 空间规则 / 有什么可拍 / 潜在冲突在哪", "avoid": "不要全是风景、不要没有角色行为", "platforms": ["B站", "小红书"]}, {"id": "intro", "label": "G 角色出场型", "driver": "角色第一印象", "formula": "环境暗示 → 局部出现 → 完整登场 → 招牌动作 → 性格反差 → 定格记忆点", "focus": "第一眼记住什么 / 如何进入画面 / 招牌动作 / 性格反差 / 哪个表情成为记忆点", "avoid": "不要角色像摆拍", "platforms": ["抖音"]}, {"id": "cover", "label": "H 封面转视频型", "driver": "静态画面如何变动态", "formula": "静态封面 → 局部微动 → 主体动作 → 空间展开 → 冲突/情绪 → 回到记忆点", "focus": "哪个元素先动 / 镜头从哪进入 / 角色如何活起来 / 空间如何展开", "avoid": "不要没有记忆点回收", "platforms": ["小红书"]}, ] # 平台 → 推荐模式(实读 05 §16) PLATFORM_HINTS = { "抖音": ["action", "product", "intro"], "小红书": ["emotion", "cover", "world"], "视频号": ["dialogue", "emotion", "science"], "B站": ["world", "action", "science"], } # 15 秒剧情结构(实读 04 §07) STRUCTURE_15S = [ {"seg": "0-3s", "name": "Hook", "desc": "抓住注意力"}, {"seg": "3-7s", "name": "冲突明确", "desc": "问题摆出来"}, {"seg": "7-12s", "name": "行动升级", "desc": "怎么应对"}, {"seg": "12-15s", "name": "结果 / 反转 / 记忆点", "desc": "落点"}, ] def _proj_dir(pid: str) -> Path: d = PROJ_ROOT / str(pid) d.mkdir(parents=True, exist_ok=True) return d def _proj_file(pid: str) -> Path: return _proj_dir(pid) / "project.json" def _read(pid: str) -> dict | None: f = _proj_file(pid) if not f.exists(): return None try: return json.loads(f.read_text(encoding="utf-8")) except Exception as e: log.warning("读取项目 %s 失败: %s", pid, e) return None def _write(pid: str, obj: dict, backup: bool = True) -> None: f = _proj_file(pid) if backup and f.exists(): try: shutil.copy2(f, f.with_suffix(".json.bak")) except Exception: pass obj["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") def _new(pid: str, title: str = "") -> dict: return { "project_id": pid, "title": title or pid, "stage": "creative", "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "updated_at": "", "creative": {}, # 创意(工坊产出:选中项 / 缺口分 / 注入模式) "setting": {}, # 设定(世界观 / 人物关系 / 基调 / 禁忌) "script": _new_script(), # ★ 剧本(按段落 + 脚本模式,见 _new_script) "storyboard_paper": _new_storyboard_paper(), # ★ 故事板(文字版镜头规划) "storyboard": {}, # 分镜表(引用 storyboards/{pid}.json + 摘要) "keyframes": {}, # 关键帧(每镜版本与采用状态摘要) "videos": {}, # 视频(每镜版本与采用状态摘要) "post": {}, # 后期(去AI化/字幕/混音/成片配置) "cast": [], # 演员表(slot / asset_id / name / note / voice_id) "assets": {}, # 本项目引用的资产(角色/场景/道具/音色…) "history": [], # 变更记录(谁在什么时候改了什么) } def _new_script() -> dict: """剧本结构(实读 AI-Animation-Studio 04 的「按段落」组织) §11 原文:脚本必须按【段落】而不是最终分镜来写。 每段必须:剧情 / 动作 / 表情 / 情绪 / 画面重点 """ return { "mode": "", # ★ 脚本模式(8 选一)—— 决定分镜怎么拆 "driver": "", # 脚本驱动力 "meta": { # 上游带入 + 人工补全 "logline": "", # 一句话故事 "conflict": "", # 核心冲突 "protagonist": "", "antagonist": "", "platform": "", # 抖音 / 小红书 / 视频号 / B站 "duration": 15, # 秒 "visual_style": "", "taboo": "", # 禁忌项 }, "value_check": {"verdict": "", "reason": "", "risk": "", "fix": ""}, "structure": {"hook": "", "conflict": "", "action": "", "escalate": "", "result": "", "memo": ""}, "emotion_route": {"start": "", "middle": "", "peak": "", "end": ""}, "paragraphs": [], # [{no, plot, action, expression, emotion, focus}] "handoff": {}, # ★ 给下一阶段的输入(显式交接) } def _new_film_settings() -> dict: """全片级设定 —— 原「故事板」里那三块**唯一不重复**的内容 为什么并入分镜(而不是独立阶段): 故事板的「逐格镜头」与分镜的 Shot 是同一层(都描述单个镜头),重复; 但它的 space / character_rules / camera_language 是**全片级**的, 分镜里没有 —— 这三块不该丢,所以溶进分镜标签顶部。 """ return { "mode": "", # 脚本模式 "driver": "", # 核心驱动力 "avoid_modes": "", # 不适合的模式 "space": { # §10 空间连续性(统一可调度的空间) "type": "", "function": "", "elements": "", "limits": ""}, "character_rules": { # §11 角色如何表演(不要每镜重新摆拍) "ref_usage": "", "consistency": "", "action_language": "", "emotion_track": "", "boundaries": ""}, "camera_language": { # §12 整体镜头语言 "tone": "", "preference": "", "composition": "", "avoid": ""}, } def _new_storyboard_paper() -> dict: """(保留向后兼容)原故事板结构 —— 现已被 film_settings + 分镜取代""" fs = _new_film_settings() return {**fs, "reason": "", "shots": [], "kf_extend": {"needed": "", "shots": "", "reason": ""}, "handoff": {}} def _merge(base: dict, patch: dict, depth: int = 0) -> dict: """深度合并(dict 递归合并,list/标量直接替换)""" for k, v in (patch or {}).items(): if isinstance(v, dict) and isinstance(base.get(k), dict) and depth < 4: _merge(base[k], v, depth + 1) else: base[k] = v return base def _history(obj: dict, action: str, detail: str = "") -> None: h = obj.setdefault("history", []) h.append({"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "action": action, "detail": detail}) del h[:-200] # 只留最近 200 条 # ── 接口 ──────────────────────────────────────────────────── @router.get("/stages") def api_stages(): """流程阶段定义(界面标签用)""" return {"status": "ok", "stages": STAGES} @router.get("/script-modes") def api_script_modes(): """★ 脚本模式定义(8 种)+ 平台推荐 + 15 秒结构 实读 AI-Animation-Studio 04/05:04 剧情脚本阶段必须先判断 「这个脚本该用哪种模式驱动镜头」—— 它决定分镜怎么拆。 """ return {"status": "ok", "modes": SCRIPT_MODES, "platform_hints": PLATFORM_HINTS, "structure_15s": STRUCTURE_15S} @router.get("/handoff-schema") def api_handoff_schema(): """各阶段「给下一阶段的输入」清单(显式交接,业界硬规矩) 业界规定:素材不存在必须写「无,不使用」,不要编造素材。 """ return {"status": "ok", "schema": { "script": { "label": "📝 剧本 → 🎬 故事板", "fields": ["故事标题", "推荐脚本模式", "建议故事板模式", "核心驱动力", "角色", "世界观", "剧情段落", "情绪路线", "重点镜头", "平台", "视频时长", "视觉风格", "禁忌项"], }, "storyboard": { "label": "🎬 故事板 → 🖼 关键帧 / 🎞 视频", "fields": ["故事板模式", "核心驱动力", "目标平台", "目标画幅", "目标视频时长", "视觉风格", "角色参考图", "场景参考图", "镜头数量", "镜头顺序", "每镜头时长", "每镜头画面内容", "每镜头动作", "每镜头运镜", "每镜头情绪", "转场接口", "是否需要关键帧扩展", "需要扩展的镜头", "扩展原因", "禁忌项"], }, "shots": { "label": "🎬 分镜 → 🖼 关键帧", "fields": ["镜头功能", "转场接口", "重点"], }, "keyframes": { "label": "🖼 关键帧 → 🎞 视频", "fields": ["已采用的关键帧", "每镜参考图", "画面描述", "景别机位"], }, "videos": { "label": "🎞 视频 → 🎙 后期", "fields": ["已采用的视频片段", "声音路径", "台词", "时长", "衔接方式"], }, }} @router.get("/list") def api_project_list(limit: int = 50): """项目列表(按更新时间倒序)""" out = [] if PROJ_ROOT.is_dir(): for d in PROJ_ROOT.iterdir(): if not d.is_dir(): continue o = _read(d.name) if not o: continue out.append({ "project_id": o.get("project_id") or d.name, "title": o.get("title") or d.name, "stage": o.get("stage") or "creative", "updated_at": o.get("updated_at") or o.get("created_at") or "", }) out.sort(key=lambda x: x["updated_at"], reverse=True) return {"status": "ok", "projects": out[:limit]} def _enrich(pid: str, o: dict) -> dict: """读时「富化」:把散落在独立文件/目录里的数据合并进项目对象(**只读,不落盘**) 为什么需要:分镜表存在 runtime/ave/storyboards/{pid}.json、 关键帧在 keyframes/{pid}/、视频在 keyframe_videos/{pid}/ —— 都不在 project.json 里。前端「上游带入」直接把 project 对象当数据源, 结果分镜/关键帧阶段永远显示「还没有上游内容」,即使实际有数据。 ⚠️ 只在这里合并,不写回文件(避免两份真相源)。 """ # ① 分镜表(用户手写进 project 的优先,其次读独立文件) if not o.get("storyboard"): sb = None for cand in (_SB_DIR / f"{pid}.json", _SB_DIR / f"sb_{pid}.json"): if cand.exists(): try: sb = json.loads(cand.read_text(encoding="utf-8")) break except Exception: pass if sb is None and _SB_DIR.is_dir(): for f in _SB_DIR.glob("*.json"): try: d = json.loads(f.read_text(encoding="utf-8")) if str(d.get("project_id")) == str(pid): sb = d break except Exception: continue if sb: o["storyboard"] = sb # ② 关键帧 / 视频 / 成片 的统计(目录扫描) rt = _AGENT_LOCAL / "runtime" / "ave" stats = {"keyframe_shots": 0, "keyframes": 0, "video_shots": 0, "videos": 0, "adopted_videos": 0, "exports": 0} kf = rt / "keyframes" / str(pid) if kf.is_dir(): for d in kf.iterdir(): if d.is_dir(): n = len(list(d.glob("v*.png"))) if n: stats["keyframe_shots"] += 1 stats["keyframes"] += n kv = rt / "keyframe_videos" / str(pid) if kv.is_dir(): for d in kv.iterdir(): if not d.is_dir(): continue js = list(d.glob("v*.json")) if js: stats["video_shots"] += 1 stats["videos"] += len(js) for j in js: try: if json.loads(j.read_text(encoding="utf-8")).get("adopted"): stats["adopted_videos"] += 1 except Exception: pass ex = rt / "projects" / str(pid) / "export" if ex.is_dir(): stats["exports"] = len(list(ex.glob("*.mp4"))) o["_stats"] = stats return o @router.get("/{pid}") def api_project_get(pid: str): """读整个项目对象(不存在则返回空白骨架,不落盘)""" o = _read(pid) if not o: o = _new(pid) o["_new"] = True # 前端据此显示「尚未开始」 _enrich(pid, o) # ★ 合并散落在文件里的数据(分镜/关键帧/视频统计) return {"status": "ok", "project": o, "stages": STAGES} @router.patch("/{pid}") def api_project_patch(pid: str, data: dict): """局部更新(深度合并)—— 各阶段把自己的产出写进来 body 示例: {"stage": "keyframes"} 切阶段 {"creative": {"selected": [...]}} 写入创意 {"setting": {"world": "...", "tone": "..."}} 写入设定 {"cast": [...]} 覆盖演员表 """ o = _read(pid) or _new(pid) # stage 必须合法 if "stage" in data and data["stage"] not in STAGE_IDS: raise HTTPException(400, detail=f"未知阶段:{data['stage']}(可选 {STAGE_IDS})") touched = [k for k in data.keys() if k not in ("stage",)] _merge(o, data) _history(o, "patch", f"更新:{', '.join(touched) if touched else 'stage'}" + (f" → {data['stage']}" if data.get("stage") else "")) _write(pid, o) return {"status": "ok", "project": o} # ══════════════════════════════════════════════════════════ # AI 自动生成(创意+设定 → 剧本;剧本 → 故事板) # 依据:AI-Animation-Studio 04 剧情脚本 / 05 故事板 Skill # ⚠️ 调用 LLM 会产生费用,需 confirm_cost # ══════════════════════════════════════════════════════════ AVE_SCRIPTS_P = Path(os.environ.get( "AGENT_SYNC", str(Path(__file__).resolve().parents[3]))) / "05_tools" / "09_ave" / "scripts" def _llm(): import sys as _s _s.path.insert(0, str(AVE_SCRIPTS_P)) from directors.llm import DirectorLLM return DirectorLLM(timeout=180) def _mode_by_label(label: str) -> dict: for m in SCRIPT_MODES: if m["label"] == label or m["id"] == label: return m return {} _SCRIPT_SYS = """你是短视频导演前期编剧 + 动画动作设计师。 你的任务:把「故事创意 + 世界观设定」转化成适合 AI 短片制作的剧情脚本。 【铁律】脚本按【段落】写,不按最终分镜写。每段必须写清五项: 剧情 / 动作 / 表情 / 情绪 / 画面重点。 【必须先判断脚本模式】8 选一,它决定后面镜头怎么拆: A动作预演型(核心=动作系统) B情绪表演型(核心=情绪变化) C对话口播型(核心=信息节奏) D产品广告型(核心=痛点→机制→结果) E科普解释型(核心=概念可视化) F世界观展示型(核心=世界规则) G角色出场型(核心=角色第一印象) H封面转视频型(核心=静变动态) 【各模式核心公式】 B 情绪表演型:平静 → 微小破绽 → 情绪被触发 → 表情管理失败 → 情绪落点 C 对话口播型:抛信息 → 反应 → 信息升级 → 情绪变化 → 结论/梗/钩子 D 产品广告型:痛点 → 问题升级 → 解决方案出现 → 机制展示 → 结果变化 → 记忆点 【对白】短、自然、推动剧情、符合角色性格。禁止解释世界观、大段说教、广播稿。 【动作】每个动作必须有原因、有结果。禁止只有摆 pose。 【情绪】必须有:起点 → 变化 → 高潮 → 落点。 【15 秒结构】0-3s Hook | 3-7s 冲突明确 | 7-12s 行动升级 | 12-15s 结果/反转/记忆点 只输出 JSON,不要任何解释文字。""" _STORYBOARD_SYS = """你是电影故事板师(预演板),不是插画师。 你的任务:把剧情脚本拆成【逐格镜头】——目标是「镜头规划」,不是「画漂亮」。 【逐格八要素】每格必须写: 编号 / 镜头名 / 镜头功能 / 相机 / 画面内容 / 行动 / 转场接口 / 重点 - 镜头功能:这个镜头为什么存在(建立空间?推进情绪?给反应?) - 转场接口:和下一镜怎么接(直接切 / 视线引导 / 遮挡) - 重点:观众此刻应该看到什么 【空间连续性铁律】所有镜头必须发生在「统一、可连续调度的空间」,不是 N 张互不相关的漂亮图。 【角色连续性铁律】不是重新设计角色,而是规定角色如何表演 —— 角色要像同一个演员连续表演,不要每格都像重新摆拍。 【关键帧扩展判断】不是所有镜头都要扩展。建议扩展的情况: 动作复杂 / 变形复杂 / 情绪变化细腻 / 镜头跨度大 / 一个镜头含多个阶段。 只输出 JSON,不要任何解释文字。""" # ══════════════════════════════════════════════════════════ # 💡 创意卡扩写(依据 AI-Animation-Studio 01 故事创意 Skill) # # 为什么需要:创意工坊产出的是「参数 + 一句话候选」, # 而业界 01 Skill 要求输出一张想清楚的卡: # 标题 / 概念 / 核心冲突(必答4问) / 传播钩子 / 内容模式(8选1) / # 核心驱动力 / 角色方向 / 世界观方向 / 价值判断 / 交接清单 # —— 只有这张卡足够厚,剧本生成才不是"自由发挥"。 # ══════════════════════════════════════════════════════════ _CREATIVE_MODES = [ ("action", "动作预演型", "动作系统、空间变化、物理冲突"), ("emotion", "情绪表演型", "情绪变化、眼神、停顿、关系距离"), ("dialogue", "对话口播型", "信息节奏、反应、观点冲突"), ("product", "产品广告型", "痛点、机制、结果"), ("science", "科普解释型", "因果关系、抽象概念可视化"), ("world", "世界观展示型", "世界规则、空间秩序、角色日常"), ("intro", "角色出场型", "角色第一印象、招牌动作、记忆点"), ("cover", "封面转视频型", "静态画面动态化、视觉展开"), ] _CREATIVE_SYS = """你是「故事创意导演」——AI 短片工作流的起点,不是随机脑洞。 你的任务:把一个朴素主题/一句话联想,变成**值得进入生产链**的创意卡。 你**不**写详细剧本、不写分镜、不写提示词。 【必须先做价值判断】逐项检查并给出结论: 是否有冲突 / 动作 / 情绪 / 视觉化空间 / 角色 / 记忆点? 是否适合短视频 / 系列化 / 目标平台? 输出:值得展开 | 需要改造 | 暂不建议,并给理由、最大问题、改造方向。 【必须提前判断内容模式(8 选 1)】不要默认都是动作片: 动作预演型 / 情绪表演型 / 对话口播型 / 产品广告型 / 科普解释型 / 世界观展示型 / 角色出场型 / 封面转视频型 并写出该模式的**核心驱动力**(驱动画面的核心动力)。 【核心冲突必须回答 4 问】谁想做什么?谁阻止了他?为什么会出问题?观众为什么想继续看? 冲突类型:角色vs角色 / vs世界 / vs自己 / vs机制 / vs时间 / vs产品问题 【传播钩子】前 3 秒必须有:异常 / 冲突 / 情绪 / 视觉奇观 / 信息差 / 动作升级 / 问题提出。 禁止「慢铺垫、讲半天背景」。 【故事标题规则】短 / 清楚 / 有画面 / 有冲突 / 有传播点。 禁止:纯抽象、太长、像论文、像普通产品名。 【角色方向】主角方向 / 对手方向 / 关系方向 / 情绪方向 / 动作方向(创意阶段不细做角色)。 【世界观方向】故事发生在什么空间?空间规则是什么?**空间能不能持续拍**? 禁止只有一句设定没有空间逻辑。 只输出 JSON,不要任何解释文字。""" @router.get("/{pid}/creative/modes") def api_creative_modes(pid: str = ""): """8 种内容模式(供前端下拉/展示)""" return {"status": "ok", "modes": [{"id": i, "label": l, "driver": d} for i, l, d in _CREATIVE_MODES]} @router.post("/{pid}/creative/expand") def api_creative_expand(pid: str, data: dict): """把「选中的创意」扩写为完整创意卡 —— ⚠️ 调 LLM 有费用,需 confirm_cost body: { confirm_cost, extra?(额外要求) } 结果存进 project.creative.card """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 扩写创意卡会调用大模型(有费用),需显式确认") o = _read(pid) or _new(pid) cr = o.get("creative") or {} st = o.get("setting") or {} sel = cr.get("selected") or [] if not sel: raise HTTPException(400, detail="还没有选中的创意 —— 请先在创意工坊里勾选,或从创意库带入") lines = [] for i, s in enumerate(sel[:5], 1): one = (s.get("one_line") or s.get("title") or "") if isinstance(s, dict) else str(s) gap = (s.get("gap_score") if isinstance(s, dict) else None) lines.append(f"{i}. {one}" + (f"(缺口分 {gap})" if gap else "")) prompt = f"""【候选创意(已选中)】 {chr(10).join(lines)} 【已有世界观设定】 {json.dumps(st, ensure_ascii=False, indent=1) if st else "(无)"} 【额外要求】 {data.get('extra') or '(无)'} 【输出 JSON 结构】 {{ "verdict": {{"conclusion":"值得展开|需要改造|暂不建议","reason":"","max_problem":"","fix":""}}, "title": "故事标题", "concept": "一句话概念", "core_conflict": {{"who":"谁想做什么","blocker":"谁阻止了他","why_problem":"为什么会出问题","why_watch":"观众为什么继续看","type":"角色vs角色|角色vs世界|角色vs自己|角色vs机制|角色vs时间|角色vs产品问题"}}, "hook": "前 3 秒的钩子(具体写出来,不要写"要抓人"这种空话)", "content_mode": "从 8 种里选一个(动作预演型/情绪表演型/对话口播型/产品广告型/科普解释型/世界观展示型/角色出场型/封面转视频型)", "mode_reason": "为什么选这个模式", "core_driver": "核心驱动力(驱动画面的核心动力)", "emotion_arc": "情绪方向(从…到…)", "action_arc": "动作方向(从…到…)", "characters": {{"protagonist":"主角方向","antagonist":"对手方向","relationship":"关系方向"}}, "world": {{"space":"发生在什么空间","rule":"空间规则","can_sustain":"这个空间能不能持续拍,为什么"}}, "series_potential": "系列化潜力", "mood": "情绪基调(几个词)", "handoff": {{"title":"","core_conflict":"","content_mode":"","core_driver":"","platform":"","duration":"","visual_style":"","taboo":""}} }}""" txt = _llm().chat(prompt, system=_CREATIVE_SYS, temperature=0.85, max_tokens=3000) if not txt: raise HTTPException(502, detail="LLM 调用失败(检查 ⚙️ 设置 里的 LLM key)") from directors.llm import DirectorLLM card = DirectorLLM.parse_json(txt) if not isinstance(card, dict): raise HTTPException(502, detail=f"LLM 输出无法解析(前 160 字:{txt[:160]})") cr["card"] = card cr["card_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") o["creative"] = cr _history(o, "creative-card", f"扩写创意卡:{card.get('title') or ''}") _write(pid, o) return {"status": "ok", "card": card, "note": "创意卡已生成;可手动编辑后去「📝 剧本」生成"} @router.post("/{pid}/generate/script") def api_generate_script(pid: str, data: dict): """创意 + 设定 → 剧本(含段落)。⚠️ 调 LLM 有费用,需 confirm_cost。 body: { confirm_cost, mode?(指定脚本模式 id/label,留空则让 AI 判断), platform?, duration?, extra?(额外要求) } """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成剧本会调用大模型(有费用),需显式确认(confirm_cost=true)") o = _read(pid) or _new(pid) cr, st = o.get("creative") or {}, o.get("setting") or {} cast = o.get("cast") or [] force_mode = _mode_by_label(str(data.get("mode") or "")) if not (cr or st): raise HTTPException(400, detail="上游为空:请先在「💡 创意」或「🌍 设定」标签填写内容") # ★ 创意卡(业界 01 Skill 的产出)优先于零散素材 —— 它是"想清楚的故事" _card = cr.get("card") if isinstance(cr, dict) else None card_txt = json.dumps(_card, ensure_ascii=False, indent=1) if _card else "(未生成创意卡)" _misc = {k: v for k, v in (cr or {}).items() if k not in ("card", "card_at")} misc_txt = json.dumps(_misc, ensure_ascii=False, indent=1) if _misc else "(无)" prompt = f"""【故事创意卡】★ 这是本次创作的**最高依据**:标题/概念/核心冲突/传播钩子/ 内容模式/核心驱动力/角色方向/世界观方向 —— 剧本必须围绕它展开 {card_txt} 【创意原始素材】(参数选型与候选;仅作补充参考) {misc_txt} 【世界观设定】 {json.dumps(st, ensure_ascii=False, indent=1) if st else "(无)"} 【已有角色】 {json.dumps(cast, ensure_ascii=False) if cast else "(无)"} 【硬性要求】 - 平台:{data.get('platform') or st.get('platform') or '抖音'} - 目标时长:{data.get('duration') or 15} 秒 - 脚本模式:{('必须使用「' + force_mode['label'] + '」') if force_mode else ('优先采用创意卡推荐的内容模式「' + str((_card or {}).get('content_mode') or '') + '」;若明显不合适再改并说明理由' if (_card or {}).get('content_mode') else '由你判断最合适的,并说明理由')} - 传播钩子:{('前 3 秒必须落实创意卡的钩子:' + str((_card or {}).get('hook') or '')) if (_card or {}).get('hook') else '前 3 秒必须有钩子(异常/冲突/情绪/视觉奇观/信息差)'} - 核心冲突:{('必须体现:' + str(((_card or {}).get('core_conflict') or {}).get('who') or '') + ' / 受阻于 ' + str(((_card or {}).get('core_conflict') or {}).get('blocker') or '')) if (_card or {}).get('core_conflict') else '必须由冲突驱动,不要平铺直叙'} - 额外要求:{data.get('extra') or '(无)'} {f'- 禁忌:{st.get("taboo")}' if st.get('taboo') else ''} 【输出 JSON 结构】 {{ "mode_id": "从 action/emotion/dialogue/product/science/world/intro/cover 中选一个", "driver": "脚本驱动力(一句话)", "value_check": {{"verdict":"适合进入脚本/需要压缩/需要重构","reason":"","risk":"","fix":""}}, "meta": {{"logline":"","conflict":"","protagonist":"","antagonist":"","platform":"","duration":15,"visual_style":"","taboo":""}}, "structure": {{"hook":"","conflict":"","action":"","escalate":"","result":"","memo":""}}, "emotion_route": {{"start":"","middle":"","peak":"","end":""}}, "paragraphs": [{{"no":1,"plot":"","action":"","expression":"","emotion":"","focus":""}}] }} 段落数量按 {data.get('duration') or 15} 秒合理分配(一般 3-6 段)。""" txt = _llm().chat(prompt, system=_SCRIPT_SYS, temperature=0.8, max_tokens=4000) if not txt: raise HTTPException(502, detail="LLM 调用失败(检查 ⚙️ 设置 里的 LLM key)") from directors.llm import DirectorLLM parsed = DirectorLLM.parse_json(txt) if not isinstance(parsed, dict): raise HTTPException(502, detail=f"LLM 输出无法解析(前 160 字:{txt[:160]})") sc = o.get("script") or _new_script() sc["mode"] = parsed.get("mode_id") or sc.get("mode") or "" sc["driver"] = parsed.get("driver") or "" for k in ("value_check", "meta", "structure", "emotion_route"): if isinstance(parsed.get(k), dict): sc.setdefault(k, {}).update(parsed[k]) if isinstance(parsed.get("paragraphs"), list): sc["paragraphs"] = [ {"no": i + 1, "plot": p.get("plot", ""), "action": p.get("action", ""), "expression": p.get("expression", ""), "emotion": p.get("emotion", ""), "focus": p.get("focus", "")} for i, p in enumerate(parsed["paragraphs"]) if isinstance(p, dict) ] o["script"] = sc o["stage"] = "script" _history(o, "generate", f"AI 生成剧本(模式 {sc['mode']},{len(sc['paragraphs'])} 段)") _write(pid, o) return {"status": "ok", "script": sc, "paragraphs": len(sc["paragraphs"]), "model": "deepseek(费用已产生)"} @router.post("/{pid}/generate/storyboard") def api_generate_storyboard(pid: str, data: dict): """剧本 → 故事板(逐格镜头)。⚠️ 调 LLM 有费用,需 confirm_cost。""" if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成故事板会调用大模型(有费用),需显式确认(confirm_cost=true)") o = _read(pid) or _new(pid) sc = o.get("script") or {} paras = sc.get("paragraphs") or [] if not paras: raise HTTPException(400, detail="剧本还没有段落:请先在「📝 剧本」标签生成或手写") mode = _mode_by_label(sc.get("mode") or "") or _mode_by_label( (sc.get("handoff") or {}).get("推荐脚本模式") or "") st = o.get("setting") or {} prompt = f"""【剧情脚本】 模式:{mode.get('label') or sc.get('mode') or '(未指定)'} 核心驱动力:{mode.get('driver') or sc.get('driver') or ''} 核心公式:{mode.get('formula') or ''} 每格要回答:{mode.get('focus') or ''} 避免:{mode.get('avoid') or ''} 【段落】 {json.dumps(paras, ensure_ascii=False, indent=1)} 【世界观】 {json.dumps(st, ensure_ascii=False) if st else '(无)'} 【出场角色】 {json.dumps(o.get('cast') or [], ensure_ascii=False)} 【硬性要求】 - 目标平台:{(sc.get('meta') or {}).get('platform') or '抖音'} - 目标总时长:{(sc.get('meta') or {}).get('duration') or 15} 秒 - 视觉风格:{(sc.get('meta') or {}).get('visual_style') or '写实自然'} - 镜头数量:按段落拆,一般 4-8 个镜头 - 每个镜头给出建议时长(秒) 【输出 JSON 结构】 {{ "mode": "{mode.get('label') or ''}", "reason": "为什么用这个模式", "driver": "核心驱动力", "avoid_modes": "不适合的模式及原因", "space": {{"type":"","function":"","elements":"","limits":""}}, "character_rules": {{"action_language":"","emotion_track":"","boundaries":""}}, "camera_language": {{"tone":"","preference":"","composition":"","avoid":""}}, "shots": [{{ "no":1,"name":"","function":"","camera":"","content":"","action":"", "transition":"","focus":"","duration":3 }}], "kf_extend": {{"needed":"需要/部分需要/不需要","shots":"","reason":""}} }}""" txt = _llm().chat(prompt, system=_STORYBOARD_SYS, temperature=0.7, max_tokens=5000) if not txt: raise HTTPException(502, detail="LLM 调用失败(检查 ⚙️ 设置 里的 LLM key)") from directors.llm import DirectorLLM parsed = DirectorLLM.parse_json(txt) if not isinstance(parsed, dict): raise HTTPException(502, detail=f"LLM 输出无法解析(前 160 字:{txt[:160]})") sb = o.get("storyboard_paper") or _new_storyboard_paper() for k in ("mode", "reason", "driver", "avoid_modes"): if parsed.get(k): sb[k] = parsed[k] for k in ("space", "character_rules", "camera_language", "kf_extend"): if isinstance(parsed.get(k), dict): sb.setdefault(k, {}).update(parsed[k]) if isinstance(parsed.get("shots"), list): sb["shots"] = [ {"no": i + 1, "name": s.get("name", ""), "function": s.get("function", ""), "camera": s.get("camera", ""), "content": s.get("content", ""), "action": s.get("action", ""), "transition": s.get("transition", ""), "focus": s.get("focus", ""), "duration": s.get("duration", 0)} for i, s in enumerate(parsed["shots"]) if isinstance(s, dict) ] o["storyboard_paper"] = sb o["stage"] = "storyboard" _history(o, "generate", f"AI 生成故事板({len(sb['shots'])} 镜)") _write(pid, o) return {"status": "ok", "storyboard": sb, "shots": len(sb["shots"]), "model": "deepseek(费用已产生)"} # ══════════════════════════════════════════════════════════ # 🖼 关键帧扩展(Keyframe Expansion) # 依据 AI-Animation-Studio 06_单镜头关键帧扩展 Skill: # §03 故事板解决「镜头顺序」,关键帧扩展解决「镜头内部怎么连续变化」 # —— 很多 AI 视频失败:一个镜头塞了开始/发展/高潮/结束, # 但模型只看见「一张图」 # §04 何时必须扩展(复杂动作/变形/表情变化/空间移动/镜头推进…) # 可不扩展(静态口播/纯封面/简单定格/超短 1 秒) # §05 A=起始 B=动作启动 C=动作进行 D=动作高潮 E=动作结果(每张更推进) # §06 数量:轻 2-3 / 中 3-4 / 复杂 4-6(越多角色漂移风险越高) # §15 每张结构:功能/画面/动作/表情/镜头/与下一帧关系 # §18 必须标注:是否建议作为首帧 / 是否建议作为尾帧 # §13 关键帧图**不要边框、编号、文字、水印**(否则会被视频模型学进去) # ══════════════════════════════════════════════════════════ KF_EXPAND_RULES = { "must": ["复杂动作", "变形", "表情变化", "空间移动", "镜头推进", "打斗", "追逐", "拆解", "产品机制", "粒子变化", "能量变化", "水流变化", "丝带变化", "镜头跨度太大"], "suggest": ["情绪变化", "哭泣", "偷听", "破防", "慢动作", "角色转头", "手部细节"], "skip": ["静态口播", "纯封面镜头", "简单定格", "超短 1 秒镜头"], } _KF_SYS = """你是「单镜头关键帧扩展」导演。 你的任务不是写故事创意,也不是写完整故事板——而是把【一个故事板镜头】 扩展成 2-5 张连续关键帧,让 AI 视频模型能理解「动作是如何连续发生的」。 【为什么要做】很多 AI 视频失败,不是因为故事板差,而是: 一个镜头里塞了 开始/发展/高潮/结束,但视频模型只看见「一张图」。 所以必须拆成 关键帧 A → B → C → D。 【核心原则】关键帧不是重复同一张图,**每一张都必须比上一张更推进一点**。 A=起始状态 B=动作启动 C=动作进行 D=动作高潮 E=动作结果 推进方向:动作 / 情绪 / 镜头 / 空间 / 物理变化 / 光线 / 能量 / 距离 / 视线 【数量规则】轻动作 2-3 张;中等 3-4 张;复杂 4-6 张;极复杂 → 建议拆成多个镜头。 ⚠️ 数量越多,角色漂移风险越高,不要为多而多。 【动作桥接】上一帧动作必须能自然进入下一帧。 ❌ A 角色站着 → B 角色已经飞出去(中间没有启动动作) ✅ A 准备 → B 发力 → C 离地 → D 飞出 【角色连续性】必须像同一个演员:脸型/比例/材质/服装/颜色/发型/气质/年龄感 【镜头连续性】不要 A 中景 → B 无理由超近景;应该 A 中景 → B 更近 → C 特写 【情绪推进】不能只写「更难过」,必须写具体表情:A 克制 → B 眼神躲闪 → C 嘴角控制失败 → D 眼泪掉下 【关键帧图要求】不要边框、编号、文字、水印(否则会被视频模型学进去)。 只输出 JSON,不要任何解释文字。""" @router.get("/{pid}/keyframe-plan") def api_keyframe_plan(pid: str): """读关键帧扩展方案(每个镜头几张、各是什么)""" o = _read(pid) or {} return {"status": "ok", "plan": o.get("keyframe_plan") or {}, "rules": KF_EXPAND_RULES, "note": "按 06 关键帧扩展 Skill 的结构:每张含 功能/画面/动作/表情/镜头/与下一帧关系"} @router.post("/{pid}/keyframe-plan/generate") def api_keyframe_plan_generate(pid: str, data: dict): """为某个(或全部)镜头生成关键帧扩展方案 —— ⚠️ 调 LLM 有费用,需 confirm_cost body: { confirm_cost, shot_id?(留空=全部需要扩展的镜头) } """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成关键帧方案会调用大模型(有费用),需显式确认") o = _read(pid) or _new(pid) sb = o.get("storyboard") or {} # 分镜表在 storyboards/ 目录 sbj = _AGENT_LOCAL / "runtime" / "ave" / "storyboards" shots = [] if sbj.is_dir(): for f in sbj.glob("*.json"): try: d = json.loads(f.read_text(encoding="utf-8")) if str(d.get("project_id")) == str(pid): shots = d.get("shots") or [] break except Exception: continue if not shots: raise HTTPException(400, detail="还没有分镜表:请先去「🎬 分镜」标签生成") only = str(data.get("shot_id") or "") # ★ 支持按段落生成(与「🎬 分镜」的逐段推进一致:做完一段验收一段) para = str(data.get("paragraph_no") or "").strip() todo = [s for s in shots if (not only or str(s.get("shot_id")) == only)] if para: todo = [s for s in todo if str(s.get("paragraph_no") or "") == para] if not todo: raise HTTPException( 404, detail=f"没有符合条件的镜头(shot_id={only or '全部'} paragraph_no={para or '全部'})") log.warning("★关键帧方案生成:shot=%s para=%s → %d 个镜头", only or '全部', para or '全部', len(todo)) old = o.get("keyframe_plan") or {} llm = _llm() made, failed = [], [] for s in todo: sid = str(s.get("shot_id")) try: prompt = _kf_plan_prompt(s, o) txt = llm.chat(prompt, system=_KF_SYS, temperature=0.6, max_tokens=3500) if not txt: failed.append({"shot_id": sid, "why": "LLM 无返回"}) continue from directors.llm import DirectorLLM p = DirectorLLM.parse_json(txt) if not isinstance(p, dict): failed.append({"shot_id": sid, "why": "输出无法解析"}) continue frames = p.get("frames") or [] old[sid] = { "need_expand": p.get("need_expand", ""), "reason": p.get("reason", ""), "count": p.get("count") or len(frames), "type": p.get("type", ""), "frames": [{ "seq": f.get("seq") or chr(65 + i), "role": f.get("role", ""), # first / mid / last "func": f.get("func", ""), "visual": f.get("visual", ""), "action": f.get("action", ""), "expression": f.get("expression", ""), "camera": f.get("camera", ""), "next_rel": f.get("next_rel", ""), } for i, f in enumerate(frames) if isinstance(f, dict)], "consistency": p.get("consistency", ""), "avoid": p.get("avoid", ""), } made.append({"shot_id": sid, "count": len(old[sid]["frames"]), "need_expand": old[sid]["need_expand"]}) except Exception as e: failed.append({"shot_id": sid, "why": f"{type(e).__name__}: {str(e)[:70]}"}) o["keyframe_plan"] = old _history(o, "kf-plan", f"关键帧扩展方案:{len(made)} 镜(失败 {len(failed)})") _write(pid, o) return {"status": "ok", "made": made, "failed": failed, "plan": old, "model": "deepseek(费用已产生)"} def _kf_plan_prompt(shot: dict, o: dict) -> str: sc = shot.get("shot_contract") or {} st = o.get("setting") or {} kf_rules = KF_EXPAND_RULES # ★ 全局设置(「🖼 关键帧设计」页的 ⚙️ 全局设置)—— 作为全片统一约束注入 ks = o.get("kf_settings") or {} _extra = [] if ks.get("consistency"): _extra.append(f"【全片统一性要求(必须遵守)】{ks['consistency']}") if ks.get("avoid"): _extra.append(f"【全片禁止项(必须遵守)】{ks['avoid']}") if ks.get("strength"): _extra.append(f"【扩展强度偏好】{ks['strength']}(这是全片偏好," f"但若本镜内容明显不符合,仍按专业判断)") _extra_txt = ("\n\n".join(_extra) + "\n\n") if _extra else "" return f"""{_extra_txt}【要扩展的故事板镜头】 镜头:{shot.get('shot_id')} 画面描述:{shot.get('visual_description') or ''} 动作:{shot.get('action') or ''} 情绪:{shot.get('sentiment') or ''} 台词:{shot.get('text') or '(无)'} 微表情:{json.dumps(shot.get('expression_detail') or {}, ensure_ascii=False)} 镜头类型:{shot.get('type') or ''} 景别/机位/运镜:{sc.get('shot_size') or ''} / {sc.get('angle') or ''} / {sc.get('camera_move') or ''} 时长:{shot.get('duration') or 5} 秒 【世界观/基调】 世界观:{st.get('world') or '(无)'} 基调:{st.get('tone') or '(无)'} 禁忌:{st.get('taboo') or '(无)'} 【扩展判断规则(必须参考)】 必须扩展:{'、'.join(kf_rules['must'])} 建议扩展:{'、'.join(kf_rules['suggest'])} 可不扩展:{'、'.join(kf_rules['skip'])} 【输出 JSON 结构】 {{ "need_expand": "必须扩展 / 建议扩展 / 可不扩展", "reason": "判断理由", "count": 3, "type": "动作关键帧 / 情绪关键帧 / 镜头关键帧 / 产品机制关键帧 / 空间变化关键帧", "frames": [ {{ "seq": "A", "role": "first | mid | last", "func": "这张的功能(例:起始状态)", "visual": "画面内容(含角色状态、所在位置、环境)", "action": "这一帧的动作", "expression": "这一帧的表情/眼神", "camera": "这一帧的镜头(景别+机位+运动)", "next_rel": "与下一帧的关系(动作如何进入下一帧)" }} ], "consistency": "统一性控制词(角色一致性/动作连续性/镜头连续性)", "avoid": "禁止项(角色漂移/动作跳变/随机场景变化等)" }} 要求: - 若判定「可不扩展」,frames 仍然给 1 张(即首帧),role=first - 若扩展,frames 按 A→B→C… 顺序,**每张必须比上一张更推进** - 首张 role=first,末张 role=last,中间 mid - 画面描述里**不要出现「关键帧A」这类字样**(会被模型学进图)""" # ══════════════════════════════════════════════════════════ # 🎙 后期 · 时间线(SSOT) # 依据 yfge/ai-video-studio:「Timeline is the single source of truth」—— # 改完只需重导出,不用重跑生成。渲染只是「导出」动作。 # ⚠️ 拼接/混流是**本地 ffmpeg,零成本**(不调生成类 API) # ══════════════════════════════════════════════════════════ VID_ROOT_P = _AGENT_LOCAL / "runtime" / "ave" / "keyframe_videos" KF_VID_META = "v*.json" def _adopted_clips(pid: str) -> list[dict]: """收集「已采用」的视频片段(按镜头顺序)""" out = [] base = VID_ROOT_P / str(pid) if not base.is_dir(): return out # 镜头顺序 + 段落归属(都以分镜表为准) order = [] para_of: dict = {} sbj = _AGENT_LOCAL / "runtime" / "ave" / "storyboards" if sbj.is_dir(): for f in sbj.glob("*.json"): try: d = json.loads(f.read_text(encoding="utf-8")) if str(d.get("project_id")) == str(pid): for s in (d.get("shots") or []): sid = str(s.get("shot_id")) order.append(sid) para_of[sid] = s.get("paragraph_no") break except Exception: continue dirs = sorted([d for d in base.iterdir() if d.is_dir()], key=lambda d: (order.index(d.name) if d.name in order else 999, d.name)) for sid_dir in dirs: for jf in sorted(sid_dir.glob(KF_VID_META), reverse=True): try: m = json.loads(jf.read_text(encoding="utf-8")) except Exception: continue if not m.get("adopted"): continue mp4 = jf.with_suffix(".mp4") if not mp4.exists(): # 对口型产物优先 alt = sid_dir / f"v{m.get('version')}_lipsync.mp4" mp4 = alt if alt.exists() else mp4 if not mp4.exists(): continue out.append({"shot_id": sid_dir.name, "version": m.get("version"), "file": str(mp4), "name": mp4.name, "has_voice": bool(m.get("has_voice")), "voice_file": m.get("voice_file") or "", "text": m.get("text") or "", "model": m.get("model") or "", "paragraph_no": para_of.get(sid_dir.name), # ★ 段落归属 "duration": 0.0}) break # 每镜只取「已采用」的那一版 return out @router.get("/{pid}/post/timeline") def api_post_timeline(pid: str): """时间线(SSOT):已采用的片段 + 时长 + 后期设置""" o = _read(pid) or _new(pid) post = o.get("post") or {} clips = _adopted_clips(pid) # 读每段真实时长(ffmpeg -i 解析,零成本) import subprocess as _sp import re as _re try: import sys as _s _s.path.insert(0, str(AVE_SCRIPTS_P)) from lib.ffmpeg import FFMPEG except Exception: FFMPEG = "ffmpeg" for c in clips: try: r = _sp.run([FFMPEG, "-i", c["file"]], capture_output=True, text=True) m = _re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", r.stderr) if m: c["duration"] = round(int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3)), 2) except Exception: pass total = round(sum(c["duration"] for c in clips), 2) # ★ 按「段落」分组 —— 三层结构:片段 → 段落 → 成片 # 段落 = 剧本的一段戏(一场戏),逐段合成、逐段验收 exp_dir = PROJ_ROOT / str(pid) / "export" groups, order_seen = [], [] for c in clips: k = c.get("paragraph_no") k = "未归属" if (k is None or k == "") else str(k) if k not in order_seen: order_seen.append(k) groups.append({"paragraph_no": k, "clips": [], "duration": 0.0, "file": "", "url": ""}) g = next(x for x in groups if x["paragraph_no"] == k) g["clips"].append(c["shot_id"]) g["duration"] = round(g["duration"] + c["duration"], 2) for g in groups: pf = exp_dir / (f"para_{g['paragraph_no']}.mp4" if g["paragraph_no"] != "未归属" else "para_unsorted.mp4") if pf.exists(): g["file"] = str(pf) g["url"] = (f"/api/ave/project/{pid}/post/export/file?name={pf.name}") g["mtime"] = datetime.fromtimestamp(pf.stat().st_mtime).strftime("%m-%d %H:%M") return {"status": "ok", "clips": clips, "total_duration": total, "groups": groups, # ★ 段落层 "settings": post.get("settings") or {}, "export": post.get("export") or {}, "note": "拼接与混流走本地 ffmpeg,不产生 API 费用"} @router.post("/{pid}/post/export") def api_post_export(pid: str, data: dict): """导出成片(本地 ffmpeg 拼接 + 混流)—— **零 API 费用** body: { order?: [shot_id...](自定义顺序), bgm?: 本地音频路径, bgm_volume?: 0.15, burn_subtitle?: false } """ o = _read(pid) or _new(pid) clips = _adopted_clips(pid) if not clips: raise HTTPException(400, detail="还没有「已采用」的视频片段 —— 请先去 🎞 视频 标签生成并采用") # ★ scope:逐段合成 or 全片 # "para:1" → 只拼第 1 段 → para_1.mp4(逐段验收,改一段不用重跑全片) # "all"(默认) → 全片 → final.mp4 scope = str(data.get("scope") or "all").strip() if scope.startswith("para:"): want = scope.split(":", 1)[1] clips = [c for c in clips if str(c.get("paragraph_no") or "未归属") == want] if not clips: raise HTTPException(400, detail=f"第 {want} 段还没有「已采用」的片段") out_name, tag = f"para_{want}.mp4", f"para{want}_" else: out_name, tag = "final.mp4", "" order = data.get("order") or [] if order: idx = {s: i for i, s in enumerate(order)} clips.sort(key=lambda c: idx.get(c["shot_id"], 9999)) out_dir = PROJ_ROOT / str(pid) / "export" out_dir.mkdir(parents=True, exist_ok=True) concat_out = out_dir / out_name steps = [] import sys as _s _s.path.insert(0, str(AVE_SCRIPTS_P)) # ── ① 逐段去AI化(可选,本地 ffmpeg,零成本)── sources = [c["file"] for c in clips] if data.get("deai"): try: from composer.de_ai import DeAIProcessor proc = DeAIProcessor() deais = [] for c in clips: d_out = out_dir / f"{tag}deai_{c['shot_id']}.mp4" proc.process(c["file"], str(d_out)) # 失败抛异常 → 整体回退 deais.append(str(d_out)) sources = deais steps.append(f"去AI化 {len(deais)} 段") except Exception as e: log.warning("去AI化失败,回退原始片段:%s", e) steps.append("去AI化失败(已回退)") # ── ② 拼接 ── try: from composer.ffmpeg import concat_segments concat_segments(sources, str(concat_out)) steps.append(f"拼接 {len(sources)} 段") except Exception as e: raise HTTPException(500, detail=f"拼接失败:{type(e).__name__}: {str(e)[:200]}") # ── ③ 字幕(可选,从各段台词 + 时间轴生成 ASS)── sub_path = "" if data.get("subtitle"): try: from composer.ffmpeg import create_subtitles segs, t = [], 0.0 for c in clips: txt = (c.get("text") or "").strip() if txt: segs.append({"text": txt, "start_sec": round(t, 2), "end_sec": round(t + (c.get("duration") or 0), 2)}) t += c.get("duration") or 0 if segs: sub_path = str(out_dir / f"{tag}subs.ass") create_subtitles(segs, sub_path) steps.append(f"字幕 {len(segs)} 条") else: steps.append("字幕:无台词可生成") except Exception as e: log.warning("字幕生成失败:%s", e) steps.append("字幕失败") # ── ④ 混流(BGM / 烧字幕,可选)── final = concat_out bgm = str(data.get("bgm") or "").strip() if bgm or sub_path: try: from composer.pipeline import StoryboardPipeline muxed = out_dir / (f"{tag}muxed.mp4" if tag else "final_muxed.mp4") StoryboardPipeline.mux_final(str(concat_out), str(muxed), bgm_path=bgm, bgm_volume=float(data.get("bgm_volume") or 0.15), subtitle_path=sub_path, burn_subtitle=bool(sub_path)) if muxed.exists(): final = muxed steps.append("混流" + ("(含字幕烧录)" if sub_path else "")) except Exception as e: log.warning("混流失败(保留拼接版):%s", e) steps.append("混流失败(已保留拼接版)") post = o.get("post") or {} post["export"] = {"path": str(final), "name": final.name, "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "clips": len(clips), "steps": steps} post["settings"] = {"bgm": bgm, "bgm_volume": data.get("bgm_volume") or 0.15, "deai": bool(data.get("deai")), "subtitle": bool(data.get("subtitle")), "order": [c["shot_id"] for c in clips]} o["post"] = post o["stage"] = "post" _history(o, "export", f"导出成片({len(clips)} 段 · {' + '.join(steps)})") _write(pid, o) return {"status": "ok", "file": str(final), "clips": len(clips), "steps": steps, "scope": scope, "url": f"/api/ave/project/{pid}/post/export/file?name={final.name}", "cost_note": "本地 ffmpeg,¥0"} @router.get("/{pid}/post/export/file") def api_post_export_file(pid: str, name: str): """读取导出成片""" p = (PROJ_ROOT / str(pid) / "export" / name).resolve() if not str(p).startswith(str(PROJ_ROOT.resolve())) or not p.exists(): raise HTTPException(404, detail=f"文件不存在: {name}") from fastapi.responses import FileResponse return FileResponse(str(p), media_type="video/mp4") @router.post("/{pid}/generate/shots") def api_generate_shots(pid: str, data: dict): """按【段落】生成分镜 —— 一次只处理一段(用户要求「一场一场做」) 为什么逐段:全篇一次生成时,AI 要同时兼顾所有段落,质量会稀释; 逐段做更聚焦、可逐段审核、可逐段返工。 body: { confirm_cost, paragraph_no: "1" 或 "1-3"(默认全部), director_id?, options? } → 生成的镜头带 paragraph_no,写入 storyboards/{pid}.json · 已有同段镜头会被替换(重做该段),其它段保留 """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成分镜会调用大模型(有费用),需显式确认") o = _read(pid) or _new(pid) sc = o.get("script") or {} paras = sc.get("paragraphs") or [] if not paras: raise HTTPException(400, detail="剧本还没有段落:请先在「📝 剧本」生成或填写") # 解析段落范围:"1" / "1-3" / 空=全部 rng = str(data.get("paragraph_no") or "").strip() picked = paras if rng: try: if "-" in rng: a, b = rng.split("-", 1) lo, hi = int(a), int(b) else: lo = hi = int(rng) picked = [p for p in paras if lo <= int(p.get("no") or 0) <= hi] except ValueError: raise HTTPException(400, detail=f"段落范围格式不对:{rng}(应为 1 或 1-3)") if not picked: raise HTTPException(404, detail=f"没有第 {rng} 段") # 段落 → 导演能读的文本(带段落号,便于它标注归属) lines = [] for p in picked: lines.append( f"【第 {p.get('no')} 段】\n" f"剧情:{p.get('plot') or ''}\n" f"动作:{p.get('action') or ''}\n" f"表情:{p.get('expression') or ''}\n" f"情绪:{p.get('emotion') or ''}\n" f"画面重点:{p.get('focus') or ''}" ) story = "\n\n".join(lines) # ★ 用户在故事框里改过的内容优先(选段落 → 改设置 → 生成 的主流程) _ov = str(data.get("story_override") or "").strip() if _ov: story = f"【第 {picked[0].get('no')} 段(用户已修订)】\n{_ov}" if len(picked) == 1 else _ov # ★ 段落级覆盖:`project.para_settings[段号]` 里设了就用它,否则回退全片 # 用途:某一场只有主角、或场景换到「家里」——不必改全局 para_set = ((o.get("para_settings") or {}).get(str(picked[0].get("no"))) or {}) \ if len(picked) == 1 else {} if para_set: log.warning("★段落级覆盖 第%s场: %s", picked[0].get("no"), json.dumps(para_set, ensure_ascii=False)[:160]) # 全片设定 → 注入(space/character_rules/camera_language 是分镜的约束) fs = o.get("film_settings") or {} opts = dict(data.get("options") or {}) extra = [] if (fs.get("space") or {}).get("type"): extra.append(f"空间:{fs['space'].get('type')}/{fs['space'].get('elements')}") if (fs.get("camera_language") or {}).get("tone"): extra.append(f"整体镜头语言:{fs['camera_language'].get('tone')}" f";构图倾向 {fs['camera_language'].get('composition')}") if (fs.get("character_rules") or {}).get("consistency"): extra.append(f"角色表演规则:{fs['character_rules'].get('consistency')}" f";动作语言 {fs['character_rules'].get('action_language')}") if extra: opts["extra_context"] = "\n".join(extra) # 调导演(照 routes/ave.py 的写法:模块级函数 + reload 避免缓存) import sys as _s _s.path.insert(0, str(AVE_SCRIPTS_P)) import importlib import directors as _d importlib.reload(_d) director = _d.get_director(data.get("director_id") or "") or _d.default_director() from schemas import GlobalAnchors anchor_data = dict(data.get("anchors") or {}) # 演员表 → 全局锚点 cast = o.get("cast") or [] if cast and not anchor_data.get("cast"): anchor_data["cast"] = cast # ★ 段落级覆盖优先(只影响这一段) if para_set.get("cast"): anchor_data["cast"] = para_set["cast"] if para_set.get("location_id"): anchor_data["location_id"] = para_set["location_id"] for _sk in ("shooting_style", "director_style", "realism_boost"): if para_set.get(_sk): opts[_sk] = para_set[_sk] # ★ 风格三兄弟:从 options 收敛到 anchors(**照抄 routes/ave.py 的逻辑**) # 提示词编译读的是 anchors.shooting_style / director_style / realism_boost; # 而 UI 的 options_schema 把它们放在 options 里 —— 不收敛就永远用默认值, # 表现为「我选了风格但生成的还是伪纪录片」。 for _k in ("shooting_style", "director_style", "realism_boost"): if not (anchor_data.get(_k) or "").strip(): _v = (opts.get(_k) or "").strip() if _v: anchor_data[_k] = _v log.warning("★分镜风格 shooting=%s director=%s realism=%s opts_keys=%s", anchor_data.get("shooting_style"), anchor_data.get("director_style"), anchor_data.get("realism_boost"), list(opts.keys())) try: sb = director.generate(story, anchors=GlobalAnchors(**anchor_data), options=opts) except Exception as e: raise HTTPException(500, detail=f"导演生成失败:{type(e).__name__}: {str(e)[:160]}") new_shots = [s.model_dump() if hasattr(s, "model_dump") else dict(s) for s in (getattr(sb, "shots", None) or [])] # 标记段落归属 for s in new_shots: s["paragraph_no"] = picked[0].get("no") if len(picked) == 1 else rng or "all" # ★★ 保护 1:导演没返回镜头就**不要动原数据**(原来会合并成空 → 全丢) if not new_shots: raise HTTPException( 500, detail="导演没有返回任何镜头(可能 LLM 失败或输出无法解析)—— " "**已保留原有分镜,未做任何修改**。请重试或换导演。") # 合并进分镜表:同段替换,其它段保留 sbj = _SB_DIR / f"{pid}.json" cur = {} if sbj.exists(): try: cur = json.loads(sbj.read_text(encoding="utf-8")) except Exception: cur = {} old_shots = cur.get("shots") or [] # ★★ 保护 2:只在「明确指定单个段落」时才替换那一段; # 生成"全部/多段"时**不删旧数据**,改为把新镜头追加到对应段落(按段落去重) _single = bool(rng) and ("-" not in rng) and len(picked) == 1 if _single: _want = str(picked[0].get("no")) keep = [x for x in old_shots if str(x.get("paragraph_no")) != _want] # 该段已有的关键帧/视频不受影响(只动分镜条目) else: # 多段/全部:按「新镜头覆盖到的段落」逐段替换,未涉及的段落保留 _touched = {str(x.get("no")) for x in picked} keep = [x for x in old_shots if str(x.get("paragraph_no") or "") not in _touched and str(x.get("paragraph_no") or "") != ""] # 空段落号的旧镜头保留 merged = keep + new_shots # ★ 按段落号排序(否则「后来生成的段落」会排到最后 —— 用户实测发现) # 无段落号的旧镜头排最后 def _pk(x): v = x.get("paragraph_no") try: return (0, int(v), 0) except (TypeError, ValueError): return (1, 0, 0) # 段内保持原顺序(stable sort 靠 Python 保证) merged.sort(key=_pk) # 重新编号(保持连续) for i, s in enumerate(merged, 1): s["shot_id"] = f"shot_{i:02d}" cur.update({ "project_id": pid, "title": (sc.get("meta") or {}).get("title") or pid, "director_id": getattr(director, "id", "") or data.get("director_id") or "", "shots": merged, }) sbj.parent.mkdir(parents=True, exist_ok=True) if sbj.exists(): shutil.copy2(sbj, sbj.with_suffix(".json.bak")) sbj.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8") # 记进项目对象(供上游带入显示) st = o.get("storyboard") or {} st["shots"] = merged st["paragraphs_done"] = sorted({str(s.get("paragraph_no")) for s in merged if s.get("paragraph_no")}) o["storyboard"] = st o["stage"] = "shots" _history(o, "shots", f"分镜(段落 {rng or '全部'}):新增 {len(new_shots)} 镜,共 {len(merged)}") _write(pid, o) return {"status": "ok", "added": len(new_shots), "total": len(merged), "paragraph_no": rng or "all", "kept": len(keep), "shots": [{"shot_id": s.get("shot_id"), "paragraph_no": s.get("paragraph_no"), "summary": (s.get("visual_description") or "")[:40]} for s in merged]} @router.post("/{pid}/storyboard/clear") def api_storyboard_clear(pid: str, data: dict): """清除分镜条目(用于清掉"未归属"的旧镜头) body: { scope: "unsorted"(只清没有 paragraph_no 的)| "para:1" | "all", confirm_cost } ⚠️ 只删**分镜条目**,不动已生成的关键帧/视频文件。 """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 清除分镜需显式确认") scope = str(data.get("scope") or "unsorted") sbj = _SB_DIR / f"{pid}.json" if not sbj.exists(): raise HTTPException(404, detail="没有分镜表") cur = json.loads(sbj.read_text(encoding="utf-8")) shots = cur.get("shots") or [] if scope == "unsorted": # 清掉「没有段落归属」的:paragraph_no 为空 / 为 None / 为 "all" # ("all" 是旧版「一次生成全部段落」留下的,无法按段落分组) keep = [s for s in shots if s.get("paragraph_no") and str(s.get("paragraph_no")) != "all"] elif scope.startswith("para:"): want = scope.split(":", 1)[1] keep = [s for s in shots if str(s.get("paragraph_no")) != want] else: keep = [] removed = len(shots) - len(keep) for i, s in enumerate(keep, 1): s["shot_id"] = f"shot_{i:02d}" cur["shots"] = keep shutil.copy2(sbj, sbj.with_suffix(".json.bak")) sbj.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8") o = _read(pid) or {} st = o.get("storyboard") or {} st["shots"] = keep st["paragraphs_done"] = sorted({str(s.get("paragraph_no")) for s in keep if s.get("paragraph_no")}) o["storyboard"] = st _history(o, "clear", f"清除分镜 {removed} 条({scope})") _write(pid, o) return {"status": "ok", "removed": removed, "total": len(keep)} @router.get("/{pid}/video-settings") def api_video_settings_get(pid: str): """读「视频」页的全局设置(默认引擎/时长/声音路径)""" o = _read(pid) or {} return {"status": "ok", "settings": o.get("video_settings") or {}} @router.post("/{pid}/video-settings") def api_video_settings_save(pid: str, data: dict): o = _read(pid) or _new(pid) cur = o.get("video_settings") or {} for k, v in (data.get("settings") or {}).items(): cur[k] = v o["video_settings"] = cur _history(o, "video-settings", "更新视频全局设置") _write(pid, o) return {"status": "ok", "settings": cur} @router.get("/{pid}/kf-settings") def api_kf_settings_get(pid: str): """读「关键帧设计」的全局设置(全片统一性/禁止项/扩展强度偏好)""" o = _read(pid) or {} return {"status": "ok", "settings": o.get("kf_settings") or {}} @router.post("/{pid}/kf-settings") def api_kf_settings_save(pid: str, data: dict): o = _read(pid) or _new(pid) cur = o.get("kf_settings") or {} for k, v in (data.get("settings") or {}).items(): cur[k] = v o["kf_settings"] = cur _history(o, "kf-settings", "更新关键帧设计全局设置") _write(pid, o) return {"status": "ok", "settings": cur} @router.get("/{pid}/para-settings") def api_para_settings_get(pid: str): """读各场次的覆盖设置 { 场号: {cast, location_id, shooting_style…} }""" o = _read(pid) or {} return {"status": "ok", "settings": o.get("para_settings") or {}} @router.post("/{pid}/para-settings") def api_para_settings_save(pid: str, data: dict): """存某一场的覆盖设置(body: {paragraph_no, values};values 为空则清除该场覆盖)""" no = str(data.get("paragraph_no") or "").strip() if not no: raise HTTPException(400, detail="paragraph_no 必填") o = _read(pid) or _new(pid) ps = o.get("para_settings") or {} vals = data.get("values") or {} if vals: ps[no] = {**(ps.get(no) or {}), **vals} else: ps.pop(no, None) o["para_settings"] = ps _history(o, "para-settings", f"第 {no} 场覆盖设置:{json.dumps(vals, ensure_ascii=False)[:80]}") _write(pid, o) return {"status": "ok", "settings": ps} @router.get("/{pid}/film-settings") def api_film_settings_get(pid: str): """读全片设定(原故事板里那三块全片级内容)""" o = _read(pid) or {} fs = o.get("film_settings") if not fs: # 迁移:从旧 storyboard_paper 里取那三块 old = o.get("storyboard_paper") or {} fs = _new_film_settings() for k in ("mode", "driver", "avoid_modes", "space", "character_rules", "camera_language"): if old.get(k): fs[k] = old[k] return {"status": "ok", "settings": fs} @router.post("/{pid}/film-settings") def api_film_settings_save(pid: str, data: dict): """存全片设定""" o = _read(pid) or _new(pid) cur = o.get("film_settings") or _new_film_settings() for k, v in (data.get("settings") or {}).items(): cur[k] = v o["film_settings"] = cur _history(o, "film-settings", "更新全片设定") _write(pid, o) return {"status": "ok", "settings": cur} @router.post("/{pid}/storyboard/sketch") def api_storyboard_sketch(pid: str, data: dict): """故事板「生成草图」(可选)—— ⚠️ 每张约 ¥0.2,需 confirm_cost。 依据 AI-Animation-Studio 05 §05:故事板目标是「规划」不是「画漂亮」—— 必须用**低完成度**提示词(粗略电影分镜/铅笔速写/简化角色)。 明确禁止:复杂插画、精修概念艺术、重材质渲染。 body: { confirm_cost, shot_no?(只生成某一格,留空=全部) } """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成故事板草图会真实扣费(约 ¥0.2/张),需显式确认") o = _read(pid) or _new(pid) sb = o.get("storyboard_paper") or {} shots = sb.get("shots") or [] if not shots: raise HTTPException(400, detail="故事板还没有镜头:请先生成或手写") only = data.get("shot_no") todo = [s for s in shots if (not only or int(s.get("no") or 0) == int(only))] if not todo: raise HTTPException(404, detail=f"没有第 {only} 格") # 低完成度故事板提示词(业界明确要求「粗略」) PRE = ("A rough storyboard sketch, pencil on paper, quick line work, " "simplified characters, no rendering, no shading, no color, " "focus on composition and action readability. ") import sys as _s _s.path.insert(0, str(AVE_SCRIPTS_P)) from keyframe_gen import build_keyframe, DEFAULT_SIZE, download out_dir = PROJ_ROOT / str(pid) / "storyboard_sketch" out_dir.mkdir(parents=True, exist_ok=True) results, failed = [], [] for s in todo: no = int(s.get("no") or 0) desc = " / ".join(x for x in ( s.get("content") or "", s.get("action") or "", f"camera: {s.get('camera')}" if s.get("camera") else "") if x) try: urls = build_keyframe(PRE + desc, ref_images=None, size=DEFAULT_SIZE, n=1) if not urls: failed.append({"no": no, "why": "未返回图片"}) continue dest = out_dir / f"sketch_{no:02d}.png" download(urls[0], str(dest)) s["sketch"] = f"/api/ave/project/{pid}/storyboard/sketch/file?name=sketch_{no:02d}.png" results.append({"no": no, "file": dest.name}) except Exception as e: failed.append({"no": no, "why": f"{type(e).__name__}: {str(e)[:80]}"}) o["storyboard_paper"] = sb _history(o, "sketch", f"故事板草图 {len(results)} 张(失败 {len(failed)})") _write(pid, o) return {"status": "ok", "generated": results, "failed": failed, "cost_note": f"约 ¥{0.2 * len(results):.1f}"} @router.get("/{pid}/storyboard/sketch/file") def api_storyboard_sketch_file(pid: str, name: str): """读取故事板草图""" p = (PROJ_ROOT / str(pid) / "storyboard_sketch" / name).resolve() if not str(p).startswith(str(PROJ_ROOT.resolve())) or not p.exists(): raise HTTPException(404, detail=f"草图不存在: {name}") from fastapi.responses import FileResponse return FileResponse(str(p), media_type="image/png") @router.get("/{pid}/artifacts") def api_project_artifacts(pid: str): """📤 本项目全部产物(按类型归类)—— 给工作室右侧「产物」面板用 归类:脚本 / 故事板 / 关键帧 / 视频 / 音频 / 成片 只列**当前项目**的,不含别处产物。 """ o = _read(pid) or {} local = _AGENT_LOCAL ave = local / "runtime" / "ave" out = {"status": "ok", "project_id": pid, "groups": [], "totals": {}} def add(key, label, items): items = [x for x in items if x] if items: out["groups"].append({"key": key, "label": label, "items": items}) # ① 脚本 sc = o.get("script") or {} script_items = [] if (sc.get("paragraphs") or []): script_items.append({"name": f"剧本({len(sc['paragraphs'])} 段)", "meta": f"模式 {sc.get('mode') or '未选'}", "url": ""}) if (o.get("storyboard_paper") or {}).get("shots"): n = len((o.get("storyboard_paper") or {}).get("shots") or []) script_items.append({"name": f"故事板({n} 镜)", "meta": "逐格规划", "url": ""}) add("script", "📝 脚本/故事板", script_items) # ② 关键帧(静帧) kfs = [] kfdir = ave / "keyframes" / pid if kfdir.is_dir(): for shot in sorted([d for d in kfdir.iterdir() if d.is_dir()]): for png in sorted(shot.glob("v*.png"), reverse=True)[:1]: kfs.append({"name": f"{shot.name}/{png.name}", "meta": "", "url": f"/api/ave/keyframe/file?path={pid}/{shot.name}/{png.name}"}) add("keyframes", "🖼 关键帧", kfs) # ③ 视频(+ 音频) vids, auds = [], [] vdir = ave / "keyframe_videos" / pid if vdir.is_dir(): for shot in sorted([d for d in vdir.iterdir() if d.is_dir()]): for mp4 in sorted(shot.glob("v*.mp4")): vids.append({"name": f"{shot.name}/{mp4.name}", "meta": f"{round(mp4.stat().st_size/1048576,1)}MB", "url": f"/api/ave/keyframe/video/file?path={pid}/{shot.name}/{mp4.name}"}) for mp3 in sorted(shot.glob("*.mp3")): auds.append({"name": f"{shot.name}/{mp3.name}", "meta": "配音", "url": ""}) add("videos", "🎞 视频", vids) add("audio", "🎵 音频", auds) # ④ 成片 / 草图 fins, sketches = [], [] for sub, lst, lab in ((("export"), fins, "成片"), (("storyboard_sketch"), sketches, "草图")): d = PROJ_ROOT / pid / sub if d.is_dir(): for f in sorted(d.glob("*")): if f.suffix.lower() in (".mp4", ".png"): lst.append({"name": f.name, "meta": f"{round(f.stat().st_size/1048576,1)}MB", "url": (f"/api/ave/project/{pid}/post/export/file?name={f.name}" if sub == "export" else f"/api/ave/project/{pid}/storyboard/sketch/file?name={f.name}")}) add("final", "📤 成片", fins) add("sketch", "✏️ 故事板草图", sketches) out["totals"] = {"groups": len(out["groups"]), "items": sum(len(g["items"]) for g in out["groups"])} return out @router.post("/{pid}/stage") def api_project_stage(pid: str, data: dict): """切换阶段(前端切标签时调用)""" stage = str(data.get("stage") or "") if stage not in STAGE_IDS: raise HTTPException(400, detail=f"未知阶段:{stage}(可选 {STAGE_IDS})") o = _read(pid) or _new(pid) old = o.get("stage") o["stage"] = stage _history(o, "stage", f"{old} → {stage}") _write(pid, o) return {"status": "ok", "stage": stage, "previous": old} @router.delete("/{pid}") def api_project_delete(pid: str): """删除项目(移到 projects/_trash,不真删)""" d = PROJ_ROOT / str(pid) if not d.is_dir(): raise HTTPException(404, detail=f"项目不存在: {pid}") trash = PROJ_ROOT / "_trash" trash.mkdir(exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") dest = trash / f"{pid}_{ts}" shutil.move(str(d), str(dest)) log.info("项目 %s 已移入回收站: %s", pid, dest) return {"status": "ok", "moved_to": str(dest)}