rvlib-shipkit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/package.json +1 -1
- package/src/assets/gen/comfy-gen.ts +55 -4
- package/src/assets/gen/generate-roll.ts +5 -3
- 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 +89 -23
- package/src/assets/server/studio-queue.ts +44 -5
- package/src/assets/server/studio-server.ts +63 -13
- package/src/shipkit.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-07-22
|
|
4
|
+
|
|
5
|
+
New (wave 3 of the shipkit expansion — media + manual assets):
|
|
6
|
+
|
|
7
|
+
- `manual()` generator backend — human-provided assets, any media: no
|
|
8
|
+
Generate button (POST /api/generate 400s; fill/add skip with a visible
|
|
9
|
+
line; generate-cat excludes them and an all-manual category 400s with an
|
|
10
|
+
upload pointer — its +N buttons are hidden in the SPA), the studio's
|
|
11
|
+
upload (button or drag-drop on the roll strip, POST /api/upload multipart,
|
|
12
|
+
ext-gated per media, seed-style `v<seed>` names — no same-ms overwrite) is
|
|
13
|
+
the only roll source. Upload itself works on EVERY kind (touched-up
|
|
14
|
+
images welcome). A manual asset with NO rolls is RED in the sidebar and
|
|
15
|
+
FAILs `shipkit assets check` with its curation brief; rolls-but-no-pick
|
|
16
|
+
WARNs. The asset's `prompt` is the brief.
|
|
17
|
+
- `ship.dir` is now honored lib-side: with no via/fn hook, picking copies
|
|
18
|
+
the roll to `<dir>/<assetId>.<ext>` (zero-config ship); hooks still win
|
|
19
|
+
and read `dir` as data. Previously `dir` was inert without a repo hook.
|
|
20
|
+
- `media: 'audio'` on kinds — rolls `v<n>.mp3|wav|ogg|m4a`, studio renders
|
|
21
|
+
players (roll strip, stage, grid card) instead of thumbnails; audio kinds
|
|
22
|
+
require `manual()` (generation backends are image-only today) and reject
|
|
23
|
+
geometry panels, both validated loud at defineKit. `isAudioRoll` exported.
|
|
24
|
+
- barrel: `manual`, `mediaOf`, `MEDIA_EXTS`, `isAudioRoll` + types
|
|
25
|
+
`Media`/`ManualGen` exported.
|
|
26
|
+
|
|
27
|
+
## 0.5.0 — 2026-07-22
|
|
28
|
+
|
|
29
|
+
New (wave 2 of the shipkit expansion — generation UX):
|
|
30
|
+
|
|
31
|
+
- live per-step progress: the comfy engine subscribes ComfyUI's native
|
|
32
|
+
`/ws?clientId=` (same clientId as the `/prompt` body) and streams
|
|
33
|
+
`executing {node}` + `progress {value,max}` into the studio; ADDITIVE by
|
|
34
|
+
contract — a dead socket degrades to no-progress, completion truth stays
|
|
35
|
+
the `/history` poll. (comfy-code evaluated and rejected: opposite graph
|
|
36
|
+
model, ~87 dl/mo solo dep; native /ws does the job in-doctrine.)
|
|
37
|
+
- studio spinners: every queued/generating asset shows a spinner placeholder
|
|
38
|
+
(sidebar row, category-grid card, roll strip) driven by the new
|
|
39
|
+
`pending: string[]` in `/api/state`; header gains a live progress bar
|
|
40
|
+
(`step v/max`).
|
|
41
|
+
- cancel: `POST /api/interrupt` (studio "stop current") — comfy `/interrupt`
|
|
42
|
+
on the box (only when the RUNNING job is a comfy kind — the box is shared)
|
|
43
|
+
+ the current job's remaining rolls dropped (logged `ok: cancelled …`,
|
|
44
|
+
never an error); queued jobs stay (`clear queue` still drops those). New
|
|
45
|
+
engine wrapper `interrupt()` exported.
|
|
46
|
+
|
|
3
47
|
## 0.4.0 — 2026-07-22
|
|
4
48
|
|
|
5
49
|
New (wave 1 of the shipkit expansion — uniform repo setup):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,16 +34,56 @@ export function applyInputs(graph: Graph, sk: Sidekick, args: { prompt: string;
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export async function submit(o: ComfyOpts, graph: Graph): Promise<string> {
|
|
37
|
+
export async function submit(o: ComfyOpts, graph: Graph, clientId?: string): Promise<string> {
|
|
38
38
|
const res = await fetch(`${o.host}/prompt`, {
|
|
39
39
|
method: 'POST',
|
|
40
40
|
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
body: JSON.stringify({ prompt: graph, client_id: crypto.randomUUID() }),
|
|
41
|
+
body: JSON.stringify({ prompt: graph, client_id: clientId ?? crypto.randomUUID() }),
|
|
42
42
|
})
|
|
43
43
|
if (!res.ok) throw new Error(`POST /prompt → ${res.status}: ${(await res.text()).slice(0, 300)} — bad graph or server mid-boot${healHint(o)}`)
|
|
44
44
|
return ((await res.json()) as { prompt_id: string }).prompt_id
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ── live progress over ComfyUI's native /ws ────────────────────────────────
|
|
48
|
+
// ADDITIVE by contract: a dead socket degrades to no-progress, completion
|
|
49
|
+
// truth stays the /history poll. The clientId MUST match the /prompt body
|
|
50
|
+
// or the server sends nothing.
|
|
51
|
+
export type GenProgress = { value: number; max: number; node: string | null }
|
|
52
|
+
export type OnProgress = (p: GenProgress) => void
|
|
53
|
+
|
|
54
|
+
const wsWarned = new Set<string>() // one WARN per host per process, not per roll
|
|
55
|
+
export function subscribeProgress(o: ComfyOpts, clientId: string, on: OnProgress): { close: () => void } {
|
|
56
|
+
const url = `${o.host.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
|
|
57
|
+
const warnOnce = (detail: string): void => {
|
|
58
|
+
if (wsWarned.has(o.host)) return
|
|
59
|
+
wsWarned.add(o.host)
|
|
60
|
+
console.log(`WARN comfy /ws unavailable — per-step progress disabled${detail}`)
|
|
61
|
+
}
|
|
62
|
+
let node: string | null = null
|
|
63
|
+
let ws: WebSocket | null = null
|
|
64
|
+
try {
|
|
65
|
+
ws = new WebSocket(url)
|
|
66
|
+
ws.onmessage = (ev) => {
|
|
67
|
+
if (typeof ev.data !== 'string') return // binary preview frames — not consumed
|
|
68
|
+
let msg: { type?: string; data?: { value?: number; max?: number; node?: string | null } }
|
|
69
|
+
try { msg = JSON.parse(ev.data) as typeof msg } catch { return }
|
|
70
|
+
if (msg.type === 'executing') { node = msg.data?.node ?? null; on({ value: 0, max: 0, node }) }
|
|
71
|
+
else if (msg.type === 'progress') on({ value: msg.data?.value ?? 0, max: msg.data?.max ?? 0, node })
|
|
72
|
+
}
|
|
73
|
+
// progress is a bonus — a refused/dropped ws warns once and stays quiet
|
|
74
|
+
ws.onerror = () => warnOnce('')
|
|
75
|
+
} catch (e) {
|
|
76
|
+
warnOnce(` (${(e as Error).message})`)
|
|
77
|
+
}
|
|
78
|
+
return { close: () => { try { ws?.close() } catch {} } }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cancel the RUNNING prompt (queued ones are untouched). */
|
|
82
|
+
export async function interrupt(o: ComfyOpts): Promise<void> {
|
|
83
|
+
const res = await fetch(`${o.host}/interrupt`, { method: 'POST' })
|
|
84
|
+
if (!res.ok) throw new Error(`POST /interrupt → ${res.status}${healHint(o)}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
47
87
|
type HistoryOutputs = Record<string, { images?: { filename: string; subfolder?: string; type?: string }[] }>
|
|
48
88
|
|
|
49
89
|
export async function waitFor(o: ComfyOpts, promptId: string): Promise<HistoryOutputs> {
|
|
@@ -76,11 +116,22 @@ async function fetchImage(o: ComfyOpts, img: { filename: string; subfolder?: str
|
|
|
76
116
|
* bindings applied, submitted, waited, non-preview outputs saved as
|
|
77
117
|
* <destDir>/<name>.<ext> (ONE clean file per asset — no _00001_ counter noise).
|
|
78
118
|
*/
|
|
79
|
-
export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string }): Promise<string[]> {
|
|
119
|
+
export async function generate(o: ComfyOpts, wf: Workflow, args: { prompt: string; seed: number; destDir: string; name: string; onProgress?: OnProgress }): Promise<string[]> {
|
|
80
120
|
const graph = structuredClone(wf.graph)
|
|
81
121
|
applyInputs(graph, wf.sidekick, { prompt: args.prompt, seed: args.seed, prefix: args.name })
|
|
82
122
|
mkdirSync(args.destDir, { recursive: true })
|
|
83
|
-
|
|
123
|
+
let outputs: HistoryOutputs
|
|
124
|
+
if (args.onProgress != null) {
|
|
125
|
+
const clientId = crypto.randomUUID()
|
|
126
|
+
const sub = subscribeProgress(o, clientId, args.onProgress)
|
|
127
|
+
try {
|
|
128
|
+
outputs = await waitFor(o, await submit(o, graph, clientId))
|
|
129
|
+
} finally {
|
|
130
|
+
sub.close()
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
outputs = await waitFor(o, await submit(o, graph))
|
|
134
|
+
}
|
|
84
135
|
const saved: string[] = []
|
|
85
136
|
for (const node of Object.values(outputs)) {
|
|
86
137
|
for (const img of node.images ?? []) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { mkdirSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import type { FoundAsset } from '../kit-config.ts'
|
|
10
|
-
import { generate } from './comfy-gen.ts'
|
|
10
|
+
import { generate, type OnProgress } from './comfy-gen.ts'
|
|
11
11
|
import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
|
|
12
12
|
|
|
13
13
|
/** roll canvas master size (square) — favicon/logo/sprites derive from it */
|
|
@@ -20,10 +20,12 @@ export type ComfyResolver = () => Promise<ComfyRuntime>
|
|
|
20
20
|
* v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
|
|
21
21
|
export async function generateRoll(
|
|
22
22
|
found: FoundAsset,
|
|
23
|
-
args: { prompt: string; seed: number; destDir: string },
|
|
23
|
+
args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress },
|
|
24
24
|
resolveComfy: ComfyResolver,
|
|
25
25
|
): Promise<void> {
|
|
26
26
|
const gen = found.kind.generate
|
|
27
|
+
if (gen.backend === 'manual')
|
|
28
|
+
throw new Error(`'${found.asset.id}' is a manual asset — upload a file in the studio (button or drag-drop), nothing generates it`)
|
|
27
29
|
if (gen.backend === 'canvas') {
|
|
28
30
|
const { createCanvas } = await import('@napi-rs/canvas')
|
|
29
31
|
const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
|
|
@@ -34,7 +36,7 @@ export async function generateRoll(
|
|
|
34
36
|
}
|
|
35
37
|
const rt = await resolveComfy()
|
|
36
38
|
const wf = workflowFor(rt.workflows, { workflow: gen.workflow, assetId: found.asset.id })
|
|
37
|
-
await generate({ host: rt.host, heal: rt.heal }, wf, { prompt: args.prompt, seed: args.seed, destDir: args.destDir, name: `v${args.seed}
|
|
39
|
+
await generate({ host: rt.host, heal: rt.heal }, wf, { prompt: args.prompt, seed: args.seed, destDir: args.destDir, name: `v${args.seed}`, onProgress: args.onProgress })
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** Memoized lazy comfy runtime for a resolved kit — loads the workflow pairs on
|
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
|
})
|
|
@@ -42,6 +42,19 @@ button.ghost{background:var(--panel2);color:var(--ink);border:1px solid var(--li
|
|
|
42
42
|
button:disabled{opacity:.45;cursor:default}
|
|
43
43
|
select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px}
|
|
44
44
|
.status{font-size:12px;color:var(--dim);min-height:16px}
|
|
45
|
+
.spin{width:12px;height:12px;border:2px solid var(--line);border-top-color:var(--gold);border-radius:50%;animation:rot 1s linear infinite;flex:none}
|
|
46
|
+
@keyframes rot{to{transform:rotate(360deg)}}
|
|
47
|
+
.progbar{width:120px;height:4px;background:var(--panel2);border-radius:2px;overflow:hidden;display:none}
|
|
48
|
+
.progbar i{display:block;height:100%;width:0;background:var(--gold);transition:width .3s}
|
|
49
|
+
.vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
|
|
50
|
+
.card .th .spin{position:absolute;top:8px;right:8px}
|
|
51
|
+
.dot.miss{background:#e5484d;border-color:#e5484d}
|
|
52
|
+
.vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
|
|
53
|
+
.vig.aud audio{width:100%;height:24px}
|
|
54
|
+
.stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
|
|
55
|
+
.stageaud audio{width:min(480px,80%)}
|
|
56
|
+
.card .aud{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;font-size:28px}
|
|
57
|
+
.card .aud audio{width:92%;height:24px}
|
|
45
58
|
.detnote{font-size:11px;color:var(--teal);line-height:1.25;max-width:150px}
|
|
46
59
|
/* ── per-asset info surface (W2-2) ── */
|
|
47
60
|
.info{display:none;flex-wrap:wrap;gap:6px;align-items:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel2)}
|
|
@@ -139,14 +152,17 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
|
|
|
139
152
|
<select id=n><option>1</option><option selected>3</option><option>6</option><option>9</option><option>12</option></select>
|
|
140
153
|
<button id=go disabled>Generate</button>
|
|
141
154
|
<div id=detnote class=detnote style="display:none">deterministic — rolls identical</div>
|
|
155
|
+
<button id=upload class=ghost style="display:none" title="add files as rolls (or drag-drop onto the strip)">upload</button>
|
|
156
|
+
<input type=file id=upfile multiple style="display:none">
|
|
142
157
|
<button id=qclear class=ghost title="drop queued jobs">clear queue</button>
|
|
158
|
+
<button id=qcancel class=ghost style="display:none" title="interrupt the current job (queued jobs stay)">stop current</button>
|
|
143
159
|
</div>
|
|
144
160
|
<div class=promptwrap>
|
|
145
161
|
<textarea id=prompt placeholder="select an asset ←"></textarea>
|
|
146
162
|
<div class=jrow><button id=saveprompt class=ghost disabled>save prompt → overlay</button>
|
|
147
163
|
<button id=copyprompt class=ghost>copy</button><span class=jmsg id=promptmsg></span></div>
|
|
148
164
|
</div>
|
|
149
|
-
<div class=ctl><div class=status id=status></div></div>
|
|
165
|
+
<div class=ctl><div class=status id=status></div><div class=progbar id=prog><i></i></div></div>
|
|
150
166
|
</div>
|
|
151
167
|
<div class=info id=info></div>
|
|
152
168
|
<div class=stage id=stage><div class=ph>Pick an asset on the left, then Generate.</div>
|
|
@@ -184,7 +200,7 @@ kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padd
|
|
|
184
200
|
</main>
|
|
185
201
|
<script>
|
|
186
202
|
const $=s=>document.querySelector(s)
|
|
187
|
-
let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set()
|
|
203
|
+
let assets=[],cur=null,rolls=[],active=null,tab='asset',openCats=new Set(),pending=[]
|
|
188
204
|
let customs=[],panelMod=null,panelKey=null // repo-defined editor panels (custom tabs)
|
|
189
205
|
let lastSideSig=null,lastRollSig=null,catMode=null,lastCatSig=null,catRows=[]
|
|
190
206
|
let rect=null,rectRatio=0.6,hasGeom=false,saveTimer=null
|
|
@@ -209,18 +225,18 @@ window.addEventListener('hashchange',restoreFromHash)
|
|
|
209
225
|
function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
|
|
210
226
|
function renderSide(list){
|
|
211
227
|
assets=list;$('#count').textContent=list.length
|
|
212
|
-
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')
|
|
228
|
+
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
|
|
213
229
|
if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
|
|
214
230
|
const cats=[...new Set(list.map(a=>a.cat))]
|
|
215
231
|
$('#list').innerHTML=cats.map(c=>{
|
|
216
232
|
const open=openCats.has(c)
|
|
217
233
|
const head='<div class=gname><span><span class=fold onclick="toggleFold(\\''+jsq(c)+'\\')">'+(open?'▾':'▸')+'</span> '+
|
|
218
234
|
'<span class=cn onclick="showCat(\\''+jsq(c)+'\\')" title="grid view">'+esc(c)+'</span>'+
|
|
219
|
-
'</span
|
|
235
|
+
'</span>'+(list.filter(a=>a.cat===c).every(a=>a.manual)?'':'<span class=catgen>'+[1,2,3].map(n=>'<button onclick="genCat(\\''+jsq(c)+'\\','+n+')">+'+n+'</button>').join('')+'</span>')+'</div>'
|
|
220
236
|
if(!open)return head
|
|
221
237
|
return head+list.filter(a=>a.cat===c).map(a=>
|
|
222
238
|
'<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
|
|
223
|
-
'</b><span>'+a.rolls+' rolls</span></div
|
|
239
|
+
'</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':(a.manual?'miss':'')))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
|
|
224
240
|
}).join('')
|
|
225
241
|
}
|
|
226
242
|
// ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
|
|
@@ -258,40 +274,68 @@ async function selectAsset(id,writeHash=true){
|
|
|
258
274
|
if(tab.indexOf('c:')===0&&!customs.some(c=>'c:'+c.id===tab))tab='asset' // new asset may not carry the open panel
|
|
259
275
|
renderCustomTabs()
|
|
260
276
|
$('#prompt').value=r.asset?r.asset.prompt:a.prompt
|
|
261
|
-
$('#
|
|
277
|
+
$('#saveprompt').disabled=false;$('#promptmsg').textContent=''
|
|
278
|
+
// manual kinds: nothing generates them — upload is the only roll source
|
|
279
|
+
const man=!!a.manual
|
|
280
|
+
$('#go').disabled=man;$('#go').style.display=man?'none':''
|
|
281
|
+
$('#upload').style.display=''
|
|
282
|
+
$('#upfile').accept=(a.media==='audio'?'.mp3,.wav,.ogg,.m4a':'.png,.webp')
|
|
262
283
|
// deterministic canvas kinds: a slot machine with one outcome — cap rolls at 1
|
|
263
284
|
const det=!!(r.info&&r.info.deterministic),n=$('#n')
|
|
264
|
-
n.disabled=det;if(det)n.value='1'
|
|
285
|
+
n.disabled=det||man;if(det)n.value='1';n.style.display=man?'none':''
|
|
286
|
+
$('#detnote').textContent=man?'manual — upload files (drag-drop works)':'deterministic — rolls identical'
|
|
287
|
+
$('#detnote').style.display=(det||man)?'':'none'
|
|
265
288
|
renderInfo(r.info)
|
|
266
289
|
await renderRolls(true);loadTab()
|
|
267
290
|
}
|
|
291
|
+
const isAud=n=>['mp3','wav','ogg','m4a'].includes(String(n||'').split('.').pop())
|
|
268
292
|
function setStage(src){
|
|
269
|
-
const st=$('#stage');
|
|
293
|
+
const st=$('#stage');st.querySelector('.stageaud')?.remove()
|
|
294
|
+
let img=st.querySelector('img.roll')
|
|
270
295
|
if(!img){st.querySelector('.ph')?.remove();img=document.createElement('img');img.className='roll';st.appendChild(img);img.onload=positionOverlay}
|
|
271
296
|
img.src=src
|
|
272
297
|
}
|
|
298
|
+
function setStageAud(n){
|
|
299
|
+
const st=$('#stage')
|
|
300
|
+
const curAud=st.querySelector('.stageaud')
|
|
301
|
+
if(curAud&&curAud.dataset.n===n)return // keep playback alive across heartbeats
|
|
302
|
+
st.querySelector('img.roll')?.remove();st.querySelector('.ph')?.remove();curAud?.remove()
|
|
303
|
+
const d=document.createElement('div');d.className='stageaud';d.dataset.n=n
|
|
304
|
+
d.innerHTML='🔊<audio controls src="'+imgUrl(cur,n)+'"></audio>'
|
|
305
|
+
st.appendChild(d);hideOverlay()
|
|
306
|
+
}
|
|
307
|
+
const showRoll=n=>isAud(n)?setStageAud(n):setStage(imgUrl(cur,n))
|
|
273
308
|
async function renderRolls(reset){
|
|
274
309
|
if(!cur)return
|
|
275
310
|
const r=await j('/api/asset?id='+encodeURIComponent(cur))
|
|
276
311
|
rolls=r.rolls
|
|
277
|
-
const
|
|
312
|
+
const genHere=pending.includes(cur)
|
|
313
|
+
const sig=cur+'|'+rolls.map(x=>x.name+(x.picked?'*':'')+(x.alt?'~':'')).join(',')+'|'+genHere
|
|
278
314
|
if(!reset&&sig===lastRollSig)return; lastRollSig=sig
|
|
279
315
|
const strip=$('#strip')
|
|
280
|
-
if(!rolls.length){strip.innerHTML='<div class=
|
|
281
|
-
const
|
|
316
|
+
if(!rolls.length&&genHere){strip.innerHTML='<div class="vig gen"><div class=spin></div>generating…</div>';hideOverlay()
|
|
317
|
+
const gst=$('#stage');gst.querySelector('img.roll')?.remove();gst.querySelector('.stageaud')?.remove()
|
|
318
|
+
if(!gst.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='generating…';gst.appendChild(ph)}
|
|
319
|
+
active=null;return}
|
|
320
|
+
if(!rolls.length){const a0=assets.find(x=>x.id===cur)
|
|
321
|
+
strip.innerHTML='<div class=ph>'+(a0&&a0.manual?'no files — upload (or drag-drop here)':'no rolls — hit Generate')+'</div>';hideOverlay()
|
|
322
|
+
const st=$('#stage');st.querySelector('img.roll')?.remove();st.querySelector('.stageaud')?.remove()
|
|
282
323
|
if(!st.querySelector('.ph')){const ph=document.createElement('div');ph.className='ph';ph.textContent='no rolls yet';st.appendChild(ph)}
|
|
283
324
|
active=null;return}
|
|
284
325
|
if(reset||!rolls.some(x=>x.name===active))active=(rolls.find(x=>x.picked)||rolls[0]).name
|
|
285
|
-
|
|
326
|
+
showRoll(active)
|
|
286
327
|
if(hasGeom)await loadRect()
|
|
328
|
+
const btns='<div class=btns><button class=b-pick>pick</button><button class=b-alt>alt</button><button class=b-del>✕</button></div>'
|
|
287
329
|
strip.innerHTML=rolls.map(x=>
|
|
288
|
-
'<div class="vig'+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
|
|
289
|
-
'<img src="'+imgUrl(cur,x.name)+'">'+(x.picked?'<span class=st>★</span>':'')+
|
|
290
|
-
'
|
|
291
|
-
|
|
330
|
+
'<div class="vig'+(isAud(x.name)?' aud':'')+(x.name===active?' active':'')+(x.picked?' picked':'')+(x.alt?' alt':'')+'" data-n="'+x.name+'">'+
|
|
331
|
+
(isAud(x.name)?'🔊<audio controls src="'+imgUrl(cur,x.name)+'"></audio>':'<img src="'+imgUrl(cur,x.name)+'">')+(x.picked?'<span class=st>★</span>':'')+
|
|
332
|
+
btns+'</div>').join('')
|
|
333
|
+
+(genHere?'<div class="vig gen"><div class=spin></div>generating…</div>':'')
|
|
334
|
+
strip.querySelectorAll('.vig[data-n]').forEach(v=>{
|
|
292
335
|
const n=v.dataset.n
|
|
293
|
-
|
|
294
|
-
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
|
|
295
339
|
v.onclick=()=>{active=n;lastRollSig=null;renderRolls(false);if(tab!=='asset')loadTab()} // meta + custom panels are per-roll
|
|
296
340
|
v.querySelector('.b-pick').onclick=e=>{e.stopPropagation();pickRoll(n)}
|
|
297
341
|
v.querySelector('.b-alt').onclick=e=>{e.stopPropagation();post('/api/alt',{id:cur,name:n}).then(()=>renderRolls(false))}
|
|
@@ -308,9 +352,24 @@ async function discard(n){
|
|
|
308
352
|
if(active===n)active=null
|
|
309
353
|
lastRollSig=null;lastSideSig=null;await renderRolls(true)
|
|
310
354
|
}
|
|
311
|
-
async function genCat(c,n){await post('/api/generate-cat',{cat:c,count:n});heartbeat()}
|
|
355
|
+
async function genCat(c,n){const r=await post('/api/generate-cat',{cat:c,count:n});if(r&&r.error)$('#status').textContent=r.error;heartbeat()}
|
|
312
356
|
$('#go').onclick=async()=>{if(!cur)return;await post('/api/generate',{id:cur,prompt:$('#prompt').value,count:+$('#n').value});heartbeat()}
|
|
313
357
|
$('#qclear').onclick=()=>post('/api/queue/clear')
|
|
358
|
+
$('#qcancel').onclick=()=>post('/api/interrupt')
|
|
359
|
+
// ── upload: button, file input, drag-drop on the strip (manual kinds' only roll source) ──
|
|
360
|
+
async function uploadFiles(files){
|
|
361
|
+
if(!cur||!files||!files.length)return
|
|
362
|
+
for(const f of files){
|
|
363
|
+
const fd=new FormData();fd.append('id',cur);fd.append('file',f)
|
|
364
|
+
const r=await fetch(R('/api/upload'),{method:'POST',body:fd}).then(x=>x.json()).catch(e=>({error:String(e)}))
|
|
365
|
+
if(r.error)$('#status').textContent='upload FAIL: '+r.error
|
|
366
|
+
}
|
|
367
|
+
lastRollSig=null;lastSideSig=null;await renderRolls(false)
|
|
368
|
+
}
|
|
369
|
+
$('#upload').onclick=()=>$('#upfile').click()
|
|
370
|
+
$('#upfile').onchange=async e=>{await uploadFiles(e.target.files);e.target.value=''}
|
|
371
|
+
$('#strip').addEventListener('dragover',e=>e.preventDefault())
|
|
372
|
+
$('#strip').addEventListener('drop',e=>{e.preventDefault();uploadFiles(e.dataTransfer.files)})
|
|
314
373
|
$('#saveprompt').onclick=async()=>{
|
|
315
374
|
const r=await post('/api/prompt',{id:cur,prompt:$('#prompt').value})
|
|
316
375
|
$('#promptmsg').textContent=r.error?('FAIL: '+r.error):'saved (overlay)'
|
|
@@ -470,7 +529,7 @@ async function fillCat(force){
|
|
|
470
529
|
const r=await j('/api/catrects?cat='+encodeURIComponent(mode))
|
|
471
530
|
if(catMode!==mode)return // user navigated away mid-fetch
|
|
472
531
|
catRows=r.rows||[]
|
|
473
|
-
const sig=JSON.stringify(catRows)
|
|
532
|
+
const sig=JSON.stringify(catRows)+'|'+pending.join(',')
|
|
474
533
|
if(!force&&sig===lastCatSig)return; lastCatSig=sig
|
|
475
534
|
const scroll=$('#catgrid').scrollTop
|
|
476
535
|
const pc=v=>(v/(TEX/100))+'%'
|
|
@@ -478,9 +537,11 @@ async function fillCat(force){
|
|
|
478
537
|
const fl=row.rect&&row.rect.flip
|
|
479
538
|
const ovm=row.rect?('<div class=ovmini style="left:'+pc(fl?TEX-row.rect.x-row.rect.w:row.rect.x)+';top:'+pc(row.rect.y)+
|
|
480
539
|
';width:'+pc(row.rect.w)+';height:'+pc(row.rect.h)+'"></div>'):''
|
|
481
|
-
return '<div class=card
|
|
540
|
+
return '<div class=card>'+(isAud(row.name)
|
|
541
|
+
?'<div class=aud>🔊<audio controls src="'+imgUrl(row.id,row.name)+'"></audio>'+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>'
|
|
542
|
+
:'<div class=th><img src="'+thumbUrl(row.id)+'"'+(fl?' style="transform:scaleX(-1)"':'')+'>'+ovm+(pending.includes(row.id)?'<div class=spin></div>':'')+'</div>')+
|
|
482
543
|
'<div class=nm onclick="selectAsset(\\''+jsq(row.id)+'\\')">'+esc(row.label)+'</div>'+
|
|
483
|
-
(row.icon?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
|
|
544
|
+
((row.icon||isAud(row.name))?'':('<div class=row><label>size <input type=number step=0.1 min=0.1 value="'+(row.rect?row.rect.size:1)+'"'+
|
|
484
545
|
' onchange="setSize(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\',this.value)"></label>'+
|
|
485
546
|
'<button class=ghost onclick="toggleFlip(\\''+jsq(row.id)+'\\',\\''+jsq(row.name)+'\\')">⇆ flip</button></div>'))+'</div>'
|
|
486
547
|
}).join('')
|
|
@@ -608,8 +669,13 @@ document.addEventListener('keydown',e=>{
|
|
|
608
669
|
let firstBeat=true
|
|
609
670
|
async function heartbeat(){
|
|
610
671
|
const s=await j('/api/state')
|
|
672
|
+
pending=s.pending||[]
|
|
611
673
|
renderSide(s.assets)
|
|
612
|
-
|
|
674
|
+
const p=s.progress
|
|
675
|
+
$('#status').textContent=s.generating?('generating '+(s.current||'…')+' '+s.done+'/'+s.total+(s.queued?(' · '+s.queued+' queued'):'')+(p&&p.max?(' · step '+p.value+'/'+p.max):'')):''
|
|
676
|
+
$('#qcancel').style.display=s.generating?'':'none'
|
|
677
|
+
const pb=$('#prog');pb.style.display=(s.generating&&p&&p.max)?'block':'none'
|
|
678
|
+
if(p&&p.max)pb.firstElementChild.style.width=Math.round(100*p.value/p.max)+'%'
|
|
613
679
|
const e=$('#errs')
|
|
614
680
|
if(s.errors.length){e.style.display='block';e.textContent=s.errors.join('\\n')}else e.style.display='none'
|
|
615
681
|
if(catMode)await fillCat(false)
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// Generation queue: single worker, IN-MEMORY (never run the studio with
|
|
2
2
|
// --watch — a restart drops every queued roll; the fill CLI is the recovery
|
|
3
|
-
// path), errors surfaced in a ring buffer, never fatal.
|
|
3
|
+
// path), errors surfaced in a ring buffer, never fatal. Per-step progress
|
|
4
|
+
// (comfy /ws) lives on state; cancel drops the CURRENT job's remaining
|
|
5
|
+
// rolls — queued jobs are `clear()`'s business.
|
|
6
|
+
import type { GenProgress } from '../gen/comfy-gen.ts'
|
|
7
|
+
|
|
4
8
|
export type Job = { assetId: string; prompt: string; count: number }
|
|
5
|
-
export type QueueState = { generating: boolean; current: string | null; done: number; total: number }
|
|
9
|
+
export type QueueState = { generating: boolean; current: string | null; done: number; total: number; progress: GenProgress | null }
|
|
6
10
|
|
|
7
11
|
export class StudioQueue {
|
|
8
12
|
private jobs: Job[] = []
|
|
9
|
-
|
|
13
|
+
private cancelRequested = false
|
|
14
|
+
readonly state: QueueState = { generating: false, current: null, done: 0, total: 0, progress: null }
|
|
10
15
|
readonly errors: string[] = []
|
|
11
16
|
|
|
12
17
|
/** generateOne throws on failure — the queue logs and moves on */
|
|
13
|
-
constructor(
|
|
18
|
+
constructor(
|
|
19
|
+
private generateOne: (job: Job, seed: number, onProgress: (p: GenProgress) => void) => Promise<void>,
|
|
20
|
+
private newSeed: () => number,
|
|
21
|
+
) {}
|
|
14
22
|
|
|
15
23
|
pushError(msg: string): void {
|
|
16
24
|
this.errors.push(msg)
|
|
@@ -20,6 +28,20 @@ export class StudioQueue {
|
|
|
20
28
|
|
|
21
29
|
get queuedCount(): number { return this.jobs.length }
|
|
22
30
|
|
|
31
|
+
/** asset ids with work still ahead of them (current + queued) — the SPA's spinner set */
|
|
32
|
+
pendingIds(): string[] {
|
|
33
|
+
const ids = this.state.generating && this.state.current != null ? [this.state.current] : []
|
|
34
|
+
for (const j of this.jobs) if (!ids.includes(j.assetId)) ids.push(j.assetId)
|
|
35
|
+
return ids
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** cancel the CURRENT job: the caller interrupts the backend, this drops
|
|
39
|
+
* the job's remaining rolls (an interrupted roll's throw is logged as a
|
|
40
|
+
* cancel, never an error). No-op when idle. */
|
|
41
|
+
requestCancel(): void {
|
|
42
|
+
if (this.state.generating) this.cancelRequested = true
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
clear(): void { this.jobs.length = 0 }
|
|
24
46
|
|
|
25
47
|
enqueue(newJobs: Job[]): void {
|
|
@@ -41,18 +63,35 @@ export class StudioQueue {
|
|
|
41
63
|
if (job == null) {
|
|
42
64
|
this.state.generating = false
|
|
43
65
|
this.state.current = null
|
|
66
|
+
this.state.progress = null
|
|
44
67
|
return
|
|
45
68
|
}
|
|
46
69
|
this.state.current = job.assetId
|
|
70
|
+
// each job starts with a clean cancel slate — a cancel raced by a
|
|
71
|
+
// roll that RESOLVED anyway must never leak onto the next job
|
|
72
|
+
this.cancelRequested = false
|
|
47
73
|
for (let i = 0; i < job.count; i++) {
|
|
74
|
+
if (this.consumeCancel(job, i)) break
|
|
48
75
|
const seed = this.newSeed()
|
|
49
76
|
try {
|
|
50
|
-
await this.generateOne(job, seed)
|
|
77
|
+
await this.generateOne(job, seed, (p) => { this.state.progress = p })
|
|
51
78
|
} catch (e) {
|
|
79
|
+
if (this.consumeCancel(job, i)) break
|
|
52
80
|
this.pushError(`${job.assetId} seed ${seed}: ${(e as Error).message}`)
|
|
53
81
|
}
|
|
82
|
+
this.state.progress = null
|
|
54
83
|
this.state.done++
|
|
55
84
|
}
|
|
56
85
|
}
|
|
57
86
|
}
|
|
87
|
+
|
|
88
|
+
/** true = cancel was pending; rolls i..count-1 are counted done + dropped */
|
|
89
|
+
private consumeCancel(job: Job, i: number): boolean {
|
|
90
|
+
if (!this.cancelRequested) return false
|
|
91
|
+
this.cancelRequested = false
|
|
92
|
+
this.state.done += job.count - i
|
|
93
|
+
this.state.progress = null
|
|
94
|
+
console.log(`ok: cancelled ${job.assetId} — ${job.count - i} roll(s) dropped`)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
58
97
|
}
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// ids/names are validated against the kit + the roll-file regex — no path
|
|
6
6
|
// from the client ever reaches the filesystem unchecked.
|
|
7
|
-
import { existsSync, unlinkSync } from 'fs'
|
|
7
|
+
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
8
8
|
import { join, relative } from 'path'
|
|
9
|
-
import { findAsset, allAssets, missingPanelScripts, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
10
|
-
import { randomSeed } from '../gen/comfy-gen.ts'
|
|
9
|
+
import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
10
|
+
import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
|
|
11
11
|
import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
|
|
12
12
|
import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
|
|
13
13
|
import { alphaHeight, toPng } from '../image-ops.ts'
|
|
@@ -21,14 +21,14 @@ export type StudioDeps = {
|
|
|
21
21
|
root: string
|
|
22
22
|
page: () => string
|
|
23
23
|
/** test seam — defaults to the comfy engine */
|
|
24
|
-
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string) => Promise<void>
|
|
24
|
+
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress) => Promise<void>
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
|
|
28
28
|
|
|
29
29
|
// what the studio detail view shows about an asset's plumbing (W2-2)
|
|
30
30
|
export type AssetInfo = {
|
|
31
|
-
backend: 'canvas' | 'comfy'
|
|
31
|
+
backend: 'canvas' | 'comfy' | 'manual'
|
|
32
32
|
/** canvas rolls have no seed input — every roll is identical */
|
|
33
33
|
deterministic: boolean
|
|
34
34
|
/** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
|
|
@@ -100,13 +100,13 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
100
100
|
if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
|
|
101
101
|
return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
|
|
102
102
|
}
|
|
103
|
-
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir) =>
|
|
104
|
-
generateRoll(found, { prompt, seed, destDir }, resolveComfy))
|
|
103
|
+
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
|
|
104
|
+
generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
|
|
105
105
|
|
|
106
|
-
const queue = new StudioQueue(async (job, seed) => {
|
|
106
|
+
const queue = new StudioQueue(async (job, seed, onProgress) => {
|
|
107
107
|
const found = findAsset(kit, job.assetId)
|
|
108
108
|
if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
|
|
109
|
-
await generateOne(found, job.prompt, seed, dirOf(job.assetId))
|
|
109
|
+
await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress)
|
|
110
110
|
}, randomSeed)
|
|
111
111
|
|
|
112
112
|
// ── session/live rect plumbing ─────────────────────────────────────────
|
|
@@ -161,7 +161,17 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
161
161
|
}
|
|
162
162
|
const ship = async (f: FoundAsset, roll: string): Promise<{ shipped: string | null }> => {
|
|
163
163
|
const fn = await resolveShipFn(f)
|
|
164
|
-
if (fn == null)
|
|
164
|
+
if (fn == null) {
|
|
165
|
+
// no hook: ship.dir is the zero-config path — copy the picked
|
|
166
|
+
// roll as <dir>/<assetId>.<ext>; without dir, pick.json is the record
|
|
167
|
+
const dir = f.kind.ship?.dir
|
|
168
|
+
if (dir == null) return { shipped: null }
|
|
169
|
+
const ext = roll.split('.').at(-1)!
|
|
170
|
+
const dest = join(root, dir, `${f.asset.id}.${ext}`)
|
|
171
|
+
mkdirSync(join(root, dir), { recursive: true })
|
|
172
|
+
await Bun.write(dest, Bun.file(join(dirOf(f.asset.id), roll)))
|
|
173
|
+
return { shipped: relative(root, dest) }
|
|
174
|
+
}
|
|
165
175
|
const dir = dirOf(f.asset.id)
|
|
166
176
|
const g = geometryOf(f)
|
|
167
177
|
const dest = await fn({
|
|
@@ -196,10 +206,10 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
196
206
|
return {
|
|
197
207
|
id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
|
|
198
208
|
prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
|
|
199
|
-
icon: f.kind.mode === 'icon',
|
|
209
|
+
icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
|
|
200
210
|
}
|
|
201
211
|
}))
|
|
202
|
-
return json({ ...queue.state, queued: queue.queuedCount, errors: queue.errors.slice(-5), assets })
|
|
212
|
+
return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
|
|
203
213
|
}
|
|
204
214
|
|
|
205
215
|
if (pathname === '/api/asset') {
|
|
@@ -274,18 +284,44 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
if (req.method !== 'POST') return new Response('', { status: 404 })
|
|
287
|
+
|
|
288
|
+
if (pathname === '/api/upload') {
|
|
289
|
+
// multipart (not json) — handled before the json body() parse.
|
|
290
|
+
// Any kind accepts uploads (touched-up images welcome); manual
|
|
291
|
+
// kinds have no other roll source. Ext validated per kind media.
|
|
292
|
+
const fd = await req.formData().catch(() => null)
|
|
293
|
+
if (fd == null) return bad('multipart form expected (id + file)')
|
|
294
|
+
const f = findAsset(kit, String(fd.get('id') ?? ''))
|
|
295
|
+
if (f == null) return bad(`unknown asset '${String(fd.get('id') ?? '')}'`)
|
|
296
|
+
const file = fd.get('file')
|
|
297
|
+
if (!(file instanceof File)) return bad('file field missing')
|
|
298
|
+
const ext = (file.name.split('.').at(-1) ?? '').toLowerCase()
|
|
299
|
+
const allowed = MEDIA_EXTS[mediaOf(f.kind)]
|
|
300
|
+
if (!allowed.includes(ext)) return bad(`'${file.name}': .${ext} not allowed for ${mediaOf(f.kind)} (${allowed.join('/')})`)
|
|
301
|
+
// seed-style name like generated rolls — Date.now() collides for
|
|
302
|
+
// same-ms multi-uploads and silently overwrites
|
|
303
|
+
const name = `v${randomSeed()}.${ext}`
|
|
304
|
+
await Bun.write(join(dirOf(f.asset.id), name), await file.arrayBuffer())
|
|
305
|
+
return json({ ok: true, name })
|
|
306
|
+
}
|
|
307
|
+
|
|
277
308
|
const b = await body()
|
|
278
309
|
|
|
279
310
|
if (pathname === '/api/generate') {
|
|
280
311
|
const f = findAsset(kit, String(b.id))
|
|
281
312
|
if (f == null) return bad(`unknown asset '${String(b.id)}'`)
|
|
313
|
+
if (f.kind.generate.backend === 'manual') return bad(`'${f.asset.id}' is a manual asset — upload a file instead`)
|
|
282
314
|
const count = Math.max(1, Math.min(24, Number(b.count) || 1))
|
|
283
315
|
const prompt = typeof b.prompt === 'string' && b.prompt.trim() !== '' ? b.prompt : await promptOf(f)
|
|
284
316
|
queue.enqueue([{ assetId: f.asset.id, prompt, count }])
|
|
285
317
|
return json({ ok: true })
|
|
286
318
|
}
|
|
287
319
|
if (pathname === '/api/generate-cat') {
|
|
288
|
-
|
|
320
|
+
// manual kinds excluded from batch gen — nothing generates them;
|
|
321
|
+
// an all-manual cat gets a truthful pointer, not "no assets"
|
|
322
|
+
const inCat = allAssets(kit).filter((f) => f.kind.category === b.cat)
|
|
323
|
+
const hit = inCat.filter((f) => f.kind.generate.backend !== 'manual')
|
|
324
|
+
if (hit.length === 0 && inCat.length > 0) return bad(`cat '${String(b.cat)}' has only manual assets — upload in the studio`)
|
|
289
325
|
if (hit.length === 0) return bad(`no assets in cat '${String(b.cat)}'`)
|
|
290
326
|
const count = Math.max(1, Math.min(6, Number(b.count) || 1))
|
|
291
327
|
queue.enqueue(await Promise.all(hit.map(async (f) => ({ assetId: f.asset.id, prompt: await promptOf(f), count }))))
|
|
@@ -295,6 +331,19 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
295
331
|
queue.clear()
|
|
296
332
|
return json({ ok: true })
|
|
297
333
|
}
|
|
334
|
+
if (pathname === '/api/interrupt') {
|
|
335
|
+
// cancel the CURRENT job: backend interrupt (comfy kits) + drop
|
|
336
|
+
// its remaining rolls; queued jobs stay (that's clear's business)
|
|
337
|
+
if (!queue.state.generating) return bad('nothing generating')
|
|
338
|
+
queue.requestCancel()
|
|
339
|
+
// only touch the box when the RUNNING job is a comfy kind — the
|
|
340
|
+
// box is shared, an unrelated prompt must never be interrupted
|
|
341
|
+
const running = queue.state.current != null ? findAsset(kit, queue.state.current) : null
|
|
342
|
+
if (kit.comfy != null && running?.kind.generate.backend === 'comfy')
|
|
343
|
+
await interrupt({ host: kit.comfy.host, heal: kit.comfy.heal }).catch((e: Error) =>
|
|
344
|
+
queue.pushError(`interrupt: ${e.message} — current roll will finish, its job is still dropped`))
|
|
345
|
+
return json({ ok: true })
|
|
346
|
+
}
|
|
298
347
|
if (pathname === '/api/pick') {
|
|
299
348
|
const name = safeRoll(b.name)
|
|
300
349
|
const f = findAsset(kit, String(b.id))
|
|
@@ -369,6 +418,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
369
418
|
const name = safeRoll(b.name)
|
|
370
419
|
const f = findAsset(kit, String(b.id))
|
|
371
420
|
if (name == null || f == null) return bad('bad id/name')
|
|
421
|
+
if (geometryOf(f) == null) return bad(`kind '${f.kind.id}' has no geometry panel`)
|
|
372
422
|
const current = await sessionRect(f, name)
|
|
373
423
|
const flipped = { ...current, flip: current.flip === true ? undefined : true } as unknown as Record<string, unknown>
|
|
374
424
|
const res = await saveRect(f, name, flipped)
|
package/src/shipkit.ts
CHANGED
|
@@ -33,8 +33,8 @@ export type { Ask, CreateOpts } from './asks/asks-queue.ts'
|
|
|
33
33
|
// assets surface (folded in from the standalone atelier lib; that npm
|
|
34
34
|
// package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
|
|
35
35
|
// declares its kinds; `shipkit assets …` derives the rest.
|
|
36
|
-
export { defineKit, defineKind, comfy, canvas, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
|
|
37
|
-
export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
|
|
36
|
+
export { defineKit, defineKind, comfy, canvas, manual, mediaOf, MEDIA_EXTS, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
|
|
37
|
+
export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
|
|
38
38
|
export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
|
|
39
39
|
export type { Brand } from './assets/kits/identity-kit.ts'
|
|
40
40
|
export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
|
|
@@ -50,7 +50,7 @@ export { loadWorkflows, workflowFor } from './assets/gen/workflow-loader.ts'
|
|
|
50
50
|
export type { Workflow, Sidekick, Binding, Graph } from './assets/gen/workflow-loader.ts'
|
|
51
51
|
export { toPng, alphaHeight } from './assets/image-ops.ts'
|
|
52
52
|
export { updateJson, readJson } from './assets/json-store.ts'
|
|
53
|
-
export { assetDir, listRolls, isRollFile, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
|
|
53
|
+
export { assetDir, listRolls, isRollFile, isAudioRoll, readPick, updatePick, metaPath, pickPath, readPromptOverlay, updatePromptOverlay, promptPath } from './assets/roll-workspace.ts'
|
|
54
54
|
export type { PickState, PromptOverlay } from './assets/roll-workspace.ts'
|
|
55
55
|
export { scanMdGraph } from './wiki/scan-md.ts'
|
|
56
56
|
export { lintMd } from './wiki/lint-md.ts'
|