|
|
@@ -37,6 +37,8 @@ import slot_workflow
|
|
|
import spine_builder
|
|
|
import providers
|
|
|
import config
|
|
|
+import baidu_segment
|
|
|
+import asset_quality
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
OUT_ROOT = os.path.join(HERE, "out")
|
|
|
@@ -108,6 +110,13 @@ def _load_library(game):
|
|
|
return lib
|
|
|
|
|
|
|
|
|
+def _save_library(game, lib):
|
|
|
+ library_path = os.path.join(OUT_ROOT, game, "library.json")
|
|
|
+ os.makedirs(os.path.dirname(library_path), exist_ok=True)
|
|
|
+ with open(library_path, "w", encoding="utf-8") as f:
|
|
|
+ json.dump(lib, f, ensure_ascii=False, indent=2)
|
|
|
+
|
|
|
+
|
|
|
def _spine_size_from_json(path):
|
|
|
try:
|
|
|
data = json.load(open(path, encoding="utf-8"))
|
|
|
@@ -399,6 +408,105 @@ def _run_retry_missing_job(job_id, game, groups, creds):
|
|
|
_set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
|
|
|
|
|
|
|
|
|
+def _transparent_prompt(extra):
|
|
|
+ return ", ".join([
|
|
|
+ extra,
|
|
|
+ "生成纯透明背景 PNG,真实 Alpha 通道,不要棋盘格,不要白底,不要阴影。",
|
|
|
+ ])
|
|
|
+
|
|
|
+
|
|
|
+def _has_alpha(img):
|
|
|
+ alpha = img.convert("RGBA").getchannel("A")
|
|
|
+ return alpha.getextrema()[0] == 0
|
|
|
+
|
|
|
+
|
|
|
+def _generate_alpha_image(creds, prompt, size, label, log):
|
|
|
+ img = providers.generate(creds["provider"], prompt, creds["api_key"],
|
|
|
+ creds.get("base_url", "https://api.openai.com/v1"),
|
|
|
+ creds.get("model", "gpt-image-2"), size)
|
|
|
+ if _has_alpha(img):
|
|
|
+ return img
|
|
|
+ log(f"🧠 [{label}] 模型没有真实 Alpha,改用百度智能抠图兜底…")
|
|
|
+ fixed = baidu_segment.remove_background(img, label=label, log=log)
|
|
|
+ if _has_alpha(fixed):
|
|
|
+ return fixed
|
|
|
+ raise RuntimeError("图片没有真实 Alpha,抠图后仍不合格")
|
|
|
+
|
|
|
+
|
|
|
+def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
|
|
|
+ _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
|
|
|
+ try:
|
|
|
+ lib = _load_library(game)
|
|
|
+ manifest = _manifest_from_library(lib)
|
|
|
+ base = os.path.join(OUT_ROOT, game)
|
|
|
+ chars_out = os.path.join(base, "characters")
|
|
|
+ boss = next((c for c in manifest.get("characters", [])
|
|
|
+ if c.get("id") == boss_id or c.get("role") == "boss"), None)
|
|
|
+ if not boss or boss.get("type") != "spine_parts":
|
|
|
+ raise RuntimeError(f"找不到可拆件关主:{boss_id}")
|
|
|
+ parts = boss.get("parts") or []
|
|
|
+ target = next((p for p in parts if p.get("id") == part_id), None)
|
|
|
+ if not target:
|
|
|
+ raise RuntimeError(f"找不到关主拆件:{part_id}")
|
|
|
+
|
|
|
+ _append_job_log(job_id, f"重生关主拆件:{boss_id}/{part_id}")
|
|
|
+ part_images = {}
|
|
|
+ for part in parts:
|
|
|
+ pid = part["id"]
|
|
|
+ ppath = os.path.join(chars_out, f"{boss_id}_parts", f"{pid}.png")
|
|
|
+ if pid != part_id:
|
|
|
+ if not os.path.isfile(ppath):
|
|
|
+ raise RuntimeError(f"缺少现有拆件,无法局部重建:{pid}")
|
|
|
+ from PIL import Image
|
|
|
+ part_images[pid] = Image.open(ppath).convert("RGBA")
|
|
|
+
|
|
|
+ style = manifest.get("style", "")
|
|
|
+ base_prompt = ", ".join(x for x in [
|
|
|
+ boss.get("prompt", ""),
|
|
|
+ target.get("prompt", ""),
|
|
|
+ style,
|
|
|
+ _transparent_prompt("single separated rigging part only, centered, no text, no other body parts"),
|
|
|
+ ] if x)
|
|
|
+ correction = ""
|
|
|
+ last_reason = ""
|
|
|
+ for attempt in range(1, 4):
|
|
|
+ if attempt > 1:
|
|
|
+ _append_job_log(job_id, f"🔁 [{part_id}] 拆件不合格,重新生成(第 {attempt}/3 次)…")
|
|
|
+ prompt = base_prompt if not correction else ", ".join([base_prompt, correction])
|
|
|
+ img = _generate_alpha_image(creds, prompt, target.get("size", boss.get("size", creds.get("size", "1024x1024"))),
|
|
|
+ f"{boss_id}/{part_id}", lambda m: _append_job_log(job_id, m))
|
|
|
+ ok, reason, detail = asset_quality.boss_part_quality(part_id, img)
|
|
|
+ if ok:
|
|
|
+ _append_job_log(job_id, f"✅ [{part_id}] 拆件质量通过:最大主体 {detail.get('largestShare', 0):.0%}")
|
|
|
+ part_images[part_id] = img
|
|
|
+ break
|
|
|
+ last_reason = reason
|
|
|
+ _append_job_log(job_id, f"⚠️ [{part_id}] 拆件质量失败:{reason}")
|
|
|
+ correction = (
|
|
|
+ "这不是干净的单个拆件。请只生成这一件,不要包含任何其他身体部位、碎片、武器边缘或相邻格内容。"
|
|
|
+ )
|
|
|
+ if part_id not in part_images:
|
|
|
+ raise RuntimeError(f"拆件重生失败:{last_reason}")
|
|
|
+
|
|
|
+ spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
|
|
|
+ write_preview=True)
|
|
|
+ part_files = spine_builder.write_part_pngs_from_files(boss_id, chars_out)
|
|
|
+ row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None)
|
|
|
+ if row:
|
|
|
+ row["parts"] = part_files
|
|
|
+ row["preview"] = f"characters/{boss_id}_preview.png"
|
|
|
+ files = [f"characters/{boss_id}.json", f"characters/{boss_id}.atlas", f"characters/{boss_id}.png",
|
|
|
+ f"characters/{boss_id}_preview.png"]
|
|
|
+ files.extend([p["file"] for p in part_files])
|
|
|
+ row["files"] = files
|
|
|
+ _save_library(game, lib)
|
|
|
+ _set_job(job_id, status="done", ok=True, game=game, updatedAt=time.time())
|
|
|
+ except Exception as e:
|
|
|
+ traceback.print_exc()
|
|
|
+ _append_job_log(job_id, f"❌ 拆件重生失败: {e}")
|
|
|
+ _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
|
|
|
+
|
|
|
+
|
|
|
def _split_lines(value):
|
|
|
if isinstance(value, list):
|
|
|
return [str(x).strip() for x in value if str(x).strip()]
|
|
|
@@ -715,6 +823,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
return self._post_open_folder()
|
|
|
if route == "/api/retry-task":
|
|
|
return self._post_retry_task()
|
|
|
+ if route == "/api/retry-boss-part":
|
|
|
+ return self._post_retry_boss_part()
|
|
|
if route == "/api/retry-missing":
|
|
|
return self._post_retry_missing()
|
|
|
if route == "/api/delete":
|
|
|
@@ -861,6 +971,33 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
threading.Thread(target=_run_retry_job, args=(job_id, game, kind, task_ids, creds), daemon=True).start()
|
|
|
return self._send(200, {"ok": True, "jobId": job_id, "game": game})
|
|
|
|
|
|
+ def _post_retry_boss_part(self):
|
|
|
+ try:
|
|
|
+ data = self._read_json_body()
|
|
|
+ except Exception as e:
|
|
|
+ return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
|
|
|
+ game = (data.get("game") or "").strip()
|
|
|
+ boss_id = (data.get("bossId") or data.get("id") or "").strip()
|
|
|
+ part_id = (data.get("partId") or "").strip()
|
|
|
+ if not game or game not in list_games():
|
|
|
+ return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
|
|
|
+ if not boss_id or not part_id:
|
|
|
+ return self._send(400, {"ok": False, "error": "缺少 bossId 或 partId"})
|
|
|
+ creds = {
|
|
|
+ "provider": data.get("provider", "OpenAI 兼容接口"),
|
|
|
+ "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
|
|
|
+ "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
|
|
|
+ "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
|
|
|
+ "size": data.get("size", "1024x1024"),
|
|
|
+ }
|
|
|
+ job_id = uuid.uuid4().hex
|
|
|
+ with JOBS_LOCK:
|
|
|
+ JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["拆件重生任务已创建,等待开始…"],
|
|
|
+ "game": game, "createdAt": time.time(), "updatedAt": time.time()}
|
|
|
+ threading.Thread(target=_run_retry_boss_part_job,
|
|
|
+ args=(job_id, game, boss_id, part_id, creds), daemon=True).start()
|
|
|
+ return self._send(200, {"ok": True, "jobId": job_id, "game": game})
|
|
|
+
|
|
|
def _post_retry_missing(self):
|
|
|
try:
|
|
|
data = self._read_json_body()
|