@homie-rocks/studio 0.48.4 → 0.50.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 +46 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +20 -16
- package/functions/FUNCTIONS.md +1 -1
- package/guides/app/SKILL.md +178 -0
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/shop/SKILL.md +153 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +5 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +18 -5
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +19 -17
- package/lib/mcp.mjs +1 -1
- package/lib/parts.mjs +6 -6
- package/lib/scaffold.mjs +45 -11
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +7 -6
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/functions-schema.mjs +15 -0
- package/worker/index.mjs +2 -1
- package/worker/parts.mjs +4 -4
- package/worker/shop-page.mjs +22 -16
- package/worker/shop-rules.mjs +4 -3
- package/worker/shop.mjs +6 -2
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* art.mjs: a game's cover, painted backdrops and textures, made at build time and never while
|
|
4
|
+
* anyone plays. Real frames come from the running game for free; generated images come from fal
|
|
5
|
+
* through the creator's own account, priced first, capped by a budget, receipted the moment fal
|
|
6
|
+
* accepts them.
|
|
7
|
+
*
|
|
8
|
+
* check ffmpeg, Chrome, the studio, the fal key (a free check)
|
|
9
|
+
* frame <game> --url <site> [--view tv|play] [--seconds 12] [--slug <art job>]
|
|
10
|
+
* real frames of the game playing, the busiest one picked
|
|
11
|
+
* cover <game> --from <image> [--focus x,y]
|
|
12
|
+
* the game's cover: 16:9, 1600x900 JPEG, into the game, named in game.json
|
|
13
|
+
* budget <slug> --cap <usd> what the person agreed to spend on this art job
|
|
14
|
+
* price --model <fal model> --input <input.json>
|
|
15
|
+
* gen <slug> --model <fal model> --input <input.json> --out <file> [--dry-run | --yes]
|
|
16
|
+
* one paid image (or a few), inside the cap, resumable, receipted
|
|
17
|
+
* tile <image> does a texture tile without a seam? numbers and a 2x2 preview
|
|
18
|
+
* fit <image> --out <file> [--width 1024] [--max-kb 300]
|
|
19
|
+
* a texture or backdrop at a size a phone can download
|
|
20
|
+
* sheet <folder> a labelled contact sheet of every image in a folder
|
|
21
|
+
*
|
|
22
|
+
* Every command takes --json. Run it from inside the studio (the folder with studio.json).
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { experienceDir, experienceJson, isServerRules, SLUG, checkBudget, findStudio, readBudget, readJson, setBudget, writeJson } from '../../music/scripts/lib/studio.mjs';
|
|
29
|
+
import { GPU_FLAGS, chromePath, htmlToPng, loadPuppeteer } from '../../video/scripts/lib/browser.mjs';
|
|
30
|
+
import { checkKey, falKey, priceOf, run as falRun } from '../../video/scripts/lib/fal.mjs';
|
|
31
|
+
import { decode, stats } from '../../playtest/scripts/lib/pixels.mjs';
|
|
32
|
+
|
|
33
|
+
const argv = process.argv.slice(2);
|
|
34
|
+
const flags = new Map();
|
|
35
|
+
const pos = [];
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
if (a.startsWith('--')) { const k = a.slice(2); const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) flags.set(k, true); else { flags.set(k, v); i++; } } else pos.push(a);
|
|
39
|
+
}
|
|
40
|
+
const JSON_OUT = flags.has('json');
|
|
41
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
const rel = (root, f) => relative(root, f).split('\\').join('/');
|
|
43
|
+
const ffmpeg = (args) => { const r = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0) throw new Error(`ffmpeg: ${String(r.stderr).trim().split('\n').pop()}`); return r; };
|
|
44
|
+
const size = (file) => { const r = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file], { encoding: 'utf8' }); const [w, h] = String(r.stdout).trim().split(',').map(Number); return { w, h }; };
|
|
45
|
+
|
|
46
|
+
function studioRoot() {
|
|
47
|
+
const root = findStudio();
|
|
48
|
+
if (!root) throw new Error('not inside a Homie studio (no studio.json here or above). The studio-setup skill makes one.');
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
function artDir(root, slug) {
|
|
52
|
+
if (!SLUG.test(String(slug ?? ''))) throw new Error('name the art job with a slug: lowercase letters, digits and hyphens (e.g. "cover" or "skyline")');
|
|
53
|
+
const d = join(root, 'art', slug);
|
|
54
|
+
mkdirSync(d, { recursive: true });
|
|
55
|
+
return d;
|
|
56
|
+
}
|
|
57
|
+
function gameDir(root, game) {
|
|
58
|
+
const d = experienceDir(root, String(game ?? ''));
|
|
59
|
+
if (!game || !existsSync(experienceJson(d))) throw new Error('a game in this studio: games/<id>/game.json');
|
|
60
|
+
return d;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A receipt line: the job's budget (running total), art/receipts.jsonl, and HOMIE_SPEND_LEDGER when set. */
|
|
64
|
+
function receipt(root, dir, line) {
|
|
65
|
+
const at = new Date().toISOString();
|
|
66
|
+
const b = readBudget(dir);
|
|
67
|
+
if (b) {
|
|
68
|
+
b.spent = +(b.spent + (Number(line.cost) || 0)).toFixed(6);
|
|
69
|
+
b.calls.push({ at, provider: line.provider, model: line.model, cost: line.cost, unit: line.unit, requestId: line.requestId ?? null, artifact: line.artifact ?? null });
|
|
70
|
+
writeJson(join(dir, 'budget.json'), b);
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(join(root, 'art'), { recursive: true });
|
|
73
|
+
appendFileSync(join(root, 'art', 'receipts.jsonl'), `${JSON.stringify({ schema: 'homie.media-receipt/1', at, ...line })}\n`);
|
|
74
|
+
const ledger = process.env.HOMIE_SPEND_LEDGER;
|
|
75
|
+
if (ledger) {
|
|
76
|
+
try { appendFileSync(ledger, `${JSON.stringify({ schema: 'homie.brand-spend/1', at, provider: line.provider, model: line.model, jobId: line.requestId ?? null, units: line.units ?? null, unit: line.unitName ?? line.unit, usd: line.cost, credits: null, unpriced: null, brand: process.env.HOMIE_SPEND_BRAND ?? null, attribution: process.env.HOMIE_SPEND_ATTRIBUTION ?? null, artifact: line.artifact ?? null })}\n`); } catch (e) { process.stderr.write(`could not write the ledger: ${e.message}\n`); }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ---------------------------------------------------------------- check */
|
|
81
|
+
|
|
82
|
+
async function check() {
|
|
83
|
+
const ff = spawnSync('ffmpeg', ['-version']).status === 0;
|
|
84
|
+
const root = findStudio();
|
|
85
|
+
const key = falKey() ? await checkKey() : { ok: false, why: 'FAL_KEY is not set (only generated images need it; frames, covers, tiles and fitting are free)' };
|
|
86
|
+
return { ok: ff, command: 'check', ffmpeg: ff, chrome: chromePath(), puppeteer: Boolean(loadPuppeteer(root)), studio: root, fal: key.ok ? `the key works (checked free; from ${key.source})` : key.why };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ---------------------------------------------------------------- frame */
|
|
90
|
+
|
|
91
|
+
async function frame(root) {
|
|
92
|
+
const game = pos[1];
|
|
93
|
+
const source = gameDir(root, game);
|
|
94
|
+
const url = String(flags.get('url') ?? '').replace(/\/+$/, '');
|
|
95
|
+
if (!/^https?:\/\//.test(url)) throw new Error('--url <the studio site: http://127.0.0.1:8787 from npm run dev, or the live site>');
|
|
96
|
+
const serverRules = isServerRules(source);
|
|
97
|
+
const view = flags.has('view') ? (flags.get('view') === 'play' ? 'play' : 'tv') : serverRules ? 'play' : 'tv';
|
|
98
|
+
const seconds = Math.max(4, Math.min(60, Number(flags.get('seconds') ?? 12)));
|
|
99
|
+
const dir = artDir(root, String(flags.get('slug') ?? 'frames'));
|
|
100
|
+
const puppeteer = loadPuppeteer(root); const exe = chromePath();
|
|
101
|
+
if (!puppeteer || !exe) throw new Error(!exe ? 'no Chrome found (set CHROME_PATH)' : 'puppeteer-core is missing (npm install in the studio)');
|
|
102
|
+
const browser = await puppeteer.launch({ executablePath: exe, headless: true, timeout: 150_000, args: [...GPU_FLAGS, '--mute-audio', '--window-size=1920,1080', '--hide-scrollbars', '--force-color-profile=srgb', '--no-first-run'] });
|
|
103
|
+
const shots = [];
|
|
104
|
+
try {
|
|
105
|
+
const page = await browser.newPage();
|
|
106
|
+
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
|
|
107
|
+
await page.setUserAgent(`${await browser.userAgent()} homie-art`);
|
|
108
|
+
await page.goto(`${url}/${game}/${view}`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
109
|
+
// The platform's own furniture never goes into art: the join card, the QR, the status chip, result cards.
|
|
110
|
+
await page.addStyleTag({ content: '.join,.chip,.card,[data-join],[data-chip],[data-results],[data-screen],[data-room-ui],[data-toast]{display:none!important}' }).catch(() => {});
|
|
111
|
+
await page.waitForSelector('iframe', { timeout: 30000 });
|
|
112
|
+
const gameFrame = page.frames().find(f => /\/__game\//.test(f.url()));
|
|
113
|
+
if (!gameFrame) throw new Error('the game frame never loaded');
|
|
114
|
+
if (serverRules) await gameFrame.waitForFunction(() => Boolean(window.__homieNet), { timeout: 30000 });
|
|
115
|
+
const before = await gameFrame.evaluate(() => window.__homieNet?.probe?.tick?.() ?? null);
|
|
116
|
+
await sleep(4000);
|
|
117
|
+
if (serverRules && view === 'tv') {
|
|
118
|
+
const after = await gameFrame.evaluate(() => window.__homieNet?.probe?.tick?.() ?? null);
|
|
119
|
+
if (!(after > before)) throw new Error('The server room is paused: a TV is not a participant. Use --view play, or keep a player in this same room, then capture again.');
|
|
120
|
+
}
|
|
121
|
+
for (let t = 0; t < seconds; t += 2) {
|
|
122
|
+
const png = await page.screenshot({ type: 'png', captureBeyondViewport: false });
|
|
123
|
+
const st = stats(decode(png, 480));
|
|
124
|
+
const f = join(dir, `frame-${String(shots.length + 1).padStart(2, '0')}.png`);
|
|
125
|
+
writeFileSync(f, png);
|
|
126
|
+
shots.push({ file: rel(root, f), ...st });
|
|
127
|
+
await sleep(2000);
|
|
128
|
+
}
|
|
129
|
+
} finally { await browser.close().catch(() => {}); }
|
|
130
|
+
const usable = shots.filter((s) => !s.black && !s.flat);
|
|
131
|
+
const best = (usable.length ? usable : shots).slice().sort((a, b) => b.edges - a.edges)[0];
|
|
132
|
+
return { ok: Boolean(usable.length), command: 'frame', view, frames: shots.map((s) => `${s.file}: detail ${(s.edges * 100).toFixed(1)}%, brightness ${s.mean}`), best: best?.file, why: usable.length ? undefined : 'every frame was black or one colour: is the game drawing?', next: `look at ${best?.file}; a cover from it: art.mjs cover ${game} --from ${best?.file}` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ---------------------------------------------------------------- cover */
|
|
136
|
+
|
|
137
|
+
function cover(root) {
|
|
138
|
+
const game = pos[1];
|
|
139
|
+
const gdir = gameDir(root, game);
|
|
140
|
+
const from = resolve(String(flags.get('from') ?? ''));
|
|
141
|
+
if (!existsSync(from)) throw new Error('--from <image>: a frame from `frame`, a painted image from `gen`, or the person\'s own art');
|
|
142
|
+
const g = readJson(experienceJson(gdir), {});
|
|
143
|
+
const pub = g.build?.mode === 'static' ? gdir : join(gdir, 'public');
|
|
144
|
+
mkdirSync(pub, { recursive: true });
|
|
145
|
+
const { w, h } = size(from);
|
|
146
|
+
if (!w || !h) throw new Error(`could not read ${from} as an image`);
|
|
147
|
+
// Crop to 16:9 around the focus (fractions; the middle by default), then 1600x900.
|
|
148
|
+
const [fx, fy] = String(flags.get('focus') ?? '0.5,0.5').split(',').map(Number);
|
|
149
|
+
const cw = Math.min(w, Math.round(h * 16 / 9)); const ch = Math.min(h, Math.round(w * 9 / 16));
|
|
150
|
+
const x = Math.max(0, Math.min(w - cw, Math.round(fx * w - cw / 2))); const y = Math.max(0, Math.min(h - ch, Math.round(fy * h - ch / 2)));
|
|
151
|
+
const out = join(pub, 'cover.jpg');
|
|
152
|
+
let q = 3;
|
|
153
|
+
for (;;) {
|
|
154
|
+
ffmpeg(['-i', from, '-vf', `crop=${cw}:${ch}:${x}:${y},scale=1600:900:flags=lanczos`, '-q:v', String(q), out]);
|
|
155
|
+
if (statSync(out).size <= 400 * 1024 || q >= 10) break;
|
|
156
|
+
q += 1;
|
|
157
|
+
}
|
|
158
|
+
g.cover = 'cover.jpg';
|
|
159
|
+
writeFileSync(experienceJson(gdir), `${JSON.stringify(g, null, 2)}\n`);
|
|
160
|
+
const st = stats(decode(out, 480));
|
|
161
|
+
const warn = [];
|
|
162
|
+
if (w < 1200) warn.push(`the source is ${w} px wide: upscaled covers look soft; capture or generate at 1600 px or more`);
|
|
163
|
+
if (st.mean < 35) warn.push(`dark (brightness ${st.mean} of 255): a card on a page reads as a black box`);
|
|
164
|
+
if (st.edges < 0.03) warn.push('little visible detail: a cover sells the game; a busier moment or a painted version reads better');
|
|
165
|
+
return { ok: true, command: 'cover', game, cover: rel(root, out), kb: Math.round(statSync(out).size / 1024), from: `${w}x${h}`, crop: `${cw}x${ch} at ${x},${y}`, gameJson: 'cover: "cover.jpg"', warnings: warn, look: 'open it: no QR code, no share panel, no debug text, not a waiting screen, and nothing the game does not contain' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---------------------------------------------------------------- money */
|
|
169
|
+
|
|
170
|
+
function budget(root) {
|
|
171
|
+
const dir = artDir(root, pos[1]);
|
|
172
|
+
const cap = Number(flags.get('cap'));
|
|
173
|
+
if (!(cap > 0 && cap <= 1000)) throw new Error('--cap <US dollars the person agreed to>');
|
|
174
|
+
const b = setBudget(dir, { provider: 'fal', unit: 'usd', cap });
|
|
175
|
+
return { ok: true, command: 'budget', slug: pos[1], cap: b.cap, spent: b.spent };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function inputOf(file) {
|
|
179
|
+
const j = readJson(resolve(String(file ?? '')), null);
|
|
180
|
+
if (!j) throw new Error('--input <file.json>: the model\'s input (its schema is on the model\'s fal page, or the fal MCP get_model_schema)');
|
|
181
|
+
return j;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function price() {
|
|
185
|
+
const model = String(flags.get('model') ?? '');
|
|
186
|
+
if (!model) throw new Error('--model <fal model id, e.g. fal-ai/flux/dev>');
|
|
187
|
+
const p = await priceOf(model, inputOf(flags.get('input')));
|
|
188
|
+
return { ok: true, command: 'price', ...p };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function gen(root) {
|
|
192
|
+
const slug = pos[1];
|
|
193
|
+
const dir = artDir(root, slug);
|
|
194
|
+
const model = String(flags.get('model') ?? '');
|
|
195
|
+
if (!model) throw new Error('--model <fal model id>');
|
|
196
|
+
if (!flags.get('out')) throw new Error('--out <file in this art job, e.g. cover-painted.png>');
|
|
197
|
+
const input = inputOf(flags.get('input'));
|
|
198
|
+
const out = resolve(dir, String(flags.get('out')));
|
|
199
|
+
if (!out.startsWith(`${dir}/`)) throw new Error('--out must stay inside the art job\'s folder');
|
|
200
|
+
if (existsSync(out)) return { ok: true, command: 'gen', slug, already: rel(root, out) };
|
|
201
|
+
const p = await priceOf(model, input);
|
|
202
|
+
if (flags.has('dry-run')) return { ok: true, command: 'gen', dryRun: true, slug, price: p };
|
|
203
|
+
const resuming = existsSync(`${out}.request`);
|
|
204
|
+
if (!resuming) {
|
|
205
|
+
const b = checkBudget(dir, p.usd, { provider: 'fal', unit: 'usd' });
|
|
206
|
+
if (!flags.has('yes')) return { ok: false, command: 'gen', needs: 'approval', slug, price: p, budget: { cap: b.cap, spent: b.spent }, why: `This call costs about US$${p.usd.toFixed(3)} (${p.basis}); US$${b.spent.toFixed(2)} of US$${b.cap.toFixed(2)} spent so far. Run it with --yes once that is fine.` };
|
|
207
|
+
}
|
|
208
|
+
const r = await falRun(model, input, {
|
|
209
|
+
out, base: dir, uploads: join(dir, 'uploads.json'), price: p, log: (m) => { if (!JSON_OUT) process.stderr.write(`${m}\n`); },
|
|
210
|
+
onAccepted: async (q) => { receipt(root, dir, { 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) }); },
|
|
211
|
+
});
|
|
212
|
+
return { ok: true, command: 'gen', slug, files: r.files.map((f) => rel(root, f)), requestId: r.requestId, usd: p.usd, spent: readJson(join(dir, 'budget.json'), {}).spent, next: 'look at it before anything else; a second call only with a changed prompt, never a loop' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ---------------------------------------------------------------- tile / fit / sheet */
|
|
216
|
+
|
|
217
|
+
function tile() {
|
|
218
|
+
const file = resolve(String(pos[1] ?? ''));
|
|
219
|
+
if (!existsSync(file)) throw new Error('usage: tile <image>');
|
|
220
|
+
const img = decode(file, 512);
|
|
221
|
+
const { w, h, rgb } = img;
|
|
222
|
+
const px = (x, y, c) => rgb[(y * w + x) * 3 + c];
|
|
223
|
+
const colDiff = (a, b) => { let s = 0; for (let y = 0; y < h; y++) for (let c = 0; c < 3; c++) s += Math.abs(px(a, y, c) - px(b, y, c)); return s / (h * 3); };
|
|
224
|
+
const rowDiff = (a, b) => { let s = 0; for (let x = 0; x < w; x++) for (let c = 0; c < 3; c++) s += Math.abs(px(x, a, c) - px(x, b, c)); return s / (w * 3); };
|
|
225
|
+
// The wrap (last column against the first) against the texture's own neighbour steps inside it.
|
|
226
|
+
let inX = 0; let nX = 0; for (let x = 1; x < w; x += 7) { inX += colDiff(x - 1, x); nX++; }
|
|
227
|
+
let inY = 0; let nY = 0; for (let y = 1; y < h; y += 7) { inY += rowDiff(y - 1, y); nY++; }
|
|
228
|
+
const seamX = +(colDiff(w - 1, 0) / Math.max(0.5, inX / nX)).toFixed(2);
|
|
229
|
+
const seamY = +(rowDiff(h - 1, 0) / Math.max(0.5, inY / nY)).toFixed(2);
|
|
230
|
+
const preview = file.replace(/(\.[a-z0-9]+)$/i, '-tiled.png');
|
|
231
|
+
ffmpeg(['-i', file, '-filter_complex', '[0]scale=512:-2,split=4[a][b][c][d];[a][b]hstack[t];[c][d]hstack[u];[t][u]vstack', preview]);
|
|
232
|
+
const ok = seamX <= 2 && seamY <= 2;
|
|
233
|
+
return { ok: true, command: 'tile', file, seamX, seamY, tiles: ok, why: ok ? 'the edges meet like the inside does (2 or less)' : `a visible seam ${seamX > 2 ? 'left to right' : ''}${seamX > 2 && seamY > 2 ? ' and ' : ''}${seamY > 2 ? 'top to bottom' : ''}: image models are bad at tiling; mirror-blend the edges or ask for a texture and make it tile yourself`, preview };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function fit() {
|
|
237
|
+
const file = resolve(String(pos[1] ?? ''));
|
|
238
|
+
if (!existsSync(file)) throw new Error('usage: fit <image> --out <file.jpg|.webp|.png> [--width 1024] [--max-kb 300]');
|
|
239
|
+
let out = resolve(String(flags.get('out') ?? file.replace(/(\.[a-z0-9]+)$/i, '-fit.jpg')));
|
|
240
|
+
const encoders = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8' }).stdout ?? '';
|
|
241
|
+
let note;
|
|
242
|
+
if (extname(out).toLowerCase() === '.webp' && !/libwebp/.test(encoders)) { out = out.replace(/\.webp$/i, '.jpg'); note = 'this ffmpeg has no WebP encoder, so it wrote a JPEG (brew install ffmpeg with libwebp, or keep JPEG: every browser reads it)'; }
|
|
243
|
+
const width = Math.max(16, Math.min(8192, Number(flags.get('width') ?? 1024)));
|
|
244
|
+
const maxKb = Number(flags.get('max-kb') ?? 300);
|
|
245
|
+
const ext = extname(out).toLowerCase();
|
|
246
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
247
|
+
let q = ext === '.webp' ? 82 : 3;
|
|
248
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
249
|
+
const enc = ext === '.webp' ? ['-c:v', 'libwebp', '-quality', String(q)] : ext === '.png' ? ['-compression_level', '9'] : ['-q:v', String(q)];
|
|
250
|
+
ffmpeg(['-i', file, '-vf', `scale=${width}:-2:flags=lanczos`, ...enc, out]);
|
|
251
|
+
if (statSync(out).size <= maxKb * 1024 || ext === '.png') break;
|
|
252
|
+
q = ext === '.webp' ? q - 10 : q + 2;
|
|
253
|
+
}
|
|
254
|
+
const kb = Math.round(statSync(out).size / 1024);
|
|
255
|
+
const { w, h } = size(out);
|
|
256
|
+
return { ok: kb <= maxKb || ext !== '.png', command: 'fit', out, size: `${w}x${h}`, kb, note, ...(kb > maxKb ? { why: `still ${kb} KB: a smaller width, or JPEG/WebP instead of PNG` } : {}), next: 'load it in the game, play it, and check the load time did not grow (the playtest\'s first row)' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function sheet(root) {
|
|
260
|
+
const dir = resolve(String(pos[1] ?? ''));
|
|
261
|
+
if (!existsSync(dir)) throw new Error('usage: sheet <folder of images>');
|
|
262
|
+
const files = readdirSync(dir).filter((f) => /\.(png|jpe?g|webp)$/i.test(f) && !f.startsWith('sheet')).sort();
|
|
263
|
+
if (!files.length) throw new Error('no images in that folder');
|
|
264
|
+
const cells = files.map((f) => {
|
|
265
|
+
const small = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', join(dir, f), '-vf', 'scale=480:-2', '-f', 'image2', '-c:v', 'mjpeg', '-q:v', '4', '-'], { maxBuffer: 32 * 1024 * 1024 }).stdout;
|
|
266
|
+
return `<figure><img src="data:image/jpeg;base64,${Buffer.from(small).toString('base64')}"><figcaption>${f.replace(/</g, '<')}</figcaption></figure>`;
|
|
267
|
+
});
|
|
268
|
+
const cols = Math.min(4, files.length); const rowsN = Math.ceil(files.length / cols);
|
|
269
|
+
const html = `<!doctype html><meta charset="utf-8"><style>body{margin:0;background:#111;color:#ddd;font:14px system-ui,sans-serif}main{display:grid;grid-template-columns:repeat(${cols},480px);gap:12px;padding:12px}figure{margin:0}img{width:480px;height:270px;object-fit:contain;background:#000;display:block}figcaption{padding-top:4px}</style><main>${cells.join('')}</main>`;
|
|
270
|
+
const out = join(dir, 'sheet.png');
|
|
271
|
+
await htmlToPng(root, html, out, { width: cols * 492 + 12, height: rowsN * 306 + 12 });
|
|
272
|
+
return { ok: true, command: 'sheet', images: files.length, sheet: out };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ---------------------------------------------------------------- main */
|
|
276
|
+
|
|
277
|
+
async function main() {
|
|
278
|
+
const cmd = pos[0];
|
|
279
|
+
if (!cmd || cmd === 'help' || flags.has('help')) {
|
|
280
|
+
const head = readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0];
|
|
281
|
+
return { ok: true, command: 'help', usage: head.replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '').trim() };
|
|
282
|
+
}
|
|
283
|
+
if (cmd === 'check') return check();
|
|
284
|
+
if (cmd === 'price') return price();
|
|
285
|
+
if (cmd === 'tile') return tile();
|
|
286
|
+
if (cmd === 'fit') return fit();
|
|
287
|
+
const root = studioRoot();
|
|
288
|
+
if (cmd === 'frame') return frame(root);
|
|
289
|
+
if (cmd === 'cover') return cover(root);
|
|
290
|
+
if (cmd === 'budget') return budget(root);
|
|
291
|
+
if (cmd === 'gen') return gen(root);
|
|
292
|
+
if (cmd === 'sheet') return sheet(root);
|
|
293
|
+
return { ok: false, command: cmd, why: `unknown command "${cmd}" (art.mjs help)` };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const print = (o) => {
|
|
297
|
+
if (JSON_OUT) { process.stdout.write(`${JSON.stringify(o, null, 2)}\n`); return; }
|
|
298
|
+
if (o.ok === false && o.why) { process.stdout.write(`art: ${o.why}\n`); return; }
|
|
299
|
+
for (const [k, v] of Object.entries(o)) if (k !== 'ok' && k !== 'command' && v !== undefined) process.stdout.write(`${k}: ${Array.isArray(v) ? `\n ${v.join('\n ')}` : typeof v === 'object' ? JSON.stringify(v) : v}\n`);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const r = await main();
|
|
304
|
+
if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
|
|
305
|
+
} catch (error) {
|
|
306
|
+
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: models
|
|
3
|
+
description: Give a game real 3D models that fit together and run on a phone — free CC0 models from Homie's starter library (Kenney, KayKit, Poly Haven, ambientCG) copied in with their licence, the person's own files checked and made phone-sized, and generated props (a concept image in the game's locked style, then Tripo P1 image-to-3D) and generated rigged characters (an A-pose concept, then Meshy 7.1 with its auto-rig, the library's clips retargeted onto it) on the person's OWN fal account under a hard budget with a receipt per call — every asset recorded with its provenance and licence (RIGHTS.md, the credits page), checked against phone budgets, shown in a lineup at true scale, and loaded through one safe loader. Use when someone wants 3D models, props, characters, scenery or "free assets" for a game, asks where a model came from or whether it may be used, or says the models look like they are from different games.
|
|
4
|
+
compatibility: Node 22. Generated props use Tripo's models, and generated characters Meshy's, on the creator's own fal account (a local FAL_KEY for this skill's script, or a job run through a signed-in fal connector and brought in with `models.mjs import`). fal's own MCP server (https://mcp.fal.ai/mcp-relay) reads schemas and prices.
|
|
5
|
+
metadata:
|
|
6
|
+
providers: fal tripo
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Models for a game: free first, everything licensed, one look
|
|
10
|
+
|
|
11
|
+
Read the `style` skill first: a game's models are made under its decisions (render style, palette, scale,
|
|
12
|
+
budgets). Routes, cheapest coherent one first:
|
|
13
|
+
|
|
14
|
+
1. **The engine** (free): procedural geometry, tinted from the palette. The house's best looks are procedural;
|
|
15
|
+
never replace one with generated meshes without the owner's ask.
|
|
16
|
+
2. **The starter library** (free, CC0): `assets find`, then `assets add`.
|
|
17
|
+
3. **The person's own files**: `assets add --file … --license …` (ask whose it is).
|
|
18
|
+
4. **Generated** (paid, their own fal account, under a budget): a concept, then a mesh: props, and rigged characters
|
|
19
|
+
(below). A character's rig, clips and feel are the `animate` skill's.
|
|
20
|
+
|
|
21
|
+
Commands run from inside the studio: `npx --no-install homie-studio assets …` (free) and this skill's script for
|
|
22
|
+
the paid route: `node "${CLAUDE_PLUGIN_ROOT}/skills/models/scripts/models.mjs" <command>` (in an app with Homie's
|
|
23
|
+
tools: `assets_find`, `asset_add`, `asset_make`, `asset_check`, `asset_lineup`, `asset_rights`).
|
|
24
|
+
|
|
25
|
+
## The starter library (free)
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx --no-install homie-studio assets find "fox" # CC0 items, phone-sized, with triangles and size
|
|
29
|
+
npx --no-install homie-studio assets find "pine tree" --family kenney
|
|
30
|
+
npx --no-install homie-studio assets add <id> kenney-cube-pets/animal-fox --height 0.7 --card "Characters/Fox"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
One library family per game (`cast.family`): mixing Kenney with KayKit shows. An added item is COPIED into
|
|
34
|
+
`games/<id>/public/models/` (never loaded from homie.rocks at runtime), recorded in `assets/manifest.json` with its
|
|
35
|
+
pack, origin and SHA-256, and its pack is credited on the landing's credits page (kindly: CC0 needs no credit).
|
|
36
|
+
`HOMIE_LIBRARY` points at another copy of the library (a folder or an address).
|
|
37
|
+
|
|
38
|
+
## The person's own files
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx --no-install homie-studio assets add <id> --file /path/to/lantern.glb --license own --as lantern --height 0.6
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Ask whose it is and under what licence: `own`, `cc0`, `cc-by-4.0` (with `--attribution "<who, where>"`),
|
|
45
|
+
`eula:<store>`, `market:<listing>`, `other` (with `--notes`). The file is refused before anything reads it when it
|
|
46
|
+
loads anything from an address (an external URI), declares a buffer over the import cap, or is over 64 MB; a
|
|
47
|
+
.gltf may bring its .bin and pictures from its own folder only. Then it is made phone-sized (its tier's
|
|
48
|
+
triangles and picture size, the pivot at the bottom centre, its height in metres, meshopt geometry, WebP
|
|
49
|
+
pictures); the raw file stays in `art/<slug>/raw/` (git-ignored; the studio's R2 with `media move` once it has
|
|
50
|
+
storage), never shipped and never committed. FBX, OBJ and .blend: export a .glb first (Blender: File > Export >
|
|
51
|
+
glTF 2.0, binary).
|
|
52
|
+
|
|
53
|
+
## Generated props (paid): price, budget, look, then the mesh
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
node <models.mjs> check # the LOCAL fal key (a free check) and the registry
|
|
57
|
+
node <models.mjs> quote <id> --count 3 # concept + mesh, priced live (about US$0.54 a prop)
|
|
58
|
+
node <models.mjs> budget <id> --cap 3 # the cap the person agreed to, for this game's models
|
|
59
|
+
node <models.mjs> prop <id> lantern --card "Items/Ember Lantern" --what "an iron lantern with a warm ember inside" --height 0.6 --dry-run
|
|
60
|
+
node <models.mjs> prop <id> lantern --card "Items/Ember Lantern" --what "…" --height 0.6 --yes # the concept only
|
|
61
|
+
# LOOK at art/lantern/concept.*: the thing, whole, in the game's style, on a plain background?
|
|
62
|
+
node <models.mjs> prop <id> lantern --mesh --yes # Tripo P1, textured, face_limit 1500; then optimised and recorded
|
|
63
|
+
npx --no-install homie-studio assets lineup <id> # true scale, silhouettes, palette drift
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Tell the person the price first and agree a cap; `prop` refuses with no budget, refuses past the cap, and asks
|
|
67
|
+
again without `--yes`. The receipt is written the moment fal accepts the job (`art/receipts.jsonl`, the
|
|
68
|
+
game's `art/<id>-models/budget.json`); running the same step again resumes it and never pays twice.
|
|
69
|
+
- The concept starts from the derived style prompt (`homie-studio style prompt <id>`) with the golden images as
|
|
70
|
+
references, on a plain background with even light (baked light is the top quality problem of image-to-3D).
|
|
71
|
+
- **Two sign-ins, one set of books.** `FAL_KEY` in the shell is what this script pays with. A fal connector (fal's
|
|
72
|
+
MCP server signed in from the app) is a separate sign-in the script cannot see: `check` reports the local key only,
|
|
73
|
+
and "no local FAL_KEY" does not mean the connector is signed out. With a key, generate here. With only a connector,
|
|
74
|
+
generate there (ask the price first and hold it to the same cap), save the job's receipt as JSON the moment it is
|
|
75
|
+
accepted (`{ "requestId", "endpoint", "cost" }`) and bring the finished file in:
|
|
76
|
+
`node <models.mjs> import <id> lantern --receipt art/lantern/mesh-job.json --file <the .glb> [--concept <image> --concept-receipt <job.json>] [--kind character] [--height 0.6]`.
|
|
77
|
+
It is refused with no budget or past the cap, writes one receipt line a request id (never twice), keeps the raw file
|
|
78
|
+
in `art/<asset>/raw/` and records the asset with every step, exactly as a job made here. Never copy a key out of
|
|
79
|
+
the connector, and never bring a connector's file in with a bare `assets add`: the spend and the provenance would
|
|
80
|
+
be missing.
|
|
81
|
+
- **Never a loop.** Look at the concept before the mesh, and at the model in the lineup before the next one. A
|
|
82
|
+
bad concept: change the words and `--concept-again` once. Spend the riskiest prop first.
|
|
83
|
+
- `references/models.json` is the dated registry of endpoints, prices and schemas. Endpoints retire and reprice
|
|
84
|
+
monthly: `node <models.mjs> registry` reads every price again for free; update the registry (`--write`) when one
|
|
85
|
+
moved, and never hard-code an endpoint anywhere else. fal's own MCP server (`get_model_schema`, `get_pricing`,
|
|
86
|
+
`search_models` for a newer Tripo version; the `art` skill says how to connect it) reads the same facts. A paid
|
|
87
|
+
run goes through `models.mjs` when there is a local key; a run made through the connector instead is only finished
|
|
88
|
+
when `models.mjs import` has recorded it (above).
|
|
89
|
+
- Tripo's models run **on fal** here (`tripo3d/...` endpoints): one account, prices read live, the receipts above.
|
|
90
|
+
Tripo's own CLI and MCP server (`tripo-cli`, `tripo mcp`) bill a separate Tripo account in Tripo credits, outside
|
|
91
|
+
this skill's budget and receipts, and a free Tripo plan makes its outputs public under CC BY 4.0
|
|
92
|
+
(`references/RIGHTS.md`): keep a game's models on the fal route. The Homie mod (Claude Code) and Homie's hooks (Codex)
|
|
93
|
+
hold a `tripo` generating command or a Tripo MCP tool as a call whose cost cannot be read first.
|
|
94
|
+
- A painted mood image for the style board: `node <models.mjs> mood <id> b --yes` (about US$0.035; a target).
|
|
95
|
+
|
|
96
|
+
## Generated characters (paid): an A-pose concept, then a rigged mesh
|
|
97
|
+
|
|
98
|
+
Library characters first (`assets find "knight" --kind character`: KayKit's adventurers and skeletons, Kenney's mini
|
|
99
|
+
and blocky characters, all CC0 and animated). For one the library cannot cover:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
node <models.mjs> character <id> ranger --card "Players/Ranger" --what "a forest ranger: green hooded cloak, leather tunic, a bow on the back" --height 1.45 --like kaykit-adventurers/rogue --dry-run
|
|
103
|
+
node <models.mjs> character <id> ranger --card "Players/Ranger" --what "…" --height 1.45 --like kaykit-adventurers/rogue --yes # the concept only
|
|
104
|
+
# LOOK at art/ranger/concept.*: one character, whole, in an A-pose, in the game's style, on a plain background?
|
|
105
|
+
node <models.mjs> character <id> ranger --mesh --yes # Meshy 7.1 + auto-rig, about US$1.40; then free: optimised, rig mapped, clips retargeted
|
|
106
|
+
npx --no-install homie-studio anim preview <id> --asset ranger # LOOK at the preview sheet
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`--like` takes a library character's own thumbnail as the style reference (so a generated hero stands beside the
|
|
110
|
+
library's in one look); golden images, when the game has them, come first. The concept is a full body in an A-pose,
|
|
111
|
+
front view, plain background (what auto-rigging needs). Humanoids with clear limbs only. The quote reads fal's pricing
|
|
112
|
+
API and the model page's add-ons (textures, rigging) and takes the higher. The raw rigged file stays in
|
|
113
|
+
`art/<asset>/raw/`; the shipped character is phone-sized with its skeleton mapped to the standard and the library's
|
|
114
|
+
CC0 clips (idle, run, jump, attack, hit, die, ...) retargeted onto it, recorded with both receipts.
|
|
115
|
+
|
|
116
|
+
## Check, lineup, rights
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npx --no-install homie-studio assets check <id> # phone budgets, the glTF-Validator, licences, staleness, big files
|
|
120
|
+
npx --no-install homie-studio assets use <id> <asset> unused # a file the game no longer draws (or cast, prop, environment)
|
|
121
|
+
npx --no-install homie-studio assets add <id> --file <model.glb> --license own --lo 0.2 # also <asset>_lo.glb: the same prop at a fifth of its triangles, for the far band (LodProps)
|
|
122
|
+
npx --no-install homie-studio assets lineup <id> # pictures in .studio/art/<id>/; look at them
|
|
123
|
+
npx --no-install homie-studio assets rights <id> # games/<id>/assets/RIGHTS.md and the credits
|
|
124
|
+
npx --no-install homie-studio assets redo <id> <asset> # made again from its kept raw file, free
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`assets redo` runs only the free steps again (today's budgets and render style, from `art/<slug>/raw/`). A model
|
|
128
|
+
drawn black in the game is metal with nothing to reflect: every style but `pbr` makes models non-metal, and a redo
|
|
129
|
+
applies it to one made before. A palette or shape change needs a new concept and mesh (paid), not a redo.
|
|
130
|
+
|
|
131
|
+
Phone budgets (per asset): a prop 1,500 triangles and 512 px pictures, a hero 8,000 and 1,024 px, one material;
|
|
132
|
+
the scene: about 100 draw calls, 150,000 triangles, 48 MB of picture memory, 5 MB of shipped payload. `assets check`
|
|
133
|
+
holds the game to them, and what it adds up is an **inventory estimate**: the recorded files' own numbers times their
|
|
134
|
+
`placements`. It never ran the game, so procedural geometry, repeated instances, effects and shadow passes are not in
|
|
135
|
+
it, and "shipped payload" is every built file gzipped, not what a browser fetched before the first round. Say
|
|
136
|
+
"inventory estimate" when you report it; the `playtest` and `perf` skills measure the running game and are the real
|
|
137
|
+
gate on a phone.
|
|
138
|
+
|
|
139
|
+
- A game that needs heavier models says so once, in game.json: `"assets": { "budgets": { "triangles": 4000, "bytes": 600000 } }`
|
|
140
|
+
(the loader's warnings and the check both read it; hard caps still hold).
|
|
141
|
+
- Standalone textures and skies are measured too (size, hash, decode, mipmapped memory once a picture). Record every
|
|
142
|
+
picture the game loads as a texture (`kind: "texture"` or `"sky"`, with its licence and SHA-256): the check lists
|
|
143
|
+
shipped pictures that have no record and leaves them out of the sum.
|
|
144
|
+
- A model's normal and roughness maps count unless the manifest declares the game draws base colour only
|
|
145
|
+
(`"inGame": { "maps": "base" }` on a record or at the top of the manifest). Declare it only when the game really
|
|
146
|
+
calls `stylize()` with a non-PBR material model on those models; never to make a number pass.
|
|
147
|
+
- The lineup is three pictures: the inventory (everything recorded), the cast (what is in play) and the environment.
|
|
148
|
+
Mark a file the game no longer draws `unused`, and give a reviewer the cast and environment lineups, not the folder.
|
|
149
|
+
|
|
150
|
+
After the lineup, have a FRESH reviewer (the `playtest` skill's blind review: a new agent that never saw your
|
|
151
|
+
reasoning) look at the style board, the golden images and the lineup pictures and score "does every model look
|
|
152
|
+
like one game?" from 1 to 10, naming outliers; record it: `assets review <id> --score <n> --outliers a,b`.
|
|
153
|
+
Under 7, or any outlier, those assets need another look.
|
|
154
|
+
|
|
155
|
+
## Loading them in the game
|
|
156
|
+
|
|
157
|
+
Every model loads through `@homie-rocks/studio/assets`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { createModels } from '@homie-rocks/studio/assets';
|
|
161
|
+
const models = createModels();
|
|
162
|
+
const fox = await models.instance('./models/fox.glb').catch(() => models.placeholder({ x: 0.5, y: 0.7, z: 0.8 }));
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
It refuses an unsafe or oversized file (an external URI, a buffer over the cap, an extension three.js cannot
|
|
166
|
+
read) before parsing it, decodes meshopt geometry, clones skinned models properly, and warns in development
|
|
167
|
+
when a model is over its budget. Draw each loaded model the way the decisions say, once, before copying it:
|
|
168
|
+
`stylize((await models.load(url)).scene, style)` with the game's `style.json` (its material model and ink outline,
|
|
169
|
+
as the style board drew them). The `gem-rush-3d` starter shows the whole pattern.
|
|
170
|
+
|
|
171
|
+
## Licences, plainly
|
|
172
|
+
|
|
173
|
+
- `references/RIGHTS.md` keeps what the providers' terms say, with read dates. Read the live pages again before
|
|
174
|
+
anything commercial is published (`registry` re-reads prices; terms you read by hand).
|
|
175
|
+
- The assets whose licence lets the file itself be handed on are CC0, CC BY with credit, the studio's own, and
|
|
176
|
+
generated ones: only those may be in a part the studio shares. With anything else the game may serve the file
|
|
177
|
+
to its players, and the studio's repository must stay private while it holds the raw file. `publish` refuses a game with an asset that
|
|
178
|
+
has no licence record.
|
|
179
|
+
- Never fetch from Poly Pizza or ShareTextures by script, never use Quaternius, Mixamo, Synty, Fab, Unity Asset
|
|
180
|
+
Store or TurboSquid files in a public game's repository, never use a model or dataset under non-commercial
|
|
181
|
+
terms.
|
|
182
|
+
|
|
183
|
+
## Never
|
|
184
|
+
|
|
185
|
+
- Never spend without a budget, past it, or without a receipt; never run paid generation in a loop; never print
|
|
186
|
+
or store a key.
|
|
187
|
+
- Never ship a raw provider file, never commit one, never ship an asset with no licence record.
|
|
188
|
+
- Never replace a model the person made without asking.
|
|
189
|
+
|
|
190
|
+
### Existing provider credentials
|
|
191
|
+
|
|
192
|
+
The tools read `FAL_KEY` or `ELEVENLABS_API_KEY` first, then the plain single-line
|
|
193
|
+
key in `~/.homie/keys/fal-key` or `~/.homie/keys/elevenlabs-key`. They also accept
|
|
194
|
+
`~/.homie/workspace/fal-key` and `~/.homie/workspace/elevenlabs-key` from earlier
|
|
195
|
+
installs. Checks name the source, never the key. Keep these files private (mode
|
|
196
|
+
0600); never copy them into a studio. ElevenLabs also accepts its CLI browser
|
|
197
|
+
sign-in and the existing Stripe Projects connection. Run the tool’s `check`
|
|
198
|
+
before declaring a provider disconnected.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Rights for models and generated art
|
|
2
|
+
|
|
3
|
+
What the providers' own terms say about the files this skill makes or brings in, with the date each page was
|
|
4
|
+
read. Terms change: before anything commercial is published, read the live page again, and update the date here
|
|
5
|
+
and in `@homie-rocks/studio`'s `lib/asset-manifest.mjs` (`PROVIDER_TERMS`), which writes every game's RIGHTS.md.
|
|
6
|
+
|
|
7
|
+
## Generated on the person's own account
|
|
8
|
+
|
|
9
|
+
- **fal** (https://fal.ai/terms, read 2026-10-02, "Last Updated: September 8, 2026"): "Customer owns and retains
|
|
10
|
+
all right, title, and interest in and to the Customer Input." There is no matching clause assigning the
|
|
11
|
+
Output to the customer; fal "does not represent, warrant, or covenant that any Output Content will be original,
|
|
12
|
+
will not infringe rights of any third party … or otherwise entitle Company to any intellectual property rights
|
|
13
|
+
in any Output Content". Third-party model outputs may not be used to train models that compete with them. The
|
|
14
|
+
endpoints in `models.json` carry fal's "Commercial use" label.
|
|
15
|
+
- **Tripo** through fal (developer terms, https://developers.tripo3d.ai/en/terms, read 2026-10-02, last updated
|
|
16
|
+
2025-07-11): "you may use Outputs for lawful commercial or non-commercial purposes". Calls through fal are paid
|
|
17
|
+
calls. Tripo's FREE plan makes outputs public under CC BY 4.0 (credit Tripo); this skill never uses a free plan.
|
|
18
|
+
- **Copyright in purely AI-made files** may be thin, so a licence on them may not bind whoever copies those
|
|
19
|
+
files. RIGHTS.md says so plainly and never claims more than the terms above.
|
|
20
|
+
|
|
21
|
+
## The starter library (CC0 only)
|
|
22
|
+
|
|
23
|
+
- **Kenney** (https://kenney.nl/support; each pack's License.txt): "Creative Commons Zero, CC0": personal,
|
|
24
|
+
educational and commercial use; credit "is not a requirement".
|
|
25
|
+
- **KayKit** (each repository's licence file): CC0 1.0, no attribution required (a request not to resell
|
|
26
|
+
unmodified copies is a request, not a term; Homie never sells them).
|
|
27
|
+
- **Poly Haven** (https://polyhaven.com/license): CC0; redistribution allowed; no credit needed for downloaded or
|
|
28
|
+
mirrored files (only the live API inside a product needs a visible credit, and Homie never proxies it).
|
|
29
|
+
- **ambientCG** (https://docs.ambientcg.com/license/): CC0 1.0; "You can include the raw files in your project,
|
|
30
|
+
for example a video game".
|
|
31
|
+
|
|
32
|
+
Never in the library or a public game's repository: Quaternius (its site licence of 2026-08-28 forbids
|
|
33
|
+
redistribution as an asset pack or template; reference only until written permission), Mixamo, Synty, Fab,
|
|
34
|
+
Unity Asset Store, TurboSquid (forbids three.js `.glb` outright), Sketchfab Standard, three.js example models,
|
|
35
|
+
anything trained on or made from non-commercial data (AMASS, SMPL, GVHMR, the Bandai Namco dataset).
|
|
36
|
+
|
|
37
|
+
## What may be handed on
|
|
38
|
+
|
|
39
|
+
A game serves its files to its players and to nobody else. A file leaves a studio only inside a part the studio
|
|
40
|
+
chose to share (the `parts` skill), and only when its licence lets the file itself be handed on.
|
|
41
|
+
|
|
42
|
+
| Licence | In a shared part |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| CC0, CC BY (credit kept), the studio's own, generated | yes |
|
|
45
|
+
| Quaternius (QAL) | no |
|
|
46
|
+
| Mixamo, a store EULA, a bought listing, "other" | no |
|