rvlib-shipkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +23 -8
  3. package/package.json +29 -13
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +161 -0
  11. package/src/assets/kits/identity-kit.ts +80 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +122 -0
  16. package/src/assets/server/studio-page.ts +587 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +360 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Tui.ts +36 -0
  27. package/src/cli/cmd_Wiki.ts +53 -0
  28. package/src/cli.ts +42 -25
  29. package/src/contracts/run-contracts.ts +33 -0
  30. package/src/core/check-ctx.ts +3 -16
  31. package/src/core/check-suite.ts +16 -2
  32. package/src/core/discover-checks.ts +48 -0
  33. package/src/core/run-check.ts +73 -0
  34. package/src/core/run-ci.ts +54 -0
  35. package/src/core/spawn-timeout.ts +65 -0
  36. package/src/core/task-runner.ts +36 -43
  37. package/src/core/wire.ts +20 -0
  38. package/src/core/write-status.ts +70 -0
  39. package/src/init-repo.ts +60 -0
  40. package/src/repo-config.ts +68 -16
  41. package/src/shipkit.ts +34 -3
  42. package/src/tui/checks-panel.ts +39 -0
  43. package/src/tui/tasks-panel.ts +54 -0
  44. package/src/tui/tui-app.tsx +91 -0
  45. package/src/tui/tui-panel.ts +57 -0
  46. package/src/tui/tui-store.ts +98 -0
  47. package/src/wiki/run-wiki.ts +50 -0
  48. package/src/cli/shipkit-asks.ts +0 -26
  49. package/src/cli/shipkit-check.ts +0 -26
  50. package/src/cli/shipkit-ci.ts +0 -30
  51. package/src/cli/shipkit-contracts.ts +0 -26
  52. package/src/cli/shipkit-init.ts +0 -40
  53. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,587 @@
