rvlib-shipkit 0.4.0 → 0.6.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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-07-22
4
+
5
+ New (wave 3 of the shipkit expansion — media + manual assets):
6
+
7
+ - `manual()` generator backend — human-provided assets, any media: no
8
+ Generate button (POST /api/generate 400s; fill/add skip with a visible
9
+ line; generate-cat excludes them and an all-manual category 400s with an
10
+ upload pointer — its +N buttons are hidden in the SPA), the studio's
11
+ upload (button or drag-drop on the roll strip, POST /api/upload multipart,
12
+ ext-gated per media, seed-style `v<seed>` names — no same-ms overwrite) is
13
+ the only roll source. Upload itself works on EVERY kind (touched-up
14
+ images welcome). A manual asset with NO rolls is RED in the sidebar and
15
+ FAILs `shipkit assets check` with its curation brief; rolls-but-no-pick
16
+ WARNs. The asset's `prompt` is the brief.
17
+ - `ship.dir` is now honored lib-side: with no via/fn hook, picking copies
18
+ the roll to `<dir>/<assetId>.<ext>` (zero-config ship); hooks still win
19
+ and read `dir` as data. Previously `dir` was inert without a repo hook.
20
+ - `media: 'audio'` on kinds — rolls `v<n>.mp3|wav|ogg|m4a`, studio renders
21
+ players (roll strip, stage, grid card) instead of thumbnails; audio kinds
22
+ require `manual()` (generation backends are image-only today) and reject
23
+ geometry panels, both validated loud at defineKit. `isAudioRoll` exported.
24
+ - barrel: `manual`, `mediaOf`, `MEDIA_EXTS`, `isAudioRoll` + types
25
+ `Media`/`ManualGen` exported.
26
+
27
+ ## 0.5.0 — 2026-07-22
28
+
29
+ New (wave 2 of the shipkit expansion — generation UX):
30
+
31
+ - live per-step progress: the comfy engine subscribes ComfyUI's native
32
+ `/ws?clientId=` (same clientId as the `/prompt` body) and streams
33
+ `executing {node}` + `progress {value,max}` into the studio; ADDITIVE by
34
+ contract — a dead socket degrades to no-progress, completion truth stays
35
+ the `/history` poll. (comfy-code evaluated and rejected: opposite graph
36
+ model, ~87 dl/mo solo dep; native /ws does the job in-doctrine.)
37
+ - studio spinners: every queued/generating asset shows a spinner placeholder
38
+ (sidebar row, category-grid card, roll strip) driven by the new
39
+ `pending: string[]` in `/api/state`; header gains a live progress bar
40
+ (`step v/max`).
41
+ - cancel: `POST /api/interrupt` (studio "stop current") — comfy `/interrupt`
42
+ on the box (only when the RUNNING job is a comfy kind — the box is shared)
43
+ + the current job's remaining rolls dropped (logged `ok: cancelled …`,
44
+ never an error); queued jobs stay (`clear queue` still drops those). New
45
+ engine wrapper `interrupt()` exported.
46
+
3
47
  ## 0.4.0 — 2026-07-22
4
48
 
5
49
  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.6.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,10 +20,12 @@ 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
