소스 검색

Expose individual boss part images

bang 2 주 전
부모
커밋
36051b9ea1
5개의 변경된 파일67개의 추가작업 그리고 2개의 파일을 삭제
  1. 6 1
      exporter.py
  2. 4 0
      pipeline.py
  3. 12 0
      server.py
  4. 28 0
      spine_builder.py
  5. 17 1
      web/index.html

+ 6 - 1
exporter.py

@@ -762,7 +762,12 @@ def export(game, out_root, log=print):
     src_ch = os.path.join(base, "characters")
     if os.path.isdir(src_ch):
         for f in sorted(os.listdir(src_ch)):
-            shutil.copy2(os.path.join(src_ch, f), os.path.join(res_ch, f))
+            src_path = os.path.join(src_ch, f)
+            dst_path = os.path.join(res_ch, f)
+            if os.path.isdir(src_path):
+                shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
+                continue
+            shutil.copy2(src_path, dst_path)
             if f.endswith(".json"):
                 char_ids.append(f[:-5])
     log(f"📦 角色 {len(char_ids)} 个")

+ 4 - 0
pipeline.py

@@ -192,6 +192,7 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
             log(f"⚠️  未填 key,跳过角色 {cid}")
             continue
         try:
+            part_files = []
             if c.get("type") == "spine_parts":
                 parts = c.get("parts") or []
                 part_images = {}
@@ -246,6 +247,7 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                         part_images[part_id] = pimg
                 spine_builder.build_parts_character(cid, part_images, chars_out, anims, parts,
                                                     write_preview=preview_img is None)
+                part_files = spine_builder.write_part_pngs_from_files(cid, chars_out)
                 if preview_img is not None:
                     os.makedirs(chars_out, exist_ok=True)
                     spine_builder.trim_to_content(preview_img, pad=16).save(
@@ -254,6 +256,7 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                 w, h = 1000, 1000
                 files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png",
                          f"characters/{cid}_preview.png"]
+                files.extend([p["file"] for p in part_files])
                 preview = f"characters/{cid}_preview.png"
             else:
                 full_prompt = ", ".join(x for x in [
@@ -275,6 +278,7 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                 "preview": preview,
                 "animations": spine_builder.anim_data(anims),
                 "files": files,
+                "parts": part_files,
             })
             log(f"✅ [{cid}] 完成 ({anims})")
             progress(f"{cid}")

+ 12 - 0
server.py

@@ -152,6 +152,15 @@ def _repair_library_index(game, lib):
             preview_path = os.path.join(base, "characters", f"{cid}_preview.png")
             if c.get("type") == "spine_parts" and not os.path.isfile(preview_path):
                 spine_builder.write_parts_preview_from_files(cid, os.path.join(base, "characters"))
+            if c.get("type") == "spine_parts":
+                parts = spine_builder.write_part_pngs_from_files(cid, os.path.join(base, "characters"))
+                if parts and existing_item.get("parts") != parts:
+                    existing_item["parts"] = parts
+                    files = existing_item.setdefault("files", [])
+                    for part in parts:
+                        if part["file"] not in files:
+                            files.append(part["file"])
+                    changed = True
             if os.path.isfile(preview_path) and not existing_item.get("preview"):
                 existing_item["preview"] = f"characters/{cid}_preview.png"
                 if existing_item["preview"] not in existing_item.setdefault("files", []):
@@ -173,6 +182,9 @@ def _repair_library_index(game, lib):
                 "animations": spine_builder.anim_data(c.get("animations", ["idle"])),
                 "files": [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"],
             }
+            if c.get("type") == "spine_parts":
+                item["parts"] = spine_builder.write_part_pngs_from_files(cid, os.path.join(base, "characters"))
+                item["files"].extend([p["file"] for p in item["parts"]])
             if item["preview"]:
                 item["files"].append(item["preview"])
             lib.setdefault("characters", []).append(item)

+ 28 - 0
spine_builder.py

@@ -351,12 +351,15 @@ def build_parts_skeleton_json(char_id, parts, atlas_w, atlas_h, animations):
 def build_parts_character(char_id, part_images, out_dir, animations, layout_parts, write_preview=True):
     """多部件 Boss:part_images {part_id: PIL RGBA} -> 单 atlas + 多骨骼 JSON。"""
     os.makedirs(out_dir, exist_ok=True)
+    parts_dir = os.path.join(out_dir, f"{char_id}_parts")
+    os.makedirs(parts_dir, exist_ok=True)
     packed = []
     x = 0
     atlas_h = 0
     for part in layout_parts:
         pid = part["id"]
         img = trim_to_content(part_images[pid].convert("RGBA"), pad=2)
+        img.save(os.path.join(parts_dir, f"{pid}.png"))
         w, h = img.size
         packed.append({**part, "img": img, "x_atlas": x, "y_atlas": 0, "w": w, "h": h})
         x += w + 2
