Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/routes/ave_assets.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1328 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}