""" capability_matcher — 技术优选层(创作意图 → 技术方案 + 缺口报告) 职责: 1. 读分镜(Shot 列表,含 tech_requirements 技术需求声明) 2. 对照能力目录(capabilities.yaml)与可行性规则库(feasibility_rules.yaml) 3. 产出每个镜头的「技术方案」(用什么能力/引擎/参数)与「缺口清单」 4. 计算能力目录指纹(版本+内容 hash),供「能力更新后自动升级方案」使用 设计原则: · 导演只管创作表达,不指定工具;本层负责把需求翻译成技术方案 · 缺口是显性产物:无能力满足的需求 → 缺口清单 → 驱动能力建设 · 能力目录更新 → 指纹变化 → 自动标记「方案可升级 / 缺口已解决」 用法: from capability_matcher import match_storyboard, catalog_fingerprint, check_upgradable plan = match_storyboard(storyboard_dict) fp = catalog_fingerprint() up = check_upgradable(old_plan) """ from __future__ import annotations import hashlib import sys from datetime import datetime, timezone from pathlib import Path import yaml _SCRIPTS = Path(__file__).resolve().parent if str(_SCRIPTS) not in sys.path: sys.path.insert(0, str(_SCRIPTS)) from lib.logger import get_logger # noqa: E402 log = get_logger("capability_matcher") _AVE_DIR = _SCRIPTS.parent _CAPABILITIES = _AVE_DIR / "config" / "capabilities.yaml" _RULES = _AVE_DIR / "config" / "feasibility_rules.yaml" # 生成能力 → 能力目录 id 映射(分镜 generation_capability / type → capability id) _CAP_MAP = { "text_to_video": "text_to_video", "image_to_video": "image_to_video", "vfx": "vfx_apply", "comfyui": "comfyui_workflow", "3d_character": "character_3d", } def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") # ════════════════════════════════════════════════════════════ # 目录加载与指纹 # ════════════════════════════════════════════════════════════ def load_capabilities() -> dict: try: return yaml.safe_load(_CAPABILITIES.read_text(encoding="utf-8")) or {} except Exception as e: log.warning("能力目录读取失败: %s", e) return {} def load_rules() -> dict: try: return yaml.safe_load(_RULES.read_text(encoding="utf-8")) or {} except Exception as e: log.warning("规则库读取失败: %s", e) return {} def catalog_fingerprint() -> dict: """能力目录 + 规则库的内容指纹(版本号 + hash,两者任一变化都能检测)""" def _h(p: Path) -> str: try: return hashlib.sha256(p.read_bytes()).hexdigest()[:16] except Exception: return "" caps = load_capabilities() rules = load_rules() return { "caps_version": caps.get("version", ""), "caps_updated": caps.get("updated", ""), "caps_hash": _h(_CAPABILITIES), "rules_version": rules.get("version", ""), "rules_hash": _h(_RULES), } def _cap_index() -> dict: """能力 id → 能力定义""" caps = load_capabilities().get("capabilities", []) or [] return {c.get("id"): c for c in caps if c.get("id")} # ════════════════════════════════════════════════════════════ # 规则引擎 # ════════════════════════════════════════════════════════════ def _rule_hit(rule: dict, shot: dict) -> bool: """判断单条规则是否命中该镜头""" tr = shot.get("tech_requirements") or {} cont = (tr.get("continuity") or {}) tg = rule.get("trigger") or {} t = tg.get("type", "") if t == "shot_duration_over": try: return float(shot.get("duration") or 0) > float(tg.get("value") or 0) except Exception: return False if t == "duration_and_motion": try: over = float(shot.get("duration") or 0) > float(tg.get("duration_over") or 0) except Exception: over = False return over and (tr.get("motion_level") in (tg.get("motion_in") or [])) if t == "text_fit": text = shot.get("text") or "" if not text: return False try: need = len(str(text).strip()) / float(tg.get("chars_per_sec") or 4.5) return need > float(shot.get("duration") or 0) + 0.3 # 容差 except Exception: return False if t == "tech_flag": return tr.get(tg.get("field", "")) == tg.get("value") if t == "tech_continuity": return cont.get(tg.get("field", "")) in (tg.get("values") or []) if t == "shot_or_need": if shot.get("type") == tg.get("shot_type"): return True kws = tg.get("need_keywords") or [] blob = " ".join([str(shot.get("visual_description") or ""), str(shot.get("action") or "")] + [str(n) for n in (tr.get("needs") or [])]) return any(k in blob for k in kws) if t == "need_keywords": blob = " ".join([str(shot.get("visual_description") or ""), str(shot.get("action") or "")] + [str(n) for n in (tr.get("needs") or [])]) return any(k in blob for k in (tg.get("keywords") or [])) if t == "manual_check": return False # 人工核对项,不在自动匹配中命中(由前端提示) return False # ════════════════════════════════════════════════════════════ # 基础能力判定(镜头 → 用哪个生成能力) # ════════════════════════════════════════════════════════════ def base_capability(shot: dict) -> str: """镜头的生成能力:显式指定 > 按类型推导 talking_head → image_to_video(绑角色)或 text_to_video vfx → vfx_apply;comfyui → comfyui_workflow;3d_character → character_3d 其他 → image_to_video(有角色锚点)或 text_to_video """ explicit = (shot.get("generation_capability") or "").strip() if explicit: return _CAP_MAP.get(explicit, explicit) t = shot.get("type") or "action" if t in ("vfx",): return "vfx_apply" if t == "comfyui": return "comfyui_workflow" if t == "3d_character": return "character_3d" # 绑了角色(含多角色演员表 cast_slots)或口播 → 图生视频 _slots = [s for s in (shot.get("cast_slots") or []) if s] if (shot.get("character_id") or "").strip() or _slots or t == "talking_head": return "image_to_video" return "text_to_video" # ════════════════════════════════════════════════════════════ # 主入口:匹配 # ════════════════════════════════════════════════════════════ def match_storyboard(storyboard: dict | list, project_id: str = "") -> dict: """分镜 → 技术方案 + 缺口报告 参数 storyboard: Storyboard.to_dict() 或 Shot 列表 返回: { project_id, generated_at, catalog: {...指纹}, shots: [ {shot_id, capability_id, capability_name, provider, maturity, plan_status, issues:[{rule_id,name,level,problem,action}], duration_slot } ], gaps: [ {need_key, name, status, problem, action, shot_ids:[...]} ], summary: {total, ok, downgraded, blocked, gap_count} } """ shots = storyboard.get("shots", []) if isinstance(storyboard, dict) else (storyboard or []) if isinstance(storyboard, dict) and not project_id: project_id = storyboard.get("project_id", "") caps = _cap_index() rules = (load_rules().get("rules") or []) needs_map = load_rules().get("capability_needs") or {} shot_plans: list[dict] = [] gap_acc: dict[str, dict] = {} for s in shots: sid = s.get("shot_id", "") cap_id = base_capability(s) cap = caps.get(cap_id) or {} maturity = cap.get("status", "unknown") providers = cap.get("providers") or [] provider = "" if providers and isinstance(providers[0], dict): provider = providers[0].get("name", "") # 跑规则 issues = [] for r in rules: if r.get("level") == "info": continue # info 不进问题列表(可另作提示) if _rule_hit(r, s): issues.append({ "rule_id": r.get("id"), "name": r.get("name"), "level": r.get("level"), "nature": r.get("nature", ""), "problem": r.get("problem", ""), "action": r.get("action", ""), }) nk = r.get("need") if nk: g = gap_acc.setdefault(nk, { "need_key": nk, "name": (needs_map.get(nk) or {}).get("name", nk), "status": (needs_map.get(nk) or {}).get("status", "unknown"), "note": (needs_map.get(nk) or {}).get("note", ""), "problem": r.get("problem", ""), "action": r.get("action", ""), "shot_ids": [], }) if sid and sid not in g["shot_ids"]: g["shot_ids"].append(sid) # 方案状态 if any(i["level"] == "blocking" for i in issues): status = "blocked" elif issues: status = "downgraded" elif maturity in ("planned", "missing"): status = "blocked" else: status = "ok" # 时长档位映射(生成能力档位:5s / 10s) try: dur = float(s.get("duration") or 0) except Exception: dur = 0.0 slot = 10 if dur > 5 else 5 if dur > 10: slot = None # 超限,需切条/接续 shot_plans.append({ "shot_id": sid, "capability_id": cap_id, "capability_name": cap.get("name", cap_id), "provider": provider, "maturity": maturity, "duration_slot": slot, "plan_status": status, "issues": issues, "manual_override": bool(s.get("tech_plan_manual")), }) gaps = sorted(gap_acc.values(), key=lambda g: g["need_key"]) summary = { "total": len(shot_plans), "ok": sum(1 for p in shot_plans if p["plan_status"] == "ok"), "downgraded": sum(1 for p in shot_plans if p["plan_status"] == "downgraded"), "blocked": sum(1 for p in shot_plans if p["plan_status"] == "blocked"), "gap_count": len(gaps), } return { "project_id": project_id, "generated_at": _now(), "catalog": catalog_fingerprint(), "shots": shot_plans, "gaps": gaps, "summary": summary, } # ════════════════════════════════════════════════════════════ # 能力更新 → 自动升级检测 # ════════════════════════════════════════════════════════════ def check_upgradable(old_plan: dict) -> dict: """对比旧技术方案的能力目录指纹与当前指纹 返回: { outdated: bool, reason: str, resolved_gaps: [ {need_key, name, was_status, now_status} ], # 缺口已解决 affected_shots: [shot_id], # 需重算的镜头 summary: {resolved, still_open} } """ if not old_plan: return {"outdated": False, "reason": "无历史方案", "resolved_gaps": [], "affected_shots": [], "summary": {"resolved": 0, "still_open": 0}} old_fp = old_plan.get("catalog") or {} now_fp = catalog_fingerprint() changed = (old_fp.get("caps_hash") != now_fp.get("caps_hash") or old_fp.get("rules_hash") != now_fp.get("rules_hash")) needs_map = load_rules().get("capability_needs") or {} resolved, still_open, affected = [], [], [] for g in (old_plan.get("gaps") or []): nk = g.get("need_key") was = g.get("status") now = (needs_map.get(nk) or {}).get("status", "missing") if was in ("missing", "planned") and now in ("ready", "partial"): resolved.append({"need_key": nk, "name": g.get("name", nk), "was_status": was, "now_status": now}) affected.extend(g.get("shot_ids") or []) else: still_open.append(nk) if not changed: reason = "能力目录未变化" elif resolved: reason = f"能力目录已更新:{len(resolved)} 项缺口已解决,可重新生成方案" else: reason = "能力目录已更新(无缺口变化)" return { "outdated": changed, "reason": reason, "resolved_gaps": resolved, "affected_shots": sorted(set(affected)), "summary": {"resolved": len(resolved), "still_open": len(still_open)}, } # ── CLI 自测 ── if __name__ == "__main__": import json fp = catalog_fingerprint() print("目录指纹:", json.dumps(fp, ensure_ascii=False)) demo = { "project_id": "demo", "shots": [ {"shot_id": "shot_01", "duration": 4, "type": "talking_head", "text": "你好,这是测试台词。", "character_id": "c1", "tech_requirements": {"continuity": {"character": "must", "expression": "prefer"}, "chain_from_prev": False, "motion_level": "small", "camera_difficulty": "fixed", "needs": []}}, {"shot_id": "shot_02", "duration": 8, "type": "action", "tech_requirements": {"continuity": {"location": "must"}, "chain_from_prev": True, "motion_level": "large", "camera_difficulty": "complex", "needs": ["子弹时间"]}}, {"shot_id": "shot_03", "duration": 12, "type": "action", "text": "", "tech_requirements": {"needs": ["手部特写"]}}, ], } plan = match_storyboard(demo) print("\n方案摘要:", json.dumps(plan["summary"], ensure_ascii=False)) for p in plan["shots"]: print(f" {p['shot_id']}: {p['capability_name']} slot={p['duration_slot']} status={p['plan_status']} issues={[i['rule_id'] for i in p['issues']]}") print("\n缺口:") for g in plan["gaps"]: print(f" [{g['status']}] {g['name']} ← {g['shot_ids']}")