移植自 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 带网关原话 / 正常返回)。
1736 lines
81 KiB
Python
1736 lines
81 KiB
Python
"""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)}
|