"""视频工厂 — 能力注册表接口(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// —— 看板「一键出片」 · workflow/// —— 流程画布各节点产物 返回按时间倒序,含**可直接播放的 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/ 或 renders/ 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///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//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