1
+ // The studio's single-page UI (served by studio-server.ts). No build step,
2
+ // vanilla JS on purpose — proven twice; the whole page is one template
3
+ // string. Kind/asset structure is read-only here (kit.ts is code-reviewed);
4
+ // the page edits WORKSPACE state: picks, alts, prompt overlays, meta
5
+ // sidecars, geometry rects. Repo-defined custom panels (kit customPanel)
6
+ // mount as extra drawer tabs: dynamic import of /panel/<kind>/<id>.js,
7
+ // mount(el, api) with the meta-sidecar transport. Look & feel changes are
8
+ // playtested by a human, never self-verified.
9
+ export const page = (): string => `<!doctype html><html><head><meta charset=utf-8>
10
+ <meta name=viewport content="width=device-width,initial-scale=1"><title>atelier studio</title>
11
+ <style>
12
+ :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
13
+ --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252;--blue:#6ca0e8;--chk1:#221d17;--chk2:#1a1611}
14
+ *{box-sizing:border-box}html,body{height:100%}
15
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif;
16
+ display:grid;grid-template-columns:270px 1fr;overflow:hidden}
17
+ aside{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;height:100vh}
18
+ .shead{position:sticky;top:0;background:var(--panel);padding:12px 14px;border-bottom:1px solid var(--line);
19
+ font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);z-index:2}
20
+ .gname{padding:8px 14px 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);
21
+ display:flex;justify-content:space-between;align-items:center}
22
+ .gname .cn{cursor:pointer}.gname .cn:hover{color:var(--gold)}
23
+ .fold{cursor:pointer;color:var(--dim);width:12px}.fold:hover{color:var(--ink)}
24
+ .catgen button{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:6px;
25
+ padding:1px 6px;font-size:11px;font-weight:700;margin-left:3px;cursor:pointer}
26
+ .catgen button:hover{color:var(--ink);border-color:var(--accent)}
27
+ .arow{display:flex;align-items:center;gap:8px;padding:3px 12px;cursor:pointer;border-left:3px solid transparent}
28
+ .arow:hover{background:var(--panel2)}
29
+ .arow.sel{background:rgba(232,140,40,.18);border-left-color:var(--accent)}
30
+ .arow .lb{flex:1;min-width:0}.arow .lb b{display:block;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
31
+ .arow .lb span{font-size:11px;color:var(--dim)}
32
+ .dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--line)}
33
+ .dot.done{background:var(--gold)}.dot.some{background:var(--accent)}
34
+ main{display:flex;flex-direction:column;height:100vh;overflow:hidden}
35
+ .bar{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
36
+ .promptwrap{flex:1}
37
+ textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border:1px solid var(--line);
38
+ border-radius:9px;padding:9px 11px;font:12px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
39
+ .ctl{display:flex;flex-direction:column;gap:7px;min-width:150px}
40
+ button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 13px;font-weight:650;cursor:pointer}
41
+ button.ghost{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
42
+ button:disabled{opacity:.45;cursor:default}
43
+ select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px}
44
+ .status{font-size:12px;color:var(--dim);min-height:16px}
45
+ .errs{background:#3a1717;border-bottom:1px solid #5a2222;color:#f0c0c0;font:12px/1.5 ui-monospace,Menlo,monospace;
46
+ padding:6px 16px;white-space:pre-wrap;display:none}
47
+ .stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:18px;overflow:hidden;
48
+ background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/34px 34px}
49
+ .stage img.roll{max-width:100%;max-height:100%;object-fit:contain;filter:drop-shadow(0 6px 20px #0008);z-index:5;position:relative}
50
+ .stage .ph{color:var(--dim)}
51
+ /* ── geometry overlay ── */
52
+ #ov{position:absolute;border:2px solid var(--gold);border-radius:50%;background:rgba(255,207,92,.14);cursor:move;z-index:10;display:none}
53
+ #ov .hdl{position:absolute;right:-7px;top:-7px;width:14px;height:14px;background:var(--gold);border-radius:3px;cursor:nesw-resize}
54
+ #ov .arrow{position:absolute;left:0;right:0;top:50%;border-top:1px dashed var(--gold);opacity:.8}
55
+ #ov .sz{position:absolute;left:50%;top:50%;transform:translate(-50%,-120%);font-size:11px;color:var(--gold);
56
+ background:#000a;padding:0 5px;border-radius:4px;white-space:nowrap}
57
+ .plumb{position:absolute;width:0;border-left:1px dashed rgba(255,207,92,.5);top:-2000px;bottom:-2000px;z-index:9;display:none}
58
+ .leg{position:absolute;z-index:10;border:1px solid;border-radius:3px}
59
+ .leg.far{border-color:var(--blue);z-index:3}
60
+ .leg.near{border-color:var(--red);z-index:7}
61
+ .leg .hip{position:absolute;top:-3px;left:50%;transform:translateX(-50%);width:6px;height:6px;border-radius:50%;background:currentColor}
62
+ .leg .foot{position:absolute;bottom:-4px;left:50%;transform:translateX(-50%);width:12px;height:7px;border-radius:50%;background:var(--gold)}
63
+ .leg .lh{position:absolute;right:-6px;top:-6px;width:11px;height:11px;background:currentColor;border-radius:3px;cursor:nesw-resize}
64
+ .leg .tag{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:9px;opacity:.85;white-space:nowrap}
65
+ .pill{position:absolute;border-radius:999px;z-index:3;opacity:.9}
66
+ .pill.near{z-index:7}
67
+ #rectbar{position:absolute;left:14px;bottom:14px;z-index:11;display:none;gap:8px;align-items:center;
68
+ background:#000b;border:1px solid var(--line);border-radius:10px;padding:7px 10px;font-size:12px}
69
+ #rectbar input[type=number]{width:64px;padding:4px}
70
+ #rectbar .ro{color:var(--dim);font:11px ui-monospace,Menlo,monospace}
71
+ /* ── category grid + lineup ── */
72
+ #catgrid{flex:1;min-height:0;overflow-y:auto;display:none;padding:14px}
73
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
74
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px}
75
+ .card .th{position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;
76
+ background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/14px 14px}
77
+ .card .th img{width:100%;height:100%;object-fit:contain}
78
+ .card .th .ovmini{position:absolute;border:1.5px solid var(--gold);border-radius:50%;background:rgba(255,207,92,.12)}
79
+ .card .nm{font-size:12px;margin:6px 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
80
+ .card .nm:hover{color:var(--gold)}
81
+ .card .row{display:flex;gap:6px;align-items:center;font-size:11px;color:var(--dim)}
82
+ .card .row input{width:58px;padding:3px}
83
+ #allbar{display:flex;gap:8px;align-items:center;margin:0 0 12px;font-size:12px;color:var(--dim)}
84
+ #mixsec{margin-top:16px}
85
+ #mixbox{position:relative;height:420px;overflow-x:auto;overflow-y:hidden;background:var(--panel);
86
+ border:1px solid var(--line);border-radius:10px}
87
+ #mixinner{position:relative;height:100%}
88
+ #mixground{position:absolute;left:0;right:0;bottom:0;height:44px;background:#1d2b1a;border-top:1px solid #2c4527}
89
+ .mixsprite{position:absolute;image-rendering:auto;pointer-events:none}
90
+ .mixlabel{position:absolute;font-size:10px;color:var(--dim);text-align:center;transform:translateX(-50%)}
91
+ .ruler{position:absolute;left:0;right:0;bottom:44px;top:0;pointer-events:none;
92
+ background:
93
+ repeating-linear-gradient(to top,rgba(255,255,255,.28) 0 1px,transparent 1px 240px),
94
+ repeating-linear-gradient(to top,rgba(255,255,255,.16) 0 1px,transparent 1px 120px),
95
+ repeating-linear-gradient(to top,rgba(255,255,255,.07) 0 1px,transparent 1px 24px)}
96
+ .rlabel{position:absolute;left:4px;font-size:9px;color:var(--dim)}
97
+ .rlabel.b{font-weight:700;color:var(--ink)}
98
+ .strip{height:150px;flex:none;border-top:1px solid var(--line);background:var(--panel);display:flex;gap:10px;padding:12px;overflow-x:auto}
99
+ .vig{position:relative;height:100%;aspect-ratio:1;flex:none;border:2px solid var(--line);border-radius:10px;overflow:hidden;cursor:pointer;
100
+ background:conic-gradient(var(--chk1) 90deg,var(--chk2) 0 180deg,var(--chk1) 0 270deg,var(--chk2) 0) 0/12px 12px}
101
+ .vig img{width:100%;height:100%;object-fit:contain}
102
+ .vig.active{border-color:var(--ink)}
103
+ .vig.picked{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold) inset}
104
+ .vig.alt{border-color:var(--teal)}
105
+ .vig .st{position:absolute;top:4px;right:6px;font-size:16px;filter:drop-shadow(0 1px 2px #000)}
106
+ .vig .btns{position:absolute;bottom:4px;left:4px;right:4px;display:flex;gap:4px;justify-content:center;opacity:0;transition:opacity .15s}
107
+ .vig:hover .btns{opacity:1}
108
+ .vig .btns button{padding:2px 7px;font-size:11px;border-radius:6px}
109
+ .vig .btns .b-alt{background:var(--teal);color:#06342c}
110
+ .vig .btns .b-del{background:var(--red);color:#fff}
111
+ .strip .ph{color:var(--dim);align-self:center;padding:0 12px}
112
+ .drawer{flex:none;border-top:1px solid var(--line);background:var(--panel)}
113
+ .tabs{display:flex;gap:2px;padding:6px 12px 0}
114
+ .tabs button{background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;
115
+ padding:5px 12px;font-size:12px}
116
+ .tabs button.on{color:var(--ink);background:var(--bg)}
117
+ .tabs .sp{flex:1}
118
+ #drawerbody{display:none;padding:10px 12px}
119
+ #drawerbody.on{display:block}
120
+ #jsonta{min-height:140px}
121
+ .jrow{display:flex;gap:8px;margin-top:6px;align-items:center}
122
+ .jmsg{font-size:12px;color:var(--dim)}
123
+ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px;font-size:11px}
124
+ .help{font-size:11px;color:var(--dim);padding:4px 16px;border-top:1px solid var(--line)}
125
+ </style></head><body>
126
+ <aside><div class=shead>Assets · <span id=count></span></div><div id=list></div></aside>
127
+ <main>
128
+ <div class=errs id=errs></div>
129
+ <div class=bar id=topbar>
130
+ <div class=ctl>
131
+ <select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
132
+ <button id=go disabled>Generate</button>
133
+ <button id=qclear class=ghost title="drop queued jobs">clear queue</button>
134
+ </div>
135
+ <div class=promptwrap>
136
+ <textarea id=prompt placeholder="select an asset ←"></textarea>
137
+ <div class=jrow><button id=saveprompt class=ghost disabled>save prompt → overlay</button>
138
+ <button id=copyprompt class=ghost>copy</button><span class=jmsg id=promptmsg></span></div>
139
+ </div>
140
+ <div class=ctl><div class=status id=status></div></div>
141
+ </div>
142
+ <div class=stage id=stage><div class=ph>Pick an asset on the left, then Generate.</div>
143
+ <div class=plumb id=plumbL></div><div class=plumb id=plumbR></div>
144
+ <div id=ov><div class=hdl></div><div class=arrow></div><div class=sz id=ovsz></div></div>
145
+ <div id=rectbar>
146
+ <label>size <input type=number id=rsize step=0.1 min=0.1></label>
147
+ <button id=rlegs class=ghost>legs</button>
148
+ <input type=color id=rcolor title="leg color">
149
+ <button id=rlegsx class=ghost title="remove legs">✕</button>
150
+ <span class=ro id=rreadout></span>
151
+ </div>
152
+ </div>
153
+ <div id=catgrid>
154
+ <div id=allbar><label>size for ALL <span id=allcat></span> <input type=number id=allsize step=0.1 min=0.1></label>
155
+ <button id=allapply class=ghost>apply</button><span class=jmsg id=allmsg></span></div>
156
+ <div class=cards id=cards></div>
157
+ <div id=mixsec><div id=mixbox><div id=mixinner><div class=ruler></div><div id=mixground></div></div></div></div>
158
+ </div>
159
+ <div class=strip id=strip><div class=ph>no rolls yet</div></div>
160
+ <div class=drawer>
161
+ <div class=tabs>
162
+ <button data-tab=asset>asset</button><button data-tab=meta>roll meta</button><span id=ctabs></span>
163
+ <span class=sp></span><button id=drawertoggle class=ghost>JSON ▾</button>
164
+ </div>
165
+ <div id=drawerbody>
166
+ <div id=jsonwrap>
167
+ <textarea id=jsonta spellcheck=false></textarea>
168
+ <div class=jrow><button id=jsonsave>save</button><span class=jmsg id=jsonmsg></span></div>
169
+ </div>
170
+ <div id=panelhost style="display:none"></div>
171
+ </div>
172
+ </div>
173
+ <div class=help><kbd>←</kbd><kbd>→</kbd> roll · <kbd>Enter</kbd> pick · <kbd>A</kbd> alt · <kbd>X</kbd> discard — picks ship via the kit's ship hook when present · category title = grid view</div>
174
+ </main>
175
+ <script>
176
+ const $=s=>document.querySelector(s)
177
+ let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set()
178
+ let customs=[],panelMod=null,panelKey=null // repo-defined editor panels (custom tabs)
179
+ let lastSideSig=null,lastRollSig=null,catMode=null,lastCatSig=null,catRows=[]
180
+ let rect=null,rectRatio=0.6,hasGeom=false,saveTimer=null
181
+ const TEX=1024,MIX_UNIT=24,MIX_GROUND=44
182
+ const V=()=>Date.now()>>13 // ~8s cache buckets: thumbs refresh without unique-URL spam
183
+ const j=async(u,o)=>{const r=await fetch(u,o);return r.json()}
184
+ const post=(u,b)=>j(u,{method:'POST',body:JSON.stringify(b)})
185
+ const imgUrl=(id,n)=>'/img?id='+encodeURIComponent(id)+'&name='+encodeURIComponent(n)+'&t='+V()
186
+ const thumbUrl=id=>'/api/thumb?id='+encodeURIComponent(id)+'&t='+V()
187
+ const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;')
188
+ const jsq=s=>String(s).replace(/\\\\/g,'\\\\\\\\').replace(/'/g,"\\\\'")
189
+ // ── hash deep-links: #<asset-id> or #cat-<Category> ──
190
+ function setHash(h){if(location.hash.slice(1)!==h)location.hash=h}
191
+ function restoreFromHash(){
192
+ const h=decodeURIComponent(location.hash.slice(1))
193
+ if(!h)return
194
+ if(h.startsWith('cat-')){const c=h.slice(4);if(assets.some(a=>a.cat===c)&&catMode!==c)showCat(c,false);return}
195
+ if(assets.some(a=>a.id===h)&&cur!==h)selectAsset(h,false)
196
+ }
197
+ window.addEventListener('hashchange',restoreFromHash)
198
+ function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
199
+ function renderSide(list){
200
+ assets=list;$('#count').textContent=list.length
201
+ const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')
202
+ if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
203
+ const cats=[...new Set(list.map(a=>a.cat))]
204
+ $('#list').innerHTML=cats.map(c=>{
205
+ const open=openCats.has(c)
206
+ const head='<div class=gname><span><span class=fold onclick="toggleFold(\\''+jsq(c)+'\\')">'+(open?'▾':'▸')+'</span> '+
207
+ '<span class=cn onclick="showCat(\\''+jsq(c)+'\\')" title="grid view">'+esc(c)+'</span>'+
208
+ '</span><span class=catgen>'+[1,2,3].map(n=>'<button onclick="genCat(\\''+jsq(c)+'\\','+n+')">+'+n+'</button>').join('')+'</span></div>'
209
+ if(!open)return head
210
+ return head+list.filter(a=>a.cat===c).map(a=>
211
+ '<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
212
+ '</b><span>'+a.rolls+' rolls</span></div><div class="dot '+(a.picked?'done':(a.rolls?'some':''))+'"></div></div>').join('')
213
+ }).join('')
214
+ }
215
+ // ── asset view ──
216
+ async function selectAsset(id,writeHash=true){
217
+ exitCat()
218
+ cur=id;active=null;lastSideSig=null;lastRollSig=null;rect=null
219
+ const a=assets.find(x=>x.id===id)
220
+ if(!a)return
221
+ openCats.add(a.cat);renderSide(assets)
222
+ if(writeHash)setHash(id)
223
+ const row=document.querySelector('.arow[data-id="'+CSS.escape(id)+'"]')
224
+ if(row)row.scrollIntoView({block:'center'})
225
+ const r=await j('/api/asset?id='+encodeURIComponent(id))
226
+ if(cur!==id)return // selection changed mid-fetch — a late response must not clobber it
227
+ hasGeom=!!(r.asset&&r.asset.geometry)&&!a.icon
228
+ customs=(r.asset&&r.asset.custom)||[]
229
+ if(tab.indexOf('c:')===0&&!customs.some(c=>'c:'+c.id===tab))tab='asset' // new asset may not carry the open panel
230
+ renderCustomTabs()
231
+ $('#prompt').value=r.asset?r.asset.prompt:a.prompt
232
+ $('#go').disabled=false;$('#saveprompt').disabled=false;$('#promptmsg').textContent=''
233
+ await renderRolls(true);loadTab()
234
+ }
235
+ function setStage(src){
236
+ const st=$('#stage');let img=st.querySelector('img.roll')
237
+ if(!img){st.querySelector('.ph')?.remove();img=document.createElement('img');img.className='roll';st.appendChild(img);img.onload=positionOverlay}
238
+ img.src=src
239
+ }
240
+ async function renderRolls(reset){
241
+ if(!cur)return
242
+ const r=await j('/api/asset?id='+encodeURIComponent(cur))
243
+ rolls=r.rolls
244
+ const sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')
245
+ if(!reset&&sig===lastRollSig)return; lastRollSig=sig
246
+ const strip=$('#strip')
247
+ if(!rolls.length){strip.innerHTML='<div class=ph>no rolls — hit Generate</div>';hideOverlay()
248
+ const st=$('#stage');st.querySelector('img.roll')?.remove()
249
+ if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
250
+ active=null;return}
251
+ if(reset||!rolls.some(x=>x.name===active))active=(rolls.find(x=>x.picked)||rolls[0]).name
252
+ setStage(imgUrl(cur,active))
253
+ if(hasGeom)await loadRect()
254
+ strip.innerHTML=rolls.map(x=>
255
+ '<div class="vig'+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
256
+ '<img src="'+imgUrl(cur,x.name)+'">'+(x.picked?'<span class=st>★</span>':'')+
257
+ '<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div></div>').join('')
258
+ strip.querySelectorAll('.vig').forEach(v=>{
259
+ const n=v.dataset.n
260
+ v.onmouseenter=()=>setStage(imgUrl(cur,n))
261
+ v.onmouseleave=()=>setStage(imgUrl(cur,active))
262
+ v.onclick=()=>{active=n;lastRollSig=null;renderRolls(false);if(tab!=='asset')loadTab()} // meta + custom panels are per-roll
263
+ v.querySelector('.b-pick').onclick=e=>{e.stopPropagation();pickRoll(n)}
264
+ v.querySelector('.b-alt').onclick=e=>{e.stopPropagation();post('/api/alt',{id:cur,name:n}).then(()=>renderRolls(false))}
265
+ v.querySelector('.b-del').onclick=e=>{e.stopPropagation();discard(n)}
266
+ })
267
+ }
268
+ async function pickRoll(n){
269
+ const res=await post('/api/pick',{id:cur,name:n})
270
+ $('#status').textContent=res.error?('ship FAILED: '+res.error):(res.shipped?('shipped → '+res.shipped):'picked (pick.json)')
271
+ active=n;lastRollSig=null;lastSideSig=null;await renderRolls(false)
272
+ }
273
+ async function discard(n){
274
+ await post('/api/discard',{id:cur,name:n})
275
+ if(active===n)active=null
276
+ lastRollSig=null;lastSideSig=null;await renderRolls(true)
277
+ }
278
+ async function genCat(c,n){await post('/api/generate-cat',{cat:c,count:n});heartbeat()}
279
+ $('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
280
+ $('#qclear').onclick=()=>post('/api/queue/clear')
281
+ $('#saveprompt').onclick=async()=>{
282
+ const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
283
+ $('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
284
+ setTimeout(()=>$('#promptmsg').textContent='',1500)
285
+ }
286
+ $('#copyprompt').onclick=()=>{navigator.clipboard.writeText($('#prompt').value);$('#promptmsg').textContent='copied'
287
+ setTimeout(()=>$('#promptmsg').textContent='',900)}
288
+ $('#prompt').addEventListener('input',e=>{e.target.style.height='auto';e.target.style.height=e.target.scrollHeight+'px'})
289
+ // ── geometry overlay ──
290
+ async function loadRect(){
291
+ if(!hasGeom||!cur||!active){hideOverlay();return}
292
+ const r=await j('/api/rect?id='+encodeURIComponent(cur)+'&name='+encodeURIComponent(active))
293
+ if(r.error){hideOverlay();return}
294
+ rect=r.rect
295
+ rectRatio=rect.w>0?rect.h/rect.w:0.6 // client re-locks with the server's ratio
296
+ positionOverlay();renderRectbar()
297
+ }
298
+ function imgBox(){
299
+ const img=$('#stage img.roll')
300
+ if(!img||!img.naturalWidth)return null
301
+ const b=img.getBoundingClientRect(),st=$('#stage').getBoundingClientRect()
302
+ const scale=Math.min(b.width/img.naturalWidth,b.height/img.naturalHeight)
303
+ const w=img.naturalWidth*scale,h=img.naturalHeight*scale
304
+ return{scale,x:b.left-st.left+(b.width-w)/2,y:b.top-st.top+(b.height-h)/2}
305
+ }
306
+ function hideOverlay(){$('#ov').style.display='none';$('#rectbar').style.display='none'
307
+ $('#plumbL').style.display='none';$('#plumbR').style.display='none'
308
+ document.querySelectorAll('.leg,.pill').forEach(e=>e.remove())}
309
+ function positionOverlay(){
310
+ if(!hasGeom||!rect){hideOverlay();return}
311
+ const bx=imgBox();if(!bx){hideOverlay();return}
312
+ const ov=$('#ov'),px=v=>v*bx.scale
313
+ ov.style.display='block'
314
+ ov.style.left=(bx.x+px(rect.x))+'px';ov.style.top=(bx.y+px(rect.y))+'px'
315
+ ov.style.width=px(rect.w)+'px';ov.style.height=px(rect.h)+'px'
316
+ $('#ovsz').textContent=(+rect.size.toFixed(2))+'m'
317
+ const pl=$('#plumbL'),pr=$('#plumbR')
318
+ pl.style.display=pr.style.display='block'
319
+ pl.style.left=(bx.x+px(rect.x))+'px';pr.style.left=(bx.x+px(rect.x+rect.w))+'px'
320
+ document.querySelectorAll('.leg,.pill').forEach(e=>e.remove())
321
+ const tags=['L·front','L·back','R·back','R·front']
322
+ ;(rect.legs||[]).forEach((l,i)=>{
323
+ const far=i<2,el=document.createElement('div')
324
+ el.className='leg '+(far?'far':'near');el.dataset.i=i
325
+ el.style.color=far?'var(--blue)':'var(--red)'
326
+ el.style.left=(bx.x+px(l.x))+'px';el.style.top=(bx.y+px(l.y))+'px'
327
+ el.style.width=px(l.w)+'px';el.style.height=px(l.h)+'px'
328
+ el.innerHTML='<div class=hip></div><div class=foot></div><div class=tag>'+tags[i]+'</div>'+(i===0?'<div class=lh></div>':'')
329
+ $('#stage').appendChild(el)
330
+ const pill=document.createElement('div')
331
+ pill.className='pill'+(far?'':' near')
332
+ pill.style.left=(bx.x+px(l.x))+'px';pill.style.top=(bx.y+px(l.y))+'px'
333
+ pill.style.width=px(l.w)+'px';pill.style.height=px(l.h)+'px'
334
+ pill.style.background=rect.legColor||'#c8a070'
335
+ $('#stage').appendChild(pill)
336
+ dragLeg(el,i)
337
+ })
338
+ }
339
+ function renderRectbar(){
340
+ if(!hasGeom||!rect){$('#rectbar').style.display='none';return}
341
+ $('#rectbar').style.display='flex'
342
+ $('#rsize').value=rect.size
343
+ $('#rcolor').value=/^#[0-9a-f]{6}$/i.test(rect.legColor||'')?rect.legColor:'#c8a070'
344
+ $('#rlegsx').style.display=rect.legs?'':'none'
345
+ const cx=Math.round(rect.x+rect.w/2),cy=Math.round(rect.y+rect.h/2)
346
+ $('#rreadout').textContent='cx'+cx+' cy'+cy+' w'+Math.round(rect.w)+'px'
347
+ }
348
+ function saveRect(){ // 250ms trailing debounce → POST; server re-cleans + measures art
349
+ clearTimeout(saveTimer)
350
+ saveTimer=setTimeout(async()=>{
351
+ if(!cur||!active||!rect)return
352
+ const r=await post('/api/rect',{id:cur,name:active,rect})
353
+ if(r.rect){rect=r.rect;positionOverlay();renderRectbar()}
354
+ else if(r.error)$('#status').textContent='rect FAIL: '+r.error
355
+ },250)
356
+ }
357
+ $('#rsize').oninput=()=>{if(!rect)return;rect.size=Math.max(.1,+$('#rsize').value||rect.size);positionOverlay();saveRect()}
358
+ $('#rcolor').oninput=()=>{if(!rect)return;rect.legColor=$('#rcolor').value;positionOverlay();saveRect()}
359
+ $('#rlegs').onclick=async()=>{ // non-destructive: server creates defaults only when absent
360
+ if(!cur||!active)return
361
+ const r=await post('/api/legs-default',{id:cur,name:active})
362
+ if(r.rect){rect=r.rect;positionOverlay();renderRectbar()}
363
+ }
364
+ $('#rlegsx').onclick=()=>{
365
+ if(!rect||!rect.legs||!confirm('remove all 4 legs?'))return
366
+ delete rect.legs;delete rect.legColor;positionOverlay();renderRectbar();saveRect()
367
+ }
368
+ // oval drag/resize (legs follow: move 1:1, resize scales around left-tip + center-y anchor)
369
+ ;(()=>{
370
+ const ov=$('#ov')
371
+ let mode=null,start=null
372
+ ov.addEventListener('pointerdown',e=>{
373
+ if(!rect)return
374
+ mode=e.target.classList.contains('hdl')?'resize':'move'
375
+ const legs=(rect.legs||[]).map(l=>({...l}))
376
+ start={px:e.clientX,py:e.clientY,x:rect.x,y:rect.y,w:rect.w,legs,cy:rect.y+rect.h/2}
377
+ ov.setPointerCapture(e.pointerId);e.preventDefault()
378
+ })
379
+ ov.addEventListener('pointermove',e=>{
380
+ if(!mode||!start||!rect)return
381
+ const bx=imgBox();if(!bx)return
382
+ const dx=(e.clientX-start.px)/bx.scale,dy=(e.clientY-start.py)/bx.scale
383
+ if(mode==='move'){
384
+ rect.x=Math.max(0,start.x+dx);rect.y=Math.max(0,start.y+dy)
385
+ rect.legs=(start.legs||[]).length?start.legs.map(l=>({...l,x:l.x+(rect.x-start.x),y:l.y+(rect.y-start.y)})):rect.legs
386
+ }else{
387
+ rect.w=Math.max(20,start.w+dx)
388
+ rect.h=rect.w*rectRatio
389
+ rect.y=start.cy-rect.h/2 // anchored: left edge + center-y
390
+ const f=rect.w/start.w
391
+ if((start.legs||[]).length)rect.legs=start.legs.map(l=>({x:start.x+(l.x-start.x)*f,y:start.cy+(l.y-start.cy)*f,w:l.w*f,h:l.h*f}))
392
+ }
393
+ positionOverlay();renderRectbar();saveRect()
394
+ })
395
+ ov.addEventListener('pointerup',()=>{mode=null;start=null;saveRect()})
396
+ })()
397
+ function dragLeg(el,i){
398
+ let mode=null,start=null
399
+ el.addEventListener('pointerdown',e=>{
400
+ if(!rect||!rect.legs)return
401
+ mode=e.target.classList.contains('lh')?'resize':'move'
402
+ start={px:e.clientX,py:e.clientY,legs:rect.legs.map(l=>({...l}))}
403
+ el.setPointerCapture(e.pointerId);e.preventDefault();e.stopPropagation()
404
+ })
405
+ el.addEventListener('pointermove',e=>{
406
+ if(!mode||!start||!rect||!rect.legs)return
407
+ const bx=imgBox();if(!bx)return
408
+ const dx=(e.clientX-start.px)/bx.scale,dy=(e.clientY-start.py)/bx.scale
409
+ if(mode==='move'){const o=start.legs[i];rect.legs[i]={...o,x:o.x+dx,y:o.y+dy}}
410
+ else{ // one size for all 4 — every leg keeps its own FOOT planted
411
+ const o=start.legs[i],w=Math.max(6,o.w+dx),h=Math.max(6,o.h-dy)
412
+ rect.legs=start.legs.map(l=>({x:l.x+(l.w-w)/2,y:l.y+l.h-h,w,h}))
413
+ }
414
+ positionOverlay();saveRect()
415
+ })
416
+ el.addEventListener('pointerup',()=>{mode=null;start=null;saveRect()})
417
+ }
418
+ window.addEventListener('resize',positionOverlay)
419
+ // ── category grid + size-true lineup ──
420
+ async function showCat(c,writeHash=true){
421
+ catMode=c;cur=null;lastSideSig=null;lastCatSig=null
422
+ if(writeHash)setHash('cat-'+c)
423
+ $('#topbar').style.display='none';$('#stage').style.display='none';$('#strip').style.display='none'
424
+ $('#catgrid').style.display='block';$('#allcat').textContent=c
425
+ openCats.add(c);renderSide(assets)
426
+ await fillCat(true)
427
+ }
428
+ function exitCat(){
429
+ if(catMode===null)return
430
+ catMode=null;lastCatSig=null
431
+ $('#topbar').style.display='';$('#stage').style.display='';$('#strip').style.display=''
432
+ $('#catgrid').style.display='none'
433
+ }
434
+ async function fillCat(force){
435
+ if(!catMode)return
436
+ const mode=catMode
437
+ const r=await j('/api/catrects?cat='+encodeURIComponent(mode))
438
+ if(catMode!==mode)return // user navigated away mid-fetch
439
+ catRows=r.rows||[]
440
+ const sig=JSON.stringify(catRows)
441
+ if(!force&&sig===lastCatSig)return; lastCatSig=sig
442
+ const scroll=$('#catgrid').scrollTop
443
+ const pc=v=>(v/(TEX/100))+'%'
444
+ $('#cards').innerHTML=catRows.map(row=>{
445
+ const fl=row.rect&&row.rect.flip
446
+ const ovm=row.rect?('<div class=ovmini style="left:'+pc(fl?TEX-row.rect.x-row.rect.w:row.rect.x)+';top:'+pc(row.rect.y)+
447
+ ';width:'+pc(row.rect.w)+';height:'+pc(row.rect.h)+'"></div>'):''
448
+ return '<div class=card><div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+'</div>'+
449
+ '<div class=nm onclick="selectAsset(\\''+jsq(row.id)+'\\')">'+esc(row.label)+'</div>'+
450
+ (row.icon?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
451
+ ' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
452
+ '<button class=ghost onclick="toggleFlip(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\')">⇆ flip</button></div>'))+'</div>'
453
+ }).join('')
454
+ $('#catgrid').scrollTop=scroll
455
+ renderMix()
456
+ }
457
+ async function setSize(id,name,v){
458
+ const row=catRows.find(r=>r.id===id)
459
+ if(!row||!row.rect)return
460
+ row.rect.size=Math.max(.1,+v||row.rect.size)
461
+ await post('/api/rect',{id,name,rect:row.rect})
462
+ renderMix() // lineup only — no grid refetch
463
+ }
464
+ async function toggleFlip(id,name){
465
+ await post('/api/flip',{id,name})
466
+ lastCatSig=null;await fillCat(true)
467
+ }
468
+ $('#allapply').onclick=async()=>{
469
+ const v=Math.max(.1,+$('#allsize').value||0)
470
+ if(!v)return
471
+ for(const row of catRows.filter(r=>!r.icon&&r.rect)){row.rect.size=v;await post('/api/rect',{id:row.id,name:row.name,rect:row.rect})}
472
+ $('#allmsg').textContent='applied';setTimeout(()=>$('#allmsg').textContent='',1200)
473
+ lastCatSig=null;await fillCat(true)
474
+ }
475
+ function renderMix(){
476
+ const inner=$('#mixinner')
477
+ inner.querySelectorAll('.mixsprite,.mixlabel,.rlabel').forEach(e=>e.remove())
478
+ const items=catRows.filter(r=>!r.icon&&r.rect).sort((a,b)=>(a.rect.size-b.rect.size)||a.label.localeCompare(b.label))
479
+ const boxH=420,baseY=boxH-MIX_GROUND
480
+ let cx=30
481
+ for(const it of items){
482
+ const ts=it.rect.size*MIX_UNIT/it.rect.w
483
+ const slot=Math.max(it.rect.size*MIX_UNIT*1.35,TEX*ts*.62,40)
484
+ cx+=slot/2
485
+ const img=document.createElement('img')
486
+ img.className='mixsprite';img.src=thumbUrl(it.id)
487
+ const fx=it.rect.flip?TEX-it.rect.x-it.rect.w:it.rect.x
488
+ img.style.width=img.style.height=(TEX*ts)+'px'
489
+ img.style.left=(cx-(fx+it.rect.w/2)*ts)+'px'
490
+ img.style.top=(baseY-(it.rect.y+it.rect.h/2)*ts)+'px'
491
+ if(it.rect.flip)img.style.transform='scaleX(-1)'
492
+ img.style.zIndex=Math.round(cx)
493
+ inner.appendChild(img)
494
+ const lb=document.createElement('div')
495
+ lb.className='mixlabel';lb.textContent=it.label+' · '+(+it.rect.size.toFixed(2))
496
+ lb.style.left=cx+'px';lb.style.bottom='14px'
497
+ inner.appendChild(lb)
498
+ cx+=slot/2+10
499
+ }
500
+ inner.style.width=Math.max(cx+30,($('#mixbox').clientWidth||600))+'px'
501
+ for(let u=5;u*MIX_UNIT<baseY;u+=5){
502
+ const l=document.createElement('div')
503
+ l.className='rlabel'+(u%10===0?' b':'');l.textContent=u
504
+ l.style.bottom=(MIX_GROUND+u*MIX_UNIT-5)+'px'
505
+ inner.appendChild(l)
506
+ }
507
+ }
508
+ // ── JSON drawer (asset = read-only kit view; meta = roll sidecar;
509
+ // c:<id> tabs = repo-defined custom panels, dynamic-imported modules) ──
510
+ $('#drawertoggle').onclick=()=>{$('#drawerbody').classList.toggle('on')}
511
+ document.querySelectorAll('.tabs [data-tab]').forEach(b=>b.onclick=()=>{tab=b.dataset.tab;loadTab();$('#drawerbody').classList.add('on')})
512
+ function renderCustomTabs(){
513
+ $('#ctabs').innerHTML=customs.map(c=>'<button data-tab="c:'+esc(c.id)+'">'+esc(c.id)+'</button>').join('')
514
+ $('#ctabs').querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{tab=b.dataset.tab;loadTab();$('#drawerbody').classList.add('on')})
515
+ document.querySelectorAll('.tabs [data-tab]').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab))
516
+ }
517
+ async function unmountPanel(){
518
+ if(panelMod&&typeof panelMod.unmount==='function'){try{await panelMod.unmount()}catch(e){$('#status').textContent='panel unmount FAILED: '+(e&&e.message||e)}}
519
+ panelMod=null;panelKey=null;$('#panelhost').innerHTML=''
520
+ }
521
+ function panelApi(c){ // frozen per mount: a roll/asset switch remounts with a fresh api
522
+ const id=cur,name=active
523
+ return{assetId:id,kindId:c.kind,roll:name,imgUrl:imgUrl(id,name),
524
+ getMeta:async()=>{const r=await post('/api/meta',{id,name});if(r.error)throw new Error(r.error);return r.text?JSON.parse(r.text):null},
525
+ saveMeta:async(data)=>{const r=await post('/api/meta',{id,name,text:JSON.stringify(data)});if(r.error)throw new Error(r.error)}}
526
+ }
527
+ async function mountPanel(c){
528
+ const key=cur+'|'+active+'|'+c.id
529
+ if(panelKey===key)return
530
+ await unmountPanel()
531
+ const host=$('#panelhost')
532
+ try{
533
+ const mod=await import(c.url)
534
+ if(typeof mod.mount!=='function')throw new Error(c.url+' must export mount(el, api)')
535
+ await mod.mount(host,panelApi(c))
536
+ panelMod=mod;panelKey=key
537
+ }catch(e){host.textContent='panel FAILED: '+(e&&e.message||e)} // loud in the drawer, never blank
538
+ }
539
+ async function loadTab(){
540
+ document.querySelectorAll('.tabs [data-tab]').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab))
541
+ const isCustom=tab.indexOf('c:')===0
542
+ $('#jsonwrap').style.display=isCustom?'none':''
543
+ $('#panelhost').style.display=isCustom?'':'none'
544
+ if(isCustom){
545
+ const c=customs.find(x=>'c:'+x.id===tab)
546
+ if(c&&cur&&active)await mountPanel(c)
547
+ else{await unmountPanel();$('#panelhost').textContent=c?'no roll selected — generate or pick one first':'panel not on this asset'}
548
+ return
549
+ }
550
+ await unmountPanel()
551
+ const ta=$('#jsonta');$('#jsonmsg').textContent=''
552
+ $('#jsonsave').disabled=(tab==='asset') // kind/asset structure is git-reviewed TS — read-only here
553
+ ta.readOnly=(tab==='asset')
554
+ if(tab==='asset'){const r=cur?await j('/api/asset?id='+encodeURIComponent(cur)):null;ta.value=r&&r.asset?JSON.stringify(r.asset,null,2):''}
555
+ else ta.value=cur&&active?(await post('/api/meta',{id:cur,name:active})).text:''
556
+ }
557
+ $('#jsonsave').onclick=async()=>{
558
+ if(tab!=='meta'||!cur||!active)return
559
+ const r=await post('/api/meta',{id:cur,name:active,text:$('#jsonta').value})
560
+ $('#jsonmsg').textContent=r.error?('FAIL: '+r.error):'saved'
561
+ }
562
+ // ── keyboard ──
563
+ document.addEventListener('keydown',e=>{
564
+ if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT'||!cur||!rolls.length)return
565
+ const i=rolls.findIndex(x=>x.name===active)
566
+ if(e.key==='ArrowRight'&&i<rolls.length-1){active=rolls[i+1].name;lastRollSig=null;renderRolls(false)}
567
+ else if(e.key==='ArrowLeft'&&i>0){active=rolls[i-1].name;lastRollSig=null;renderRolls(false)}
568
+ else if(e.key==='Enter'&&active)pickRoll(active)
569
+ else if((e.key==='a'||e.key==='A')&&active)post('/api/alt',{id:cur,name:active}).then(()=>renderRolls(false))
570
+ else if((e.key==='x'||e.key==='X')&&active)discard(active)
571
+ else return
572
+ e.preventDefault()
573
+ })
574
+ // ── heartbeat: status + change-signature refreshes (grid OR asset view) ──
575
+ let firstBeat=true
576
+ async function heartbeat(){
577
+ const s=await j('/api/state')
578
+ renderSide(s.assets)
579
+ $('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')):''
580
+ const e=$('#errs')
581
+ if(s.errors.length){e.style.display='block';e.textContent=s.errors.join('\\n')}else e.style.display='none'
582
+ if(catMode)await fillCat(false)
583
+ else if(cur&&!document.querySelector('.vig:hover'))await renderRolls(false)
584
+ if(firstBeat){firstBeat=false;restoreFromHash()}
585
+ }
586
+ heartbeat();setInterval(heartbeat,1500)
587
+ </script></body></html>`
@@ -0,0 +1,58 @@
1
+ // Generation queue: single worker, IN-MEMORY (never run the studio with
2
+ // --watch — a restart drops every queued roll; the fill CLI is the recovery
3
+ // path), errors surfaced in a ring buffer, never fatal.
4
+ export type Job = { assetId: string; prompt: string; count: number }
5
+ export type QueueState = { generating: boolean; current: string | null; done: number; total: number }
6
+
7
+ export class StudioQueue {
8
+ private jobs: Job[] = []
9
+ readonly state: QueueState = { generating: false, current: null, done: 0, total: 0 }
10
+ readonly errors: string[] = []
11
+
12
+ /** generateOne throws on failure — the queue logs and moves on */
13
+ constructor(private generateOne: (job: Job, seed: number) => Promise<void>, private newSeed: () => number) {}
14
+
15
+ pushError(msg: string): void {
16
+ this.errors.push(msg)
17
+ if (this.errors.length > 20) this.errors.shift()
18
+ console.log(`FAIL: ${msg}`)
19
+ }
20
+
21
+ get queuedCount(): number { return this.jobs.length }
22
+
23
+ clear(): void { this.jobs.length = 0 }
24
+
25
+ enqueue(newJobs: Job[]): void {
26
+ if (!this.state.generating) {
27
+ this.state.done = 0
28
+ this.state.total = 0
29
+ }
30
+ this.state.total += newJobs.reduce((s, j) => s + j.count, 0)
31
+ this.jobs.push(...newJobs)
32
+ if (!this.state.generating) {
33
+ this.state.generating = true
34
+ void this.workerLoop()
35
+ }
36
+ }
37
+
38
+ private async workerLoop(): Promise<void> {
39
+ while (true) {
40
+ const job = this.jobs.shift()
41
+ if (job == null) {
42
+ this.state.generating = false
43
+ this.state.current = null
44
+ return
45
+ }
46
+ this.state.current = job.assetId
47
+ for (let i = 0; i < job.count; i++) {
48
+ const seed = this.newSeed()
49
+ try {
50
+ await this.generateOne(job, seed)
51
+ } catch (e) {
52
+ this.pushError(`${job.assetId} seed ${seed}: ${(e as Error).message}`)
53
+ }
54
+ this.state.done++
55
+ }
56
+ }
57
+ }
58
+ }