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.
- package/CHANGELOG.md +58 -1
- package/package.json +3 -2
- package/src/assets/gen/derived.ts +83 -0
- package/src/assets/gen/gemini-gen.ts +77 -0
- package/src/assets/gen/generate-roll.ts +14 -1
- package/src/assets/kit-config.ts +47 -3
- package/src/assets/roll-workspace.ts +3 -1
- package/src/assets/run-assets.ts +84 -27
- package/src/assets/server/studio-page.ts +66 -18
- package/src/assets/server/studio-server.ts +73 -11
- package/src/serve/hub-app/hub-app.tsx +156 -0
- package/src/serve/hub-app/hub.css +41 -0
- package/src/serve/hub-app/index.html +13 -0
- package/src/serve/hub-pages.ts +39 -78
- package/src/serve/run-serve.ts +83 -4
- package/src/serve/serve-hub.ts +30 -9
- package/src/shipkit.ts +5 -4
|
@@ -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
|
|
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
|
-
$('#
|
|
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'
|
|
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');
|
|
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){
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
307
|
-
v.
|
|
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
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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>
|