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