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 +24 -0
- package/package.json +1 -1
- package/src/assets/gen/generate-roll.ts +2 -0
- package/src/assets/kit-config.ts +23 -2
- package/src/assets/roll-workspace.ts +3 -1
- package/src/assets/run-assets.ts +19 -3
- package/src/assets/server/studio-page.ts +64 -17
- package/src/assets/server/studio-server.ts +43 -6
- package/src/shipkit.ts +3 -3
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.
|
|
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)
|
package/src/assets/kit-config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
$('#
|
|
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'
|
|
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');
|
|
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){
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
307
|
-
v.
|
|
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
|
|
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)
|
|
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
|
-
|
|
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'
|