Ver código fonte

Show assembled previews for part atlases

bang 2 semanas atrás
pai
commit
1c82b76c70
4 arquivos alterados com 158 adições e 4 exclusões
  1. 19 2
      pipeline.py
  2. 18 1
      server.py
  3. 72 0
      spine_builder.py
  4. 49 1
      web/index.html

+ 19 - 2
pipeline.py

@@ -143,11 +143,24 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
         cell_w = max(1, sheet.width // cols)
         cell_h = max(1, sheet.height // rows)
         part_images = {}
+        major_ids = {
+            "torso", "head", "left_arm", "right_arm", "greatsword",
+            "left_leg", "right_leg", "cape",
+        }
         for idx, part in enumerate(parts):
             col = idx % cols
             row = idx // cols
             box = (col * cell_w, row * cell_h, (col + 1) * cell_w, (row + 1) * cell_h)
-            part_images[part["id"]] = sheet.crop(box)
+            cell = sheet.crop(box)
+            bbox = cell.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
+            if not bbox:
+                raise RuntimeError(f"拆件表第 {idx + 1} 格 {part['id']} 是空的")
+            bw, bh = bbox[2] - bbox[0], bbox[3] - bbox[1]
+            if part["id"] in major_ids and (bw < cell_w * 0.16 or bh < cell_h * 0.16):
+                raise RuntimeError(
+                    f"拆件表第 {idx + 1} 格 {part['id']} 太小:{bw}x{bh},需要填满单元格"
+                )
+            part_images[part["id"]] = cell
         return part_images
 
     # ---- A. 角色(Spine)----
@@ -195,7 +208,9 @@ 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)
                 w, h = 1000, 1000
-                files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
+                files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png",
+                         f"characters/{cid}_preview.png"]
+                preview = f"characters/{cid}_preview.png"
             else:
                 full_prompt = ", ".join(x for x in [
                     c.get("prompt", ""), style,
@@ -206,12 +221,14 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
                 spine_builder.build_character(cid, img, chars_out, anims)
                 w, h = spine_builder.trim_to_content(img).size
                 files = [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"]
+                preview = ""
             upsert("characters", {
                 "id": cid,
                 "png": f"characters/{cid}.png",
                 "w": w, "h": h,
                 "type": c.get("type", "spine"),
                 "role": c.get("role", ""),
+                "preview": preview,
                 "animations": spine_builder.anim_data(anims),
                 "files": files,
             })

+ 18 - 1
server.py

@@ -144,10 +144,24 @@ def _repair_library_index(game, lib):
 
     for c in manifest.get("characters", []):
         cid = c.get("id")
-        if not cid or cid in by_section["characters"]:
+        if not cid:
             continue
         paths = [os.path.join(base, "characters", f"{cid}.{ext}") for ext in ("json", "atlas", "png")]
+        existing_item = by_section["characters"].get(cid)
+        if existing_item:
+            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 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", []):
+                    existing_item["files"].append(existing_item["preview"])
+                changed = True
+            continue
         if all(os.path.isfile(p) for p in paths):
+            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"))
             w, h = _spine_size_from_json(paths[0])
             item = {
                 "id": cid,
@@ -155,9 +169,12 @@ def _repair_library_index(game, lib):
                 "w": w, "h": h,
                 "type": c.get("type", "spine"),
                 "role": c.get("role", ""),
+                "preview": f"characters/{cid}_preview.png" if os.path.isfile(preview_path) else "",
                 "animations": spine_builder.anim_data(c.get("animations", ["idle"])),
                 "files": [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"],
             }
+            if item["preview"]:
+                item["files"].append(item["preview"])
             lib.setdefault("characters", []).append(item)
             by_section["characters"][cid] = item
             changed = True

+ 72 - 0
spine_builder.py

@@ -376,4 +376,76 @@ def build_parts_character(char_id, part_images, out_dir, animations, layout_part
     skel = build_parts_skeleton_json(char_id, packed, atlas_w, atlas_h, animations)
     with open(os.path.join(out_dir, f"{char_id}.json"), "w", encoding="utf-8") as f:
         json.dump(skel, f, ensure_ascii=False, indent=2)
+    write_parts_preview(char_id, packed, out_dir)
     return png_path
+
+
+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))
+    by_id = {p["id"]: p for p in packed}
+    order = [
+        "cape", "defeated_hero_shadow", "left_leg", "right_leg", "torso",
+        "left_arm", "right_arm", "greatsword", "coin_bag", "head",
+        "horn_left", "horn_right", "crack_core", "coin_splash",
+    ]
+    for pid in order + [p["id"] for p in packed if p["id"] not in order]:
+        p = by_id.get(pid)
+        if not p:
+            continue
+        img = p["img"]
+        x = int(500 + p.get("x", 0) - img.width / 2)
+        y = int(700 - p.get("y", 0) - img.height / 2)
+        canvas.paste(img, (x, y), img)
+    preview = trim_to_content(canvas, pad=16)
+    preview.save(os.path.join(out_dir, f"{char_id}_preview.png"))
+
+
+def _read_atlas_regions(atlas_path):
+    lines = [x.rstrip("\n") for x in open(atlas_path, encoding="utf-8")]
+    regions = {}
+    i = 5
+    while i < len(lines):
+        name = lines[i].strip()
+        if not name:
+            i += 1
+            continue
+        xy = size = None
+        j = i + 1
+        while j < min(i + 8, len(lines)):
+            s = lines[j].strip()
+            if s.startswith("xy:"):
+                xy = [int(x.strip()) for x in s.split(":", 1)[1].split(",")]
+            if s.startswith("size:"):
+                size = [int(x.strip()) for x in s.split(":", 1)[1].split(",")]
+            j += 1
+        if xy and size:
+            regions[name] = {"x": xy[0], "y": xy[1], "w": size[0], "h": size[1]}
+        i += 7
+    return regions
+
+
+def write_parts_preview_from_files(char_id, out_dir):
+    """Rebuild preview from existing png/atlas/json files."""
+    png_path = os.path.join(out_dir, f"{char_id}.png")
+    atlas_path = os.path.join(out_dir, f"{char_id}.atlas")
+    json_path = os.path.join(out_dir, f"{char_id}.json")
+    if not (os.path.isfile(png_path) and os.path.isfile(atlas_path) and os.path.isfile(json_path)):
+        return ""
+    atlas = Image.open(png_path).convert("RGBA")
+    regions = _read_atlas_regions(atlas_path)
+    skel = json.load(open(json_path, encoding="utf-8"))
+    bones = {b["name"]: b for b in skel.get("bones", [])}
+    packed = []
+    for slot in skel.get("slots", []):
+        name = slot.get("attachment") or slot.get("name")
+        r = regions.get(name)
+        b = bones.get(slot.get("bone"), {})
+        if not r:
+            continue
+        img = atlas.crop((r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"]))
+        packed.append({"id": name, "img": img, "x": b.get("x", 0), "y": b.get("y", 0)})
+    if not packed:
+        return ""
+    write_parts_preview(char_id, packed, out_dir)
+    return os.path.join(out_dir, f"{char_id}_preview.png")

+ 49 - 1
web/index.html

@@ -66,6 +66,7 @@
   .stage{height:200px;border-radius:10px;background:
         repeating-conic-gradient(#241a3d 0 25%, #2c2150 0 50%) 0/22px 22px;
         display:flex;align-items:flex-end;justify-content:center;overflow:hidden;position:relative}
+  .stage.clickable{cursor:pointer}
   .stage img{max-height:88%;max-width:88%;transform-origin:50% 100%;will-change:transform;
         filter:drop-shadow(0 6px 10px rgba(0,0,0,.35))}
   .stage canvas{position:absolute;inset:0;width:100%;height:100%}
@@ -81,8 +82,20 @@
         background:linear-gradient(135deg,var(--accent),var(--accent2));margin:auto;
         display:flex;align-items:center;justify-content:center;font-weight:800;color:#241a3d;font-size:22px}
   .empty{color:var(--muted);text-align:center;padding:50px;border:1px dashed var(--line);border-radius:14px}
+  .modal{position:fixed;inset:0;background:rgba(8,5,18,.78);display:none;align-items:center;
+        justify-content:center;padding:28px;z-index:20}
+  .modal.open{display:flex}
+  .modal-panel{width:min(980px,96vw);max-height:92vh;overflow:auto;background:var(--panel);
+        border:1px solid var(--line);border-radius:14px;padding:18px}
+  .modal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
+  .modal-head h3{margin:0;font-size:18px}
+  .parts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+  .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}
   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}}
 </style>
 </head>
 <body>
@@ -230,6 +243,25 @@
   <div id="view"></div>
 </div>
 
+<div class="modal" id="partsModal">
+  <div class="modal-panel">
+    <div class="modal-head">
+      <h3 id="partsTitle">拆件预览</h3>
+      <button class="ghost" id="partsClose">关闭</button>
+    </div>
+    <div class="parts-grid">
+      <div class="parts-box">
+        <div class="name">完整预览</div>
+        <img id="partsPreview" alt="完整预览">
+      </div>
+      <div class="parts-box">
+        <div class="name">拆件图 / Atlas</div>
+        <img id="partsAtlas" alt="拆件图">
+      </div>
+    </div>
+  </div>
+</div>
+
 <script>
 const $ = s => document.querySelector(s);
 let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", ASSET_VER=Date.now(), TAB="chars";
@@ -246,6 +278,13 @@ function missingTasks(){
 function markTasksRunning(items, running=true){
   items.forEach(t=>running?ACTIVE_TASKS.add(taskKey(t.kind,t.id)):ACTIVE_TASKS.delete(taskKey(t.kind,t.id)));
 }
+
+function openPartsModal(c, t){
+  $('#partsTitle').textContent = `${t.chineseName} · 拆件检查`;
+  $('#partsPreview').src = assetUrl(c.preview || c.png);
+  $('#partsAtlas').src = assetUrl(c.png);
+  $('#partsModal').classList.add('open');
+}
 function syncRunningWithLibrary(){
   for(const key of [...ACTIVE_TASKS]){
     const [kind,id] = key.split(':');
@@ -331,14 +370,17 @@ function renderChars(v){
     const running=ACTIVE_TASKS.has(taskKey('characters',t.id));
     const animMap=c.animations||{};
     const anims=Object.keys(animMap);
+    const displayImg = c.preview || c.png;
+    const isParts = c.type==='spine_parts' || t.assetType==='spine_parts';
     const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
     card.innerHTML=`
-      <div class="stage">${done?`<img src="${assetUrl(c.png)}" alt="${esc(t.id)}">`:`<div class="placeholder">${running?'生成中…':'待生成'}<br>${esc(t.chineseName)}</div>`}</div>
+      <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>
         动作: ${esc((done?anims:t.animations||[]).join(', ')||'idle')}</div>
+      ${done&&isParts?'<button class="ghost parts-btn">查看拆件图</button>':''}
       <div class="task-prompt">${esc(t.prompt||'')}</div>
       ${done?'':`<button class="ghost retry-btn" data-kind="characters" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':'补生成 / 重试'}</button>`}`;
     if(done&&anims.length){
@@ -347,6 +389,10 @@ function renderChars(v){
       animTargets.push(tgt);
       selA.onchange=()=>{ tgt.anim=animMap[selA.value]; tgt.start=performance.now(); };
     }
+    if(done&&isParts){
+      card.querySelector('.stage').onclick=()=>openPartsModal(c,t);
+      card.querySelector('.parts-btn').onclick=()=>openPartsModal(c,t);
+    }
     grid.appendChild(card);
   });
   v.appendChild(grid);
@@ -523,6 +569,8 @@ $('#view').addEventListener('click', async e=>{
     $('#retryMissingBtn').disabled=false;
   }
 });
+$('#partsClose').onclick=()=>$('#partsModal').classList.remove('open');
+$('#partsModal').onclick=e=>{ if(e.target.id==='partsModal') $('#partsModal').classList.remove('open'); };
 $('#gameSel').onchange=e=>loadLibrary(e.target.value);
 function currentManifestGame(){
   try{ return JSON.parse($('#manifest').value||'{}').game || ''; }catch(e){ return ''; }