@@ -381,6 +384,31 @@ def build_parts_character(char_id, part_images, out_dir, animations, layout_part
     return png_path
 
 
+def write_part_pngs_from_files(char_id, out_dir):
+    """Export individual part PNGs from an existing atlas."""
+    png_path = os.path.join(out_dir, f"{char_id}.png")
+    atlas_path = os.path.join(out_dir, f"{char_id}.atlas")
+    if not (os.path.isfile(png_path) and os.path.isfile(atlas_path)):
+        return []
+    atlas = Image.open(png_path).convert("RGBA")
+    regions = _read_atlas_regions(atlas_path)
+    if not regions:
+        return []
+    parts_dir = os.path.join(out_dir, f"{char_id}_parts")
+    os.makedirs(parts_dir, exist_ok=True)
+    files = []
+    for name, r in regions.items():
+        img = atlas.crop((r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"]))
+        img.save(os.path.join(parts_dir, f"{name}.png"))
+        files.append({
+            "id": name,
+            "file": f"characters/{char_id}_parts/{name}.png",
+            "w": r["w"],
+            "h": r["h"],
+        })
+    return files
+
+
 def write_parts_preview(char_id, packed, out_dir):
     """Write a human-readable assembled preview for multi-part characters."""
     canvas = Image.new("RGBA", (1000, 1000), (0, 0, 0, 0))

+ 17 - 1
web/index.html

@@ -93,6 +93,11 @@
   .parts-box{background:#160f29;border:1px solid var(--line);border-radius:10px;padding:12px}
   .parts-box img{width:100%;max-height:520px;object-fit:contain;
         background:repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/22px 22px;border-radius:8px}
+  .parts-list{grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}
+  .part-card{background:#160f29;border:1px solid var(--line);border-radius:9px;padding:8px;min-width:0}
+  .part-card img{width:100%;height:92px;object-fit:contain;background:
+        repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/18px 18px;border-radius:7px}
+  .part-card .meta{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:5px}
   code{background:#160f29;padding:1px 6px;border-radius:5px;font-size:12px}
   a{color:var(--accent2)}
   @media(max-width:760px){.parts-grid{grid-template-columns:1fr}}
@@ -258,6 +263,11 @@
         <div class="name">拆件图 / Atlas</div>
         <img id="partsAtlas" alt="拆件图">
       </div>
+      <div class="parts-box" style="grid-column:1/-1">
+        <div class="name">单独拆件 PNG</div>
+        <div class="meta" id="partsCount"></div>
+        <div class="parts-list" id="partsList"></div>
+      </div>
     </div>
   </div>
 </div>
@@ -283,6 +293,11 @@ function openPartsModal(c, t){
   $('#partsTitle').textContent = `${t.chineseName} · 拆件检查`;
   $('#partsPreview').src = assetUrl(c.preview || c.png);
   $('#partsAtlas').src = assetUrl(c.png);
+  const parts = c.parts || [];
+  $('#partsCount').textContent = parts.length ? `已拆出 ${parts.length} 个单独 PNG,可逐个检查。` : '没有单独拆件 PNG;请重新生成该关主。';
+  $('#partsList').innerHTML = parts.length
+    ? parts.map(p=>`<div class="part-card"><img src="${assetUrl(p.file)}" alt="${esc(p.id)}"><div class="meta">${esc(p.id)} · ${esc(p.w||'?')}×${esc(p.h||'?')}</div></div>`).join('')
+    : '<div class="empty">缺少单独拆件文件。</div>';
   $('#partsModal').classList.add('open');
 }
 function syncRunningWithLibrary(){
@@ -372,13 +387,14 @@ function renderChars(v){
     const anims=Object.keys(animMap);
     const displayImg = c.preview || c.png;
     const isParts = c.type==='spine_parts' || t.assetType==='spine_parts';
+    const partCount = (c.parts || []).length;
     const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
     card.innerHTML=`
       <div class="stage ${done&&isParts?'clickable':''}">${done?`<img src="${assetUrl(displayImg)}" alt="${esc(t.id)}">`:`<div class="placeholder">${running?'生成中…':'待生成'}<br>${esc(t.chineseName)}</div>`}</div>
       <div class="task-head"><div><div class="name">${esc(t.chineseName)}</div><div class="meta">${esc(t.englishName)}</div></div>
         <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
       ${done&&anims.length?`<div class="row"><select>${anims.map(a=>`<option>${esc(a)}</option>`).join('')}</select></div>`:''}
-      <div class="meta"><span class="pill">${esc(t.assetType||'spine')}</span> ${esc(t.use)}<br>
+      <div class="meta"><span class="pill">${esc(t.assetType||'spine')}</span> ${isParts?`<span class="pill">拆件 ${partCount}</span> `:''}${esc(t.use)}<br>
         动作: ${esc((done?anims:t.animations||[]).join(', ')||'idle')}</div>
       ${done&&isParts?'<button class="ghost parts-btn">查看拆件图</button>':''}
       <div class="task-prompt">${esc(t.prompt||'')}</div>