Bläddra i källkod

Show retry progress and batch missing assets

bang 2 veckor sedan
förälder
incheckning
7470909617
3 ändrade filer med 156 tillägg och 29 borttagningar
  1. 9 5
      pipeline.py
  2. 62 1
      server.py
  3. 85 23
      web/index.html

+ 9 - 5
pipeline.py

@@ -178,11 +178,10 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
             progress(f"{cid}")
 
     boss_id = required_boss_id()
-    if boss_id and boss_required_in_this_run and not any(c.get("id") == boss_id for c in library["characters"]):
-        detail = ";".join(required_failures) if required_failures else "生成结果中没有关主资源"
-        raise RuntimeError(
-            f"关主大魔王资源缺失:{boss_id}。已开启关主玩法,必须生成 boss 拆件和动作后才能继续。原因:{detail}"
-        )
+    boss_missing_after_chars = (
+        boss_id and boss_required_in_this_run
+        and not any(c.get("id") == boss_id for c in library["characters"])
+    )
 
     # ---- A2. UI 美术(背景 / Logo / 卷轴框 / 按钮 等整图)----
     ui_art_out = os.path.join(base_out, "ui_art")
@@ -244,4 +243,9 @@ def run(manifest, out_root, creds=None, log=print, merge_existing=False):
     with open(os.path.join(base_out, "library.json"), "w", encoding="utf-8") as f:
         json.dump(library, f, ensure_ascii=False, indent=2)
     log("—— 完成 ——")
+    if boss_missing_after_chars:
+        detail = ";".join(required_failures) if required_failures else "生成结果中没有关主资源"
+        raise RuntimeError(
+            f"关主大魔王资源缺失:{boss_id}。已保存其他成功资源;请在任务卡片里继续补生成 boss。原因:{detail}"
+        )
     return library, base_out

+ 62 - 1
server.py

@@ -215,12 +215,20 @@ def _build_tasks(lib):
 
 def _filter_manifest_tasks(manifest, kind, task_ids):
     wanted = set(task_ids)
-    filtered = copy_manifest = json.loads(json.dumps(manifest, ensure_ascii=False))
+    filtered = json.loads(json.dumps(manifest, ensure_ascii=False))
     for key in ("characters", "ui_art", "vfx", "ui"):
         filtered[key] = [x for x in manifest.get(key, []) if key == kind and x.get("id") in wanted]
     return filtered
 
 
+def _filter_manifest_groups(manifest, groups):
+    filtered = json.loads(json.dumps(manifest, ensure_ascii=False))
+    for key in ("characters", "ui_art", "vfx", "ui"):
+        wanted = set(groups.get(key, []))
+        filtered[key] = [x for x in manifest.get(key, []) if x.get("id") in wanted]
+    return filtered
+
+
 def _run_retry_job(job_id, game, kind, task_ids, creds):
     _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
     try:
@@ -239,6 +247,27 @@ def _run_retry_job(job_id, game, kind, task_ids, creds):
         _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
 
 
