pipeline.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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. HERE = os.path.dirname(os.path.abspath(__file__))
  12. def run(manifest, out_root, creds=None, log=print, merge_existing=False):
  13. """manifest: dict; out_root: 输出根目录; creds: {provider,api_key,base_url,model,size}
  14. 返回 (library_dict, base_out)。"""
  15. creds = creds or {}
  16. game = manifest.get("game", "game")
  17. base_out = os.path.join(out_root, game)
  18. chars_out = os.path.join(base_out, "characters")
  19. vfx_out = os.path.join(base_out, "vfx")
  20. ui_out = os.path.join(base_out, "ui")
  21. style = manifest.get("style", "")
  22. library = {
  23. "game": game,
  24. "slot_config": manifest.get("slot_config", {}),
  25. "characters": [],
  26. "vfx": [],
  27. "ui": [],
  28. "ui_art": [],
  29. }
  30. if merge_existing:
  31. library_path = os.path.join(base_out, "library.json")
  32. if os.path.isfile(library_path):
  33. with open(library_path, encoding="utf-8") as f:
  34. existing = json.load(f)
  35. for key in ("characters", "vfx", "ui", "ui_art"):
  36. library[key] = existing.get(key, [])
  37. library["slot_config"] = manifest.get("slot_config") or existing.get("slot_config", {})
  38. def upsert(section, item):
  39. item_id = item.get("id")
  40. if not item_id:
  41. library[section].append(item)
  42. return
  43. rows = library.setdefault(section, [])
  44. for idx, old in enumerate(rows):
  45. if old.get("id") == item_id:
  46. rows[idx] = item
  47. return
  48. rows.append(item)
  49. total_steps = len(manifest.get("characters", [])) + len(manifest.get("ui_art", [])) + len(manifest.get("vfx", [])) + (1 if manifest.get("ui", []) else 0)
  50. done_steps = 0
  51. def progress(label):
  52. nonlocal done_steps
  53. done_steps += 1
  54. log(f"进度 {done_steps}/{max(1, total_steps)} · {label}")
  55. def transparent_prompt(extra):
  56. return ", ".join([
  57. extra,
  58. "生成纯透明背景 PNG,真实 Alpha 通道,不要棋盘格,不要白底,不要阴影。",
  59. ])
  60. def alpha_report(img):
  61. img = img.convert("RGBA")
  62. alpha = img.getchannel("A")
  63. mn, mx = alpha.getextrema()
  64. transparent = sum(1 for v in alpha.getdata() if v == 0)
  65. ratio = transparent / max(1, img.width * img.height)
  66. return mn, mx, ratio
  67. def has_alpha(img):
  68. return alpha_report(img)[0] == 0
  69. def log_alpha(label, img, required):
  70. if not required:
  71. return
  72. mn, mx, ratio = alpha_report(img)
  73. if mn == 0:
  74. log(f"✅ [{label}] Alpha 透明通道有效:透明像素 {ratio:.1%}")
  75. else:
  76. log(f"⚠️ [{label}] 模型返回 PNG 但没有透明 Alpha:alpha={mn}-{mx},请重新生成或换支持透明输出的图像模型")
  77. def generate_checked(label, prompt, size, require_alpha):
  78. img = providers.generate(creds["provider"], prompt, creds["api_key"],
  79. creds.get("base_url", "https://api.openai.com/v1"),
  80. creds.get("model", "gpt-image-2"),
  81. size)
  82. log_alpha(label, img, require_alpha)
  83. if not require_alpha or has_alpha(img):
  84. return img
  85. log(f"🧠 [{label}] 模型没有真实 Alpha,直接改用百度智能抠图兜底…")
  86. try:
  87. fixed = baidu_segment.remove_background(img, label=label, log=log)
  88. except Exception as e:
  89. raise RuntimeError(f"模型没有返回真实 Alpha,百度智能抠图也失败:{e}")
  90. log_alpha(label, fixed, True)
  91. if has_alpha(fixed):
  92. return fixed
  93. raise RuntimeError("百度智能抠图返回结果仍没有真实 Alpha 透明通道")
  94. def boss_config():
  95. boss = manifest.get("slot_config", {}).get("boss", {})
  96. return boss if boss.get("enabled", False) else {}
  97. def required_boss_id():
  98. boss = boss_config()
  99. if not boss:
  100. return ""
  101. return boss.get("id") or "boss_demon_lord"
  102. def is_required_boss(c):
  103. boss_id = required_boss_id()
  104. return bool(boss_id and (c.get("role") == "boss" or c.get("id") == boss_id))
  105. boss_required_in_this_run = any(is_required_boss(c) for c in manifest.get("characters", []))
  106. required_failures = []
  107. # ---- A. 角色(Spine)----
  108. for i, c in enumerate(manifest.get("characters", [])):
  109. cid = c.get("id", f"char_{i}")
  110. anims = c.get("animations", ["idle"])
  111. if not creds.get("api_key"):
  112. log(f"⚠️ 未填 key,跳过角色 {cid}")
  113. continue
  114. try:
  115. if c.get("type") == "spine_parts":
  116. part_images = {}
  117. parts = c.get("parts") or []
  118. for part in parts:
  119. part_id = part["id"]
  120. part_prompt = ", ".join(x for x in [
  121. c.get("prompt", ""),
  122. part.get("prompt", ""),
  123. style,
  124. transparent_prompt("single separated rigging part only, centered, no text, no other body parts")
  125. ] if x)
  126. log(f"🎨 [{cid}/{part_id}] 生成 Boss 拆件…")
  127. pimg = generate_checked(f"{cid}/{part_id}", part_prompt,
  128. part.get("size", c.get("size", creds.get("size", "1024x1024"))),
  129. True)
  130. part_images[part_id] = pimg
  131. spine_builder.build_parts_character(cid, part_images, chars_out, anims, parts)
  132. w, h = 1000, 1000
  133. files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
  134. else:
  135. full_prompt = ", ".join(x for x in [
  136. c.get("prompt", ""), style,
  137. 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"),
  138. ] if x)
  139. log(f"🎨 [{cid}] 生成角色图…")
  140. img = generate_checked(cid, full_prompt, c.get("size", creds.get("size", "1024x1024")), True)
  141. spine_builder.build_character(cid, img, chars_out, anims)
  142. w, h = spine_builder.trim_to_content(img).size
  143. files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
  144. upsert("characters", {
  145. "id": cid,
  146. "png": f"characters/{cid}.png",
  147. "w": w, "h": h,
  148. "type": c.get("type", "spine"),
  149. "role": c.get("role", ""),
  150. "animations": spine_builder.anim_data(anims),
  151. "files": files,
  152. })
  153. log(f"✅ [{cid}] 完成 ({anims})")
  154. progress(f"{cid}")
  155. except Exception as e:
  156. log(f"❌ [{cid}] 失败: {e}")
  157. if is_required_boss(c):
  158. required_failures.append(f"{cid}: {e}")
  159. progress(f"{cid}")
  160. boss_id = required_boss_id()
  161. boss_missing_after_chars = (
  162. boss_id and boss_required_in_this_run
  163. and not any(c.get("id") == boss_id for c in library["characters"])
  164. )
  165. # ---- A2. UI 美术(背景 / Logo / 卷轴框 / 按钮 等整图)----
  166. ui_art_out = os.path.join(base_out, "ui_art")
  167. for a in manifest.get("ui_art", []):
  168. aid = a.get("id", "art")
  169. if not creds.get("api_key"):
  170. log(f"⚠️ 未填 key,跳过 UI 美术 {aid}")
  171. continue
  172. try:
  173. transparent = a.get("transparent", True)
  174. extra = (transparent_prompt("single clean UI element, no text")
  175. if transparent
  176. else "full-bleed illustration, no text, no UI elements")
  177. full_prompt = ", ".join(x for x in [a.get("prompt", ""), style if a.get("use_style") else "", extra] if x)
  178. log(f"🖼 [{aid}] 生成 UI 美术…")
  179. img = generate_checked(aid, full_prompt, a.get("size", creds.get("size", "1024x1024")), transparent)
  180. os.makedirs(ui_art_out, exist_ok=True)
  181. img.save(os.path.join(ui_art_out, f"{aid}.png"))
  182. upsert("ui_art", {"id": aid, "file": f"ui_art/{aid}.png",
  183. "w": img.width, "h": img.height,
  184. "transparent": transparent})
  185. log(f"✅ [{aid}] UI 美术完成")
  186. progress(f"{aid}")
  187. except Exception as e:
  188. log(f"❌ [{aid}] UI 美术失败: {e}")
  189. progress(f"{aid}")
  190. # ---- B. 粒子 VFX ----
  191. for v in manifest.get("vfx", []):
  192. vid = v.get("id", "vfx")
  193. try:
  194. path = particle_builder.build_particle(
  195. vid, v.get("template", "burst"), v.get("color", [255, 255, 255]), vfx_out)
  196. cfg = json.load(open(path, encoding="utf-8"))
  197. upsert("vfx", {"id": vid, "template": v.get("template"),
  198. "file": f"vfx/{vid}.particle.json", "config": cfg})
  199. log(f"✨ [{vid}] 粒子配置完成")
  200. progress(f"{vid}")
  201. except Exception as e:
  202. log(f"❌ [{vid}] 粒子失败: {e}")
  203. progress(f"{vid}")
  204. # ---- C. UI Tween ----
  205. ui = manifest.get("ui", [])
  206. if ui:
  207. used = [u.get("preset") for u in ui if u.get("preset")]
  208. try:
  209. tween_builder.build_tweens(used, ui_out)
  210. for u in ui:
  211. upsert("ui", {"id": u.get("id"), "preset": u.get("preset"),
  212. "params": u.get("params", {})})
  213. log(f"🎛 TweenPresets.ts 完成 ({used})")
  214. progress("TweenPresets")
  215. except Exception as e:
  216. log(f"❌ Tween 失败: {e}")
  217. progress("TweenPresets")
  218. os.makedirs(base_out, exist_ok=True)
  219. with open(os.path.join(base_out, "library.json"), "w", encoding="utf-8") as f:
  220. json.dump(library, f, ensure_ascii=False, indent=2)
  221. log("—— 完成 ——")
  222. if boss_missing_after_chars:
  223. detail = ";".join(required_failures) if required_failures else "生成结果中没有关主资源"
  224. raise RuntimeError(
  225. f"关主大魔王资源缺失:{boss_id}。已保存其他成功资源;请在任务卡片里继续补生成 boss。原因:{detail}"
  226. )
  227. return library, base_out