@homie-rocks/studio 0.21.0 → 0.23.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 +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/lib/chat-cli.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio chat` — a studio's room chat from the studio folder (worker/chat.mjs says what it is; NETPLAY.md
|
|
3
|
+
* section 19 the rules and the wire). The office key it uses is the same as `office`'s: minted with the studio's own
|
|
4
|
+
* Cloudflare login, ten minutes long, dropped as soon as the answer is in.
|
|
5
|
+
*
|
|
6
|
+
* homie-studio chat [--game <id>] each game's chat rules, every live room's last minutes, the
|
|
7
|
+
* reports, and the review's day (neurons used of its budget)
|
|
8
|
+
* homie-studio chat rules <game> [--server <id>] [--mode off|emoji|lines|text] [--who anyone|signed-in|members]
|
|
9
|
+
* [--react …] [--slow <s>] [--max <n>] [--links block|allow] [--swears block|allow]
|
|
10
|
+
* [--ai on|off] [--bubbles on|off] [--watchers on|off] [--hub on|off] [--block "a,b"] [--reset]
|
|
11
|
+
* homie-studio chat remove <game> <room> <line id>|--all
|
|
12
|
+
* homie-studio chat budget <neurons> the review's day for the whole studio (default 2,000)
|
|
13
|
+
* homie-studio chat words the built-in word list the floor holds (worker/chat-words.mjs)
|
|
14
|
+
*
|
|
15
|
+
* A change that opens chat up (a wider mode or audience, less slow mode, links or swears allowed, the review off) or
|
|
16
|
+
* a bigger budget is only ASKED for here: the answer is the owner's one-tap link, as `office kick` gives.
|
|
17
|
+
*/
|
|
18
|
+
import { builtinWords } from '../worker/chat.mjs';
|
|
19
|
+
import { askedFor, withKey } from './office.mjs';
|
|
20
|
+
|
|
21
|
+
const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
22
|
+
const onOff = (v) => (v === undefined ? undefined : v === true || v === 'on' || v === 'true' || v === 'yes');
|
|
23
|
+
|
|
24
|
+
/** The flags `chat rules` reads, as the office API's fields. */
|
|
25
|
+
export function chatFields(flags) {
|
|
26
|
+
const f = {};
|
|
27
|
+
const get = (k) => (flags.has(k) ? flags.get(k) : undefined);
|
|
28
|
+
for (const k of ['mode', 'who', 'react', 'links', 'swears']) if (get(k) !== undefined) f[k] = String(get(k));
|
|
29
|
+
for (const k of ['slow', 'max']) if (get(k) !== undefined) f[k] = Number(get(k));
|
|
30
|
+
for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (get(k) !== undefined) f[k] = onOff(get(k));
|
|
31
|
+
if (get('block') !== undefined) f.block = String(get('block')).split(',').map((w) => w.trim()).filter(Boolean);
|
|
32
|
+
if (get('allow') !== undefined) f.allow = String(get('allow')).split(',').map((w) => w.trim()).filter(Boolean);
|
|
33
|
+
return f;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function chatShow(root, { url, game } = {}) {
|
|
37
|
+
const r = await withKey(root, url, (call) => call(`/_studio/api/chat${game ? `?game=${encodeURIComponent(game)}` : ''}`));
|
|
38
|
+
if (!r.ok) return { ok: false, command: 'chat', why: r.why ?? r.message ?? 'the office did not answer' };
|
|
39
|
+
return { command: 'chat', ...r, ok: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function chatRulesSet(root, game, flags, { url } = {}) {
|
|
43
|
+
if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'chat rules', why: 'usage: homie-studio chat rules <game> [--server <id>] --mode off|emoji|lines|text [--who …] [--slow <s>] … | --reset' };
|
|
44
|
+
const server = flags.get('server') ?? '';
|
|
45
|
+
const body = flags.has('reset') ? { game, server, reset: true } : { game, server, ...chatFields(flags) };
|
|
46
|
+
if (!body.reset && Object.keys(body).length <= 2) return { ok: false, command: 'chat rules', why: 'say what to change: --mode, --who, --react, --slow, --max, --links, --swears, --ai, --bubbles, --watchers, --hub, --block, --allow; or --reset' };
|
|
47
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/chat/rules', body));
|
|
48
|
+
return askedFor(root, url, r, 'chat rules');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function chatRemove(root, game, room, id, { url, all = false } = {}) {
|
|
52
|
+
if (!GAME.test(String(game ?? '')) || !room || (!id && !all)) return { ok: false, command: 'chat remove', why: 'usage: homie-studio chat remove <game> <room> <line id> | --all (the ids are in `homie-studio chat`)' };
|
|
53
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/chat/remove', { game, room, ...(all ? { all: true } : { id }) }));
|
|
54
|
+
return r.ok ? { ok: true, command: 'chat remove', removed: r.removed ?? 0, message: `Took ${r.removed ?? 0} message${r.removed === 1 ? '' : 's'} down in ${room}, on every screen.` } : { ok: false, command: 'chat remove', why: r.message ?? r.why };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function chatBudget(root, neurons, { url } = {}) {
|
|
58
|
+
const n = Math.floor(Number(neurons));
|
|
59
|
+
if (!(n >= 0 && n <= 1e7)) return { ok: false, command: 'chat budget', why: 'usage: homie-studio chat budget <neurons a day> (default 2000; Workers AI gives an account 10,000 a day free)' };
|
|
60
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/chat/budget', { neurons: n }));
|
|
61
|
+
return askedFor(root, url, r, 'chat budget');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The built-in list, by group: what the floor always holds (swears only while a game holds swears). */
|
|
65
|
+
export function chatWords() {
|
|
66
|
+
const w = builtinWords();
|
|
67
|
+
return {
|
|
68
|
+
ok: true, command: 'chat words',
|
|
69
|
+
groups: {
|
|
70
|
+
slurs: [...w.slurs], slursInsideWords: w.slursAnywhere, sexual: [...w.sexual], sexualInsideWords: w.sexualAnywhere,
|
|
71
|
+
swears: [...w.swears], swearsInsideWords: w.swearsAnywhere, harm: w.harm, contact: w.contact,
|
|
72
|
+
},
|
|
73
|
+
note: 'Whole words unless "inside words". Shown as the floor reads them (lower case, letters only). A studio adds its own with chat rules --block, and lets one through with --allow.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function chatLines(r) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const d = r.day;
|
|
80
|
+
if (d) out.push(`Review today: ${d.ai ? `${Math.round(d.used.neurons)} of ${d.budget.neurons} neurons, ${d.used.reviews} reviewed, ${d.used.held} held${d.used.errors ? `, ${d.used.errors} unanswered (the word list decided)` : ''}` : 'no Workers AI binding yet (npm run deploy binds it); the word list decides'}.`);
|
|
81
|
+
for (const g of r.games ?? []) {
|
|
82
|
+
const c = g.chat?.rules;
|
|
83
|
+
out.push(`${g.name} (${g.id}): ${c ? `${c.mode}${c.mode === 'text' ? `, typing ${c.who}` : ''}, emoji ${c.react}, slow ${c.slow} s${c.ai ? ', reviewed' : ''}${c.capped ? ` (capped: ${c.capped})` : ''}` : 'no rules read'} · from ${g.chat?.from ?? 'default'}`);
|
|
84
|
+
for (const sv of g.chat?.servers ?? []) if (sv.office) out.push(` server ${sv.id}: ${sv.rules.mode}, typing ${sv.rules.who}`);
|
|
85
|
+
for (const rm of g.rooms ?? []) for (const l of (rm.chat?.lines ?? []).slice(-8)) out.push(` ${rm.room} ${l.id} ${l.by === 'studio' ? 'Studio' : l.name}: ${l.kind === 'react' ? l.glyph : l.text}`);
|
|
86
|
+
for (const p of g.chat?.reports ?? []) out.push(` report ${p.id}: "${p.text}" by ${p.name} (${p.reasonText}), ${p.room}`);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -218,7 +218,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
218
218
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
219
219
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
220
220
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
|
|
221
|
-
step('Workers AI bound (AI): a server\'s AI guides think with it, within
|
|
221
|
+
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
const started = Date.now();
|
|
@@ -271,6 +271,9 @@ export function needsWorkersAi(root, w, db) {
|
|
|
271
271
|
try {
|
|
272
272
|
const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
|
|
273
273
|
if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
|
|
274
|
+
// Room chat (0.23.0): a game whose players may type has its messages reviewed by the studio's own Workers AI
|
|
275
|
+
// (Cloudflare's Clef decision model, within a day's budget of the free allocation) unless its game.json says no.
|
|
276
|
+
if ((cat.games ?? []).some((g) => g.chat !== false && (g.chat?.mode ?? 'text') === 'text' && g.chat?.ai !== false)) return true;
|
|
274
277
|
} catch { /* not built */ }
|
|
275
278
|
const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
|
|
276
279
|
if (r.code !== 0) return false;
|
|
@@ -343,7 +346,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
343
346
|
try {
|
|
344
347
|
const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
|
|
345
348
|
if (!json.ai) { json.ai = { binding: 'AI' }; writeFileSync(configPath(root), `${JSON.stringify(json, null, 2)}\n`); }
|
|
346
|
-
step('Workers AI bound (AI)
|
|
349
|
+
step('Workers AI bound (AI): the AI guides and the review of typed room chat');
|
|
347
350
|
} catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
|
|
348
351
|
}
|
|
349
352
|
const started = Date.now();
|
package/lib/codex.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { latestFeedFor, summarize } from './feed-summary.mjs';
|
|
|
42
42
|
import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
|
|
43
43
|
import { readTheme } from './site.mjs';
|
|
44
44
|
import { GAME_ID, listGames, readStudio } from './studio.mjs';
|
|
45
|
+
import { decisionRows, readDecisions } from './decisions.mjs';
|
|
45
46
|
|
|
46
47
|
export const CODEX_FILE = 'CODEX.md';
|
|
47
48
|
export const CODEX_DIR = join('.studio', 'codex');
|
|
@@ -314,6 +315,9 @@ function sectionHtml(section, emb, ctx) {
|
|
|
314
315
|
}
|
|
315
316
|
const questions = section.key === 'questions';
|
|
316
317
|
let body = blocks(intro, emb, { dated: section.key === 'latest' });
|
|
318
|
+
// The Art direction tab is the style bible: the decisions (games/<id>/codex/decisions.json) drawn after the prose.
|
|
319
|
+
const decided = section.key === 'art' && ctx.decisions ? decisionsHtml(ctx.decisions, emb) : '';
|
|
320
|
+
if (decided) body += decided;
|
|
317
321
|
if (questions) body = body.replace(/^<(ul|ol)>/, '<ol class="questions">').replace(/<\/(ul|ol)>$/, '</ol>');
|
|
318
322
|
const empty = !body && !cards.length;
|
|
319
323
|
return `<section class="tab" id="${esc(section.id)}" data-key="${esc(section.key ?? '')}">
|
|
@@ -324,6 +328,29 @@ ${cards.length ? `<div class="cards">${cards.map((c) => card(c.title, c.lines, e
|
|
|
324
328
|
</section>`;
|
|
325
329
|
}
|
|
326
330
|
|
|
331
|
+
/* ------------------------------------------------------------------ the art direction decisions */
|
|
332
|
+
|
|
333
|
+
const STATE_WORD = { auto: 'Auto', steered: 'Steered', pinned: 'Pinned by use', locked: 'Locked' };
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The decisions as the style bible: the board's directions (the engine's swatches, a painted mood image only as a
|
|
337
|
+
* labelled target), the golden images, then one table per phase: decision, value (with the palette's colours), why, its
|
|
338
|
+
* state and who set it. Pictures come from codex/board/ inside the game's folder, embedded like any codex picture.
|
|
339
|
+
*/
|
|
340
|
+
function decisionsHtml(doc, emb) {
|
|
341
|
+
const phases = decisionRows(doc);
|
|
342
|
+
if (!phases.length) return '';
|
|
343
|
+
const swatch = (c) => `<span class="sw" style="background:${esc(c)}" title="${esc(c)}"></span>`;
|
|
344
|
+
const board = (doc.board?.directions ?? []).map((d) => {
|
|
345
|
+
const pic = d.swatch ? emb.image(d.swatch) : null;
|
|
346
|
+
const mood = d.mood?.path ? emb.image(d.mood.path) : null;
|
|
347
|
+
return `<figure class="dir${doc.board.chosen === d.id ? ' chosen' : ''}">${pic ? `<img src="${pic}" alt="Direction ${esc(d.id.toUpperCase())}, drawn by the game engine">` : ''}${mood ? `<img src="${mood}" alt="A painted mood image for direction ${esc(d.id.toUpperCase())} (a target)">` : ''}<figcaption><b>${esc(d.id.toUpperCase())}</b> ${esc(d.label)}${doc.board.chosen === d.id ? ' · chosen' : ''}${mood ? ' · the painting is a target, not what the game draws' : ''}</figcaption></figure>`;
|
|
348
|
+
}).join('');
|
|
349
|
+
const golden = (doc.golden ?? []).map((g) => { const pic = emb.image(g.path); return pic ? `<img src="${pic}" alt="Golden image">` : ''; }).join('');
|
|
350
|
+
const tables = phases.map((p) => `<h3 class="phase">${esc(p.label)}</h3><div class="scroll"><table class="decisions"><thead><tr><th>Decision</th><th>Value</th><th>Why</th><th>State</th></tr></thead><tbody>${p.rows.map((r) => `<tr class="${esc(r.state)}"><td>${esc(r.name)}</td><td>${r.colours ? `<span class="sws">${r.colours.map(swatch).join('')}</span> ` : ''}${esc(r.label)}${r.steer.length ? ` <em>(“${esc(r.steer.at(-1))}”)</em>` : ''}</td><td>${esc(r.why ?? '')}</td><td><span class="st ${esc(r.state)}">${esc(STATE_WORD[r.state] ?? r.state)}</span>${r.by === 'person' ? ' <small>the person</small>' : ''}${r.at ? ` <small>${esc(String(r.at).slice(0, 10))}</small>` : ''}</td></tr>`).join('')}</tbody></table></div>`).join('');
|
|
351
|
+
return `<div class="decided"><p class="eyebrow">The decisions</p><p class="sub">Every look decision with its why. Auto: picked from the plan; steered: nudged by the person; pinned: an asset is built on it; locked: frozen by the person. Your AI changes a locked one only after you say so, and shows what would need remaking first.</p>${board ? `<div class="board">${board}</div>` : ''}${golden ? `<p class="eyebrow">Golden images</p><div class="gallery golden">${golden}</div>` : ''}${tables}${doc.derived?.prompt?.text ? `<details class="prompt"><summary>The style prompt every generated picture starts from</summary><p>${esc(doc.derived.prompt.text)}</p></details>` : ''}</div>`;
|
|
352
|
+
}
|
|
353
|
+
|
|
327
354
|
/* ------------------------------------------------------------------ the build status tab */
|
|
328
355
|
|
|
329
356
|
const STATE_MARK = { done: '✓', pass: '✓', skipped: '–', skip: '–', running: '◌', failed: '✗', fail: '✗', stopped: '■', pending: '' };
|
|
@@ -432,6 +459,13 @@ code{font-family:var(--mono);font-size:.92em}pre{overflow-x:auto;background:var(
|
|
|
432
459
|
.try{margin:16px 0;padding:14px 16px;border:1px solid var(--gold);border-radius:14px;background:color-mix(in srgb,var(--gold) 8%,transparent)}.try .eyebrow{color:var(--gold)}.try p{margin:0}
|
|
433
460
|
.panel h3{margin-bottom:6px}.status .panel{margin-top:14px}
|
|
434
461
|
footer{max-width:1080px;margin:0 auto;padding:18px 16px 40px;color:var(--dim);font-size:13px;border-top:1px solid var(--line)}
|
|
462
|
+
.decided{margin-top:22px}.decided h3.phase{margin:22px 0 4px;font:600 13px/1.4 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--accent2)}
|
|
463
|
+
table.decisions td:first-child{white-space:nowrap;font-weight:600}table.decisions td:nth-child(3){color:var(--dim);font-size:14px}
|
|
464
|
+
.sws{display:inline-flex;gap:3px;vertical-align:middle}.sw{display:inline-block;width:16px;height:16px;border-radius:4px;border:1px solid var(--line)}
|
|
465
|
+
.st{display:inline-block;padding:1px 8px;border-radius:999px;font:600 12px/1.6 var(--mono);border:1px solid var(--line);color:var(--dim)}
|
|
466
|
+
.st.locked{color:var(--gold);border-color:var(--gold)}.st.pinned{color:var(--accent2);border-color:color-mix(in srgb,var(--accent2) 60%,transparent)}.st.steered{color:var(--ink)}
|
|
467
|
+
.board{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:12px;margin:12px 0}.board figure{margin:0}.board figure.chosen img:first-child{outline:3px solid var(--accent);outline-offset:-3px}.board img+img{margin-top:6px}
|
|
468
|
+
details.prompt{margin:16px 0;color:var(--dim);font-size:14px}details.prompt summary{cursor:pointer;color:var(--accent2)}
|
|
435
469
|
@media (max-width:560px){.card .art{width:72px;height:72px}ol.timeline li{grid-template-columns:1fr;gap:2px}}`;
|
|
436
470
|
}
|
|
437
471
|
|
|
@@ -502,10 +536,17 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
502
536
|
let studio = {};
|
|
503
537
|
try { studio = readStudio(root); } catch { studio = {}; }
|
|
504
538
|
const emb = new Embedder(root, game.dir);
|
|
539
|
+
let decisions = null;
|
|
540
|
+
try { decisions = readDecisions(root, id); } catch { decisions = null; }
|
|
541
|
+
// A palette or fonts the person steered or locked, or an asset pinned, is the game's look: the codex wears it too.
|
|
542
|
+
const pal = decisions?.decisions?.['style.palette'];
|
|
543
|
+
const ui = decisions?.decisions?.['style.ui'];
|
|
544
|
+
if (pal && pal.state !== 'auto') meta.palette = { ...(meta.palette ?? {}), ...Object.fromEntries(['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold'].filter((k) => pal.value?.[k]).map((k) => [k, pal.value[k]])) };
|
|
545
|
+
if (ui && ui.state !== 'auto' && ui.value?.display) meta.fonts = { ...(meta.fonts ?? {}), display: ui.value.display, body: ui.value.body ?? meta.fonts?.body };
|
|
505
546
|
const look = lookOf(root, game, meta, emb);
|
|
506
547
|
const title = parsed.title ?? game.name ?? id;
|
|
507
548
|
const eyebrow = typeof meta.eyebrow === 'string' && meta.eyebrow.trim() ? meta.eyebrow.trim() : `${studio.name ?? 'Studio'} · ${title}`;
|
|
508
|
-
const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode };
|
|
549
|
+
const ctx = { eyebrow: String(eyebrow).slice(0, 80), pixel: meta.pixel === true, try: typeof meta.try === 'string' ? meta.try.slice(0, 300) : '', mode, decisions };
|
|
509
550
|
const s = summarize(feed === undefined ? latestFeedFor(root, id) : feed);
|
|
510
551
|
const sections = parsed.sections.filter((x) => x.body.join('\n').trim() || x.key);
|
|
511
552
|
const tabs = sections.map((x) => `<a href="#${esc(x.id)}">${esc(x.title)}</a>`);
|
|
@@ -519,6 +560,7 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
519
560
|
const start = s?.state === 'running' ? 'build-status' : (sections[0]?.id ?? 'build-status');
|
|
520
561
|
const tagline = typeof meta.tagline === 'string' ? meta.tagline : '';
|
|
521
562
|
const gaps = codexGaps(parsed);
|
|
563
|
+
if (decisions) gaps.missing = gaps.missing.filter((x) => x !== 'Art direction');
|
|
522
564
|
const html = `<!doctype html>
|
|
523
565
|
<html lang="en">
|
|
524
566
|
<head>
|
package/lib/colour.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COLOUR ARITHMETIC for art direction: hex and RGB, HSL nudges (a palette steered "warmer" or "less saturated"),
|
|
3
|
+
* CIE Lab and the CIEDE2000 difference (is an asset's albedo still in the locked palette?), and a small k-means for
|
|
4
|
+
* the dominant colours of a picture. No dependencies; numbers, never a file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
|
8
|
+
export const HEX = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
|
|
9
|
+
|
|
10
|
+
/** "#ffcf5a" (or "#fc5") as [r, g, b] in 0..255; null when it is not a hex colour. */
|
|
11
|
+
export function hexToRgb(hex) {
|
|
12
|
+
const m = HEX.exec(String(hex ?? '').trim());
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
const h = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1];
|
|
15
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function rgbToHex([r, g, b]) {
|
|
19
|
+
return `#${[r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function rgbToHsl([r, g, b]) {
|
|
23
|
+
const R = r / 255; const G = g / 255; const B = b / 255;
|
|
24
|
+
const max = Math.max(R, G, B); const min = Math.min(R, G, B);
|
|
25
|
+
const l = (max + min) / 2;
|
|
26
|
+
if (max === min) return [0, 0, l];
|
|
27
|
+
const d = max - min;
|
|
28
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
29
|
+
let h = max === R ? (G - B) / d + (G < B ? 6 : 0) : max === G ? (B - R) / d + 2 : (R - G) / d + 4;
|
|
30
|
+
h *= 60;
|
|
31
|
+
return [h, s, l];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hslToRgb([h, s, l]) {
|
|
35
|
+
const H = (((h % 360) + 360) % 360) / 360;
|
|
36
|
+
if (s === 0) return [l * 255, l * 255, l * 255];
|
|
37
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
38
|
+
const p = 2 * l - q;
|
|
39
|
+
const f = (t) => { let x = t; if (x < 0) x += 1; if (x > 1) x -= 1; if (x < 1 / 6) return p + (q - p) * 6 * x; if (x < 1 / 2) return q; if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; return p; };
|
|
40
|
+
return [f(H + 1 / 3) * 255, f(H) * 255, f(H - 1 / 3) * 255];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The shortest signed angle from hue a to hue b, in degrees. */
|
|
44
|
+
const hueStep = (a, b) => ((((b - a) % 360) + 540) % 360) - 180;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One colour nudged: { hue: degrees toward `toward` (or a plain rotation), sat: multiply, light: add (-1..1),
|
|
48
|
+
* warm: 0..1 (toward orange) or cool (toward blue) }. Greys stay grey.
|
|
49
|
+
*/
|
|
50
|
+
export function nudge(hex, { warm = 0, cool = 0, sat = 1, light = 0, hue = 0 } = {}) {
|
|
51
|
+
const rgb = hexToRgb(hex);
|
|
52
|
+
if (!rgb) return hex;
|
|
53
|
+
let [h, s, l] = rgbToHsl(rgb);
|
|
54
|
+
if (s > 0.06) {
|
|
55
|
+
if (warm) h += hueStep(h, 32) * clamp(warm);
|
|
56
|
+
if (cool) h += hueStep(h, 215) * clamp(cool);
|
|
57
|
+
h += hue;
|
|
58
|
+
}
|
|
59
|
+
s = clamp(s * sat);
|
|
60
|
+
l = clamp(l + light, 0.02, 0.98);
|
|
61
|
+
return rgbToHex(hslToRgb([h, s, l]));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ------------------------------------------------------------------ Lab and CIEDE2000 */
|
|
65
|
+
|
|
66
|
+
function srgbToLinear(c) { const v = c / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }
|
|
67
|
+
|
|
68
|
+
/** sRGB [0..255] to CIE L*a*b* (D65). */
|
|
69
|
+
export function rgbToLab([r, g, b]) {
|
|
70
|
+
const R = srgbToLinear(r); const G = srgbToLinear(g); const B = srgbToLinear(b);
|
|
71
|
+
const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
|
|
72
|
+
const Y = R * 0.2126 + G * 0.7152 + B * 0.0722;
|
|
73
|
+
const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
|
|
74
|
+
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
|
|
75
|
+
const fx = f(X); const fy = f(Y); const fz = f(Z);
|
|
76
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** CIEDE2000 between two Lab colours (Sharma, Wu and Dalal 2005). About 2.3 is "just noticeable". */
|
|
80
|
+
export function deltaE2000([L1, a1, b1], [L2, a2, b2]) {
|
|
81
|
+
const rad = Math.PI / 180;
|
|
82
|
+
const C1 = Math.hypot(a1, b1); const C2 = Math.hypot(a2, b2);
|
|
83
|
+
const Cb = (C1 + C2) / 2;
|
|
84
|
+
const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
|
|
85
|
+
const ap1 = (1 + G) * a1; const ap2 = (1 + G) * a2;
|
|
86
|
+
const Cp1 = Math.hypot(ap1, b1); const Cp2 = Math.hypot(ap2, b2);
|
|
87
|
+
const hp = (b, ap) => { if (b === 0 && ap === 0) return 0; const h = Math.atan2(b, ap) / rad; return h >= 0 ? h : h + 360; };
|
|
88
|
+
const hp1 = hp(b1, ap1); const hp2 = hp(b2, ap2);
|
|
89
|
+
const dL = L2 - L1; const dC = Cp2 - Cp1;
|
|
90
|
+
let dh = 0;
|
|
91
|
+
if (Cp1 * Cp2 !== 0) { dh = hp2 - hp1; if (dh > 180) dh -= 360; else if (dh < -180) dh += 360; }
|
|
92
|
+
const dH = 2 * Math.sqrt(Cp1 * Cp2) * Math.sin((dh / 2) * rad);
|
|
93
|
+
const Lb = (L1 + L2) / 2; const Cpb = (Cp1 + Cp2) / 2;
|
|
94
|
+
let hb = hp1 + hp2;
|
|
95
|
+
if (Cp1 * Cp2 !== 0) { hb = Math.abs(hp1 - hp2) <= 180 ? (hp1 + hp2) / 2 : hp1 + hp2 < 360 ? (hp1 + hp2 + 360) / 2 : (hp1 + hp2 - 360) / 2; }
|
|
96
|
+
const T = 1 - 0.17 * Math.cos((hb - 30) * rad) + 0.24 * Math.cos(2 * hb * rad) + 0.32 * Math.cos((3 * hb + 6) * rad) - 0.2 * Math.cos((4 * hb - 63) * rad);
|
|
97
|
+
const dTheta = 30 * Math.exp(-(((hb - 275) / 25) ** 2));
|
|
98
|
+
const Rc = 2 * Math.sqrt(Cpb ** 7 / (Cpb ** 7 + 25 ** 7));
|
|
99
|
+
const Sl = 1 + (0.015 * (Lb - 50) ** 2) / Math.sqrt(20 + (Lb - 50) ** 2);
|
|
100
|
+
const Sc = 1 + 0.045 * Cpb; const Sh = 1 + 0.015 * Cpb * T;
|
|
101
|
+
const Rt = -Math.sin(2 * dTheta * rad) * Rc;
|
|
102
|
+
return Math.sqrt((dL / Sl) ** 2 + (dC / Sc) ** 2 + (dH / Sh) ** 2 + Rt * (dC / Sc) * (dH / Sh));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** ΔE2000 between two hex colours. */
|
|
106
|
+
export const deltaHex = (a, b) => { const x = hexToRgb(a); const y = hexToRgb(b); return x && y ? deltaE2000(rgbToLab(x), rgbToLab(y)) : Infinity; };
|
|
107
|
+
|
|
108
|
+
/** The nearest palette colour to `hex`: { hex, delta }. */
|
|
109
|
+
export function nearest(hex, palette) {
|
|
110
|
+
let best = { hex: null, delta: Infinity };
|
|
111
|
+
for (const p of palette) { const d = deltaHex(hex, p); if (d < best.delta) best = { hex: p, delta: d }; }
|
|
112
|
+
return best;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Dominant colours of RGB samples ([[r,g,b], ...], or a flat Uint8Array of RGB with `weights`), by k-means in Lab:
|
|
117
|
+
* [{ hex, share }] biggest share first. Deterministic: the first centres are the most distant samples.
|
|
118
|
+
*/
|
|
119
|
+
export function dominant(samples, k = 5, { iterations = 12 } = {}) {
|
|
120
|
+
const pts = samples.map((s) => ({ rgb: s.rgb ?? s, w: s.w ?? 1, lab: rgbToLab(s.rgb ?? s) }));
|
|
121
|
+
if (!pts.length) return [];
|
|
122
|
+
const K = Math.min(k, pts.length);
|
|
123
|
+
// Farthest-point seeding from the heaviest sample.
|
|
124
|
+
const centres = [pts.slice().sort((a, b) => b.w - a.w)[0].lab];
|
|
125
|
+
while (centres.length < K) {
|
|
126
|
+
let far = null; let fd = -1;
|
|
127
|
+
for (const p of pts) { const d = Math.min(...centres.map((c) => (c[0] - p.lab[0]) ** 2 + (c[1] - p.lab[1]) ** 2 + (c[2] - p.lab[2]) ** 2)); if (d > fd) { fd = d; far = p; } }
|
|
128
|
+
centres.push(far.lab);
|
|
129
|
+
}
|
|
130
|
+
let assign = new Array(pts.length).fill(0);
|
|
131
|
+
for (let it = 0; it < iterations; it++) {
|
|
132
|
+
assign = pts.map((p) => { let bi = 0; let bd = Infinity; centres.forEach((c, i) => { const d = (c[0] - p.lab[0]) ** 2 + (c[1] - p.lab[1]) ** 2 + (c[2] - p.lab[2]) ** 2; if (d < bd) { bd = d; bi = i; } }); return bi; });
|
|
133
|
+
for (let i = 0; i < K; i++) {
|
|
134
|
+
let w = 0; const s = [0, 0, 0];
|
|
135
|
+
pts.forEach((p, j) => { if (assign[j] === i) { w += p.w; s[0] += p.lab[0] * p.w; s[1] += p.lab[1] * p.w; s[2] += p.lab[2] * p.w; } });
|
|
136
|
+
if (w) centres[i] = [s[0] / w, s[1] / w, s[2] / w];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const total = pts.reduce((n, p) => n + p.w, 0);
|
|
140
|
+
return centres.map((c, i) => {
|
|
141
|
+
let w = 0; const rgb = [0, 0, 0];
|
|
142
|
+
pts.forEach((p, j) => { if (assign[j] === i) { w += p.w; rgb[0] += p.rgb[0] * p.w; rgb[1] += p.rgb[1] * p.w; rgb[2] += p.rgb[2] * p.w; } });
|
|
143
|
+
return w ? { hex: rgbToHex(rgb.map((x) => x / w)), share: +(w / total).toFixed(3) } : null;
|
|
144
|
+
}).filter(Boolean).sort((a, b) => b.share - a.share);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Relative luminance (0..1) of a hex colour, for "is this a light palette". */
|
|
148
|
+
export function luminance(hex) {
|
|
149
|
+
const rgb = hexToRgb(hex);
|
|
150
|
+
if (!rgb) return 0;
|
|
151
|
+
const [r, g, b] = rgb.map(srgbToLinear);
|
|
152
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Interpolate two hex colours (t 0..1). */
|
|
156
|
+
export function mix(a, b, t) {
|
|
157
|
+
const x = hexToRgb(a); const y = hexToRgb(b);
|
|
158
|
+
if (!x || !y) return a;
|
|
159
|
+
return rgbToHex(x.map((v, i) => v + (y[i] - v) * t));
|
|
160
|
+
}
|