rvlib-shipkit 0.4.0 → 0.5.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.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-07-22
|
|
4
|
+
|
|
5
|
+
New (wave 2 of the shipkit expansion — generation UX):
|
|
6
|
+
|
|
7
|
+
- live per-step progress: the comfy engine subscribes ComfyUI's native
|
|
8
|
+
`/ws?clientId=` (same clientId as the `/prompt` body) and streams
|
|
9
|
+
`executing {node}` + `progress {value,max}` into the studio; ADDITIVE by
|
|
10
|
+
contract — a dead socket degrades to no-progress, completion truth stays
|
|
11
|
+
the `/history` poll. (comfy-code evaluated and rejected: opposite graph
|
|
12
|
+
model, ~87 dl/mo solo dep; native /ws does the job in-doctrine.)
|
|
13
|
+
- studio spinners: every queued/generating asset shows a spinner placeholder
|
|
14
|
+
(sidebar row, category-grid card, roll strip) driven by the new
|
|
15
|
+
`pending: string[]` in `/api/state`; header gains a live progress bar
|
|
16
|
+
(`step v/max`).
|
|
17
|
+
- cancel: `POST /api/interrupt` (studio "stop current") — comfy `/interrupt`
|
|
18
|
+
on the box (only when the RUNNING job is a comfy kind — the box is shared)
|
|
19
|
+
+ the current job's remaining rolls dropped (logged `ok: cancelled …`,
|
|
20
|
+
never an error); queued jobs stay (`clear queue` still drops those). New
|
|
21
|
+
engine wrapper `interrupt()` exported.
|
|
22
|
+
|
|
3
23
|
## 0.4.0 — 2026-07-22
|
|
4
24
|
|
|
5
25
|
New (wave 1 of the shipkit expansion — uniform repo setup):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,16 +34,56 @@ export function applyInputs(graph: Graph, sk: Sidekick, args: { prompt: string;
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export async function submit(o: ComfyOpts, graph: Graph): Promise<string> {
|
|
37
|
+
export async function submit(o: ComfyOpts, graph: Graph, clientId?: string): Promise<string> {
|
|
38
38
|
const res = await fetch(`${o.host}/prompt`, {
|
|
39
39
|
method: 'POST',
|
|
40
40
|
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
body: JSON.stringify({ prompt: graph, client_id: crypto.randomUUID() }),
|
|
41
|
+
body: JSON.stringify({ prompt: graph, client_id: clientId ?? crypto.randomUUID() }),
|
|
42
42
|
})
|
|
43
43
|
if (!res.ok) throw new Error(`POST /prompt → ${res.status}: ${(await res.text()).slice(0, 300)} — bad graph or server mid-boot${healHint(o)}`)
|
|
44
44
|
return ((await res.json()) as { prompt_id: string }).prompt_id
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ── live progress over ComfyUI's native /ws ────────────────────────────────
|
|
48
|
+
// ADDITIVE by contract: a dead socket degrades to no-progress, completion
|
|
49
|
+
// truth stays the /history poll. The clientId MUST match the /prompt body
|
|
50
|
+
// or the server sends nothing.
|
|
51
|
+
export type GenProgress = { value: number; max: number; node: string | null }
|
|
52
|
+
export type OnProgress = (p: GenProgress) => void
|
|
53
|
+
|
|
54
|
+
const wsWarned = new Set<string>() // one WARN per host per process, not per roll
|
|
55
|
+
export function subscribeProgress(o: ComfyOpts, clientId: string, on: OnProgress): { close: () => void } {
|
|
56
|
+
const url = `${o.host.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
|
|
57
|
+
const warnOnce = (detail: string): void => {
|
|
58
|
+
if (wsWarned.has(o.host)) return
|
|
59
|
+
wsWarned.add(o.host)
|
|
60
|
+
console.log(`WARN comfy /ws unavailable — per-step progress disabled${detail}`)
|
|
61
|
+
}
|
|
62
|
+
let node: string | null = null
|
|
63
|
+
let ws: WebSocket | null = null
|
|
64
|
+
try {
|
|
65
|
+
ws = new WebSocket(url)
|
|
66
|
+
ws.onmessage = (ev) => {
|
|
67
|
+
if (typeof ev.data !== 'string') return // binary preview frames — not consumed
|
|
68
|
+
let msg: { type?: string; data?: { value?: number; max?: number; node?: string | null } }
|
|
69
|
+
try { msg = JSON.parse(ev.data) as typeof msg } catch { return }
|
|
70
|
+
if (msg.type === 'executing') { node = msg.data?.node ?? null; on({ value: 0, max: 0, node }) }
|
|
71
|
+
else if (msg.type === 'progress') on({ value: msg.data?.value ?? 0, max: msg.data?.max ?? 0, node })
|
|
72
|
+
}
|
|
73
|
+
// progress is a bonus — a refused/dropped ws warns once and stays quiet
|
|
74
|
+
ws.onerror = () => warnOnce('')
|
|
75
|
+
} catch (e) {
|
|
76
|
+
warnOnce(` (${(e as Error).message})`)
|
|
77
|
+
}
|
|
78
|
+
return { close: () => { try { ws?.close() } catch {} } }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cancel the RUNNING prompt (queued ones are untouched). */
|
|
82
|
+
export async function interrupt(o: ComfyOpts): Promise<void> {
|
|
83
|
+
const res = await fetch(`${o.host}/interrupt`, { method: 'POST' })
|
|
84
|
+
if (!res.ok) throw new Error(`POST /interrupt → ${res.status}${healHint(o)}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
47
87
|
type HistoryOutputs = Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }>
|
|
48
88
|
|
|
49
89
|
export async function waitFor(o: ComfyOpts, promptId: string): Promise<HistoryOutputs> {
|
|
@@ -76,11 +116,22 @@ async function fetchImage(o: ComfyOpts, img: { filename: string; subfolder?: str
|
|
|
76
116
|
* bindings applied, submitted, waited, non-preview outputs saved as
|
|
77
117
|
* <destDir>/<name>.<ext> (ONE clean file per asset — no _00001_ counter noise).
|
|
78
118
|
*/
|
|
79
|
-
export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string }): Promise<string[]> {
|
|
119
|
+
export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string; onProgress?: OnProgress }): Promise<string[]> {
|
|
80
120
|
const graph = structuredClone(wf.graph)
|
|
81
121
|
applyInputs(graph, wf.sidekick, { prompt: args.prompt, seed: args.seed, prefix: args.name })
|
|
82
122
|
mkdirSync(args.destDir, { recursive: true })
|
|
83
|
-
|
|
123
|
+
let outputs: HistoryOutputs
|
|
124
|
+
if (args.onProgress != null) {
|
|
125
|
+
const clientId = crypto.randomUUID()
|
|
126
|
+
const sub = subscribeProgress(o, clientId, args.onProgress)
|
|
127
|
+
try {
|
|
128
|
+
outputs = await waitFor(o, await submit(o, graph, clientId))
|
|
129
|
+
} finally {
|
|
130
|
+
sub.close()
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
outputs = await waitFor(o, await submit(o, graph))
|
|
134
|
+
}
|
|
84
135
|
const saved: string[] = []
|
|
85
136
|
for (const node of Object.values(outputs)) {
|
|
86
137
|
for (const img of node.images ?? []) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { mkdirSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import type { FoundAsset } from '../kit-config.ts'
|
|
10
|
-
import { generate } from './comfy-gen.ts'
|
|
10
|
+
import { generate, type OnProgress } from './comfy-gen.ts'
|
|
11
11
|
import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
|
|
12
12
|
|
|
13
13
|
/** roll canvas master size (square) — favicon/logo/sprites derive from it */
|
|
@@ -20,7 +20,7 @@ export type ComfyResolver = () => Promise<ComfyRuntime>
|
|
|
20
20
|
* v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
|
|
21
21
|
export async function generateRoll(
|
|
22
22
|
found: FoundAsset,
|
|
23
|
-
args: { prompt: string; seed: number; destDir: string },
|
|
23
|
+
args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress },
|
|
24
24
|
resolveComfy: ComfyResolver,
|
|
25
25
|
): Promise<void> {
|
|
26
26
|
const gen = found.kind.generate
|
|
@@ -34,7 +34,7 @@ export async function generateRoll(
|
|
|
34
34
|
}
|
|
35
35
|
const rt = await resolveComfy()
|
|
36
36
|
const wf = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
|
|
37
|
-
await generate({ host: rt.host, heal: rt.heal }, wf, { prompt: args.prompt, seed: args.seed, destDir: args.destDir, name: `v${args.seed}
|
|
37
|
+
await generate({ host: rt.host, heal: rt.heal }, wf, { prompt: args.prompt, seed: args.seed, destDir: args.destDir, name: `v${args.seed}`, onProgress: args.onProgress })
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Memoized lazy comfy runtime for a resolved kit — loads the workflow pairs on
|
|
@@ -42,6 +42,12 @@ button.ghost{background:var(--panel2);color:var(--ink);border:1px solid var(--li
|
|
|
42
42
|
button:disabled{opacity:.45;cursor:default}
|
|
43
43
|
select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px}
|
|
44
44
|
.status{font-size:12px;color:var(--dim);min-height:16px}
|
|
45
|
+
.spin{width:12px;height:12px;border:2px solid var(--line);border-top-color:var(--gold);border-radius:50%;animation:rot 1s linear infinite;flex:none}
|
|
46
|
+
@keyframes rot{to{transform:rotate(360deg)}}
|
|
47
|
+
.progbar{width:120px;height:4px;background:var(--panel2);border-radius:2px;overflow:hidden;display:none}
|
|
48
|
+
.progbar i{display:block;height:100%;width:0;background:var(--gold);transition:width .3s}
|
|
49
|
+
.vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
|
|
50
|
+
.card .th .spin{position:absolute;top:8px;right:8px}
|
|
45
51
|
.detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
|
|
46
52
|
/* ── per-asset info surface (W2-2) ── */
|
|
47
53
|
.info{display:none;flex-wrap:wrap;gap:6px;align-items:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel2)}
|
|
@@ -140,13 +146,14 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
|
|
|
140
146
|
<button id=go disabled>Generate</button>
|
|
141
147
|
<div id=detnote class=detnote style="display:none">deterministic — rolls identical</div>
|
|
142
148
|
<button id=qclear class=ghost title="drop queued jobs">clear queue</button>
|
|
149
|
+
<button id=qcancel class=ghost style="display:none" title="interrupt the current job (queued jobs stay)">stop current</button>
|
|
143
150
|
</div>
|
|
144
151
|
<div class=promptwrap>
|
|
145
152
|
<textarea id=prompt placeholder="select an asset ←"></textarea>
|
|
146
153
|
<div class=jrow><button id=saveprompt class=ghost disabled>save prompt → overlay</button>
|
|
147
154
|
<button id=copyprompt class=ghost>copy</button><span class=jmsg id=promptmsg></span></div>
|
|
148
155
|
</div>
|
|
149
|
-
<div class=ctl><div class=status id=status></div></div>
|
|
156
|
+
<div class=ctl><div class=status id=status></div><div class=progbar id=prog><i></i></div></div>
|
|
150
157
|
</div>
|
|
151
158
|
<div class=info id=info></div>
|
|
152
159
|
<div class=stage id=stage><div class=ph>Pick an asset on the left, then Generate.</div>
|
|
@@ -184,7 +191,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
|
|
|
184
191
|
</main>
|
|
185
192
|
<script>
|
|
186
193
|
const $=s=>document.querySelector(s)
|
|
187
|
-
let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set()
|
|
194
|
+
let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set(),pending=[]
|
|
188
195
|
let customs=[],panelMod=null,panelKey=null // repo-defined editor panels (custom tabs)
|
|
189
196
|
let lastSideSig=null,lastRollSig=null,catMode=null,lastCatSig=null,catRows=[]
|
|
190
197
|
let rect=null,rectRatio=0.6,hasGeom=false,saveTimer=null
|
|
@@ -209,7 +216,7 @@ window.addEventListener('hashchange',restoreFromHash)
|
|
|
209
216
|
function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
|
|
210
217
|
function renderSide(list){
|
|
211
218
|
assets=list;$('#count').textContent=list.length
|
|
212
|
-
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')
|
|
219
|
+
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
|
|
213
220
|
if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
|
|
214
221
|
const cats=[...new Set(list.map(a=>a.cat))]
|
|
215
222
|
$('#list').innerHTML=cats.map(c=>{
|
|
@@ -220,7 +227,7 @@ function renderSide(list){
|
|
|
220
227
|
if(!open)return head
|
|
221
228
|
return head+list.filter(a=>a.cat===c).map(a=>
|
|
222
229
|
'<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
|
|
223
|
-
'</b><span>'+a.rolls+' rolls</span></div
|
|
230
|
+
'</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':''))+'"></div>')+'</div>').join('')
|
|
224
231
|
}).join('')
|
|
225
232
|
}
|
|
226
233
|
// ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
|
|
@@ -274,9 +281,14 @@ async function renderRolls(reset){
|
|
|
274
281
|
if(!cur)return
|
|
275
282
|
const r=await j('/api/asset?id='+encodeURIComponent(cur))
|
|
276
283
|
rolls=r.rolls
|
|
277
|
-
const
|
|
284
|
+
const genHere=pending.includes(cur)
|
|
285
|
+
const sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')+'|'+genHere
|
|
278
286
|
if(!reset&&sig===lastRollSig)return; lastRollSig=sig
|
|
279
287
|
const strip=$('#strip')
|
|
288
|
+
if(!rolls.length&&genHere){strip.innerHTML='<div class="vig gen"><div class=spin></div>generating…</div>';hideOverlay()
|
|
289
|
+
const gst=$('#stage');gst.querySelector('img.roll')?.remove()
|
|
290
|
+
if(!gst.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='generating…';gst.appendChild(ph)}
|
|
291
|
+
active=null;return}
|
|
280
292
|
if(!rolls.length){strip.innerHTML='<div class=ph>no rolls — hit Generate</div>';hideOverlay()
|
|
281
293
|
const st=$('#stage');st.querySelector('img.roll')?.remove()
|
|
282
294
|
if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
|
|
@@ -288,7 +300,8 @@ async function renderRolls(reset){
|
|
|
288
300
|
'<div class="vig'+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
|
|
289
301
|
'<img src="'+imgUrl(cur,x.name)+'">'+(x.picked?'<span class=st>★</span>':'')+
|
|
290
302
|
'<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div></div>').join('')
|
|
291
|
-
|
|
303
|
+
+(genHere?'<div class="vig gen"><div class=spin></div>generating…</div>':'')
|
|
304
|
+
strip.querySelectorAll('.vig[data-n]').forEach(v=>{
|
|
292
305
|
const n=v.dataset.n
|
|
293
306
|
v.onmouseenter=()=>setStage(imgUrl(cur,n))
|
|
294
307
|
v.onmouseleave=()=>setStage(imgUrl(cur,active))
|
|
@@ -311,6 +324,7 @@ async function discard(n){
|
|
|
311
324
|
async function genCat(c,n){await post('/api/generate-cat',{cat:c,count:n});heartbeat()}
|
|
312
325
|
$('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
|
|
313
326
|
$('#qclear').onclick=()=>post('/api/queue/clear')
|
|
327
|
+
$('#qcancel').onclick=()=>post('/api/interrupt')
|
|
314
328
|
$('#saveprompt').onclick=async()=>{
|
|
315
329
|
const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
|
|
316
330
|
$('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
|
|
@@ -470,7 +484,7 @@ async function fillCat(force){
|
|
|
470
484
|
const r=await j('/api/catrects?cat='+encodeURIComponent(mode))
|
|
471
485
|
if(catMode!==mode)return // user navigated away mid-fetch
|
|
472
486
|
catRows=r.rows||[]
|
|
473
|
-
const sig=JSON.stringify(catRows)
|
|
487
|
+
const sig=JSON.stringify(catRows)+'|'+pending.join(',')
|
|
474
488
|
if(!force&&sig===lastCatSig)return; lastCatSig=sig
|
|
475
489
|
const scroll=$('#catgrid').scrollTop
|
|
476
490
|
const pc=v=>(v/(TEX/100))+'%'
|
|
@@ -478,7 +492,7 @@ async function fillCat(force){
|
|
|
478
492
|
const fl=row.rect&&row.rect.flip
|
|
479
493
|
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)+
|
|
480
494
|
';width:'+pc(row.rect.w)+';height:'+pc(row.rect.h)+'"></div>'):''
|
|
481
|
-
return '<div class=card><div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+'</div>'+
|
|
495
|
+
return '<div class=card><div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>'+
|
|
482
496
|
'<div class=nm onclick="selectAsset(\\''+jsq(row.id)+'\\')">'+esc(row.label)+'</div>'+
|
|
483
497
|
(row.icon?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
|
|
484
498
|
' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
|
|
@@ -608,8 +622,13 @@ document.addEventListener('keydown',e=>{
|
|
|
608
622
|
let firstBeat=true
|
|
609
623
|
async function heartbeat(){
|
|
610
624
|
const s=await j('/api/state')
|
|
625
|
+
pending=s.pending||[]
|
|
611
626
|
renderSide(s.assets)
|
|
612
|
-
|
|
627
|
+
const p=s.progress
|
|
628
|
+
$('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')+(p&&p.max?(' · step '+p.value+'/'+p.max):'')):''
|
|
629
|
+
$('#qcancel').style.display=s.generating?'':'none'
|
|
630
|
+
const pb=$('#prog');pb.style.display=(s.generating&&p&&p.max)?'block':'none'
|
|
631
|
+
if(p&&p.max)pb.firstElementChild.style.width=Math.round(100*p.value/p.max)+'%'
|
|
613
632
|
const e=$('#errs')
|
|
614
633
|
if(s.errors.length){e.style.display='block';e.textContent=s.errors.join('\\n')}else e.style.display='none'
|
|
615
634
|
if(catMode)await fillCat(false)
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// Generation queue: single worker, IN-MEMORY (never run the studio with
|
|
2
2
|
// --watch — a restart drops every queued roll; the fill CLI is the recovery
|
|
3
|
-
// path), errors surfaced in a ring buffer, never fatal.
|
|
3
|
+
// path), errors surfaced in a ring buffer, never fatal. Per-step progress
|
|
4
|
+
// (comfy /ws) lives on state; cancel drops the CURRENT job's remaining
|
|
5
|
+
// rolls — queued jobs are `clear()`'s business.
|
|
6
|
+
import type { GenProgress } from '../gen/comfy-gen.ts'
|
|
7
|
+
|
|
4
8
|
export type Job = { assetId: string; prompt: string; count: number }
|
|
5
|
-
export type QueueState = { generating: boolean; current: string | null; done: number; total: number }
|
|
9
|
+
export type QueueState = { generating: boolean; current: string | null; done: number; total: number; progress: GenProgress | null }
|
|
6
10
|
|
|
7
11
|
export class StudioQueue {
|
|
8
12
|
private jobs: Job[] = []
|
|
9
|
-
|
|
13
|
+
private cancelRequested = false
|
|
14
|
+
readonly state: QueueState = { generating: false, current: null, done: 0, total: 0, progress: null }
|
|
10
15
|
readonly errors: string[] = []
|
|
11
16
|
|
|
12
17
|
/** generateOne throws on failure — the queue logs and moves on */
|
|
13
|
-
constructor(
|
|
18
|
+
constructor(
|
|
19
|
+
private generateOne: (job: Job, seed: number, onProgress: (p: GenProgress) => void) => Promise<void>,
|
|
20
|
+
private newSeed: () => number,
|
|
21
|
+
) {}
|
|
14
22
|
|
|
15
23
|
pushError(msg: string): void {
|
|
16
24
|
this.errors.push(msg)
|
|
@@ -20,6 +28,20 @@ export class StudioQueue {
|
|
|
20
28
|
|
|
21
29
|
get queuedCount(): number { return this.jobs.length }
|
|
22
30
|
|
|
31
|
+
/** asset ids with work still ahead of them (current + queued) — the SPA's spinner set */
|
|
32
|
+
pendingIds(): string[] {
|
|
33
|
+
const ids = this.state.generating && this.state.current != null ? [this.state.current] : []
|
|
34
|
+
for (const j of this.jobs) if (!ids.includes(j.assetId)) ids.push(j.assetId)
|
|
35
|
+
return ids
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** cancel the CURRENT job: the caller interrupts the backend, this drops
|
|
39
|
+
* the job's remaining rolls (an interrupted roll's throw is logged as a
|
|
40
|
+
* cancel, never an error). No-op when idle. */
|
|
41
|
+
requestCancel(): void {
|
|
42
|
+
if (this.state.generating) this.cancelRequested = true
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
clear(): void { this.jobs.length = 0 }
|
|
24
46
|
|
|
25
47
|
enqueue(newJobs: Job[]): void {
|
|
@@ -41,18 +63,35 @@ export class StudioQueue {
|
|
|
41
63
|
if (job == null) {
|
|
42
64
|
this.state.generating = false
|
|
43
65
|
this.state.current = null
|
|
66
|
+
this.state.progress = null
|
|
44
67
|
return
|
|
45
68
|
}
|
|
46
69
|
this.state.current = job.assetId
|
|
70
|
+
// each job starts with a clean cancel slate — a cancel raced by a
|
|
71
|
+
// roll that RESOLVED anyway must never leak onto the next job
|
|
72
|
+
this.cancelRequested = false
|
|
47
73
|
for (let i = 0; i < job.count; i++) {
|
|
74
|
+
if (this.consumeCancel(job, i)) break
|
|
48
75
|
const seed = this.newSeed()
|
|
49
76
|
try {
|
|
50
|
-
await this.generateOne(job, seed)
|
|
77
|
+
await this.generateOne(job, seed, (p) => { this.state.progress = p })
|
|
51
78
|
} catch (e) {
|
|
79
|
+
if (this.consumeCancel(job, i)) break
|
|
52
80
|
this.pushError(`${job.assetId} seed ${seed}: ${(e as Error).message}`)
|
|
53
81
|
}
|
|
82
|
+
this.state.progress = null
|
|
54
83
|
this.state.done++
|
|
55
84
|
}
|
|
56
85
|
}
|
|
57
86
|
}
|
|
87
|
+
|
|
88
|
+
/** true = cancel was pending; rolls i..count-1 are counted done + dropped */
|
|
89
|
+
private consumeCancel(job: Job, i: number): boolean {
|
|
90
|
+
if (!this.cancelRequested) return false
|
|
91
|
+
this.cancelRequested = false
|
|
92
|
+
this.state.done += job.count - i
|
|
93
|
+
this.state.progress = null
|
|
94
|
+
console.log(`ok: cancelled ${job.assetId} — ${job.count - i} roll(s) dropped`)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
58
97
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync, unlinkSync } from 'fs'
|
|
8
8
|
import { join, relative } from 'path'
|
|
9
9
|
import { findAsset, allAssets, missingPanelScripts, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
10
|
-
import { randomSeed } from '../gen/comfy-gen.ts'
|
|
10
|
+
import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
|
|
11
11
|
import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
|
|
12
12
|
import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
|
|
13
13
|
import { alphaHeight, toPng } from '../image-ops.ts'
|
|
@@ -21,7 +21,7 @@ export type StudioDeps = {
|
|
|
21
21
|
root: string
|
|
22
22
|
page: () => string
|
|
23
23
|
/** test seam — defaults to the comfy engine */
|
|
24
|
-
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string) => Promise<void>
|
|
24
|
+
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress) => Promise<void>
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
|
|
@@ -100,13 +100,13 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
100
100
|
if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
|
|
101
101
|
return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
|
|
102
102
|
}
|
|
103
|
-
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir) =>
|
|
104
|
-
generateRoll(found, { prompt, seed, destDir }, resolveComfy))
|
|
103
|
+
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
|
|
104
|
+
generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
|
|
105
105
|
|
|
106
|
-
const queue = new StudioQueue(async (job, seed) => {
|
|
106
|
+
const queue = new StudioQueue(async (job, seed, onProgress) => {
|
|
107
107
|
const found = findAsset(kit, job.assetId)
|
|
108
108
|
if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
|
|
109
|
-
await generateOne(found, job.prompt, seed, dirOf(job.assetId))
|
|
109
|
+
await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress)
|
|
110
110
|
}, randomSeed)
|
|
111
111
|
|
|
112
112
|
// ── session/live rect plumbing ─────────────────────────────────────────
|
|
@@ -199,7 +199,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
199
199
|
icon: f.kind.mode === 'icon',
|
|
200
200
|
}
|
|
201
201
|
}))
|
|
202
|
-
return json({ ...queue.state, queued: queue.queuedCount, errors: queue.errors.slice(-5), assets })
|
|
202
|
+
return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
if (pathname === '/api/asset') {
|
|
@@ -295,6 +295,19 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
295
295
|
queue.clear()
|
|
296
296
|
return json({ ok: true })
|
|
297
297
|
}
|
|
298
|
+
if (pathname === '/api/interrupt') {
|
|
299
|
+
// cancel the CURRENT job: backend interrupt (comfy kits) + drop
|
|
300
|
+
// its remaining rolls; queued jobs stay (that's clear's business)
|
|
301
|
+
if (!queue.state.generating) return bad('nothing generating')
|
|
302
|
+
queue.requestCancel()
|
|
303
|
+
// only touch the box when the RUNNING job is a comfy kind — the
|
|
304
|
+
// box is shared, an unrelated prompt must never be interrupted
|
|
305
|
+
const running = queue.state.current != null ? findAsset(kit, queue.state.current) : null
|
|
306
|
+
if (kit.comfy != null && running?.kind.generate.backend === 'comfy')
|
|
307
|
+
await interrupt({ host: kit.comfy.host, heal: kit.comfy.heal }).catch((e: Error) =>
|
|
308
|
+
queue.pushError(`interrupt: ${e.message} — current roll will finish, its job is still dropped`))
|
|
309
|
+
return json({ ok: true })
|
|
310
|
+
}
|
|
298
311
|
if (pathname === '/api/pick') {
|
|
299
312
|
const name = safeRoll(b.name)
|
|
300
313
|
const f = findAsset(kit, String(b.id))
|