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.
Files changed (57) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +23 -8
  3. package/package.json +15 -3
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +179 -0
  11. package/src/assets/kits/identity-kit.ts +119 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +142 -0
  16. package/src/assets/server/studio-page.ts +620 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +412 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Serve.ts +34 -0
  27. package/src/cli/cmd_Tui.ts +36 -0
  28. package/src/cli/cmd_Wiki.ts +53 -0
  29. package/src/cli.ts +44 -25
  30. package/src/contracts/run-contracts.ts +33 -0
  31. package/src/core/check-ctx.ts +3 -16
  32. package/src/core/check-suite.ts +16 -2
  33. package/src/core/discover-checks.ts +48 -0
  34. package/src/core/run-check.ts +73 -0
  35. package/src/core/run-ci.ts +54 -0
  36. package/src/core/spawn-timeout.ts +65 -0
  37. package/src/core/task-runner.ts +36 -43
  38. package/src/core/wire.ts +20 -0
  39. package/src/core/write-status.ts +70 -0
  40. package/src/init-repo.ts +60 -0
  41. package/src/repo-config.ts +82 -15
  42. package/src/serve/hub-pages.ts +84 -0
  43. package/src/serve/run-serve.ts +85 -0
  44. package/src/serve/serve-hub.ts +77 -0
  45. package/src/shipkit.ts +39 -3
  46. package/src/tui/checks-panel.ts +39 -0
  47. package/src/tui/tasks-panel.ts +54 -0
  48. package/src/tui/tui-app.tsx +91 -0
  49. package/src/tui/tui-panel.ts +57 -0
  50. package/src/tui/tui-store.ts +98 -0
  51. package/src/wiki/run-wiki.ts +50 -0
  52. package/src/cli/shipkit-asks.ts +0 -26
  53. package/src/cli/shipkit-check.ts +0 -26
  54. package/src/cli/shipkit-ci.ts +0 -30
  55. package/src/cli/shipkit-contracts.ts +0 -26
  56. package/src/cli/shipkit-init.ts +0 -41
  57. 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
+ })