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

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

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

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

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

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

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

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

1767 lines
75 KiB
Python
Raw Blame History

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