Эх сурвалжийг харах

Bind boss parts to preview versions

bang 2 долоо хоног өмнө
parent
commit
fb8008a07e
3 өөрчлөгдсөн 209 нэмэгдсэн , 2 устгасан
  1. 17 0
      exporter.py
  2. 163 1
      server.py
  3. 29 1
      web/index.html

+ 17 - 0
exporter.py

@@ -180,6 +180,23 @@ def _qa_report(lib, base, slot_src):
                     )
             if row.get("type") != "spine_parts":
                 _qa_add(report, "warning", "boss_not_parts", "关主不是 spine_parts,爆炸拆件动作会受限。", boss_id, "重新生成关主拆件。")
+            preview_version = row.get("previewVersion", "")
+            part_versions = row.get("partVersions") or {}
+            if preview_version:
+                stale = [
+                    part.get("id", "")
+                    for part in row.get("parts") or []
+                    if part_versions.get(part.get("id", "")) != preview_version
+                ]
+                if stale:
+                    _qa_add(
+                        report,
+                        "error",
+                        "boss_parts_version_mismatch",
+                        f"关主拆件与当前主图版本不一致:{', '.join(stale[:6])}",
+                        boss_id,
+                        "点击“按主图重生全部拆件”,让拆件和主图绑定同一版本。",
+                    )
             for part in row.get("parts") or []:
                 pfile = part.get("file", "")
                 ppath = os.path.join(base, pfile)

+ 163 - 1
server.py

@@ -28,6 +28,7 @@ import threading
 import time
 import traceback
 import uuid
+import base64
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from urllib.parse import urlparse, parse_qs, unquote
 
@@ -342,6 +343,17 @@ def _asset_quality_for_task(game, kind, item, asset):
     else:
         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 []:
         pfile = part.get("file", "")
         ppath = os.path.join(base, pfile)
@@ -470,6 +482,42 @@ def _generate_alpha_image(creds, prompt, size, label, log):
     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):
     _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
     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)
         if not target:
             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}")
         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 [
             target.get("prompt", ""),
             style,
+            consistency,
             _transparent_prompt(
                 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"
@@ -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,
                                             write_preview=False)
         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"
+            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",
                      f"characters/{boss_id}_preview.png"]
             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)
         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["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", [])
             if row["preview"] not in files:
                 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())
 
 
+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):
     if isinstance(value, list):
         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()
         if route == "/api/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":
             return self._post_retry_missing()
         if route == "/api/delete":
@@ -1123,6 +1259,32 @@ class Handler(BaseHTTPRequestHandler):
                          args=(job_id, game, boss_id, creds), daemon=True).start()
         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):
         try:
             data = self._read_json_body()

+ 29 - 1
web/index.html

@@ -406,7 +406,7 @@ function renderChars(v){
       <div class="meta"><span class="pill">${esc(t.assetType||'spine')}</span> ${isParts?`<span class="pill">拆件 ${partCount}</span> `:''}${esc(t.use)}<br>
         ${isParts?'Cocos 中播放真实骨骼动作;此处只做静态资源检查。':'动作: '+esc((done?anims:t.animations||[]).join(', ')||'idle')}</div>
       ${qaErrors.length?`<div class="qa-list">${qaErrors.slice(0,3).map(x=>`<div>需修复:${esc(x)}</div>`).join('')}</div>`:''}
-      ${usable&&isParts?'<div class="row"><button class="ghost parts-btn">查看拆件图</button><button class="ghost preview-btn" data-boss="'+esc(t.id)+'">重生主图</button></div>':''}
+      ${usable&&isParts?'<div class="row"><button class="ghost parts-btn">查看拆件图</button><button class="ghost preview-btn" data-boss="'+esc(t.id)+'">重生主图</button></div><button class="ghost parts-from-preview-btn" data-boss="'+esc(t.id)+'">按主图重生全部拆件</button>':''}
       <div class="task-prompt">${esc(t.prompt||'')}</div>
       <button class="ghost retry-btn" data-kind="characters" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':(usable?'重新生成':'补生成 / 重试')}</button>`;
     if(usable&&!isParts&&anims.length){
@@ -566,6 +566,34 @@ document.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{
 });
 
 $('#view').addEventListener('click', async e=>{
+  const partsFromPreviewBtn=e.target.closest('.parts-from-preview-btn');
+  if(partsFromPreviewBtn){
+    const game=$('#gameSel').value;
+    if(!game||game==='(暂无)'){ opMsg('没有可重生的资源库',false); return; }
+    const item={kind:'characters',id:partsFromPreviewBtn.dataset.boss};
+    markTasksRunning([item], true);
+    render();
+    opMsg(`正在按主图重生全部拆件 ${item.id}…`);
+    const log=$('#log'); log.style.display='block'; log.textContent=`按主图重生全部拆件 ${item.id} 任务创建中…`;
+    try{
+      const r=await fetch('/api/retry-boss-parts-from-preview',{method:'POST',headers:{'Content-Type':'application/json'},
+        body:JSON.stringify({
+          game, bossId:item.id,
+          provider:$('#provider').value, api_key:$('#apiKey').value,
+          base_url:$('#baseUrl').value, model:$('#model').value, size:$('#size').value })});
+      const d=await r.json();
+      if(!d.ok || !d.jobId){
+        log.textContent='❌ '+(d.error||'按主图重生拆件失败');
+        markTasksRunning([item], false); render();
+        return;
+      }
+      await pollJob(d.jobId, log, partsFromPreviewBtn, [item]);
+    }catch(err){
+      log.textContent='请求失败: '+err;
+      markTasksRunning([item], false); render();
+    }
+    return;
+  }
   const previewBtn=e.target.closest('.preview-btn');
   if(previewBtn){
     const game=$('#gameSel').value;