Pārlūkot izejas kodu

Show live boss part generation progress

bang 2 nedēļas atpakaļ
vecāks
revīzija
fbf6a7e174
2 mainītis faili ar 149 papildinājumiem un 1 dzēšanām
  1. 114 0
      server.py
  2. 35 1
      web/index.html

+ 114 - 0
server.py

@@ -531,6 +531,35 @@ def _part_consistency_prompt(row, preview_analysis):
     )
 
 
+def _save_boss_part_image(chars_out, boss_id, part_id, img):
+    parts_dir = os.path.join(chars_out, f"{boss_id}_parts")
+    os.makedirs(parts_dir, exist_ok=True)
+    clean = spine_builder.trim_to_content(img.convert("RGBA"), pad=2)
+    path = os.path.join(parts_dir, f"{part_id}.png")
+    clean.save(path)
+    return {
+        "id": part_id,
+        "file": f"characters/{boss_id}_parts/{part_id}.png",
+        "w": clean.width,
+        "h": clean.height,
+    }
+
+
+def _upsert_part_meta(row, part_meta, preview_version=""):
+    parts = row.setdefault("parts", [])
+    for idx, old in enumerate(parts):
+        if old.get("id") == part_meta["id"]:
+            parts[idx] = part_meta
+            break
+    else:
+        parts.append(part_meta)
+    files = row.setdefault("files", [])
+    if part_meta["file"] not in files:
+        files.append(part_meta["file"])
+    if preview_version:
+        row.setdefault("partVersions", {})[part_meta["id"]] = preview_version
+
+
 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:
@@ -551,6 +580,15 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
         consistency = _part_consistency_prompt(row, row.get("previewAnalysis", ""))
 
         _append_job_log(job_id, f"重生关主拆件:{boss_id}/{part_id}")
+        _set_job(job_id, progress={
+            "type": "boss_parts",
+            "bossId": boss_id,
+            "total": 1,
+            "done": 0,
+            "current": part_id,
+            "completed": [],
+            "failed": [],
+        })
         part_images = {}
         for part in parts:
             pid = part["id"]
@@ -590,6 +628,20 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
             if ok:
                 _append_job_log(job_id, f"✅ [{part_id}] 拆件质量通过:最大主体 {detail.get('largestShare', 0):.0%}")
                 part_images[part_id] = img
+                if row:
+                    part_meta = _save_boss_part_image(chars_out, boss_id, part_id, img)
+                    _upsert_part_meta(row, part_meta, preview_version)
+                    _save_library(game, lib)
+                    _set_job(job_id, progress={
+                        "type": "boss_parts",
+                        "bossId": boss_id,
+                        "total": 1,
+                        "done": 1,
+                        "current": "",
+                        "completed": [part_id],
+                        "lastCompleted": part_id,
+                        "failed": [],
+                    })
                 break
             last_reason = reason
             _append_job_log(job_id, f"⚠️ [{part_id}] 拆件质量失败:{reason}")
@@ -597,6 +649,15 @@ def _run_retry_boss_part_job(job_id, game, boss_id, part_id, creds):
                 "这不是干净的单个拆件。请只生成这一件,不要包含任何其他身体部位、碎片、武器边缘或相邻格内容。"
             )
         if part_id not in part_images:
+            _set_job(job_id, progress={
+                "type": "boss_parts",
+                "bossId": boss_id,
+                "total": 1,
+                "done": 0,
+                "current": "",
+                "completed": [],
+                "failed": [{"id": part_id, "reason": last_reason}],
+            })
             raise RuntimeError(f"拆件重生失败:{last_reason}")
 
         spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
@@ -709,8 +770,27 @@ def _run_retry_boss_parts_from_preview_job(job_id, game, boss_id, creds):
         consistency = _part_consistency_prompt(row, row.get("previewAnalysis", ""))
         part_images = {}
         _append_job_log(job_id, f"按当前主图重生全部拆件:{boss_id},共 {len(parts)} 个")
+        _set_job(job_id, progress={
+            "type": "boss_parts",
+            "bossId": boss_id,
+            "total": len(parts),
+            "done": 0,
+            "current": "",
+            "completed": [],
+            "failed": [],
+        })
         for idx, part in enumerate(parts, start=1):
             pid = part["id"]
