rvlib-shipkit 0.1.1 → 0.3.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 +137 -0
- package/README.md +23 -8
- package/package.json +15 -3
- package/src/asks/asks-queue.ts +20 -7
- package/src/assets/gen/comfy-gen.ts +106 -0
- package/src/assets/gen/generate-roll.ts +57 -0
- package/src/assets/gen/workflow-loader.ts +67 -0
- package/src/assets/image-ops.ts +30 -0
- package/src/assets/json-store.ts +27 -0
- package/src/assets/kit-config.ts +179 -0
- package/src/assets/kits/identity-kit.ts +119 -0
- package/src/assets/native-deps.ts +18 -0
- package/src/assets/panels/geometry-rects.ts +84 -0
- package/src/assets/roll-workspace.ts +41 -0
- package/src/assets/run-assets.ts +142 -0
- package/src/assets/server/studio-page.ts +620 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +412 -0
- package/src/assets/vite.ts +62 -0
- package/src/cli/cmd_Asks.ts +83 -0
- package/src/cli/cmd_Assets.ts +120 -0
- package/src/cli/cmd_Check.ts +30 -0
- package/src/cli/cmd_Ci.ts +41 -0
- package/src/cli/cmd_Contracts.ts +35 -0
- package/src/cli/cmd_Init.ts +28 -0
- package/src/cli/cmd_Serve.ts +34 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +44 -25
- package/src/contracts/run-contracts.ts +33 -0
- package/src/core/check-ctx.ts +3 -16
- package/src/core/check-suite.ts +16 -2
- package/src/core/discover-checks.ts +48 -0
- package/src/core/run-check.ts +73 -0
- package/src/core/run-ci.ts +54 -0
- package/src/core/spawn-timeout.ts +65 -0
- package/src/core/task-runner.ts +36 -43
- package/src/core/wire.ts +20 -0
- package/src/core/write-status.ts +70 -0
- package/src/init-repo.ts +60 -0
- package/src/repo-config.ts +82 -15
- package/src/serve/hub-pages.ts +84 -0
- package/src/serve/run-serve.ts +85 -0
- package/src/serve/serve-hub.ts +77 -0
- package/src/shipkit.ts +39 -3
- package/src/tui/checks-panel.ts +39 -0
- package/src/tui/tasks-panel.ts +54 -0
- package/src/tui/tui-app.tsx +91 -0
- package/src/tui/tui-panel.ts +57 -0
- package/src/tui/tui-store.ts +98 -0
- package/src/wiki/run-wiki.ts +50 -0
- package/src/cli/shipkit-asks.ts +0 -26
- package/src/cli/shipkit-check.ts +0 -26
- package/src/cli/shipkit-ci.ts +0 -30
- package/src/cli/shipkit-contracts.ts +0 -26
- package/src/cli/shipkit-init.ts +0 -41
- package/src/cli/shipkit-wiki.ts +0 -74
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// defineKit — the repo-side surface: .rv-assets/kit.ts declares asset kinds,
|
|
2
|
+
// generators, ship targets and metadata panels; the studio/CLI derive
|
|
3
|
+
// everything else. STRUCTURE lives here (git-reviewed TS); studio-edited
|
|
4
|
+
// state lives in the workspace overlays (pick.json, prompt.json, *.rect.json).
|
|
5
|
+
// Kit edits need a studio restart; prompt tweaks don't (overlay).
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import type { alphaHeight, toPng } from './image-ops.ts'
|
|
9
|
+
import type { GroundRect } from './panels/geometry-rects.ts'
|
|
10
|
+
|
|
11
|
+
export type AssetSpec = {
|
|
12
|
+
id: string
|
|
13
|
+
/** FULL prompt text incl. style — the studio shows/edits exactly what generates */
|
|
14
|
+
prompt: string
|
|
15
|
+
label?: string
|
|
16
|
+
/** freeform data passed through to the ship hook */
|
|
17
|
+
ship?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ComfyGen = { backend: 'comfy'; workflow?: string; bg?: string }
|
|
21
|
+
export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
|
|
22
|
+
export type GeneratorSpec = ComfyGen | CanvasGen
|
|
23
|
+
export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
|
|
24
|
+
export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
|
|
25
|
+
|
|
26
|
+
export type GeometryPanelSpec = {
|
|
27
|
+
panel: 'geometry'
|
|
28
|
+
/** oval height = width × ratio, ratio OWNED by the repo (mirror of its game constant) */
|
|
29
|
+
ratio: number
|
|
30
|
+
/** default oval when an asset has no saved rect yet */
|
|
31
|
+
defaultOval: { x: number; y: number; w: number; size: number }
|
|
32
|
+
/** per-category default size overrides */
|
|
33
|
+
sizeByCategory?: Record<string, number>
|
|
34
|
+
legs?: boolean
|
|
35
|
+
}
|
|
36
|
+
export type GridPanelSpec = { panel: 'grid'; fields: ('size' | 'flip')[] }
|
|
37
|
+
/** Repo-defined metadata editor: an ES module the studio serves at
|
|
38
|
+
* GET /panel/<kindId>/<id>.js and mounts per asset —
|
|
39
|
+
* `export function mount(el, api)` (+ optional `unmount()`), api =
|
|
40
|
+
* { assetId, kindId, roll, imgUrl, getMeta(), saveMeta(json) } backed by
|
|
41
|
+
* the roll's meta sidecar. Geometry/grid stay baked-in; this is the
|
|
42
|
+
* escape hatch for ANY other metadata shape. */
|
|
43
|
+
export type CustomPanelSpec = {
|
|
44
|
+
panel: 'custom'
|
|
45
|
+
/** url-safe kebab-case, unique per kind — becomes the served module name */
|
|
46
|
+
id: string
|
|
47
|
+
/** repo-root-relative ES module path (e.g. .rv-assets/panels/legs-editor.js) */
|
|
48
|
+
script: string
|
|
49
|
+
}
|
|
50
|
+
export type PanelSpec = GeometryPanelSpec | GridPanelSpec | CustomPanelSpec
|
|
51
|
+
export const geometryPanel = (p: Omit<GeometryPanelSpec, 'panel'>): GeometryPanelSpec => ({ panel: 'geometry', ...p })
|
|
52
|
+
export const gridPanel = (p: Omit<GridPanelSpec, 'panel'>): GridPanelSpec => ({ panel: 'grid', ...p })
|
|
53
|
+
export const customPanel = (p: Omit<CustomPanelSpec, 'panel'>): CustomPanelSpec => ({ panel: 'custom', ...p })
|
|
54
|
+
|
|
55
|
+
export type KindMeta = {
|
|
56
|
+
/** live write-through target, repo-root-relative (e.g. assets/heroes/ground-rects.json) */
|
|
57
|
+
file?: string
|
|
58
|
+
panels?: PanelSpec[]
|
|
59
|
+
/** ships to an art-normalized folder: sizes are in art-height units */
|
|
60
|
+
artNormalized?: boolean
|
|
61
|
+
/** repo-owned semantics gate, run before any metadata write-through —
|
|
62
|
+
* return error strings to reject the write (loud, never silent) */
|
|
63
|
+
validate?: (data: unknown) => string[] | void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What a ship hook receives when a roll is picked (studio /api/pick). */
|
|
67
|
+
export type ShipContext = {
|
|
68
|
+
assetId: string
|
|
69
|
+
file: string
|
|
70
|
+
asset: AssetSpec
|
|
71
|
+
kind: Kind
|
|
72
|
+
meta: unknown
|
|
73
|
+
rect: GroundRect | null
|
|
74
|
+
projectRoot: string
|
|
75
|
+
img: { toPng: typeof toPng; alphaHeight: typeof alphaHeight }
|
|
76
|
+
}
|
|
77
|
+
export type ShipFn = (ctx: ShipContext) => Promise<string | void>
|
|
78
|
+
|
|
79
|
+
export type Kind = {
|
|
80
|
+
id: string
|
|
81
|
+
category: string
|
|
82
|
+
assets: AssetSpec[]
|
|
83
|
+
generate: GeneratorSpec
|
|
84
|
+
/** icon kinds get no geometry, thumbnails only */
|
|
85
|
+
mode?: 'sprite' | 'icon'
|
|
86
|
+
/** 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 */
|
|
88
|
+
ship?: { dir?: string; via?: string; fn?: ShipFn }
|
|
89
|
+
meta?: KindMeta
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type KitConfig = {
|
|
93
|
+
port?: number
|
|
94
|
+
/** roll workspace root, repo-root-relative; gitignored */
|
|
95
|
+
workspace?: string
|
|
96
|
+
comfy?: { host: string; workflows: string; heal?: string }
|
|
97
|
+
kinds: Kind[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ResolvedKit = KitConfig & { port: number; workspace: string }
|
|
101
|
+
|
|
102
|
+
export function validateKit(k: KitConfig): string[] {
|
|
103
|
+
const errs: string[] = []
|
|
104
|
+
if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
|
|
105
|
+
const kindIds = new Set<string>()
|
|
106
|
+
const assetIds = new Set<string>()
|
|
107
|
+
for (const kind of k.kinds ?? []) {
|
|
108
|
+
if (!kind.id || !kind.category) errs.push(`kind needs id + category (got ${JSON.stringify({ id: kind.id, category: kind.category })})`)
|
|
109
|
+
if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
|
|
110
|
+
kindIds.add(kind.id)
|
|
111
|
+
if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
|
|
112
|
+
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
|
+
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
|
+
for (const a of kind.assets ?? []) {
|
|
115
|
+
if (!a.id || typeof a.prompt !== 'string') errs.push(`kind '${kind.id}': asset needs id + prompt (got ${JSON.stringify(a).slice(0, 80)})`)
|
|
116
|
+
if (assetIds.has(a.id)) errs.push(`duplicate asset id '${a.id}' (ids are global across kinds)`)
|
|
117
|
+
assetIds.add(a.id)
|
|
118
|
+
}
|
|
119
|
+
const panelIds = new Set<string>()
|
|
120
|
+
for (const p of kind.meta?.panels ?? []) {
|
|
121
|
+
if (p.panel !== 'custom') continue
|
|
122
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(p.id ?? '')) errs.push(`kind '${kind.id}': custom panel id must be url-safe kebab-case (got '${p.id}')`)
|
|
123
|
+
if (panelIds.has(p.id)) errs.push(`kind '${kind.id}': duplicate custom panel id '${p.id}'`)
|
|
124
|
+
panelIds.add(p.id)
|
|
125
|
+
if (typeof p.script !== 'string' || p.script === '') errs.push(`kind '${kind.id}' panel '${p.id}': script path required (repo-root-relative ES module)`)
|
|
126
|
+
else if (p.script.startsWith('/') || p.script.split('/').includes('..'))
|
|
127
|
+
errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return errs
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Custom panel scripts that don't exist on disk — checked at kit load AND
|
|
134
|
+
* studio boot so a typo'd path dies loud, never as a silent 404 in the SPA. */
|
|
135
|
+
export function missingPanelScripts(kit: KitConfig, root: string): string[] {
|
|
136
|
+
const missing: string[] = []
|
|
137
|
+
for (const kind of kit.kinds) {
|
|
138
|
+
for (const p of kind.meta?.panels ?? []) {
|
|
139
|
+
if (p.panel !== 'custom') continue
|
|
140
|
+
if (!existsSync(join(root, p.script))) missing.push(`kind '${kind.id}' panel '${p.id}': ${p.script} not found under ${root}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return missing
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function defineKit(k: KitConfig): ResolvedKit {
|
|
147
|
+
const errs = validateKit(k)
|
|
148
|
+
if (errs.length > 0) throw new Error(`invalid kit:\n ${errs.join('\n ')}`)
|
|
149
|
+
return { ...k, port: k.port ?? 4800, workspace: k.workspace ?? '.rv-assets/out' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const defineKind = (k: Kind): Kind => k
|
|
153
|
+
|
|
154
|
+
export async function loadKit(root: string): Promise<ResolvedKit> {
|
|
155
|
+
const path = join(root, '.rv-assets/kit.ts')
|
|
156
|
+
if (!existsSync(path)) throw new Error(`no .rv-assets/kit.ts in ${root} — create one (defineKit) or run the init snippet from the docs`)
|
|
157
|
+
const mod = (await import(path)) as { default?: ResolvedKit }
|
|
158
|
+
if (mod.default == null) throw new Error(`${path}: must default-export defineKit({...})`)
|
|
159
|
+
const missing = missingPanelScripts(mod.default, root)
|
|
160
|
+
if (missing.length > 0) throw new Error(`kit custom panel scripts missing — create them or fix the paths:\n ${missing.join('\n ')}`)
|
|
161
|
+
return mod.default
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── lookups shared by server + CLI ──────────────────────────────────────────
|
|
165
|
+
export type FoundAsset = { kind: Kind; asset: AssetSpec }
|
|
166
|
+
|
|
167
|
+
export function findAsset(kit: KitConfig, assetId: string): FoundAsset | null {
|
|
168
|
+
for (const kind of kit.kinds) {
|
|
169
|
+
const asset = kind.assets.find((a) => a.id === assetId)
|
|
170
|
+
if (asset != null) return { kind, asset }
|
|
171
|
+
}
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function allAssets(kit: KitConfig): FoundAsset[] {
|
|
176
|
+
return kit.kinds.flatMap((kind) => kind.assets.map((asset) => ({ kind, asset })))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const categoriesOf = (kit: KitConfig): string[] => [...new Set(kit.kinds.map((k) => k.category))]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// kits/identity — favicon / logo / OG as asset kinds. One render path
|
|
2
|
+
// (renderIdentityFromMark: mark png → logo master 1024², favicon 512², og
|
|
3
|
+
// card), two ways to get the mark:
|
|
4
|
+
// - canvas mark: deterministic, instant — `shipkit assets identity` draws it
|
|
5
|
+
// headlessly and renders the set.
|
|
6
|
+
// - comfy mark: roll-and-pick in the studio; the kind carries a BUILT-IN ship
|
|
7
|
+
// hook, so picking a logo roll re-renders the whole identity set from the
|
|
8
|
+
// picked png. Headless `identity` uses the current pick — no pick yet is a
|
|
9
|
+
// loud FAIL, never a placeholder.
|
|
10
|
+
// Outputs are COMMITTED: public/favicon.png (512² master), public/og.png
|
|
11
|
+
// (1200×630), public/logo.png. Brand data is per-repo and never defaults
|
|
12
|
+
// across repos.
|
|
13
|
+
import { createCanvas, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
|
|
14
|
+
import { mkdirSync } from 'fs'
|
|
15
|
+
import { dirname, join } from 'path'
|
|
16
|
+
import sharp from 'sharp'
|
|
17
|
+
import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
|
|
18
|
+
|
|
19
|
+
export type Brand = {
|
|
20
|
+
name: string
|
|
21
|
+
tagline?: string
|
|
22
|
+
/** background / accent / ink — used by the default og layout */
|
|
23
|
+
palette: { bg: string; accent: string; ink: string }
|
|
24
|
+
/** the mark: a canvas draw (deterministic) or a comfy generator (roll-and-pick) */
|
|
25
|
+
mark: CanvasGen | ComfyGen
|
|
26
|
+
/** comfy marks: the FULL generation prompt for identity-logo rolls
|
|
27
|
+
* (style included — the studio shows/edits exactly this) */
|
|
28
|
+
markPrompt?: string
|
|
29
|
+
/** og layout hook — replaces the WHOLE default card (bg + mark left +
|
|
30
|
+
* name/tagline right); the hook owns compositing the mark if it wants one */
|
|
31
|
+
drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const FAVICON_SIZE = 512
|
|
35
|
+
export const OG_W = 1200
|
|
36
|
+
export const OG_H = 630
|
|
37
|
+
export const LOGO_SIZE = 1024
|
|
38
|
+
|
|
39
|
+
/** the one next step when a comfy mark has no picked roll yet */
|
|
40
|
+
export const NO_PICK_FIX = 'roll + pick in the studio: bun run assets → #identity-logo'
|
|
41
|
+
|
|
42
|
+
export function identityKinds(brand: Brand): Kind[] {
|
|
43
|
+
return [
|
|
44
|
+
defineKind({
|
|
45
|
+
id: 'identity-logo', category: 'Identity', mode: 'icon',
|
|
46
|
+
assets: [{ id: 'identity-logo', prompt: brand.markPrompt ?? `logo mark for ${brand.name}`, label: 'logo' }],
|
|
47
|
+
generate: brand.mark,
|
|
48
|
+
// built-in ship: picking a logo roll re-renders the WHOLE identity set
|
|
49
|
+
ship: { fn: async (ctx) => (await renderIdentityFromMark(brand, ctx.projectRoot, await sharp(ctx.file).png().toBuffer())).join(' + ') },
|
|
50
|
+
}),
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// shrink px until text fits maxW (og cards must never clip the tagline)
|
|
55
|
+
const fitFont = (ctx: SKRSContext2D, text: string, px: number, maxW: number, bold = false): void => {
|
|
56
|
+
for (; px > 12; px -= 2) {
|
|
57
|
+
ctx.font = `${bold ? 'bold ' : ''}${px}px sans-serif`
|
|
58
|
+
if (ctx.measureText(text).width <= maxW) return
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }, mark: Image): void => {
|
|
63
|
+
ctx.fillStyle = brand.palette.bg
|
|
64
|
+
ctx.fillRect(0, 0, size.w, size.h)
|
|
65
|
+
ctx.fillStyle = brand.palette.accent
|
|
66
|
+
ctx.fillRect(0, size.h - 14, size.w, 14)
|
|
67
|
+
const pad = 72
|
|
68
|
+
const markSize = size.h - pad * 2
|
|
69
|
+
ctx.drawImage(mark, pad, pad, markSize, markSize)
|
|
70
|
+
const textX = pad + markSize + 64
|
|
71
|
+
const maxW = size.w - textX - 48
|
|
72
|
+
ctx.fillStyle = brand.palette.ink
|
|
73
|
+
fitFont(ctx, brand.name, Math.round(size.h / 6), maxW, true)
|
|
74
|
+
ctx.textBaseline = 'middle'
|
|
75
|
+
ctx.fillText(brand.name, textX, size.h / 2 - (brand.tagline != null ? size.h / 12 : 0))
|
|
76
|
+
if (brand.tagline != null) {
|
|
77
|
+
ctx.fillStyle = brand.palette.accent
|
|
78
|
+
fitFont(ctx, brand.tagline, Math.round(size.h / 14), maxW)
|
|
79
|
+
ctx.fillText(brand.tagline, textX + 2, size.h / 2 + size.h / 9)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** THE identity writer — any mark png (canvas draw or picked comfy roll) →
|
|
84
|
+
* public/logo.png (1024² master) + public/favicon.png (512² resize) +
|
|
85
|
+
* public/og.png (default card: bg + mark left + name/tagline right). */
|
|
86
|
+
export async function renderIdentityFromMark(brand: Brand, root: string, markPng: Buffer): Promise<string[]> {
|
|
87
|
+
const out: string[] = []
|
|
88
|
+
const write = async (rel: string, buf: Buffer): Promise<void> => {
|
|
89
|
+
const path = join(root, rel)
|
|
90
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
91
|
+
await Bun.write(path, buf)
|
|
92
|
+
out.push(rel)
|
|
93
|
+
}
|
|
94
|
+
const logoPng = await sharp(markPng)
|
|
95
|
+
.resize(LOGO_SIZE, LOGO_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
96
|
+
.png().toBuffer()
|
|
97
|
+
await write('public/logo.png', logoPng)
|
|
98
|
+
// favicon = logo master resized to 512² (browsers derive the rest)
|
|
99
|
+
await write('public/favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
|
|
100
|
+
const og = createCanvas(OG_W, OG_H)
|
|
101
|
+
const ctx = og.getContext('2d')
|
|
102
|
+
if (brand.drawOg != null) brand.drawOg(ctx, brand, { w: OG_W, h: OG_H })
|
|
103
|
+
else defaultOg(ctx, brand, { w: OG_W, h: OG_H }, await loadImage(logoPng))
|
|
104
|
+
await write('public/og.png', og.toBuffer('image/png'))
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Headless render of the full identity set. Canvas marks draw themselves;
|
|
109
|
+
* comfy marks need `markFile` = the PICKED roll (the caller resolves the
|
|
110
|
+
* pick — run-assets does) and FAIL loud without one. */
|
|
111
|
+
export async function renderIdentity(brand: Brand, root: string, opts?: { markFile?: string }): Promise<string[]> {
|
|
112
|
+
if (brand.mark.backend === 'canvas') {
|
|
113
|
+
const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
|
|
114
|
+
await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
|
|
115
|
+
return renderIdentityFromMark(brand, root, logo.toBuffer('image/png'))
|
|
116
|
+
}
|
|
117
|
+
if (opts?.markFile == null) throw new Error(`brand '${brand.name}' uses a comfy mark and no roll is picked — ${NO_PICK_FIX}`)
|
|
118
|
+
return renderIdentityFromMark(brand, root, await sharp(opts.markFile).png().toBuffer())
|
|
119
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Native-dep readiness probes for the asset generators. These MUST live in the
|
|
2
|
+
// lib so the `@napi-rs/canvas` / `sharp` specifiers resolve against the lib's
|
|
3
|
+
// node_modules (the same way the generators do) — a repo probing from its own
|
|
4
|
+
// root would false-negative. Each not only resolves the module but exercises
|
|
5
|
+
// the native binding (a present-but-ABI-broken .node passes resolution yet
|
|
6
|
+
// dies at first generate — the exact A32 gap). Both return the package name;
|
|
7
|
+
// both throw loud on failure.
|
|
8
|
+
export async function assertCanvasReady(): Promise<string> {
|
|
9
|
+
const { createCanvas } = await import('@napi-rs/canvas')
|
|
10
|
+
createCanvas(2, 2).getContext('2d').fillRect(0, 0, 1, 1)
|
|
11
|
+
return '@napi-rs/canvas'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function assertSharpReady(): Promise<string> {
|
|
15
|
+
const sharp = (await import('sharp')).default
|
|
16
|
+
await sharp({ create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).png().toBuffer()
|
|
17
|
+
return 'sharp'
|
|
18
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Ground-geometry rect math: the session↔live formats behind the geometry
|
|
2
|
+
// panel. SEMANTICS (what the oval means, size units, the ratio value) are
|
|
3
|
+
// the consuming repo's; this module owns only the mechanics:
|
|
4
|
+
// - session rect (per roll, <workspace>/studio/<id>/<roll>.rect.json):
|
|
5
|
+
// { x,y,w,h,size,art?,flip?,legs?[4],legColor? } — h ALWAYS re-derived
|
|
6
|
+
// = w × ratio preserving center-y, so data can never drift off-ratio.
|
|
7
|
+
// - live rect (the game file): flip folds into MIRRORED coordinates and the
|
|
8
|
+
// flag is DROPPED — pixels ship mirrored, loaders must never double-flip.
|
|
9
|
+
export type LegRect = { x: number; y: number; w: number; h: number }
|
|
10
|
+
export type GroundRect = {
|
|
11
|
+
x: number; y: number; w: number; h: number
|
|
12
|
+
size: number
|
|
13
|
+
/** alpha-content height px (studio metadata; props normalize by it) */
|
|
14
|
+
art?: number
|
|
15
|
+
flip?: boolean
|
|
16
|
+
legs?: LegRect[]
|
|
17
|
+
legColor?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const r2 = (n: number): number => Math.round(n * 100) / 100
|
|
21
|
+
const r3 = (n: number): number => Math.round(n * 1000) / 1000
|
|
22
|
+
|
|
23
|
+
/** Normalize a possibly-legacy rect: fold `scale` into `size`, round
|
|
24
|
+
* (x/y/w/h 2dp, size 3dp, legs 2dp), ratio-lock h preserving center-y. */
|
|
25
|
+
export function cleanRect(raw: Record<string, unknown>, ratio: number): GroundRect {
|
|
26
|
+
const num = (k: string, dflt: number): number => (typeof raw[k] === 'number' && Number.isFinite(raw[k]) ? (raw[k] as number) : dflt)
|
|
27
|
+
const size = typeof raw.size === 'number' ? (raw.size as number) : num('scale', 1) // legacy key folds in
|
|
28
|
+
const x = num('x', 0)
|
|
29
|
+
const y = num('y', 0)
|
|
30
|
+
const w = Math.max(1, num('w', 1))
|
|
31
|
+
const oldH = num('h', w * ratio)
|
|
32
|
+
const h = w * ratio
|
|
33
|
+
const yLocked = y + (oldH - h) / 2 // keep center-y while re-locking the ratio
|
|
34
|
+
const out: GroundRect = { x: r2(x), y: r2(yLocked), w: r2(w), h: r2(h), size: r3(size) }
|
|
35
|
+
if (typeof raw.art === 'number') out.art = raw.art as number
|
|
36
|
+
if (raw.flip === true) out.flip = true
|
|
37
|
+
if (Array.isArray(raw.legs) && raw.legs.length === 4) {
|
|
38
|
+
out.legs = (raw.legs as LegRect[]).map((l) => ({ x: r2(l.x), y: r2(l.y), w: r2(l.w), h: r2(l.h) }))
|
|
39
|
+
if (typeof raw.legColor === 'string') out.legColor = raw.legColor
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Session → live: mirror x when flipped (oval AND legs), DROP the flag. */
|
|
45
|
+
export function liveRect(clean: GroundRect, textureWidth = 1024): GroundRect {
|
|
46
|
+
if (clean.flip !== true) {
|
|
47
|
+
const { flip: _flip, ...rest } = clean
|
|
48
|
+
return { ...rest }
|
|
49
|
+
}
|
|
50
|
+
const { flip: _flip, ...rest } = clean
|
|
51
|
+
const out: GroundRect = { ...rest, x: r2(textureWidth - clean.x - clean.w) }
|
|
52
|
+
if (clean.legs != null) out.legs = clean.legs.map((l) => ({ ...l, x: r2(textureWidth - l.x - l.w) }))
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Default 4-leg layout derived from the oval — order [L·front, L·back,
|
|
57
|
+
* R·back, R·front]; pair (0,1) = far side, (2,3) = near side. Each rect IS
|
|
58
|
+
* the leg: top-center = hip, bottom-center = foot on the ground. */
|
|
59
|
+
export function defaultLegs(oval: GroundRect): LegRect[] {
|
|
60
|
+
const u = oval.w
|
|
61
|
+
const cx = oval.x + oval.w / 2
|
|
62
|
+
const cy = oval.y + oval.h / 2
|
|
63
|
+
const lw = 0.09 * u
|
|
64
|
+
const lh = 0.3 * u
|
|
65
|
+
const centersDx = [0.2, -0.3, -0.22, 0.28]
|
|
66
|
+
return centersDx.map((dx, i) => {
|
|
67
|
+
const footY = cy + (i < 2 ? -0.06 : 0.06) * u
|
|
68
|
+
return { x: r2(cx + dx * u - lw / 2), y: r2(footY - lh), w: r2(lw), h: r2(lh) }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Legacy circle → oval migration (run on session read). A circle is
|
|
73
|
+
* |h − w| ≤ 0.5. anchor: where the new oval's center-y comes from —
|
|
74
|
+
* 'bottom' (characters/scenery: feet at circle bottom) or 'center'
|
|
75
|
+
* (buildings). artNormalized folds art-height units into oval-width units:
|
|
76
|
+
* size *= w / art. Non-circles pass through untouched. */
|
|
77
|
+
export function migrateCircle(rect: GroundRect, opts: { ratio: number; anchor: 'bottom' | 'center'; artNormalized?: boolean }): GroundRect {
|
|
78
|
+
if (Math.abs(rect.h - rect.w) > 0.5) return rect
|
|
79
|
+
const cy = opts.anchor === 'bottom' ? rect.y + rect.h : rect.y + rect.h / 2
|
|
80
|
+
let size = rect.size
|
|
81
|
+
if (opts.artNormalized === true && rect.art != null && rect.art > 0) size = size * (rect.w / rect.art)
|
|
82
|
+
const h = rect.w * opts.ratio
|
|
83
|
+
return cleanRect({ ...rect, size, h, y: cy - h / 2 } as unknown as Record<string, unknown>, opts.ratio)
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The <workspace>/studio/<assetId>/ roll-workspace conventions — shared by
|
|
2
|
+
// the studio server, the fill CLI and ship hooks. Files per asset dir:
|
|
3
|
+
// v<seed>.webp|png rolls · pick.json { pick, alts[], shippedAt } ·
|
|
4
|
+
// <roll>.meta.json freeform sidecars (project tooling reads/writes them) ·
|
|
5
|
+
// prompt.json { prompt } overlay layered over the kind's prompt builder.
|
|
6
|
+
// All writes go through the locked json-store; hand edits are legal (the
|
|
7
|
+
// studio re-reads every poll).
|
|
8
|
+
import { existsSync, readdirSync, statSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { readJson, updateJson } from './json-store.ts'
|
|
11
|
+
|
|
12
|
+
export type PickState = { pick: string | null; alts: string[]; shippedAt: string | null }
|
|
13
|
+
export const PICK_INIT: PickState = { pick: null, alts: [], shippedAt: null }
|
|
14
|
+
|
|
15
|
+
export type PromptOverlay = { prompt: string | null }
|
|
16
|
+
export const PROMPT_INIT: PromptOverlay = { prompt: null }
|
|
17
|
+
|
|
18
|
+
export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png)$/.test(f)
|
|
19
|
+
|
|
20
|
+
export const assetDir = (workspace: string, assetId: string): string => join(workspace, 'studio', assetId)
|
|
21
|
+
|
|
22
|
+
export function listRolls(dir: string): string[] {
|
|
23
|
+
if (!existsSync(dir)) return []
|
|
24
|
+
return readdirSync(dir)
|
|
25
|
+
.filter(isRollFile)
|
|
26
|
+
.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs) // newest first
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const pickPath = (dir: string): string => join(dir, 'pick.json')
|
|
30
|
+
|
|
31
|
+
export const readPick = (dir: string): Promise<PickState> => readJson(pickPath(dir), PICK_INIT)
|
|
32
|
+
|
|
33
|
+
export const updatePick = (dir: string, up: (s: PickState) => void): Promise<PickState> => updateJson(pickPath(dir), PICK_INIT, up)
|
|
34
|
+
|
|
35
|
+
export const metaPath = (dir: string, roll: string): string => join(dir, `${roll}.meta.json`)
|
|
36
|
+
|
|
37
|
+
export const promptPath = (dir: string): string => join(dir, 'prompt.json')
|
|
38
|
+
|
|
39
|
+
export const readPromptOverlay = (dir: string): Promise<PromptOverlay> => readJson(promptPath(dir), PROMPT_INIT)
|
|
40
|
+
|
|
41
|
+
export const updatePromptOverlay = (dir: string, up: (s: PromptOverlay) => void): Promise<PromptOverlay> => updateJson(promptPath(dir), PROMPT_INIT, up)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// shipkit assets — implementations behind cli/cmd_Assets.ts (clipanion owns
|
|
2
|
+
// parsing; these plain functions own the logic, print through injected
|
|
3
|
+
// sinks, and return exit codes — 0 ok · 1 fail).
|
|
4
|
+
import { existsSync } from 'fs'
|
|
5
|
+
import { join, relative } from 'path'
|
|
6
|
+
import { randomSeed } from './gen/comfy-gen.ts'
|
|
7
|
+
import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
|
|
8
|
+
import { loadWorkflows } from './gen/workflow-loader.ts'
|
|
9
|
+
import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
|
|
10
|
+
import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
|
|
11
|
+
import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
|
|
12
|
+
import { page } from './server/studio-page.ts'
|
|
13
|
+
import { serveStudio } from './server/studio-server.ts'
|
|
14
|
+
|
|
15
|
+
export type Print = (line: string) => void
|
|
16
|
+
export type AssetsIo = { print: Print; printErr: Print }
|
|
17
|
+
|
|
18
|
+
class AssetsFail extends Error {}
|
|
19
|
+
const die = (msg: string): never => { throw new AssetsFail(msg) }
|
|
20
|
+
|
|
21
|
+
// Lazy comfy runtime — a canvas-only kit (no comfy: block) never loads
|
|
22
|
+
// workflows or needs the box; only a real comfy roll triggers resolution.
|
|
23
|
+
const comfyFor = (root: string, kit: ResolvedKit): ComfyResolver =>
|
|
24
|
+
comfyResolver(root, kit, () => die('this kit has no comfy config — add comfy: { host, workflows } to fill comfy assets'))
|
|
25
|
+
|
|
26
|
+
async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveComfy: ComfyResolver, assetId: string, count: number): Promise<number> {
|
|
27
|
+
const f = findAsset(kit, assetId)
|
|
28
|
+
if (f == null) die(`unknown asset '${assetId}'`)
|
|
29
|
+
const dir = assetDir(join(root, kit.workspace), assetId)
|
|
30
|
+
const prompt = (await readPromptOverlay(dir)).prompt ?? f!.asset.prompt
|
|
31
|
+
let failed = 0
|
|
32
|
+
for (let i = 0; i < count; i++) {
|
|
33
|
+
const seed = randomSeed()
|
|
34
|
+
try {
|
|
35
|
+
await generateRoll(f!, { prompt, seed, destDir: dir }, resolveComfy)
|
|
36
|
+
io.print(`ok: ${assetId} v${seed}`)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
failed++
|
|
39
|
+
io.printErr(`FAIL: ${assetId} seed ${seed}: ${(e as Error).message}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return failed
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// wraps an implementation so kit/config errors become one FAIL line + exit 1
|
|
46
|
+
async function guarded(io: AssetsIo, body: () => Promise<number>): Promise<number> {
|
|
47
|
+
try {
|
|
48
|
+
return await body()
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (e instanceof AssetsFail) { io.printErr(`FAIL: ${e.message}`); return 1 }
|
|
51
|
+
throw e
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const runAssetsServe = (root: string, io: AssetsIo): Promise<number> =>
|
|
56
|
+
guarded(io, async () => {
|
|
57
|
+
const kit = await loadKit(root)
|
|
58
|
+
const studio = await serveStudio({ kit, root, page })
|
|
59
|
+
io.print(`ok: studio → http://127.0.0.1:${studio.server.port} (${allAssets(kit).length} assets)`)
|
|
60
|
+
await new Promise(() => {}) // Bun.serve owns the process — never resolve
|
|
61
|
+
return 0
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | 'add'; n: number; categories: string[] }): Promise<number> =>
|
|
65
|
+
guarded(io, async () => {
|
|
66
|
+
const kit = await loadKit(root)
|
|
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))
|
|
71
|
+
.map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
|
|
72
|
+
.map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
|
|
73
|
+
.filter((t) => t.want > 0)
|
|
74
|
+
.sort((a, b) => a.have - b.have) // sparsest first
|
|
75
|
+
let failed = 0
|
|
76
|
+
for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
|
|
77
|
+
io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
|
|
78
|
+
return failed > 0 ? 1 : 0
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n: number }): Promise<number> =>
|
|
82
|
+
guarded(io, async () => {
|
|
83
|
+
const kit = await loadKit(root)
|
|
84
|
+
const failed = await rollOnce(root, io, kit, comfyFor(root, kit), o.assetId, o.n)
|
|
85
|
+
return failed > 0 ? 1 : 0
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
|
|
89
|
+
guarded(io, async () => {
|
|
90
|
+
const brandPath = join(root, '.rv-identity/brand.ts')
|
|
91
|
+
if (!existsSync(brandPath)) die('no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)')
|
|
92
|
+
const mod = (await import(brandPath)) as { default?: Brand }
|
|
93
|
+
if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
|
|
94
|
+
const brand = mod.default!
|
|
95
|
+
let markFile: string | undefined
|
|
96
|
+
if (brand.mark.backend === 'comfy') {
|
|
97
|
+
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
98
|
+
// owns the workspace where picks live
|
|
99
|
+
const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
|
|
100
|
+
const dir = assetDir(join(root, kit.workspace), 'identity-logo')
|
|
101
|
+
const pick = await readPick(dir)
|
|
102
|
+
if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
|
|
103
|
+
markFile = join(dir, pick.pick!)
|
|
104
|
+
if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
|
|
105
|
+
}
|
|
106
|
+
const written = await renderIdentity(brand, root, { markFile })
|
|
107
|
+
for (const w of written) io.print(`ok: ${w}`)
|
|
108
|
+
return 0
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
|
|
112
|
+
guarded(io, async () => {
|
|
113
|
+
let bad = 0
|
|
114
|
+
const kit = await loadKit(root).catch((e: Error) => { io.print(`FAIL: ${e.message}`); bad++; return null })
|
|
115
|
+
if (kit == null) return 1
|
|
116
|
+
io.print(`ok: kit loads — ${kit.kinds.length} kind(s), ${allAssets(kit).length} asset(s), workspace ${kit.workspace}`)
|
|
117
|
+
if (kit.comfy != null) {
|
|
118
|
+
try {
|
|
119
|
+
const wfs = await loadWorkflows(join(root, kit.comfy.workflows))
|
|
120
|
+
io.print(`ok: ${wfs.size} workflow pair(s) bound`)
|
|
121
|
+
// provenance: where each pair lives + that both halves validated
|
|
122
|
+
// (loadWorkflows throws on any invalid pair, so reaching here = valid).
|
|
123
|
+
// There is no global workflow store — git owns each repo's pairs; the
|
|
124
|
+
// house library (wiki/build/comfyui/references) is the copy-from source.
|
|
125
|
+
for (const wf of wfs.values()) {
|
|
126
|
+
const rel = relative(root, wf.jsonPath).replace(/\.json$/, '')
|
|
127
|
+
io.print(`ok: workflow '${wf.id}' → ${rel}.{json,ts} (pair valid)`)
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
io.print(`FAIL: ${(e as Error).message}`)
|
|
131
|
+
bad++
|
|
132
|
+
}
|
|
133
|
+
const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
|
|
134
|
+
io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
|
|
135
|
+
}
|
|
136
|
+
if (existsSync(join(root, '.rv-identity/brand.ts'))) {
|
|
137
|
+
const outs = ['public/favicon.png', 'public/og.png', 'public/logo.png'].filter((p) => !existsSync(join(root, p)))
|
|
138
|
+
if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
|
|
139
|
+
else io.print('ok: identity outputs present')
|
|
140
|
+
}
|
|
141
|
+
return bad > 0 ? 1 : 0
|
|
142
|
+
})
|