Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/routes/ave_project.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1736 lines
81 KiB
Python
Raw Blame History

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