@homie-rocks/studio 0.51.4 → 0.51.5
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 +29 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/SKILL.md +1 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +45 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
|
@@ -1,51 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
* with a receipt for every call. The free parts (the starter library, optimising, checking, the lineup)
|
|
5
|
-
* are `npx --no-install homie-studio assets …`; this script is only the paid route and its registry.
|
|
6
|
-
*
|
|
7
|
-
* check the LOCAL fal key (a free check), the studio, the registry's endpoints; a fal
|
|
8
|
-
* connector signed in from the app is a separate sign-in this script cannot see
|
|
9
|
-
* registry [--write] every registry endpoint's live price again (free), and what moved or retired
|
|
10
|
-
* budget <game> --cap <usd> what the person agreed to spend on this game's generated models
|
|
11
|
-
* quote <game> [--count <n>] what one prop costs today (concept + mesh), and n of them, against the cap
|
|
12
|
-
* prop <game> <asset> --card "Items/Lantern" --what "<the thing, in plain words>" [--height <m>] [--faces 1500]
|
|
13
|
-
* [--dry-run | --yes]
|
|
14
|
-
* step 1 (no concept yet): ONE concept image in the locked style (the derived style prompt; the golden images as
|
|
15
|
-
* references when there are any), plain background, even light. Then STOP: look at it.
|
|
16
|
-
* step 2 (`prop … --mesh --yes`): Tripo P1 image-to-3D from that concept (textured, face_limit from the tier),
|
|
17
|
-
* then `homie-studio assets add` makes it phone-sized for free and records it with every step and receipt.
|
|
18
|
-
* character <game> <asset> --card "Players/Ranger" --what "<who, in plain words>" [--height <m>] [--like <library item>]
|
|
19
|
-
* [--faces 7000] [--dry-run | --yes]
|
|
20
|
-
* step 1: ONE full-body concept in an A-pose, front view, in the locked style (the golden images, or --like: a
|
|
21
|
-
* library character's thumbnail as the style reference). Then STOP: look at it.
|
|
22
|
-
* step 2 (`character … --mesh --yes`): Meshy 7.1 image-to-3D with a humanoid auto-rig (textured, about US$1.40),
|
|
23
|
-
* then `homie-studio assets add --rigged` makes it phone-sized for free, maps its skeleton to the standard and
|
|
24
|
-
* retargets the library's clips onto it (KayKit's CC0 humanoid set by default; --clips-from another).
|
|
25
|
-
* import <game> <asset> --receipt <job.json> --file <completed.glb> [--kind prop|character] [--card "…"] [--height <m>]
|
|
26
|
-
* [--concept <image> --concept-receipt <job.json>] [--usd <n>] [--dry-run]
|
|
27
|
-
* a job ALREADY RUN through a fal connector (its MCP server, signed in from the app, with no key in this shell),
|
|
28
|
-
* brought in under the same budget, receipts and provenance as one made here: the receipt's request id and cost
|
|
29
|
-
* go to art/receipts.jsonl and the game's budget (refused with no budget, or past the cap), the file is kept in
|
|
30
|
-
* art/<asset>/raw/, then `homie-studio assets add` makes it phone-sized and records every step. No network; the
|
|
31
|
-
* same receipt imported twice is counted once.
|
|
32
|
-
* mood <game> <a|b|c|all> [--yes] a painted mood image per style-board direction (a target, never shipped)
|
|
33
|
-
* receipts <game> what this game's models cost so far, from art/receipts.jsonl
|
|
34
|
-
*
|
|
35
|
-
* Every paid call is priced from fal's own pricing API first (a credit-billed endpoint through the registry's credit
|
|
36
|
-
* table), refused with no budget or past the cap, asked again without --yes, receipted the moment fal accepts it, and
|
|
37
|
-
* resumed (never paid twice) from <out>.request. Never a retry loop: look, change the words, run once more.
|
|
38
|
-
* FAL_QUEUE_URL, FAL_API_URL and FAL_STORAGE_URL point it at a test double. Every command takes --json.
|
|
39
|
-
*/
|
|
2
|
+
/** models.mjs: local asset preparation only. Generation, schemas and pricing belong to the official provider MCP.
|
|
3
|
+
* Read ../SKILL.md for the current workflow. */
|
|
40
4
|
import { spawnSync } from 'node:child_process';
|
|
41
5
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
42
6
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
43
7
|
import { fileURLToPath } from 'node:url';
|
|
44
8
|
import { experienceDir, experienceJson, SLUG, checkBudget, findStudio, readBudget, readJson, setBudget, writeJson } from '../../music/scripts/lib/studio.mjs';
|
|
45
|
-
|
|
9
|
+
|
|
46
10
|
|
|
47
11
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
48
|
-
const REGISTRY = join(HERE, '..', 'references', 'models.json');
|
|
49
12
|
const argv = process.argv.slice(2);
|
|
50
13
|
const flags = new Map();
|
|
51
14
|
const pos = [];
|
|
@@ -56,7 +19,6 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
56
19
|
const JSON_OUT = flags.has('json');
|
|
57
20
|
const say = (m) => { if (!JSON_OUT) process.stderr.write(`${m}\n`); };
|
|
58
21
|
const rel = (root, f) => relative(root, f).split('\\').join('/');
|
|
59
|
-
const registry = () => JSON.parse(readFileSync(REGISTRY, 'utf8'));
|
|
60
22
|
|
|
61
23
|
function studioRoot() {
|
|
62
24
|
const root = findStudio();
|
|
@@ -102,77 +64,15 @@ function receipt(root, game, line) {
|
|
|
102
64
|
}
|
|
103
65
|
|
|
104
66
|
/** A file's real picture type from its first bytes; the extension made to match (fal answers PNG or JPEG). */
|
|
105
|
-
|
|
106
|
-
const b = readFileSync(file).subarray(0, 4);
|
|
107
|
-
const ext = b[0] === 0xff && b[1] === 0xd8 ? '.jpg' : b[0] === 0x89 && b[1] === 0x50 ? '.png' : b.toString('latin1', 0, 4) === 'RIFF' ? '.webp' : null;
|
|
108
|
-
if (!ext || file.endsWith(ext)) return file;
|
|
109
|
-
const to = file.replace(/\.[a-z0-9]+$/i, ext);
|
|
110
|
-
renameSync(file, to);
|
|
111
|
-
for (const side of ['.json', '.request']) if (existsSync(`${file}${side}`)) renameSync(`${file}${side}`, `${to}${side}`);
|
|
112
|
-
return to;
|
|
113
|
-
}
|
|
67
|
+
|
|
114
68
|
|
|
115
69
|
/**
|
|
116
70
|
* One paid call: priced (live), checked against the cap, asked for without --yes, receipted on acceptance, resumed on
|
|
117
71
|
* a rerun. Returns { ok, file, usd } or { ok: false, needs: 'approval', price } or { already }.
|
|
118
72
|
*/
|
|
119
|
-
async function paid(root, game, { model, input, out, base, credits = null, addons = null, what, asset }) {
|
|
120
|
-
// Done before (the file and its answer are both there): never paid twice.
|
|
121
|
-
const done = [...new Set(['.png', '.jpg', '.webp', '.glb'].map((e) => out.replace(/\.[a-z0-9]+$/i, e)))].find((f) => existsSync(f) && existsSync(`${f}.json`));
|
|
122
|
-
if (done) return { ok: true, already: true, file: done, usd: readJson(`${done}.json`, {})?.price?.usd ?? 0 };
|
|
123
|
-
const p = await priceOf(model, input, { credits, addons });
|
|
124
|
-
if (flags.has('dry-run')) return { ok: true, dryRun: true, price: p };
|
|
125
|
-
const dir = budgetDir(root, game);
|
|
126
|
-
const resuming = existsSync(`${out}.request`);
|
|
127
|
-
if (!resuming) {
|
|
128
|
-
const b = checkBudget(dir, p.usd, { provider: 'fal', unit: 'usd' });
|
|
129
|
-
if (!flags.has('yes')) return { ok: false, needs: 'approval', price: p, budget: { cap: b.cap, spent: b.spent }, why: `${what} costs about US$${p.usd.toFixed(3)} (${p.basis}) on the person's own fal account; US$${b.spent.toFixed(2)} of US$${b.cap.toFixed(2)} spent so far. Run it with --yes once that is fine.` };
|
|
130
|
-
}
|
|
131
|
-
const r = await falRun(model, input, {
|
|
132
|
-
out, base, uploads: join(dir, 'uploads.json'), price: p, log: say,
|
|
133
|
-
onAccepted: async (q) => { receipt(root, game, { provider: 'fal', model, requestId: q.request_id, cost: p.usd, unit: 'usd', units: p.units, unitName: p.unit, basis: p.basis, artifact: rel(root, out), asset, what }); },
|
|
134
|
-
});
|
|
135
|
-
return { ok: true, file: r.files[0], requestId: r.requestId, usd: p.usd, price: p };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/* ---------------------------------------------------------------- commands */
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Two different sign-ins, which this check used to report as one. FAL_KEY is what THIS script pays with. A fal
|
|
142
|
-
* connector (fal's MCP server, signed in from the app) is signed in somewhere this script cannot see: it can be ready
|
|
143
|
-
* while there is no key here, and neither says anything about the other.
|
|
144
|
-
*/
|
|
145
|
-
const NO_LOCAL_KEY = 'no local FAL_KEY in this shell, so this script cannot make a paid call itself. That says nothing about a fal connector: one signed in from the app is a separate sign-in this script cannot see or use, and it may well be ready. Two ways on: set FAL_KEY and generate here (prop, character), or generate through the connector and bring the finished job in with `models.mjs import <game> <asset> --receipt <job.json> --file <model.glb>` (the same budget, receipts and provenance). The starter library, optimising and the checks need neither';
|
|
146
73
|
|
|
147
|
-
async function check() {
|
|
148
|
-
const root = findStudio();
|
|
149
|
-
const reg = registry();
|
|
150
|
-
const local = Boolean(falKey());
|
|
151
|
-
const key = local ? await checkKey() : { ok: false, why: NO_LOCAL_KEY };
|
|
152
|
-
const auth = {
|
|
153
|
-
localKey: key.ok ? 'works (checked free)' : local ? `set, but not accepted: ${key.why}` : 'not set',
|
|
154
|
-
connector: 'not checked here: a fal connector signs in from the app, apart from this script. Ask it for its own account status; if it is ready, generate there and `import` the finished job',
|
|
155
|
-
paidRoutes: [...(key.ok ? ['prop, character and mood here (FAL_KEY)'] : []), 'import: a job a fal connector already ran, with its receipt and its file'],
|
|
156
|
-
};
|
|
157
|
-
return { ok: true, command: 'check', studio: root, fal: key.ok ? `the key works (checked free; from ${key.source})` : key.why, auth, registry: { checked: reg.checked, concept: reg.concept.noRefs.endpoint, conceptWithRefs: reg.concept.withRefs.endpoint, mesh: reg.mesh.prop.endpoint, mood: reg.mood.endpoint } };
|
|
158
|
-
}
|
|
159
74
|
|
|
160
|
-
|
|
161
|
-
const reg = registry();
|
|
162
|
-
const rows = [];
|
|
163
|
-
const entries = [['concept.withRefs', reg.concept.withRefs], ['concept.noRefs', reg.concept.noRefs], ['mesh.prop', reg.mesh.prop], ['mood', reg.mood], ...reg.mesh.fallbacks.map((f, i) => [`mesh.fallbacks.${i}`, f])];
|
|
164
|
-
for (const [at, e] of entries) {
|
|
165
|
-
try {
|
|
166
|
-
const u = await unitPrice(e.endpoint);
|
|
167
|
-
const usd = /^credits?$/i.test(u.unit) && e.credits ? +(u.unitPrice * (e.credits.base + (e.credits.texture && e.input?.texture !== false ? e.credits.texture : 0))).toFixed(4) : u.unitPrice;
|
|
168
|
-
const moved = Math.abs(usd - Number(e.price.usd)) > 1e-6;
|
|
169
|
-
rows.push({ at, endpoint: e.endpoint, unit: u.unit, unitPrice: u.unitPrice, usd, recorded: e.price.usd, moved });
|
|
170
|
-
if (flags.has('write')) { e.price.usd = usd; e.price.read = new Date().toISOString().slice(0, 10); }
|
|
171
|
-
} catch (error) { rows.push({ at, endpoint: e.endpoint, error: /404/.test(error.message) ? 'retired or renamed (404): find its successor on fal.ai/explore/3d and update the registry' : error.message }); }
|
|
172
|
-
}
|
|
173
|
-
if (flags.has('write')) { reg.checked = new Date().toISOString().slice(0, 10); writeFileSync(REGISTRY, `${JSON.stringify(reg, null, 2)}\n`); }
|
|
174
|
-
return { ok: rows.every((r) => !r.error), command: 'registry', checked: reg.checked, rows };
|
|
175
|
-
}
|
|
75
|
+
/* ---------------------------------------------------------------- commands */
|
|
176
76
|
|
|
177
77
|
function budget(root) {
|
|
178
78
|
const game = pos[1];
|
|
@@ -186,147 +86,11 @@ function budget(root) {
|
|
|
186
86
|
}
|
|
187
87
|
|
|
188
88
|
/** The concept's words: the thing, then the derived style prompt (never edited by hand), then how to frame it. */
|
|
189
|
-
function conceptInput(root, game, what) {
|
|
190
|
-
const reg = registry();
|
|
191
|
-
const style = studio(root, ['style', 'prompt', game]);
|
|
192
|
-
const golden = (style.golden ?? []).map((g) => relative(root, join(experienceDir(root, game), g.path))).filter((p) => existsSync(join(root, p))).slice(0, 4);
|
|
193
|
-
const prompt = `${String(what).trim().replace(/\.$/, '')}. A single game prop, the whole object in frame, centred, seen three-quarters from slightly above. ${style.prompt.text}.`;
|
|
194
|
-
if (golden.length) {
|
|
195
|
-
const c = reg.concept.withRefs;
|
|
196
|
-
return { model: c.endpoint, input: { ...c.input, prompt: `${prompt} Match the style of the reference images exactly; do not copy their content.`, [c.refs]: golden.map((g) => `@file:${g}`) }, refs: golden, rev: style.prompt.rev };
|
|
197
|
-
}
|
|
198
|
-
const c = reg.concept.noRefs;
|
|
199
|
-
return { model: c.endpoint, input: { ...c.input, prompt }, refs: [], rev: style.prompt.rev };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function prop(root) {
|
|
203
|
-
const [, game, asset] = pos;
|
|
204
|
-
gameDir(root, game);
|
|
205
|
-
if (!SLUG.test(String(asset ?? ''))) throw new Error('name the asset: models.mjs prop <game> <asset id> --card "Items/<Name>" --what "<the thing>"');
|
|
206
|
-
const reg = registry();
|
|
207
|
-
const dir = join(root, 'art', asset);
|
|
208
|
-
mkdirSync(join(dir, 'raw'), { recursive: true });
|
|
209
|
-
const conceptFile = ['concept.png', 'concept.jpg', 'concept.webp'].map((f) => join(dir, f)).find((f) => existsSync(f) && existsSync(`${f}.json`));
|
|
210
|
-
const card = String(flags.get('card') ?? `Items/${asset}`);
|
|
211
|
-
const height = flags.has('height') ? Number(flags.get('height')) : null;
|
|
212
|
-
// Step 1: the concept, then stop and look.
|
|
213
|
-
if (!conceptFile || flags.has('concept-again')) {
|
|
214
|
-
const what = flags.get('what');
|
|
215
|
-
if (!what) throw new Error('--what "<the thing, in plain words: shape, material, size>" (the style comes from the game\'s locked decisions)');
|
|
216
|
-
const c = conceptInput(root, game, what);
|
|
217
|
-
if (flags.has('concept-again') && conceptFile) {
|
|
218
|
-
const was = `concept-was-${Date.now()}`;
|
|
219
|
-
for (const f of [conceptFile, `${conceptFile}.json`, `${conceptFile}.request`]) if (existsSync(f)) renameSync(f, f.replace(/concept(?=\.[a-z]+)/, was));
|
|
220
|
-
}
|
|
221
|
-
const meshPrice = await priceOf(reg.mesh.prop.endpoint, { ...reg.mesh.prop.input, image_url: 'x' }, { credits: reg.mesh.prop.credits }).catch(() => null);
|
|
222
|
-
const r = await paid(root, game, { model: c.model, input: c.input, out: join(dir, 'concept.png'), base: root, what: `the concept image for ${asset}`, asset });
|
|
223
|
-
if (!r.ok || r.dryRun) return { ...r, command: 'prop', step: 'concept', game, asset, then: meshPrice ? { what: 'the mesh (Tripo P1, textured)', usd: meshPrice.usd } : null, total: r.price && meshPrice ? +(r.price.usd + meshPrice.usd).toFixed(3) : null };
|
|
224
|
-
const file = fixExt(r.file);
|
|
225
|
-
writeJson(join(dir, 'concept.meta.json'), { what: String(what), card, height, model: c.model, refs: c.refs, promptRev: c.rev, prompt: c.input.prompt });
|
|
226
|
-
return { ok: true, command: 'prop', step: 'concept', game, asset, concept: rel(root, file), usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, next: `look at ${rel(root, file)} first (does it read as the thing, in the game's style, whole, on a plain background?). Then: models.mjs prop ${game} ${asset} --mesh --yes (about US$${meshPrice?.usd ?? '0.50'}); or change --what and --concept-again (never a loop)` };
|
|
227
|
-
}
|
|
228
|
-
if (!flags.has('mesh')) return { ok: true, command: 'prop', step: 'look', game, asset, concept: rel(root, conceptFile), next: `the concept exists: look at it, then models.mjs prop ${game} ${asset} --mesh --yes` };
|
|
229
|
-
// Step 2: the mesh from the concept, then optimised and recorded for free.
|
|
230
|
-
const meta = readJson(join(dir, 'concept.meta.json'), {});
|
|
231
|
-
const m = reg.mesh.prop;
|
|
232
|
-
const tierFaces = Number(flags.get('faces') ?? 1500);
|
|
233
|
-
const input = { ...m.input, image_url: `@file:${rel(root, conceptFile)}`, [m.faceLimit]: tierFaces };
|
|
234
|
-
const meshOut = join(dir, 'raw', 'mesh.glb');
|
|
235
|
-
const r = await paid(root, game, { model: m.endpoint, input, out: meshOut, base: root, credits: m.credits, what: `the 3D model for ${asset}`, asset });
|
|
236
|
-
if (!r.ok || r.dryRun) return { ...r, command: 'prop', step: 'mesh', game, asset };
|
|
237
|
-
const concept = readJson(`${conceptFile}.json`, {});
|
|
238
|
-
const mesh = readJson(`${r.file}.json`, {});
|
|
239
|
-
const steps = [
|
|
240
|
-
{ what: 'concept', provider: 'fal', endpoint: concept.model ?? meta.model, usd: concept.price?.usd ?? null, receipt: rel(root, `${conceptFile}.json`), refs: meta.refs ?? [], promptRev: meta.promptRev ?? null, requestId: concept.requestId ?? null, at: concept.at ?? null },
|
|
241
|
-
{ what: 'mesh', provider: 'fal', endpoint: m.endpoint, input: { [m.faceLimit]: tierFaces, texture: true }, usd: mesh.price?.usd ?? r.usd, receipt: rel(root, `${r.file}.json`), requestId: mesh.requestId ?? r.requestId ?? null, at: mesh.at ?? null },
|
|
242
|
-
];
|
|
243
|
-
const stepsFile = join(dir, 'steps.json');
|
|
244
|
-
writeJson(stepsFile, steps);
|
|
245
|
-
const added = studio(root, ['assets', 'add', game, '--file', rel(root, r.file), '--route', 'generated', '--license', 'generated', '--as', asset, '--kind', 'prop', '--card', meta.card ?? card, '--steps', rel(root, stepsFile), '--concept', rel(root, conceptFile), '--slug', asset, ...((meta.height ?? height) ? ['--height', String(meta.height ?? height)] : [])]);
|
|
246
|
-
return { ok: true, command: 'prop', step: 'mesh', game, asset, model: added.file, before: added.before, after: added.after, usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, receipts: steps.map((s) => s.receipt), next: `homie-studio assets lineup ${game} (true scale, silhouettes, palette drift); look at it` };
|
|
247
|
-
}
|
|
248
89
|
|
|
249
90
|
/** A character's concept words: who, the derived style prompt without its prop framing, then an A-pose, front view. */
|
|
250
|
-
function characterConcept(root, game, what, like) {
|
|
251
|
-
const reg = registry();
|
|
252
|
-
const style = studio(root, ['style', 'prompt', game]);
|
|
253
|
-
const golden = (style.golden ?? []).map((g) => relative(root, join(experienceDir(root, game), g.path))).filter((p) => existsSync(join(root, p))).slice(0, 4);
|
|
254
|
-
const refs = [...golden, ...(like ? [like] : [])].slice(0, 4);
|
|
255
|
-
const look = String(style.prompt.text).replace(/,?\s*3\/4 view, centred, whole object in frame[^]*$/, '');
|
|
256
|
-
const prompt = `${String(what).trim().replace(/\.$/, '')}. One game character, full body from head to feet, standing in an A-pose (arms straight and angled down about 45 degrees away from the body, hands open, legs slightly apart), front view facing the camera, centred. ${look}, lit evenly and neutrally for modelling, plain light grey background, no ground shadow, no text, no logo.`;
|
|
257
|
-
if (refs.length) {
|
|
258
|
-
const c = reg.concept.withRefs;
|
|
259
|
-
return { model: c.endpoint, input: { ...c.input, image_size: { width: 1536, height: 2560 }, prompt: `${prompt} Match the style of the reference images exactly (shapes, proportions, colours, how it is shaded); do not copy their content.`, [c.refs]: refs.map((g) => `@file:${g}`) }, refs, rev: style.prompt.rev };
|
|
260
|
-
}
|
|
261
|
-
const c = reg.concept.noRefs;
|
|
262
|
-
return { model: c.endpoint, input: { ...c.input, image_size: { width: 1536, height: 2560 }, prompt }, refs: [], rev: style.prompt.rev };
|
|
263
|
-
}
|
|
264
91
|
|
|
265
|
-
/** A library item's thumbnail into the job's folder, as a style reference (CC0, the library's own render). */
|
|
266
|
-
async function likeRef(root, dir, item) {
|
|
267
|
-
if (!item) return null;
|
|
268
|
-
// The studio's own toolkit and its sharp (both installed in the studio), never this plugin's.
|
|
269
|
-
const { createRequire } = await import('node:module');
|
|
270
|
-
const { pathToFileURL } = await import('node:url');
|
|
271
|
-
const need = createRequire(join(root, 'package.json'));
|
|
272
|
-
const { libraryBase, loadIndex, itemThumb } = await import(pathToFileURL(join(root, 'node_modules', '@homie-rocks', 'studio', 'lib', 'library.mjs')).href);
|
|
273
|
-
const { index, lib } = await loadIndex({ lib: libraryBase() });
|
|
274
|
-
const it = index.items.find((x) => x.id === item);
|
|
275
|
-
if (!it) throw new Error(`no library item "${item}" (homie-studio assets find "<words>" --kind character)`);
|
|
276
|
-
const bytes = await itemThumb(lib, it);
|
|
277
|
-
if (!bytes) throw new Error(`${item} has no thumbnail`);
|
|
278
|
-
const sharp = (await import(pathToFileURL(need.resolve('sharp')).href)).default;
|
|
279
|
-
const file = join(dir, 'like.png');
|
|
280
|
-
await sharp(Buffer.from(bytes)).resize(768, 768, { fit: 'contain', background: '#d9d9d9' }).flatten({ background: '#d9d9d9' }).png().toFile(file);
|
|
281
|
-
return rel(root, file);
|
|
282
|
-
}
|
|
283
92
|
|
|
284
|
-
|
|
285
|
-
const [, game, asset] = pos;
|
|
286
|
-
gameDir(root, game);
|
|
287
|
-
if (!SLUG.test(String(asset ?? ''))) throw new Error('name the asset: models.mjs character <game> <asset id> --card "Players/<Name>" --what "<who>"');
|
|
288
|
-
const reg = registry();
|
|
289
|
-
const dir = join(root, 'art', asset);
|
|
290
|
-
mkdirSync(join(dir, 'raw'), { recursive: true });
|
|
291
|
-
const conceptFile = ['concept.png', 'concept.jpg', 'concept.webp'].map((f) => join(dir, f)).find((f) => existsSync(f) && existsSync(`${f}.json`));
|
|
292
|
-
const card = String(flags.get('card') ?? `Players/${asset}`);
|
|
293
|
-
const height = flags.has('height') ? Number(flags.get('height')) : null;
|
|
294
|
-
const m = reg.character.mesh;
|
|
295
|
-
const meshInput = (concept) => ({ ...m.input, image_url: concept, [m.polycount]: Number(flags.get('faces') ?? m.input.target_polycount), ...(height ? { [m.height]: height } : {}) });
|
|
296
|
-
if (!conceptFile || flags.has('concept-again')) {
|
|
297
|
-
const what = flags.get('what');
|
|
298
|
-
if (!what) throw new Error('--what "<who, in plain words: build, clothes, colours, what they carry>" (the style comes from the game\'s locked decisions)');
|
|
299
|
-
const like = flags.has('dry-run') ? (flags.get('like') ? 'like.png' : null) : await likeRef(root, dir, flags.get('like') ?? null);
|
|
300
|
-
const c = characterConcept(root, game, what, like);
|
|
301
|
-
if (flags.has('concept-again') && conceptFile) {
|
|
302
|
-
const was = `concept-was-${Date.now()}`;
|
|
303
|
-
for (const f of [conceptFile, `${conceptFile}.json`, `${conceptFile}.request`]) if (existsSync(f)) renameSync(f, f.replace(/concept(?=\.[a-z]+)/, was));
|
|
304
|
-
}
|
|
305
|
-
const meshPrice = await priceOf(m.endpoint, meshInput('x'), { addons: m.addons }).catch(() => null);
|
|
306
|
-
const r = await paid(root, game, { model: c.model, input: c.input, out: join(dir, 'concept.png'), base: root, what: `the character concept for ${asset}`, asset });
|
|
307
|
-
if (!r.ok || r.dryRun) return { ...r, command: 'character', step: 'concept', game, asset, then: meshPrice ? { what: 'the rigged, textured model (Meshy 7.1)', usd: meshPrice.usd, basis: meshPrice.basis } : null, total: r.price && meshPrice ? +(r.price.usd + meshPrice.usd).toFixed(3) : null };
|
|
308
|
-
const file = fixExt(r.file);
|
|
309
|
-
writeJson(join(dir, 'concept.meta.json'), { what: String(what), card, height, model: c.model, refs: c.refs, promptRev: c.rev, prompt: c.input.prompt, like: flags.get('like') ?? null });
|
|
310
|
-
return { ok: true, command: 'character', step: 'concept', game, asset, concept: rel(root, file), usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, next: `look at ${rel(root, file)} first (one character, whole, in an A-pose, in the game's style, on a plain background?). Then: models.mjs character ${game} ${asset} --mesh --yes (about US$${meshPrice?.usd ?? '1.40'}); or change --what and --concept-again (never a loop)` };
|
|
311
|
-
}
|
|
312
|
-
if (!flags.has('mesh')) return { ok: true, command: 'character', step: 'look', game, asset, concept: rel(root, conceptFile), next: `the concept exists: look at it, then models.mjs character ${game} ${asset} --mesh --yes` };
|
|
313
|
-
const meta = readJson(join(dir, 'concept.meta.json'), {});
|
|
314
|
-
const input = meshInput(`@file:${rel(root, conceptFile)}`);
|
|
315
|
-
if (meta.height && !height) input[m.height] = meta.height;
|
|
316
|
-
const meshOut = join(dir, 'raw', 'rigged.glb');
|
|
317
|
-
const r = await paid(root, game, { model: m.endpoint, input, out: meshOut, base: root, credits: null, addons: m.addons, what: `the rigged 3D character for ${asset}`, asset });
|
|
318
|
-
if (!r.ok || r.dryRun) return { ...r, command: 'character', step: 'mesh', game, asset };
|
|
319
|
-
const concept = readJson(`${conceptFile}.json`, {});
|
|
320
|
-
const mesh = readJson(`${r.file}.json`, {});
|
|
321
|
-
const steps = [
|
|
322
|
-
{ what: 'concept', provider: 'fal', endpoint: concept.model ?? meta.model, usd: concept.price?.usd ?? null, receipt: rel(root, `${conceptFile}.json`), refs: meta.refs ?? [], promptRev: meta.promptRev ?? null, requestId: concept.requestId ?? null, at: concept.at ?? null },
|
|
323
|
-
{ what: 'mesh and rig', provider: 'fal', endpoint: m.endpoint, input: { [m.polycount]: input[m.polycount], pose_mode: input.pose_mode, enable_rigging: true, should_texture: true }, usd: mesh.price?.usd ?? r.usd, receipt: rel(root, `${r.file}.json`), requestId: mesh.requestId ?? r.requestId ?? null, at: mesh.at ?? null },
|
|
324
|
-
];
|
|
325
|
-
const stepsFile = join(dir, 'steps.json');
|
|
326
|
-
writeJson(stepsFile, steps);
|
|
327
|
-
const added = studio(root, ['assets', 'add', game, '--file', rel(root, r.file), '--route', 'generated', '--license', 'generated', '--as', asset, '--kind', 'character', '--rigged', '--card', meta.card ?? card, '--steps', rel(root, stepsFile), '--concept', rel(root, conceptFile), '--slug', asset, ...(flags.get('clips-from') ? ['--clips-from', String(flags.get('clips-from'))] : []), ...((meta.height ?? height) ? ['--height', String(meta.height ?? height)] : [])]);
|
|
328
|
-
return { ok: true, command: 'character', step: 'mesh', game, asset, model: added.model ?? added.file, anims: added.anims ?? null, skeleton: added.skeleton ?? null, verbs: added.verbs ?? [], before: added.before, after: added.after, usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, receipts: steps.map((s) => s.receipt), next: `homie-studio anim preview ${game} --asset ${asset} (its clips, looping) and homie-studio assets lineup ${game}; look at both` };
|
|
329
|
-
}
|
|
93
|
+
/** A library item's thumbnail into the job's folder, as a style reference (CC0, the library's own render). */
|
|
330
94
|
|
|
331
95
|
/**
|
|
332
96
|
* A connector's job receipt, read tolerantly (what was saved when the connector accepted the job): its request id, its
|
|
@@ -347,12 +111,7 @@ function jobReceipt(file, { usd = null } = {}) {
|
|
|
347
111
|
const pick = [usd, j.usd, j.cost, j.price?.usd, j.estimate?.usd, j.estimatedCost, j.estimated_cost, j.price].map((x) => (x === null || x === undefined || x === '' || typeof x === 'object' ? NaN : Number(x))).find((n) => Number.isFinite(n) && n >= 0);
|
|
348
112
|
// No cost on the receipt: the registry's recorded price for that endpoint, said as such. Never zero by default.
|
|
349
113
|
let price = Number.isFinite(pick) ? { usd: pick, basis: usd !== null && Number(usd) === pick ? 'the price given with --usd' : 'the connector receipt\'s own cost' } : null;
|
|
350
|
-
if (!price) {
|
|
351
|
-
const reg = registry();
|
|
352
|
-
const known = [reg.concept.withRefs, reg.concept.noRefs, reg.mesh.prop, reg.mood, reg.character?.mesh, ...(reg.mesh.fallbacks ?? [])].find((e) => e?.endpoint === endpoint);
|
|
353
|
-
if (known && Number.isFinite(Number(known.price?.usd))) price = { usd: Number(known.price.usd), basis: `the registry's recorded price for ${endpoint} (read ${known.price.read ?? reg.checked}); the receipt names none` };
|
|
354
|
-
}
|
|
355
|
-
if (!price) throw new Error(`${file} names no cost and ${endpoint} is not in the registry: say what it cost with --usd <US dollars> (an unknown cost is never recorded as nothing)`);
|
|
114
|
+
if (!price) throw new Error(`${file} names no cost for ${endpoint}: say what it cost with --usd <US dollars> (an unknown cost is never recorded as nothing)`);
|
|
356
115
|
return { file: abs, requestId: requestId.trim(), endpoint: endpoint.trim(), price, at: typeof (j.at ?? j.createdAt ?? j.created_at) === 'string' ? (j.at ?? j.createdAt ?? j.created_at) : null };
|
|
357
116
|
}
|
|
358
117
|
|
|
@@ -411,40 +170,6 @@ async function importJob(root) {
|
|
|
411
170
|
return { ok: true, command: 'import', game, asset, kind, via: 'connector', model: added.model ?? added.file, before: added.before, after: added.after, usd: owed, counted: fresh.map((j) => j.requestId), alreadyCounted: jobs.filter((j) => seen.has(j.requestId)).map((j) => j.requestId), spent: b?.spent ?? null, cap: b?.cap ?? null, receipts: steps.map((s) => s.receipt), next: `homie-studio assets lineup ${game} (true scale, silhouettes, palette drift); look at it` };
|
|
412
171
|
}
|
|
413
172
|
|
|
414
|
-
async function quote(root) {
|
|
415
|
-
const game = pos[1];
|
|
416
|
-
gameDir(root, game);
|
|
417
|
-
const reg = registry();
|
|
418
|
-
const n = Math.max(1, Number(flags.get('count') ?? 1));
|
|
419
|
-
const c = await priceOf(reg.concept.noRefs.endpoint, { ...reg.concept.noRefs.input, prompt: 'x' });
|
|
420
|
-
const m = await priceOf(reg.mesh.prop.endpoint, { ...reg.mesh.prop.input, image_url: 'x' }, { credits: reg.mesh.prop.credits });
|
|
421
|
-
const one = +(c.usd + m.usd).toFixed(3);
|
|
422
|
-
const b = readBudget(budgetDir(root, game));
|
|
423
|
-
return { ok: true, command: 'quote', game, concept: c, mesh: m, perProp: one, count: n, total: +(one * n).toFixed(3), budget: b ? { cap: b.cap, spent: b.spent, left: +(b.cap - b.spent).toFixed(3) } : null, note: 'a redo of one step costs that step again; a budget of about US$1.10 a prop allows one redo' };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
async function mood(root) {
|
|
427
|
-
const [, game, which] = pos;
|
|
428
|
-
gameDir(root, game);
|
|
429
|
-
const reg = registry();
|
|
430
|
-
const doc = readJson(join(experienceDir(root, game), 'codex', 'decisions.json'), null);
|
|
431
|
-
const dirs = (doc?.board?.directions ?? []).filter((d) => which === 'all' || d.id === which);
|
|
432
|
-
if (!dirs.length) throw new Error(`no style-board direction "${which}" (homie-studio style board ${game} draws the board first)`);
|
|
433
|
-
const out = [];
|
|
434
|
-
for (const d of dirs) {
|
|
435
|
-
const p = d.values['style.palette'] ?? {};
|
|
436
|
-
const prompt = `A painted mood image (key art) for a game: ${d.label}. ${String(doc.prompt ?? '').slice(0, 240)}. Colours ${[p.bg, p.accent, p.accent2, p.gold, p.good].filter(Boolean).join(' ')}, ${d.values['style.light']?.time ?? ''} light, seen from a ${d.values['style.camera']?.angle ?? 'three-quarter'} camera. Concept painting, no text, no logo, no real people or brands.`;
|
|
437
|
-
const file = join(root, 'art', `${game}-board`, `${d.id}-mood.png`);
|
|
438
|
-
const r = await paid(root, game, { model: reg.mood.endpoint, input: { ...reg.mood.input, prompt }, out: file, base: root, what: `a mood image for direction ${d.id.toUpperCase()}`, asset: `board-${d.id}` });
|
|
439
|
-
if (!r.ok || r.dryRun) { out.push({ direction: d.id, ...r }); if (!r.ok) break; continue; }
|
|
440
|
-
const fixed = fixExt(r.file);
|
|
441
|
-
studio(root, ['style', 'mood', game, d.id, '--image', fixed, '--usd', String(r.usd), '--receipt', rel(root, `${fixed}.json`), '--model', reg.mood.endpoint]);
|
|
442
|
-
out.push({ direction: d.id, ok: true, file: rel(root, fixed), usd: r.usd });
|
|
443
|
-
}
|
|
444
|
-
const stop = out.find((x) => x.ok === false);
|
|
445
|
-
return { ok: !stop, command: 'mood', game, images: out, ...(stop ? { needs: stop.needs, why: stop.why } : {}), note: 'a mood image is a target, labelled so, never what the game draws' };
|
|
446
|
-
}
|
|
447
|
-
|
|
448
173
|
function receiptsOf(root) {
|
|
449
174
|
const game = pos[1];
|
|
450
175
|
const lines = existsSync(join(root, 'art', 'receipts.jsonl')) ? readFileSync(join(root, 'art', 'receipts.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter((x) => x && (!game || x.game === game)) : [];
|
|
@@ -455,15 +180,9 @@ function receiptsOf(root) {
|
|
|
455
180
|
async function main() {
|
|
456
181
|
const cmd = pos[0];
|
|
457
182
|
if (!cmd || cmd === 'help' || flags.has('help')) return { ok: true, command: 'help', usage: readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0].replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '').trim() };
|
|
458
|
-
if (cmd === 'check') return check();
|
|
459
|
-
if (cmd === 'registry') return registryCheck();
|
|
460
183
|
const root = studioRoot();
|
|
461
184
|
if (cmd === 'budget') return budget(root);
|
|
462
|
-
if (cmd === 'quote') return quote(root);
|
|
463
|
-
if (cmd === 'prop') return prop(root);
|
|
464
|
-
if (cmd === 'character') return character(root);
|
|
465
185
|
if (cmd === 'import') return importJob(root);
|
|
466
|
-
if (cmd === 'mood') return mood(root);
|
|
467
186
|
if (cmd === 'receipts') return receiptsOf(root);
|
|
468
187
|
return { ok: false, command: cmd, why: `unknown command "${cmd}" (models.mjs help)` };
|
|
469
188
|
}
|