+def _run_retry_missing_job(job_id, game, groups, 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)
+        partial = _filter_manifest_groups(manifest, groups)
+        labels = []
+        for kind, ids in groups.items():
+            if ids:
+                labels.append(f"{kind}: {', '.join(ids)}")
+        _append_job_log(job_id, "批量补生成缺失项:" + ";".join(labels))
+        new_lib, _ = pipeline.run(partial, OUT_ROOT, creds=creds,
+                                  log=lambda m: _append_job_log(job_id, m),
+                                  merge_existing=True)
+        _set_job(job_id, status="done", ok=True, game=new_lib["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()]
@@ -555,6 +584,8 @@ class Handler(BaseHTTPRequestHandler):
             return self._post_open_folder()
         if route == "/api/retry-task":
             return self._post_retry_task()
+        if route == "/api/retry-missing":
+            return self._post_retry_missing()
         if route == "/api/delete":
             return self._post_delete()
         if route == "/api/slot-workflow":
@@ -699,6 +730,36 @@ class Handler(BaseHTTPRequestHandler):
         threading.Thread(target=_run_retry_job, args=(job_id, game, kind, task_ids, 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()
+        except Exception as e:
+            return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
+        game = (data.get("game") or "").strip()
+        if not game or game not in list_games():
+            return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
+
+        lib = _load_library(game)
+        tasks = _build_tasks(lib)
+        groups = {k: [t["id"] for t in rows if t.get("status") != "done"]
+                  for k, rows in tasks.items()}
+        groups = {k: v for k, v in groups.items() if v}
+        if not groups:
+            return self._send(400, {"ok": False, "error": "当前资源库没有缺失任务"})
+        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_missing_job, args=(job_id, game, groups, creds), daemon=True).start()
+        return self._send(200, {"ok": True, "jobId": job_id, "game": game, "groups": groups})
+
     def _post_delete(self):
         try:
             data = self._read_json_body()

+ 85 - 23
web/index.html

@@ -53,10 +53,12 @@
   .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;
         display:flex;flex-direction:column;gap:10px}
   .card.missing{border-style:dashed;background:#241a3d}
+  .card.running{border-color:var(--accent2);box-shadow:0 0 0 1px rgba(126,232,255,.35),0 0 18px rgba(126,232,255,.18)}
   .task-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
   .task-status{border-radius:20px;padding:2px 9px;font-size:11px;white-space:nowrap;border:1px solid var(--line)}
   .task-status.done{color:#96ffce;background:#10291f}
   .task-status.missing{color:#ffd0df;background:#3d1830}
+  .task-status.running{color:#7ee8ff;background:#112d3a}
   .task-prompt{max-height:94px;overflow:auto;background:#160f29;border:1px solid var(--line);
         border-radius:8px;padding:8px;color:#cfc4ec;font-size:11px;line-height:1.45;white-space:pre-wrap}
   .placeholder{width:82%;height:82%;border:1px dashed #6b5c92;border-radius:12px;display:flex;
@@ -211,6 +213,7 @@
     <span class="meta">资源库(game):</span>
     <select id="gameSel"></select>
     <button class="ghost" id="reloadBtn">↻ 刷新</button>
+    <button id="retryMissingBtn">批量补生成缺失项</button>
     <button class="ghost" id="openFolderBtn">📂 打开素材目录</button>
     <button id="exportBtn">📦 导出 Cocos 整合包</button>
     <button class="ghost" id="deleteBtn">🗑 删除该资源库</button>
@@ -231,8 +234,17 @@
 const $ = s => document.querySelector(s);
 let LIB = {characters:[],vfx:[],ui:[]}, ASSET="", TAB="chars";
 const animTargets = []; // {el, anim, start}
+const ACTIVE_TASKS = new Set();
 const esc = s => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
 const tasksFor = kind => ((LIB.tasks&&LIB.tasks[kind]) || []);
+const taskKey = (kind,id) => `${kind}:${id}`;
+function missingTasks(){
+  return ['characters','ui_art','vfx','ui'].flatMap(kind => tasksFor(kind)
+    .filter(t=>t.status!=='done').map(t=>({kind,id:t.id})));
+}
+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)));
+}
 
 // ---------- 拉取默认 manifest & 资源库 ----------
 async function loadManifest(){
@@ -253,6 +265,7 @@ async function loadLibrary(game){
   if(lib.taskSummary && !pending){
     opMsg(`任务 ${lib.taskSummary.done}/${lib.taskSummary.total} 已完成,缺失 ${lib.taskSummary.missing} 个。`, lib.taskSummary.missing===0);
   }
+  $('#retryMissingBtn').disabled = !lib.taskSummary || !lib.taskSummary.missing || ACTIVE_TASKS.size>0;
   const boss = lib.slot_config && lib.slot_config.boss;
   if(boss && boss.enabled){
     const bossId = boss.id || 'boss_demon_lord';
@@ -304,18 +317,19 @@ function renderChars(v){
   list.forEach(t=>{
     const c=t.asset||{};
     const done=t.status==='done' && c.png;
+    const running=ACTIVE_TASKS.has(taskKey('characters',t.id));
     const animMap=c.animations||{};
     const anims=Object.keys(animMap);
-    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
     card.innerHTML=`
-      <div class="stage">${done?`<img src="${ASSET+c.png}" alt="${esc(t.id)}">`:`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="stage">${done?`<img src="${ASSET+c.png}" 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':'missing'}">${done?'已生成':'缺失'}</span></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>
       <div class="task-prompt">${esc(t.prompt||'')}</div>
-      ${done?'':`<button class="ghost retry-btn" data-kind="characters" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
+      ${done?'':`<button class="ghost retry-btn" data-kind="characters" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':'补生成 / 重试'}</button>`}`;
     if(done&&anims.length){
       const img=card.querySelector('img'), selA=card.querySelector('select');
       const tgt={el:img, anim:animMap[anims[0]], start:performance.now()};
@@ -334,15 +348,16 @@ function renderArt(v){
   list.forEach(t=>{
     const a=t.asset||{};
     const done=t.status==='done' && a.file;
-    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
+    const running=ACTIVE_TASKS.has(taskKey('ui_art',t.id));
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
     card.innerHTML=`
-      <div class="stage">${done?`<img class="art-img" src="${ASSET+a.file}" alt="${esc(t.id)}">`:`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="stage">${done?`<img class="art-img" src="${ASSET+a.file}" 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':'missing'}">${done?'已生成':'缺失'}</span></div>
+        <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
       <div class="meta">${done?`${a.w}×${a.h}px · ${a.transparent?'透明素材':'整图背景'}`:`${esc(t.size||'')} · ${t.transparent?'透明素材':'整图背景'}`}<br>
         <span class="pill">ui_art</span> ${esc(t.use)}</div>
       <div class="task-prompt">${esc(t.prompt||'')}</div>
-      ${done?'':`<button class="ghost retry-btn" data-kind="ui_art" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
+      ${done?'':`<button class="ghost retry-btn" data-kind="ui_art" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':'补生成 / 重试'}</button>`}`;
     grid.appendChild(card);
   });
   v.appendChild(grid);
@@ -355,14 +370,15 @@ function renderVfx(v){
   list.forEach(t=>{
     const x=t.asset||{};
     const done=t.status==='done' && x.config;
-    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
+    const running=ACTIVE_TASKS.has(taskKey('vfx',t.id));
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
     card.innerHTML=`
-      <div class="stage">${done?'<canvas></canvas>':`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+      <div class="stage">${done?'<canvas></canvas>':`<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':'missing'}">${done?'已生成':'缺失'}</span></div>
+        <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
       <div class="meta"><span class="pill">particle</span> ${done?`模板: ${esc(x.template)} · 发射率 ${x.config.emissionRate}/s · 寿命 ${x.config.life}s`:esc(t.use)}</div>
       <div class="task-prompt">${esc(t.prompt||'')}</div>
-      ${done?'':`<button class="ghost retry-btn" data-kind="vfx" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
+      ${done?'':`<button class="ghost retry-btn" data-kind="vfx" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':'补生成 / 重试'}</button>`}`;
     grid.appendChild(card);
     if(done) startParticle(card.querySelector('canvas'), x.config);
   });
@@ -440,13 +456,14 @@ function renderUi(v){
   list.forEach(t=>{
     const u=t.asset||{};
     const done=t.status==='done';
+    const running=ACTIVE_TASKS.has(taskKey('ui',t.id));
     const preset=done?u.preset:t.prompt;
-    const card=document.createElement('div'); card.className='card '+(done?'':'missing');
-    card.innerHTML=`<div class="stage">${done?`<div class="demo-box">${preset==='number_roll'?'0':'UI'}</div>`:`<div class="placeholder">待生成<br>${esc(t.chineseName)}</div>`}</div>
+    const card=document.createElement('div'); card.className='card '+(done?'':'missing')+(running?' running':'');
+    card.innerHTML=`<div class="stage">${done?`<div class="demo-box">${preset==='number_roll'?'0':'UI'}</div>`:`<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':'missing'}">${done?'已生成':'缺失'}</span></div>
+        <span class="task-status ${done?'done':(running?'running':'missing')}">${done?'已生成':(running?'生成中':'缺失')}</span></div>
       <div class="meta"><span class="pill">tween</span> 预设: ${esc(preset)}<br>${esc(t.use)}</div>
-      ${done?'<button class="ghost">▶ 播放</button>':`<button class="ghost retry-btn" data-kind="ui" data-id="${esc(t.id)}">补生成 / 重试</button>`}`;
+      ${done?'<button class="ghost">▶ 播放</button>':`<button class="ghost retry-btn" data-kind="ui" data-id="${esc(t.id)}" ${running?'disabled':''}>${running?'正在生成…':'补生成 / 重试'}</button>`}`;
     if(!done){ grid.appendChild(card); return; }
     const box=card.querySelector('.demo-box');
     card.querySelector('button').onclick=()=>{
@@ -469,7 +486,11 @@ $('#view').addEventListener('click', async e=>{
   if(!btn) return;
   const game=$('#gameSel').value;
   if(!game||game==='(暂无)'){ opMsg('没有可重试的资源库',false); return; }
-  btn.disabled=true;
+  const item={kind:btn.dataset.kind,id:btn.dataset.id};
+  markTasksRunning([item], true);
+  render();
+  $('#retryMissingBtn').disabled=true;
+  opMsg(`正在补生成 ${item.kind}/${item.id}…`);
   const log=$('#log'); log.style.display='block'; log.textContent=`补生成 ${btn.dataset.kind}/${btn.dataset.id} 任务创建中…`;
   try{
     const r=await fetch('/api/retry-task',{method:'POST',headers:{'Content-Type':'application/json'},
@@ -480,13 +501,15 @@ $('#view').addEventListener('click', async e=>{
     const d=await r.json();
     if(!d.ok || !d.jobId){
       log.textContent='❌ '+(d.error||'补生成失败');
-      btn.disabled=false;
+      markTasksRunning([item], false); render();
+      $('#retryMissingBtn').disabled=false;
       return;
     }
-    await pollJob(d.jobId, log, btn);
+    await pollJob(d.jobId, log, btn, [item]);
   }catch(err){
     log.textContent='请求失败: '+err;
-    btn.disabled=false;
+    markTasksRunning([item], false); render();
+    $('#retryMissingBtn').disabled=false;
   }
 });
 $('#gameSel').onchange=e=>loadLibrary(e.target.value);
@@ -661,6 +684,36 @@ $('#aiWorkflowBtn').onclick=async()=>{
 
 $('#openGenBtn').onclick=()=>{ $('#genPanel').open=true; $('#genPanel').scrollIntoView({behavior:'smooth',block:'start'}); };
 
+$('#retryMissingBtn').onclick=async()=>{
+  const game=$('#gameSel').value;
+  if(!game||game==='(暂无)'){ opMsg('没有可补生成的资源库',false); return; }
+  const items=missingTasks();
+  if(!items.length){ opMsg('当前没有缺失任务'); return; }
+  markTasksRunning(items, true);
+  render();
+  const btn=$('#retryMissingBtn'); btn.disabled=true;
+  opMsg(`正在批量补生成 ${items.length} 个缺失任务…`);
+  const log=$('#log'); log.style.display='block'; log.textContent=`批量补生成 ${items.length} 个缺失任务创建中…`;
+  try{
+    const r=await fetch('/api/retry-missing',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({
+        game, 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(items, false); render();
+      btn.disabled=false;
+      return;
+    }
+    await pollJob(d.jobId, log, btn, items);
+  }catch(e){
+    log.textContent='请求失败: '+e;
+    markTasksRunning(items, false); render();
+    btn.disabled=false;
+  }
+};
+
 $('#exportBtn').onclick=async()=>{
   const game=$('#gameSel').value;
   if(!game||game==='(暂无)'){ opMsg('没有可导出的资源库',false); return; }
@@ -722,7 +775,7 @@ $('#startBtn').onclick=async()=>{
   }catch(e){ log.textContent='请求失败: '+e; }
 };
 
-async function pollJob(jobId, log, btn){
+async function pollJob(jobId, log, btn, runningItems=[]){
   let lastText='';
   while(true){
     let d;
@@ -738,13 +791,22 @@ async function pollJob(jobId, log, btn){
     lastText=head+'\n'+lines.join('\n')+(d.error?('\n❌ '+d.error):'');
     log.textContent=lastText;
     log.scrollTop=log.scrollHeight;
+    if((d.logs||[]).length){
+      opMsg((d.status==='running'?'生成中:':'任务状态:') + d.logs[d.logs.length-1], d.status!=='error');
+    }
     if(d.status==='done'){
+      markTasksRunning(runningItems, false);
+      ACTIVE_TASKS.clear();
       await loadLibrary(d.game);
-      btn.disabled=false;
+      if(btn) btn.disabled=false;
       return;
     }
     if(d.status==='error'){
-      btn.disabled=false;
+      markTasksRunning(runningItems, false);
+      ACTIVE_TASKS.clear();
+      render();
+      if(btn) btn.disabled=false;
+      $('#retryMissingBtn').disabled=false;
       return;
     }
     await new Promise(r=>setTimeout(r,1200));