27
+ if (gen.backend === 'manual')
28
+ throw new Error(`'${found.asset.id}' is a manual asset — upload a file in the studio (button or drag-drop), nothing generates it`)
27
29
  if (gen.backend === 'canvas') {
28
30
  const { createCanvas } = await import('@napi-rs/canvas')
29
31
  const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
@@ -34,7 +36,7 @@ export async function generateRoll(
34
36
  }
35
37
  const rt = await resolveComfy()
36
38
  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}` })
39
+ 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
40
  }
39
41
 
40
42
  /** Memoized lazy comfy runtime for a resolved kit — loads the workflow pairs on
@@ -19,9 +19,20 @@ export type AssetSpec = {
19
19
 
20
20
  export type ComfyGen = { backend: 'comfy'; workflow?: string; bg?: string }
21
21
  export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
22
- export type GeneratorSpec = ComfyGen | CanvasGen
22
+ /** Human-provided assets (any media): the studio's upload mints rolls, a
23
+ * manual asset with no rolls is RED + FAILs `shipkit assets check`. The
24
+ * asset's `prompt` is the curation brief. */
25
+ export type ManualGen = { backend: 'manual' }
26
+ export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen
23
27
  export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
24
28
  export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
29
+ export const manual = (): ManualGen => ({ backend: 'manual' })
30
+
31
+ export type Media = 'image' | 'audio'
32
+ export const mediaOf = (kind: Kind): Media => kind.media ?? 'image'
33
+ /** roll/upload extensions per media — ONE source for the workspace regex,
34
+ * the upload route, and the SPA's player-vs-img decision */
35
+ export const MEDIA_EXTS: Record<Media, string[]> = { image: ['webp', 'png'], audio: ['mp3', 'wav', 'ogg', 'm4a'] }
25
36
 
26
37
  export type GeometryPanelSpec = {
27
38
  panel: 'geometry'
@@ -81,10 +92,14 @@ export type Kind = {
81
92
  category: string
82
93
  assets: AssetSpec[]
83
94
  generate: GeneratorSpec
95
+ /** roll media (default 'image') — audio kinds render players, take no geometry */
96
+ media?: Media
84
97
  /** icon kinds get no geometry, thumbnails only */
85
98
  mode?: 'sprite' | 'icon'
86
99
  /** via = repo hook module path (default .rv-assets/ship.ts) · fn = a
87
- * built-in lib hook (e.g. identityKinds re-rendering the set) — never both */
100
+ * built-in lib hook (e.g. identityKinds re-rendering the set) — never
101
+ * both. With NO hook, `dir` alone is the zero-config ship: the picked
102
+ * roll is copied to <dir>/<assetId>.<ext>; hooks read dir as data. */
88
103
  ship?: { dir?: string; via?: string; fn?: ShipFn }
89
104
  meta?: KindMeta
90
105
  }
@@ -109,6 +124,12 @@ export function validateKit(k: KitConfig): string[] {
109
124
  if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
110
125
  kindIds.add(kind.id)
111
126
  if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
127
+ if (kind.media != null && !(kind.media in MEDIA_EXTS))
128
+ errs.push(`kind '${kind.id}': unknown media '${String(kind.media)}' (${Object.keys(MEDIA_EXTS).join('/')})`)
129
+ if (kind.media === 'audio' && kind.generate?.backend !== 'manual')
130
+ errs.push(`kind '${kind.id}': media 'audio' supports only the manual() backend today (generation backends are image-only)`)
131
+ if (kind.media === 'audio' && (kind.meta?.panels ?? []).some((p) => p.panel === 'geometry'))
132
+ errs.push(`kind '${kind.id}': audio kinds take no geometry panel`)
112
133
  if (kind.ship?.fn != null && kind.ship?.via != null) errs.push(`kind '${kind.id}': ship.fn and ship.via are two hooks for one pick — keep one`)
113
134
  if (!Array.isArray(kind.assets) || kind.assets.length === 0) errs.push(`kind '${kind.id}' has no assets — a silent no-op kind is a config mistake`)
114
135
  for (const a of kind.assets ?? []) {
@@ -15,7 +15,9 @@ export const PICK_INIT: PickState = { pick: null, alts: [], shippedAt: null }
15
15
  export type PromptOverlay = { prompt: string | null }
16
16
  export const PROMPT_INIT: PromptOverlay = { prompt: null }
17
17
 
18
- export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png)$/.test(f)
18
+ // extension list mirrors kit-config MEDIA_EXTS (audio added 0.6.0)
19
+ export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png|mp3|wav|ogg|m4a)$/.test(f)
20
+ export const isAudioRoll = (f: string): boolean => /\.(mp3|wav|ogg|m4a)$/.test(f)
19
21
 
20
22
  export const assetDir = (workspace: string, assetId: string): string => join(workspace, 'studio', assetId)
21
23
 
@@ -65,9 +65,13 @@ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | '
65
65
  guarded(io, async () => {
66
66
  const kit = await loadKit(root)
67
67
  const resolveComfy = comfyFor(root, kit)
68
- // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process)
69
- const targets = allAssets(kit)
70
- .filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
68
+ // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process);
69
+ // manual kinds have nothing to generate — visible skip, never silence
70
+ const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
71
+ const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
72
+ if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
73
+ const targets = inScope
74
+ .filter((f) => f.kind.generate.backend !== 'manual')
71
75
  .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
72
76
  .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
73
77
  .filter((t) => t.want > 0)
@@ -138,5 +142,17 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
138
142
  if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
139
143
  else io.print('ok: identity outputs present')
140
144
  }
145
+ // manual assets: no rolls = nothing to ship = RED (the missing list
146
+ // is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
147
+ const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
148
+ let manualBad = 0
149
+ for (const f of manuals) {
150
+ const dir = assetDir(join(root, kit.workspace), f.asset.id)
151
+ const rolls = listRolls(dir)
152
+ if (rolls.length === 0) { io.print(`FAIL: manual asset '${f.asset.id}' has no file — upload in the studio (brief: ${f.asset.prompt.slice(0, 60)})`); manualBad++ }
153
+ else if ((await readPick(dir)).pick == null) io.print(`WARN manual asset '${f.asset.id}' has ${rolls.length} file(s), none picked`)
154
+ }
155
+ bad += manualBad
156
+ if (manuals.length > 0 && manualBad === 0) io.print(`ok: ${manuals.length} manual asset(s) provided`)
141
157
  return bad > 0 ? 1 : 0
142
158
  })
@@ -42,6 +42,19 @@ 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}
51
+ .dot.miss{background:#e5484d;border-color:#e5484d}
52
+ .vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
53
+ .vig.aud audio{width:100%;height:24px}
54
+ .stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
55
+ .stageaud audio{width:min(480px,80%)}
56
+ .card .aud{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;font-size:28px}
57
+ .card .aud audio{width:92%;height:24px}
45
58
  .detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
46
59
  /* ── per-asset info surface (W2-2) ── */
47
60
  .info{display:none;flex-wrap:wrap;gap:6px;align-items:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel2)}
@@ -139,14 +152,17 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
139
152
  <select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
140
153
  <button id=go disabled>Generate</button>
141
154
  <div id=detnote class=detnote style="display:none">deterministic — rolls identical</div>
155
+ <button id=upload class=ghost style="display:none" title="add files as rolls (or drag-drop onto the strip)">upload</button>
156
+ <input type=file id=upfile multiple style="display:none">
142
157
  <button id=qclear class=ghost title="drop queued jobs">clear queue</button>
158
+ <button id=qcancel class=ghost style="display:none" title="interrupt the current job (queued jobs stay)">stop current</button>
143
159
  </div>
144
160
  <div class=promptwrap>
145
161
  <textarea id=prompt placeholder="select an asset ←"></textarea>
146
162
  <div class=jrow><button id=saveprompt class=ghost disabled>save prompt → overlay</button>
147
163
  <button id=copyprompt class=ghost>copy</button><span class=jmsg id=promptmsg></span></div>
148
164
  </div>
149
- <div class=ctl><div class=status id=status></div></div>
165
+ <div class=ctl><div class=status id=status></div><div class=progbar id=prog><i></i></div></div>
150
166
  </div>
151
167
  <div class=info id=info></div>
152
168
  <div class=stage id=stage><div class=ph>Pick an asset on the left, then Generate.</div>
@@ -184,7 +200,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
184
200
  </main>
185
201
  <script>
186
202
  const $=s=>document.querySelector(s)
187
- let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set()
203
+ let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set(),pending=[]
188
204
  let customs=[],panelMod=null,panelKey=null // repo-defined editor panels (custom tabs)
189
205
  let lastSideSig=null,lastRollSig=null,catMode=null,lastCatSig=null,catRows=[]
190
206
  let rect=null,rectRatio=0.6,hasGeom=false,saveTimer=null
@@ -209,18 +225,18 @@ window.addEventListener('hashchange',restoreFromHash)
209
225
  function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
210
226
  function renderSide(list){
211
227
  assets=list;$('#count').textContent=list.length
212
- const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')
228
+ const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
213
229
  if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
214
230
  const cats=[...new Set(list.map(a=>a.cat))]
215
231
  $('#list').innerHTML=cats.map(c=>{
216
232
  const open=openCats.has(c)
217
233
  const head='<div class=gname><span><span class=fold onclick="toggleFold(\\''+jsq(c)+'\\')">'+(open?'▾':'▸')+'</span> '+
218
234
  '<span class=cn onclick="showCat(\\''+jsq(c)+'\\')" title="grid view">'+esc(c)+'</span>'+
219
- '</span><span class=catgen>'+[1,2,3].map(n=>'<button onclick="genCat(\\''+jsq(c)+'\\','+n+')">+'+n+'</button>').join('')+'</span></div>'
235
+ '</span>'+(list.filter(a=>a.cat===c).every(a=>a.manual)?'':'<span class=catgen>'+[1,2,3].map(n=>'<button onclick="genCat(\\''+jsq(c)+'\\','+n+')">+'+n+'</button>').join('')+'</span>')+'</div>'
220
236
  if(!open)return head
221
237
  return head+list.filter(a=>a.cat===c).map(a=>
222
238
  '<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('')
239
+ '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':(a.manual?'miss':'')))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
224
240
  }).join('')
225
241
  }
226
242
  // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
@@ -258,40 +274,68 @@ async function selectAsset(id,writeHash=true){
258
274
  if(tab.indexOf('c:')===0&&!customs.some(c=>'c:'+c.id===tab))tab='asset' // new asset may not carry the open panel
259
275
  renderCustomTabs()
260
276
  $('#prompt').value=r.asset?r.asset.prompt:a.prompt
261
- $('#go').disabled=false;$('#saveprompt').disabled=false;$('#promptmsg').textContent=''
277
+ $('#saveprompt').disabled=false;$('#promptmsg').textContent=''
278
+ // manual kinds: nothing generates them — upload is the only roll source
279
+ const man=!!a.manual
280
+ $('#go').disabled=man;$('#go').style.display=man?'none':''
281
+ $('#upload').style.display=''
282
+ $('#upfile').accept=(a.media==='audio'?'.mp3,.wav,.ogg,.m4a':'.png,.webp')
262
283
  // deterministic canvas kinds: a slot machine with one outcome — cap rolls at 1
263
284
  const det=!!(r.info&&r.info.deterministic),n=$('#n')
264
- n.disabled=det;if(det)n.value='1';$('#detnote').style.display=det?'':'none'
285
+ n.disabled=det||man;if(det)n.value='1';n.style.display=man?'none':''
286
+ $('#detnote').textContent=man?'manual — upload files (drag-drop works)':'deterministic — rolls identical'
287
+ $('#detnote').style.display=(det||man)?'':'none'
265
288
  renderInfo(r.info)
266
289
  await renderRolls(true);loadTab()
267
290
  }
291
+ const isAud=n=>['mp3','wav','ogg','m4a'].includes(String(n||'').split('.').pop())
268
292
  function setStage(src){
269
- const st=$('#stage');let img=st.querySelector('img.roll')
293
+ const st=$('#stage');st.querySelector('.stageaud')?.remove()
294
+ let img=st.querySelector('img.roll')
270
295
  if(!img){st.querySelector('.ph')?.remove();img=document.createElement('img');img.className='roll';st.appendChild(img);img.onload=positionOverlay}
271
296
  img.src=src
272
297
  }
298
+ function setStageAud(n){
299
+ const st=$('#stage')
300
+ const curAud=st.querySelector('.stageaud')
301
+ if(curAud&&curAud.dataset.n===n)return // keep playback alive across heartbeats
302
+ st.querySelector('img.roll')?.remove();st.querySelector('.ph')?.remove();curAud?.remove()
303
+ const d=document.createElement('div');d.className='stageaud';d.dataset.n=n
304
+ d.innerHTML='🔊<audio controls src="'+imgUrl(cur,n)+'"></audio>'
305
+ st.appendChild(d);hideOverlay()
306
+ }
307
+ const showRoll=n=>isAud(n)?setStageAud(n):setStage(imgUrl(cur,n))
273
308
  async function renderRolls(reset){
274
309
  if(!cur)return
275
310
  const r=await j('/api/asset?id='+encodeURIComponent(cur))
276
311
  rolls=r.rolls
277
- const sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')
312
+ const genHere=pending.includes(cur)
313
+ const sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')+'|'+genHere
278
314
  if(!reset&&sig===lastRollSig)return; lastRollSig=sig
279
315
  const strip=$('#strip')
280
- if(!rolls.length){strip.innerHTML='<div class=ph>no rolls — hit Generate</div>';hideOverlay()
281
- const st=$('#stage');st.querySelector('img.roll')?.remove()
316
+ if(!rolls.length&&genHere){strip.innerHTML='<div class="vig gen"><div class=spin></div>generating…</div>';hideOverlay()
317
+ const gst=$('#stage');gst.querySelector('img.roll')?.remove();gst.querySelector('.stageaud')?.remove()
318
+ if(!gst.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='generating…';gst.appendChild(ph)}
319
+ active=null;return}
320
+ if(!rolls.length){const a0=assets.find(x=>x.id===cur)
321
+ strip.innerHTML='<div class=ph>'+(a0&&a0.manual?'no files — upload (or drag-drop here)':'no rolls — hit Generate')+'</div>';hideOverlay()
322
+ const st=$('#stage');st.querySelector('img.roll')?.remove();st.querySelector('.stageaud')?.remove()
282
323
  if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
283
324
  active=null;return}
284
325
  if(reset||!rolls.some(x=>x.name===active))active=(rolls.find(x=>x.picked)||rolls[0]).name
285
- setStage(imgUrl(cur,active))
326
+ showRoll(active)
286
327
  if(hasGeom)await loadRect()
328
+ const btns='<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div>'
287
329
  strip.innerHTML=rolls.map(x=>
288
- '<div class="vig'+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
289
- '<img src="'+imgUrl(cur,x.name)+'">'+(x.picked?'<span class=st>★</span>':'')+
290
- '<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=>{
330
+ '<div class="vig'+(isAud(x.name)?' aud':'')+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
331
+ (isAud(x.name)?'🔊<audio controls src="'+imgUrl(cur,x.name)+'"></audio>':'<img src="'+imgUrl(cur,x.name)+'">')+(x.picked?'<span class=st>★</span>':'')+
332
+ btns+'</div>').join('')
333
+ +(genHere?'<div class="vig gen"><div class=spin></div>generating…</div>':'')
334
+ strip.querySelectorAll('.vig[data-n]').forEach(v=>{
292
335
  const n=v.dataset.n
293
- v.onmouseenter=()=>setStage(imgUrl(cur,n))
294
- v.onmouseleave=()=>setStage(imgUrl(cur,active))
336
+ // hover-preview stays image-only — recreating an <audio> mid-playback on hover would cut the sound
337
+ v.onmouseenter=()=>{if(!isAud(n))setStage(imgUrl(cur,n))}
338
+ v.onmouseleave=()=>showRoll(active) // setStageAud keepalive avoids audio recreate when unchanged
295
339
  v.onclick=()=>{active=n;lastRollSig=null;renderRolls(false);if(tab!=='asset')loadTab()} // meta + custom panels are per-roll
296
340
  v.querySelector('.b-pick').onclick=e=>{e.stopPropagation();pickRoll(n)}
297
341
  v.querySelector('.b-alt').onclick=e=>{e.stopPropagation();post('/api/alt',{id:cur,name:n}).then(()=>renderRolls(false))}
@@ -308,9 +352,24 @@ async function discard(n){
308
352
  if(active===n)active=null
309
353
  lastRollSig=null;lastSideSig=null;await renderRolls(true)
310
354
  }
311
- async function genCat(c,n){await post('/api/generate-cat',{cat:c,count:n});heartbeat()}
355
+ async function genCat(c,n){const r=await post('/api/generate-cat',{cat:c,count:n});if(r&&r.error)$('#status').textContent=r.error;heartbeat()}
312
356
  $('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
313
357
  $('#qclear').onclick=()=>post('/api/queue/clear')
358
+ $('#qcancel').onclick=()=>post('/api/interrupt')
359
+ // ── upload: button, file input, drag-drop on the strip (manual kinds' only roll source) ──
360
+ async function uploadFiles(files){
361
+ if(!cur||!files||!files.length)return
362
+ for(const f of files){
363
+ const fd=new FormData();fd.append('id',cur);fd.append('file',f)
364
+ const r=await fetch(R('/api/upload'),{method:'POST',body:fd}).then(x=>x.json()).catch(e=>({error:String(e)}))
365
+ if(r.error)$('#status').textContent='upload FAIL: '+r.error
366
+ }
367
+ lastRollSig=null;lastSideSig=null;await renderRolls(false)
368
+ }
369
+ $('#upload').onclick=()=>$('#upfile').click()
370
+ $('#upfile').onchange=async e=>{await uploadFiles(e.target.files);e.target.value=''}
371
+ $('#strip').addEventListener('dragover',e=>e.preventDefault())
372
+ $('#strip').addEventListener('drop',e=>{e.preventDefault();uploadFiles(e.dataTransfer.files)})
314
373
  $('#saveprompt').onclick=async()=>{
315
374
  const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
316
375
  $('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
@@ -470,7 +529,7 @@ async function fillCat(force){
470
529
  const r=await j('/api/catrects?cat='+encodeURIComponent(mode))
471
530
  if(catMode!==mode)return // user navigated away mid-fetch
472
531
  catRows=r.rows||[]
473
- const sig=JSON.stringify(catRows)
532
+ const sig=JSON.stringify(catRows)+'|'+pending.join(',')
474
533
  if(!force&&sig===lastCatSig)return; lastCatSig=sig
475
534
  const scroll=$('#catgrid').scrollTop
476
535
  const pc=v=>(v/(TEX/100))+'%'
@@ -478,9 +537,11 @@ async function fillCat(force){
478
537
  const fl=row.rect&&row.rect.flip
479
538
  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
539
  ';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>'+
540
+ return '<div class=card>'+(isAud(row.name)
541
+ ?'<div class=aud>🔊<audio controls src="'+imgUrl(row.id,row.name)+'"></audio>'+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>'
542
+ :'<div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>')+
482
543
  '<div class=nm onclick="selectAsset(\\''+jsq(row.id)+'\\')">'+esc(row.label)+'</div>'+
483
- (row.icon?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
544
+ ((row.icon||isAud(row.name))?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
484
545
  ' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
485
546
  '<button class=ghost onclick="toggleFlip(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\')">⇆ flip</button></div>'))+'</div>'
486
547
  }).join('')
@@ -608,8 +669,13 @@ document.addEventListener('keydown',e=>{
608
669
  let firstBeat=true
609
670
  async function heartbeat(){
610
671
  const s=await j('/api/state')
672
+ pending=s.pending||[]
611
673
  renderSide(s.assets)
612
- $('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')):''
674
+ const p=s.progress
675
+ $('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')+(p&&p.max?(' · step '+p.value+'/'+p.max):'')):''
676
+ $('#qcancel').style.display=s.generating?'':'none'
677
+ const pb=$('#prog');pb.style.display=(s.generating&&p&&p.max)?'block':'none'
678
+ if(p&&p.max)pb.firstElementChild.style.width=Math.round(100*p.value/p.max)+'%'
613
679
  const e=$('#errs')
614
680
  if(s.errors.length){e.style.display='block';e.textContent=s.errors.join('\\n')}else e.style.display='none'
615
681
  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
  }
@@ -4,10 +4,10 @@
4
4
  //
5
5
  // ids/names are validated against the kit + the roll-file regex — no path
6
6
  // from the client ever reaches the filesystem unchecked.
7
- import { existsSync, unlinkSync } from 'fs'
7
+ import { existsSync, mkdirSync, unlinkSync } from 'fs'
8
8
  import { join, relative } from 'path'
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'
9
+ import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.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,14 +21,14 @@ 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 }
28
28
 
29
29
  // what the studio detail view shows about an asset's plumbing (W2-2)
30
30
  export type AssetInfo = {
31
- backend: 'canvas' | 'comfy'
31
+ backend: 'canvas' | 'comfy' | 'manual'
32
32
  /** canvas rolls have no seed input — every roll is identical */
33
33
  deterministic: boolean
34
34
  /** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
@@ -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 ─────────────────────────────────────────
@@ -161,7 +161,17 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
161
161
  }
162
162
  const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
163
163
  const fn = await resolveShipFn(f)
164
- if (fn == null) return { shipped: null } // no hook: pick.json is the record
164
+ if (fn == null) {
165
+ // no hook: ship.dir is the zero-config path — copy the picked
166
+ // roll as <dir>/<assetId>.<ext>; without dir, pick.json is the record
167
+ const dir = f.kind.ship?.dir
168
+ if (dir == null) return { shipped: null }
169
+ const ext = roll.split('.').at(-1)!
170
+ const dest = join(root, dir, `${f.asset.id}.${ext}`)
171
+ mkdirSync(join(root, dir), { recursive: true })
172
+ await Bun.write(dest, Bun.file(join(dirOf(f.asset.id), roll)))
173
+ return { shipped: relative(root, dest) }
174
+ }
165
175
  const dir = dirOf(f.asset.id)
166
176
  const g = geometryOf(f)
167
177
  const dest = await fn({
@@ -196,10 +206,10 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
196
206
  return {
197
207
  id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
198
208
  prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
199
- icon: f.kind.mode === 'icon',
209
+ icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
200
210
  }
201
211
  }))
202
- return json({ ...queue.state, queued: queue.queuedCount, errors: queue.errors.slice(-5), assets })
212
+ return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
203
213
  }
204
214
 
205
215
  if (pathname === '/api/asset') {
@@ -274,18 +284,44 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
274
284
  }
275
285
 
276
286
  if (req.method !== 'POST') return new Response('', { status: 404 })
287
+
288
+ if (pathname === '/api/upload') {
289
+ // multipart (not json) — handled before the json body() parse.
290
+ // Any kind accepts uploads (touched-up images welcome); manual
291
+ // kinds have no other roll source. Ext validated per kind media.
292
+ const fd = await req.formData().catch(() => null)
293
+ if (fd == null) return bad('multipart form expected (id + file)')
294
+ const f = findAsset(kit, String(fd.get('id') ?? ''))
295
+ if (f == null) return bad(`unknown asset '${String(fd.get('id') ?? '')}'`)
296
+ const file = fd.get('file')
297
+ if (!(file instanceof File)) return bad('file field missing')
298
+ const ext = (file.name.split('.').at(-1) ?? '').toLowerCase()
299
+ const allowed = MEDIA_EXTS[mediaOf(f.kind)]
300
+ if (!allowed.includes(ext)) return bad(`'${file.name}': .${ext} not allowed for ${mediaOf(f.kind)} (${allowed.join('/')})`)
301
+ // seed-style name like generated rolls — Date.now() collides for
302
+ // same-ms multi-uploads and silently overwrites
303
+ const name = `v${randomSeed()}.${ext}`
304
+ await Bun.write(join(dirOf(f.asset.id), name), await file.arrayBuffer())
305
+ return json({ ok: true, name })
306
+ }
307
+
277
308
  const b = await body()
278
309
 
279
310
  if (pathname === '/api/generate') {
280
311
  const f = findAsset(kit, String(b.id))
281
312
  if (f == null) return bad(`unknown asset '${String(b.id)}'`)
313
+ if (f.kind.generate.backend === 'manual') return bad(`'${f.asset.id}' is a manual asset — upload a file instead`)
282
314
  const count = Math.max(1, Math.min(24, Number(b.count) || 1))
283
315
  const prompt = typeof b.prompt === 'string' && b.prompt.trim() !== '' ? b.prompt : await promptOf(f)
284
316
  queue.enqueue([{ assetId: f.asset.id, prompt, count }])
285
317
  return json({ ok: true })
286
318
  }
287
319
  if (pathname === '/api/generate-cat') {
288
- const hit = allAssets(kit).filter((f) => f.kind.category === b.cat)
320
+ // manual kinds excluded from batch gen — nothing generates them;
321
+ // an all-manual cat gets a truthful pointer, not "no assets"
322
+ const inCat = allAssets(kit).filter((f) => f.kind.category === b.cat)
323
+ const hit = inCat.filter((f) => f.kind.generate.backend !== 'manual')
324
+ if (hit.length === 0 && inCat.length > 0) return bad(`cat '${String(b.cat)}' has only manual assets — upload in the studio`)
289
325
  if (hit.length === 0) return bad(`no assets in cat '${String(b.cat)}'`)
290
326
  const count = Math.max(1, Math.min(6, Number(b.count) || 1))
291
327
  queue.enqueue(await Promise.all(hit.map(async (f) => ({ assetId: f.asset.id, prompt: await promptOf(f), count }))))
@@ -295,6 +331,19 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
295
331
  queue.clear()
296
332
  return json({ ok: true })
297
333
  }
334
+ if (pathname === '/api/interrupt') {
335
+ // cancel the CURRENT job: backend interrupt (comfy kits) + drop
336
+ // its remaining rolls; queued jobs stay (that's clear's business)
337
+ if (!queue.state.generating) return bad('nothing generating')
338
+ queue.requestCancel()
339
+ // only touch the box when the RUNNING job is a comfy kind — the
340
+ // box is shared, an unrelated prompt must never be interrupted
341
+ const running = queue.state.current != null ? findAsset(kit, queue.state.current) : null
342
+ if (kit.comfy != null && running?.kind.generate.backend === 'comfy')
343
+ await interrupt({ host: kit.comfy.host, heal: kit.comfy.heal }).catch((e: Error) =>
344
+ queue.pushError(`interrupt: ${e.message} — current roll will finish, its job is still dropped`))
345
+ return json({ ok: true })
346
+ }
298
347
  if (pathname === '/api/pick') {
299
348
  const name = safeRoll(b.name)
300
349
  const f = findAsset(kit, String(b.id))
@@ -369,6 +418,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
369
418
  const name = safeRoll(b.name)
370
419
  const f = findAsset(kit, String(b.id))
371
420
  if (name == null || f == null) return bad('bad id/name')
421
+ if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
372
422
  const current = await sessionRect(f, name)
373
423
  const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
374
424
  const res = await saveRect(f, name, flipped)
package/src/shipkit.ts CHANGED
@@ -33,8 +33,8 @@ export type { Ask, CreateOpts } from './asks/asks-queue.ts'
33
33
  // assets surface (folded in from the standalone atelier lib; that npm
34
34
  // package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
35
35
  // declares its kinds; `shipkit assets …` derives the rest.
36
- export { defineKit, defineKind, comfy, canvas, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
37
- export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
36
+ export { defineKit, defineKind, comfy, canvas, manual, mediaOf, MEDIA_EXTS, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
37
+ export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
38
38
  export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
39
39
  export type { Brand } from './assets/kits/identity-kit.ts'
40
40
  export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
@@ -50,7 +50,7 @@ export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
50
50
  export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
51
51
  export { toPng, alphaHeight } from './assets/image-ops.ts'
52
52
  export { updateJson, readJson } from './assets/json-store.ts'
53
- export { assetDir, listRolls, isRollFile, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
53
+ export { assetDir, listRolls, isRollFile, isAudioRoll, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
54
54
  export type { PickState, PromptOverlay } from './assets/roll-workspace.ts'
55
55
  export { scanMdGraph } from './wiki/scan-md.ts'
56
56
  export { lintMd } from './wiki/lint-md.ts'