|
@@ -28,6 +28,7 @@ import threading
|
|
|
import time
|
|
import time
|
|
|
import traceback
|
|
import traceback
|
|
|
import uuid
|
|
import uuid
|
|
|
|
|
+import base64
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
from urllib.parse import urlparse, parse_qs, unquote
|
|
from urllib.parse import urlparse, parse_qs, unquote
|
|
|
|
|
|
|
@@ -342,6 +343,17 @@ def _asset_quality_for_task(game, kind, item, asset):
|
|
|
else:
|
|
else:
|
|
|
errors.append("完整预览缺失")
|
|
errors.append("完整预览缺失")
|
|
|
|
|
|
|
|
|
|
+ preview_version = asset.get("previewVersion", "")
|
|
|
|
|
+ part_versions = asset.get("partVersions") or {}
|
|
|
|
|
+ if preview_version:
|
|
|
|
|
+ stale = [
|
|
|
|
|
+ part.get("id", "")
|
|
|
|
|
+ for part in asset.get("parts") or []
|
|
|
|
|
+ if part_versions.get(part.get("id", "")) != preview_version
|
|
|
|
|
+ ]
|
|
|
|
|
+ if stale:
|
|
|
|
|
+ errors.append(f"拆件与当前主图版本不一致,请按主图重生全部拆件:{', '.join(stale[:6])}")
|
|
|
|
|
+
|
|
|
for part in asset.get("parts") or []:
|
|
for part in asset.get("parts") or []:
|
|
|
pfile = part.get("file", "")
|
|
pfile = part.get("file", "")
|
|
|
ppath = os.path.join(base, pfile)
|
|
ppath = os.path.join(base, pfile)
|
|
@@ -470,6 +482,42 @@ def _generate_alpha_image(creds, prompt, size, label, log):
|
|
|
raise RuntimeError("图片没有真实 Alpha,抠图后仍不合格")
|
|
raise RuntimeError("图片没有真实 Alpha,抠图后仍不合格")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _new_asset_version():
|
|
|
|
|
+ return f"v{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _image_data_url(path):
|
|
|
|
|
+ with open(path, "rb") as f:
|
|
|
|
|
+ raw = f.read()
|
|
|
|
|
+ return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _boss_preview_analysis(preview_path, creds, log):
|
|
|
|
|
+ try:
|
|
|
|
|
+ notes = providers.analyze_reference_images(
|
|
|
|
|
+ image_data_urls=[_image_data_url(preview_path)],
|
|
|
|
|
+ api_key=creds.get("api_key", ""),
|
|
|
|
|
+ base_url=creds.get("base_url", DEFAULT_BASE_URL),
|
|
|
|
|
+ model=DEFAULT_TEXT_MODEL,
|
|
|
|
|
+ )
|
|
|
|
|
+ text = json.dumps(notes, ensure_ascii=False)
|
|
|
|
|
+ log(f"🔎 主图风格解析完成,用于约束拆件一致性")
|
|
|
|
|
+ return text[:1800]
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log(f"⚠️ 主图风格解析失败,改用文字 prompt 约束一致性:{e}")
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _part_consistency_prompt(row, preview_analysis):
|
|
|
|
|
+ version = row.get("previewVersion", "")
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"Match the current boss preview exactly. previewVersion={version}. "
|
|
|
|
|
+ "Keep the same character identity, jelly material, color palette, crown/armor shapes, proportions, lighting, and candy-land art style. "
|
|
|
|
|
+ "This must be one isolated rigging part cropped from that same character design, not a redesigned character. "
|
|
|
|
|
+ + (f"Reference preview analysis: {preview_analysis}" if preview_analysis else "")
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
|
|
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())
|
|
_set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
|
|
|
try:
|
|
try:
|
|
@@ -485,6 +533,9 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
|
|
|
target = next((p for p in parts if p.get("id") == part_id), None)
|
|
target = next((p for p in parts if p.get("id") == part_id), None)
|
|
|
if not target:
|
|
if not target:
|
|
|
raise RuntimeError(f"找不到关主拆件:{part_id}")
|
|
raise RuntimeError(f"找不到关主拆件:{part_id}")
|
|
|
|
|
+ row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None) or {}
|
|
|
|
|
+ preview_version = row.get("previewVersion", "")
|
|
|
|
|
+ consistency = _part_consistency_prompt(row, row.get("previewAnalysis", ""))
|
|
|
|
|
|
|
|
_append_job_log(job_id, f"重生关主拆件:{boss_id}/{part_id}")
|
|
_append_job_log(job_id, f"重生关主拆件:{boss_id}/{part_id}")
|
|
|
part_images = {}
|
|
part_images = {}
|
|
@@ -501,6 +552,7 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
|
|
|
base_prompt = ", ".join(x for x in [
|
|
base_prompt = ", ".join(x for x in [
|
|
|
target.get("prompt", ""),
|
|
target.get("prompt", ""),
|
|
|
style,
|
|
style,
|
|
|
|
|
+ consistency,
|
|
|
_transparent_prompt(
|
|
_transparent_prompt(
|
|
|
f"only the {part_id} rigging part from the boss character, isolated single part, "
|
|
f"only the {part_id} rigging part from the boss character, isolated single part, "
|
|
|
"not a full character, no complete body, no other body parts, centered"
|
|
"not a full character, no complete body, no other body parts, centered"
|
|
@@ -530,10 +582,12 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
|
|
|
spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
|
|
spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
|
|
|
write_preview=False)
|
|
write_preview=False)
|
|
|
part_files = spine_builder.write_part_pngs_from_files(boss_id, chars_out)
|
|
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:
|
|
if row:
|
|
|
row["parts"] = part_files
|
|
row["parts"] = part_files
|
|
|
row["preview"] = f"characters/{boss_id}_preview.png"
|
|
row["preview"] = f"characters/{boss_id}_preview.png"
|
|
|
|
|
+ versions = row.setdefault("partVersions", {})
|
|
|
|
|
+ if preview_version:
|
|
|
|
|
+ versions[part_id] = preview_version
|
|
|
files = [f"characters/{boss_id}.json", f"characters/{boss_id}.atlas", f"characters/{boss_id}.png",
|
|
files = [f"characters/{boss_id}.json", f"characters/{boss_id}.atlas", f"characters/{boss_id}.png",
|
|
|
f"characters/{boss_id}_preview.png"]
|
|
f"characters/{boss_id}_preview.png"]
|
|
|
files.extend([p["file"] for p in part_files])
|
|
files.extend([p["file"] for p in part_files])
|
|
@@ -592,7 +646,13 @@ def _run_retry_boss_preview_job(job_id, game, boss_id, creds):
|
|
|
|
|
|
|
|
row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None)
|
|
row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None)
|
|
|
if row:
|
|
if row:
|
|
|
|
|
+ preview_version = _new_asset_version()
|
|
|
|
|
+ preview_path = os.path.join(chars_out, f"{boss_id}_preview.png")
|
|
|
row["preview"] = f"characters/{boss_id}_preview.png"
|
|
row["preview"] = f"characters/{boss_id}_preview.png"
|
|
|
|
|
+ row["previewVersion"] = preview_version
|
|
|
|
|
+ row["partsSourcePreviewVersion"] = row.get("partsSourcePreviewVersion", "")
|
|
|
|
|
+ row["partsStale"] = True
|
|
|
|
|
+ row["previewAnalysis"] = _boss_preview_analysis(preview_path, creds, lambda m: _append_job_log(job_id, m))
|
|
|
files = row.setdefault("files", [])
|
|
files = row.setdefault("files", [])
|
|
|
if row["preview"] not in files:
|
|
if row["preview"] not in files:
|
|
|
files.append(row["preview"])
|
|
files.append(row["preview"])
|
|
@@ -604,6 +664,80 @@ def _run_retry_boss_preview_job(job_id, game, boss_id, creds):
|
|
|
_set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
|
|
_set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _run_retry_boss_parts_from_preview_job(job_id, game, boss_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")
|
|
|
|
|
+ row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None)
|
|
|
|
|
+ boss = next((c for c in manifest.get("characters", [])
|
|
|
|
|
+ if c.get("id") == boss_id or c.get("role") == "boss"), None)
|
|
|
|
|
+ if not row or not boss or boss.get("type") != "spine_parts":
|
|
|
|
|
+ raise RuntimeError(f"找不到可按主图重生的关主:{boss_id}")
|
|
|
|
|
+ preview_path = os.path.join(base, row.get("preview") or f"characters/{boss_id}_preview.png")
|
|
|
|
|
+ if not os.path.isfile(preview_path):
|
|
|
|
|
+ raise RuntimeError("请先重生主图,再按主图重生拆件")
|
|
|
|
|
+ preview_version = row.get("previewVersion") or _new_asset_version()
|
|
|
|
|
+ row["previewVersion"] = preview_version
|
|
|
|
|
+ if not row.get("previewAnalysis"):
|
|
|
|
|
+ row["previewAnalysis"] = _boss_preview_analysis(preview_path, creds, lambda m: _append_job_log(job_id, m))
|
|
|
|
|
+
|
|
|
|
|
+ parts = boss.get("parts") or []
|
|
|
|
|
+ style = manifest.get("style", "")
|
|
|
|
|
+ consistency = _part_consistency_prompt(row, row.get("previewAnalysis", ""))
|
|
|
|
|
+ part_images = {}
|
|
|
|
|
+ _append_job_log(job_id, f"按当前主图重生全部拆件:{boss_id},共 {len(parts)} 个")
|
|
|
|
|
+ for idx, part in enumerate(parts, start=1):
|
|
|
|
|
+ pid = part["id"]
|
|
|
|
|
+ base_prompt = ", ".join(x for x in [
|
|
|
|
|
+ part.get("prompt", ""),
|
|
|
|
|
+ style,
|
|
|
|
|
+ consistency,
|
|
|
|
|
+ _transparent_prompt(
|
|
|
|
|
+ f"only the {pid} rigging part from the current boss preview, isolated single part, "
|
|
|
|
|
+ "not a full character, no complete body, no other body parts, centered"
|
|
|
|
|
+ ),
|
|
|
|
|
+ ] if x)
|
|
|
|
|
+ correction = ""
|
|
|
|
|
+ last_reason = ""
|
|
|
|
|
+ for attempt in range(1, 4):
|
|
|
|
|
+ if attempt > 1:
|
|
|
|
|
+ _append_job_log(job_id, f"🔁 [{pid}] 拆件不合格,重新生成(第 {attempt}/3 次)…")
|
|
|
|
|
+ prompt = base_prompt if not correction else ", ".join([base_prompt, correction])
|
|
|
|
|
+ img = _generate_alpha_image(creds, prompt, part.get("size", boss.get("size", creds.get("size", "1024x1024"))),
|
|
|
|
|
+ f"{boss_id}/{pid}", lambda m: _append_job_log(job_id, m))
|
|
|
|
|
+ ok, reason, detail = asset_quality.boss_part_quality(pid, img)
|
|
|
|
|
+ if ok:
|
|
|
|
|
+ _append_job_log(job_id, f"✅ [{idx}/{len(parts)}] {pid} 通过:最大主体 {detail.get('largestShare', 0):.0%}")
|
|
|
|
|
+ part_images[pid] = img
|
|
|
|
|
+ break
|
|
|
|
|
+ last_reason = reason
|
|
|
|
|
+ _append_job_log(job_id, f"⚠️ [{pid}] 拆件质量失败:{reason}")
|
|
|
|
|
+ correction = "请严格参考主图,只输出这一件拆件,不要完整角色,不要其他部件,不要相邻碎片。"
|
|
|
|
|
+ if pid not in part_images:
|
|
|
|
|
+ raise RuntimeError(f"{pid} 重生失败:{last_reason}")
|
|
|
|
|
+
|
|
|
|
|
+ spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
|
|
|
|
|
+ write_preview=False)
|
|
|
|
|
+ part_files = spine_builder.write_part_pngs_from_files(boss_id, chars_out)
|
|
|
|
|
+ row["parts"] = part_files
|
|
|
|
|
+ row["partVersions"] = {p["id"]: preview_version for p in part_files}
|
|
|
|
|
+ row["partsSourcePreviewVersion"] = preview_version
|
|
|
|
|
+ row["partsStale"] = False
|
|
|
|
|
+ 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):
|
|
def _split_lines(value):
|
|
|
if isinstance(value, list):
|
|
if isinstance(value, list):
|
|
|
return [str(x).strip() for x in value if str(x).strip()]
|
|
return [str(x).strip() for x in value if str(x).strip()]
|
|
@@ -924,6 +1058,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
return self._post_retry_boss_part()
|
|
return self._post_retry_boss_part()
|
|
|
if route == "/api/retry-boss-preview":
|
|
if route == "/api/retry-boss-preview":
|
|
|
return self._post_retry_boss_preview()
|
|
return self._post_retry_boss_preview()
|
|
|
|
|
+ if route == "/api/retry-boss-parts-from-preview":
|
|
|
|
|
+ return self._post_retry_boss_parts_from_preview()
|
|
|
if route == "/api/retry-missing":
|
|
if route == "/api/retry-missing":
|
|
|
return self._post_retry_missing()
|
|
return self._post_retry_missing()
|
|
|
if route == "/api/delete":
|
|
if route == "/api/delete":
|
|
@@ -1123,6 +1259,32 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
args=(job_id, game, boss_id, creds), daemon=True).start()
|
|
args=(job_id, game, boss_id, creds), daemon=True).start()
|
|
|
return self._send(200, {"ok": True, "jobId": job_id, "game": game})
|
|
return self._send(200, {"ok": True, "jobId": job_id, "game": game})
|
|
|
|
|
|
|
|
|
|
+ def _post_retry_boss_parts_from_preview(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()
|
|
|
|
|
+ if not game or game not in list_games():
|
|
|
|
|
+ return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
|
|
|
|
|
+ if not boss_id:
|
|
|
|
|
+ return self._send(400, {"ok": False, "error": "缺少 bossId"})
|
|
|
|
|
+ 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_parts_from_preview_job,
|
|
|
|
|
+ args=(job_id, game, boss_id, creds), daemon=True).start()
|
|
|
|
|
+ return self._send(200, {"ok": True, "jobId": job_id, "game": game})
|
|
|
|
|
+
|
|
|
def _post_retry_missing(self):
|
|
def _post_retry_missing(self):
|
|
|
try:
|
|
try:
|
|
|
data = self._read_json_body()
|
|
data = self._read_json_body()
|