+            _set_job(job_id, progress={
+                "type": "boss_parts",
+                "bossId": boss_id,
+                "total": len(parts),
+                "done": len(part_images),
+                "current": pid,
+                "completed": list(part_images.keys()),
+                "failed": [],
+            })
+            _append_job_log(job_id, f"🎨 [{idx}/{len(parts)}] 正在生成拆件:{pid}")
             base_prompt = ", ".join(x for x in [
                 part.get("prompt", ""),
                 style,
@@ -739,11 +819,35 @@ def _run_retry_boss_parts_from_preview_job(job_id, game, boss_id, creds):
                 if ok:
                     _append_job_log(job_id, f"✅ [{idx}/{len(parts)}] {pid} 通过:最大主体 {detail.get('largestShare', 0):.0%}")
                     part_images[pid] = img
+                    part_meta = _save_boss_part_image(chars_out, boss_id, pid, img)
+                    _upsert_part_meta(row, part_meta, preview_version)
+                    row["partsSourcePreviewVersion"] = ""
+                    row["partsStale"] = True
+                    _save_library(game, lib)
+                    _set_job(job_id, progress={
+                        "type": "boss_parts",
+                        "bossId": boss_id,
+                        "total": len(parts),
+                        "done": len(part_images),
+                        "current": "",
+                        "completed": list(part_images.keys()),
+                        "lastCompleted": pid,
+                        "failed": [],
+                    })
                     break
                 last_reason = reason
                 _append_job_log(job_id, f"⚠️ [{pid}] 拆件质量失败:{reason}")
                 correction = "请严格参考主图,只输出这一件拆件,不要完整角色,不要其他部件,不要相邻碎片。"
             if pid not in part_images:
+                _set_job(job_id, progress={
+                    "type": "boss_parts",
+                    "bossId": boss_id,
+                    "total": len(parts),
+                    "done": len(part_images),
+                    "current": "",
+                    "completed": list(part_images.keys()),
+                    "failed": [{"id": pid, "reason": last_reason}],
+                })
                 raise RuntimeError(f"{pid} 重生失败:{last_reason}")
 
         spine_builder.build_parts_character(boss_id, part_images, chars_out, boss.get("animations", ["idle"]), parts,
@@ -758,6 +862,16 @@ def _run_retry_boss_parts_from_preview_job(job_id, game, boss_id, creds):
         files.extend([p["file"] for p in part_files])
         row["files"] = files
         _save_library(game, lib)
+        _set_job(job_id, progress={
+            "type": "boss_parts",
+            "bossId": boss_id,
+            "total": len(parts),
+            "done": len(parts),
+            "current": "",
+            "completed": [p["id"] for p in part_files],
+            "lastCompleted": "",
+            "failed": [],
+        })
         _set_job(job_id, status="done", ok=True, game=game, updatedAt=time.time())
     except Exception as e:
         traceback.print_exc()

+ 35 - 1
web/index.html

@@ -105,6 +105,11 @@
         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}
   .part-card button{width:100%;padding:7px 8px;margin-top:7px;font-size:12px}
+  .part-state{display:inline-block;margin-top:5px;border-radius:999px;padding:2px 7px;font-size:11px;
+        background:#241a3d;color:var(--muted);border:1px solid var(--line)}
+  .part-state.running{color:#7ee8ff;background:#112d3a}
+  .part-state.done{color:#96ffce;background:#10291f}
+  .part-state.error{color:#ffd0df;background:#3d1830}
   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}}
@@ -284,6 +289,7 @@ let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", ASSET_VER=Date.now(), TAB="cha
 const animTargets = []; // {el, anim, start}
 const ACTIVE_TASKS = new Set();
 let CURRENT_PARTS = null;
+const PART_PROGRESS = {};
 const esc = s => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
 const tasksFor = kind => ((LIB.tasks&&LIB.tasks[kind]) || []);
 const assetUrl = path => ASSET + path + (path.includes('?')?'&':'?') + 'v=' + ASSET_VER;
@@ -302,12 +308,26 @@ function openPartsModal(c, t){
   $('#partsTitle').textContent = `${t.chineseName} · 拆件检查`;
   $('#partsAtlas').src = assetUrl(c.png);
   const parts = c.parts || [];
+  const progress = PART_PROGRESS[c.id] || {};
+  const completed = new Set(progress.completed || []);
+  const failed = new Map((progress.failed || []).map(x=>[x.id, x.reason || '失败']));
   $('#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><button class="ghost part-retry-btn" data-boss="${esc(c.id)}" data-part="${esc(p.id)}">重生该拆件</button></div>`).join('')
+    ? parts.map(p=>{
+        const state = progress.current===p.id ? 'running' : (failed.has(p.id) ? 'error' : (completed.has(p.id) ? 'done' : ''));
+        const label = state==='running' ? '生成中' : (state==='done' ? '已刷新' : (state==='error' ? failed.get(p.id) : ''));
+        return `<div class="part-card" data-part="${esc(p.id)}"><img src="${assetUrl(p.file)}" alt="${esc(p.id)}"><div class="meta">${esc(p.id)} · ${esc(p.w||'?')}×${esc(p.h||'?')}</div>${state?`<span class="part-state ${state}">${esc(label)}</span>`:''}<button class="ghost part-retry-btn" data-boss="${esc(c.id)}" data-part="${esc(p.id)}" ${state==='running'?'disabled':''}>${state==='running'?'生成中…':'重生该拆件'}</button></div>`;
+      }).join('')
     : '<div class="empty">缺少单独拆件文件。</div>';
   $('#partsModal').classList.add('open');
 }
+
+function refreshOpenPartsModal(){
+  if(!$('#partsModal').classList.contains('open') || !CURRENT_PARTS) return;
+  const bossId = CURRENT_PARTS.asset.id || CURRENT_PARTS.task.id;
+  const row = tasksFor('characters').find(t=>t.id===bossId);
+  if(row && row.asset) openPartsModal(row.asset, row);
+}
 function syncRunningWithLibrary(){
   for(const key of [...ACTIVE_TASKS]){
     const [kind,id] = key.split(':');
@@ -1010,16 +1030,28 @@ async function pollJob(jobId, log, btn, runningItems=[]){
     if((d.logs||[]).length){
       opMsg((d.status==='running'?'生成中:':'任务状态:') + d.logs[d.logs.length-1], d.status!=='error');
     }
+    if(d.progress && d.progress.type==='boss_parts' && d.progress.bossId){
+      PART_PROGRESS[d.progress.bossId] = d.progress;
+      const current = d.progress.current ? `当前:${d.progress.current}` : '';
+      const count = `${d.progress.done||0}/${d.progress.total||0}`;
+      if(current) opMsg(`拆件生成 ${count} · ${current}`);
+      await loadLibrary(d.game || $('#gameSel').value, {silent:true});
+      refreshOpenPartsModal();
+      lastRefresh = Date.now();
+    }
     const now=Date.now();
     if(d.status==='running' && d.game && now-lastRefresh>2500){
       lastRefresh=now;
       await loadLibrary(d.game, {silent:true});
+      refreshOpenPartsModal();
     }
     if(d.status==='done'){
       markTasksRunning(runningItems, false);
       ACTIVE_TASKS.clear();
       await loadLibrary(d.game);
+      refreshOpenPartsModal();
       if(btn) btn.disabled=false;
+      if(btn && btn.id==='partsRebuildAll') btn.textContent='按主图重生全部拆件';
       return;
     }
     if(d.status==='error'){
@@ -1028,7 +1060,9 @@ async function pollJob(jobId, log, btn, runningItems=[]){
       await loadLibrary(d.game || $('#gameSel').value);
       opMsg('任务结束但有错误,已自动刷新资源库;成功的素材已经显示。', false);
       if(btn) btn.disabled=false;
+      if(btn && btn.id==='partsRebuildAll') btn.textContent='按主图重生全部拆件';
       $('#retryMissingBtn').disabled=false;
+      refreshOpenPartsModal();
       return;
     }
     await new Promise(r=>setTimeout(r,1200));