pipeline.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. """可被网站/命令行复用的生成管线。
  2. 读 manifest -> 生成各类资产 -> 写 library.json(供网站可视化列出与预览)。
  3. """
  4. import json
  5. import os
  6. import providers
  7. import spine_builder
  8. import particle_builder
  9. import tween_builder
  10. import baidu_segment
  11. import asset_quality
  12. HERE = os.path.dirname(os.path.abspath(__file__))
  13. def run(manifest, out_root, creds=None, log=print, merge_existing=False):
  14. """manifest: dict; out_root: 输出根目录; creds: {provider,api_key,base_url,model,size}
  15. 返回 (library_dict, base_out)。"""
  16. creds = creds or {}
  17. game = manifest.get("game", "game")
  18. base_out = os.path.join(out_root, game)
  19. chars_out = os.path.join(base_out, "characters")
  20. vfx_out = os.path.join(base_out, "vfx")
  21. ui_out = os.path.join(base_out, "ui")
  22. style = manifest.get("style", "")
  23. library = {
  24. "game": game,
  25. "slot_config": manifest.get("slot_config", {}),
  26. "characters": [],
  27. "vfx": [],
  28. "ui": [],
  29. "ui_art": [],
  30. }
  31. if merge_existing:
  32. library_path = os.path.join(base_out, "library.json")
  33. if os.path.isfile(library_path):
  34. with open(library_path, encoding="utf-8") as f:
  35. existing = json.load(f)
  36. for key in ("characters", "vfx", "ui", "ui_art"):
  37. library[key] = existing.get(key, [])
  38. library["slot_config"] = manifest.get("slot_config") or existing.get("slot_config", {})
  39. def upsert(section, item):
  40. item_id = item.get("id")
  41. if not item_id:
  42. library[section].append(item)
  43. return
  44. rows = library.setdefault(section, [])
  45. for idx, old in enumerate(rows):
  46. if old.get("id") == item_id:
  47. rows[idx] = item
  48. return
  49. rows.append(item)
  50. total_steps = len(manifest.get("characters", [])) + len(manifest.get("ui_art", [])) + len(manifest.get("vfx", [])) + (1 if manifest.get("ui", []) else 0)
  51. done_steps = 0
  52. def progress(label):
  53. nonlocal done_steps
  54. done_steps += 1
  55. log(f"进度 {done_steps}/{max(1, total_steps)} · {label}")
  56. def transparent_prompt(extra):
  57. return ", ".join([
  58. extra,
  59. "生成纯透明背景 PNG,真实 Alpha 通道,不要棋盘格,不要白底,不要阴影。",
  60. ])
  61. def alpha_report(img):
  62. img = img.convert("RGBA")
  63. alpha = img.getchannel("A")
  64. mn, mx = alpha.getextrema()
  65. transparent = sum(1 for v in alpha.getdata() if v == 0)
  66. ratio = transparent / max(1, img.width * img.height)
  67. return mn, mx, ratio
  68. def has_alpha(img):
  69. return alpha_report(img)[0] == 0
  70. def log_alpha(label, img, required):
  71. if not required:
  72. return
  73. mn, mx, ratio = alpha_report(img)
  74. if mn == 0:
  75. log(f"✅ [{label}] Alpha 透明通道有效:透明像素 {ratio:.1%}")
  76. else:
  77. log(f"⚠️ [{label}] 模型返回 PNG 但没有透明 Alpha:alpha={mn}-{mx},请重新生成或换支持透明输出的图像模型")
  78. def generate_checked(label, prompt, size, require_alpha):
  79. img = providers.generate(creds["provider"], prompt, creds["api_key"],
  80. creds.get("base_url", "https://api.openai.com/v1"),
  81. creds.get("model", "gpt-image-2"),
  82. size)
  83. log_alpha(label, img, require_alpha)
  84. if not require_alpha or has_alpha(img):
  85. return img
  86. log(f"🧠 [{label}] 模型没有真实 Alpha,直接改用百度智能抠图兜底…")
  87. try:
  88. fixed = baidu_segment.remove_background(img, label=label, log=log)
  89. except Exception as e:
  90. raise RuntimeError(f"模型没有返回真实 Alpha,百度智能抠图也失败:{e}")
  91. log_alpha(label, fixed, True)
  92. if has_alpha(fixed):
  93. return fixed
  94. raise RuntimeError("百度智能抠图返回结果仍没有真实 Alpha 透明通道")
  95. def generate_boss_preview_checked(cid, base_prompt, size):
  96. correction = ""
  97. last_reason = ""
  98. for attempt in range(1, 4):
  99. prompt = base_prompt if not correction else ", ".join([base_prompt, correction])
  100. if attempt > 1:
  101. log(f"🔁 [{cid}/preview] 关主预览不合格,重新生成完整角色(第 {attempt}/3 次)…")
  102. img = generate_checked(f"{cid}/preview", prompt, size, True)
  103. ok, reason, report = asset_quality.boss_preview_quality(img)
  104. if ok:
  105. log(f"✅ [{cid}/preview] 关主完整性检查通过:最大主体 {report['largestShare']:.0%}")
  106. return img
  107. last_reason = reason
  108. log(f"⚠️ [{cid}/preview] 关主完整性检查失败:{reason}")
  109. correction = (
  110. "这不是完整关主角色。请重新生成一个单体完整角色:所有身体、头、手、脚、武器必须连接在同一个主体轮廓内,"
  111. "不要拆件图、不要 sprite sheet、不要分离的靴子/武器/金币袋/碎片,只有一个居中站立的完整关主。"
  112. )
  113. raise RuntimeError(f"关主预览不合格:{last_reason}")
  114. def boss_config():
  115. boss = manifest.get("slot_config", {}).get("boss", {})
  116. return boss if boss.get("enabled", False) else {}
  117. def required_boss_id():
  118. boss = boss_config()
  119. if not boss:
  120. return ""
  121. return boss.get("id") or "boss_demon_lord"
  122. def is_required_boss(c):
  123. boss_id = required_boss_id()
  124. return bool(boss_id and (c.get("role") == "boss" or c.get("id") == boss_id))
  125. boss_required_in_this_run = any(is_required_boss(c) for c in manifest.get("characters", []))
  126. required_failures = []
  127. def part_sheet_prompt(c, parts, cols, rows):
  128. ordered = ", ".join(f"{idx + 1}. {p['id']} ({p.get('prompt', '')})" for idx, p in enumerate(parts))
  129. return ", ".join(x for x in [
  130. c.get("prompt", ""),
  131. style,
  132. (
  133. f"create one transparent PNG boss rigging parts sprite sheet, exactly {cols} columns and {rows} rows, "
  134. "one isolated part per cell, no labels, no numbers, no grid lines, no text, no shadows, no background, "
  135. "parts must not overlap cell borders, all parts from the same character, same lighting and style, "
  136. "center each part inside its own cell, leave unused cells fully transparent"
  137. ),
  138. "cell order left to right, top to bottom: " + ordered,
  139. transparent_prompt("sprite sheet of separated rigging parts only")
  140. ] if x)
  141. def split_part_sheet(sheet_img, parts, cols, rows):
  142. sheet = sheet_img.convert("RGBA")
  143. cell_w = max(1, sheet.width // cols)
  144. cell_h = max(1, sheet.height // rows)
  145. part_images = {}
  146. major_ids = {
  147. "torso", "head", "left_arm", "right_arm", "greatsword",
  148. "left_leg", "right_leg", "cape",
  149. }
  150. for idx, part in enumerate(parts):
  151. col = idx % cols
  152. row = idx // cols
  153. box = (col * cell_w, row * cell_h, (col + 1) * cell_w, (row + 1) * cell_h)
  154. cell = sheet.crop(box)
  155. bbox = cell.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
  156. if not bbox:
  157. raise RuntimeError(f"拆件表第 {idx + 1} 格 {part['id']} 是空的")
  158. bw, bh = bbox[2] - bbox[0], bbox[3] - bbox[1]
  159. if part["id"] in major_ids and (bw < cell_w * 0.16 or bh < cell_h * 0.16):
  160. raise RuntimeError(
  161. f"拆件表第 {idx + 1} 格 {part['id']} 太小:{bw}x{bh},需要填满单元格"
  162. )
  163. part_images[part["id"]] = cell
  164. return part_images
  165. # ---- A. 角色(Spine)----
  166. for i, c in enumerate(manifest.get("characters", [])):
  167. cid = c.get("id", f"char_{i}")
  168. anims = c.get("animations", ["idle"])
  169. if not creds.get("api_key"):
  170. log(f"⚠️ 未填 key,跳过角色 {cid}")
  171. continue
  172. try:
  173. part_files = []
  174. if c.get("type") == "spine_parts":
  175. parts = c.get("parts") or []
  176. part_images = {}
  177. preview_img = None
  178. try:
  179. preview_prompt = ", ".join(x for x in [
  180. c.get("prompt", ""),
  181. style,
  182. transparent_prompt(
  183. "single complete assembled boss character preview, full body, centered, readable silhouette, no separated parts, no sprite sheet, no atlas"
  184. )
  185. ] if x)
  186. log(f"🖼 [{cid}] 生成完整 Boss 预览图…")
  187. preview_img = generate_boss_preview_checked(
  188. cid,
  189. preview_prompt,
  190. c.get("size", creds.get("size", "1024x1024")),
  191. )
  192. except Exception as e:
  193. raise RuntimeError(f"完整 Boss 预览图生成失败:{e}")
  194. sheet_cfg = c.get("spriteSheet") or {}
  195. # Boss rigging sheets are too easy for image models to violate:
  196. # one bad grid alignment corrupts every sliced part. Generate
  197. # boss parts independently for usable rigging assets.
  198. use_sheet = False
  199. if (c.get("partGeneration") == "sprite_sheet" or sheet_cfg.get("enabled")):
  200. log(f"ℹ️ [{cid}] 已禁用 Boss 拆件表切图,改为逐部件生成,避免串格/半截拆件")
  201. if use_sheet:
  202. cols = int(sheet_cfg.get("cols") or 4)
  203. rows = int(sheet_cfg.get("rows") or 4)
  204. if len(parts) > cols * rows:
  205. raise RuntimeError(f"Boss 拆件数 {len(parts)} 超过 sprite sheet 容量 {cols}x{rows}")
  206. try:
  207. log(f"🎨 [{cid}] 生成 Boss 拆件表 {cols}×{rows}…")
  208. sheet = generate_checked(f"{cid}/parts_sheet",
  209. part_sheet_prompt(c, parts, cols, rows),
  210. sheet_cfg.get("size", c.get("size", "1024x1024")),
  211. True)
  212. part_images = split_part_sheet(sheet, parts, cols, rows)
  213. log(f"✂️ [{cid}] 已按固定网格切出 {len(parts)} 个 Boss 拆件")
  214. except Exception as e:
  215. log(f"⚠️ [{cid}] 拆件表生成/切图失败,回退逐部件生成:{e}")
  216. part_images = {}
  217. if not part_images:
  218. for part in parts:
  219. part_id = part["id"]
  220. part_prompt = ", ".join(x for x in [
  221. part.get("prompt", ""),
  222. style,
  223. transparent_prompt(
  224. f"only the {part_id} rigging part from the boss character, isolated single part, "
  225. "not a full character, no complete body, no other body parts, no sprite sheet, "
  226. "fill most of the image with this one clean part, centered"
  227. )
  228. ] if x)
  229. log(f"🎨 [{cid}/{part_id}] 生成 Boss 拆件…")
  230. pimg = generate_checked(f"{cid}/{part_id}", part_prompt,
  231. part.get("size", c.get("size", creds.get("size", "1024x1024"))),
  232. True)
  233. part_images[part_id] = pimg
  234. spine_builder.build_parts_character(cid, part_images, chars_out, anims, parts,
  235. write_preview=preview_img is None)
  236. part_files = spine_builder.write_part_pngs_from_files(cid, chars_out)
  237. bad_parts = []
  238. for part in part_files:
  239. ok, reason, detail = asset_quality.boss_part_quality(
  240. part["id"],
  241. os.path.join(base_out, part["file"]),
  242. )
  243. if not ok:
  244. bad_parts.append(
  245. f"{part['id']}:{reason} 最大主体 {detail.get('largestShare', 0):.0%},第二主体 {detail.get('secondShare', 0):.0%}"
  246. )
  247. if bad_parts:
  248. raise RuntimeError("Boss 拆件质量不合格:" + ";".join(bad_parts[:4]))
  249. if preview_img is not None:
  250. os.makedirs(chars_out, exist_ok=True)
  251. spine_builder.trim_to_content(preview_img, pad=16).save(
  252. os.path.join(chars_out, f"{cid}_preview.png")
  253. )
  254. w, h = 1000, 1000
  255. files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png",
  256. f"characters/{cid}_preview.png"]
  257. files.extend([p["file"] for p in part_files])
  258. preview = f"characters/{cid}_preview.png"
  259. else:
  260. full_prompt = ", ".join(x for x in [
  261. c.get("prompt", ""), style,
  262. transparent_prompt("single game icon character or slot symbol, centered, full body in frame, no text, not a boss, not a demon lord, not dark armor"),
  263. ] if x)
  264. log(f"🎨 [{cid}] 生成角色图…")
  265. img = generate_checked(cid, full_prompt, c.get("size", creds.get("size", "1024x1024")), True)
  266. spine_builder.build_character(cid, img, chars_out, anims)
  267. w, h = spine_builder.trim_to_content(img).size
  268. files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
  269. preview = ""
  270. upsert("characters", {
  271. "id": cid,
  272. "png": f"characters/{cid}.png",
  273. "w": w, "h": h,
  274. "type": c.get("type", "spine"),
  275. "role": c.get("role", ""),
  276. "preview": preview,
  277. "animations": spine_builder.anim_data(anims),
  278. "files": files,
  279. "parts": part_files,
  280. })
  281. log(f"✅ [{cid}] 完成 ({anims})")
  282. progress(f"{cid}")
  283. except Exception as e:
  284. log(f"❌ [{cid}] 失败: {e}")
  285. if is_required_boss(c):
  286. required_failures.append(f"{cid}: {e}")
  287. progress(f"{cid}")
  288. boss_id = required_boss_id()
  289. boss_missing_after_chars = (
  290. boss_id and boss_required_in_this_run
  291. and not any(c.get("id") == boss_id for c in library["characters"])
  292. )
  293. # ---- A2. UI 美术(背景 / Logo / 卷轴框 / 按钮 等整图)----
  294. ui_art_out = os.path.join(base_out, "ui_art")
  295. for a in manifest.get("ui_art", []):
  296. aid = a.get("id", "art")
  297. if not creds.get("api_key"):
  298. log(f"⚠️ 未填 key,跳过 UI 美术 {aid}")
  299. continue
  300. try:
  301. transparent = a.get("transparent", True)
  302. extra = (transparent_prompt("single clean UI element, no text")
  303. if transparent
  304. else "full-bleed illustration, no text, no UI elements")
  305. full_prompt = ", ".join(x for x in [a.get("prompt", ""), style if a.get("use_style") else "", extra] if x)
  306. log(f"🖼 [{aid}] 生成 UI 美术…")
  307. img = generate_checked(aid, full_prompt, a.get("size", creds.get("size", "1024x1024")), transparent)
  308. os.makedirs(ui_art_out, exist_ok=True)
  309. img.save(os.path.join(ui_art_out, f"{aid}.png"))
  310. upsert("ui_art", {"id": aid, "file": f"ui_art/{aid}.png",
  311. "w": img.width, "h": img.height,
  312. "transparent": transparent})
  313. log(f"✅ [{aid}] UI 美术完成")
  314. progress(f"{aid}")
  315. except Exception as e:
  316. log(f"❌ [{aid}] UI 美术失败: {e}")
  317. progress(f"{aid}")
  318. # ---- B. 粒子 VFX ----
  319. for v in manifest.get("vfx", []):
  320. vid = v.get("id", "vfx")
  321. try:
  322. path = particle_builder.build_particle(
  323. vid, v.get("template", "burst"), v.get("color", [255, 255, 255]), vfx_out)
  324. cfg = json.load(open(path, encoding="utf-8"))
  325. upsert("vfx", {"id": vid, "template": v.get("template"),
  326. "file": f"vfx/{vid}.particle.json", "config": cfg})
  327. log(f"✨ [{vid}] 粒子配置完成")
  328. progress(f"{vid}")
  329. except Exception as e:
  330. log(f"❌ [{vid}] 粒子失败: {e}")
  331. progress(f"{vid}")
  332. # ---- C. UI Tween ----
  333. ui = manifest.get("ui", [])
  334. if ui:
  335. used = [u.get("preset") for u in ui if u.get("preset")]
  336. try:
  337. tween_builder.build_tweens(used, ui_out)
  338. for u in ui:
  339. upsert("ui", {"id": u.get("id"), "preset": u.get("preset"),
  340. "params": u.get("params", {})})
  341. log(f"🎛 TweenPresets.ts 完成 ({used})")
  342. progress("TweenPresets")
  343. except Exception as e:
  344. log(f"❌ Tween 失败: {e}")
  345. progress("TweenPresets")
  346. os.makedirs(base_out, exist_ok=True)
  347. with open(os.path.join(base_out, "library.json"), "w", encoding="utf-8") as f:
  348. json.dump(library, f, ensure_ascii=False, indent=2)
  349. log("—— 完成 ——")
  350. if boss_missing_after_chars:
  351. detail = ";".join(required_failures) if required_failures else "生成结果中没有关主资源"
  352. raise RuntimeError(
  353. f"关主大魔王资源缺失:{boss_id}。已保存其他成功资源;请在任务卡片里继续补生成 boss。原因:{detail}"
  354. )
  355. return library, base_out