rvlib-shipkit 0.5.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,29 @@
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
+
3
27
  ## 0.5.0 — 2026-07-22
4
28
 
5
29
  New (wave 2 of the shipkit expansion — generation UX):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.5.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",
@@ -24,6 +24,8 @@ export async function generateRoll(
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)
@@ -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
  })
@@ -48,6 +48,13 @@ 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
+ .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}
51
58
  .detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
52
59
  /* ── per-asset info surface (W2-2) ── */
53
60
  .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 +152,8 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
145
152
  <select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
146
153
  <button id=go disabled>Generate</button>
147
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">
148
157
  <button id=qclear class=ghost title="drop queued jobs">clear queue</button>
149
158
  <button id=qcancel class=ghost style="display:none" title="interrupt the current job (queued jobs stay)">stop current</button>
150
159
  </div>
@@ -223,11 +232,11 @@ function renderSide(list){
223
232
  const open=openCats.has(c)
224
233
  const head='<div class=gname><span><span class=fold onclick="toggleFold(\\''+jsq(c)+'\\')">'+(open?'▾':'▸')+'</span> '+
225
234
  '<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>'
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>'
227
236
  if(!open)return head
228
237
  return head+list.filter(a=>a.cat===c).map(a=>
229
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)+
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('')
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('')
231
240
  }).join('')
232
241
  }
233
242
  // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
@@ -265,18 +274,37 @@ async function selectAsset(id,writeHash=true){
265
274
  if(tab.indexOf('c:')===0&&!customs.some(c=>'c:'+c.id===tab))tab='asset' // new asset may not carry the open panel
266
275
  renderCustomTabs()
267
276
  $('#prompt').value=r.asset?r.asset.prompt:a.prompt
268
- $('#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')
269
283
  // deterministic canvas kinds: a slot machine with one outcome — cap rolls at 1
270
284
  const det=!!(r.info&&r.info.deterministic),n=$('#n')
271
- 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'
272
288
  renderInfo(r.info)
273
289
  await renderRolls(true);loadTab()
274
290
  }
291
+ const isAud=n=>['mp3','wav','ogg','m4a'].includes(String(n||'').split('.').pop())
275
292
  function setStage(src){
276
- const st=$('#stage');let img=st.querySelector('img.roll')
293
+ const st=$('#stage');st.querySelector('.stageaud')?.remove()
294
+ let img=st.querySelector('img.roll')
277
295
  if(!img){st.querySelector('.ph')?.remove();img=document.createElement('img');img.className='roll';st.appendChild(img);img.onload=positionOverlay}
278
296
  img.src=src
279
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))
280
308
  async function renderRolls(reset){
281
309
  if(!cur)return
282
310
  const r=await j('/api/asset?id='+encodeURIComponent(cur))
@@ -286,25 +314,28 @@ async function renderRolls(reset){
286
314
  if(!reset&&sig===lastRollSig)return; lastRollSig=sig
287
315
  const strip=$('#strip')
288
316
  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()
317
+ const gst=$('#stage');gst.querySelector('img.roll')?.remove();gst.querySelector('.stageaud')?.remove()
290
318
  if(!gst.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='generating…';gst.appendChild(ph)}
291
319
  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()
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()
294
323
  if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
295
324
  active=null;return}
296
325
  if(reset||!rolls.some(x=>x.name===active))active=(rolls.find(x=>x.picked)||rolls[0]).name
297
- setStage(imgUrl(cur,active))
326
+ showRoll(active)
298
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>'
299
329
  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('')
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('')
303
333
  +(genHere?'<div class="vig gen"><div class=spin></div>generating…</div>':'')
304
334
  strip.querySelectorAll('.vig[data-n]').forEach(v=>{
305
335
  const n=v.dataset.n
306
- v.onmouseenter=()=>setStage(imgUrl(cur,n))
307
- 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
308
339
  v.onclick=()=>{active=n;lastRollSig=null;renderRolls(false);if(tab!=='asset')loadTab()} // meta + custom panels are per-roll
309
340
  v.querySelector('.b-pick').onclick=e=>{e.stopPropagation();pickRoll(n)}
310
341
  v.querySelector('.b-alt').onclick=e=>{e.stopPropagation();post('/api/alt',{id:cur,name:n}).then(()=>renderRolls(false))}
@@ -321,10 +352,24 @@ async function discard(n){
321
352
  if(active===n)active=null
322
353
  lastRollSig=null;lastSideSig=null;await renderRolls(true)
323
354
  }
324
- 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()}
325
356
  $('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
326
357
  $('#qclear').onclick=()=>post('/api/queue/clear')
327
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)})
328
373
  $('#saveprompt').onclick=async()=>{
329
374
  const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
330
375
  $('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
@@ -492,9 +537,11 @@ async function fillCat(force){
492
537
  const fl=row.rect&&row.rect.flip
493
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)+
494
539
  ';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>'+
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>')+
496
543
  '<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)+'"'+
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)+'"'+
498
545
  ' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
499
546
  '<button class=ghost onclick="toggleFlip(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\')">⇆ flip</button></div>'))+'</div>'
500
547
  }).join('')
@@ -4,9 +4,9 @@
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
11
  import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
12
12
  import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
@@ -28,7 +28,7 @@ export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue;
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) */
@@ -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,7 +206,7 @@ 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
212
  return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
@@ -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 }))))
@@ -382,6 +418,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
382
418
  const name = safeRoll(b.name)
383
419
  const f = findAsset(kit, String(b.id))
384
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`)
385
422
  const current = await sessionRect(f, name)
386
423
  const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
387
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'