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.4.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
- const outputs = await waitFor(o, await submit(o, graph))
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><div class="dot '+(a.picked?'done':(a.rolls?'some':''))+'"></div></div>').join('')
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 sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')
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
- strip.querySelectorAll('.vig').forEach(v=>{
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
- $('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')):''
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
- readonly state: QueueState = { generating: false, current: null, done: 0, total: 0 }
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(private generateOne: (job: Job, seed: number) => Promise<void>, private newSeed: () => number) {}
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))