""" routes/ave_assets.py — 视频工厂资源库接口 ============================================ 按《DCS 说明书 v2.0》第五节的资产目录规范,为「资源库」菜单下的各库 (角色/场景/道具/产品/服装/音色/BGM/音效/特效预设)提供统一 CRUD 接口。 接口({kind} = characters | locations | real_locations | products | props | wardrobe | voices | bgm | sfx | vfx_presets): GET /api/ave/assets/{kind} 列表(q 搜索 + filters 筛选 + sort 排序) GET /api/ave/assets/{kind}/stats 统计 GET /api/ave/assets/{kind}/{aid} 详情 POST /api/ave/assets/{kind} 新建 PATCH /api/ave/assets/{kind}/{aid} 更新 DELETE /api/ave/assets/{kind}/{aid} 删除(默认软删到 _trash/) POST /api/ave/assets/{kind}/{aid}/file 上传文件(参考图/音色) GET /api/ave/assets/{kind}/{aid}/file/{name} 读取文件(预览用) POST /api/ave/assets/migrate 从旧角色库迁移 """ from __future__ import annotations import logging import os import sys from pathlib import Path from fastapi import APIRouter, File, HTTPException, UploadFile from fastapi.responses import FileResponse logger = logging.getLogger("dashboard.ave_assets") router = APIRouter(prefix="/api/ave/assets", tags=["ave-assets"]) def _resolve_sync_root() -> Path: """agent-sync 仓库根目录 环境变量 AGENT_SYNC 优先(但必须真实有效),否则按文件位置推导。 ⚠️ 原实现默认值少了一层(parents[2] = 05_tools),在环境变量缺失时会算错; 本函数按 `routes/ave_assets.py` 上推 4 层 = agent-sync 根。 """ env = os.environ.get("AGENT_SYNC", "") if env: p = Path(env) if (p / "05_tools").is_dir(): return p return Path(__file__).resolve().parents[3] AGENT_SYNC = _resolve_sync_root() _AVE_SCRIPTS = AGENT_SYNC / "05_tools" / "09_ave" / "scripts" def _store(kind: str): """惰性取仓库实例""" if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) try: from assets_manager import AssetStore, CharacterStore, ASSET_DIRS except Exception as e: raise HTTPException(500, detail=f"资源库模块不可用: {e}") if kind not in ASSET_DIRS: raise HTTPException(400, detail=f"未知资产类型: {kind}(可选: {list(ASSET_DIRS)})") return CharacterStore() if kind == "characters" else AssetStore(kind) def _safe_name(name: str) -> str: """防目录穿越""" n = Path(name).name if not n or n.startswith(".") or "/" in n or "\\" in n: raise HTTPException(400, detail="非法文件名") return n # ── 列表 / 详情 / 统计 ────────────────────────────────── # ═══════════════════════════════════════════════════════════ # 角色结构化字段(分层)+ 按层生成定妆照 # ═══════════════════════════════════════════════════════════ def _load_prompt_assembler(): """加载 character_generator.prompt_assembler(含 CHAR_LAYERS / CharacterPromptSystem)""" if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) import importlib return importlib.import_module("character_generator.prompt_assembler") @router.get("/character-layers") def api_character_layers(): """角色**结构化字段定义**(供前端自动生成表单) 返回 10 主层 + 子层(每层 label/icon/fields[key,label,type,options]) + 定妆照视角(variant)清单 + 可用生成引擎。 """ try: mod = _load_prompt_assembler() except Exception as e: raise HTTPException(500, detail=f"字段定义加载失败: {e}") layers = [] for lid, ldef in (getattr(mod, "CHAR_LAYERS", {}) or {}).items(): subs = (ldef.get("sub_layers") or {}) layers.append({ "id": lid, "label": ldef.get("label", lid), "icon": ldef.get("icon", ""), "order": ldef.get("order", 99), "fields": ldef.get("fields") or [], "sub_layers": [ {"id": sid, "label": s.get("label", sid), "icon": s.get("icon", ""), "fields": s.get("fields") or []} for sid, s in subs.items() ], }) layers.sort(key=lambda x: x.get("order", 99)) return { "status": "ok", "layers": layers, "variants": [ {"id": "front_face", "label": "正面定妆照", "desc": "半身正面 · 看清五官"}, {"id": "full_body", "label": "全身照", "desc": "完整身材 + 服装"}, {"id": "grid", "label": "多视角九宫格", "desc": "正/侧/背 多角度一致性"}, {"id": "scene", "label": "场景照", "desc": "带背景环境"}, {"id": "expression", "label": "表情特写", "desc": "情绪表达"}, ], "engines": [ {"id": "kling", "label": "可灵(Kling)", "key_ref": "kling.api_key", "note": "图片额度需单独充值(与视频额度分开计费)"}, {"id": "jimeng", "label": "即梦(Seedream 4.0)", "key_ref": "volcano.ark_api_key", "note": "字节模型,成本更低;需先在火山方舟控制台开通该模型"}, ], } def _polish_prompt(p: str) -> str: """清理组装器输出的**空字段标签**(如 '鼻梁。' '身穿。' '神色。')与多余标点 做法:从 CHAR_LAYERS 收集所有「层/字段的中文标签」,出现「标签。」(后面没跟值) 即视为空字段残留 → 移除。 """ import re if not p: return p s = p for lb in sorted(_empty_label_set(), key=len, reverse=True): # 长标签优先 s = s.replace(f'{lb}。', '').replace(f'{lb},', '') s = re.sub(r'。{2,}', '。', s) s = re.sub(r',{2,}', ',', s) s = re.sub(r'^[。,、\s]+', '', s) return s.strip('。,、 \n') _LABEL_CACHE = None def _empty_label_set() -> set: """从 CHAR_LAYERS 收集可能的「空字段标签」中文词(用于清理残留)""" global _LABEL_CACHE if _LABEL_CACHE is not None: return _LABEL_CACHE s: set = set() # ① 组装器模板里的「固定前缀词」(形如 f"身穿{val}")→ 取值为空时残留成 "身穿。" s.update({"身穿", "神色", "身高", "体型", "鼻梁", "鼻头", "鼻翼", "唇型", "唇色", "眉型", "眉色", "发型", "发色"}) try: mod = _load_prompt_assembler() for lid, L in (getattr(mod, "CHAR_LAYERS", {}) or {}).items(): lb = (L.get("label") or "").strip() if lb: s.add(lb) for f in (L.get("fields") or []): fl = (f.get("label") or "").strip() if fl: s.add(fl) for sid, S in (L.get("sub_layers") or {}).items(): sb = (S.get("label") or "").strip() if sb: s.add(sb) for f in (S.get("fields") or []): fl = (f.get("label") or "").strip() if fl: s.add(fl) except Exception: pass _LABEL_CACHE = s return s # ════════════════════════════════════════════════════════════ # 角色参考图「质检」(生成后检查背景是否干净) # ════════════════════════════════════════════════════════════ # 为什么要检查:参考图带场景背景 → 图生视频时模型会把背景绑定到角色上 # → 生成画面环境错乱。判据:图片四条边缘的颜色标准差(纯色背景 < 12)。 @router.get("/characters/{aid}/quality") def api_character_quality(aid: str): """检查单个角色参考图质量(零成本,纯本地计算)""" try: from character_generator.quality_check import check_reference_quality except Exception as e: raise HTTPException(500, detail=f"质检模块加载失败: {e}") st = _store("characters") it = st.get(aid) if not it: raise HTTPException(404, detail=f"角色不存在: {aid}") refs = it.get("reference_images") or {} results = {} for slot, fname in refs.items(): fpath = Path(st.kind_dir(aid)) / Path(str(fname)).name results[slot] = check_reference_quality(str(fpath)) ok_all = all(r.get("ok") for r in results.values()) if results else False return {"aid": aid, "name": it.get("name", aid), "ok": ok_all, "results": results, "no_reference": not bool(refs)} @router.get("/characters-quality/all") def api_characters_quality_all(): """批量质检:角色库里所有参考图(给页面挂徽章用)""" try: from character_generator.quality_check import check_reference_quality except Exception as e: raise HTTPException(500, detail=f"质检模块加载失败: {e}") st = _store("characters") out = [] for it in st.list(): aid = it.get("id") refs = it.get("reference_images") or {} if not refs: out.append({"aid": aid, "name": it.get("name", aid), "ok": False, "no_reference": True, "worst_score": 0, "issues": ["无参考图"]}) continue worst = 100 issues: list = [] for slot, fname in refs.items(): fpath = Path(st.kind_dir(aid)) / Path(str(fname)).name r = check_reference_quality(str(fpath)) worst = min(worst, r.get("score", 0)) issues += r.get("issues", []) out.append({"aid": aid, "name": it.get("name", aid), "ok": worst >= 60 and not issues, "no_reference": False, "worst_score": worst, "issues": list(dict.fromkeys(issues))[:3]}) bad = sum(1 for x in out if not x["ok"]) return {"total": len(out), "bad": bad, "items": out} @router.post("/characters/{aid}/quality-preview") def api_character_quality_preview(aid: str, data: dict = None): """质检**将要生成**的提示词(不实际生成,零费用) 用于「生成前」告知用户:按当前分层设置生成的定妆照是否会带场景背景。 """ data = data or {} warnings: list = [] layers = data.get("layers") or {} l9 = layers.get("L9_background") or {} env = str(l9.get("environment") or "").strip() bg_type = str(l9.get("bg_type") or "").strip() season = str(l9.get("season") or "").strip() if env: warnings.append( f"「具体环境」填了「{env}」→ 定妆照会带场景背景,图生视频时会被模型绑定。" "定妆照建议留空(背景由模板固定为纯白无缝)。") if bg_type and bg_type not in ("纯色背景", ""): warnings.append(f"「背景类型」选了「{bg_type}」→ 建议定妆照改用「纯色背景」。") if season: warnings.append(f"「季节/天气」填了「{season}」→ 定妆照不需要,建议清空。") return {"aid": aid, "ok": not warnings, "warnings": warnings, "note": "定妆照只用于「保人脸」,环境交给分镜/场地图 —— 两者不要混在一张图里。"} # ════════════════════════════════════════════════════════════ # 短剧角色参考图「套装」(5 视角 · 分开生成 + 主定妆作参考图) # ════════════════════════════════════════════════════════════ _PSET_JOBS: dict = {} # job_id → 状态(内存) @router.get("/characters/portrait-set/views") def api_pset_views(): """套装视角定义 + 负面词(**纯计算,零费用**)""" try: from character_generator.portrait_set import ( PORTRAIT_VIEWS, VIEW_ORDER, PORTRAIT_NEGATIVE_ZH) except Exception as e: raise HTTPException(500, detail=f"套装模块加载失败: {e}") return {"status": "ok", "negative": PORTRAIT_NEGATIVE_ZH, "views": [{"id": v, **PORTRAIT_VIEWS[v]} for v in VIEW_ORDER], "cost_estimate": round(len(VIEW_ORDER) * 0.2, 2), "note": "先生成主定妆 → 以其为参考图逐张生成其余 4 视角(五官锁定)"} @router.get("/characters/portrait-set/presets") def api_pset_presets(): """预设角色包列表(6 套短剧人设,**零费用**)""" try: from character_generator.portrait_set import load_presets except Exception as e: raise HTTPException(500, detail=f"套装模块加载失败: {e}") presets = load_presets() return {"status": "ok", "total": len(presets), "presets": [{k: v for k, v in p.items() if k != "expressions"} for p in presets], "note": "选用预设会自动填入角色描述与神态;生成仍需 confirm_cost"} @router.post("/characters/portrait-set/from-preset") def api_pset_from_preset(data: dict): """按预设取 5 视角完整提示词(**纯计算,零费用**)""" try: from character_generator.portrait_set import preset_to_set, get_preset except Exception as e: raise HTTPException(500, detail=f"套装模块加载失败: {e}") pid = ((data or {}).get("preset_id") or "").strip() if not pid: raise HTTPException(400, detail="需要 preset_id") ps = get_preset(pid) if not ps: raise HTTPException(404, detail=f"预设不存在: {pid}") return {"status": "ok", "preset": ps, "set": preset_to_set(pid)} @router.post("/characters/portrait-set/preview") def api_pset_preview(data: dict): """套装 5 视角的完整提示词预览(**纯计算,零费用**)""" try: from character_generator.portrait_set import build_full_set except Exception as e: raise HTTPException(500, detail=f"套装模块加载失败: {e}") data = data or {} desc = (data.get("desc") or "").strip() if not desc: raise HTTPException(400, detail="需要 desc(角色描述)") return {"status": "ok", "set": build_full_set(desc, bg=(data.get("bg") or ""), light=(data.get("light") or ""))} @router.post("/characters/portrait-set/generate") def api_pset_generate(data: dict): """生成套装(**真实扣费**,约 ¥1/套 5 张) Body: { desc, character_name?, anchor_image?, only?: [], confirm_cost: true } 返回 job_id;用 GET /characters/portrait-set/jobs/{job_id} 查进度 """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成角色套装会真实扣费(约 ¥1/套),需显式确认(confirm_cost=true)") desc = (data.get("desc") or "").strip() if not desc: raise HTTPException(400, detail="需要 desc(角色描述)") import threading import uuid as _uuid job_id = _uuid.uuid4().hex[:12] _PSET_JOBS[job_id] = {"job_id": job_id, "status": "running", "progress": 0, "note": "准备中", "result": None, "error": None} def _run(): def _cb(p, n): j = _PSET_JOBS.get(job_id) if j: j["progress"] = p j["note"] = n try: if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) from character_sheet import build_portrait_set out = build_portrait_set( desc, character_name=(data.get("character_name") or "").strip(), anchor_image=(data.get("anchor_image") or "").strip(), only=tuple(data.get("only") or ()), progress_cb=_cb, ) _PSET_JOBS[job_id].update({"status": "done", "progress": 100, "note": out.get("note", "完成"), "result": out}) except Exception as e: logger.warning("套装生成失败 %s: %s", job_id, e) _PSET_JOBS[job_id].update({"status": "failed", "error": str(e)[:400]}) threading.Thread(target=_run, daemon=True).start() return {"status": "ok", "job_id": job_id, "message": "已开始生成套装(5 张,约 1-2 分钟)。用 jobs/{job_id} 查进度。"} @router.get("/characters/portrait-set/jobs/{job_id}") def api_pset_job(job_id: str): j = _PSET_JOBS.get(job_id) if not j: raise HTTPException(404, detail="任务不存在") return j @router.post("/characters/{aid}/clean-reference") def api_char_clean_reference(aid: str, data: dict = None): """一键清理角色参考图背景(抠图换纯色底) ⚠️ 参考图带场景背景 → 图生视频时模型会把背景"绑定"到角色上。 本接口用 rembg(本地 U2Net)把参考图抠出来合成纯色底。 原图备份为 *_orig.*(可回溯)。 Body: { bg?: "cool_grey"|"white"|"light_grey", dry_run?: bool } """ try: from character_generator.quality_check import ( clean_reference_background, check_reference_quality, pick_rembg_model) except Exception as e: raise HTTPException(500, detail=f"质检模块加载失败: {e}") data = data or {} bg = (data.get("bg") or "cool_grey").strip() dry_run = bool(data.get("dry_run")) model = pick_rembg_model() if not model: raise HTTPException(400, detail=( "抠图模型未就绪。下载放到 ~/.u2net/u2net.onnx 后即可用: " "curl -L -o ~/.u2net/u2net.onnx " "'https://modelscope.cn/api/v1/models/AI-ModelScope/u2net/repo" "?Revision=master&FilePath=u2net.onnx'")) st = _store("characters") it = st.get(aid) if not it: raise HTTPException(404, detail=f"角色不存在: {aid}") refs = dict(it.get("reference_images") or {}) if not refs: raise HTTPException(400, detail="该角色没有参考图") import shutil results = [] updates = {} for slot, fname in refs.items(): src = Path(st.kind_dir(aid)) / Path(str(fname)).name if not src.exists(): results.append({"slot": slot, "ok": False, "reason": "文件缺失"}) continue before = check_reference_quality(str(src)) if before.get("ok") and before.get("edge_std", 99) < 10: results.append({"slot": slot, "ok": True, "skipped": True, "before": before.get("edge_std"), "reason": "已经很干净,跳过"}) continue if dry_run: results.append({"slot": slot, "ok": True, "dry_run": True, "before": before.get("edge_std")}) continue bak = src.with_name(f"{src.stem}_orig{src.suffix}") if not bak.exists(): shutil.copy2(src, bak) r = clean_reference_background(str(src), bg=bg) if r.get("ok"): updates[slot] = Path(r["output"]).name results.append({"slot": slot, "ok": True, "before": r["before"]["edge_std"], "after": r["after"]["edge_std"], "improved": r.get("improved"), "file": updates[slot]}) else: results.append({"slot": slot, "ok": False, "reason": r.get("reason", "")}) if updates and not dry_run: refs.update(updates) st.update(aid, {"reference_images": refs}) return {"status": "ok", "aid": aid, "model": model, "bg": bg, "dry_run": dry_run, "updated": len(updates), "results": results} @router.post("/characters/assemble-prompt") def api_assemble_prompt(data: dict): """**纯计算**:按分层字段组装提示词(**不生成、不扣费**)—— 供前端实时预览 Body: { layers: {layer_id: {field: value}}, variant: str } Returns: { prompt, chars } """ layers_data = data.get("layers") or {} variant = (data.get("variant") or "front_face").strip() try: mod = _load_prompt_assembler() sys_inst = mod.CharacterPromptSystem() for lid, fields in layers_data.items(): if isinstance(fields, dict) and fields: sys_inst.set_layer(lid, fields) prompt = _polish_prompt(sys_inst.build_prompt( variant=variant, lang="zh", angle=(data.get("angle") or "").strip() or "front", )) except Exception as e: raise HTTPException(400, detail=f"提示词组装失败: {e}") return {"status": "ok", "prompt": (prompt or ""), "variant": variant, "chars": len(prompt or ""), "filled_layers": [k for k, v in layers_data.items() if v]} @router.post("/characters/parse-prompt") def api_parse_prompt(data: dict): """自由文本人物描述 → 结构化角色字段(角色生成器「✨ 按提示词预填」用) Body: { text: str } 返回: { layers: {L0_identity: {gender: "男性"}, ...}, filled: N } ⚠️ **会调用一次 LLM(几分钱)** """ text = (data.get("text") or "").strip() if not text: raise HTTPException(400, detail="需要 text(人物描述)") text = text[:1200] try: if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) from character_generator.prompt_assembler import CHAR_LAYERS except Exception as e: raise HTTPException(500, detail=f"角色字段定义加载失败: {e}") # ① 字段说明(含 select 选项,供 LLM 严格选择) spec_lines, valid = [], {} for lid, L in CHAR_LAYERS.items(): fields = L.get("fields") or [] if not fields: continue parts = [] for f in fields: k, lab, tp, opts = f.get("key"), f.get("label"), f.get("type"), f.get("options") valid[f"{lid}.{k}"] = opts if tp == "select" else None parts.append(f"{k}({lab}{'|选项: ' + '/'.join(opts) if opts else ''})") spec_lines.append(f"{lid}【{L.get('name') or ''}】:" + ",".join(parts)) spec = "\n".join(spec_lines) sys_prompt = "你是角色设定解析器。只做结构化拆解,不创作、不扩写、不编造。" user_prompt = f"""把下面的人物描述拆解到角色字段结构中。 【字段结构】(标了「选项」的字段只能从选项中选,必须原样使用选项文字) {spec} 【规则】 1. 只填描述里**明确提到或能直接推断**的字段;没提到的**留空**,不要编造 2. select 字段的值必须是给定选项之一;text 字段用简短短语(≤12 字) 3. 只输出有内容的层与字段,不要输出空层 4. 严格输出 JSON,不要任何解释: {{"L0_identity": {{"gender": "男性", "age": "青年(18-30)"}}, "L3_hair": {{"style": "短发"}}}} 【人物描述】 {text}""" try: from directors.llm import DirectorLLM llm = DirectorLLM(timeout=90) if not getattr(llm, "available", False): raise RuntimeError("LLM 未配置(检查 local.yaml → llm.api_key)") raw = llm.chat(user_prompt, system=sys_prompt, temperature=0.2, max_tokens=1400) got = llm.parse_json(raw) or {} except Exception as e: raise HTTPException(500, detail=f"AI 解析失败: {e}") # ② 校验:层/字段必须存在;select 值必须在选项内(做一次模糊匹配兜底) out, filled = {}, 0 for lid, kv in (got.items() if isinstance(got, dict) else []): if lid not in CHAR_LAYERS or not isinstance(kv, dict): continue keep = {} for k, v in kv.items(): key = f"{lid}.{k}" if key not in valid: continue s = str(v).strip() if not s: continue opts = valid[key] if opts and s not in opts: hit = next((o for o in opts if o in s or s in o), None) if not hit: continue # 不在选项内 → 丢弃(避免脏值) s = hit keep[k] = s[:40] if keep: out[lid] = keep filled += len(keep) return {"status": "ok", "layers": out, "filled": filled, "note": "已按描述预填字段(未提及的保持为空,可继续补充细节)"} @router.post("/characters/generate-by-layers") def api_generate_by_layers(data: dict): """**按分层字段**组装提示词 → 生成定妆照 → 入库 Body: layers: dict {layer_id: {field_key: value}}(见 /character-layers) variant: str front_face | full_body | grid | scene | expression character_name: str 角色名(用于入库命名) engine: str 生成引擎(当前 kling) save_to_library: bool 是否存入角色库(默认 true) confirm_cost: bool **必须 true**(真实扣费) """ if not data.get("confirm_cost"): raise HTTPException(400, detail="⛔ 生成定妆照会真实扣费,需显式确认(confirm_cost=true)") layers_data = data.get("layers") or {} variant = (data.get("variant") or "front_face").strip() engine = (data.get("engine") or "kling").strip() name = (data.get("character_name") or layers_data.get("L0_identity", {}).get("name") or "新角色").strip() if engine not in ("kling", "jimeng"): raise HTTPException(400, detail=f"不支持的生成引擎: {engine}(可用 kling / jimeng)") # ① 用 CharacterPromptSystem 组装提示词 try: mod = _load_prompt_assembler() sys_inst = mod.CharacterPromptSystem() for lid, fields in layers_data.items(): if isinstance(fields, dict) and fields: sys_inst.set_layer(lid, fields) prompt = _polish_prompt(sys_inst.build_prompt( variant=variant, lang="zh", angle=(data.get("angle") or "").strip() or "front", )) except Exception as e: raise HTTPException(400, detail=f"提示词组装失败: {e}") # ⚡ 用户在「生成前审核面板」里编辑过的提示词优先 # ("只告知费用、不告知内容"起不到审核作用 —— 用户可当场改提示词再生成) override = (data.get("prompt_override") or "").strip() if override: prompt = override if not (prompt or "").strip(): raise HTTPException(400, detail="提示词为空 —— 请至少填写「核心身份」等字段") # ② 生成图片(可灵) if engine == "jimeng": url = _seedream_generate(prompt, size="2K") # 即梦 Seedream(火山方舟) else: aspect = "3:4" if variant in ("front_face", "expression") else "9:16" url = _kling_image_generate(prompt, aspect_ratio=aspect) # ③ 入库(可选) saved = None if data.get("save_to_library", True): store = _store("characters") # 找同名角色,没有则创建 existing = [x for x in store.list() if (x.get("name") or "") == name] if existing: aid = existing[0]["id"] refs = dict(existing[0].get("reference_images") or {}) else: created = store.create({"name": name, "type": "real", "role": "support", "description": f"由分层字段生成({variant})", "source": "ai_generated"}) aid = created["id"] refs = {} slot = {"front_face": "front", "full_body": "full", "grid": "grid", "scene": "scene", "expression": "expression"}.get(variant, "front") fname = f"ref_{slot}.png" dest = Path(store.kind_dir(aid)) / fname dest.parent.mkdir(parents=True, exist_ok=True) try: import urllib.request as _u opener = _u.build_opener(_u.ProxyHandler({})) hdr = {} try: import yaml as _y local = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local")) cfg = (_y.safe_load((local / "tools" / "ave" / "config" / "local.yaml").read_text(encoding="utf-8")) or {}).get("kling") or {} if cfg.get("api_key"): hdr = {"Authorization": f"Bearer {cfg['api_key'].strip()}"} except Exception: pass with opener.open(_u.Request(url, headers=hdr), timeout=180) as r: dest.write_bytes(r.read()) refs[slot] = fname store.update(aid, {"reference_images": refs, "prompt_used": prompt[:2000]}) saved = {"id": aid, "name": name, "slot": slot, "file": fname} except Exception as e: logger.warning(" ⚠️ 定妆照下载/入库失败: %s", e) logger.info(" 🎨 按层生成定妆照: %s(%s / %s)", name, variant, engine) return {"status": "ok", "prompt": prompt, "variant": variant, "engine": engine, "image_url": url, "saved": saved} # ════════════════════════════════════════════════════════════ # 音色目录(CosyVoice 官方清单)与试听 # ⚠️ 必须定义在 /{kind} 与 /{kind}/{aid} 之前,否则会被通配路由吞掉 # ════════════════════════════════════════════════════════════ _AVE_CONFIG = AGENT_SYNC / "05_tools" / "09_ave" / "config" _VOICE_CATALOG: dict | None = None def _preview_dir() -> Path: d = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local")) \ / "runtime" / "ave" / "voice_preview" d.mkdir(parents=True, exist_ok=True) return d @router.get("/voices/catalog") def api_voice_catalog(): """CosyVoice 官方音色目录(供音色库「+ 从官方挑选」)""" global _VOICE_CATALOG if _VOICE_CATALOG is None: try: import yaml p = _AVE_CONFIG / "cosyvoice_voices.yaml" _VOICE_CATALOG = yaml.safe_load(p.read_text(encoding="utf-8")) or {} except Exception as e: raise HTTPException(500, detail=f"音色目录读取失败: {e}") return {"status": "ok", **_VOICE_CATALOG} @router.post("/voices/preview") def api_voice_preview(data: dict): """试听一句(**会消耗少量 TTS 费用**;相同 音色+文本 复用缓存,不重复扣费)""" voice_id = (data.get("voice_id") or "").strip() if not voice_id: raise HTTPException(400, detail="需要 voice_id") text = (data.get("text") or "你好,这是音色试听,一二三四五。").strip()[:60] import hashlib key = hashlib.md5(f"{voice_id}|{text}".encode("utf-8")).hexdigest()[:16] out = _preview_dir() / f"{key}.mp3" cached = out.exists() and out.stat().st_size > 1000 if not cached: try: if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) from voice_synthesizer.aliyun import synthesize from lib.config import load_config ak = (load_config().get("aliyun", {}) or {}).get("api_key", "") if not ak: raise HTTPException(400, detail="未配置百炼 API Key(agent-local → local.yaml → aliyun.api_key)") synthesize(text, str(out), api_key=ak, voice_id=voice_id) except HTTPException: raise except Exception as e: raise HTTPException(500, detail=f"试听合成失败: {e}") return {"status": "ok", "cached": cached, "voice_id": voice_id, "text": text, "url": f"/api/ave/assets/voices/preview-file/{key}.mp3", "note": "同内容只合一次;首次试听有少量 TTS 费用"} @router.get("/voices/preview-file/{name}") def api_voice_preview_file(name: str): """试听音频(受控:仅 voice_preview 目录下的 mp3)""" safe = Path(name).name if not safe.endswith(".mp3"): raise HTTPException(400, detail="非法文件名") p = _preview_dir() / safe if not p.exists(): raise HTTPException(404, detail="试听文件不存在") return FileResponse(str(p), media_type="audio/mpeg") @router.get("/{kind}") def api_assets_list(kind: str, q: str = "", type: str = "", role: str = "", project: str = "", tag: str = "", has_voice: str = "", sort: str = "updated_desc", limit: int = 500): """列表(搜索 + 筛选 + 排序)""" try: st = _store(kind) filters: dict = {} if type: filters["type"] = type if role: filters["role"] = role if project: filters["project"] = project items = st.list(q=q, filters=filters, sort=sort, limit=limit) # tag / has_voice 为"包含式"筛选(list 内做) if tag: items = [m for m in items if tag in (m.get("tags") or [])] if has_voice == "1": items = [m for m in items if (m.get("voice") or {}).get("sample")] return {"status": "ok", "kind": kind, "total": len(items), "items": items} except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) @router.get("/{kind}/stats") def api_assets_stats(kind: str): """统计(角标用)""" try: st = _store(kind) if hasattr(st, "stats"): return {"status": "ok", "stats": st.stats()} items = st.list(limit=10000) return {"status": "ok", "stats": {"total": len(items)}} except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) @router.get("/{kind}/{aid}") def api_assets_get(kind: str, aid: str): """详情""" try: m = _store(kind).get(aid) except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) if not m: raise HTTPException(404, detail=f"资产不存在: {aid}") return {"status": "ok", "item": m} # ── 写 ───────────────────────────────────────────────── @router.post("/{kind}") def api_assets_create(kind: str, data: dict): """新建(id 可指定;不指定则由名字生成)""" try: if not (data.get("name") or data.get("id")): raise HTTPException(400, detail="需要 name 或 id") return {"status": "ok", "item": _store(kind).create(data)} except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) @router.patch("/{kind}/{aid}") def api_assets_update(kind: str, aid: str, data: dict): """更新(部分字段)""" try: m = _store(kind).update(aid, data) except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) if not m: raise HTTPException(404, detail=f"资产不存在: {aid}") return {"status": "ok", "item": m} @router.delete("/{kind}/{aid}") def api_assets_delete(kind: str, aid: str, hard: str = ""): """删除(默认软删到 _trash/;hard=1 彻底删)""" try: ok = _store(kind).delete(aid, hard=(hard == "1")) except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) if not ok: raise HTTPException(404, detail=f"资产不存在: {aid}") return {"status": "ok", "deleted": aid, "hard": hard == "1"} # ── 文件上传 / 读取 ──────────────────────────────────── @router.post("/{kind}/{aid}/file") async def api_assets_upload(kind: str, aid: str, file: UploadFile = File(...), name: str = ""): """上传资产文件(如角色参考图 ref_front.png / 音色 voice_sample.mp3) name 可指定目标文件名(含子目录,如 expressions/happy.png)。 """ try: st = _store(kind) if not st.get(aid): raise HTTPException(404, detail=f"资产不存在: {aid}(先新建再上传)") target = (name or file.filename or "file").replace("\\", "/") parts = [p for p in target.split("/") if p and p != ".."] if not parts: raise HTTPException(400, detail="非法文件名") content = await file.read() if len(content) > 50 * 1024 * 1024: raise HTTPException(400, detail="文件超过 50MB") p = st.save_file(aid, "/".join(parts), content) logger.info(" 📤 上传 %s/%s → %s(%.1f KB)", kind, aid, p.name, len(content) / 1024) return {"status": "ok", "path": "/".join(parts), "size": len(content)} except HTTPException: raise except Exception as e: raise HTTPException(500, detail=str(e)) @router.get("/{kind}/{aid}/file/{fname:path}") def api_assets_file(kind: str, aid: str, fname: str): """读取资产文件(图片/音频预览) 兼容三种 fname 形式: ① 文件名(新格式,如 `ref_front.jpg`)→ 在资产目录内查找 ② 绝对路径(旧数据迁移遗留,如 `/Users/.../阿远_grid_face_neutral.png`) → 先按文件名在资产目录找;找不到再尝试原绝对路径(仅限本机存在时) ③ 子路径(如 `subdir/a.png`)→ 资产目录内相对查找 """ try: st = _store(kind) except HTTPException: raise base = st.dir / aid raw = (fname or "").replace("\\", "/") # ① 文件名 / 子路径:资产目录内查找 parts = [p for p in raw.split("/") if p and p != ".."] cand = base / Path(*parts) if parts else base if cand.exists() and cand.is_file(): return FileResponse(str(cand)) # ② 绝对路径(旧数据):先取文件名再找;最后尝试原路径 if raw.startswith("/"): name_only = Path(raw).name cand2 = base / name_only if cand2.exists() and cand2.is_file(): return FileResponse(str(cand2)) try: p_abs = Path(raw) if p_abs.exists() and p_abs.is_file(): return FileResponse(str(p_abs)) # 本机路径仍存在(同机迁移场景) except Exception: pass raise HTTPException(404, detail=f"文件不存在(已尝试:{raw[:60]})") # ── 迁移 ─────────────────────────────────────────────── @router.post("/migrate") def api_assets_migrate(): """从旧角色库(character_registry/registry.yaml)迁移到新目录结构""" if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) try: from assets_manager import migrate_from_registry return {"status": "ok", **migrate_from_registry()} except Exception as e: raise HTTPException(500, detail=str(e)) # ═══════════════════════════════════════════════════════════ # 角色描述扩展(LLM 帮写更完整的文生图提示词) # ═══════════════════════════════════════════════════════════ @router.post("/characters-helper/expand-direction") def api_expand_direction(data: dict): """用 LLM 把简短的角色想法扩展成完整的定妆照提示词(供「角色生成器」用) Body: { description: str, name?: str } """ desc = (data.get("description") or "").strip() if not desc: raise HTTPException(400, detail="description 必填") try: if str(_AVE_SCRIPTS) not in sys.path: sys.path.insert(0, str(_AVE_SCRIPTS)) from directors.llm import DirectorLLM except Exception as e: raise HTTPException(500, detail=f"LLM 不可用: {e}") llm = DirectorLLM(timeout=60) if not llm.available: raise HTTPException(503, detail="AI 不可用(检查 llm.api_key 配置)") name = (data.get("name") or "").strip() prompt = ( "你是影视定妆照的提示词工程师。把下面的角色想法扩展成一段完整的**文生图提示词**(中文,120~200 字)," "要覆盖:年龄段、性别、面部特征、发型、体型、服装、神态气质、拍摄风格(真实摄影/纯色背景/自然光)。\n" "只输出提示词本身,不要解释、不要分点、不要引号。\n\n" f"角色名:{name or '(未命名)'}\n角色想法:{desc}" ) out = llm.chat(prompt, temperature=0.7, max_tokens=500).strip() if not out: raise HTTPException(502, detail="LLM 未返回内容,请重试") logger.info(" ✨ 角色描述扩展: %s → %d 字", desc[:20], len(out)) return {"status": "ok", "expanded": out} # ═══════════════════════════════════════════════════════════ # 生成参考图(文生图 → 存入资产) # ═══════════════════════════════════════════════════════════ def _build_image_prompt(kind: str, meta: dict, slot: str) -> str: """按资产类型 + 字段组装文生图提示词(用户也可在前端传入自定义 prompt 覆盖)""" def _g(*keys): for k in keys: v = meta.get(k) if isinstance(v, str) and v.strip(): return v.strip() if isinstance(v, list) and v: return "、".join(str(x) for x in v) return "" name = _g("name") or "untitled" desc = _g("description", "notes") if kind == "characters": parts = [ f"人物定妆照:{name}", desc, f"类型:{_g('role', 'type')}" if _g("role", "type") else "", f"外观:{_g('appearance', 'tags')}" if _g("appearance", "tags") else "", ] slot_hint = {"front": "正面全身照", "side": "侧面全身照", "back": "背面全身照", "full": "全身照", "portrait": "半身定妆照"}.get(slot, "") parts.append(slot_hint or "正面全身照") parts.append("真实摄影风格,纯色简洁背景,自然光,高清,半身或全身构图") elif kind == "locations": parts = [ f"场景空间:{name}", desc, f"空间类型:{_g('space_type')}" if _g("space_type") else "", f"光源状态:{_g('lighting')}" if _g("lighting") else "", f"材质要点:{_g('materials')}" if _g("materials") else "", {"wide": "全景广角", "detail": "细节特写", "light": "灯光氛围"}.get(slot, "全景"), "真实空间摄影,自然透视,无人物,高清", ] elif kind in ("products", "props"): parts = [ f"物品:{name}", desc, f"类别:{_g('category', 'type')}" if _g("category", "type") else "", {"front": "正面", "angle": "四分之三侧面", "detail": "细节特写", "in_hand": "手持使用中"}.get(slot, "正面"), "产品摄影,简洁背景,柔和打光,高清细节", f"必保特征:{_g('must_keep')}" if _g("must_keep") else "", ] else: parts = [f"{kind}:{name}", desc, "真实摄影风格,简洁背景,高清"] return ",".join(p for p in parts if p and p.strip()) def _seedream_generate(prompt: str, size: str = "2K", model: str = "doubao-seedream-4-0-250828", ref_image: str = "") -> str: """即梦(火山方舟 Seedream)文生图 / 图生图 → 返回图片 URL 配置:`agent-local local.yaml → volcano.ark_api_key` ⚠️ 模型需先在**火山方舟控制台 → 开通管理**里开通,否则报 InvalidEndpointOrModel.NotFound """ import json as _json import urllib.error import urllib.request import yaml local = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local")) cfg = (yaml.safe_load((local / "tools" / "ave" / "config" / "local.yaml") .read_text(encoding="utf-8")) or {}).get("volcano") or {} key = (cfg.get("ark_api_key") or cfg.get("api_key") or "").strip() if not key: raise HTTPException(400, detail="火山方舟未配置 ark_api_key → 去「🔑 API 配置」填写") opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) payload: dict = {"model": model, "prompt": prompt[:2000], "size": size, "response_format": "url", "watermark": False} if ref_image: payload["image"] = ref_image # 图生图(URL / base64) req = urllib.request.Request( "https://ark.cn-beijing.volces.com/api/v3/images/generations", data=_json.dumps(payload).encode("utf-8"), headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}) try: with opener.open(req, timeout=180) as r: d = _json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="ignore") try: dj = _json.loads(body) err = dj.get("error") or {} code = err.get("code") or dj.get("code") msg = err.get("message") or dj.get("message") or body[:200] except Exception: code, msg = e.code, body[:200] if "NotFound" in str(code) or "not found" in str(msg).lower(): raise HTTPException(400, detail=( f"即梦模型不可用({model})—— 需先到**火山方舟控制台 → 开通管理**开通该模型;" "或改用 Seedream 5.0(doubao-seedream-5-0-260128)")) raise HTTPException(400, detail=f"即梦接口错误(HTTP {e.code} code={code}):{msg}") except HTTPException: raise except Exception as e: raise HTTPException(400, detail=f"即梦请求失败: {e}") data = d.get("data") or [] if not data or not data[0].get("url"): raise HTTPException(500, detail=f"即梦未返回图片:{str(d)[:200]}") return data[0]["url"] def _kling_image_generate(prompt: str, aspect_ratio: str = "9:16", model: str = "kling-v3") -> str: """可灵文生图:提交 → 轮询 → 返回图片 URL(同步,约 30~90 秒) ⚠️ 模型名注意:`kling-v1`/`v1-5` 等旧图片模型已停用(code 1203)→ 用 **kling-v3** """ import json as _json import time as _time import urllib.error import urllib.request import yaml local = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local")) cfg_path = local / "tools" / "ave" / "config" / "local.yaml" cfg = (yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}).get("kling") or {} key = (cfg.get("api_key") or "").strip() base = (cfg.get("base_url") or "https://api-beijing.klingai.com").rstrip("/") if not key: raise HTTPException(400, detail="可灵未配置 api_key → 「🔑 API 配置」填写") opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # 绕过系统代理(localhost/外网都更稳) hdr = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} def _req(url, data=None): r = urllib.request.Request(url, data=data, headers=hdr) try: with opener.open(r, timeout=60) as resp: return _json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="ignore") try: d = _json.loads(body) code, msg = d.get("code"), d.get("message", "") except Exception: code, msg = e.code, body[:150] if code == 1102 or "balance" in str(msg).lower(): raise HTTPException(400, detail="可灵**图片额度**不足(code 1102)—— 请到可灵开放平台为图片生成充值(视频额度与图片额度分开计费)") if code == 1101: raise HTTPException(400, detail="可灵鉴权失败(code 1101)—— 检查 API Key") raise HTTPException(400, detail=f"可灵图片接口错误(HTTP {e.code} code={code}):{msg}") # ① 提交 payload = _json.dumps({"model_name": model, "prompt": prompt[:2500], "aspect_ratio": aspect_ratio, "n": 1}).encode("utf-8") d = _req(f"{base}/v1/images/generations", payload) if d.get("code") != 0: raise HTTPException(400, detail=f"可灵提交失败 code={d.get('code')}: {d.get('message')}") task_id = (d.get("data") or {}).get("task_id") if not task_id: raise HTTPException(500, detail="可灵未返回 task_id") # ② 轮询(最多 3 分钟) deadline = _time.time() + 180 while _time.time() < deadline: _time.sleep(6) q = _req(f"{base}/v1/images/generations/{task_id}") data = q.get("data") or {} st = data.get("task_status") if st == "succeed": imgs = (data.get("task_result") or {}).get("images") or [] if imgs and imgs[0].get("url"): return imgs[0]["url"] raise HTTPException(500, detail="可灵任务成功但无图片输出") if st == "failed": raise HTTPException(400, detail=f"可灵生成失败:{data.get('task_status_msg') or '未知原因'}") raise HTTPException(504, detail="可灵图片生成超时(3 分钟)—— 可稍后重试") @router.post("/{kind}/{aid}/lock") def api_asset_lock(kind: str, aid: str, data: dict = None): """🔒 锁定资产(冻结 reference_images / 生成历史 / seed / 音色) Body: { reason: str }(可选) """ data = data or {} st = _store(kind) r = st.lock(aid, reason=(data.get("reason") or "手动锁定")) if not r: raise HTTPException(404, detail=f"{kind} 不存在: {aid}") return {"status": "ok", "locked": True, "locked_at": r.get("locked_at"), "locked_reason": r.get("locked_reason")} @router.post("/{kind}/{aid}/unlock") def api_asset_unlock(kind: str, aid: str, data: dict = None): """🔓 解锁(会记入 unlock_history,供版本追溯)""" data = data or {} st = _store(kind) r = st.unlock(aid, reason=(data.get("reason") or "")) if not r: raise HTTPException(404, detail=f"{kind} 不存在: {aid}") return {"status": "ok", "locked": False} @router.get("/{kind}/{aid}/history") def api_asset_history(kind: str, aid: str): """📌 生成历史(版本号 + 提示词 + seed + 模型 + 引擎)—— 供版本说明 / 追溯""" st = _store(kind) r = st.get(aid) if not r: raise HTTPException(404, detail=f"{kind} 不存在: {aid}") return { "status": "ok", "locked": bool(r.get("locked")), "locked_at": r.get("locked_at"), "locked_reason": r.get("locked_reason"), "consistency_seed": r.get("consistency_seed"), "active_version": r.get("active_version"), "history": r.get("generation_history") or [], "unlock_history": r.get("unlock_history") or [], } @router.post("/{kind}/{aid}/preview-image-prompt") def api_preview_image_prompt(kind: str, aid: str, data: dict): """预览「生成参考图」将发送的完整提示词与参数(**纯计算,不生成、不扣费**) 用途:让用户在点「生成」**之前**看到并修改提示词。 只告知费用、不告知内容的"确认"起不到审核作用(用户反馈)。 """ st = _store(kind) item = st.get(aid) if not item: raise HTTPException(404, detail=f"{kind} 不存在: {aid}") slot = (data.get("slot") or "front").strip() provider = (data.get("provider") or "kling").strip() user_prompt = (data.get("prompt") or "").strip() auto = _build_image_prompt(kind, item, slot) prompt = user_prompt or auto used = {k: v for k, v in (item or {}).items() if isinstance(v, (str, list)) and v and k not in ("id", "created_at", "updated_at", "locked_at")} return { "status": "ok", "prompt": prompt, "auto_prompt": auto, "is_custom": bool(user_prompt), "slot": slot, "provider": provider, "provider_name": ("可灵(Kling)" if provider == "kling" else ("即梦 Seedream" if provider == "jimeng" else provider)), "aspect_ratio": (data.get("aspect_ratio") or "9:16"), "source_fields": used, "cost_hint": ("可灵图片按张计费(约 ¥0.2~0.5/张,以官方账单为准)" if provider == "kling" else "即梦按张计费(以官方账单为准)"), } @router.post("/{kind}/{aid}/preview-image-prompt") def api_preview_image_prompt(kind: str, aid: str, data: dict): """**预览**「生成参考图」将发送的完整提示词与参数(**纯计算,不生成、不扣费**) 用途:让用户在点「生成」**之前**看到并修改提示词。 (只告知费用、不告知内容的"确认"起不到审核作用 —— 用户反馈) """ st = _store(kind) item = st.get(aid) if not item: raise HTTPException(404, detail=f"{kind} 不存在: {aid}") slot = (data.get("slot") or "front").strip() provider = (data.get("provider") or "kling").strip() user_prompt = (data.get("prompt") or "").strip() auto_prompt = _build_image_prompt(kind, item, slot) used = {k: v for k, v in (item or {}).items() if isinstance(v, (str, list)) and v and k not in ("id", "created_at", "updated_at", "locked_at")} return { "status": "ok", "prompt": user_prompt or auto_prompt, "auto_prompt": auto_prompt, "is_custom": bool(user_prompt), "slot": slot, "provider": provider, "provider_name": ("可灵(Kling)" if provider == "kling" else ("即梦 Seedream" if provider == "jimeng" else provider)), "aspect_ratio": data.get("aspect_ratio") or "9:16", "source_fields": used, "cost_hint": ("可灵图片按张计费(约 ¥0.2~0.5/张,以官方账单为准)" if provider == "kling" else "即梦按张计费(以官方账单为准)"), } @router.post("/{kind}/{aid}/generate-image") def api_assets_generate_image(kind: str, aid: str, data: dict): """**文生图生成参考图**并存入资产(角色 / 场景 / 产品 / 道具…) Body: slot: str 参考图槽位(front / side / back / full / wide / detail / light / angle / in_hand…) provider: str 供应商(当前支持 kling) prompt: str 自定义提示词(不传则按资产字段自动组装) aspect_ratio: str 9:16 | 1:1 | 16:9(默认 9:16) """ import urllib.request st = _store(kind) item = st.get(aid) if not item: raise HTTPException(404, detail=f"{kind} 不存在: {aid}") slot = (data.get("slot") or "front").strip() provider = (data.get("provider") or "kling").strip() # 🔒 锁定保护:锁定后不能覆盖已有槽位(允许新增新槽位 / 追加新版本) if item.get("locked") and (item.get("reference_images") or {}).get(slot): raise HTTPException(400, detail=( f"资产已锁定({item.get('locked_at') or ''}),槽位「{slot}」已有图不能覆盖。" f"请换一个槽位名(如 {slot}_v2),或先在详情里「🔓 解锁」。")) prompt = (data.get("prompt") or "").strip() or _build_image_prompt(kind, item, slot) if provider not in ("kling", "jimeng"): raise HTTPException(400, detail=f"不支持的图片供应商: {provider}(可用 kling / jimeng)") url = _kling_image_generate(prompt, aspect_ratio=data.get("aspect_ratio") or "9:16") # 下载图片 → 存为该槽位的参考图 fname = f"ref_{slot}.png" dest_dir = st.kind_dir(aid) dest = Path(dest_dir) / fname dest.parent.mkdir(parents=True, exist_ok=True) try: opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) hdr = {} try: import yaml local = Path(os.environ.get("AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local")) cfg = (yaml.safe_load((local / "tools" / "ave" / "config" / "local.yaml").read_text(encoding="utf-8")) or {}).get("kling") or {} if cfg.get("api_key"): hdr = {"Authorization": f"Bearer {cfg['api_key'].strip()}"} except Exception: pass with opener.open(urllib.request.Request(url, headers=hdr), timeout=180) as r: dest.write_bytes(r.read()) except Exception as e: raise HTTPException(500, detail=f"图片已生成但下载失败: {e}(URL: {url[:80]})") # 更新元数据(reference_images[slot] = 文件名) refs = dict(item.get("reference_images") or {}) refs[slot] = fname st.update(aid, {"reference_images": refs}) logger.info(" 🎨 %s/%s 生成参考图: %s(%s)", kind, aid, fname, provider) return {"status": "ok", "slot": slot, "file": fname, "prompt": prompt, "provider": provider}