rvlib-shipkit 0.1.1 → 0.2.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 +77 -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 +161 -0
- package/src/assets/kits/identity-kit.ts +80 -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 +122 -0
- package/src/assets/server/studio-page.ts +587 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +360 -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_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +42 -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 +68 -16
- package/src/shipkit.ts +34 -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,161 @@
|
|
|
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
|
+
|
|
9
|
+
export type AssetSpec = {
|
|
10
|
+
id: string
|
|
11
|
+
/** FULL prompt text incl. style — the studio shows/edits exactly what generates */
|
|
12
|
+
prompt: string
|
|
13
|
+
label?: string
|
|
14
|
+
/** freeform data passed through to the ship hook */
|
|
15
|
+
ship?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ComfyGen = { backend: 'comfy'; workflow?: string; bg?: string }
|
|
19
|
+
export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
|
|
20
|
+
export type GeneratorSpec = ComfyGen | CanvasGen
|
|
21
|
+
export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
|
|
22
|
+
export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
|
|
23
|
+
|
|
24
|
+
export type GeometryPanelSpec = {
|
|
25
|
+
panel: 'geometry'
|
|
26
|
+
/** oval height = width × ratio, ratio OWNED by the repo (mirror of its game constant) */
|
|
27
|
+
ratio: number
|
|
28
|
+
/** default oval when an asset has no saved rect yet */
|
|
29
|
+
defaultOval: { x: number; y: number; w: number; size: number }
|
|
30
|
+
/** per-category default size overrides */
|
|
31
|
+
sizeByCategory?: Record<string, number>
|
|
32
|
+
legs?: boolean
|
|
33
|
+
}
|
|
34
|
+
export type GridPanelSpec = { panel: 'grid'; fields: ('size' | 'flip')[] }
|
|
35
|
+
/** Repo-defined metadata editor: an ES module the studio serves at
|
|
36
|
+
* GET /panel/<kindId>/<id>.js and mounts per asset —
|
|
37
|
+
* `export function mount(el, api)` (+ optional `unmount()`), api =
|
|
38
|
+
* { assetId, kindId, roll, imgUrl, getMeta(), saveMeta(json) } backed by
|
|
39
|
+
* the roll's meta sidecar. Geometry/grid stay baked-in; this is the
|
|
40
|
+
* escape hatch for ANY other metadata shape. */
|
|
41
|
+
export type CustomPanelSpec = {
|
|
42
|
+
panel: 'custom'
|
|
43
|
+
/** url-safe kebab-case, unique per kind — becomes the served module name */
|
|
44
|
+
id: string
|
|
45
|
+
/** repo-root-relative ES module path (e.g. .rv-assets/panels/legs-editor.js) */
|
|
46
|
+
script: string
|
|
47
|
+
}
|
|
48
|
+
export type PanelSpec = GeometryPanelSpec | GridPanelSpec | CustomPanelSpec
|
|
49
|
+
export const geometryPanel = (p: Omit<GeometryPanelSpec, 'panel'>): GeometryPanelSpec => ({ panel: 'geometry', ...p })
|
|
50
|
+
export const gridPanel = (p: Omit<GridPanelSpec, 'panel'>): GridPanelSpec => ({ panel: 'grid', ...p })
|
|
51
|
+
export const customPanel = (p: Omit<CustomPanelSpec, 'panel'>): CustomPanelSpec => ({ panel: 'custom', ...p })
|
|
52
|
+
|
|
53
|
+
export type KindMeta = {
|
|
54
|
+
/** live write-through target, repo-root-relative (e.g. assets/heroes/ground-rects.json) */
|
|
55
|
+
file?: string
|
|
56
|
+
panels?: PanelSpec[]
|
|
57
|
+
/** ships to an art-normalized folder: sizes are in art-height units */
|
|
58
|
+
artNormalized?: boolean
|
|
59
|
+
/** repo-owned semantics gate, run before any metadata write-through —
|
|
60
|
+
* return error strings to reject the write (loud, never silent) */
|
|
61
|
+
validate?: (data: unknown) => string[] | void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type Kind = {
|
|
65
|
+
id: string
|
|
66
|
+
category: string
|
|
67
|
+
assets: AssetSpec[]
|
|
68
|
+
generate: GeneratorSpec
|
|
69
|
+
/** icon kinds get no geometry, thumbnails only */
|
|
70
|
+
mode?: 'sprite' | 'icon'
|
|
71
|
+
ship?: { dir?: string; via?: string }
|
|
72
|
+
meta?: KindMeta
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type KitConfig = {
|
|
76
|
+
port?: number
|
|
77
|
+
/** roll workspace root, repo-root-relative; gitignored */
|
|
78
|
+
workspace?: string
|
|
79
|
+
comfy?: { host: string; workflows: string; heal?: string }
|
|
80
|
+
kinds: Kind[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ResolvedKit = KitConfig & { port: number; workspace: string }
|
|
84
|
+
|
|
85
|
+
export function validateKit(k: KitConfig): string[] {
|
|
86
|
+
const errs: string[] = []
|
|
87
|
+
if (!Array.isArray(k.kinds) || k.kinds.length === 0) errs.push('kinds must be a non-empty array')
|
|
88
|
+
const kindIds = new Set<string>()
|
|
89
|
+
const assetIds = new Set<string>()
|
|
90
|
+
for (const kind of k.kinds ?? []) {
|
|
91
|
+
if (!kind.id || !kind.category) errs.push(`kind needs id + category (got ${JSON.stringify({ id: kind.id, category: kind.category })})`)
|
|
92
|
+
if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
|
|
93
|
+
kindIds.add(kind.id)
|
|
94
|
+
if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
|
|
95
|
+
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`)
|
|
96
|
+
for (const a of kind.assets ?? []) {
|
|
97
|
+
if (!a.id || typeof a.prompt !== 'string') errs.push(`kind '${kind.id}': asset needs id + prompt (got ${JSON.stringify(a).slice(0, 80)})`)
|
|
98
|
+
if (assetIds.has(a.id)) errs.push(`duplicate asset id '${a.id}' (ids are global across kinds)`)
|
|
99
|
+
assetIds.add(a.id)
|
|
100
|
+
}
|
|
101
|
+
const panelIds = new Set<string>()
|
|
102
|
+
for (const p of kind.meta?.panels ?? []) {
|
|
103
|
+
if (p.panel !== 'custom') continue
|
|
104
|
+
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}')`)
|
|
105
|
+
if (panelIds.has(p.id)) errs.push(`kind '${kind.id}': duplicate custom panel id '${p.id}'`)
|
|
106
|
+
panelIds.add(p.id)
|
|
107
|
+
if (typeof p.script !== 'string' || p.script === '') errs.push(`kind '${kind.id}' panel '${p.id}': script path required (repo-root-relative ES module)`)
|
|
108
|
+
else if (p.script.startsWith('/') || p.script.split('/').includes('..'))
|
|
109
|
+
errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return errs
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Custom panel scripts that don't exist on disk — checked at kit load AND
|
|
116
|
+
* studio boot so a typo'd path dies loud, never as a silent 404 in the SPA. */
|
|
117
|
+
export function missingPanelScripts(kit: KitConfig, root: string): string[] {
|
|
118
|
+
const missing: string[] = []
|
|
119
|
+
for (const kind of kit.kinds) {
|
|
120
|
+
for (const p of kind.meta?.panels ?? []) {
|
|
121
|
+
if (p.panel !== 'custom') continue
|
|
122
|
+
if (!existsSync(join(root, p.script))) missing.push(`kind '${kind.id}' panel '${p.id}': ${p.script} not found under ${root}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return missing
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function defineKit(k: KitConfig): ResolvedKit {
|
|
129
|
+
const errs = validateKit(k)
|
|
130
|
+
if (errs.length > 0) throw new Error(`invalid kit:\n ${errs.join('\n ')}`)
|
|
131
|
+
return { ...k, port: k.port ?? 4800, workspace: k.workspace ?? '.rv-assets/out' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const defineKind = (k: Kind): Kind => k
|
|
135
|
+
|
|
136
|
+
export async function loadKit(root: string): Promise<ResolvedKit> {
|
|
137
|
+
const path = join(root, '.rv-assets/kit.ts')
|
|
138
|
+
if (!existsSync(path)) throw new Error(`no .rv-assets/kit.ts in ${root} — create one (defineKit) or run the init snippet from the docs`)
|
|
139
|
+
const mod = (await import(path)) as { default?: ResolvedKit }
|
|
140
|
+
if (mod.default == null) throw new Error(`${path}: must default-export defineKit({...})`)
|
|
141
|
+
const missing = missingPanelScripts(mod.default, root)
|
|
142
|
+
if (missing.length > 0) throw new Error(`kit custom panel scripts missing — create them or fix the paths:\n ${missing.join('\n ')}`)
|
|
143
|
+
return mod.default
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── lookups shared by server + CLI ──────────────────────────────────────────
|
|
147
|
+
export type FoundAsset = { kind: Kind; asset: AssetSpec }
|
|
148
|
+
|
|
149
|
+
export function findAsset(kit: KitConfig, assetId: string): FoundAsset | null {
|
|
150
|
+
for (const kind of kit.kinds) {
|
|
151
|
+
const asset = kind.assets.find((a) => a.id === assetId)
|
|
152
|
+
if (asset != null) return { kind, asset }
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function allAssets(kit: KitConfig): FoundAsset[] {
|
|
158
|
+
return kit.kinds.flatMap((kind) => kind.assets.map((asset) => ({ kind, asset })))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const categoriesOf = (kit: KitConfig): string[] => [...new Set(kit.kinds.map((k) => k.category))]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// kits/identity — favicon / logo / OG as asset kinds. Two paths:
|
|
2
|
+
// - canvas mark: deterministic, instant — `renderIdentity` writes the whole
|
|
3
|
+
// set headlessly (the CLI `identity` command).
|
|
4
|
+
// - comfy mark: the kinds go through the normal roll-and-pick studio loop
|
|
5
|
+
// (a placeholder-grade logo auto-accepted is a non-goal).
|
|
6
|
+
// Outputs are COMMITTED: public/favicon.png (512² master), public/og.png
|
|
7
|
+
// (1200×630), public/logo.png. Brand data is per-repo and never defaults
|
|
8
|
+
// across repos.
|
|
9
|
+
import { createCanvas, type SKRSContext2D } from '@napi-rs/canvas'
|
|
10
|
+
import { mkdirSync } from 'fs'
|
|
11
|
+
import { dirname, join } from 'path'
|
|
12
|
+
import sharp from 'sharp'
|
|
13
|
+
import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
|
|
14
|
+
|
|
15
|
+
export type Brand = {
|
|
16
|
+
name: string
|
|
17
|
+
tagline?: string
|
|
18
|
+
/** background / accent / ink — used by the default og layout */
|
|
19
|
+
palette: { bg: string; accent: string; ink: string }
|
|
20
|
+
/** the mark: a canvas draw (deterministic) or a comfy generator (roll-and-pick) */
|
|
21
|
+
mark: CanvasGen | ComfyGen
|
|
22
|
+
/** og layout hook — default: bg fill, mark left, name+tagline right */
|
|
23
|
+
drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const FAVICON_SIZE = 512
|
|
27
|
+
export const OG_W = 1200
|
|
28
|
+
export const OG_H = 630
|
|
29
|
+
export const LOGO_SIZE = 1024
|
|
30
|
+
|
|
31
|
+
export function identityKinds(brand: Brand): Kind[] {
|
|
32
|
+
return [
|
|
33
|
+
defineKind({
|
|
34
|
+
id: 'identity-logo', category: 'Identity', mode: 'icon',
|
|
35
|
+
assets: [{ id: 'identity-logo', prompt: `logo mark for ${brand.name}`, label: 'logo' }],
|
|
36
|
+
generate: brand.mark,
|
|
37
|
+
}),
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }): void => {
|
|
42
|
+
ctx.fillStyle = brand.palette.bg
|
|
43
|
+
ctx.fillRect(0, 0, size.w, size.h)
|
|
44
|
+
ctx.fillStyle = brand.palette.accent
|
|
45
|
+
ctx.fillRect(0, size.h - 14, size.w, 14)
|
|
46
|
+
ctx.fillStyle = brand.palette.ink
|
|
47
|
+
ctx.font = `bold ${Math.round(size.h / 6)}px sans-serif`
|
|
48
|
+
ctx.textBaseline = 'middle'
|
|
49
|
+
ctx.fillText(brand.name, 64, size.h / 2 - (brand.tagline != null ? size.h / 12 : 0))
|
|
50
|
+
if (brand.tagline != null) {
|
|
51
|
+
ctx.font = `${Math.round(size.h / 14)}px sans-serif`
|
|
52
|
+
ctx.fillStyle = brand.palette.accent
|
|
53
|
+
ctx.fillText(brand.tagline, 66, size.h / 2 + size.h / 9)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Headless render of the full identity set — canvas marks only (a comfy
|
|
58
|
+
* mark goes through the studio; this errors loud instead of guessing). */
|
|
59
|
+
export async function renderIdentity(brand: Brand, root: string): Promise<string[]> {
|
|
60
|
+
if (brand.mark.backend !== 'canvas') throw new Error(`brand '${brand.name}' uses a comfy mark — roll + pick it in the studio; 'identity' renders canvas marks only`)
|
|
61
|
+
const out: string[] = []
|
|
62
|
+
const write = async (rel: string, buf: Buffer): Promise<void> => {
|
|
63
|
+
const path = join(root, rel)
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
65
|
+
await Bun.write(path, buf)
|
|
66
|
+
out.push(rel)
|
|
67
|
+
}
|
|
68
|
+
// logo master — the mark draws into a square canvas
|
|
69
|
+
const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
|
|
70
|
+
await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
|
|
71
|
+
const logoPng = logo.toBuffer('image/png')
|
|
72
|
+
await write('public/logo.png', logoPng)
|
|
73
|
+
// favicon = logo master resized to 512² (browsers derive the rest)
|
|
74
|
+
await write('public/favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
|
|
75
|
+
// og card
|
|
76
|
+
const og = createCanvas(OG_W, OG_H)
|
|
77
|
+
;(brand.drawOg ?? defaultOg)(og.getContext('2d'), brand, { w: OG_W, h: OG_H })
|
|
78
|
+
await write('public/og.png', og.toBuffer('image/png'))
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
@@ -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,122 @@
|
|
|
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 } 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 { renderIdentity, type Brand } from './kits/identity-kit.ts'
|
|
11
|
+
import { assetDir, listRolls, 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 written = await renderIdentity(mod.default!, root)
|
|
95
|
+
for (const w of written) io.print(`ok: ${w}`)
|
|
96
|
+
return 0
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
|
|
100
|
+
guarded(io, async () => {
|
|
101
|
+
let bad = 0
|
|
102
|
+
const kit = await loadKit(root).catch((e: Error) => { io.print(`FAIL: ${e.message}`); bad++; return null })
|
|
103
|
+
if (kit == null) return 1
|
|
104
|
+
io.print(`ok: kit loads — ${kit.kinds.length} kind(s), ${allAssets(kit).length} asset(s), workspace ${kit.workspace}`)
|
|
105
|
+
if (kit.comfy != null) {
|
|
106
|
+
try {
|
|
107
|
+
const wfs = await loadWorkflows(join(root, kit.comfy.workflows))
|
|
108
|
+
io.print(`ok: ${wfs.size} workflow pair(s) bound (${[...wfs.keys()].join(', ')})`)
|
|
109
|
+
} catch (e) {
|
|
110
|
+
io.print(`FAIL: ${(e as Error).message}`)
|
|
111
|
+
bad++
|
|
112
|
+
}
|
|
113
|
+
const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
|
|
114
|
+
io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
|
|
115
|
+
}
|
|
116
|
+
if (existsSync(join(root, '.rv-identity/brand.ts'))) {
|
|
117
|
+
const outs = ['public/favicon.png', 'public/og.png', 'public/logo.png'].filter((p) => !existsSync(join(root, p)))
|
|
118
|
+
if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
|
|
119
|
+
else io.print('ok: identity outputs present')
|
|
120
|
+
}
|
|
121
|
+
return bad > 0 ? 1 : 0
|
|
122
|
+
})
|