移植自 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 带网关原话 / 正常返回)。
1767 lines
75 KiB
Python
1767 lines
75 KiB
Python
"""routes/ave_keyframe.py — 🖼 关键帧工作台
|
||
|
||
流程位置:① 剧本创作 → ②【关键帧工作台】→ ③ 关键帧变视频
|
||
|
||
存储:
|
||
agent-local/runtime/ave/keyframes/{project_id}/{shot_id}/
|
||
v1.png / v1.json (每版一张图 + 一份元数据)
|
||
v2.png / v2.json
|
||
(adopted 字段标记采用哪一版)
|
||
agent-local/runtime/ave/keyframes/{project_id}/_index.json (项目级索引)
|
||
|
||
接口:
|
||
GET /api/ave/keyframe/projects 有关键帧的项目列表
|
||
GET /api/ave/keyframe/shots?project_id= 某项目的镜头清单 + 各版状态
|
||
GET /api/ave/keyframe/context?project_id=&shot_id= 单镜头上下文(建议参考图 + 提示词草稿)
|
||
POST /api/ave/keyframe/generate 生成(需 confirm_cost)
|
||
POST /api/ave/keyframe/adopt 采用某版
|
||
POST /api/ave/keyframe/discard 丢弃某版
|
||
GET /api/ave/keyframe/file?path= 读取图片
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
|
||
log = logging.getLogger("dashboard.ave_keyframe")
|
||
|
||
router = APIRouter(prefix="/api/ave/keyframe", tags=["ave-keyframe"])
|
||
|
||
_AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(Path(__file__).resolve().parents[3])))
|
||
_AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(Path(_AGENT_SYNC).parent / "agent-local")))
|
||
AVE_SCRIPTS = _AGENT_SYNC / "05_tools" / "09_ave" / "scripts"
|
||
KF_ROOT = _AGENT_LOCAL / "runtime" / "ave" / "keyframes"
|
||
STORIES_DIR = _AGENT_LOCAL / "runtime" / "ave" / "storyboards"
|
||
|
||
if str(AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
|
||
|
||
# ── 工具 ───────────────────────────────────────────────────
|
||
def _proj_dir(pid: str) -> Path:
|
||
d = KF_ROOT / str(pid)
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _shot_dir(pid: str, sid: str) -> Path:
|
||
d = _proj_dir(pid) / str(sid)
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _read_json(p: Path, default=None):
|
||
try:
|
||
return json.loads(p.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _safe_under(base: Path, target: Path) -> bool:
|
||
try:
|
||
return str(target.resolve()).startswith(str(base.resolve()))
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _load_storyboard(pid: str) -> dict | None:
|
||
"""从 storyboards 目录取分镜(供镜头上下文 / 参考图建议)"""
|
||
for name in (f"{pid}.json", f"sb_{pid}.json"):
|
||
d = _read_json(STORIES_DIR / name)
|
||
if d:
|
||
return d
|
||
# 兜底:扫描目录里含该 pid 的文件
|
||
if STORIES_DIR.exists():
|
||
for f in STORIES_DIR.glob("*.json"):
|
||
d = _read_json(f)
|
||
if d and str(d.get("project_id")) == str(pid):
|
||
return d
|
||
return None
|
||
|
||
|
||
def _storyboard_path(pid: str):
|
||
"""分镜表**文件路径**(写回用;_load_storyboard 只返回内容)"""
|
||
for name in (f"{pid}.json", f"sb_{pid}.json"):
|
||
p = STORIES_DIR / name
|
||
if p.exists():
|
||
return p
|
||
if STORIES_DIR.exists():
|
||
for f in STORIES_DIR.glob("*.json"):
|
||
d = _read_json(f)
|
||
if d and str(d.get("project_id")) == str(pid):
|
||
return f
|
||
return None
|
||
|
||
|
||
def _shot_of(sb: dict, sid: str) -> dict | None:
|
||
for s in (sb.get("shots") or []):
|
||
if str(s.get("shot_id")) == str(sid):
|
||
return s
|
||
return None
|
||
|
||
|
||
# ── 参考图建议(复用现有资产解析)────────────────────────
|
||
def _suggest_refs(sb: dict, shot: dict) -> dict:
|
||
"""返回 {role_images: [...], scene_image: ..., hints: [...]}"""
|
||
out = {"role_images": [], "scene_image": "", "hints": []}
|
||
try:
|
||
from composer.pipeline import StoryboardPipeline
|
||
from schemas import Shot, Storyboard, GlobalAnchors, ShotContract
|
||
|
||
ga = sb.get("global_anchors") or {}
|
||
sc = shot.get("shot_contract") or {}
|
||
obj = Shot(shot_id=shot.get("shot_id", "s1"),
|
||
location_id=shot.get("location_id") or ga.get("location_id", ""),
|
||
character_id=shot.get("character_id") or "",
|
||
cast_slots=shot.get("cast_slots") or [],
|
||
shot_contract=ShotContract(shot_size=sc.get("shot_size", ""),
|
||
angle=sc.get("angle", "")))
|
||
sobj = Storyboard(project_id=sb.get("project_id", ""),
|
||
global_anchors=GlobalAnchors(
|
||
location_id=ga.get("location_id", ""),
|
||
cast=ga.get("cast") or []))
|
||
# 角色参考图(按演员表顺序)
|
||
for slot in (shot.get("cast_slots") or []):
|
||
cid = ""
|
||
for c in (ga.get("cast") or []):
|
||
if c.get("slot") == slot:
|
||
cid = c.get("asset_id", "")
|
||
break
|
||
cid = cid or shot.get("character_id") or ""
|
||
if not cid:
|
||
continue
|
||
p = StoryboardPipeline._ref_image_of_character(
|
||
cid, shot_size=sc.get("shot_size", ""), angle=sc.get("angle", ""))
|
||
if p:
|
||
out["role_images"].append({"slot": slot, "asset_id": cid, "path": p})
|
||
else:
|
||
# 资产存在但取不到图 → 明确告警(而不是静默降级)
|
||
adir = _AGENT_LOCAL / "tools" / "ave" / "assets" / "characters" / str(cid)
|
||
if not adir.exists():
|
||
out["hints"].append(f"⚠️ 角色「{cid}」在资产库中不存在")
|
||
elif not (adir / "meta.json").exists():
|
||
out["hints"].append(f"⚠️ 角色「{cid}」是空目录,没有任何内容")
|
||
else:
|
||
out["hints"].append(f"⚠️ 角色「{cid}」没有可用参考图(该角色只有 meta,未生成图)")
|
||
# 场景参考图(按机位)
|
||
loc = StoryboardPipeline._resolve_location_ref(obj, sobj)
|
||
if loc:
|
||
out["scene_image"] = loc
|
||
out["hints"].append(f"角色图 {len(out['role_images'])} 张 · "
|
||
f"场景图 {'有' if out['scene_image'] else '无'}")
|
||
except Exception as e:
|
||
out["hints"].append(f"参考图解析失败: {type(e).__name__}: {e}")
|
||
return out
|
||
|
||
|
||
def _prompt_draft(sb: dict, shot: dict) -> str:
|
||
"""从分镜生成**关键帧**提示词草稿
|
||
|
||
⚠️ 关键帧是静帧 —— 只要「这一瞬间的画面」,**不要**运动/动作演化/摄影特征
|
||
(那些属于第③步视频阶段)。所以这里不调 build_shot_prompt(那是给视频用的),
|
||
而是自己拼:画面描述 + 景别 + 机位 + 光线 + 风格。
|
||
"""
|
||
parts: list[str] = []
|
||
vd = (shot.get("visual_description") or "").strip()
|
||
if vd:
|
||
parts.append(vd)
|
||
sc = shot.get("shot_contract") or {}
|
||
if sc.get("shot_size"):
|
||
parts.append(f"景别:{sc['shot_size']}")
|
||
if sc.get("angle"):
|
||
parts.append(f"机位:{sc['angle']}")
|
||
ga = sb.get("global_anchors") or {}
|
||
if ga.get("lighting_state"):
|
||
parts.append(f"光线:{ga['lighting_state']}")
|
||
if ga.get("shooting_style"):
|
||
# ★ 用「图像模型能看懂的风格短语」,而不是内部代号
|
||
# (代号 documentary_realism 对图模型是噪音 —— 它不认识这个字符串)
|
||
parts.append(f"风格:{_STYLE_PHRASE.get(ga['shooting_style'], '真实摄影')}")
|
||
parts.append("单帧静态画面,写实摄影")
|
||
return ";".join(p for p in parts if p)
|
||
|
||
|
||
# ── 接口 ───────────────────────────────────────────────────
|
||
@router.get("/projects")
|
||
def api_kf_projects():
|
||
"""有关键帧记录的「项目」(按分镜表聚合;无记录的分镜也会列出,方便起步)"""
|
||
projs: dict = {}
|
||
# ① 分镜表里的项目
|
||
if STORIES_DIR.exists():
|
||
for f in sorted(STORIES_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||
d = _read_json(f) or {}
|
||
pid = str(d.get("project_id") or "")
|
||
if not pid:
|
||
continue
|
||
projs.setdefault(pid, {
|
||
"project_id": pid, "title": (d.get("title") or "")[:40],
|
||
"shots": len(d.get("shots") or []), "kf_done": 0, "kf_total": 0,
|
||
"updated_at": d.get("created_at") or "",
|
||
})
|
||
# ② 已有的关键帧目录
|
||
if KF_ROOT.exists():
|
||
for d in KF_ROOT.iterdir():
|
||
if not d.is_dir():
|
||
continue
|
||
idx = _read_json(d / "_index.json") or {}
|
||
projs.setdefault(str(d.name), {"project_id": str(d.name), "title": "",
|
||
"shots": 0, "updated_at": ""})
|
||
p = projs[str(d.name)]
|
||
p["kf_done"] = idx.get("adopted", 0)
|
||
p["kf_total"] = idx.get("total", 0)
|
||
p["updated_at"] = idx.get("updated_at") or p.get("updated_at", "")
|
||
return {"status": "ok", "projects": list(projs.values())}
|
||
|
||
|
||
@router.get("/shots")
|
||
def api_kf_shots(project_id: str):
|
||
"""某项目的镜头清单(含每镜头各版本与采用状态)"""
|
||
sb = _load_storyboard(project_id) or {}
|
||
shots = sb.get("shots") or []
|
||
pdir = _proj_dir(project_id)
|
||
out = []
|
||
for s in shots:
|
||
sid = str(s.get("shot_id") or "")
|
||
if not sid:
|
||
continue
|
||
sdir = pdir / sid
|
||
versions = []
|
||
if sdir.exists():
|
||
for jf in sorted(sdir.glob("v*.json")):
|
||
m = _read_json(jf) or {}
|
||
png = jf.with_suffix(".png")
|
||
versions.append({
|
||
"version": m.get("version"), "ok": png.exists(),
|
||
"url": f"/api/ave/keyframe/file?path={project_id}/{sid}/{png.name}" if png.exists() else "",
|
||
"model": m.get("model", ""), "size": m.get("size", ""),
|
||
"prompt": (m.get("prompt") or "")[:200],
|
||
"created_at": m.get("created_at", ""), "n_refs": m.get("n_refs", 0),
|
||
"adopted": bool(m.get("adopted")), # ← 前端靠它判断「采用」与左列缩略图
|
||
})
|
||
sc = s.get("shot_contract") or {}
|
||
out.append({
|
||
"shot_id": sid,
|
||
"paragraph_no": s.get("paragraph_no"), # ★ 段落归属(供前端按段分组)
|
||
"visual": (s.get("visual_description") or "")[:80],
|
||
"shot_size": sc.get("shot_size", ""), "angle": sc.get("angle", ""),
|
||
"cast_slots": s.get("cast_slots") or [],
|
||
"versions": versions,
|
||
"adopted": next((v["version"] for v in versions if v.get("adopted")), None),
|
||
})
|
||
return {"status": "ok", "project_id": project_id, "shots": out,
|
||
"has_storyboard": bool(shots)}
|
||
|
||
|
||
# ── 镜头「详情」(把导演分镜里的参数尽量全地带过来)──────
|
||
_INTENT_FIELDS = ("visual_description", "action", "text", "sentiment", "type", "duration")
|
||
|
||
|
||
def _shot_detail_legacy_sb_first(sb: dict, shot: dict) -> dict:
|
||
"""⚠️ 已废弃:早期版本(参数顺序 sb 在前)。
|
||
|
||
保留仅为历史可读;**不要调用** —— 现在统一用文件后部的
|
||
_shot_detail(shot, sb)(参数顺序 shot 在前,且同时返回
|
||
camera/looks/assets 与 params/asset/global 两套键名)。
|
||
Python 同名函数会互相覆盖,同名共存曾导致参数错位、四层全空的 bug。
|
||
"""
|
||
# ---- 以下为历史实现,已不再被调用 ----
|
||
# 分三层:
|
||
# intent 意图 —— 来自导演分镜(可编辑 → 可回填)
|
||
# params 参数 —— 来自导演分镜(供参考,不直接编辑)
|
||
# asset 资产 —— 绑定的角色/场景/道具(含解析结果)
|
||
ga = sb.get("global_anchors") or {}
|
||
sc = shot.get("shot_contract") or {}
|
||
|
||
# ① 意图
|
||
intent = {
|
||
"visual_description": shot.get("visual_description", ""),
|
||
"action": shot.get("action") or "",
|
||
"text": shot.get("text") or "",
|
||
"sentiment": shot.get("sentiment") or "",
|
||
"type": shot.get("type") or "",
|
||
"duration": shot.get("duration"),
|
||
}
|
||
|
||
# ② 拍摄参数
|
||
params = {
|
||
"shot_size": sc.get("shot_size", ""),
|
||
"angle": sc.get("angle", ""),
|
||
"camera_move": sc.get("camera_move", ""),
|
||
"lighting": sc.get("lighting") or ga.get("lighting_state", ""),
|
||
"continuity_mode": shot.get("continuity_mode") or "",
|
||
"generation_capability": shot.get("generation_capability") or "",
|
||
"transition_in": (shot.get("transition_in") or {}),
|
||
"transition_out": (shot.get("transition_out") or {}),
|
||
"speed": shot.get("speed") or {},
|
||
"color": shot.get("color") or {},
|
||
"tech_requirements": shot.get("tech_requirements") or {},
|
||
"vfx": shot.get("vfx") or {},
|
||
}
|
||
|
||
# ③ 表情 / 音频(有则展开)
|
||
expr = shot.get("expression_detail") or {}
|
||
if expr:
|
||
params["expression_detail"] = expr
|
||
audio = shot.get("audio") or {}
|
||
if audio:
|
||
params["audio"] = audio
|
||
|
||
# ④ 资产绑定
|
||
cast_names = []
|
||
for slot in (shot.get("cast_slots") or []):
|
||
nm = next((c.get("name") for c in (ga.get("cast") or []) if c.get("slot") == slot), "")
|
||
cast_names.append({"slot": slot, "name": nm})
|
||
asset = {
|
||
"cast_slots": shot.get("cast_slots") or [],
|
||
"cast_names": cast_names,
|
||
"character_id": shot.get("character_id") or "",
|
||
"location_id": shot.get("location_id") or ga.get("location_id", ""),
|
||
"props": shot.get("props") or [],
|
||
"product_id": shot.get("product_id") or "",
|
||
"product_placement": shot.get("product_placement") or "",
|
||
}
|
||
|
||
# ⑤ 全局上下文(导演定的基准)
|
||
global_ctx = {
|
||
"title": sb.get("title", ""),
|
||
"story": (sb.get("story") or "")[:400],
|
||
"shooting_style": ga.get("shooting_style", ""),
|
||
"realism_boost": ga.get("realism_boost", ""),
|
||
"lighting_state": ga.get("lighting_state", ""),
|
||
"time_of_day": ga.get("time_of_day", ""),
|
||
"mood": ga.get("mood", ""),
|
||
}
|
||
return {"intent": intent, "params": params, "asset": asset, "global": global_ctx}
|
||
|
||
|
||
@router.get("/size-presets")
|
||
def api_kf_size_presets():
|
||
"""画幅预设 + 模型注册表(含能力注释)"""
|
||
try:
|
||
import keyframe_gen
|
||
return {"status": "ok", "presets": keyframe_gen.SIZE_PRESETS,
|
||
"default": keyframe_gen.DEFAULT_SIZE, "model": keyframe_gen.DEFAULT_MODEL,
|
||
"model_by_mode": keyframe_gen.MODEL_BY_MODE,
|
||
"models": keyframe_gen.image_models()}
|
||
except Exception as e:
|
||
return {"status": "ok", "presets": {"竖屏 9:16 · 720P": "720*1280"},
|
||
"default": "720*1280", "models": [], "error": str(e)[:120]}
|
||
|
||
|
||
@router.get("/context")
|
||
def api_kf_context(project_id: str, shot_id: str):
|
||
"""单镜头的完整上下文:详情(全字段)+ 建议参考图 + 提示词草稿"""
|
||
sb = _load_storyboard(project_id)
|
||
if not sb:
|
||
raise HTTPException(404, detail=f"未找到分镜表: {project_id}")
|
||
shot = _shot_of(sb, shot_id)
|
||
if not shot:
|
||
raise HTTPException(404, detail=f"未找到镜头: {shot_id}")
|
||
refs = _suggest_refs(sb, shot)
|
||
return {"status": "ok", "shot_id": shot_id,
|
||
"detail": _shot_detail(shot, sb), # ⚠️ 参数顺序:shot 在前(统一签名)
|
||
"role_images": refs["role_images"], "scene_image": refs["scene_image"],
|
||
"hints": refs["hints"], "prompt": _prompt_draft(sb, shot),
|
||
# ★ 关键帧设计(A/B/C 各帧)—— 工作台按帧生成
|
||
"kf_plan": _kf_plan_of(project_id, shot_id),
|
||
# ★ 已生成的帧(含 frame_seq / role),供界面显示「哪一帧已生成」
|
||
"generated_frames": _kf_generated_frames(project_id, shot_id)}
|
||
|
||
|
||
@router.post("/update-shot")
|
||
def api_kf_update_shot(data: dict):
|
||
"""把关键帧工作台里改过的「意图」回填到导演分镜(显式调用,不自动)
|
||
|
||
body: { project_id, shot_id, fields: {visual_description?, action?, text?, sentiment?} }
|
||
"""
|
||
pid = str(data.get("project_id") or "")
|
||
sid = str(data.get("shot_id") or "")
|
||
fields = data.get("fields") or {}
|
||
if not (pid and sid and fields):
|
||
raise HTTPException(400, detail="project_id / shot_id / fields 必填")
|
||
|
||
# 找到分镜文件
|
||
target = None
|
||
for name in (f"{pid}.json", f"sb_{pid}.json"):
|
||
p = STORIES_DIR / name
|
||
if p.exists():
|
||
target = p
|
||
break
|
||
if target is None and STORIES_DIR.exists():
|
||
for f in STORIES_DIR.glob("*.json"):
|
||
d = _read_json(f) or {}
|
||
if str(d.get("project_id")) == pid:
|
||
target = f
|
||
break
|
||
if target is None:
|
||
raise HTTPException(404, detail=f"未找到分镜表: {pid}")
|
||
|
||
sb = _read_json(target) or {}
|
||
changed = []
|
||
for s in (sb.get("shots") or []):
|
||
if str(s.get("shot_id")) != sid:
|
||
continue
|
||
for k, v in fields.items():
|
||
if k in _INTENT_FIELDS or k == "visual_description":
|
||
s[k] = v
|
||
changed.append(k)
|
||
break
|
||
if not changed:
|
||
raise HTTPException(400, detail="没有可回填的字段")
|
||
|
||
backup = target.with_suffix(".json.bak")
|
||
try:
|
||
backup.write_text(json.dumps(_read_json(target), ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
target.write_text(json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return {"status": "ok", "changed": changed, "backup": backup.name,
|
||
"note": "已回填导演分镜(原文件已备份为 .bak)"}
|
||
|
||
|
||
@router.post("/generate")
|
||
def api_kf_generate(data: dict):
|
||
"""生成关键帧(真实扣费,需 confirm_cost=true)
|
||
|
||
body: { project_id, shot_id, prompt, negative?, mode(text2img|single|multi|edit),
|
||
ref_images?[], scene_image?, role_images?[], n?, size?, model?, confirm_cost }
|
||
"""
|
||
if not data.get("confirm_cost"):
|
||
raise HTTPException(400, detail="⛔ 生成关键帧会真实扣费(约 ¥0.2/张),需显式确认(confirm_cost=true)")
|
||
pid = str(data.get("project_id") or "").strip()
|
||
sid = str(data.get("shot_id") or "").strip()
|
||
prompt = (data.get("prompt") or "").strip()
|
||
frame_seq = str(data.get("frame_seq") or "").strip() # ★ A / B / C …
|
||
plan = _kf_plan_of(pid, sid) if (pid and sid) else {}
|
||
frame_role = ""
|
||
if frame_seq:
|
||
# 按「关键帧设计」里该帧的内容自动组装提示词(设计层 → 生成层)
|
||
fr = next((x for x in (plan.get("frames") or [])
|
||
if str(x.get("seq")) == frame_seq), None)
|
||
if not fr:
|
||
raise HTTPException(400, detail=f"设计里没有帧 {frame_seq}(先到「🖼 关键帧设计」生成方案)")
|
||
frame_role = fr.get("role") or ""
|
||
if not prompt:
|
||
prompt = _kf_frame_prompt(pid, sid, frame_seq)
|
||
if not pid or not sid or not prompt:
|
||
raise HTTPException(400, detail="project_id / shot_id 必填;prompt 空则需指定 frame_seq")
|
||
|
||
mode = data.get("mode") or "multi"
|
||
refs: list[str] = []
|
||
if mode != "text2img":
|
||
refs += [r for r in (data.get("role_images") or []) if r]
|
||
if data.get("scene_image"):
|
||
refs.append(data["scene_image"])
|
||
refs += [r for r in (data.get("ref_images") or []) if r]
|
||
if mode == "edit" and not refs:
|
||
raise HTTPException(400, detail="改造模式需要至少 1 张底图(ref_images)")
|
||
|
||
try:
|
||
import keyframe_gen
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"keyframe_gen 模块加载失败: {e}")
|
||
|
||
# 模型:前端可显式指定;否则按「生成方式」自动选
|
||
# multi/text2img/single → wan2.7-image-pro(融合强)
|
||
# edit → qwen-image-edit-plus(结构保持重绘)
|
||
model = data.get("model") or keyframe_gen.model_for_mode(mode)
|
||
try:
|
||
urls = keyframe_gen.build_keyframe(
|
||
prompt, refs,
|
||
negative_prompt=(data.get("negative") or ""),
|
||
size=(data.get("size") or keyframe_gen.DEFAULT_SIZE),
|
||
n=int(data.get("n") or 1),
|
||
model=model,
|
||
)
|
||
except Exception as e:
|
||
log.warning("关键帧生成失败: %s", e)
|
||
raise HTTPException(500, detail=f"生成失败: {type(e).__name__}: {str(e)[:200]}")
|
||
|
||
sdir = _shot_dir(pid, sid)
|
||
existing = sorted(sdir.glob("v*.json"))
|
||
start = len(existing) + 1
|
||
saved = []
|
||
for i, u in enumerate(urls):
|
||
v = start + i
|
||
png = sdir / f"v{v}.png"
|
||
try:
|
||
keyframe_gen.download(u, png)
|
||
except Exception as e:
|
||
log.warning("下载第 %d 版失败: %s", v, e)
|
||
continue
|
||
meta = {"version": v, "model": model,
|
||
"size": data.get("size") or keyframe_gen.DEFAULT_SIZE, "prompt": prompt,
|
||
"negative": data.get("negative") or "",
|
||
"mode": mode, "n_refs": len(refs), "refs": [Path(r).name for r in refs],
|
||
# ★ 帧标识(来自关键帧设计 A/B/C)—— 视频标签据此选首/尾帧
|
||
"frame_seq": frame_seq, "role": frame_role,
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"adopted": False, "url_src": u}
|
||
(sdir / f"v{v}.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
saved.append(v)
|
||
_refresh_index(pid)
|
||
note = f"生成 {len(saved)} 版;在界面上选一版「采用」"
|
||
if frame_seq:
|
||
note = f"生成关键帧 {frame_seq}({frame_role or '—'}){len(saved)} 版"
|
||
return {"status": "ok", "versions": saved, "frame_seq": frame_seq,
|
||
"role": frame_role, "prompt_used": prompt, "note": note}
|
||
|
||
|
||
@router.post("/adopt")
|
||
def api_kf_adopt(data: dict):
|
||
"""采用某一版(同一镜头只有一个 adopted)"""
|
||
pid, sid, v = str(data.get("project_id") or ""), str(data.get("shot_id") or ""), data.get("version")
|
||
if not (pid and sid and v):
|
||
raise HTTPException(400, detail="project_id / shot_id / version 必填")
|
||
sdir = _shot_dir(pid, sid)
|
||
found = False
|
||
for jf in sdir.glob("v*.json"):
|
||
m = _read_json(jf) or {}
|
||
m["adopted"] = (int(m.get("version") or -1) == int(v))
|
||
if m["adopted"]:
|
||
found = True
|
||
jf.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
if not found:
|
||
raise HTTPException(404, detail=f"未找到版本 v{v}")
|
||
_refresh_index(pid)
|
||
return {"status": "ok", "adopted": int(v)}
|
||
|
||
|
||
@router.post("/discard")
|
||
def api_kf_discard(data: dict):
|
||
"""丢弃某一版(同时删图与元数据)"""
|
||
pid, sid, v = str(data.get("project_id") or ""), str(data.get("shot_id") or ""), data.get("version")
|
||
if not (pid and sid and v):
|
||
raise HTTPException(400, detail="project_id / shot_id / version 必填")
|
||
sdir = _shot_dir(pid, sid)
|
||
n = 0
|
||
for suf in (".png", ".json"):
|
||
f = sdir / f"v{v}{suf}"
|
||
if f.exists():
|
||
f.unlink()
|
||
n += 1
|
||
_refresh_index(pid)
|
||
return {"status": "ok", "removed": n}
|
||
|
||
|
||
@router.get("/file")
|
||
def api_kf_file(path: str):
|
||
"""读取关键帧图片(限制在 keyframes 目录内)"""
|
||
p = (KF_ROOT / path).resolve()
|
||
if not _safe_under(KF_ROOT, p) or not p.exists() or not p.is_file():
|
||
raise HTTPException(404, detail=f"文件不存在: {path}")
|
||
return FileResponse(str(p), media_type="image/png")
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# 第③步:关键帧 → 视频
|
||
# ══════════════════════════════════════════════════════════
|
||
VID_ROOT = _AGENT_LOCAL / "runtime" / "ave" / "keyframe_videos"
|
||
KF_ROOT_V = KF_ROOT
|
||
_VID_JOBS: dict = {} # 内存态进度(重启即清,历史看 job.json)
|
||
|
||
|
||
def _video_models() -> list[dict]:
|
||
try:
|
||
import yaml
|
||
p = _AGENT_SYNC / "05_tools" / "09_ave" / "config" / "video_models.yaml"
|
||
d = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||
return d.get("models") or []
|
||
except Exception as e:
|
||
log.warning("读取 video_models.yaml 失败: %s", e)
|
||
return []
|
||
|
||
|
||
def _video_dir(pid: str, sid: str) -> Path:
|
||
d = VID_ROOT / str(pid) / str(sid)
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _seg_ver(fname: str) -> tuple[int, int]:
|
||
"""文件名 → (片段号, 版本号)
|
||
|
||
新格式:`s1_v2.mp4` → (1, 2)
|
||
老格式:`v2.mp4` → (1, 2) ← 向后兼容(老产物视为第 1 段)
|
||
"""
|
||
import re as _re
|
||
m = _re.match(r"^s(\d+)_v(\d+)", str(fname))
|
||
if m:
|
||
return int(m.group(1)), int(m.group(2))
|
||
m = _re.match(r"^v(\d+)", str(fname))
|
||
if m:
|
||
return 1, int(m.group(1))
|
||
return 1, 0
|
||
|
||
|
||
def _segments_of(pid: str, sid: str) -> list[dict]:
|
||
"""把一个镜头的视频按「片段」归组(片段 = 首帧→尾帧的一段视频)
|
||
|
||
为什么要有片段概念:关键帧是 A→B→C→D,
|
||
那 A→B 一段、B→C 一段、C→D 一段 —— **拼起来才是这一镜的完整内容**。
|
||
之前只有「版本」(同一段生成多次挑一个),语义不对。
|
||
"""
|
||
d = VID_ROOT / str(pid) / str(sid)
|
||
segs: dict[int, list] = {}
|
||
if d.is_dir():
|
||
for jf in sorted(d.glob("*.json")):
|
||
stem = jf.stem
|
||
if stem == "concat":
|
||
continue
|
||
seg, ver = _seg_ver(stem)
|
||
mp4 = jf.with_suffix(".mp4")
|
||
m = _read_json(jf) or {}
|
||
segs.setdefault(seg, []).append({
|
||
"version": ver, "ok": mp4.exists(),
|
||
"url": (f"/api/ave/keyframe/video/file?path={pid}/{sid}/{mp4.name}"
|
||
if mp4.exists() else ""),
|
||
"model": m.get("model", ""), "duration": m.get("duration", ""),
|
||
"created_at": m.get("created_at", ""),
|
||
"adopted": bool(m.get("adopted")),
|
||
"first_frame": m.get("first_frame", ""),
|
||
"tail_frame": m.get("tail_frame", ""),
|
||
"tail_mode": m.get("tail_mode", ""),
|
||
"tts": m.get("tts", False), "lipsync": m.get("lipsync", False),
|
||
})
|
||
out = []
|
||
for seg in sorted(segs):
|
||
vs = sorted(segs[seg], key=lambda x: x["version"])
|
||
out.append({
|
||
"segment": seg,
|
||
"versions": vs,
|
||
"adopted": next((x["version"] for x in vs if x["adopted"]), None),
|
||
})
|
||
return out
|
||
|
||
|
||
def _concat_path(pid: str, sid: str) -> Path:
|
||
return VID_ROOT / str(pid) / str(sid) / "concat.json"
|
||
|
||
|
||
# ── 项目级设置(音色/台词/参数 暂存,切镜头不丢)────────────
|
||
def _settings_path(pid: str) -> Path:
|
||
return VID_ROOT / str(pid) / "settings.json"
|
||
|
||
|
||
def _load_settings(pid: str) -> dict:
|
||
p = _settings_path(pid)
|
||
if p.exists():
|
||
try:
|
||
return json.loads(p.read_text(encoding="utf-8")) or {}
|
||
except Exception:
|
||
pass
|
||
return {"shots": {}, "defaults": {}}
|
||
|
||
|
||
def _save_settings(pid: str, data: dict) -> None:
|
||
p = _settings_path(pid)
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
# ── 音色智能绑定:性别 → 音色池 ────────────────────────────
|
||
_FEMALE_HINTS = ("女", "女主", "妈", "姐", "妹", "护士", "姑娘", "女孩", "她", "少妇", "婆婆")
|
||
_MALE_HINTS = ("男", "男主", "爸", "哥", "弟", "医生", "大叔", "小伙", "他", "爷爷", "叔叔")
|
||
|
||
|
||
def _detect_gender(*texts) -> str:
|
||
"""从角色描述/备注里判断性别(用于自动配声)"""
|
||
blob = " ".join(str(t or "") for t in texts)
|
||
if any(h in blob for h in _FEMALE_HINTS):
|
||
return "female"
|
||
if any(h in blob for h in _MALE_HINTS):
|
||
return "male"
|
||
return ""
|
||
|
||
|
||
def _voice_pools() -> dict:
|
||
try:
|
||
import yaml as _y
|
||
vp = _AGENT_SYNC / "05_tools" / "09_ave" / "config" / "cosyvoice_voices.yaml"
|
||
return (_y.safe_load(vp.read_text(encoding="utf-8")) or {}).get("pools") or {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _character_status(aid: str) -> dict:
|
||
"""检查角色资产真实可用性 —— 目录不存在 / 空壳 / 无参考图 都要能识别出来
|
||
|
||
返回 {asset_id, exists, has_meta, has_image, name, problem}
|
||
"""
|
||
if not aid:
|
||
return {"asset_id": "", "exists": False, "has_meta": False,
|
||
"has_image": False, "name": "", "problem": "未绑定角色"}
|
||
d = _AGENT_LOCAL / "tools" / "ave" / "assets" / "characters" / str(aid)
|
||
if not d.is_dir():
|
||
return {"asset_id": aid, "exists": False, "has_meta": False, "has_image": False,
|
||
"name": aid, "problem": "角色资产不存在(可能已被删除)"}
|
||
mj = d / "meta.json"
|
||
has_meta = mj.exists()
|
||
imgs = [p.name for p in d.glob("*.png")] + [p.name for p in d.glob("*.jpg")]
|
||
name = aid
|
||
if has_meta:
|
||
try:
|
||
m = json.loads(mj.read_text(encoding="utf-8"))
|
||
name = m.get("name") or m.get("id") or aid
|
||
except Exception:
|
||
pass
|
||
if not has_meta and not imgs:
|
||
return {"asset_id": aid, "exists": True, "has_meta": False, "has_image": False,
|
||
"name": name, "problem": "角色资产是空壳(无 meta、无参考图)—— 请到角色库补全或换成别的角色"}
|
||
if not imgs:
|
||
return {"asset_id": aid, "exists": True, "has_meta": has_meta, "has_image": False,
|
||
"name": name, "problem": "角色资产没有参考图 —— 无法用于图生视频/关键帧融合"}
|
||
return {"asset_id": aid, "exists": True, "has_meta": has_meta, "has_image": True,
|
||
"name": name, "problem": ""}
|
||
|
||
|
||
def _character_note(aid: str) -> str:
|
||
"""读角色资产的描述/备注(用于猜性别)"""
|
||
if not aid:
|
||
return ""
|
||
try:
|
||
m = json.loads((_AGENT_LOCAL / "tools" / "ave" / "assets" / "characters"
|
||
/ str(aid) / "meta.json").read_text(encoding="utf-8"))
|
||
return " ".join(str(m.get(k) or "") for k in ("description", "role", "name", "tags"))
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _resolve_voice(sb: dict, shot: dict, pid: str) -> dict:
|
||
"""决定这一镜该用什么音色 —— 优先顺序:
|
||
|
||
1. 项目设置里存过的(用户手动选过,最高优先)
|
||
2. 角色资产上绑定的音色
|
||
3. 演员表 cast[].voice_id
|
||
4. 按角色性别/备注自动匹配音色池(女→female_lead / 男→male_lead)
|
||
5. 兜底默认
|
||
"""
|
||
sid = str(shot.get("shot_id") or "")
|
||
st = (_load_settings(pid).get("shots") or {}).get(sid) or {}
|
||
# ② 项目级「角色 → 音色」绑定(一个角色全片一个声音)
|
||
cast_voices = (_load_settings(pid).get("cast_voices") or {})
|
||
for m in (sb.get("global_anchors") or {}).get("cast") or []:
|
||
aid = str(m.get("asset_id") or "")
|
||
if not (aid and cast_voices.get(aid)):
|
||
continue
|
||
# ★ 绑定的角色若已失效(被删/空壳),这条绑定不该继续生效 —— 否则"假绑定"
|
||
cs = _character_status(aid)
|
||
if cs["problem"]:
|
||
return {"voice_id": "", "source": f"⚠️ 绑定的角色「{cs['name']}」已失效:{cs['problem']}",
|
||
"auto": True, "cast_id": aid, "stale": True, "cast_status": cs}
|
||
return {"voice_id": cast_voices[aid],
|
||
"source": f"角色绑定({cs['name'] or m.get('name') or m.get('slot') or aid})",
|
||
"auto": False, "cast_id": aid, "cast_status": cs}
|
||
if st.get("voice_id"):
|
||
return {"voice_id": st["voice_id"], "source": "本镜单独指定", "auto": False}
|
||
|
||
ga = sb.get("global_anchors") or {}
|
||
cast = ga.get("cast") or []
|
||
slot = (shot.get("cast_slots") or [None])[0] if shot.get("cast_slots") else None
|
||
member = next((c for c in cast if c.get("slot") == slot), None) or (cast[0] if cast else {})
|
||
|
||
# ② 演员表绑的音色
|
||
if member.get("voice_id"):
|
||
return {"voice_id": member["voice_id"], "source": f"演员表绑定({member.get('slot')})", "auto": True}
|
||
|
||
# ③ 猜性别:角色描述 > 演员表备注 > 镜头文本
|
||
note = _character_note(member.get("asset_id") or shot.get("character_id") or "")
|
||
gender = _detect_gender(note, member.get("note"), shot.get("visual_description"), shot.get("text"))
|
||
pools = _voice_pools()
|
||
pool_name = ("female_lead" if gender == "female" else "male_lead") if gender else ""
|
||
if pool_name and pools.get(pool_name):
|
||
who = member.get("name") or member.get("asset_id") or slot or "角色"
|
||
return {"voice_id": pools[pool_name][0],
|
||
"source": f"按性别自动匹配 · {who}({'女' if gender == 'female' else '男'})",
|
||
"auto": True, "gender": gender}
|
||
|
||
return {"voice_id": (pools.get("male_lead") or ["longmiao_v3"])[0],
|
||
"source": "默认(未能判断性别)", "auto": True}
|
||
|
||
|
||
def _motion_prompt(sb: dict, shot: dict) -> str:
|
||
"""运动提示词草稿 —— 图生视频**只写怎么动**,不重描述视觉(可灵官方守则)
|
||
|
||
⚠️ 必须带**机位/运镜**(camera_move + angle):
|
||
不带的话模型会自由发挥(实测用户反馈:分镜写「固定机位」,
|
||
生成结果却是旋转镜头)—— 机位约束是「怎么动」的一部分,属于运动描述。
|
||
|
||
带上有台词的镜头:把「说话」写进动作里(原生有声模式下即语音内容)
|
||
"""
|
||
parts = []
|
||
act = (shot.get("action") or "").strip()
|
||
if act:
|
||
parts.append(act)
|
||
|
||
# ★ 机位与运镜(决定画面稳不稳,必须写清,否则模型乱动镜头)
|
||
sc = shot.get("shot_contract") or {}
|
||
cm = (sc.get("camera_move") or "").strip()
|
||
if cm:
|
||
parts.append(f"镜头:{cm}")
|
||
ang = (sc.get("angle") or "").strip()
|
||
if ang:
|
||
parts.append(f"机位:{ang}")
|
||
ss = (sc.get("shot_size") or "").strip()
|
||
if ss:
|
||
parts.append(f"景别:{ss}")
|
||
|
||
expr = shot.get("expression_detail") or {}
|
||
if isinstance(expr, dict):
|
||
for k in ("eyes", "eye", "mouth", "brow", "micro_expression", "body"):
|
||
if expr.get(k):
|
||
parts.append(str(expr[k]))
|
||
if not parts and (shot.get("visual_description") or ""):
|
||
parts.append("画面中人物自然呼吸,轻微动作") # 兜底:别让提示词为空
|
||
|
||
prompt = ",".join(parts)
|
||
|
||
# ★★ 台词(原生有声模式的关键!不写台词 = 模型自己编,实测编成英语)
|
||
# 规范见 docs/realism/07-特殊字符规范.md:
|
||
# 台词用 { } 包裹;外语台词须标注语种(用日语说道{こんにちは})
|
||
# 语言需统一,避免中英文混用
|
||
txt = (shot.get("text") or "").strip()
|
||
if txt:
|
||
emo = (shot.get("sentiment") or "").strip()
|
||
lead = f"用{emo}的语气" if emo else "轻声"
|
||
prompt += f"。她{lead}说{{{txt}}}(中文,普通话)"
|
||
|
||
return prompt
|
||
|
||
|
||
def _kf_plan_of(pid: str, sid: str) -> dict:
|
||
"""从「🖼 关键帧设计」取这一镜的帧设计(A/B/C…)
|
||
|
||
⚠️ 这是设计层与生成层的**唯一衔接点**:
|
||
没有它,关键帧工作台就只能在真空中生成图(之前就是这样,链路是断的)。
|
||
"""
|
||
try:
|
||
pf = (_AGENT_LOCAL / "runtime" / "ave" / "projects" / str(pid) / "project.json")
|
||
if not pf.exists():
|
||
return {}
|
||
o = json.loads(pf.read_text(encoding="utf-8"))
|
||
return ((o.get("keyframe_plan") or {}).get(str(sid))) or {}
|
||
except Exception as e:
|
||
log.warning("读关键帧设计 %s#%s 失败: %s", pid, sid, e)
|
||
return {}
|
||
|
||
|
||
def _kf_frame_prompt(pid: str, sid: str, seq: str) -> str:
|
||
"""把设计里某一帧(A/B/C)组装成图像生成提示词
|
||
|
||
依据 06 Skill §15 六要素:功能/画面/动作/表情/镜头/与下一帧关系
|
||
⚠️ §13:画面里**不要出现「关键帧A」这类字样**(会被模型学进图)
|
||
"""
|
||
plan = _kf_plan_of(pid, sid)
|
||
frames = plan.get("frames") or []
|
||
f = next((x for x in frames if str(x.get("seq")) == str(seq)), None)
|
||
if not f:
|
||
return ""
|
||
# ★ 只取「这一张图里看得见的东西」:画面/动作/表情/镜头
|
||
# ❌ 不要 next_rel(与下一帧的关系)—— 那是两帧之间的叙事关系,
|
||
# 喂给**单张图**的生成模型是纯噪音,还可能让它画出"过渡动作"
|
||
bits = [x for x in (f.get("visual"), f.get("action"), f.get("expression"),
|
||
f.get("camera")) if x]
|
||
if plan.get("consistency"):
|
||
bits.append(str(plan["consistency"]))
|
||
return ",".join(bits)
|
||
|
||
|
||
def _kf_generated_frames(pid: str, sid: str) -> list[dict]:
|
||
"""这一镜**已生成**的关键帧(按帧标识归组),供视频标签选首/尾帧
|
||
|
||
⚠️ 只读:直接用 KF_ROOT 拼路径,不走 _shot_dir()(那个会 mkdir 产生副作用)
|
||
"""
|
||
out = []
|
||
if not (pid and sid):
|
||
return out
|
||
d = KF_ROOT / str(pid) / str(sid)
|
||
if not d.is_dir():
|
||
return out
|
||
for jf in sorted(d.glob("v*.json")):
|
||
m = _read_json(jf) or {}
|
||
png = jf.with_suffix(".png")
|
||
if not png.exists():
|
||
continue
|
||
out.append({
|
||
"version": m.get("version"),
|
||
"frame_seq": m.get("frame_seq") or "",
|
||
"role": m.get("role") or "",
|
||
"adopted": bool(m.get("adopted")),
|
||
"url": f"/api/ave/keyframe/file?path={pid}/{sid}/{png.name}",
|
||
"prompt": (m.get("prompt") or "")[:60],
|
||
})
|
||
return out
|
||
|
||
|
||
def _storyboard_shot_of(pid: str, no: int) -> dict:
|
||
"""从「🎬 故事板」标签取第 no 格的规划(镜头功能/转场接口/重点)
|
||
|
||
依据 AI-Animation-Studio 05 §13 的逐格八要素;这些是「为什么有这个镜头」
|
||
和「怎么接下一镜」,分镜表里原本没有,显示出来才能对照。
|
||
"""
|
||
try:
|
||
pf = (_AGENT_LOCAL / "runtime" / "ave" / "projects" / str(pid) / "project.json")
|
||
if not pf.exists():
|
||
return {}
|
||
o = json.loads(pf.read_text(encoding="utf-8"))
|
||
shots = ((o.get("storyboard_paper") or {}).get("shots")) or []
|
||
for s in shots:
|
||
if int(s.get("no") or 0) == int(no):
|
||
return s
|
||
except Exception as e:
|
||
log.warning("读故事板格 %s#%s 失败: %s", pid, no, e)
|
||
return {}
|
||
|
||
|
||
# 拍摄风格代号 → 图像模型能看懂的风格短语
|
||
# (图模型不认识 documentary_realism 这类内部代号,必须换成描述)
|
||
_STYLE_PHRASE = {
|
||
"documentary_realism": "纪实摄影,自然光,随手抓拍感,无影棚布光",
|
||
"cinematic": "电影感静帧,戏剧性打光,浅景深,考究构图",
|
||
"vlog_handheld": "手持 Vlog 视角,手机拍摄,轻微晃动",
|
||
"studio_clean": "棚拍,均匀柔光,干净背景",
|
||
"selfie_pov": "手机前置自拍视角,手持一臂距离,构图略微歪斜",
|
||
"interview_doc": "访谈纪实,半身对镜,柔和主光",
|
||
"anime_2d": "2D 动画赛璐璐风格,清晰描线,平涂上色",
|
||
"anime_3d": "3D 卡通渲染,次表面散射,柔和光照",
|
||
"product_ad": "产品广告摄影,精致布光,特写质感",
|
||
}
|
||
|
||
|
||
def _shot_detail(shot: dict, sb: dict) -> dict:
|
||
"""这一镜的**全部导演意图**(只读展示用)—— 第③步右侧「分镜原貌」
|
||
|
||
分四层(与第②步关键帧工作台一致):
|
||
intent 意图:画面描述/动作/情绪/台词/微表情
|
||
camera 拍摄:景别/机位/运镜/镜头类型/转场/速度
|
||
looks 质感:调色/后期(去AI化)
|
||
assets 资产:场景/出场角色/连续性
|
||
"""
|
||
sc = shot.get("shot_contract") or {}
|
||
ga = sb.get("global_anchors") or {}
|
||
loc_id = shot.get("location_id") or ga.get("location_id") or ""
|
||
loc_name = loc_id
|
||
try:
|
||
lj = (_AGENT_LOCAL / "tools" / "ave" / "assets" / "locations" / str(loc_id) / "meta.json")
|
||
if lj.exists():
|
||
loc_name = (json.loads(lj.read_text(encoding="utf-8")).get("name") or loc_id)
|
||
except Exception:
|
||
pass
|
||
|
||
cast_names = []
|
||
for slot in (shot.get("cast_slots") or []):
|
||
c = next((x for x in (ga.get("cast") or []) if x.get("slot") == slot), None)
|
||
if c:
|
||
cast_names.append({"slot": slot, "name": c.get("name") or c.get("asset_id"),
|
||
"asset_id": c.get("asset_id")})
|
||
|
||
tr = shot.get("tech_requirements") or {}
|
||
# ⚠️ 键名必须同时满足两个消费者,否则会出现「某一层永远空」的隐性 bug:
|
||
# · 关键帧工作台(ave-keyframes.js)读 intent / params / asset / global
|
||
# · 视频标签(ave-keyframe-video.js)读 intent / camera / looks / assets
|
||
# 两套都返回,value 指向同一对象。
|
||
_intent = {
|
||
"visual_description": shot.get("visual_description") or "",
|
||
"action": shot.get("action") or "",
|
||
"sentiment": shot.get("sentiment") or "",
|
||
"text": shot.get("text") or "",
|
||
"expression_detail": shot.get("expression_detail") or {},
|
||
}
|
||
_camera = {
|
||
"type": shot.get("type") or "",
|
||
"shot_size": sc.get("shot_size") or "",
|
||
"angle": sc.get("angle") or "",
|
||
"camera_move": sc.get("camera_move") or "",
|
||
"lens": sc.get("lens") or "",
|
||
"composition": sc.get("composition") or "",
|
||
"transition_in": (shot.get("transition_in") or {}).get("type") or "",
|
||
"transition_out": (shot.get("transition_out") or {}).get("type") or "",
|
||
"speed": (shot.get("speed") or {}).get("factor") or 1.0,
|
||
}
|
||
_looks = {
|
||
"color": shot.get("color") or {},
|
||
"post_process": shot.get("post_process") or {},
|
||
"audio": shot.get("audio") or {},
|
||
}
|
||
_assets = {
|
||
"location_id": loc_id,
|
||
"location_name": loc_name,
|
||
"cast": cast_names,
|
||
"continuity_mode": shot.get("continuity_mode") or "",
|
||
"continuity": tr.get("continuity") or {},
|
||
}
|
||
_global = {
|
||
"project_id": sb.get("project_id"),
|
||
"title": sb.get("title"),
|
||
"director": sb.get("director_id") or "",
|
||
"realism_boost": ga.get("realism_boost") or "",
|
||
"shooting_style": ga.get("shooting_style") or "",
|
||
"cast": ga.get("cast") or [],
|
||
}
|
||
return {
|
||
# 通用四层(视频标签用 camera/looks/assets)
|
||
"intent": _intent,
|
||
"camera": _camera,
|
||
"looks": _looks,
|
||
"assets": _assets,
|
||
# 兼容别名(关键帧工作台用 params/asset/global)
|
||
"params": _camera,
|
||
"asset": _assets,
|
||
"global": _global,
|
||
# 平铺(旧调用方)
|
||
"visual_description": _intent["visual_description"],
|
||
"action": _intent["action"],
|
||
"sentiment": _intent["sentiment"],
|
||
"text": _intent["text"],
|
||
"type": _camera["type"],
|
||
"shot_size": _camera["shot_size"],
|
||
"angle": _camera["angle"],
|
||
"camera_move": _camera["camera_move"],
|
||
}
|
||
|
||
|
||
def _adopted_kf(pid: str, sid: str) -> str:
|
||
"""本镜的关键帧路径(优先「采用」,无则回退最新版)"""
|
||
p, _ = _kf_with_source(pid, sid)
|
||
return p
|
||
|
||
|
||
def _kf_of_frame(pid: str, sid: str, seq: str) -> str:
|
||
"""取**指定帧**(A/B/C)的已采用关键帧路径
|
||
|
||
用于视频标签选首帧:「这一镜用 A 帧还是 B 帧当首帧」
|
||
"""
|
||
if not seq:
|
||
return ""
|
||
d = KF_ROOT / str(pid) / str(sid)
|
||
if not d.is_dir():
|
||
return ""
|
||
best = None
|
||
for jf in sorted(d.glob("v*.json")):
|
||
m = _read_json(jf) or {}
|
||
if str(m.get("frame_seq") or "") != str(seq):
|
||
continue
|
||
png = jf.with_suffix(".png")
|
||
if not png.exists():
|
||
continue
|
||
if m.get("adopted"):
|
||
return str(png)
|
||
best = str(png) # 没有采用的就取最新一版
|
||
return best or ""
|
||
|
||
|
||
def _kf_with_source(pid: str, sid: str) -> tuple[str, str]:
|
||
"""→ (路径, 来源) 来源: adopted / latest / ''"""
|
||
try:
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
from composer.pipeline import StoryboardPipeline as SP
|
||
return SP._keyframe_of_shot(pid, sid, fallback=True)
|
||
except Exception as e:
|
||
log.warning("取关键帧失败 %s/%s: %s", pid, sid, e)
|
||
return "", ""
|
||
|
||
|
||
def _last_frame_of(pid: str, sid: str) -> str:
|
||
"""取某镜已生成视频的末帧(跨镜衔接用)—— 有缓存就复用"""
|
||
vdir = VID_ROOT / str(pid) / str(sid)
|
||
if not vdir.is_dir():
|
||
return ""
|
||
for jf in sorted(vdir.glob("v*.json"), 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():
|
||
continue
|
||
cached = vdir / f"tail_{m.get('version')}.png"
|
||
if cached.exists():
|
||
return str(cached)
|
||
try:
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
# ⚠️ 函数名是 extract_tail_frame(不是 extract_last_frame)—— 已实测确认
|
||
from composer.frames import extract_tail_frame
|
||
extract_tail_frame(str(mp4), str(cached))
|
||
if cached.exists():
|
||
return str(cached)
|
||
except Exception as e:
|
||
log.warning("抽尾帧失败 %s: %s", mp4.name, e)
|
||
return ""
|
||
|
||
|
||
@router.get("/video/models")
|
||
def api_kf_video_models():
|
||
"""视频模型注册表(含能力注释与价格)"""
|
||
ms = _video_models()
|
||
return {"status": "ok", "models": ms,
|
||
"supports_labels": {"i2v": "图生视频", "kf2v": "首尾帧", "r2v": "多图参考",
|
||
"t2v": "文生视频"}}
|
||
|
||
|
||
@router.get("/video/shots")
|
||
def api_kf_video_shots(project_id: str):
|
||
"""第③步镜头清单:关键帧状态(首帧是否就绪)+ 视频版本"""
|
||
sb = _load_storyboard(project_id) or {}
|
||
shots = sb.get("shots") or []
|
||
out = []
|
||
for s in shots:
|
||
sid = str(s.get("shot_id") or "")
|
||
if not sid:
|
||
continue
|
||
kf, kf_src = _kf_with_source(project_id, sid)
|
||
segs = _segments_of(project_id, sid)
|
||
versions = [] # 扁平列表(向后兼容旧前端)
|
||
for sg in segs:
|
||
for v0 in sg["versions"]:
|
||
versions.append({**v0, "segment": sg["segment"]})
|
||
_cj = _read_json(_concat_path(project_id, sid)) or {}
|
||
_shot_mp4 = VID_ROOT / str(project_id) / sid / "shot.mp4"
|
||
sc = s.get("shot_contract") or {}
|
||
out.append({
|
||
"shot_id": sid,
|
||
"paragraph_no": s.get("paragraph_no"), # ★ 段落归属
|
||
"kf_ready": bool(kf),
|
||
"kf_source": kf_src, # adopted / latest / ''
|
||
"kf_url": (f"/api/ave/keyframe/file?path={project_id}/{sid}/"
|
||
f"{Path(kf).name}") if kf else "",
|
||
"text": (s.get("text") or ""), # 台词(配音用)
|
||
"visual": (s.get("visual_description") or "")[:60],
|
||
"action": (s.get("action") or "")[:80],
|
||
"duration": s.get("duration"),
|
||
"shot_size": sc.get("shot_size", ""),
|
||
"versions": versions,
|
||
"segments": segs, # ★ 片段结构(一镜多段:A→B、B→C…)
|
||
"segment_count": len(segs),
|
||
"concat": _cj, # 拼接清单(段号 → 采用的版本)
|
||
"shot_url": (f"/api/ave/keyframe/video/file?path={project_id}/{sid}/shot.mp4"
|
||
if _shot_mp4.exists() else ""),
|
||
"adopted": next((v["version"] for v in versions if v.get("adopted")), None),
|
||
})
|
||
return {"status": "ok", "project_id": project_id, "shots": out,
|
||
"has_storyboard": bool(shots),
|
||
"ready_count": sum(1 for x in out if x["kf_ready"])}
|
||
|
||
|
||
@router.get("/video/context")
|
||
def api_kf_video_context(project_id: str, shot_id: str):
|
||
"""单镜上下文:首帧(采用的关键帧)+ 可选尾帧 + 运动提示词"""
|
||
sb = _load_storyboard(project_id)
|
||
if not sb:
|
||
raise HTTPException(404, detail=f"未找到分镜表: {project_id}")
|
||
shot = _shot_of(sb, shot_id)
|
||
if not shot:
|
||
raise HTTPException(404, detail=f"未找到镜头: {shot_id}")
|
||
kf = _adopted_kf(project_id, shot_id)
|
||
# 上一镜(用于跨镜衔接)
|
||
order = [str(x.get("shot_id")) for x in (sb.get("shots") or [])]
|
||
idx = order.index(str(shot_id)) if str(shot_id) in order else -1
|
||
prev_sid = order[idx - 1] if idx > 0 else ""
|
||
prev_kf = _adopted_kf(project_id, prev_sid) if prev_sid else ""
|
||
# 台词 + 音色(智能解析:角色性别 → 音色池;用户选过的优先)
|
||
ga = sb.get("global_anchors") or {}
|
||
vr = _resolve_voice(sb, shot, project_id)
|
||
pools = _voice_pools()
|
||
voices = []
|
||
# 把「该角色的推荐音色」排在最前并标出
|
||
voices.append({"id": vr["voice_id"], "label": f"★ 推荐 · {vr['source']}"})
|
||
for pk, label in (("female_lead", "女主"), ("male_lead", "男主"),
|
||
("female_charm", "女主播"), ("male_mature", "沉稳男"),
|
||
("narrator", "旁白")):
|
||
for vid in (pools.get(pk) or []):
|
||
if vid != vr["voice_id"]:
|
||
voices.append({"id": vid, "label": f"{label}·{vid}"})
|
||
|
||
return {"status": "ok", "shot_id": shot_id,
|
||
"first_frame": kf,
|
||
"first_frame_url": (f"/api/ave/keyframe/file?path={project_id}/{shot_id}/"
|
||
f"{Path(kf).name}") if kf else "",
|
||
"prev_shot": prev_sid,
|
||
"prev_kf": prev_kf,
|
||
"motion_prompt": _motion_prompt(sb, shot),
|
||
"duration": shot.get("duration") or 5,
|
||
"text": shot.get("text") or "",
|
||
"detail": _shot_detail(shot, sb), # ★ 分镜原貌(导演给的全部意图)
|
||
"storyboard_shot": _storyboard_shot_of(project_id, idx + 1), # ★ 故事板对应格
|
||
"kf_plan": _kf_plan_of(project_id, shot_id), # ★ 关键帧设计(A/B/C 各帧)
|
||
# ★ 首帧/尾帧候选:本镜已生成的关键帧(按帧标识),可直接选来当首/尾帧
|
||
"generated_frames": _kf_generated_frames(project_id, shot_id),
|
||
"prev_frames": _kf_generated_frames(project_id, prev_sid) if prev_sid else [],
|
||
"seq": {"index": idx + 1, "total": len(order)}, # 第几镜 / 共几镜
|
||
"voices": voices,
|
||
"voice_auto": vr,
|
||
"saved": ((_load_settings(project_id).get("shots") or {}).get(str(shot_id)) or {}),
|
||
"cast": ga.get("cast") or [],
|
||
"cast_status": [_character_status(c.get("asset_id")) | {
|
||
"slot": c.get("slot"), "cast_name": c.get("name") or c.get("slot"),
|
||
} for c in (ga.get("cast") or [])],
|
||
"hint": ("" if kf else "⚠️ 这一镜还没有关键帧 —— 请先到第②步生成")}
|
||
|
||
|
||
@router.get("/video/characters")
|
||
def api_kf_video_characters():
|
||
"""角色库清单(供「更换角色」下拉用)—— 只列出真正可用的"""
|
||
base = _AGENT_LOCAL / "tools" / "ave" / "assets" / "characters"
|
||
out = []
|
||
if base.is_dir():
|
||
for d in sorted(base.iterdir()):
|
||
if not d.is_dir() or d.name.startswith("_") or d.name.startswith("."):
|
||
continue
|
||
cs = _character_status(d.name)
|
||
if cs["problem"]:
|
||
continue # 空壳/无图的不列出来
|
||
out.append({"asset_id": d.name, "name": cs["name"],
|
||
"images": len(list(d.glob("*.png"))) + len(list(d.glob("*.jpg")))})
|
||
return {"status": "ok", "characters": out}
|
||
|
||
|
||
@router.post("/video/rebind-cast")
|
||
def api_kf_video_rebind_cast(data: dict):
|
||
"""把演员表里失效的角色换成另一个角色(写回分镜表,自动备份)
|
||
|
||
body: { project_id, slot, asset_id }
|
||
"""
|
||
pid, slot, new_aid = (str(data.get("project_id") or ""), str(data.get("slot") or ""),
|
||
str(data.get("asset_id") or ""))
|
||
if not (pid and slot and new_aid):
|
||
raise HTTPException(400, detail="project_id / slot / asset_id 必填")
|
||
cs = _character_status(new_aid)
|
||
if cs["problem"]:
|
||
raise HTTPException(400, detail=f"目标角色不可用:{cs['problem']}")
|
||
|
||
jf = _storyboard_path(pid)
|
||
if not jf or not jf.exists():
|
||
raise HTTPException(404, detail=f"未找到分镜表: {pid}")
|
||
sb = json.loads(jf.read_text(encoding="utf-8"))
|
||
ga = sb.setdefault("global_anchors", {})
|
||
cast = ga.get("cast") or []
|
||
hit = next((c for c in cast if c.get("slot") == slot), None)
|
||
if not hit:
|
||
raise HTTPException(404, detail=f"演员表里没有这个位置: {slot}")
|
||
old = hit.get("asset_id")
|
||
hit["asset_id"] = new_aid
|
||
# 备份后写回
|
||
import shutil
|
||
shutil.copy2(jf, jf.with_suffix(".json.bak"))
|
||
jf.write_text(json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
log.info("演员表 %s 的 %s:%s → %s", pid, slot, old, new_aid)
|
||
return {"status": "ok", "slot": slot, "old": old, "new": new_aid, "backup": jf.name + ".bak"}
|
||
|
||
|
||
@router.post("/video/bind-voice")
|
||
def api_kf_video_bind_voice(data: dict):
|
||
"""把音色绑定到「角色」(不是绑到某个镜头)—— 该角色出场的所有镜头都用它
|
||
|
||
body: { project_id, asset_id, voice_id } # asset_id 为空 = 解除绑定
|
||
"""
|
||
pid = str(data.get("project_id") or "")
|
||
aid = str(data.get("asset_id") or "")
|
||
vid = str(data.get("voice_id") or "")
|
||
if not pid:
|
||
raise HTTPException(400, detail="project_id 必填")
|
||
s = _load_settings(pid)
|
||
cv = s.setdefault("cast_voices", {})
|
||
if not aid:
|
||
raise HTTPException(400, detail="asset_id 必填(要绑到哪个角色)")
|
||
# ★ 不允许绑到失效角色(否则就是"假绑定"—— 界面显示已绑,实际无声音)
|
||
cs = _character_status(aid)
|
||
if cs["problem"]:
|
||
raise HTTPException(400, detail=f"角色「{cs['name']}」不可用:{cs['problem']}")
|
||
if vid:
|
||
cv[aid] = vid
|
||
else:
|
||
cv.pop(aid, None)
|
||
_save_settings(pid, s)
|
||
return {"status": "ok", "asset_id": aid, "voice_id": vid, "cast_voices": cv}
|
||
|
||
|
||
@router.get("/video/settings")
|
||
def api_kf_video_settings(project_id: str):
|
||
"""读项目级设置(音色/台词/参数 暂存)"""
|
||
return {"status": "ok", "settings": _load_settings(project_id)}
|
||
|
||
|
||
@router.post("/video/settings")
|
||
def api_kf_video_settings_save(data: dict):
|
||
"""存设置(单镜或整项目默认值)—— 切镜头/刷新都不丢
|
||
|
||
body: { project_id, shot_id?, values:{...} | defaults:{...} }
|
||
"""
|
||
pid = str(data.get("project_id") or "")
|
||
if not pid:
|
||
raise HTTPException(400, detail="project_id 必填")
|
||
s = _load_settings(pid)
|
||
if data.get("defaults"):
|
||
s["defaults"] = {**(s.get("defaults") or {}), **data["defaults"]}
|
||
sid = str(data.get("shot_id") or "")
|
||
if sid and data.get("values"):
|
||
s.setdefault("shots", {})[sid] = {**(s.get("shots", {}).get(sid) or {}), **data["values"]}
|
||
_save_settings(pid, s)
|
||
return {"status": "ok", "saved": bool(sid), "settings": s}
|
||
|
||
|
||
@router.post("/video/generate-batch")
|
||
def api_kf_video_generate_batch(data: dict):
|
||
"""批量生成:把所有「有关键帧且还没生成视频」的镜头排队生成
|
||
|
||
body: { project_id, only_missing?:true, confirm_cost:true, overrides?:{model,duration,...} }
|
||
"""
|
||
if not data.get("confirm_cost"):
|
||
raise HTTPException(400, detail="⛔ 批量生成会真实扣费,需显式确认(confirm_cost=true)")
|
||
pid = str(data.get("project_id") or "")
|
||
sb = _load_storyboard(pid)
|
||
if not sb:
|
||
raise HTTPException(404, detail=f"未找到分镜表: {pid}")
|
||
s = _load_settings(pid)
|
||
only_missing = bool(data.get("only_missing", True))
|
||
overrides = data.get("overrides") or {}
|
||
|
||
targets = []
|
||
for shot in (sb.get("shots") or []):
|
||
sid = str(shot.get("shot_id") or "")
|
||
if not sid or not _adopted_kf(pid, sid):
|
||
continue
|
||
if only_missing:
|
||
vdir = VID_ROOT / pid / sid
|
||
if vdir.is_dir() and any(vdir.glob("v*.json")):
|
||
continue
|
||
st = (s.get("shots") or {}).get(sid) or {}
|
||
cfg = {**{k: v for k, v in (s.get("defaults") or {}).items()}, **st, **overrides}
|
||
targets.append({"shot_id": sid, "cfg": cfg})
|
||
|
||
if not targets:
|
||
return {"status": "ok", "queued": 0,
|
||
"note": "没有需要生成的镜头(要么没有关键帧,要么都已经生成过了)"}
|
||
|
||
run_id = uuid.uuid4().hex[:10]
|
||
job = {"run_id": run_id, "status": "running", "batch": True,
|
||
"total": len(targets), "done": 0, "failed": 0,
|
||
"items": [{"shot_id": t["shot_id"], "status": "pending"} for t in targets],
|
||
"log": [], "started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||
_VID_JOBS[run_id] = job
|
||
|
||
def _work():
|
||
for i, t in enumerate(targets):
|
||
sid, cfg = t["shot_id"], t["cfg"]
|
||
item = job["items"][i]
|
||
item["status"] = "running"
|
||
try:
|
||
job["log"].append(f"▶ [{i+1}/{len(targets)}] {sid}")
|
||
_generate_one(pid, sid, cfg, job)
|
||
item["status"] = "done"
|
||
job["done"] += 1
|
||
except Exception as e:
|
||
item["status"] = "failed"
|
||
item["error"] = f"{type(e).__name__}: {e}"
|
||
job["failed"] += 1
|
||
job["log"].append(f"❌ {sid}: {e}")
|
||
job["status"] = "done"
|
||
job["log"].append(f"🎉 批量完成:成功 {job['done']} · 失败 {job['failed']}")
|
||
|
||
import threading
|
||
threading.Thread(target=_work, daemon=True).start()
|
||
return {"status": "ok", "run_id": run_id, "queued": len(targets),
|
||
"shots": [t["shot_id"] for t in targets],
|
||
"note": "已排队,轮询 /video/job 看进度"}
|
||
|
||
|
||
@router.post("/video/generate")
|
||
def api_kf_video_generate(data: dict):
|
||
"""生成单个镜头的视频(真实扣费,需 confirm_cost=true)
|
||
|
||
body: { project_id, shot_id, motion_prompt?, model?, duration?, tail_mode?,
|
||
tail_image?, confirm_cost }
|
||
tail_mode: none | prev(上一镜末帧)| custom(自定义尾帧路径)
|
||
"""
|
||
if not data.get("confirm_cost"):
|
||
raise HTTPException(400, detail="⛔ 生成视频会真实扣费(按秒计费),需显式确认(confirm_cost=true)")
|
||
pid, sid = str(data.get("project_id") or ""), str(data.get("shot_id") or "")
|
||
if not (pid and sid):
|
||
raise HTTPException(400, detail="project_id / shot_id 必填")
|
||
|
||
first = _adopted_kf(pid, sid)
|
||
if not first:
|
||
raise HTTPException(400, detail="这一镜还没有「采用」的关键帧 —— 请先到第②步生成并采用")
|
||
|
||
model = data.get("model") or "kling-v3"
|
||
info = next((m for m in _video_models() if m.get("id") == model), {})
|
||
if info.get("status"):
|
||
raise HTTPException(400, detail=f"模型「{info.get('name', model)}」尚未接线({info['status']})")
|
||
|
||
# 尾帧
|
||
tail = ""
|
||
tm = (data.get("tail_mode") or "none").strip()
|
||
if tm == "prev":
|
||
order = []
|
||
sb = _load_storyboard(pid) or {}
|
||
order = [str(x.get("shot_id")) for x in (sb.get("shots") or [])]
|
||
i = order.index(sid) if sid in order else -1
|
||
if i > 0:
|
||
tail = _last_frame_of(pid, order[i - 1])
|
||
elif tm == "custom":
|
||
tail = str(data.get("tail_image") or "")
|
||
|
||
out_dir = _video_dir(pid, sid)
|
||
run_id = uuid.uuid4().hex[:10]
|
||
job = {"run_id": run_id, "status": "running", "shot_id": sid, "model": model,
|
||
"started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "log": []}
|
||
_VID_JOBS[run_id] = job
|
||
cfg = {**data, "tail_resolved": tail, "tail_mode": tm}
|
||
|
||
def _work():
|
||
try:
|
||
_generate_one(pid, sid, cfg, job)
|
||
job["status"] = "done"
|
||
except Exception as e:
|
||
job["status"] = "failed"
|
||
job["error"] = f"{type(e).__name__}: {e}"
|
||
log.warning("关键帧转视频失败 %s/%s: %s", pid, sid, e)
|
||
|
||
import threading
|
||
threading.Thread(target=_work, daemon=True).start()
|
||
return {"status": "ok", "run_id": run_id, "note": "已提交,轮询 /video/job 查看进度"}
|
||
|
||
|
||
def _generate_one(pid: str, sid: str, cfg: dict, job: dict) -> None:
|
||
"""生成单个镜头视频(单镜与批量共用)
|
||
|
||
cfg 可含:motion_prompt / model / duration / tts / lipsync / text / voice_id
|
||
tail_mode / tail_image / tail_resolved
|
||
"""
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
from capabilities.kling_video import KlingVideoClient
|
||
|
||
# 首帧:可指定用哪一帧(A/B/C);默认用「已采用」的那张
|
||
first = _kf_of_frame(pid, sid, cfg.get("first_frame_seq") or "") or _adopted_kf(pid, sid)
|
||
if not first:
|
||
raise RuntimeError(f"{sid} 没有「采用」的关键帧")
|
||
|
||
model = cfg.get("model") or "kling-v3"
|
||
dur = str(cfg.get("duration") or 5)
|
||
tm = (cfg.get("tail_mode") or "none").strip()
|
||
|
||
# 尾帧解析
|
||
tail = cfg.get("tail_resolved") or ""
|
||
if not tail and tm == "custom":
|
||
tail = str(cfg.get("tail_image") or "")
|
||
if not tail and tm == "prev":
|
||
sb = _load_storyboard(pid) or {}
|
||
order = [str(x.get("shot_id")) for x in (sb.get("shots") or [])]
|
||
i = order.index(sid) if sid in order else -1
|
||
if i > 0:
|
||
tail = _last_frame_of(pid, order[i - 1])
|
||
if not tail and tm == "frame": # 用本镜的某一张关键帧当尾帧
|
||
tail = _kf_of_frame(pid, sid, cfg.get("tail_seq") or "")
|
||
if not tail and tm == "prevframe": # 用上一镜的某一张关键帧当尾帧
|
||
sb = _load_storyboard(pid) or {}
|
||
order = [str(x.get("shot_id")) for x in (sb.get("shots") or [])]
|
||
i = order.index(sid) if sid in order else -1
|
||
if i > 0:
|
||
tail = _kf_of_frame(pid, order[i - 1], cfg.get("tail_seq") or "")
|
||
if tail and job is not None:
|
||
job["log"].append(f"🔗 衔接尾帧:{Path(tail).name}")
|
||
|
||
out_dir = _video_dir(pid, sid)
|
||
# ★ 片段号:一个镜头可由多段视频拼成(A→B、B→C…)
|
||
# 不传则是第 1 段(向后兼容老调用)
|
||
seg = max(1, int(cfg.get("segment") or 1))
|
||
_same = [f for f in out_dir.glob("*.json")
|
||
if f.stem != "concat" and _seg_ver(f.stem)[0] == seg]
|
||
v = len(_same) + 1
|
||
base = f"s{seg}_v{v}" # 产物基名(片段_版本)
|
||
dest = out_dir / f"{base}.mp4"
|
||
|
||
# 按 channel 分流:kling(可灵) vs dashscope(百炼/万相)
|
||
info = next((m for m in _video_models() if m.get("id") == model), {})
|
||
channel = info.get("channel") or "kling"
|
||
|
||
if channel == "dashscope":
|
||
# ── 百炼通道(¥0.1/秒 vs 可灵 ¥0.6/秒)──
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
from capabilities import dashscope_video as dv
|
||
if not dv.available():
|
||
raise RuntimeError("百炼未配置(agent-local/tools/ave/config/local.yaml → aliyun.api_key)")
|
||
if not tail:
|
||
raise RuntimeError(
|
||
"百炼首尾帧模型(KF2V)必须有尾帧 —— 请在「尾帧」里选一张\n"
|
||
"(用本镜的帧 / 上一镜的帧 / 视频末帧)")
|
||
job["log"].append(f"☁️ 百炼通道 · {info.get('name', model)} · {info.get('price', '')}")
|
||
if str(dur) != "5":
|
||
job["log"].append("ℹ️ 该模型时长固定 5 秒(官方规格),已忽略时长设置")
|
||
dv.generate_kf2v(
|
||
first, tail, str(dest), prompt=(cfg.get("motion_prompt") or ""),
|
||
model=model,
|
||
progress_cb=lambda s: job["log"].append(str(s)),
|
||
)
|
||
else:
|
||
# ── 可灵通道 ──
|
||
job["log"].append("🎬 提交可灵图生视频…")
|
||
client = KlingVideoClient()
|
||
if not client.available:
|
||
raise RuntimeError("可灵未配置(Dashboard → ⚙️ 服务 → 🔑 API 配置)")
|
||
|
||
# 🔊 原生有声:一次生成「带语音的视频」→ 不需要后期配音/口型
|
||
native = "on" if cfg.get("native_sound") else "off"
|
||
if native == "on":
|
||
job["log"].append("🔊 原生有声模式(sound=on)—— 语音由可灵直接生成,无需配音/口型")
|
||
|
||
client.generate_video(
|
||
cfg.get("motion_prompt") or "", dest,
|
||
image_path=first, duration=dur, model=model, tail_image_path=tail,
|
||
sound=native,
|
||
progress_cb=lambda s: job["log"].append(str(s)),
|
||
)
|
||
job["log"].append(f"✅ 画面完成:v{v}.mp4")
|
||
|
||
# ── 配音(TTS)──
|
||
do_tts = bool(cfg.get("tts"))
|
||
do_lip = bool(cfg.get("lipsync"))
|
||
text = (cfg.get("text") or "").strip()
|
||
voice_mp3 = ""
|
||
if do_tts:
|
||
if not text:
|
||
job["log"].append("⚠️ 勾了配音但这一镜没有台词 → 跳过配音")
|
||
else:
|
||
from composer.dubbing import synthesize_line
|
||
voice_id = cfg.get("voice_id") or _resolve_voice(
|
||
_load_storyboard(pid) or {}, {"shot_id": sid}, pid)["voice_id"]
|
||
voice_mp3 = out_dir / f"{base}_voice.mp3"
|
||
job["log"].append(f"🎙 合成配音(音色 {voice_id})…")
|
||
synthesize_line(text, voice_id, str(voice_mp3))
|
||
job["log"].append(f"✅ 配音完成:{voice_mp3.name}")
|
||
|
||
# ── 对口型 ──
|
||
if do_lip and voice_mp3:
|
||
from composer.lipsync import lipsync_videoretalk
|
||
ls_out = out_dir / f"{base}_lipsync.mp4"
|
||
job["log"].append("👄 对口型(百炼 VideoRetalk,¥0.08/秒)…")
|
||
lipsync_videoretalk(str(dest), str(voice_mp3), str(ls_out), ref_image_path=first,
|
||
progress_cb=lambda s: job["log"].append(str(s)))
|
||
if ls_out.exists():
|
||
dest = ls_out
|
||
job["log"].append(f"✅ 对口型完成:{ls_out.name}")
|
||
elif do_lip:
|
||
job["log"].append("⚠️ 勾了对口型但没有配音 → 已跳过(请先勾「生成配音」)")
|
||
|
||
meta = {"version": v, "segment": seg, "model": model, "duration": dur,
|
||
"motion_prompt": cfg.get("motion_prompt") or "",
|
||
"first_frame": Path(first).name, "tail_frame": Path(tail).name if tail else "",
|
||
"tail_mode": tm, "adopted": False,
|
||
"tts": do_tts, "voice_id": cfg.get("voice_id") or "",
|
||
"text": text, "lipsync": do_lip,
|
||
"voice_file": Path(voice_mp3).name if voice_mp3 else "",
|
||
"has_voice": bool(voice_mp3),
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
||
(out_dir / f"{base}.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
job["version"] = v
|
||
job["log"].append(f"🎉 完成:{dest.name}")
|
||
|
||
|
||
@router.get("/video/job")
|
||
def api_kf_video_job(run_id: str):
|
||
j = _VID_JOBS.get(run_id)
|
||
if not j:
|
||
raise HTTPException(404, detail="任务不存在(Dashboard 重启会清空运行中任务)")
|
||
return {"status": "ok", "job": j}
|
||
|
||
|
||
@router.post("/video/recover")
|
||
def api_kf_video_recover(data: dict):
|
||
"""补下载:扫描遗留的 .taskid,用可灵任务状态把**已完成但未下载**的产物救回来
|
||
|
||
为什么需要:generate_video 是「提交 → 轮询 → 下载」一条龙;
|
||
如果在下载前进程被中断(重启/批中断),可灵那边任务已 succeed、
|
||
但本地只有 .taskid 没有 mp4 —— 钱花了产物没拿回来。
|
||
**本操作不产生新费用**(只是取回已完成任务的结果)。
|
||
|
||
body: { project_id }
|
||
"""
|
||
pid = str(data.get("project_id") or "")
|
||
if not pid:
|
||
raise HTTPException(400, detail="project_id 必填")
|
||
base = VID_ROOT / pid
|
||
if not base.is_dir():
|
||
return {"status": "ok", "recovered": [], "note": "没有视频目录"}
|
||
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
from capabilities.kling_video import KlingVideoClient
|
||
client = KlingVideoClient()
|
||
if not client.available:
|
||
raise HTTPException(400, detail="可灵未配置")
|
||
|
||
recovered, skipped, failed = [], [], []
|
||
for tf in sorted(base.glob("*/*.taskid")):
|
||
mp4 = Path(str(tf)[:-len(".taskid")]) # 去掉 .taskid
|
||
sid = tf.parent.name
|
||
if mp4.exists():
|
||
skipped.append(f"{sid}/{mp4.name}")
|
||
continue
|
||
tid = ""
|
||
try:
|
||
tid = tf.read_text(encoding="utf-8").strip()
|
||
d = client._get(f"/v1/videos/image2video/{tid}")
|
||
data_ = d.get("data") or {}
|
||
st = data_.get("task_status")
|
||
if st != "succeed":
|
||
failed.append({"shot": sid, "task_id": tid, "status": st or "?"})
|
||
continue
|
||
vids = (((data_.get("task_result") or {}).get("videos")) or [])
|
||
if not vids:
|
||
failed.append({"shot": sid, "task_id": tid, "status": "succeed 但无视频URL"})
|
||
continue
|
||
url = vids[0].get("url") or ""
|
||
client.download(url, str(mp4))
|
||
# 补一份 meta(标记为补下载)
|
||
mj = mp4.with_suffix(".json")
|
||
if not mj.exists():
|
||
mj.write_text(json.dumps({
|
||
"version": int(Path(mp4).stem.lstrip("v") or 1),
|
||
"model": data_.get("task_result", {}).get("model_name") or "",
|
||
"duration": vids[0].get("duration") or "",
|
||
"motion_prompt": "", "first_frame": "",
|
||
"tail_frame": "", "tail_mode": "none", "adopted": False,
|
||
"recovered": True,
|
||
"note": "补下载(原任务在下载前被中断)",
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
recovered.append({"shot": sid, "file": mp4.name, "task_id": tid})
|
||
log.info("补下载成功 %s/%s ← %s", pid, sid, mp4.name)
|
||
except Exception as e:
|
||
failed.append({"shot": sid, "task_id": tid,
|
||
"status": f"{type(e).__name__}: {str(e)[:80]}"})
|
||
|
||
return {"status": "ok", "recovered": recovered, "skipped": skipped,
|
||
"failed": failed, "cost_note": "仅取回已完成任务的结果,不产生新费用"}
|
||
|
||
|
||
@router.post("/video/adopt")
|
||
def api_kf_video_adopt(data: dict):
|
||
"""采用某个片段的某个版本(片段内互斥,不影响别的片段)"""
|
||
pid, sid, v = str(data.get("project_id") or ""), str(data.get("shot_id") or ""), data.get("version")
|
||
seg = max(1, int(data.get("segment") or 1))
|
||
if not (pid and sid and v):
|
||
raise HTTPException(400, detail="project_id / shot_id / version 必填")
|
||
vdir = _video_dir(pid, sid)
|
||
for jf in vdir.glob("*.json"):
|
||
if jf.stem == "concat":
|
||
continue
|
||
sg, ver = _seg_ver(jf.stem)
|
||
if sg != seg:
|
||
continue # 只动本片段
|
||
m = _read_json(jf) or {}
|
||
m["adopted"] = (ver == int(v))
|
||
jf.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return {"status": "ok", "segment": seg, "adopted": int(v)}
|
||
|
||
|
||
@router.post("/video/discard")
|
||
def api_kf_video_discard(data: dict):
|
||
"""丢弃某个片段的某个版本(含它的配音/口型副产物)"""
|
||
pid, sid, v = str(data.get("project_id") or ""), str(data.get("shot_id") or ""), data.get("version")
|
||
seg = max(1, int(data.get("segment") or 1))
|
||
vdir = _video_dir(pid, sid)
|
||
n = 0
|
||
for stem in (f"s{seg}_v{v}", f"v{v}"):
|
||
for suf in (".mp4", ".json", "_voice.mp3", "_lipsync.mp4"):
|
||
f = vdir / f"{stem}{suf}"
|
||
if f.exists():
|
||
f.unlink()
|
||
n += 1
|
||
return {"status": "ok", "removed": n}
|
||
|
||
|
||
@router.post("/video/concat")
|
||
def api_kf_video_concat(data: dict):
|
||
"""把这一镜的多个片段按顺序拼成完整镜头(**本地 ffmpeg,¥0**)
|
||
|
||
body: { project_id, shot_id, picks?: {"1": 2, "2": 1} }
|
||
picks = 每段指定用哪个版本;不指定则用该段「已采用」的版本。
|
||
"""
|
||
pid, sid = str(data.get("project_id") or ""), str(data.get("shot_id") or "")
|
||
if not (pid and sid):
|
||
raise HTTPException(400, detail="project_id / shot_id 必填")
|
||
segs = _segments_of(pid, sid)
|
||
if not segs:
|
||
raise HTTPException(400, detail="这一镜还没有任何视频片段")
|
||
picks = data.get("picks") or {}
|
||
d = VID_ROOT / pid / sid
|
||
files, used = [], []
|
||
for sg in segs:
|
||
no = sg["segment"]
|
||
want = picks.get(str(no)) or picks.get(no)
|
||
pick = None
|
||
if want:
|
||
pick = next((v for v in sg["versions"] if v["version"] == int(want)), None)
|
||
if not pick:
|
||
pick = next((v for v in sg["versions"] if v["adopted"]), None)
|
||
if not pick:
|
||
pick = sg["versions"][-1] if sg["versions"] else None
|
||
if not pick or not pick.get("ok"):
|
||
raise HTTPException(400, detail=f"片段 {no} 没有可用的视频(先生成)")
|
||
for cand in (f"s{no}_v{pick['version']}.mp4", f"v{pick['version']}.mp4"):
|
||
fp = d / cand
|
||
if fp.exists():
|
||
files.append(str(fp))
|
||
used.append({"segment": no, "version": pick["version"], "file": cand})
|
||
break
|
||
if len(files) < 1:
|
||
raise HTTPException(400, detail="没有可拼接的片段")
|
||
|
||
out = d / "shot.mp4"
|
||
if len(files) == 1:
|
||
import shutil
|
||
shutil.copy2(files[0], out) # 只有一段:直接复制
|
||
else:
|
||
sys.path.insert(0, str(AVE_SCRIPTS))
|
||
from composer.ffmpeg import concat_segments
|
||
concat_segments(files, str(out))
|
||
# 记录拼接清单
|
||
_concat_path(pid, sid).write_text(json.dumps(
|
||
{"picks": {str(x["segment"]): x["version"] for x in used}, "at":
|
||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")}, ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
return {"status": "ok", "segments": len(files), "used": used,
|
||
"url": f"/api/ave/keyframe/video/file?path={pid}/{sid}/shot.mp4",
|
||
"cost_note": "本地 ffmpeg 拼接,零 API 费用"}
|
||
|
||
|
||
@router.get("/video/file")
|
||
def api_kf_video_file(path: str):
|
||
p = (VID_ROOT / path).resolve()
|
||
if not _safe_under(VID_ROOT, p) or not p.exists():
|
||
raise HTTPException(404, detail=f"文件不存在: {path}")
|
||
return FileResponse(str(p), media_type="video/mp4")
|
||
|
||
|
||
def _refresh_index(pid: str) -> None:
|
||
"""刷新项目级索引(总数 / 已采用数)"""
|
||
pdir = _proj_dir(pid)
|
||
total = adopted = 0
|
||
for sd in pdir.iterdir():
|
||
if not sd.is_dir():
|
||
continue
|
||
vs = []
|
||
for jf in sd.glob("v*.json"):
|
||
m = _read_json(jf) or {}
|
||
if m:
|
||
vs.append(m)
|
||
if vs:
|
||
total += 1
|
||
if any(m.get("adopted") for m in vs):
|
||
adopted += 1
|
||
(pdir / "_index.json").write_text(json.dumps({
|
||
"total": total, "adopted": adopted,
|
||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|