移植自 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 带网关原话 / 正常返回)。
1824 lines
80 KiB
Python
1824 lines
80 KiB
Python
"""视频工厂 — 能力注册表接口(S1)
|
||
|
||
用途:视频工厂的能力真相源,供「🧩 能力库」页读写。
|
||
|
||
接口:
|
||
GET /api/ave/capabilities 能力列表(含分类/状态/供应商/密钥配置状态)
|
||
POST /api/ave/capabilities/{cap_id}/default 改默认供应商 / 模型
|
||
POST /api/ave/capabilities/{cap_id}/param 改参数默认值
|
||
GET /api/ave/capabilities/maintenance 维护信息(可归档空壳目录等)
|
||
|
||
数据源:05_tools/09_ave/config/capabilities.yaml(能力注册表)
|
||
"""
|
||
import logging
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
|
||
logger = logging.getLogger("dashboard.ave")
|
||
|
||
router = APIRouter(prefix="/api/ave", tags=["ave"])
|
||
|
||
AGENT_SYNC = Path(os.environ.get(
|
||
"AGENT_SYNC", Path(__file__).resolve().parent.parent.parent))
|
||
_AVE_SCRIPTS = AGENT_SYNC / "05_tools" / "09_ave" / "scripts"
|
||
|
||
|
||
def _registry():
|
||
"""惰性导入能力注册表(避免模块加载顺序问题)"""
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from capabilities import get_registry
|
||
return get_registry(reload=True) # 每次都读盘,保证外部改动可见
|
||
|
||
|
||
@router.get("/capabilities")
|
||
def api_capabilities():
|
||
"""能力列表(含密钥配置状态)"""
|
||
try:
|
||
data = _registry().to_dict()
|
||
logger.info(" 🧩 能力注册表读取: %d 个能力", data.get("total", 0))
|
||
return {"status": "ok", **data}
|
||
except Exception as e:
|
||
logger.error(" ❌ 能力注册表读取失败: %s", e)
|
||
raise HTTPException(500, detail=f"能力注册表读取失败: {e}")
|
||
|
||
|
||
@router.post("/capabilities/{cap_id}/default")
|
||
def api_set_default(cap_id: str, data: dict):
|
||
"""改默认供应商 / 模型
|
||
|
||
Body: { provider?: str, model?: str }
|
||
"""
|
||
reg = _registry()
|
||
if not reg.get(cap_id):
|
||
raise HTTPException(404, detail=f"能力不存在: {cap_id}")
|
||
provider = (data.get("provider") or "").strip() or None
|
||
model = (data.get("model") or "").strip() or None
|
||
if not provider and not model:
|
||
raise HTTPException(400, detail="provider 或 model 至少给一个")
|
||
ok = reg.set_default(cap_id, provider=provider, model=model)
|
||
if not ok:
|
||
raise HTTPException(400, detail="供应商非法或保存失败")
|
||
logger.info(" 🧩 能力默认值已改: %s → provider=%s model=%s", cap_id, provider, model)
|
||
return {"status": "ok", "capability": reg.get(cap_id)}
|
||
|
||
|
||
@router.post("/capabilities/{cap_id}/param")
|
||
def api_set_param(cap_id: str, data: dict):
|
||
"""改参数默认值
|
||
|
||
Body: { param: str, value: any }
|
||
"""
|
||
reg = _registry()
|
||
if not reg.get(cap_id):
|
||
raise HTTPException(404, detail=f"能力不存在: {cap_id}")
|
||
param = (data.get("param") or "").strip()
|
||
if not param:
|
||
raise HTTPException(400, detail="param 必填")
|
||
if "value" not in data:
|
||
raise HTTPException(400, detail="value 必填")
|
||
ok = reg.set_param_default(cap_id, param, data["value"])
|
||
if not ok:
|
||
raise HTTPException(400, detail=f"参数不存在、值非法或保存失败: {param}")
|
||
logger.info(" 🧩 能力参数默认值已改: %s.%s = %s", cap_id, param, data["value"])
|
||
return {"status": "ok", "capability": reg.get(cap_id)}
|
||
|
||
|
||
@router.get("/capabilities/maintenance")
|
||
def api_maintenance():
|
||
"""维护信息:可归档的编号空壳目录等"""
|
||
try:
|
||
reg = _registry()
|
||
return {
|
||
"status": "ok",
|
||
"legacy_empty_dirs": reg.remove_legacy_dirs(),
|
||
"registry_path": str(reg.path),
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=str(e))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 导演创作(S2):导演选择 + 故事 → 分镜 + 单镜头重生成
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
|
||
def _directors():
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
import directors as d
|
||
import importlib
|
||
importlib.reload(d) # 每次读新注册表(避免长驻进程缓存)
|
||
return d
|
||
|
||
|
||
@router.get("/directors")
|
||
def api_directors():
|
||
"""导演系统列表(含可用性 + 各自选项 schema)"""
|
||
try:
|
||
return {"status": "ok", "directors": _directors().list_info()}
|
||
except Exception as e:
|
||
logger.error(" ❌ 导演列表读取失败: %s", e)
|
||
raise HTTPException(500, detail=str(e))
|
||
|
||
|
||
# ★ 资产描述里的"影棚语言"清洗表
|
||
# 根因:用户建角色/场景时可能填了"柔光箱均匀布光、中焦85mm、纯灰色专业背景"
|
||
# 这类专业影棚词,若不处理会被注入给导演 → 导演照抄 → 画面变成影棚感,与"真实"相反
|
||
_STUDIO_JARGON = (
|
||
"柔光箱均匀布光", "柔光箱", "均匀布光", "布光", "打光",
|
||
"纯灰色专业背景", "纯灰色背景", "纯色背景", "灰色背景", "professional background",
|
||
"中焦85mm", "85mm", "压缩空间", "反光板", "灯架", "摄影棚", "影棚",
|
||
)
|
||
|
||
|
||
def _clean_studio_jargon(s: str) -> str:
|
||
"""清洗资产描述里的影棚语言(生活/自拍场景用不到,会污染导演画面描述)"""
|
||
import re as _re
|
||
out = str(s or "")
|
||
for w in _STUDIO_JARGON:
|
||
out = out.replace(w, "")
|
||
out = _re.sub(r"[、,,]\s*(?=[、,,])", "", out) # 连续分隔符
|
||
out = _re.sub(r"^[\s、,,]+|[\s、,,]+$", "", out)
|
||
return out.strip()
|
||
|
||
|
||
def _inject_asset_context(a: dict) -> None:
|
||
"""★「提前喂给导演」:按 anchors 里的资产 ID 从资产库查**详情**,填入 `*_context`
|
||
|
||
这样导演写分镜的画面描述会是「单人病房 302(空间类型=医院病房 · 光源=日光灯)」
|
||
而不是只拿到 `loc_hospital_302` 这个 ID 靠猜。
|
||
"""
|
||
import sys as _sys
|
||
if str(_AVE_SCRIPTS) not in _sys.path:
|
||
_sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
try:
|
||
from assets_manager import AssetStore
|
||
except Exception:
|
||
return
|
||
|
||
def _desc(kind: str, aid: str) -> str:
|
||
if not aid:
|
||
return ""
|
||
try:
|
||
it = AssetStore(kind).get(aid) or {}
|
||
except Exception:
|
||
return ""
|
||
name = str(it.get("name") or aid)
|
||
feats = []
|
||
for k in ("description", "space_type", "lighting", "materials", "role", "type",
|
||
"appearance", "must_keep", "notes", "structure"):
|
||
v = it.get(k)
|
||
if isinstance(v, list) and v:
|
||
v = "、".join(str(x) for x in v)
|
||
if v:
|
||
# ★ 清洗影棚语言(避免资产描述污染导演的画面描述)
|
||
_v = _clean_studio_jargon(str(v))
|
||
if _v:
|
||
feats.append(f"{k}={_v}")
|
||
refs = it.get("reference_images") or {}
|
||
if refs:
|
||
feats.append(f"有{len(refs)}张参考图")
|
||
return f"{name}({' · '.join(feats)})" if feats else name
|
||
|
||
for id_key, kind, ctx_key in (
|
||
("character_id", "characters", "character_context"),
|
||
("location_id", "locations", "location_context"),
|
||
("product_id", "products", "product_context"),
|
||
("props", "props", "props_context"),
|
||
):
|
||
if a.get(ctx_key):
|
||
continue # 已显式提供 context → 不覆盖
|
||
vals = a.get(id_key)
|
||
if isinstance(vals, list):
|
||
texts = [t for t in (_desc(kind, str(x)) for x in vals if x) if t]
|
||
if texts:
|
||
a[ctx_key] = ";".join(texts)
|
||
elif vals:
|
||
dd = _desc(kind, str(vals))
|
||
if dd:
|
||
a[ctx_key] = dd
|
||
|
||
# ★ 演员表注入(用户 2026-10-03 要求):逐人查角色库详情 → 拼 cast_context
|
||
# 让导演拿到"这一场戏有谁、各自长什么样",而不是只有一个角色
|
||
if not a.get("cast_context"):
|
||
cast = a.get("cast") or []
|
||
if cast:
|
||
lines = []
|
||
for i, m in enumerate(cast, 1):
|
||
slot = (m.get("slot") or "").strip() or f"人物{i}"
|
||
aid = (m.get("asset_id") or "").strip()
|
||
nm = (m.get("name") or "").strip()
|
||
note = (m.get("note") or "").strip()
|
||
detail = _desc("characters", aid) if aid else ""
|
||
seg = [f"【{slot}】"]
|
||
if detail:
|
||
seg.append(detail)
|
||
elif nm:
|
||
seg.append(f"资产={nm}")
|
||
elif note:
|
||
seg.append(f"要求={note}")
|
||
else:
|
||
seg.append("(未绑定资产,按文案自由发挥)")
|
||
if note and detail:
|
||
seg.append(f"补充要求={note}")
|
||
lines.append(" · ".join(seg))
|
||
a["cast_context"] = "\n".join(lines)
|
||
|
||
|
||
@router.post("/storyboard/generate")
|
||
def api_storyboard_generate(data: dict):
|
||
"""故事 → 统一分镜 Schema
|
||
|
||
Body:
|
||
story: str 故事/文案(必填)
|
||
director_id: str 用哪个导演(默认第一个可用)
|
||
options: dict 导演选项(shot_count / shot_duration / shooting_style…)
|
||
anchors: dict 全局锚点(character_id / location_id / consistency_seed…)
|
||
"""
|
||
story = (data.get("story") or "").strip()
|
||
if not story:
|
||
raise HTTPException(400, detail="story 必填")
|
||
if len(story) > 4000:
|
||
raise HTTPException(400, detail="故事太长(≤4000 字)")
|
||
|
||
try:
|
||
d = _directors()
|
||
director = d.get_director(data.get("director_id") or "") or d.default_director()
|
||
if not getattr(director, "available", True):
|
||
raise HTTPException(503, detail=f"导演不可用:{director.name}(检查 LLM 配置)")
|
||
|
||
import sys as _sys
|
||
if str(_AVE_SCRIPTS) not in _sys.path:
|
||
_sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from schemas import GlobalAnchors
|
||
|
||
anchor_data = dict(data.get("anchors") or {})
|
||
# ★ 拍摄风格(拍法):前端可能放在 anchors 或 options 里 → 统一收敛到 anchors
|
||
# 提示词编译读的是 anchors.shooting_style,缺了就永远是默认值(伪纪录片)
|
||
_opts0 = data.get("options") or {}
|
||
if not (anchor_data.get("shooting_style") or "").strip():
|
||
_ss = (_opts0.get("shooting_style") or "").strip()
|
||
if _ss:
|
||
anchor_data["shooting_style"] = _ss
|
||
# ★ 导演风格 / 反AI感:同样从 options 收敛(**options_schema 是唯一 UI 来源**)
|
||
# 否则前端两处输入会不同步,且 anchors 侧缺值 → 风格包读不到、不生效
|
||
if not (anchor_data.get("director_style") or "").strip():
|
||
_ds = (_opts0.get("director_style") or "").strip()
|
||
if _ds:
|
||
anchor_data["director_style"] = _ds
|
||
if not (anchor_data.get("realism_boost") or "").strip():
|
||
_rb = (_opts0.get("realism_boost") or "").strip()
|
||
if _rb:
|
||
anchor_data["realism_boost"] = _rb
|
||
# ★ 资产注入("提前喂给导演"):按 id 从资产库查**详情** → 填入 *_context
|
||
# 这样导演写分镜时知道"病房长什么样 / 角色是谁",画面描述才具体
|
||
_inject_asset_context(anchor_data)
|
||
anchors = GlobalAnchors(**anchor_data)
|
||
sb = director.generate(story, anchors=anchors, options=data.get("options") or {})
|
||
|
||
if not sb.shots:
|
||
raise HTTPException(502, detail="导演未产出分镜(LLM 返回异常,请重试)")
|
||
|
||
logger.info(" 🎬 分镜生成: %s → %d 镜头 %.1fs", director.id, len(sb.shots), sb.total_duration())
|
||
return {"status": "ok", "storyboard": sb.to_dict(),
|
||
"summary": sb.summary(), "director_id": director.id}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(" ❌ 分镜生成失败: %s", e)
|
||
raise HTTPException(500, detail=f"分镜生成失败: {e}")
|
||
|
||
|
||
@router.post("/storyboard/revise")
|
||
def api_storyboard_revise(data: dict):
|
||
"""单镜头重生成
|
||
|
||
Body: { storyboard: dict, shot_id: str, instruction?: str, director_id?: str }
|
||
"""
|
||
sb_data = data.get("storyboard") or {}
|
||
shot_id = (data.get("shot_id") or "").strip()
|
||
if not sb_data or not shot_id:
|
||
raise HTTPException(400, detail="storyboard 和 shot_id 必填")
|
||
try:
|
||
d = _directors()
|
||
director = d.get_director(data.get("director_id") or (sb_data.get("director_id") or "")) \
|
||
or d.default_director()
|
||
from schemas import Storyboard
|
||
sb = Storyboard.from_dict(sb_data)
|
||
sb = director.revise_shot(sb, shot_id, data.get("instruction") or "")
|
||
logger.info(" 🔄 镜头重生成: %s (%s)", shot_id, director.id)
|
||
return {"status": "ok", "storyboard": sb.to_dict(), "summary": sb.summary()}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(" ❌ 镜头重生成失败: %s", e)
|
||
raise HTTPException(500, detail=f"重生成失败: {e}")
|
||
|
||
|
||
@router.post("/storyboard/save")
|
||
def api_storyboard_save(data: dict):
|
||
"""保存分镜(用户编辑后落盘,供渲染/复用)
|
||
|
||
Body: { storyboard: dict, name?: str }
|
||
"""
|
||
sb_data = data.get("storyboard") or {}
|
||
if not sb_data:
|
||
raise HTTPException(400, detail="storyboard 必填")
|
||
try:
|
||
from schemas import Storyboard
|
||
sb = Storyboard.from_dict(sb_data)
|
||
out_dir = Path(os.environ.get(
|
||
"AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"
|
||
)) / "runtime" / "ave" / "storyboards"
|
||
path = sb.save(out_dir / f"{sb.project_id}.json")
|
||
logger.info(" 💾 分镜已保存: %s", path.name)
|
||
return {"status": "ok", "path": str(path), "summary": sb.summary()}
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"保存失败: {e}")
|
||
|
||
|
||
@router.get("/storyboards/{project_id}")
|
||
def api_storyboard_get(project_id: str):
|
||
"""读取已保存的分镜(完整,含 shots)—— 供前端「打开已保存分镜」用"""
|
||
import json as _json
|
||
p = _ave_local_dir("storyboards") / f"{project_id}.json"
|
||
if not p.exists():
|
||
raise HTTPException(404, detail=f"分镜不存在: {project_id}")
|
||
return {"status": "ok", "storyboard": _json.loads(p.read_text(encoding="utf-8"))}
|
||
|
||
|
||
@router.get("/storyboards")
|
||
def api_storyboard_list():
|
||
"""已保存的分镜列表"""
|
||
try:
|
||
d = Path(os.environ.get(
|
||
"AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"
|
||
)) / "runtime" / "ave" / "storyboards"
|
||
items = []
|
||
if d.exists():
|
||
import json as _json
|
||
for f in sorted(d.glob("*.json"), key=lambda p: -p.stat().st_mtime)[:50]:
|
||
try:
|
||
data = _json.loads(f.read_text(encoding="utf-8"))
|
||
items.append({
|
||
"project_id": data.get("project_id", f.stem),
|
||
"title": data.get("title", ""),
|
||
"director_id": data.get("director_id", ""),
|
||
"shot_count": len(data.get("shots") or []),
|
||
"total_duration": round(sum(s.get("duration", 0) for s in (data.get("shots") or [])), 1),
|
||
"status": data.get("status"),
|
||
"output_path": data.get("output_path"),
|
||
"mtime": f.stat().st_mtime,
|
||
})
|
||
except Exception:
|
||
continue
|
||
return {"status": "ok", "storyboards": items, "total": len(items)}
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=str(e))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 出片(后台任务 + 进度轮询)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
_RENDER_JOBS: dict = {} # job_id → 任务状态(内存镜像;同时落盘 renders/{job_id}/job.json)
|
||
|
||
|
||
# ── 任务持久化 / 恢复 / 续跑(2026-10-03 新增)──────────────
|
||
# 出片任务跑在进程内线程里,重启即中断。以下函数让任务状态落盘,
|
||
# 使重启后仍能「看见中断」「续跑补齐」(复用的镜头不重复扣费)。
|
||
|
||
def _job_json_path(job_id: str):
|
||
"""任务的持久化位置(与产物同目录)"""
|
||
return _ave_local_dir("renders") / job_id / "job.json"
|
||
|
||
|
||
def _persist_job(job_id: str) -> None:
|
||
"""把任务状态写盘 —— 供 Dashboard 重启后仍能看见 / 续跑"""
|
||
import json as _json
|
||
with _render_lock():
|
||
job = dict(_RENDER_JOBS.get(job_id) or {})
|
||
if not job:
|
||
return
|
||
try:
|
||
p = _job_json_path(job_id)
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(_json.dumps(job, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
except Exception as e:
|
||
logger.warning("任务状态写盘失败 %s: %s", job_id, e)
|
||
|
||
|
||
def _restore_jobs() -> int:
|
||
"""启动恢复:磁盘上仍标 running 的任务 → interrupted(进程已死,已生成的镜头保留)"""
|
||
import json as _json
|
||
import time as _time
|
||
n = 0
|
||
try:
|
||
for jf in sorted(_ave_local_dir("renders").glob("*/job.json")):
|
||
try:
|
||
j = _json.loads(jf.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
if j.get("status") == "running":
|
||
j["status"] = "interrupted"
|
||
j["error"] = "服务重启导致中断(已生成的镜头已保留,可点「续跑」补齐)"
|
||
j["interrupted_at"] = _time.time()
|
||
try:
|
||
jf.write_text(_json.dumps(j, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
n += 1
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning("任务状态恢复失败: %s", e)
|
||
if n:
|
||
logger.info(" ⏸ %d 个未完成任务已标记为「已中断」(可续跑)", n)
|
||
return n
|
||
|
||
|
||
def _start_job(job_id: str, sb) -> None:
|
||
"""登记新任务(含可搜索信息:标题 / 项目号 / 脚本片段)并落盘"""
|
||
import time as _time
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id] = {
|
||
"status": "running", "progress": [], "output": None, "error": None,
|
||
"failed": [], "segments": 0, "total_shots": len(sb.shots),
|
||
"title": sb.title or sb.project_id, "started_at": _time.time(),
|
||
"project_id": sb.project_id,
|
||
"story_snippet": (sb.story or "")[:120],
|
||
}
|
||
_persist_job(job_id)
|
||
|
||
|
||
def _make_progress_cb(job_id: str):
|
||
"""进度回调:累积事件 + 节流写盘(每 3 秒最多一次)"""
|
||
import time as _time
|
||
last = [0.0]
|
||
|
||
def _cb(step: str, sid: str, status: str, msg: str):
|
||
with _render_lock():
|
||
job = _RENDER_JOBS.get(job_id)
|
||
if job is None:
|
||
return
|
||
job["progress"].append({"step": step, "shot_id": sid,
|
||
"status": status, "msg": msg})
|
||
job["last_event_at"] = _time.time()
|
||
now = _time.time()
|
||
if now - last[0] > 3:
|
||
last[0] = now
|
||
_persist_job(job_id)
|
||
return _cb
|
||
|
||
|
||
def _render_worker(job_id: str, sb, reuse_existing: bool = False,
|
||
audio_first: bool = False, lipsync: bool = False,
|
||
align_to_audio: bool = False, sfx: bool = False) -> None:
|
||
"""出片线程主体(首跑/续跑/音频先行共用)
|
||
|
||
reuse_existing=True 时复用已生成的镜头;
|
||
audio_first=True 时先生成台词音频、按音频时长调整镜头后再生成画面
|
||
"""
|
||
import time as _time
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from composer.pipeline import StoryboardPipeline
|
||
work = _ave_local_dir("renders") / job_id
|
||
out = work / "final.mp4"
|
||
pipe = StoryboardPipeline(work_dir=str(work))
|
||
r = pipe.render(sb, str(out), progress_cb=_make_progress_cb(job_id),
|
||
reuse_existing=reuse_existing, audio_first=audio_first,
|
||
lipsync=lipsync, align_to_audio=align_to_audio,
|
||
sfx_from_video=sfx)
|
||
sb.save(_ave_local_dir("storyboards") / f"{sb.project_id}.json")
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id].update({
|
||
"status": "done", "output": r.get("output"),
|
||
"failed": r.get("failed") or [], "segments": r.get("segments") or 0,
|
||
"duration": sb.total_duration(), "finished_at": _time.time(),
|
||
"delivery_status": r.get("delivery_status") or sb.delivery_status,
|
||
"delivery_note": r.get("delivery_note") or sb.delivery_note,
|
||
})
|
||
_persist_job(job_id)
|
||
logger.info(" 🎬 出片完成: %s(%d 段)", job_id, r.get("segments") or 0)
|
||
|
||
# ★ 一步到位:音频先行后自动配音混流(复用 pre_audio → 不重复 TTS)
|
||
if audio_first:
|
||
try:
|
||
from composer.dubbing import build_voice_track
|
||
voice = work / "voice_track.wav"
|
||
_dd = build_voice_track(sb, str(voice), str(work / "dub_work"),
|
||
use_ambience=True,
|
||
progress_cb=_make_progress_cb(job_id),
|
||
reuse_dir=str(work / "pre_audio"))
|
||
muxed = work / "final_muxed.mp4"
|
||
StoryboardPipeline.mux_final(str(out), str(muxed), voice_path=str(voice),
|
||
narration_path=(_dedub.get("narration") or ""))
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id].update({
|
||
"output": str(muxed), "has_voice": True,
|
||
"dub": {"segments": _dd.get("segments"),
|
||
"voice_seconds": _dd.get("voice_seconds"),
|
||
"ambience_hits": _dd.get("ambience_hits"),
|
||
"voices": _dd.get("voices")},
|
||
"delivery_note": "一步到位:画面 + " +
|
||
("对口型 + " if lipsync else "") + "配音混流",
|
||
})
|
||
_persist_job(job_id)
|
||
logger.info(" 🎙 一步到位:已自动配音混流 → %s", muxed.name)
|
||
except Exception as e:
|
||
logger.warning(" ⚠️ 自动配音失败(画面成片仍可用): %s", e)
|
||
|
||
# ★ 质检辅助:生成「接触印相表」(抽帧拼图,便于一眼核对口型/人物一致性)—— 零成本
|
||
try:
|
||
from composer.qa import make_shot_qa
|
||
_qa = make_shot_qa(str(work))
|
||
if _qa.get("final"):
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id]["qa_sheet"] = _qa["final"]
|
||
_RENDER_JOBS[job_id]["qa_shots"] = len(_qa.get("shots") or [])
|
||
_persist_job(job_id)
|
||
logger.info(" 🖼 质检图已生成(%d 张镜头图 + 1 张成片图)", _qa.get("shots") and len(_qa["shots"]) or 0)
|
||
except Exception as e:
|
||
logger.debug("质检图生成失败(不影响交付): %s", e)
|
||
except Exception as e:
|
||
logger.warning(" ❌ 出片失败 %s: %s", job_id, e)
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id].update({"status": "failed", "error": str(e),
|
||
"finished_at": _time.time()})
|
||
_persist_job(job_id)
|
||
|
||
|
||
def _dub_worker(job_id: str, sb, use_ambience: bool = True,
|
||
ambience_volume: float = 0.15) -> None:
|
||
"""配音线程:TTS 人声(+ 环境音铺底)→ 与成片混流
|
||
|
||
⚠️ 会产生 TTS 费用(按台词字数,通常几分钱)
|
||
"""
|
||
import time as _time
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from composer.pipeline import StoryboardPipeline
|
||
from composer.dubbing import build_voice_track
|
||
work = _ave_local_dir("renders") / job_id
|
||
final = work / "final.mp4"
|
||
if not final.exists():
|
||
raise RuntimeError("成片 final.mp4 不存在,无法配音")
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id].update({"status": "running", "dubding": True,
|
||
"dub_error": None})
|
||
voice = work / "voice_track.wav"
|
||
r = build_voice_track(sb, str(voice), str(work / "dub_work"),
|
||
use_ambience=use_ambience,
|
||
ambience_volume=ambience_volume,
|
||
progress_cb=_make_progress_cb(job_id),
|
||
reuse_dir=str(work / "pre_audio")) # ★ 复用音频先行的产物
|
||
muxed = work / "final_muxed.mp4"
|
||
StoryboardPipeline.mux_final(str(final), str(muxed), voice_path=str(voice),
|
||
narration_path=(r.get("narration") or ""))
|
||
with _render_lock():
|
||
_RENDER_JOBS[job_id].update({
|
||
"status": "done", "output": str(muxed), "has_voice": True,
|
||
"dub": {"segments": r.get("segments"),
|
||
"voice_seconds": r.get("voice_seconds"),
|
||
"ambience_hits": r.get("ambience_hits"),
|
||
"voices": r.get("voices"), "notes": r.get("notes")},
|
||
"finished_at": _time.time(),
|
||
"delivery_note": "已配音(人声" + ("+环境音" if r.get("ambience_hits") else "") + ")",
|
||
})
|
||
_persist_job(job_id)
|
||
logger.info(" 🎙 配音完成: %s(人声 %.1fs / 环境音命中 %d)",
|
||
job_id, r.get("voice_seconds") or 0, r.get("ambience_hits") or 0)
|
||
except Exception as e:
|
||
logger.warning(" ❌ 配音失败 %s: %s", job_id, e)
|
||
with _render_lock():
|
||
job = _RENDER_JOBS.get(job_id) or {}
|
||
job.update({"dub_error": str(e)[:400]})
|
||
if job.get("status") == "running":
|
||
job["status"] = "done" # 画面成片仍可用
|
||
_persist_job(job_id)
|
||
|
||
|
||
_RENDER_LOCK = None # 惰性创建
|
||
|
||
|
||
def _render_lock():
|
||
global _RENDER_LOCK
|
||
if _RENDER_LOCK is None:
|
||
import threading
|
||
_RENDER_LOCK = threading.Lock()
|
||
return _RENDER_LOCK
|
||
|
||
|
||
def _ave_local_dir(sub: str) -> Path:
|
||
d = Path(os.environ.get(
|
||
"AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"
|
||
)) / "runtime" / "ave" / sub
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _save_tech_plan(plan: dict) -> None:
|
||
"""技术方案存盘(供「能力目录更新 → 自动升级检测」比对历史指纹)"""
|
||
pid = (plan or {}).get("project_id")
|
||
if not pid:
|
||
return
|
||
try:
|
||
import json as _json
|
||
(_ave_local_dir("technical_plans") / f"{pid}.json").write_text(
|
||
_json.dumps(plan, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
except Exception as e:
|
||
logger.warning("技术方案存盘失败: %s", e)
|
||
|
||
|
||
def _load_tech_plan(project_id: str) -> dict:
|
||
"""读取历史技术方案(无则返回 {})"""
|
||
if not project_id:
|
||
return {}
|
||
p = _ave_local_dir("technical_plans") / f"{project_id}.json"
|
||
if not p.exists():
|
||
return {}
|
||
try:
|
||
import json as _json
|
||
return _json.loads(p.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _shot_has_char(shot) -> bool:
|
||
"""镜头是否绑了角色 —— 同时看多角色演员表(cast_slots)与单角色字段
|
||
|
||
⚠️ 旧实现只看 character_id → 用「演员表」的分镜全部被误判为「未绑定角色」
|
||
(前端预览会错误显示"未绑定/纯文生视频",生成能力也判错)
|
||
"""
|
||
if isinstance(shot, dict):
|
||
slots = [s for s in (shot.get("cast_slots") or []) if s]
|
||
return bool(slots or (shot.get("character_id") or "").strip())
|
||
slots = [s for s in (getattr(shot, "cast_slots", None) or []) if s]
|
||
return bool(slots or (getattr(shot, "character_id", "") or "").strip())
|
||
|
||
|
||
def _cap_for_shot(shot) -> tuple:
|
||
"""按镜头判断「用哪个能力生成」→ (能力id, 中文说明)
|
||
|
||
**优先读镜头手动指定的 `generation_capability`**;为空则自动判定。
|
||
"""
|
||
ov = (getattr(shot, "generation_capability", "") or "").lower()
|
||
if ov == "text_to_video":
|
||
return "text_to_video", "文生视频(手动指定)"
|
||
if ov == "image_to_video":
|
||
has_char = _shot_has_char(shot)
|
||
return "image_to_video", ("图生视频(手动指定·用角色参考图保一致性)" if has_char
|
||
else "图生视频(手动指定·未绑角色,生成时将退化为文生视频)")
|
||
if ov == "vfx":
|
||
return "vfx_apply", "视频特效(手动指定·运镜/慢动作)"
|
||
if ov == "comfyui":
|
||
return "comfyui_workflow", "ComfyUI 工作流(手动指定)"
|
||
|
||
# ── 自动判定(未手动指定时)──
|
||
t = (getattr(shot, "type", "") or "").lower()
|
||
if t == "talking_head":
|
||
return "talking_head", "口播/口型(当前以图生视频 + 后期口型替代)"
|
||
if t == "vfx":
|
||
return "vfx_apply", "视频特效(运镜/慢动作等)"
|
||
if t == "comfyui":
|
||
return "comfyui_workflow", "ComfyUI 工作流(面部/风格控制)"
|
||
if _shot_has_char(shot):
|
||
return "image_to_video", "图生视频(用角色参考图保住人脸一致性)"
|
||
return "text_to_video", "文生视频"
|
||
|
||
|
||
@router.post("/storyboard/preview-prompts")
|
||
def api_preview_prompts(data: dict):
|
||
"""预览「每个镜头用什么提示词 + 什么能力生成」(**纯计算,不调用生成、不扣费**)
|
||
|
||
Body: { storyboard: dict, engine?: str }
|
||
Returns: { shots: [{shot_id, index, type, duration, prompt, chars,
|
||
capability, capability_name, engine, model, params, has_char_ref}] }
|
||
"""
|
||
sb_data = data.get("storyboard")
|
||
if not sb_data:
|
||
raise HTTPException(400, detail="需要 storyboard")
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from schemas import Storyboard
|
||
from composer.pipeline import StoryboardPipeline
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"模块加载失败: {e}")
|
||
|
||
sb = Storyboard.from_dict(sb_data)
|
||
engine = (data.get("engine") or "kling").strip()
|
||
aspect = StoryboardPipeline._aspect_ratio(sb)
|
||
|
||
out = []
|
||
for i, shot in enumerate(sb.shots, 1):
|
||
prompt = StoryboardPipeline.build_shot_prompt(shot, sb, has_ref_image=_shot_has_char(shot))
|
||
cap, cap_name = _cap_for_shot(shot)
|
||
out.append({
|
||
"shot_id": shot.shot_id, "index": i, "type": shot.type,
|
||
"duration": shot.duration,
|
||
"prompt": prompt, "chars": len(prompt),
|
||
"capability": cap, "capability_name": cap_name,
|
||
"engine": engine,
|
||
"model": "kling-v3" if engine == "kling" else "",
|
||
"params": {"duration": "10" if (shot.duration or 5) > 7 else "5",
|
||
"aspect_ratio": aspect},
|
||
"has_char_ref": _shot_has_char(shot),
|
||
})
|
||
# 技术优选层: 技术需求 → 能力匹配 + 缺口报告(纯计算, 零费用)
|
||
tech_plan = None
|
||
try:
|
||
from capability_matcher import match_storyboard
|
||
tech_plan = match_storyboard(sb.to_dict())
|
||
_save_tech_plan(tech_plan) # 存盘: 供能力更新后升级检测
|
||
except Exception as e:
|
||
logger.warning("技术方案匹配失败: %s", e)
|
||
return {"status": "ok", "engine": engine, "aspect_ratio": aspect,
|
||
"total": len(out), "total_duration": sb.total_duration(),
|
||
"shots": out,
|
||
"tech_plan": tech_plan,
|
||
"note": "只做提示词组装与能力判定,**不调用生成、不产生费用**"}
|
||
|
||
|
||
@router.post("/storyboard/technical-plan")
|
||
def api_technical_plan(data: dict):
|
||
"""技术优选层: 分镜 → 技术方案 + 缺口报告(**纯计算, 零费用**)
|
||
|
||
Body: { storyboard: dict } 或 { project_id: str }
|
||
Returns: { plan: {shots:[{shot_id,capability_id,plan_status,issues,duration_slot}],
|
||
gaps:[...], summary:{...}, catalog:{...}}, upgrade: {...} }
|
||
"""
|
||
import json as _json
|
||
sb_data = data.get("storyboard")
|
||
if not sb_data and data.get("project_id"):
|
||
p = _ave_local_dir("storyboards") / f"{data['project_id']}.json"
|
||
if not p.exists():
|
||
raise HTTPException(404, detail=f"分镜不存在: {data['project_id']}")
|
||
sb_data = _json.loads(p.read_text(encoding="utf-8"))
|
||
if not sb_data:
|
||
raise HTTPException(400, detail="需要 storyboard 或 project_id")
|
||
try:
|
||
from capability_matcher import match_storyboard, check_upgradable
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"技术优选层加载失败: {e}")
|
||
|
||
plan = match_storyboard(sb_data)
|
||
old_plan = (sb_data or {}).get("technical_plan") or _load_tech_plan(plan.get("project_id", ""))
|
||
upgrade = check_upgradable(old_plan)
|
||
_save_tech_plan(plan)
|
||
return {"status": "ok", "plan": plan, "upgrade": upgrade,
|
||
"note": "只做需求→能力匹配,**不调用生成、不产生费用**"}
|
||
|
||
|
||
@router.post("/storyboard/suggest-assets")
|
||
def api_suggest_assets(data: dict):
|
||
"""AI 分析文案 → 预选这场戏需要的资产(用户 2026-10-03 要求)
|
||
|
||
Body: { story: str }
|
||
返回 { cast:[{slot,appearance,asset_id,asset_name,note}],
|
||
locations/props/products:[{need,asset_id,asset_name}],
|
||
new_assets:[{kind,name,why,prompt}], summary }
|
||
"""
|
||
import sys as _sys
|
||
story = (data or {}).get("story", "").strip()
|
||
if not story:
|
||
raise HTTPException(400, detail="需要 story(故事文案)")
|
||
if str(_AVE_SCRIPTS) not in _sys.path:
|
||
_sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
try:
|
||
from assets_manager import AssetStore
|
||
from directors.llm import DirectorLLM
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"模块加载失败: {e}")
|
||
|
||
KINDS = [
|
||
("characters", "角色库"), ("locations", "场景库"),
|
||
("real_locations", "真实场地"), ("products", "产品库"), ("props", "道具库"),
|
||
]
|
||
# 收集:清单文本(喂 LLM)+ 名称→id 反查表
|
||
name2id: dict = {}
|
||
cat_lines = []
|
||
for kind, label in KINDS:
|
||
try:
|
||
items = AssetStore(kind).list()[:60]
|
||
except Exception:
|
||
items = []
|
||
rows = []
|
||
for it in items:
|
||
nm = str(it.get("name") or it.get("id") or "").strip()
|
||
if not nm:
|
||
continue
|
||
name2id.setdefault(kind, {})[nm] = it.get("id") or ""
|
||
bits = []
|
||
for kk in ("role", "type", "description", "space_type", "lighting", "appearance", "notes"):
|
||
v = it.get(kk)
|
||
if isinstance(v, list):
|
||
v = "、".join(str(x) for x in v)
|
||
if v:
|
||
bits.append(str(v)[:36])
|
||
rows.append(" - " + nm + (f"({'·'.join(bits[:3])})" if bits else ""))
|
||
cat_lines.append(f"【{label}】\n" + ("\n".join(rows) if rows else " (空)"))
|
||
|
||
system = (
|
||
"你是短剧/广告的前期统筹。看完文案,判断这条片子**实际需要哪些资产**,"
|
||
"并优先从现有资产库里匹配(名称必须原样抄写,匹配不上就留空并列入 new_assets)。\n"
|
||
"只输出 JSON,不要解释。"
|
||
)
|
||
prompt = (
|
||
"【文案】\n" + story[:1500] +
|
||
"\n\n【现有资产库(优先匹配,名称原样抄写)】\n" + "\n".join(cat_lines) +
|
||
"\n\n请输出 JSON:\n"
|
||
"{\n"
|
||
' "cast": [{"slot": "主角", "appearance": "年龄/性别/外貌要求", '
|
||
'"matched": "从角色库选的名称,没有就留空", "note": ""}],\n'
|
||
' "locations": [{"need": "场景要求", "matched": "从场景库/真实场地选的名称,没有留空"}],\n'
|
||
' "props": [{"need": "道具要求", "matched": "从道具库选的名称,没有留空"}],\n'
|
||
' "products": [{"need": "产品要求", "matched": "从产品库选的名称,没有留空"}],\n'
|
||
' "new_assets": [{"kind": "characters|locations|props|products", "name": "建议名称", '
|
||
'"why": "为什么需要", "prompt": "可直接用于生成的描述"}],\n'
|
||
' "summary": "一句话总结这场戏"\n'
|
||
"}\n\n"
|
||
"注意:cast 只列**戏份上有意义的人物**(主角/配角),群演不必单列;"
|
||
"matched 匹配不上时留空字符串,并在 new_assets 里给建议。"
|
||
)
|
||
|
||
try:
|
||
llm = DirectorLLM(timeout=120)
|
||
if not llm.available:
|
||
raise HTTPException(500, detail="LLM 未配置(请先在设置里配置大模型 Key)")
|
||
raw = llm.chat(prompt, system=system, temperature=0.6, max_tokens=2500)
|
||
out = llm.parse_json(raw) or {}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"分析失败: {e}")
|
||
|
||
def _resolve(kind: str, matched: str) -> tuple[str, str]:
|
||
m = (matched or "").strip()
|
||
if not m:
|
||
return "", ""
|
||
# 兼容:模型可能写成"场景库/名称"或加括号说明
|
||
m2 = m.split("/")[-1].split("(")[0].strip()
|
||
for cand in (m, m2):
|
||
aid = (name2id.get(kind) or {}).get(cand, "")
|
||
if aid:
|
||
return aid, cand
|
||
return "", m
|
||
|
||
cast = []
|
||
for c in (out.get("cast") or [])[:6]:
|
||
aid, nm = _resolve("characters", c.get("matched") or "")
|
||
cast.append({
|
||
"slot": (c.get("slot") or "").strip() or "人物",
|
||
"appearance": (c.get("appearance") or "").strip(),
|
||
"asset_id": aid, "asset_name": nm,
|
||
"note": (c.get("note") or "").strip(),
|
||
})
|
||
|
||
def _pick(key: str, kinds, name_key: str = "need"):
|
||
"""按 kind 依次尝试匹配(如场景库→真实场地),命中即止(避免重复列出)"""
|
||
if isinstance(kinds, str):
|
||
kinds = [kinds]
|
||
res = []
|
||
for it in (out.get(key) or [])[:8]:
|
||
aid, nm = "", ""
|
||
for kd in kinds:
|
||
aid, nm = _resolve(kd, it.get("matched") or "")
|
||
if aid:
|
||
break
|
||
res.append({name_key: (it.get(name_key) or "").strip(),
|
||
"asset_id": aid, "asset_name": nm})
|
||
return res
|
||
|
||
return {
|
||
"status": "ok",
|
||
"cast": cast,
|
||
"locations": _pick("locations", ["locations", "real_locations"]),
|
||
"props": _pick("props", "props"),
|
||
"products": _pick("products", "products"),
|
||
"new_assets": (out.get("new_assets") or [])[:6],
|
||
"summary": (out.get("summary") or "").strip(),
|
||
"catalog": {k: len(v) for k, v in name2id.items()},
|
||
}
|
||
|
||
|
||
@router.get("/capability-catalog")
|
||
def api_capability_catalog():
|
||
"""能力目录 + 规则库 + 缺口状态(供前端展示「哪些能力就绪 / 哪些是缺口」)"""
|
||
try:
|
||
from capability_matcher import load_capabilities, load_rules, catalog_fingerprint
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"技术优选层加载失败: {e}")
|
||
caps = load_capabilities()
|
||
rules = load_rules()
|
||
return {
|
||
"status": "ok",
|
||
"fingerprint": catalog_fingerprint(),
|
||
"capabilities": caps.get("capabilities", []),
|
||
"capability_needs": rules.get("capability_needs", {}),
|
||
"rules_count": len(rules.get("rules") or []),
|
||
}
|
||
|
||
|
||
@router.post("/storyboard/render")
|
||
def api_storyboard_render(data: dict):
|
||
"""分镜 → 出片(**后台执行**,立即返回 job_id,前端轮询进度)
|
||
|
||
Body:
|
||
storyboard: dict — 完整分镜(优先)
|
||
project_id: str — 或从已保存分镜读取
|
||
"""
|
||
import json as _json
|
||
import threading
|
||
import time as _time
|
||
import uuid
|
||
|
||
sb_data = data.get("storyboard")
|
||
if not sb_data and data.get("project_id"):
|
||
p = _ave_local_dir("storyboards") / f"{data['project_id']}.json"
|
||
if not p.exists():
|
||
raise HTTPException(404, detail=f"分镜不存在: {data['project_id']}")
|
||
sb_data = _json.loads(p.read_text(encoding="utf-8"))
|
||
if not sb_data:
|
||
raise HTTPException(400, detail="需要 storyboard 或 project_id")
|
||
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from schemas import Storyboard
|
||
from composer.pipeline import StoryboardPipeline
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"流水线不可用: {e}")
|
||
|
||
sb = Storyboard.from_dict(sb_data)
|
||
job_id = uuid.uuid4().hex[:12]
|
||
_start_job(job_id, sb)
|
||
|
||
_audio_first = bool((data or {}).get("audio_first", True)) # ★ 默认开启(音频先行是音画同步的前提)
|
||
_lipsync = bool((data or {}).get("lipsync", False))
|
||
_align = bool((data or {}).get("align_to_audio", True)) # ★ 默认开(画面变速无损)
|
||
_sfx = bool((data or {}).get("sfx_from_video", False)) # 视频生音效(¥0.25/镜头)默认关
|
||
threading.Thread(target=_render_worker,
|
||
args=(job_id, sb, False, _audio_first, _lipsync, _align, _sfx), daemon=True).start()
|
||
if _audio_first:
|
||
logger.info(" 🎙 本次出片启用「音频先行」(台词时长决定镜头时长)")
|
||
if _lipsync:
|
||
logger.info(" 🎭 本次出片启用「对口型」(仅有台词+有出场的镜头,¥0.08/秒)")
|
||
logger.info(" 🎬 出片任务启动: %s(%d 镜头)", job_id, len(sb.shots))
|
||
return {"status": "ok", "job_id": job_id,
|
||
"message": f"开始出片:{len(sb.shots)} 个镜头(约 {len(sb.shots) * 2} 分钟)"}
|
||
|
||
|
||
@router.get("/storyboard/render/{job_id}")
|
||
def api_render_status(job_id: str):
|
||
"""查询出片进度"""
|
||
with _render_lock():
|
||
job = _RENDER_JOBS.get(job_id)
|
||
if not job:
|
||
raise HTTPException(404, detail="任务不存在(或 Dashboard 已重启)")
|
||
return {"status": "ok", "job": job}
|
||
|
||
|
||
@router.post("/storyboard/render/{job_id}/resume")
|
||
def api_render_resume(job_id: str, data: dict = None):
|
||
"""续跑:复用已生成的镜头,只补缺失的(**已生成的镜头不重复扣费**)
|
||
|
||
Body: { audio_first?: bool } # 默认 true —— 补齐后自动配音混流(复用 pre_audio)
|
||
"""
|
||
_af = bool((data or {}).get("audio_first", True))
|
||
# ★ 续跑必须继承后处理开关 —— 否则「音画对齐 / 对口型 / 音效」会静默丢失
|
||
# (历史 bug:只传了 5 个参数 → 后三个用默认 False → 续跑出的片音画不同步且无任何提示)
|
||
import json as _json
|
||
import threading
|
||
import time as _time
|
||
|
||
jf = _job_json_path(job_id)
|
||
if not jf.exists():
|
||
raise HTTPException(404, detail=f"任务不存在(无持久化记录): {job_id}")
|
||
try:
|
||
j = _json.loads(jf.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
raise HTTPException(400, detail=f"任务记录损坏: {e}")
|
||
|
||
pid = j.get("project_id") or ""
|
||
sb_file = _ave_local_dir("storyboards") / f"{pid}.json"
|
||
if not (pid and sb_file.exists()):
|
||
raise HTTPException(404, detail=f"无法续跑:分镜 {pid or '(未知)'} 不存在(可能已被删除)")
|
||
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from schemas import Storyboard
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"模块加载失败: {e}")
|
||
|
||
sb = Storyboard.from_dict(_json.loads(sb_file.read_text(encoding="utf-8")))
|
||
with _render_lock():
|
||
cur = dict(_RENDER_JOBS.get(job_id) or j)
|
||
cur.update({"status": "running", "progress": [], "error": None,
|
||
"resumed_at": _time.time(), "resumed": True,
|
||
"resume_count": int(cur.get("resume_count") or 0) + 1})
|
||
_RENDER_JOBS[job_id] = cur
|
||
_persist_job(job_id)
|
||
|
||
# 从原任务记录继承(数据里显式给了就用显式值)
|
||
_align = bool((data or {}).get("align_to_audio", True)) # ★ 默认开
|
||
_ls = bool((data or {}).get("lipsync", j.get("lipsync") or False))
|
||
_sfx = bool((data or {}).get("sfx_from_video", j.get("sfx_from_video") or False))
|
||
|
||
threading.Thread(target=_render_worker,
|
||
args=(job_id, sb, True, _af, _ls, _align, _sfx), daemon=True).start()
|
||
logger.info(" ▶️ 续跑任务: %s(复用已生成镜头 · 对齐=%s · 口型=%s · 音效=%s)",
|
||
job_id, _align, _ls, _sfx)
|
||
return {"status": "ok", "job_id": job_id, "reused": True,
|
||
"message": "已开始续跑:复用已生成的镜头,只补缺失的(不重复扣费)"}
|
||
|
||
|
||
# 模块导入时恢复一次:把上个进程遗留的 running 任务标记为 interrupted
|
||
_restore_jobs()
|
||
|
||
|
||
@router.post("/storyboard/render/{job_id}/dub")
|
||
def api_render_dub(job_id: str, data: dict | None = None):
|
||
"""给成片加配音(TTS 人声 + 环境音铺底)→ final_muxed.mp4
|
||
|
||
⚠️ **会产生 TTS 费用**(按台词字数计,通常几分钱)
|
||
Body: { use_ambience?: bool = true, ambience_volume?: float = 0.15 }
|
||
"""
|
||
import json as _json
|
||
import threading
|
||
import time as _tm
|
||
data = data or {}
|
||
jf = _job_json_path(job_id)
|
||
if not jf.exists():
|
||
raise HTTPException(404, detail=f"任务不存在: {job_id}")
|
||
try:
|
||
j = _json.loads(jf.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
raise HTTPException(400, detail=f"任务记录损坏: {e}")
|
||
if j.get("status") == "running" and j.get("dubding"):
|
||
raise HTTPException(409, detail="该任务正在配音中,请稍候")
|
||
|
||
pid = j.get("project_id") or ""
|
||
sb_file = _ave_local_dir("storyboards") / f"{pid}.json"
|
||
if not (pid and sb_file.exists()):
|
||
raise HTTPException(404, detail=f"无法配音:分镜 {pid or '(未知)'} 不存在")
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from schemas import Storyboard
|
||
sb = Storyboard.from_dict(_json.loads(sb_file.read_text(encoding="utf-8")))
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"分镜读取失败: {e}")
|
||
|
||
lines = [s for s in sb.shots if (getattr(s, "text", "") or "").strip()]
|
||
if not lines:
|
||
raise HTTPException(400, detail="该分镜没有任何台词,无需配音(可在分镜卡片里填台词后再配音)")
|
||
|
||
use_amb = bool(data.get("use_ambience", True))
|
||
amb_vol = float(data.get("ambience_volume", 0.15))
|
||
with _render_lock():
|
||
cur = dict(_RENDER_JOBS.get(job_id) or j)
|
||
cur.update({"status": "running", "dubding": True, "progress": [],
|
||
"dub_error": None, "dubbing_at": _tm.time()})
|
||
_RENDER_JOBS[job_id] = cur
|
||
_persist_job(job_id)
|
||
threading.Thread(target=_dub_worker, args=(job_id, sb, use_amb, amb_vol),
|
||
daemon=True).start()
|
||
logger.info(" 🎙 开始配音: %s(%d 句台词)", job_id, len(lines))
|
||
return {"status": "ok", "job_id": job_id, "lines": len(lines),
|
||
"note": "配音会产生 TTS 费用(按字数,通常几分钱)",
|
||
"message": f"已开始配音({len(lines)} 句台词);进度见任务状态"}
|
||
|
||
|
||
@router.get("/generated-images")
|
||
def api_generated_images(limit: int = 200):
|
||
"""**已生成的图片索引**(扫描 `agent-local/runtime/ave/generated_images/`)
|
||
|
||
用途:让「生成过的图」一眼可见,避免找不到产物(用户反馈的核心痛点)。
|
||
"""
|
||
import datetime
|
||
from urllib.parse import quote
|
||
|
||
local = Path(os.environ.get("AGENT_LOCAL",
|
||
Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||
d = local / "runtime" / "ave" / "generated_images"
|
||
items = []
|
||
if d.exists():
|
||
for p in sorted(d.glob("*"), key=lambda x: -(x.stat().st_mtime if x.exists() else 0)):
|
||
if not p.is_file() or p.suffix.lower() not in (".png", ".jpg", ".jpeg", ".webp"):
|
||
continue
|
||
try:
|
||
st = p.stat()
|
||
items.append({
|
||
"name": p.name,
|
||
"size_kb": round(st.st_size / 1024),
|
||
"mtime": st.st_mtime,
|
||
"time": datetime.datetime.fromtimestamp(st.st_mtime).strftime("%m-%d %H:%M"),
|
||
"url": f"/api/ave/file?path={quote(str(p))}",
|
||
})
|
||
except Exception:
|
||
continue
|
||
return {"status": "ok", "total": len(items), "items": items[:limit]}
|
||
|
||
|
||
@router.get("/inventory")
|
||
def api_inventory():
|
||
"""📊 全面盘点:本地到底攒了多少产物(按目录/类型/大小)
|
||
|
||
回答「我现在到底有多少缓存记录/生产记录」—— 只读统计,不扫内容。
|
||
覆盖:renders(出片)· workflow(画布)· keyframe_videos(关键帧转视频)·
|
||
projects(工作室项目:导出/草图)· keyframes(静帧)· storyboards(分镜表)
|
||
"""
|
||
import datetime
|
||
local = Path(os.environ.get("AGENT_LOCAL",
|
||
Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||
ave = local / "runtime" / "ave"
|
||
out = {"status": "ok", "root": str(ave), "groups": [], "totals": {}}
|
||
if not ave.exists():
|
||
return {"status": "ok", "groups": [], "note": f"目录不存在: {ave}"}
|
||
|
||
def stat_of(sub: str, pattern: str) -> dict:
|
||
d = ave / sub
|
||
if not d.exists():
|
||
return {"dir": sub, "files": 0, "mb": 0, "dirs": 0, "latest": ""}
|
||
files = list(d.rglob(pattern))
|
||
size = sum(f.stat().st_size for f in files if f.is_file())
|
||
latest = max((f.stat().st_mtime for f in files), default=0)
|
||
return {
|
||
"dir": sub, "files": len(files), "mb": round(size / 1048576, 1),
|
||
"dirs": len([x for x in d.iterdir() if x.is_dir()]),
|
||
"latest": (datetime.datetime.fromtimestamp(latest).strftime("%m-%d %H:%M")
|
||
if latest else ""),
|
||
}
|
||
|
||
groups = [
|
||
stat_of("renders", "*.mp4"),
|
||
stat_of("workflow", "*.mp4"),
|
||
stat_of("keyframe_videos", "*.mp4"),
|
||
stat_of("projects", "*.mp4"),
|
||
stat_of("keyframes", "*.png"),
|
||
stat_of("projects/storyboard_sketch", "*.png"),
|
||
stat_of("storyboards", "*.json"),
|
||
stat_of("technical_plans", "*.json"),
|
||
stat_of("generated_images", "*.png"),
|
||
]
|
||
labels = {
|
||
"renders": "📋 看板出片(renders)",
|
||
"workflow": "🔀 流程画布(workflow)",
|
||
"keyframe_videos": "🎞 关键帧转视频(keyframe_videos)",
|
||
"projects": "🎙 工作室项目产物(projects)",
|
||
"keyframes": "🖼 关键帧静帧(keyframes)",
|
||
"projects/storyboard_sketch": "✏️ 故事板草图",
|
||
"storyboards": "🎬 分镜表(storyboards)",
|
||
"technical_plans": "🔧 技术方案(technical_plans)",
|
||
"generated_images": "🖼 早期生成图(generated_images)",
|
||
}
|
||
for g in groups:
|
||
g["label"] = labels.get(g["dir"], g["dir"])
|
||
if g["files"]:
|
||
out["groups"].append(g)
|
||
out["totals"] = {
|
||
"files": sum(g["files"] for g in out["groups"]),
|
||
"mb": round(sum(g["mb"] for g in out["groups"]), 1),
|
||
"groups": len(out["groups"]),
|
||
}
|
||
return out
|
||
|
||
|
||
@router.get("/artifacts")
|
||
def api_artifacts(limit: int = 400, group: bool = True, only_final: bool = False):
|
||
"""**全部产物索引**(统一产物中心)
|
||
|
||
扫描 agent-local/runtime/ave/ 下所有视频产物:
|
||
· renders/<job_id>/ —— 看板「一键出片」
|
||
· workflow/<run_id>/<node>/ —— 流程画布各节点产物
|
||
返回按时间倒序,含**可直接播放的 URL**。
|
||
"""
|
||
import datetime
|
||
from urllib.parse import quote
|
||
|
||
local = Path(os.environ.get("AGENT_LOCAL",
|
||
Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||
ave = local / "runtime" / "ave"
|
||
items = []
|
||
if ave.exists():
|
||
for p in ave.rglob("*.mp4"):
|
||
try:
|
||
rel = p.relative_to(ave)
|
||
parts = rel.parts
|
||
st = p.stat()
|
||
items.append({
|
||
"rel": str(rel),
|
||
"kind": parts[0] if parts else "",
|
||
"run_id": parts[1] if len(parts) > 1 else "",
|
||
"node": parts[2] if len(parts) > 2 else "",
|
||
"name": p.name,
|
||
"size_mb": round(st.st_size / 1048576, 2),
|
||
"mtime": st.st_mtime,
|
||
"time": datetime.datetime.fromtimestamp(st.st_mtime).strftime("%m-%d %H:%M"),
|
||
"url": f"/api/ave/file?path={quote(str(p))}",
|
||
})
|
||
except Exception:
|
||
continue
|
||
# ① 分类标注
|
||
STAGE_ORDER = {"final": 0, "concat": 1, "shot": 2, "deai": 3, "norm": 4, "other": 5}
|
||
STAGE_CN = {"final": "🎬 成片", "concat": "拼接(未混流)", "shot": "生成原始",
|
||
"deai": "去AI化后", "norm": "规格规范化", "other": "其他"}
|
||
for it in items:
|
||
nm = it["name"]
|
||
rel = it["rel"]
|
||
parts = rel.split("/")
|
||
# 所属流程(分组键):workflow/<run_id> 或 renders/<job_id>
|
||
if rel.startswith("workflow/") and len(parts) >= 2:
|
||
it["group_key"] = parts[1]
|
||
it["group_type"] = "workflow"
|
||
elif rel.startswith("renders/") and len(parts) >= 2:
|
||
it["group_key"] = parts[1]
|
||
it["group_type"] = "renders"
|
||
elif rel.startswith("keyframe_videos/") and len(parts) >= 2:
|
||
# 🎞 导演工作室「关键帧转视频」的产物:keyframe_videos/<pid>/<shot>/v*.mp4
|
||
it["group_key"] = parts[1]
|
||
it["group_type"] = "keyframe_videos"
|
||
it["stage"] = "shot"
|
||
it["stage_cn"] = "关键帧转视频"
|
||
elif rel.startswith("projects/") and len(parts) >= 2:
|
||
# 🎙 工作室「后期」导出的成片:projects/<pid>/export/*.mp4
|
||
it["group_key"] = parts[1]
|
||
it["group_type"] = "studio_export"
|
||
it["stage"] = "final" if "export" in parts else "other"
|
||
it["stage_cn"] = "工作室导出成片" if "export" in parts else "工作室中间产物"
|
||
else:
|
||
it["group_key"] = parts[0] if parts else "other"
|
||
it["group_type"] = "other"
|
||
if rel.startswith("renders/"):
|
||
it["stage"] = "final" if nm.startswith("final") else "other"
|
||
elif nm.startswith("final_muxed"):
|
||
it["stage"] = "final"
|
||
elif nm.startswith("final"):
|
||
it["stage"] = "concat"
|
||
elif nm.startswith("deai_"):
|
||
it["stage"] = "deai"
|
||
elif nm.startswith("norm_"):
|
||
it["stage"] = "norm"
|
||
elif nm.startswith("shot_"):
|
||
it["stage"] = "shot"
|
||
else:
|
||
it["stage"] = "other"
|
||
it["is_final"] = it["stage"] == "final"
|
||
it["stage_cn"] = STAGE_CN.get(it["stage"], it["stage"])
|
||
|
||
total_all = len(items)
|
||
|
||
# ② 按流程分组(一次完整流程 = 一组)
|
||
groups: dict = {}
|
||
for it in items:
|
||
g = groups.setdefault(it["group_key"], {
|
||
"key": it["group_key"], "type": it["group_type"],
|
||
"items": [], "final": None, "mtime": 0,
|
||
})
|
||
g["items"].append(it)
|
||
g["mtime"] = max(g["mtime"], it["mtime"])
|
||
# 成片:优先 final(mixed 后)→ 否则最后一个 final
|
||
if it["is_final"] and (g["final"] is None or it["mtime"] > g["final"]["mtime"]):
|
||
g["final"] = it
|
||
glist = []
|
||
for g in groups.values():
|
||
g["items"].sort(key=lambda x: (STAGE_ORDER.get(x["stage"], 9), x["name"]))
|
||
g["count"] = len(g["items"])
|
||
g["total_mb"] = round(sum(x["size_mb"] for x in g["items"]), 2)
|
||
fin = g["final"]
|
||
g["label"] = f"{g['type']}·{g['key']}"
|
||
g["time"] = (fin or g["items"][0])["time"] if g["items"] else ""
|
||
# 没有成片的组(纯中间产物)默认隐藏,但可通过 only_final=False 显示
|
||
glist.append(g)
|
||
glist.sort(key=lambda x: -x["mtime"])
|
||
# 有真成片的组优先
|
||
with_final = [g for g in glist if g["final"]]
|
||
without = [g for g in glist if not g["final"]]
|
||
ordered = with_final + (without if not only_final else [])
|
||
|
||
if not group:
|
||
items.sort(key=lambda x: -x["mtime"])
|
||
return {"status": "ok", "total": len(items), "total_all": total_all,
|
||
"items": items[:limit], "only_final": only_final}
|
||
return {"status": "ok", "total_groups": len(ordered),
|
||
"total_all": total_all, "only_final": only_final,
|
||
"groups": ordered[:max(1, limit // 5)]}
|
||
|
||
|
||
@router.get("/renders/{job_id}/qa")
|
||
def api_render_qa(job_id: str):
|
||
"""质检图(接触印相表)列表 —— 页面可直接查看口型/人物一致性
|
||
|
||
不存在时按需生成(纯本地 ffmpeg,零成本)。
|
||
"""
|
||
work = _ave_local_dir("renders") / job_id
|
||
if not work.exists():
|
||
raise HTTPException(404, detail=f"任务目录不存在: {job_id}")
|
||
qa_dir = work / "qa"
|
||
files = sorted(qa_dir.glob("*.jpg")) if qa_dir.exists() else []
|
||
if not files:
|
||
try:
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
from composer.qa import make_shot_qa
|
||
make_shot_qa(str(work))
|
||
files = sorted(qa_dir.glob("*.jpg"))
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"质检图生成失败: {e}")
|
||
return {
|
||
"status": "ok", "job_id": job_id, "count": len(files),
|
||
"sheets": [{"name": f.name, "size_kb": round(f.stat().st_size / 1024, 1),
|
||
"url": f"/api/ave/renders/{job_id}/qa/{f.name}"} for f in files],
|
||
}
|
||
|
||
|
||
@router.get("/renders/{job_id}/qa/{name}")
|
||
def api_render_qa_file(job_id: str, name: str):
|
||
"""返回单张质检图(受控:只允许 jpg,且在任务目录内)"""
|
||
from fastapi.responses import FileResponse
|
||
if not name.endswith(".jpg") or "/" in name or ".." in name:
|
||
raise HTTPException(400, detail="非法文件名")
|
||
p = _ave_local_dir("renders") / job_id / "qa" / name
|
||
if not p.exists():
|
||
raise HTTPException(404, detail="质检图不存在")
|
||
return FileResponse(str(p), media_type="image/jpeg")
|
||
|
||
|
||
@router.get("/renders")
|
||
def api_render_list():
|
||
"""出片记录(**内存任务 + 磁盘产物合并** —— Dashboard 重启后历史仍在)
|
||
|
||
进行中的任务附带进度摘要(percent / 当前步骤 / 已完成镜头数),
|
||
供「📋 生产记录」页与全局任务指示器实时展示。
|
||
"""
|
||
def _summary(v: dict) -> dict:
|
||
"""从累积的 progress 事件里算出人类可读的进度"""
|
||
prog = v.get("progress") or []
|
||
total = int(v.get("total_shots") or 0)
|
||
done = len({p.get("shot_id") for p in prog if p.get("status") == "done"})
|
||
last = prog[-1] if prog else {}
|
||
cur = (f"{last.get('shot_id', '')} {last.get('msg') or last.get('status', '')}").strip()
|
||
pct = int(done / total * 100) if total else 0
|
||
if v.get("status") == "done":
|
||
pct = 100
|
||
return {
|
||
"progress_done": done, "progress_total": total,
|
||
"percent": pct, "current_step": cur, "progress_events": len(prog),
|
||
}
|
||
|
||
jobs = [
|
||
{"job_id": k, **{kk: vv for kk, vv in v.items() if kk != "progress"}, **_summary(v)}
|
||
for k, v in sorted(_RENDER_JOBS.items(),
|
||
key=lambda x: x[1].get("started_at", 0), reverse=True)
|
||
]
|
||
known = {j["job_id"] for j in jobs}
|
||
disk: list = []
|
||
try:
|
||
renders_dir = _ave_local_dir("renders")
|
||
for d in sorted(renders_dir.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)[:60]:
|
||
if not d.is_dir() or d.name in known:
|
||
continue
|
||
# 优先读持久化状态(含 interrupted / 进度 / 可搜索信息)
|
||
jf = d / "job.json"
|
||
if jf.exists():
|
||
try:
|
||
import json as _json
|
||
j = _json.loads(jf.read_text(encoding="utf-8"))
|
||
j["job_id"] = j.get("job_id") or d.name
|
||
j["from_disk"] = True
|
||
final = d / "final.mp4"
|
||
if final.exists() and not j.get("output"):
|
||
j["output"] = str(final)
|
||
segs = sorted(d.glob("deai_*.mp4")) or sorted(d.glob("shot_*.mp4"))
|
||
j["segments"] = j.get("segments") or len(segs)
|
||
j["size_mb"] = round(final.stat().st_size / 1024 / 1024, 2) if final.exists() else 0
|
||
j.setdefault("delivery_status",
|
||
"候选成片" if final.exists() else "未完成/待验证")
|
||
j.setdefault("delivery_note",
|
||
"历史产物(来自磁盘)" if final.exists()
|
||
else (j.get("error") or "无成片文件"))
|
||
disk.append(j)
|
||
continue
|
||
except Exception:
|
||
pass
|
||
final = d / "final.mp4"
|
||
segs = sorted(d.glob("deai_*.mp4")) or sorted(d.glob("shot_*.mp4"))
|
||
size_mb = round(final.stat().st_size / 1024 / 1024, 2) if final.exists() else 0
|
||
disk.append({
|
||
"job_id": d.name,
|
||
"status": "done" if final.exists() else "unknown",
|
||
"output": str(final) if final.exists() else None,
|
||
"segments": len(segs),
|
||
"size_mb": size_mb,
|
||
"started_at": d.stat().st_mtime,
|
||
"delivery_status": "候选成片" if final.exists() else "未完成/待验证",
|
||
"delivery_note": "历史产物(来自磁盘,未经本轮审查)" if final.exists() else "无成片文件",
|
||
"from_disk": True,
|
||
})
|
||
except Exception as e:
|
||
logger.warning("扫描出片磁盘产物失败: %s", e)
|
||
return {"status": "ok", "jobs": jobs + disk, "disk_count": len(disk)}
|
||
|
||
|
||
@router.post("/storyboard/render/{job_id}/review")
|
||
def api_render_review(job_id: str):
|
||
"""用户确认「已完整播放审查」→ 交付状态由「候选成片」升级为「合格成片」
|
||
|
||
按 produce-ai-video 验收方法论:未经完整播放审查不得称合格。
|
||
"""
|
||
import time as _time
|
||
with _render_lock():
|
||
job = _RENDER_JOBS.get(job_id)
|
||
if not job:
|
||
raise HTTPException(404, detail="任务不存在(或 Dashboard 已重启)")
|
||
if job.get("status") != "done":
|
||
raise HTTPException(400, detail="任务尚未完成,不能确认")
|
||
if job.get("failed"):
|
||
raise HTTPException(400, detail=f"存在 {len(job['failed'])} 个失败镜头,不能标记为合格成片(请先重试失败镜头)")
|
||
job["delivery_status"] = "合格成片"
|
||
job["delivery_note"] = "用户已完整播放审查确认"
|
||
job["reviewed_at"] = _time.time()
|
||
logger.info(" ✅ 用户确认合格成片: %s", job_id)
|
||
return {"status": "ok", "delivery_status": "合格成片", "delivery_note": "用户已完整播放审查确认"}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 可灵 Webhook 回调(验签 + 自动转存产物)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
_CALLBACK_LOG: list = [] # 最近回调记录(内存态,供排查)
|
||
|
||
|
||
def _download_callback_files(info: dict) -> None:
|
||
"""后台转存回调产物(可灵资源 URL 30 天后清理,必须及时存)"""
|
||
import urllib.request
|
||
tid = info.get("task_id") or "unknown"
|
||
out = _ave_local_dir("callbacks") / tid
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
for i, f in enumerate(info.get("files") or [], 1):
|
||
url = f.get("url") or ""
|
||
if not url:
|
||
continue
|
||
t = f.get("type") or "file"
|
||
ext = {"video": ".mp4", "image": ".png", "audio": ".mp3"}.get(t, ".bin")
|
||
dest = out / f"{i:02d}_{t}{ext}"
|
||
try:
|
||
with urllib.request.urlopen(url, timeout=300) as r, open(dest, "wb") as fp:
|
||
fp.write(r.read())
|
||
logger.info(" 💾 回调产物已转存: %s/%s", tid, dest.name)
|
||
except Exception as e:
|
||
logger.warning(" 回调产物转存失败 %s: %s", url[:60], e)
|
||
|
||
|
||
@router.post("/kling/callback")
|
||
async def api_kling_callback(request: Request):
|
||
"""可灵 Webhook 回调入口(验签 → 解析 → 后台转存)
|
||
|
||
请求头:webhook-id / webhook-timestamp / webhook-signature
|
||
验签规范:`09_ave/scripts/lib/kling_webhook.py`(官方 HMAC-SHA256)
|
||
|
||
⚠️ 前提:可灵服务器需能访问本端点 → 需要**公网可达地址**。
|
||
若 Dashboard 仅在局域网/Tailscale(私有网络),可灵无法回调 → 此时继续用轮询即可。
|
||
"""
|
||
import json as _json
|
||
import threading
|
||
import time as _time
|
||
|
||
raw = await request.body()
|
||
wid = request.headers.get("webhook-id", "")
|
||
ts = request.headers.get("webhook-timestamp", "")
|
||
sig = request.headers.get("webhook-signature", "")
|
||
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
try:
|
||
from lib.kling_webhook import verify, parse_callback, load_webhook_secret
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"验签模块不可用: {e}")
|
||
|
||
ok, why = verify(load_webhook_secret(), raw, wid, ts, sig)
|
||
if not ok:
|
||
logger.warning(" ❌ 可灵回调验签失败: %s", why)
|
||
raise HTTPException(401, detail=f"验签失败: {why}")
|
||
|
||
try:
|
||
body = _json.loads(raw)
|
||
except Exception:
|
||
raise HTTPException(400, detail="body 不是合法 JSON")
|
||
|
||
info = parse_callback(body)
|
||
logger.info(" 📩 可灵回调: %s status=%s files=%d",
|
||
info.get("task_id"), info.get("status"), len(info.get("files") or []))
|
||
|
||
rec = {**info, "received_at": _time.time(), "webhook_id": wid}
|
||
_CALLBACK_LOG.append(rec)
|
||
del _CALLBACK_LOG[:-200] # 只保留最近 200 条
|
||
|
||
try:
|
||
d = _ave_local_dir("callbacks")
|
||
(d / f"{info.get('task_id') or wid}.json").write_text(
|
||
_json.dumps({**body, "_received_at": rec["received_at"]},
|
||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception as e:
|
||
logger.warning(" 回调落盘失败: %s", e)
|
||
|
||
if info.get("succeeded") and info.get("files"):
|
||
threading.Thread(target=_download_callback_files, args=(info,), daemon=True).start()
|
||
|
||
return {"status": "ok"} # 可灵要求快速返回 200
|
||
|
||
|
||
@router.get("/kling/callbacks")
|
||
def api_kling_callbacks():
|
||
"""最近的可灵回调记录(排查用)"""
|
||
return {"status": "ok", "count": len(_CALLBACK_LOG),
|
||
"callbacks": _CALLBACK_LOG[-20:]}
|
||
|
||
|
||
@router.get("/kling/webhook-info")
|
||
def api_kling_webhook_info():
|
||
"""Webhook 配置状态(不返回 secret 内容)"""
|
||
if str(_AVE_SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(_AVE_SCRIPTS))
|
||
try:
|
||
from lib.kling_webhook import load_webhook_secret
|
||
s = load_webhook_secret()
|
||
except Exception:
|
||
s = ""
|
||
return {
|
||
"status": "ok",
|
||
"secret_configured": bool(s),
|
||
"secret_hint": (s[:10] + "…" + s[-4:]) if s else "",
|
||
"callback_path": "/api/ave/kling/callback",
|
||
"note": "可灵需能访问本地址(需公网可达);仅内网/Tailscale 时请继续用轮询",
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 产物文件读取(预览用,限 ave 目录内)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
@router.get("/file")
|
||
def api_ave_file(path: str):
|
||
"""读取视频工厂产物文件(**仅限 agent-local/runtime/ave 与 tools/ave/assets 内**,防目录穿越)"""
|
||
from fastapi.responses import FileResponse
|
||
target = Path(path).resolve()
|
||
base = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local")).resolve()
|
||
allowed = [(base / "runtime" / "ave"), (base / "tools" / "ave" / "assets")]
|
||
if not any(str(target).startswith(str(a)) for a in allowed):
|
||
raise HTTPException(403, detail="路径不在允许范围内(仅 ave 产物/资产)")
|
||
if not target.exists():
|
||
raise HTTPException(404, detail="文件不存在")
|
||
return FileResponse(str(target))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 能力试跑(预检 dry_run + 真跑)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def _cap_preflight(cap: dict, params: dict) -> dict:
|
||
"""预检:解析将使用的供应商 / 模型 / 合并后的参数 / 密钥状态(不调用任何外部 API)"""
|
||
prov_id = params.get("provider") or (cap.get("defaults") or {}).get("provider") or ""
|
||
prov = next((p for p in (cap.get("providers") or []) if p.get("id") == prov_id), {})
|
||
merged = {**(cap.get("defaults") or {}), **{k: v for k, v in (params or {}).items() if k != "provider"}}
|
||
return {
|
||
"capability_id": cap.get("id"),
|
||
"capability_name": cap.get("name"),
|
||
"status": cap.get("status"),
|
||
"provider": prov_id,
|
||
"provider_name": prov.get("name") or prov_id,
|
||
"model": prov.get("model") or "",
|
||
"key_ref": prov.get("key_ref") or "",
|
||
"key_ready": bool(prov.get("key_configured")),
|
||
"params": merged,
|
||
"impl": cap.get("impl") or "",
|
||
"execution": cap.get("execution") or "local",
|
||
}
|
||
|
||
|
||
@router.post("/capabilities/{cap_id}/test")
|
||
def api_test_capability(cap_id: str, data: dict):
|
||
"""试跑能力
|
||
|
||
Body:
|
||
params: dict — 覆盖默认参数(如 prompt / text / duration)
|
||
dry_run: bool — **默认 True**:只预检(不调外部 API、零费用)
|
||
设为 False 才真正调用(**可能产生费用**)
|
||
|
||
当前已接入真跑的实现:文本/图片 → 视频(可灵 kling-v3)。
|
||
其余能力返回「预检结果 + 实现未接入」,便于确认配置无误后再接入。
|
||
"""
|
||
params = data.get("params") or {}
|
||
dry_run = data.get("dry_run", True)
|
||
reg = _registry()
|
||
cap = reg.get(cap_id)
|
||
if not cap:
|
||
raise HTTPException(404, detail=f"能力不存在: {cap_id}")
|
||
|
||
pre = _cap_preflight(cap, params)
|
||
|
||
if dry_run:
|
||
return {"status": "ok", "dry_run": True, "preflight": pre,
|
||
"message": "预检完成(未调用外部 API、未产生费用)"}
|
||
|
||
# ── 真跑 ─────────────────────────────────────────
|
||
if not pre["key_ready"]:
|
||
raise HTTPException(400, detail=f"供应商「{pre['provider_name']}」的密钥未配置({pre['key_ref']})→ 去「🔑 API 配置」填写")
|
||
|
||
import sys as _sys
|
||
_scripts = Path(__file__).resolve().parents[2] / "09_ave" / "scripts"
|
||
if str(_scripts) not in _sys.path:
|
||
_sys.path.insert(0, str(_scripts))
|
||
|
||
out_dir = _ave_local_dir("cap_tests")
|
||
prompt = params.get("prompt") or params.get("text") or ""
|
||
|
||
if cap_id in ("text_to_video", "image_to_video"):
|
||
try:
|
||
from capabilities.kling_video import KlingVideoClient, KlingError
|
||
except Exception as e:
|
||
raise HTTPException(500, detail=f"可灵引擎不可用: {e}")
|
||
client = KlingVideoClient()
|
||
if not client.available:
|
||
raise HTTPException(400, detail="可灵未配置 api_key")
|
||
dest = out_dir / f"test_{cap_id}_{int(__import__('time').time())}.mp4"
|
||
try:
|
||
p = client.generate_video(
|
||
prompt or "一只猫在阳光下打盹",
|
||
dest,
|
||
image_path=params.get("image") or params.get("image_path") or "",
|
||
duration=str(params.get("duration") or 5),
|
||
aspect_ratio=params.get("aspect_ratio") or "9:16",
|
||
)
|
||
except KlingError as e:
|
||
raise HTTPException(400, detail=str(e))
|
||
logger.info(" 🧪 能力试跑 %s → %s", cap_id, p)
|
||
return {"status": "ok", "dry_run": False, "preflight": pre,
|
||
"output": str(p), "message": "试跑完成(已产生费用)"}
|
||
|
||
# 其他能力:尚未接入真跑
|
||
return {"status": "ok", "dry_run": True, "preflight": pre,
|
||
"not_implemented": True,
|
||
"message": f"「{pre['capability_name']}」的真跑实现尚未接入(预检可通过)。"
|
||
f"实现位置:{pre['impl'] or '待补'}"}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 视频工厂设置(全局默认:去AI化 / 输出规格 / 渲染)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
DEFAULT_SETTINGS = {
|
||
"de_ai": {"enabled": True, "blur_sigma": 0.4, "grain": 12,
|
||
"saturation": -0.12, "temperature": 5300, "crf": 23},
|
||
"output": {"resolution": "1080x1920", "fps": 30, "bitrate": "8M"},
|
||
"render": {"max_workers": 3, "kling_model": "kling-v3", "kling_duration": "5"},
|
||
# 💰 费用(用于运行前的费用预估弹框;按供应商实际单价调整)
|
||
"cost": {"kling_price_per_sec": 0.6, "max_shots_per_run": 5},
|
||
}
|
||
|
||
|
||
def _settings_path() -> Path:
|
||
base = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||
return base / "tools" / "ave" / "config" / "settings.yaml"
|
||
|
||
|
||
@router.get("/settings")
|
||
def api_get_settings():
|
||
"""读视频工厂全局设置(文件不存在时返回默认值)"""
|
||
import yaml
|
||
p = _settings_path()
|
||
data = {}
|
||
if p.exists():
|
||
try:
|
||
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||
except Exception as e:
|
||
logger.warning("settings.yaml 解析失败: %s", e)
|
||
merged = {}
|
||
for k, v in DEFAULT_SETTINGS.items():
|
||
merged[k] = {**v, **(data.get(k) or {})} if isinstance(v, dict) else data.get(k, v)
|
||
return {"status": "ok", "settings": merged, "defaults": DEFAULT_SETTINGS,
|
||
"path": str(p), "exists": p.exists()}
|
||
|
||
|
||
@router.post("/settings")
|
||
def api_save_settings(data: dict):
|
||
"""保存视频工厂全局设置(按分组合并写入 settings.yaml)"""
|
||
import yaml
|
||
p = _settings_path()
|
||
cur: dict = {}
|
||
if p.exists():
|
||
try:
|
||
cur = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||
except Exception:
|
||
cur = {}
|
||
for k, v in (data.get("settings") or {}).items():
|
||
if k not in DEFAULT_SETTINGS:
|
||
continue # 白名单,防写入未知键
|
||
if isinstance(DEFAULT_SETTINGS[k], dict) and isinstance(v, dict):
|
||
cur[k] = {**(cur.get(k) or {}), **v}
|
||
else:
|
||
cur[k] = v
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(yaml.safe_dump(cur, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||
logger.info(" ⚙️ 视频工厂设置已保存: %s", p)
|
||
return {"status": "ok", "settings": cur, "path": str(p)}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 费用统计(估算,**只读不产生费用**)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
@router.get("/costs")
|
||
def api_costs():
|
||
"""视频工厂费用统计(**估算**,以供应商账单为准)
|
||
|
||
· 可灵:成功任务数 / 总时长 → 按官方价估算
|
||
· 出片:记录数 / 段数 / 总大小
|
||
· AI 文本:调用次数 / token(读 agent-local/runtime/ai_usage.jsonl)
|
||
"""
|
||
import json
|
||
out: dict = {"status": "ok", "kling": {}, "renders": {}, "ai_text": {}, "estimate": {}}
|
||
base = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"))
|
||
|
||
# ① 可灵任务(只读查询,不产生费用)
|
||
try:
|
||
import sys as _sys
|
||
_scripts = Path(__file__).resolve().parents[2] / "09_ave" / "scripts"
|
||
if str(_scripts) not in _sys.path:
|
||
_sys.path.insert(0, str(_scripts))
|
||
from capabilities.kling_video import KlingVideoClient
|
||
c = KlingVideoClient()
|
||
if c.available:
|
||
by_kind = {"text2video": {"tasks": 0, "seconds": 0.0},
|
||
"image2video": {"tasks": 0, "seconds": 0.0}}
|
||
for kind in ("text2video", "image2video"):
|
||
try:
|
||
d = c._get(f"/v1/videos/{kind}")
|
||
for t in (d.get("data") or []):
|
||
if t.get("task_status") != "succeed":
|
||
continue
|
||
vids = (t.get("task_result") or {}).get("videos") or []
|
||
dur = 5.0
|
||
if vids:
|
||
try:
|
||
dur = float(vids[0].get("duration") or 5)
|
||
except Exception:
|
||
dur = 5.0
|
||
by_kind[kind]["tasks"] += 1
|
||
by_kind[kind]["seconds"] += dur
|
||
except Exception as e:
|
||
logger.debug("可灵 %s 任务查询失败: %s", kind, e)
|
||
tasks = sum(v["tasks"] for v in by_kind.values())
|
||
secs = round(sum(v["seconds"] for v in by_kind.values()), 1)
|
||
out["kling"] = {"tasks": tasks, "total_seconds": secs, "by_kind": by_kind,
|
||
"unit_price": "¥0.6/秒(Kling 3.0 无声 720P,估算)"}
|
||
out["estimate"]["kling_cost"] = round(secs * 0.6, 2)
|
||
# ★ 官方免费接口:资源包余量(**真实数据**,非估算;有 12h 延迟)
|
||
try:
|
||
out["kling_account"] = c.get_account_costs(30)
|
||
except Exception as e:
|
||
out["kling_account"] = {"error": str(e)[:120]}
|
||
else:
|
||
out["kling"] = {"error": "可灵未配置 api_key"}
|
||
except Exception as e:
|
||
out["kling"] = {"error": str(e)[:120]}
|
||
|
||
# ② 出片记录
|
||
try:
|
||
jobs = api_render_list().get("jobs", [])
|
||
out["renders"] = {
|
||
"count": len(jobs),
|
||
"finished": sum(1 for j in jobs if j.get("status") == "done"),
|
||
"segments": sum(j.get("segments") or 0 for j in jobs),
|
||
"total_mb": round(sum(j.get("size_mb") or 0 for j in jobs), 1),
|
||
}
|
||
except Exception as e:
|
||
out["renders"] = {"error": str(e)[:120]}
|
||
|
||
# ③ AI 文本消耗
|
||
try:
|
||
p = base / "runtime" / "ai_usage.jsonl"
|
||
calls = in_tok = out_tok = 0
|
||
if p.exists():
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
try:
|
||
d = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
calls += 1
|
||
in_tok += int(d.get("prompt_tokens") or d.get("input_tokens") or 0)
|
||
out_tok += int(d.get("completion_tokens") or d.get("output_tokens") or 0)
|
||
out["ai_text"] = {"calls": calls, "input_tokens": in_tok, "output_tokens": out_tok,
|
||
"unit_price": "DeepSeek 按 token 计费"}
|
||
except Exception as e:
|
||
out["ai_text"] = {"error": str(e)[:120]}
|
||
|
||
return out
|