移植自 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 带网关原话 / 正常返回)。
1328 lines
58 KiB
Python
1328 lines
58 KiB
Python
"""
|
||
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}
|