rvlib-shipkit 0.5.0 → 0.7.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.
@@ -48,6 +48,14 @@ select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid
48
48
  .progbar i{display:block;height:100%;width:0;background:var(--gold);transition:width .3s}
49
49
  .vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
50
50
  .card .th .spin{position:absolute;top:8px;right:8px}
51
+ .dot.miss{background:#e5484d;border-color:#e5484d}
52
+ .dot.stale{background:#f5a524;border-color:#f5a524}
53
+ .vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
54
+ .vig.aud audio{width:100%;height:24px}
55
+ .stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
56
+ .stageaud audio{width:min(480px,80%)}
57
+ .card .aud{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;font-size:28px}
58
+ .card .aud audio{width:92%;height:24px}
51
59
  .detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
52
60
  /* ── per-asset info surface (W2-2) ── */
53
61
  .info{display:none;flex-wrap:wrap;gap:6px;align-items:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel2)}
@@ -145,6 +153,8 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
145
153
  <select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
146
154
  <button id=go disabled>Generate</button>
147
155
  <div id=detnote class=detnote style="display:none">deterministic — rolls identical</div>
156
+ <button id=upload class=ghost style="display:none" title="add files as rolls (or drag-drop onto the strip)">upload</button>
157
+ <input type=file id=upfile multiple style="display:none">
148
158
  <button id=qclear class=ghost title="drop queued jobs">clear queue</button>
149
159
  <button id=qcancel class=ghost style="display:none" title="interrupt the current job (queued jobs stay)">stop current</button>
150
160
  </div>
@@ -216,18 +226,18 @@ window.addEventListener('hashchange',restoreFromHash)
216
226
  function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
217
227
  function renderSide(list){
218
228
  assets=list;$('#count').textContent=list.length
219
- const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
229
+ const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked,a.stale]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
220
230
  if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
221
231
  const cats=[...new Set(list.map(a=>a.cat))]
222
232
  $('#list').innerHTML=cats.map(c=>{
223
233
  const open=openCats.has(c)
224
234
  const head='<div class=gname><span><span class=fold onclick="toggleFold(\\''+jsq(c)+'\\')">'+(open?'▾':'▸')+'</span> '+
225
235
  '<span class=cn onclick="showCat(\\''+jsq(c)+'\\')" title="grid view">'+esc(c)+'</span>'+
226
- '</span><span class=catgen>'+[1,2,3].map(n=>'<button onclick="genCat(\\''+jsq(c)+'\\','+n+')">+'+n+'</button>').join('')+'</span></div>'
236
+ '</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>'
227
237
  if(!open)return head
228
238
  return head+list.filter(a=>a.cat===c).map(a=>
229
239
  '<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
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('')
240
+ '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.stale?'stale':(a.picked?'done':(a.rolls?'some':(a.manual?'miss':''))))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
231
241
  }).join('')
232
242
  }
233
243
  // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
@@ -265,18 +275,37 @@ async function selectAsset(id,writeHash=true){
265
275
  if(tab.indexOf('c:')===0&&!customs.some(c=>'c:'+c.id===tab))tab='asset' // new asset may not carry the open panel
266
276
  renderCustomTabs()
267
277
  $('#prompt').value=r.asset?r.asset.prompt:a.prompt
268
- $('#go').disabled=false;$('#saveprompt').disabled=false;$('#promptmsg').textContent=''
278
+ $('#saveprompt').disabled=false;$('#promptmsg').textContent=''
279
+ // manual kinds: nothing generates them — upload is the only roll source
280
+ const man=!!a.manual
281
+ $('#go').disabled=man;$('#go').style.display=man?'none':''
282
+ $('#upload').style.display=''
283
+ $('#upfile').accept=(a.media==='audio'?'.mp3,.wav,.ogg,.m4a':'.png,.webp')
269
284
  // deterministic canvas kinds: a slot machine with one outcome — cap rolls at 1
270
285
  const det=!!(r.info&&r.info.deterministic),n=$('#n')
271
- n.disabled=det;if(det)n.value='1';$('#detnote').style.display=det?'':'none'
286
+ n.disabled=det||man;if(det)n.value='1';n.style.display=man?'none':''
287
+ $('#detnote').textContent=man?'manual — upload files (drag-drop works)':'deterministic — rolls identical'
288
+ $('#detnote').style.display=(det||man)?'':'none'
272
289
  renderInfo(r.info)
273
290
  await renderRolls(true);loadTab()
274
291
  }
292
+ const isAud=n=>['mp3','wav','ogg','m4a'].includes(String(n||'').split('.').pop())
275
293
  function setStage(src){
276
- const st=$('#stage');let img=st.querySelector('img.roll')
294
+ const st=$('#stage');st.querySelector('.stageaud')?.remove()
295
+ let img=st.querySelector('img.roll')
277
296
  if(!img){st.querySelector('.ph')?.remove();img=document.createElement('img');img.className='roll';st.appendChild(img);img.onload=positionOverlay}
278
297
  img.src=src
279
298
  }
299
+ function setStageAud(n){
300
+ const st=$('#stage')
301
+ const curAud=st.querySelector('.stageaud')
302
+ if(curAud&&curAud.dataset.n===n)return // keep playback alive across heartbeats
303
+ st.querySelector('img.roll')?.remove();st.querySelector('.ph')?.remove();curAud?.remove()
304
+ const d=document.createElement('div');d.className='stageaud';d.dataset.n=n
305
+ d.innerHTML='🔊<audio controls src="'+imgUrl(cur,n)+'"></audio>'
306
+ st.appendChild(d);hideOverlay()
307
+ }
308
+ const showRoll=n=>isAud(n)?setStageAud(n):setStage(imgUrl(cur,n))
280
309
  async function renderRolls(reset){
281
310
  if(!cur)return
282
311
  const r=await j('/api/asset?id='+encodeURIComponent(cur))
@@ -286,25 +315,28 @@ async function renderRolls(reset){
286
315
  if(!reset&&sig===lastRollSig)return; lastRollSig=sig
287
316
  const strip=$('#strip')
288
317
  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()
318
+ const gst=$('#stage');gst.querySelector('img.roll')?.remove();gst.querySelector('.stageaud')?.remove()
290
319
  if(!gst.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='generating…';gst.appendChild(ph)}
291
320
  active=null;return}
292
- if(!rolls.length){strip.innerHTML='<div class=ph>no rolls — hit Generate</div>';hideOverlay()
293
- const st=$('#stage');st.querySelector('img.roll')?.remove()
321
+ if(!rolls.length){const a0=assets.find(x=>x.id===cur)
322
+ strip.innerHTML='<div class=ph>'+(a0&&a0.manual?'no files — upload (or drag-drop here)':'no rolls — hit Generate')+'</div>';hideOverlay()
323
+ const st=$('#stage');st.querySelector('img.roll')?.remove();st.querySelector('.stageaud')?.remove()
294
324
  if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
295
325
  active=null;return}
296
326
  if(reset||!rolls.some(x=>x.name===active))active=(rolls.find(x=>x.picked)||rolls[0]).name
297
- setStage(imgUrl(cur,active))
327
+ showRoll(active)
298
328
  if(hasGeom)await loadRect()
329
+ const btns='<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div>'
299
330
  strip.innerHTML=rolls.map(x=>
300
- '<div class="vig'+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
301
- '<img src="'+imgUrl(cur,x.name)+'">'+(x.picked?'<span class=st>★</span>':'')+
302
- '<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div></div>').join('')
331
+ '<div class="vig'+(isAud(x.name)?' aud':'')+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
332
+ (isAud(x.name)?'🔊<audio controls src="'+imgUrl(cur,x.name)+'"></audio>':'<img src="'+imgUrl(cur,x.name)+'">')+(x.picked?'<span class=st>★</span>':'')+
333
+ btns+'</div>').join('')
303
334
  +(genHere?'<div class="vig gen"><div class=spin></div>generating…</div>':'')
304
335
  strip.querySelectorAll('.vig[data-n]').forEach(v=>{
305
336
  const n=v.dataset.n
306
- v.onmouseenter=()=>setStage(imgUrl(cur,n))
307
- v.onmouseleave=()=>setStage(imgUrl(cur,active))
337
+ // hover-preview stays image-only — recreating an <audio> mid-playback on hover would cut the sound
338
+ v.onmouseenter=()=>{if(!isAud(n))setStage(imgUrl(cur,n))}
339
+ v.onmouseleave=()=>showRoll(active) // setStageAud keepalive avoids audio recreate when unchanged
308
340
  v.onclick=()=>{active=n;lastRollSig=null;renderRolls(false);if(tab!=='asset')loadTab()} // meta + custom panels are per-roll
309
341
  v.querySelector('.b-pick').onclick=e=>{e.stopPropagation();pickRoll(n)}
310
342
  v.querySelector('.b-alt').onclick=e=>{e.stopPropagation();post('/api/alt',{id:cur,name:n}).then(()=>renderRolls(false))}
@@ -321,10 +353,24 @@ async function discard(n){
321
353
  if(active===n)active=null
322
354
  lastRollSig=null;lastSideSig=null;await renderRolls(true)
323
355
  }
324
- async function genCat(c,n){await post('/api/generate-cat',{cat:c,count:n});heartbeat()}
356
+ 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()}
325
357
  $('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
326
358
  $('#qclear').onclick=()=>post('/api/queue/clear')
327
359
  $('#qcancel').onclick=()=>post('/api/interrupt')
360
+ // ── upload: button, file input, drag-drop on the strip (manual kinds' only roll source) ──
361
+ async function uploadFiles(files){
362
+ if(!cur||!files||!files.length)return
363
+ for(const f of files){
364
+ const fd=new FormData();fd.append('id',cur);fd.append('file',f)
365
+ const r=await fetch(R('/api/upload'),{method:'POST',body:fd}).then(x=>x.json()).catch(e=>({error:String(e)}))
366
+ if(r.error)$('#status').textContent='upload FAIL: '+r.error
367
+ }
368
+ lastRollSig=null;lastSideSig=null;await renderRolls(false)
369
+ }
370
+ $('#upload').onclick=()=>$('#upfile').click()
371
+ $('#upfile').onchange=async e=>{await uploadFiles(e.target.files);e.target.value=''}
372
+ $('#strip').addEventListener('dragover',e=>e.preventDefault())
373
+ $('#strip').addEventListener('drop',e=>{e.preventDefault();uploadFiles(e.dataTransfer.files)})
328
374
  $('#saveprompt').onclick=async()=>{
329
375
  const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
330
376
  $('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
@@ -492,9 +538,11 @@ async function fillCat(force){
492
538
  const fl=row.rect&&row.rect.flip
493
539
  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)+
494
540
  ';width:'+pc(row.rect.w)+';height:'+pc(row.rect.h)+'"></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>'+
541
+ return '<div class=card>'+(isAud(row.name)
542
+ ?'<div class=aud>🔊<audio controls src="'+imgUrl(row.id,row.name)+'"></audio>'+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>'
543
+ :'<div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>')+
496
544
  '<div class=nm onclick="selectAsset(\\''+jsq(row.id)+'\\')">'+esc(row.label)+'</div>'+
497
- (row.icon?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
545
+ ((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)+'"'+
498
546
  ' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
499
547
  '<button class=ghost onclick="toggleFlip(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\')">⇆ flip</button></div>'))+'</div>'
500
548
  }).join('')
@@ -4,10 +4,11 @@
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'
9
+ import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
10
10
  import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
11
+ import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
11
12
  import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
12
13
  import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
13
14
  import { alphaHeight, toPng } from '../image-ops.ts'
@@ -20,15 +21,16 @@ export type StudioDeps = {
20
21
  kit: ResolvedKit
21
22
  root: string
22
23
  page: () => string
23
- /** test seam — defaults to the comfy engine */
24
- generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress) => Promise<void>
24
+ /** test seam — defaults to the real engines. `context` = a derived
25
+ * asset's resolved parent picks (queue resolves it at run time) */
26
+ generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress, context?: ParentContext) => Promise<void>
25
27
  }
26
28
 
27
29
  export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
28
30
 
29
31
  // what the studio detail view shows about an asset's plumbing (W2-2)
30
32
  export type AssetInfo = {
31
- backend: 'canvas' | 'comfy'
33
+ backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
32
34
  /** canvas rolls have no seed input — every roll is identical */
33
35
  deterministic: boolean
34
36
  /** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
@@ -100,15 +102,36 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
100
102
  if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
101
103
  return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
102
104
  }
103
- const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
104
- generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
105
+ const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
106
+ generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
105
107
 
106
108
  const queue = new StudioQueue(async (job, seed, onProgress) => {
107
109
  const found = findAsset(kit, job.assetId)
108
110
  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), onProgress)
111
+ // derived assets: parents' picks resolved at RUN time (a pick made
112
+ // while the job queued is honored); a blocker names the parent
113
+ let context: ParentContext | undefined
114
+ if (parentIdsOf(found).length > 0) {
115
+ const r = await resolveParentContext(kit, workspace, found)
116
+ if ('missing' in r) throw new Error(`'${job.assetId}' blocked — parents not ready: ${r.missing.join(', ')}`)
117
+ context = r
118
+ }
119
+ await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress, context)
110
120
  }, randomSeed)
111
121
 
122
+ // machine-clearable red clears itself: re-picking a PARENT auto-enqueues
123
+ // every now-stale child (1 roll each) — no button, no nag. Generation
124
+ // spend is gated on the intentful pick, never on passive page loads.
125
+ const regenStaleChildren = async (parentId: string): Promise<void> => {
126
+ for (const f of allAssets(kit)) {
127
+ if (!parentIdsOf(f).includes(parentId)) continue
128
+ if (!(await isStale(kit, workspace, f))) continue
129
+ if (queue.pendingIds().includes(f.asset.id)) continue // already queued — never double-spend
130
+ queue.enqueue([{ assetId: f.asset.id, prompt: await promptOf(f), count: 1 }])
131
+ console.log(`ok: ${f.asset.id} auto-regen queued (parent '${parentId}' re-picked)`)
132
+ }
133
+ }
134
+
112
135
  // ── session/live rect plumbing ─────────────────────────────────────────
113
136
  const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
114
137
  const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
@@ -161,7 +184,17 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
161
184
  }
162
185
  const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
163
186
  const fn = await resolveShipFn(f)
164
- if (fn == null) return { shipped: null } // no hook: pick.json is the record
187
+ if (fn == null) {
188
+ // no hook: ship.dir is the zero-config path — copy the picked
189
+ // roll as <dir>/<assetId>.<ext>; without dir, pick.json is the record
190
+ const dir = f.kind.ship?.dir
191
+ if (dir == null) return { shipped: null }
192
+ const ext = roll.split('.').at(-1)!
193
+ const dest = join(root, dir, `${f.asset.id}.${ext}`)
194
+ mkdirSync(join(root, dir), { recursive: true })
195
+ await Bun.write(dest, Bun.file(join(dirOf(f.asset.id), roll)))
196
+ return { shipped: relative(root, dest) }
197
+ }
165
198
  const dir = dirOf(f.asset.id)
166
199
  const g = geometryOf(f)
167
200
  const dest = await fn({
@@ -196,7 +229,8 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
196
229
  return {
197
230
  id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
198
231
  prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
199
- icon: f.kind.mode === 'icon',
232
+ icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
233
+ derived: parentIdsOf(f).length > 0, stale: await isStale(kit, workspace, f),
200
234
  }
201
235
  }))
202
236
  return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
@@ -274,18 +308,44 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
274
308
  }
275
309
 
276
310
  if (req.method !== 'POST') return new Response('', { status: 404 })
311
+
312
+ if (pathname === '/api/upload') {
313
+ // multipart (not json) — handled before the json body() parse.
314
+ // Any kind accepts uploads (touched-up images welcome); manual
315
+ // kinds have no other roll source. Ext validated per kind media.
316
+ const fd = await req.formData().catch(() => null)
317
+ if (fd == null) return bad('multipart form expected (id + file)')
318
+ const f = findAsset(kit, String(fd.get('id') ?? ''))
319
+ if (f == null) return bad(`unknown asset '${String(fd.get('id') ?? '')}'`)
320
+ const file = fd.get('file')
321
+ if (!(file instanceof File)) return bad('file field missing')
322
+ const ext = (file.name.split('.').at(-1) ?? '').toLowerCase()
323
+ const allowed = MEDIA_EXTS[mediaOf(f.kind)]
324
+ if (!allowed.includes(ext)) return bad(`'${file.name}': .${ext} not allowed for ${mediaOf(f.kind)} (${allowed.join('/')})`)
325
+ // seed-style name like generated rolls — Date.now() collides for
326
+ // same-ms multi-uploads and silently overwrites
327
+ const name = `v${randomSeed()}.${ext}`
328
+ await Bun.write(join(dirOf(f.asset.id), name), await file.arrayBuffer())
329
+ return json({ ok: true, name })
330
+ }
331
+
277
332
  const b = await body()
278
333
 
279
334
  if (pathname === '/api/generate') {
280
335
  const f = findAsset(kit, String(b.id))
281
336
  if (f == null) return bad(`unknown asset '${String(b.id)}'`)
337
+ if (f.kind.generate.backend === 'manual') return bad(`'${f.asset.id}' is a manual asset — upload a file instead`)
282
338
  const count = Math.max(1, Math.min(24, Number(b.count) || 1))
283
339
  const prompt = typeof b.prompt === 'string' && b.prompt.trim() !== '' ? b.prompt : await promptOf(f)
284
340
  queue.enqueue([{ assetId: f.asset.id, prompt, count }])
285
341
  return json({ ok: true })
286
342
  }
287
343
  if (pathname === '/api/generate-cat') {
288
- const hit = allAssets(kit).filter((f) => f.kind.category === b.cat)
344
+ // manual kinds excluded from batch gen — nothing generates them;
345
+ // an all-manual cat gets a truthful pointer, not "no assets"
346
+ const inCat = allAssets(kit).filter((f) => f.kind.category === b.cat)
347
+ const hit = inCat.filter((f) => f.kind.generate.backend !== 'manual')
348
+ if (hit.length === 0 && inCat.length > 0) return bad(`cat '${String(b.cat)}' has only manual assets — upload in the studio`)
289
349
  if (hit.length === 0) return bad(`no assets in cat '${String(b.cat)}'`)
290
350
  const count = Math.max(1, Math.min(6, Number(b.count) || 1))
291
351
  queue.enqueue(await Promise.all(hit.map(async (f) => ({ assetId: f.asset.id, prompt: await promptOf(f), count }))))
@@ -319,6 +379,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
319
379
  s.pick = name
320
380
  s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
321
381
  })
382
+ void regenStaleChildren(f.asset.id).catch((e: Error) => queue.pushError(`auto-regen after '${f.asset.id}' pick: ${e.message}`))
322
383
  return json(res)
323
384
  } catch (e) {
324
385
  queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
@@ -382,6 +443,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
382
443
  const name = safeRoll(b.name)
383
444
  const f = findAsset(kit, String(b.id))
384
445
  if (name == null || f == null) return bad('bad id/name')
446
+ if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
385
447
  const current = await sessionRect(f, name)
386
448
  const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
387
449
  const res = await saveRect(f, name, flipped)
@@ -0,0 +1,156 @@
1
+ // The hub's React app (Bun.serve HTML import — no vite, no build step).
2
+ // Two views routed by pathname: / = attention dashboard (everything red),
3
+ // /status = STATUS.md + streamed check runs. The rail lists every mounted
4
+ // tool (from /hub.json) — legacy pages (graphs, studio) get the same rail
5
+ // injected server-side until they migrate.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import { observer } from 'mobx-react-lite'
8
+ import { StrictMode } from 'react'
9
+ import { createRoot } from 'react-dom/client'
10
+
11
+ type HubTool = { path: string; label: string; desc: string }
12
+ type AttentionLine = { text: string; href?: string }
13
+ type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
14
+
15
+ const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
16
+
17
+ class HubStore {
18
+ name = ''
19
+ tools: HubTool[] = []
20
+ sections: AttentionSection[] | null = null
21
+ statusMd: string | null = null
22
+ running = false
23
+ note = ''
24
+ error: string | null = null
25
+
26
+ constructor() {
27
+ makeAutoObservable(this)
28
+ }
29
+
30
+ get redCount(): number {
31
+ return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
32
+ }
33
+
34
+ async load(): Promise<void> {
35
+ try {
36
+ const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
37
+ const att = (await (await fetch('/api/attention.json')).json()) as { sections: AttentionSection[] }
38
+ runInAction(() => {
39
+ this.name = hub.name
40
+ this.tools = hub.tools
41
+ this.sections = att.sections
42
+ })
43
+ if (location.pathname === '/status') {
44
+ const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
45
+ runInAction(() => { this.statusMd = s.statusMd })
46
+ }
47
+ } catch (e) {
48
+ runInAction(() => { this.error = (e as Error).message })
49
+ }
50
+ }
51
+
52
+ async runCheck(): Promise<void> {
53
+ this.running = true
54
+ this.note = 'running…'
55
+ let text = ''
56
+ try {
57
+ const r = await fetch('/status/run', { method: 'POST' })
58
+ const rd = r.body!.getReader()
59
+ const dec = new TextDecoder()
60
+ for (;;) {
61
+ const { done, value } = await rd.read()
62
+ if (done) break
63
+ text += dec.decode(value, { stream: true })
64
+ runInAction(() => { this.statusMd = text })
65
+ }
66
+ runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
67
+ void this.load() // red counts moved — refresh the dashboard data
68
+ } catch (e) {
69
+ runInAction(() => {
70
+ this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
71
+ this.note = 'stream broke'
72
+ })
73
+ }
74
+ runInAction(() => { this.running = false })
75
+ }
76
+ }
77
+
78
+ const store = new HubStore()
79
+ void store.load()
80
+
81
+ const Rail = observer(() => {
82
+ const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...store.tools.filter((t) => t.path !== '/')]
83
+ return (
84
+ <nav className="skrail">
85
+ {items.map((t) => {
86
+ const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
87
+ return (
88
+ <a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
89
+ {RAIL_ICONS[t.path] ?? '·'}
90
+ {t.path === '/' && store.redCount > 0 ? <i /> : null}
91
+ </a>
92
+ )
93
+ })}
94
+ </nav>
95
+ )
96
+ })
97
+
98
+ const Attention = observer(() => {
99
+ const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
100
+ return (
101
+ <div className="wrap">
102
+ <div className="crumb">shipkit hub · attention</div>
103
+ <h1>{store.name}<small>everything that needs you, one page</small></h1>
104
+ {store.sections == null ? <div className="sub">loading…</div>
105
+ : live.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
106
+ : live.map((s) => (
107
+ <section key={s.title} className={s.tone}>
108
+ <h2>{s.href != null ? <a href={s.href}>{s.title}</a> : s.title}<em>{s.lines.length}</em></h2>
109
+ {s.lines.map((l, i) => (
110
+ <div key={i} className="line">{l.href != null ? <a href={l.href}>{l.text}</a> : l.text}</div>
111
+ ))}
112
+ </section>
113
+ ))}
114
+ <div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
115
+ </div>
116
+ )
117
+ })
118
+
119
+ const wireClass = (l: string): string => {
120
+ const t = l.trimStart()
121
+ if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
122
+ if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
123
+ if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
124
+ if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
125
+ return ''
126
+ }
127
+
128
+ const Status = observer(() => (
129
+ <div className="wrap">
130
+ <div className="crumb">shipkit hub · status</div>
131
+ <h1>{store.name}<small>STATUS.md</small></h1>
132
+ <div className="bar">
133
+ <button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
134
+ <span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
135
+ </div>
136
+ <pre>
137
+ {(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
138
+ .split('\n')
139
+ .map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
140
+ </pre>
141
+ </div>
142
+ ))
143
+
144
+ const App = observer(() => (
145
+ <div className="app">
146
+ <Rail />
147
+ {store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
148
+ : location.pathname === '/status' ? <Status /> : <Attention />}
149
+ </div>
150
+ ))
151
+
152
+ createRoot(document.getElementById('root')!).render(
153
+ <StrictMode>
154
+ <App />
155
+ </StrictMode>,
156
+ )
@@ -0,0 +1,41 @@
1
+ /* house palette — matches the studio/graph family. Plain CSS on purpose:
2
+ a published lib must not impose bunfig plugins (tailwind) on consumers. */
3
+ :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
4
+ --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
5
+ *{box-sizing:border-box}
6
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
7
+ .app{display:flex;min-height:100vh}
8
+ .wrap{max-width:900px;margin:0 auto;padding:34px 26px;flex:1}
9
+ h1{font-size:20px;margin:0}
10
+ h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
11
+ .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
12
+
13
+ .skrail{position:sticky;top:0;align-self:flex-start;height:100vh;width:46px;display:flex;flex-direction:column;
14
+ align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid var(--line);flex:none}
15
+ .skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
16
+ font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
17
+ .skrail a:hover{background:var(--panel2);filter:none;opacity:1}
18
+ .skrail a.on{background:var(--panel2);border-left-color:var(--accent);filter:none;opacity:1}
19
+ .skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:var(--red)}
20
+
21
+ .clear{margin-top:30px;font-size:17px;color:var(--teal)}
22
+ section{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:12px;
23
+ padding:14px 18px;margin-top:16px}
24
+ section.red{border-left-color:var(--red)}
25
+ h2{font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:8px}
26
+ h2 a{color:var(--ink);text-decoration:none}h2 a:hover{color:var(--gold)}
27
+ h2 em{font-style:normal;font-size:12px;color:var(--dim);background:var(--panel2);border-radius:8px;padding:1px 8px}
28
+ .line{font:12.5px/1.7 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
29
+ .line a{color:var(--dim);text-decoration:underline dotted}.line a:hover{color:var(--gold)}
30
+ section.red .line{color:#e8a0a0}
31
+ .sub{margin-top:10px;font-size:12px;color:var(--dim)}
32
+ .sub a{color:var(--dim)}
33
+
34
+ .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
35
+ button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
36
+ button:disabled{opacity:.45;cursor:default}
37
+ .note{font-size:12px;color:var(--dim)}
38
+ pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
39
+ font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
40
+ .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
41
+ .err{margin-top:26px;color:var(--red)}
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>shipkit hub</title>
7
+ <link rel="stylesheet" href="./hub.css" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./hub-app.tsx"></script>
12
+ </body>
13
+ </html>