@homie-rocks/studio 0.20.0 → 0.22.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 +78 -1
- package/README.md +55 -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 +52 -3
- 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 +25 -2
- package/lib/check.mjs +3 -1
- 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/office.mjs +30 -8
- package/lib/optimise.mjs +380 -0
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +32 -1
- 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 +75 -0
- package/lib/thumb.mjs +82 -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/package.json +14 -2
- 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 +2086 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/index.mjs +5 -3
- package/worker/pages.mjs +13 -5
- package/worker/qr.mjs +11 -0
- package/worker/site.mjs +5 -3
- package/worker/version.mjs +1 -1
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
|
+
}
|