Jelajahi Sumber

Separate boss preview and part retries

bang 2 minggu lalu
induk
melakukan
181e5afe31
4 mengubah file dengan 131 tambahan dan 6 penghapusan
  1. 7 0
      asset_quality.py
  2. 4 2
      pipeline.py
  3. 91 3
      server.py
  4. 29 1
      web/index.html

+ 7 - 0
asset_quality.py

@@ -121,4 +121,11 @@ def boss_part_quality(part_id, image_or_path):
     if sig_count >= 2 and second >= 0.04:
         return False, f"拆件包含 {sig_count} 个明显分离主体,疑似切进了相邻部件。", report
 
+    w, h = report.get("size") or [0, 0]
+    small_part = any(token in pid for token in (
+        "head", "horn", "arm", "leg", "hand", "boot", "weapon", "sword"
+    ))
+    if small_part and max(w, h) >= 620 and min(w, h) >= 420:
+        return False, "拆件尺寸接近完整角色,疑似把主图生成到了单个部件里。", report
+
     return True, "拆件主体干净。", report

+ 4 - 2
pipeline.py

@@ -235,10 +235,12 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                     for part in parts:
                         part_id = part["id"]
                         part_prompt = ", ".join(x for x in [
-                            c.get("prompt", ""),
                             part.get("prompt", ""),
                             style,
-                            transparent_prompt("single separated rigging part only, centered, no text, no other body parts")
+                            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"
+                            )
                         ] if x)
                         log(f"🎨 [{cid}/{part_id}] 生成 Boss 拆件…")
                         pimg = generate_checked(f"{cid}/{part_id}", part_prompt,

+ 91 - 3
server.py

@@ -499,10 +499,12 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
 
         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"),
+            _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"
+            ),
         ] if x)
         correction = ""
         last_reason = ""
@@ -526,7 +528,7 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
             raise RuntimeError(f"拆件重生失败:{last_reason}")
 
         spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
-                                            write_preview=True)
+                                            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:
@@ -544,6 +546,64 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
         _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
 
 
+def _run_retry_boss_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")
+        boss = next((c for c in manifest.get("characters", [])
+                     if c.get("id") == boss_id or c.get("role") == "boss"), None)
+        if not boss:
+            raise RuntimeError(f"找不到关主:{boss_id}")
+        style = manifest.get("style", "")
+        base_prompt = ", ".join(x for x in [
+            boss.get("prompt", ""),
+            style,
+            _transparent_prompt(
+                "single complete assembled boss character preview, full body, centered, readable silhouette, "
+                "one complete character only, no separated parts, no sprite sheet, no atlas, no cropped body"
+            ),
+        ] if x)
+        correction = ""
+        last_reason = ""
+        _append_job_log(job_id, f"重生关主完整预览:{boss_id}")
+        for attempt in range(1, 4):
+            if attempt > 1:
+                _append_job_log(job_id, f"🔁 [{boss_id}/preview] 主图不合格,重新生成(第 {attempt}/3 次)…")
+            prompt = base_prompt if not correction else ", ".join([base_prompt, correction])
+            img = _generate_alpha_image(creds, prompt, boss.get("size", creds.get("size", "1024x1024")),
+                                        f"{boss_id}/preview", lambda m: _append_job_log(job_id, m))
+            ok, reason, detail = asset_quality.boss_preview_quality(img)
+            if ok:
+                os.makedirs(chars_out, exist_ok=True)
+                spine_builder.trim_to_content(img, pad=16).save(os.path.join(chars_out, f"{boss_id}_preview.png"))
+                _append_job_log(job_id, f"✅ [{boss_id}/preview] 主图质量通过:最大主体 {detail.get('largestShare', 0):.0%}")
+                break
+            last_reason = reason
+            _append_job_log(job_id, f"⚠️ [{boss_id}/preview] 主图质量失败:{reason}")
+            correction = (
+                "这不是完整主图。请只生成一个完整、站立、全身、主体连贯的关主角色;"
+                "不要拆件、不要把部件分开、不要 atlas、不要只画头或半身。"
+            )
+        else:
+            raise RuntimeError(f"主图重生失败:{last_reason}")
+
+        row = next((c for c in lib.get("characters", []) if c.get("id") == boss_id), None)
+        if row:
+            row["preview"] = f"characters/{boss_id}_preview.png"
+            files = row.setdefault("files", [])
+            if row["preview"] not in files:
+                files.append(row["preview"])
+        _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()]
@@ -862,6 +922,8 @@ class Handler(BaseHTTPRequestHandler):
             return self._post_retry_task()
         if route == "/api/retry-boss-part":
             return self._post_retry_boss_part()
+        if route == "/api/retry-boss-preview":
+            return self._post_retry_boss_preview()
         if route == "/api/retry-missing":
             return self._post_retry_missing()
         if route == "/api/delete":
@@ -1035,6 +1097,32 @@ class Handler(BaseHTTPRequestHandler):
                          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_boss_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_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?'<button class="ghost parts-btn">查看拆件图</button>':''}
+      ${usable&&isParts?'<div class="row"><button class="ghost parts-btn">查看拆件图</button><button class="ghost preview-btn" data-boss="'+esc(t.id)+'">重生主图</button></div>':''}
       <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 previewBtn=e.target.closest('.preview-btn');
+  if(previewBtn){
+    const game=$('#gameSel').value;
+    if(!game||game==='(暂无)'){ opMsg('没有可重生的资源库',false); return; }
+    const item={kind:'characters',id:previewBtn.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-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, previewBtn, [item]);
+    }catch(err){
+      log.textContent='请求失败: '+err;
+      markTasksRunning([item], false); render();
+    }
+    return;
+  }
   const btn=e.target.closest('.retry-btn');
   if(!btn) return;
   const game=$('#gameSel').value;