Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/routes/ave.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

1824 lines
80 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.
"""视频工厂 — 能力注册表接口(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