@genex-ai/cli-demo 0.47.0-dev.70 → 0.47.0-dev.74
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/README.md +25 -2
- package/dist/index.js +1158 -16
- package/package.json +4 -2
- package/templates/skills/genex-ai-hud/SKILL.md +381 -0
- package/templates/skills/genex-ai-hud/references/masked-fill.md +145 -0
- package/templates/skills/genex-ai-hud/references/stage1-prompt-template.md +44 -0
- package/templates/skills/genex-ai-hud/references/stage2-prompt-template.md +73 -0
- package/templates/skills/genex-ai-image/SKILL.md +14 -4
- package/templates/skills/genex-ai-menu/SKILL.md +291 -0
- package/templates/skills/genex-ai-video/SKILL.md +11 -4
- package/templates/skills/genex-getting-started/SKILL.md +5 -2
- package/templates/skills/genex-threejs-game-ui/SKILL.md +149 -18
- package/templates/skills/genex-threejs-game-ui/references/style-capsules.md +89 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +27 -1
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +37 -17
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +10 -5
package/dist/index.js
CHANGED
|
@@ -445,7 +445,7 @@ async function authorize(authBaseUrl, options) {
|
|
|
445
445
|
cleanup();
|
|
446
446
|
resolve(token);
|
|
447
447
|
};
|
|
448
|
-
const
|
|
448
|
+
const fail2 = (err) => {
|
|
449
449
|
if (settled) return;
|
|
450
450
|
settled = true;
|
|
451
451
|
cleanup();
|
|
@@ -462,7 +462,7 @@ async function authorize(authBaseUrl, options) {
|
|
|
462
462
|
}
|
|
463
463
|
});
|
|
464
464
|
});
|
|
465
|
-
server.on("error", (err) =>
|
|
465
|
+
server.on("error", (err) => fail2(err));
|
|
466
466
|
server.listen(0, "127.0.0.1", () => {
|
|
467
467
|
const { port } = server.address();
|
|
468
468
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
@@ -493,7 +493,7 @@ async function authorize(authBaseUrl, options) {
|
|
|
493
493
|
);
|
|
494
494
|
}
|
|
495
495
|
timer = setTimeout(() => {
|
|
496
|
-
|
|
496
|
+
fail2(
|
|
497
497
|
new Error(
|
|
498
498
|
"Timed out waiting for authorization. Re-run the command to try again."
|
|
499
499
|
)
|
|
@@ -1825,6 +1825,13 @@ function advisoryNudges(log, d) {
|
|
|
1825
1825
|
);
|
|
1826
1826
|
}
|
|
1827
1827
|
}
|
|
1828
|
+
async function firstPreviewNudge(log, cwd = process.cwd()) {
|
|
1829
|
+
const meta = await readProject(cwd);
|
|
1830
|
+
if (!meta || meta.lastDeployAt || meta.status === "published") return;
|
|
1831
|
+
log.dim(
|
|
1832
|
+
" \u279C Draft page still the placeholder? Ship the first rough build now: npx genex preview \u2014 then keep building."
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1828
1835
|
|
|
1829
1836
|
// src/commands/publish.ts
|
|
1830
1837
|
async function runPublish(opts) {
|
|
@@ -1861,6 +1868,8 @@ async function runPublish(opts) {
|
|
|
1861
1868
|
process.exitCode = 1;
|
|
1862
1869
|
return;
|
|
1863
1870
|
}
|
|
1871
|
+
meta.lastDeployAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1872
|
+
await writeProject(meta);
|
|
1864
1873
|
} else {
|
|
1865
1874
|
log.info("Skipping build + deploy (--no-push).");
|
|
1866
1875
|
}
|
|
@@ -1944,8 +1953,9 @@ async function runPreview(opts) {
|
|
|
1944
1953
|
const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
|
|
1945
1954
|
if (freshStatus && freshStatus !== meta.status) {
|
|
1946
1955
|
meta.status = freshStatus;
|
|
1947
|
-
await writeProject(meta);
|
|
1948
1956
|
}
|
|
1957
|
+
meta.lastDeployAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1958
|
+
await writeProject(meta);
|
|
1949
1959
|
const published = meta.status === "published";
|
|
1950
1960
|
log.plain("");
|
|
1951
1961
|
if (published) {
|
|
@@ -2001,6 +2011,40 @@ async function* readSSE(body) {
|
|
|
2001
2011
|
|
|
2002
2012
|
// src/commands/generate.ts
|
|
2003
2013
|
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2014
|
+
function buildGenOptions(kind, opts) {
|
|
2015
|
+
const options = {};
|
|
2016
|
+
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
2017
|
+
if (kind === "sfx" && opts.duration) options.durationSeconds = opts.duration;
|
|
2018
|
+
if (kind === "image") {
|
|
2019
|
+
if (opts.transparent) options.transparent = true;
|
|
2020
|
+
if (opts.aspect) options.aspect = opts.aspect;
|
|
2021
|
+
if (opts.quality) options.quality = opts.quality;
|
|
2022
|
+
if (opts.candidates !== void 0 && opts.candidates > 1) options.candidates = opts.candidates;
|
|
2023
|
+
if (opts.width && opts.height) {
|
|
2024
|
+
options.width = opts.width;
|
|
2025
|
+
options.height = opts.height;
|
|
2026
|
+
}
|
|
2027
|
+
if (opts.editUrl) {
|
|
2028
|
+
options.sourceUrl = opts.editUrl;
|
|
2029
|
+
options.op = "edit";
|
|
2030
|
+
}
|
|
2031
|
+
if (opts.cleanUrl) {
|
|
2032
|
+
options.sourceUrl = opts.cleanUrl;
|
|
2033
|
+
options.op = "remove-bg";
|
|
2034
|
+
options.bgMode = opts.bgMode ?? "sheet";
|
|
2035
|
+
}
|
|
2036
|
+
if (opts.removeBackground) options.removeBackground = true;
|
|
2037
|
+
if (opts.bgMode) options.bgMode = opts.bgMode;
|
|
2038
|
+
}
|
|
2039
|
+
if (kind === "video") {
|
|
2040
|
+
if (opts.duration) options.durationSeconds = opts.duration;
|
|
2041
|
+
if (opts.loop) options.loop = true;
|
|
2042
|
+
if (opts.frameUrl) options.frameUrl = opts.frameUrl;
|
|
2043
|
+
if (opts.firstFrameUrl) options.firstFrameUrl = opts.firstFrameUrl;
|
|
2044
|
+
if (opts.lastFrameUrl) options.lastFrameUrl = opts.lastFrameUrl;
|
|
2045
|
+
}
|
|
2046
|
+
return options;
|
|
2047
|
+
}
|
|
2004
2048
|
async function runGenerate(kind, opts) {
|
|
2005
2049
|
const log = createLogger({ quiet: opts.quiet });
|
|
2006
2050
|
const prompt = opts.prompt?.trim();
|
|
@@ -2017,13 +2061,7 @@ async function runGenerate(kind, opts) {
|
|
|
2017
2061
|
}
|
|
2018
2062
|
const meta = await readProject();
|
|
2019
2063
|
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
2020
|
-
const options =
|
|
2021
|
-
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
2022
|
-
if (kind === "sfx" && opts.duration) options.durationSeconds = opts.duration;
|
|
2023
|
-
if (kind === "image" && opts.transparent) options.transparent = true;
|
|
2024
|
-
if (kind === "image" && opts.aspect) options.aspect = opts.aspect;
|
|
2025
|
-
if (kind === "video" && opts.duration) options.durationSeconds = opts.duration;
|
|
2026
|
-
if (kind === "video" && opts.loop) options.loop = true;
|
|
2064
|
+
const options = buildGenOptions(kind, opts);
|
|
2027
2065
|
log.plain(c.bold(`genex ${kind}`));
|
|
2028
2066
|
log.dim(` ${prompt}`);
|
|
2029
2067
|
log.plain("");
|
|
@@ -2044,7 +2082,15 @@ async function runGenerate(kind, opts) {
|
|
|
2044
2082
|
return;
|
|
2045
2083
|
}
|
|
2046
2084
|
if (!res.ok) {
|
|
2047
|
-
|
|
2085
|
+
let message;
|
|
2086
|
+
if (res.status >= 400 && res.status < 500) {
|
|
2087
|
+
try {
|
|
2088
|
+
const body = await res.json();
|
|
2089
|
+
if (typeof body.message === "string" && body.message.trim()) message = body.message;
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
log.error(message ?? `Generation request failed (HTTP ${res.status}).`);
|
|
2048
2094
|
process.exitCode = 1;
|
|
2049
2095
|
return;
|
|
2050
2096
|
}
|
|
@@ -2055,9 +2101,14 @@ async function runGenerate(kind, opts) {
|
|
|
2055
2101
|
return;
|
|
2056
2102
|
}
|
|
2057
2103
|
if (opts.noWait) {
|
|
2058
|
-
log.success(`Queued (${id})
|
|
2104
|
+
log.success(`Queued (${id}).`);
|
|
2105
|
+
log.plain(` Pick it up any time with: ${c.cyan(`npx genex wait ${id}`)}`);
|
|
2106
|
+
log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
|
|
2059
2107
|
return;
|
|
2060
2108
|
}
|
|
2109
|
+
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2110
|
+
}
|
|
2111
|
+
async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
2061
2112
|
log.step("Generating\u2026 (this can take up to a minute)");
|
|
2062
2113
|
const onProgress = (p) => log.dim(` ${p}%`);
|
|
2063
2114
|
const timeoutMs = waitTimeoutFor(kind);
|
|
@@ -2069,6 +2120,9 @@ async function runGenerate(kind, opts) {
|
|
|
2069
2120
|
process.exitCode = 1;
|
|
2070
2121
|
return;
|
|
2071
2122
|
}
|
|
2123
|
+
await reportTerminal(kind, view, log);
|
|
2124
|
+
}
|
|
2125
|
+
async function reportTerminal(kind, view, log) {
|
|
2072
2126
|
if (view.status !== "completed") {
|
|
2073
2127
|
log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
2074
2128
|
process.exitCode = 1;
|
|
@@ -2088,6 +2142,7 @@ async function runGenerate(kind, opts) {
|
|
|
2088
2142
|
}
|
|
2089
2143
|
log.plain("");
|
|
2090
2144
|
printHint(kind, files, log);
|
|
2145
|
+
await firstPreviewNudge(log);
|
|
2091
2146
|
}
|
|
2092
2147
|
var WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2093
2148
|
var KIND_WAIT_TIMEOUT_MS = {
|
|
@@ -2185,6 +2240,63 @@ function printHint(kind, files, log) {
|
|
|
2185
2240
|
log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
|
|
2186
2241
|
}
|
|
2187
2242
|
|
|
2243
|
+
// src/commands/wait.ts
|
|
2244
|
+
var TERMINAL2 = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
2245
|
+
async function runWait(opts) {
|
|
2246
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
2247
|
+
const id = opts.id?.trim();
|
|
2248
|
+
if (!id) {
|
|
2249
|
+
log.error(
|
|
2250
|
+
`Missing generation id. Usage: ${c.cyan("genex wait <id>")} \u2014 the id printed when you enqueued with --no-wait.`
|
|
2251
|
+
);
|
|
2252
|
+
process.exitCode = 1;
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
const token = opts.token ?? await readUserToken(opts.envPath);
|
|
2256
|
+
if (!token) {
|
|
2257
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
2258
|
+
process.exitCode = 1;
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const meta = await readProject();
|
|
2262
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
2263
|
+
let view;
|
|
2264
|
+
try {
|
|
2265
|
+
const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
|
|
2266
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
2267
|
+
});
|
|
2268
|
+
if (res.status === 401) {
|
|
2269
|
+
log.error("Unauthorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
2270
|
+
process.exitCode = 1;
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
if (res.status === 404) {
|
|
2274
|
+
log.error(`No generation ${id} on this account \u2014 check the id printed by --no-wait.`);
|
|
2275
|
+
process.exitCode = 1;
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (!res.ok) {
|
|
2279
|
+
log.error(`Couldn't look up the generation (HTTP ${res.status}).`);
|
|
2280
|
+
process.exitCode = 1;
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
({ generation: view } = await res.json());
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
|
|
2286
|
+
process.exitCode = 1;
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
const kind = view.kind ?? "image";
|
|
2290
|
+
log.plain(c.bold("genex wait"));
|
|
2291
|
+
log.dim(` ${kind} ${id}`);
|
|
2292
|
+
log.plain("");
|
|
2293
|
+
if (TERMINAL2.has(view.status)) {
|
|
2294
|
+
await reportTerminal(kind, view, log);
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2188
2300
|
// src/commands/controller.ts
|
|
2189
2301
|
import fs12 from "fs/promises";
|
|
2190
2302
|
import path13 from "path";
|
|
@@ -2658,6 +2770,7 @@ async function runController(opts) {
|
|
|
2658
2770
|
for (const line of set.sketch) {
|
|
2659
2771
|
log.dim(` ${line}`);
|
|
2660
2772
|
}
|
|
2773
|
+
await firstPreviewNudge(log);
|
|
2661
2774
|
}
|
|
2662
2775
|
async function installOwnerAvatar(args) {
|
|
2663
2776
|
const { root, srcDir, apiUrl, token, log } = args;
|
|
@@ -2764,6 +2877,906 @@ function rank(items, query) {
|
|
|
2764
2877
|
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || b.p.playsCount - a.p.playsCount).map((x) => x.p);
|
|
2765
2878
|
}
|
|
2766
2879
|
|
|
2880
|
+
// src/commands/ui.ts
|
|
2881
|
+
import fs14 from "fs/promises";
|
|
2882
|
+
import path14 from "path";
|
|
2883
|
+
import { PNG as PNG2 } from "pngjs";
|
|
2884
|
+
|
|
2885
|
+
// src/lib/png-tools.ts
|
|
2886
|
+
import fs13 from "fs/promises";
|
|
2887
|
+
import { PNG } from "pngjs";
|
|
2888
|
+
var ALPHA_TRANSPARENT_MAX = 16;
|
|
2889
|
+
var isHttpUrl = (s) => /^https?:\/\//i.test(s);
|
|
2890
|
+
async function loadPng(input) {
|
|
2891
|
+
let buf;
|
|
2892
|
+
if (isHttpUrl(input)) {
|
|
2893
|
+
const res = await fetch(input);
|
|
2894
|
+
if (!res.ok) throw new Error(`Couldn't fetch ${input} (HTTP ${res.status}).`);
|
|
2895
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
2896
|
+
} else {
|
|
2897
|
+
buf = await fs13.readFile(input);
|
|
2898
|
+
}
|
|
2899
|
+
return PNG.sync.read(buf);
|
|
2900
|
+
}
|
|
2901
|
+
async function writePng(file, png) {
|
|
2902
|
+
await fs13.writeFile(file, PNG.sync.write(png));
|
|
2903
|
+
}
|
|
2904
|
+
function cropPng(image, box) {
|
|
2905
|
+
const out = new PNG({ width: box.w, height: box.h });
|
|
2906
|
+
PNG.bitblt(image, out, box.x, box.y, box.w, box.h, 0, 0);
|
|
2907
|
+
return out;
|
|
2908
|
+
}
|
|
2909
|
+
function floodFill(width, height, visited, queue, startX, startY, inRegion, onPixel) {
|
|
2910
|
+
let head = 0;
|
|
2911
|
+
let tail = 0;
|
|
2912
|
+
const start = startY * width + startX;
|
|
2913
|
+
visited[start] = 1;
|
|
2914
|
+
queue[tail++] = start;
|
|
2915
|
+
let pixels = 0;
|
|
2916
|
+
let minX = startX;
|
|
2917
|
+
let minY = startY;
|
|
2918
|
+
let maxX = startX;
|
|
2919
|
+
let maxY = startY;
|
|
2920
|
+
let touchesEdge = false;
|
|
2921
|
+
while (head < tail) {
|
|
2922
|
+
const idx = queue[head++];
|
|
2923
|
+
const x = idx % width;
|
|
2924
|
+
const y = idx / width | 0;
|
|
2925
|
+
pixels++;
|
|
2926
|
+
if (onPixel) onPixel(idx);
|
|
2927
|
+
if (x < minX) minX = x;
|
|
2928
|
+
if (x > maxX) maxX = x;
|
|
2929
|
+
if (y < minY) minY = y;
|
|
2930
|
+
if (y > maxY) maxY = y;
|
|
2931
|
+
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) touchesEdge = true;
|
|
2932
|
+
if (x + 1 < width && !visited[idx + 1] && inRegion(idx + 1)) {
|
|
2933
|
+
visited[idx + 1] = 1;
|
|
2934
|
+
queue[tail++] = idx + 1;
|
|
2935
|
+
}
|
|
2936
|
+
if (x > 0 && !visited[idx - 1] && inRegion(idx - 1)) {
|
|
2937
|
+
visited[idx - 1] = 1;
|
|
2938
|
+
queue[tail++] = idx - 1;
|
|
2939
|
+
}
|
|
2940
|
+
if (y + 1 < height && !visited[idx + width] && inRegion(idx + width)) {
|
|
2941
|
+
visited[idx + width] = 1;
|
|
2942
|
+
queue[tail++] = idx + width;
|
|
2943
|
+
}
|
|
2944
|
+
if (y > 0 && !visited[idx - width] && inRegion(idx - width)) {
|
|
2945
|
+
visited[idx - width] = 1;
|
|
2946
|
+
queue[tail++] = idx - width;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
return { pixels, minX, minY, maxX, maxY, touchesEdge };
|
|
2950
|
+
}
|
|
2951
|
+
function scanComponents(width, height, inRegion, onComponent, onPixel) {
|
|
2952
|
+
const visited = new Uint8Array(width * height);
|
|
2953
|
+
const queue = new Int32Array(width * height);
|
|
2954
|
+
for (let y = 0; y < height; y++) {
|
|
2955
|
+
for (let x = 0; x < width; x++) {
|
|
2956
|
+
const idx = y * width + x;
|
|
2957
|
+
if (visited[idx] || !inRegion(idx)) continue;
|
|
2958
|
+
onComponent(floodFill(width, height, visited, queue, x, y, inRegion, onPixel));
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
function alphaBBoxPx(png) {
|
|
2963
|
+
const { width, height, data } = png;
|
|
2964
|
+
let minX = width;
|
|
2965
|
+
let minY = height;
|
|
2966
|
+
let maxX = -1;
|
|
2967
|
+
let maxY = -1;
|
|
2968
|
+
for (let y = 0; y < height; y++) {
|
|
2969
|
+
for (let x = 0; x < width; x++) {
|
|
2970
|
+
if (data[(y * width + x) * 4 + 3] > ALPHA_TRANSPARENT_MAX) {
|
|
2971
|
+
if (x < minX) minX = x;
|
|
2972
|
+
if (x > maxX) maxX = x;
|
|
2973
|
+
if (y < minY) minY = y;
|
|
2974
|
+
if (y > maxY) maxY = y;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
return maxX < 0 ? null : { minX, minY, maxX, maxY };
|
|
2979
|
+
}
|
|
2980
|
+
function computeBBoxes(png) {
|
|
2981
|
+
const { width, height, data } = png;
|
|
2982
|
+
const r3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
2983
|
+
const result = {
|
|
2984
|
+
size: { w: width, h: height },
|
|
2985
|
+
aspectRatio: r3(width / height),
|
|
2986
|
+
outerBBox: null,
|
|
2987
|
+
innerBBox: null
|
|
2988
|
+
};
|
|
2989
|
+
const outer = alphaBBoxPx(png);
|
|
2990
|
+
if (!outer) return result;
|
|
2991
|
+
const { minX, minY, maxX, maxY } = outer;
|
|
2992
|
+
result.outerBBox = {
|
|
2993
|
+
x: r3(minX / width),
|
|
2994
|
+
y: r3(minY / height),
|
|
2995
|
+
w: r3((maxX - minX + 1) / width),
|
|
2996
|
+
h: r3((maxY - minY + 1) / height)
|
|
2997
|
+
};
|
|
2998
|
+
const cx = Math.floor((minX + maxX) / 2);
|
|
2999
|
+
const cy = Math.floor((minY + maxY) / 2);
|
|
3000
|
+
if (data[(cy * width + cx) * 4 + 3] > ALPHA_TRANSPARENT_MAX) return result;
|
|
3001
|
+
const cavity = floodFill(
|
|
3002
|
+
width,
|
|
3003
|
+
height,
|
|
3004
|
+
new Uint8Array(width * height),
|
|
3005
|
+
new Int32Array(width * height),
|
|
3006
|
+
cx,
|
|
3007
|
+
cy,
|
|
3008
|
+
(idx) => data[idx * 4 + 3] <= ALPHA_TRANSPARENT_MAX
|
|
3009
|
+
);
|
|
3010
|
+
if (cavity.touchesEdge) return result;
|
|
3011
|
+
const cavW = cavity.maxX - cavity.minX + 1;
|
|
3012
|
+
const cavH = cavity.maxY - cavity.minY + 1;
|
|
3013
|
+
if (cavW > width * 0.1 && cavH > height * 0.1) {
|
|
3014
|
+
result.innerBBox = {
|
|
3015
|
+
x: r3(cavity.minX / width),
|
|
3016
|
+
y: r3(cavity.minY / height),
|
|
3017
|
+
w: r3(cavW / width),
|
|
3018
|
+
h: r3(cavH / height)
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
return result;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// src/commands/ui.ts
|
|
3025
|
+
var UI_NUMBER_FLAGS = {
|
|
3026
|
+
"--min-pixels": "minPixels",
|
|
3027
|
+
"--padding": "padding",
|
|
3028
|
+
"--chroma": "chroma",
|
|
3029
|
+
"--dilate": "dilate",
|
|
3030
|
+
"--registration-tolerance": "registrationTolerance",
|
|
3031
|
+
"--coverage-min": "coverageMin",
|
|
3032
|
+
"--coverage-max": "coverageMax",
|
|
3033
|
+
"--component-min-pixels": "componentMinPixels",
|
|
3034
|
+
"--edge-flush-max": "edgeFlushMax",
|
|
3035
|
+
"--quantize": "quantize",
|
|
3036
|
+
"--ink-dist": "inkDist",
|
|
3037
|
+
"--min-frac": "minFrac"
|
|
3038
|
+
};
|
|
3039
|
+
var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or https URL>)")}
|
|
3040
|
+
ui extract --out-dir <dir> --names a,b,c (reading order) \u2014 split an
|
|
3041
|
+
asset sheet into per-element transparent PNGs + .bbox.json
|
|
3042
|
+
sidecars. [--min-pixels 2000] [--padding 8] [--chroma 240]
|
|
3043
|
+
[--dilate 0]
|
|
3044
|
+
ui masks --out-dir <dir> --pairs "name:cX,cY,w,h:aX,aY,w,h[:n];\u2026" \u2014
|
|
3045
|
+
clean frame + green-annotated duplicate \u2192 frame + fill mask
|
|
3046
|
+
+ fillBox (+ overlay + provenance JSON). [--registration-
|
|
3047
|
+
tolerance 0.04] [--coverage-min 0.01] [--coverage-max 0.85]
|
|
3048
|
+
[--component-min-pixels 100] [--edge-flush-max 0.04]
|
|
3049
|
+
ui text-color --box x,y,w,h \u2014 eyedropper a text region: plate color +
|
|
3050
|
+
ink candidates as JSON. [--quantize 12] [--ink-dist 60]
|
|
3051
|
+
[--min-frac 0.004] [--out <json>] [--crop <png>]
|
|
3052
|
+
ui trim [--out <png>] \u2014 crop to the alpha bbox (default: overwrite
|
|
3053
|
+
--in), print real dims + refresh the .bbox.json sidecar.`;
|
|
3054
|
+
async function runUi(opts) {
|
|
3055
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
3056
|
+
try {
|
|
3057
|
+
switch (opts.sub?.trim()) {
|
|
3058
|
+
case "extract":
|
|
3059
|
+
await uiExtract(opts, log);
|
|
3060
|
+
return;
|
|
3061
|
+
case "masks":
|
|
3062
|
+
await uiMasks(opts, log);
|
|
3063
|
+
return;
|
|
3064
|
+
case "text-color":
|
|
3065
|
+
await uiTextColor(opts, log);
|
|
3066
|
+
return;
|
|
3067
|
+
case "trim":
|
|
3068
|
+
await uiTrim(opts, log);
|
|
3069
|
+
return;
|
|
3070
|
+
default:
|
|
3071
|
+
log.error(
|
|
3072
|
+
opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim.` : "Missing ui tool. Tools: extract, masks, text-color, trim."
|
|
3073
|
+
);
|
|
3074
|
+
log.plain(UI_USAGE);
|
|
3075
|
+
process.exitCode = 1;
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
} catch (err) {
|
|
3079
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
3080
|
+
process.exitCode = 1;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
function fail(message, details) {
|
|
3084
|
+
throw new Error(details === void 0 ? message : `${message} ${JSON.stringify(details)}`);
|
|
3085
|
+
}
|
|
3086
|
+
function requireOpt(value, flag, sub) {
|
|
3087
|
+
if (!value?.trim()) fail(`Missing ${flag}. Run \`genex ui ${sub}\` \u2014 see \`genex --help\`.`);
|
|
3088
|
+
return value.trim();
|
|
3089
|
+
}
|
|
3090
|
+
async function uiExtract(opts, log) {
|
|
3091
|
+
const input = requireOpt(opts.input, "--in", "extract");
|
|
3092
|
+
const outDir = requireOpt(opts.outDir, "--out-dir", "extract");
|
|
3093
|
+
const names = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
3094
|
+
if (names.length === 0) fail("Missing --names (comma-separated element names, reading order).");
|
|
3095
|
+
const minPixels = opts.minPixels ?? 2e3;
|
|
3096
|
+
const padding = opts.padding ?? 8;
|
|
3097
|
+
const chroma = opts.chroma ?? 240;
|
|
3098
|
+
const dilatePx = opts.dilate ?? 0;
|
|
3099
|
+
const sheet = await loadPng(input);
|
|
3100
|
+
const { width: W, height: H, data } = sheet;
|
|
3101
|
+
await fs14.mkdir(outDir, { recursive: true });
|
|
3102
|
+
log.plain(c.bold("genex ui extract"));
|
|
3103
|
+
log.dim(` ${input} (${W}x${H}) \u2192 ${outDir}, ${names.length} names`);
|
|
3104
|
+
let hasTransparency = false;
|
|
3105
|
+
for (let i = 0; i < W * H; i++) {
|
|
3106
|
+
if (data[i * 4 + 3] < 250) {
|
|
3107
|
+
hasTransparency = true;
|
|
3108
|
+
break;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
const inputMode = hasTransparency ? "alpha" : "chroma";
|
|
3112
|
+
const subject = new Uint8Array(W * H);
|
|
3113
|
+
let subjectCount = 0;
|
|
3114
|
+
for (let i = 0; i < W * H; i++) {
|
|
3115
|
+
const a = data[i * 4 + 3];
|
|
3116
|
+
const isOpaque = a > ALPHA_TRANSPARENT_MAX;
|
|
3117
|
+
let isSubject = false;
|
|
3118
|
+
if (inputMode === "alpha") {
|
|
3119
|
+
isSubject = isOpaque;
|
|
3120
|
+
} else {
|
|
3121
|
+
const r = data[i * 4 + 0];
|
|
3122
|
+
const g = data[i * 4 + 1];
|
|
3123
|
+
const b = data[i * 4 + 2];
|
|
3124
|
+
const isWhite = r >= chroma && g >= chroma && b >= chroma;
|
|
3125
|
+
isSubject = isOpaque && !isWhite;
|
|
3126
|
+
}
|
|
3127
|
+
if (isSubject) {
|
|
3128
|
+
subject[i] = 1;
|
|
3129
|
+
subjectCount++;
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
log.dim(
|
|
3133
|
+
` mask: ${inputMode} mode, ${subjectCount} subject px (${(subjectCount / (W * H) * 100).toFixed(1)}%)`
|
|
3134
|
+
);
|
|
3135
|
+
let connect = subject;
|
|
3136
|
+
if (dilatePx > 0) {
|
|
3137
|
+
const tmp = new Uint8Array(W * H);
|
|
3138
|
+
for (let y = 0; y < H; y++) {
|
|
3139
|
+
let run2 = 0;
|
|
3140
|
+
for (let x = 0; x < W; x++) {
|
|
3141
|
+
if (subject[y * W + x]) run2 = dilatePx;
|
|
3142
|
+
else if (run2 > 0) run2--;
|
|
3143
|
+
tmp[y * W + x] = run2 > 0 || subject[y * W + x] ? 1 : 0;
|
|
3144
|
+
}
|
|
3145
|
+
run2 = 0;
|
|
3146
|
+
for (let x = W - 1; x >= 0; x--) {
|
|
3147
|
+
if (subject[y * W + x]) run2 = dilatePx;
|
|
3148
|
+
else if (run2 > 0) run2--;
|
|
3149
|
+
if (run2 > 0) tmp[y * W + x] = 1;
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
const tmp2 = new Uint8Array(W * H);
|
|
3153
|
+
for (let x = 0; x < W; x++) {
|
|
3154
|
+
let run2 = 0;
|
|
3155
|
+
for (let y = 0; y < H; y++) {
|
|
3156
|
+
if (tmp[y * W + x]) run2 = dilatePx;
|
|
3157
|
+
else if (run2 > 0) run2--;
|
|
3158
|
+
tmp2[y * W + x] = run2 > 0 || tmp[y * W + x] ? 1 : 0;
|
|
3159
|
+
}
|
|
3160
|
+
run2 = 0;
|
|
3161
|
+
for (let y = H - 1; y >= 0; y--) {
|
|
3162
|
+
if (tmp[y * W + x]) run2 = dilatePx;
|
|
3163
|
+
else if (run2 > 0) run2--;
|
|
3164
|
+
if (run2 > 0) tmp2[y * W + x] = 1;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
connect = tmp2;
|
|
3168
|
+
}
|
|
3169
|
+
const components = [];
|
|
3170
|
+
let realSubjectPixels = 0;
|
|
3171
|
+
scanComponents(
|
|
3172
|
+
W,
|
|
3173
|
+
H,
|
|
3174
|
+
(idx) => connect[idx] !== 0,
|
|
3175
|
+
(stats) => {
|
|
3176
|
+
components.push({
|
|
3177
|
+
minX: stats.minX,
|
|
3178
|
+
minY: stats.minY,
|
|
3179
|
+
maxX: stats.maxX,
|
|
3180
|
+
maxY: stats.maxY,
|
|
3181
|
+
pixels: stats.pixels,
|
|
3182
|
+
subjectPixels: realSubjectPixels,
|
|
3183
|
+
cx: 0,
|
|
3184
|
+
cy: 0
|
|
3185
|
+
});
|
|
3186
|
+
realSubjectPixels = 0;
|
|
3187
|
+
},
|
|
3188
|
+
(idx) => {
|
|
3189
|
+
if (subject[idx]) realSubjectPixels++;
|
|
3190
|
+
}
|
|
3191
|
+
);
|
|
3192
|
+
const big = components.filter((comp) => comp.subjectPixels >= minPixels);
|
|
3193
|
+
log.dim(
|
|
3194
|
+
` components: ${big.length} kept, ${components.length - big.length} discarded (< ${minPixels} px)`
|
|
3195
|
+
);
|
|
3196
|
+
if (big.length < names.length) {
|
|
3197
|
+
log.warn(
|
|
3198
|
+
`Only ${big.length} components \u2265 ${minPixels}px; expected ${names.length}. Lower --min-pixels or check the sheet.`
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
const heights = big.map((comp) => comp.maxY - comp.minY + 1).sort((a, b) => a - b);
|
|
3202
|
+
const medianH = heights[Math.floor(heights.length / 2)] || 50;
|
|
3203
|
+
const bandH = Math.max(20, medianH * 0.6);
|
|
3204
|
+
for (const comp of big) {
|
|
3205
|
+
comp.cx = (comp.minX + comp.maxX) / 2;
|
|
3206
|
+
comp.cy = (comp.minY + comp.maxY) / 2;
|
|
3207
|
+
}
|
|
3208
|
+
big.sort((a, b) => a.cy - b.cy);
|
|
3209
|
+
const bands = [];
|
|
3210
|
+
for (const comp of big) {
|
|
3211
|
+
const last = bands[bands.length - 1];
|
|
3212
|
+
if (!last || comp.cy - last.maxCy > bandH) {
|
|
3213
|
+
bands.push({ items: [comp], maxCy: comp.cy });
|
|
3214
|
+
} else {
|
|
3215
|
+
last.items.push(comp);
|
|
3216
|
+
last.maxCy = Math.max(last.maxCy, comp.cy);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
const sorted = [];
|
|
3220
|
+
for (const band of bands) {
|
|
3221
|
+
band.items.sort((a, b) => a.cx - b.cx);
|
|
3222
|
+
sorted.push(...band.items);
|
|
3223
|
+
}
|
|
3224
|
+
const extractions = [];
|
|
3225
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
3226
|
+
const comp = sorted[i];
|
|
3227
|
+
const name = i < names.length ? names[i] : `extra-${i - names.length}`;
|
|
3228
|
+
const padX0 = Math.max(0, comp.minX - padding);
|
|
3229
|
+
const padY0 = Math.max(0, comp.minY - padding);
|
|
3230
|
+
const padX1 = Math.min(W, comp.maxX + padding + 1);
|
|
3231
|
+
const padY1 = Math.min(H, comp.maxY + padding + 1);
|
|
3232
|
+
const cropW = padX1 - padX0;
|
|
3233
|
+
const cropH = padY1 - padY0;
|
|
3234
|
+
if (cropW < 4 || cropH < 4) {
|
|
3235
|
+
log.warn(`Skipping "${name}" \u2014 crop too small (${cropW}x${cropH}).`);
|
|
3236
|
+
continue;
|
|
3237
|
+
}
|
|
3238
|
+
const out = new PNG2({ width: cropW, height: cropH });
|
|
3239
|
+
for (let yo = 0; yo < cropH; yo++) {
|
|
3240
|
+
const sy = padY0 + yo;
|
|
3241
|
+
for (let xo = 0; xo < cropW; xo++) {
|
|
3242
|
+
const sx = padX0 + xo;
|
|
3243
|
+
const sIdx = (sy * W + sx) * 4;
|
|
3244
|
+
const oIdx = (yo * cropW + xo) * 4;
|
|
3245
|
+
const r = data[sIdx + 0];
|
|
3246
|
+
const g = data[sIdx + 1];
|
|
3247
|
+
const b = data[sIdx + 2];
|
|
3248
|
+
const a = data[sIdx + 3];
|
|
3249
|
+
out.data[oIdx + 0] = r;
|
|
3250
|
+
out.data[oIdx + 1] = g;
|
|
3251
|
+
out.data[oIdx + 2] = b;
|
|
3252
|
+
if (inputMode === "alpha") {
|
|
3253
|
+
out.data[oIdx + 3] = a;
|
|
3254
|
+
} else {
|
|
3255
|
+
const isWhite = r >= chroma && g >= chroma && b >= chroma;
|
|
3256
|
+
out.data[oIdx + 3] = isWhite ? 0 : 255;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
const outPath = path14.join(outDir, `${name}.png`);
|
|
3261
|
+
await writePng(outPath, out);
|
|
3262
|
+
const sidecar = {
|
|
3263
|
+
name,
|
|
3264
|
+
out: outPath,
|
|
3265
|
+
sheet: { w: W, h: H },
|
|
3266
|
+
sheetBBox: { x: padX0, y: padY0, w: cropW, h: cropH },
|
|
3267
|
+
sheetBBoxNorm: {
|
|
3268
|
+
x: +(padX0 / W).toFixed(3),
|
|
3269
|
+
y: +(padY0 / H).toFixed(3),
|
|
3270
|
+
w: +(cropW / W).toFixed(3),
|
|
3271
|
+
h: +(cropH / H).toFixed(3)
|
|
3272
|
+
},
|
|
3273
|
+
cropDims: { w: cropW, h: cropH },
|
|
3274
|
+
aspectRatio: +(cropW / cropH).toFixed(3),
|
|
3275
|
+
componentPixels: comp.pixels
|
|
3276
|
+
};
|
|
3277
|
+
const { name: _n, out: _o, ...sidecarBody } = sidecar;
|
|
3278
|
+
await fs14.writeFile(
|
|
3279
|
+
outPath.replace(/\.png$/i, "") + ".bbox.json",
|
|
3280
|
+
JSON.stringify(sidecarBody, null, 2)
|
|
3281
|
+
);
|
|
3282
|
+
extractions.push(sidecar);
|
|
3283
|
+
log.success(
|
|
3284
|
+
`${name}.png ${cropW}x${cropH} (ar ${sidecar.aspectRatio}) at sheet ${padX0},${padY0}`
|
|
3285
|
+
);
|
|
3286
|
+
}
|
|
3287
|
+
const debugPath = path14.join(outDir, "extract-debug.json");
|
|
3288
|
+
await fs14.writeFile(
|
|
3289
|
+
debugPath,
|
|
3290
|
+
JSON.stringify(
|
|
3291
|
+
{
|
|
3292
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3293
|
+
input,
|
|
3294
|
+
sheet: { w: W, h: H },
|
|
3295
|
+
mode: inputMode,
|
|
3296
|
+
rawComponentCount: components.length,
|
|
3297
|
+
filteredComponentCount: big.length,
|
|
3298
|
+
bandH,
|
|
3299
|
+
extractions: extractions.map((e) => ({
|
|
3300
|
+
name: e.name,
|
|
3301
|
+
sheetBBox: e.sheetBBox,
|
|
3302
|
+
sheetBBoxNorm: e.sheetBBoxNorm,
|
|
3303
|
+
ar: e.aspectRatio
|
|
3304
|
+
}))
|
|
3305
|
+
},
|
|
3306
|
+
null,
|
|
3307
|
+
2
|
|
3308
|
+
)
|
|
3309
|
+
);
|
|
3310
|
+
const missing = names.filter((n) => !extractions.some((e) => e.name === n));
|
|
3311
|
+
log.plain("");
|
|
3312
|
+
log.success(`Extracted ${extractions.length} elements \u2192 ${outDir} (+ .bbox.json sidecars).`);
|
|
3313
|
+
if (missing.length > 0) {
|
|
3314
|
+
log.warn(`Missing: ${missing.join(", ")} \u2014 fewer components than names on the sheet.`);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
function parseBox(raw, label) {
|
|
3318
|
+
const parts = raw.split(",").map((part) => Number.parseInt(part.trim(), 10));
|
|
3319
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
3320
|
+
fail(`Invalid ${label} box \u2014 expected x,y,w,h.`, { raw });
|
|
3321
|
+
}
|
|
3322
|
+
const [x, y, w, h] = parts;
|
|
3323
|
+
if (w <= 0 || h <= 0) fail(`Invalid ${label} box dimensions.`, { raw });
|
|
3324
|
+
return { x, y, w, h };
|
|
3325
|
+
}
|
|
3326
|
+
function parsePairs(raw) {
|
|
3327
|
+
return raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
3328
|
+
const pieces = part.split(":");
|
|
3329
|
+
if (pieces.length !== 3 && pieces.length !== 4) {
|
|
3330
|
+
fail(
|
|
3331
|
+
"Invalid pair \u2014 expected name:cleanX,cleanY,w,h:annX,annY,w,h[:expectedComponents].",
|
|
3332
|
+
{ pair: part }
|
|
3333
|
+
);
|
|
3334
|
+
}
|
|
3335
|
+
const [name, cleanRaw, annotatedRaw, expectedRaw] = pieces;
|
|
3336
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) fail("Invalid pair name.", { name });
|
|
3337
|
+
const expectedComponents = expectedRaw === void 0 ? null : Number.parseInt(expectedRaw, 10);
|
|
3338
|
+
if (expectedRaw !== void 0 && (!Number.isFinite(expectedComponents) || expectedComponents < 1)) {
|
|
3339
|
+
fail("Invalid expected component count.", { name, expectedRaw });
|
|
3340
|
+
}
|
|
3341
|
+
return {
|
|
3342
|
+
name,
|
|
3343
|
+
cleanBox: parseBox(cleanRaw, `${name} clean`),
|
|
3344
|
+
annotatedBox: parseBox(annotatedRaw, `${name} annotated`),
|
|
3345
|
+
expectedComponents
|
|
3346
|
+
};
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
function assertInside(box, image, label) {
|
|
3350
|
+
if (box.x < 0 || box.y < 0 || box.x + box.w > image.width || box.y + box.h > image.height) {
|
|
3351
|
+
fail(`${label} crop is outside the source image.`, {
|
|
3352
|
+
box,
|
|
3353
|
+
image: { w: image.width, h: image.height }
|
|
3354
|
+
});
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
function edgeOpaqueFraction(png, alphaThresh = 40) {
|
|
3358
|
+
const { width: w, height: h, data } = png;
|
|
3359
|
+
const alphaAt = (x, y) => data[(y * w + x << 2) + 3];
|
|
3360
|
+
let top = 0;
|
|
3361
|
+
let bottom = 0;
|
|
3362
|
+
let left = 0;
|
|
3363
|
+
let right = 0;
|
|
3364
|
+
for (let x = 0; x < w; x += 1) {
|
|
3365
|
+
if (alphaAt(x, 0) > alphaThresh) top += 1;
|
|
3366
|
+
if (alphaAt(x, h - 1) > alphaThresh) bottom += 1;
|
|
3367
|
+
}
|
|
3368
|
+
for (let y = 0; y < h; y += 1) {
|
|
3369
|
+
if (alphaAt(0, y) > alphaThresh) left += 1;
|
|
3370
|
+
if (alphaAt(w - 1, y) > alphaThresh) right += 1;
|
|
3371
|
+
}
|
|
3372
|
+
return { top: top / w, bottom: bottom / w, left: left / h, right: right / h };
|
|
3373
|
+
}
|
|
3374
|
+
function assertNotFlush(png, label, maxFrac) {
|
|
3375
|
+
const edges = edgeOpaqueFraction(png);
|
|
3376
|
+
const flush2 = Object.entries(edges).filter(([, frac]) => frac > maxFrac);
|
|
3377
|
+
if (flush2.length > 0) {
|
|
3378
|
+
fail(
|
|
3379
|
+
`${label}: crop box is slicing the element \u2014 opaque content is flush against the image edge. Widen the crop box (or pass --edge-flush-max 1 for a deliberate full-bleed asset).`,
|
|
3380
|
+
{
|
|
3381
|
+
flushEdges: flush2.map(([edge, frac]) => ({ edge, opaqueFraction: Number(frac.toFixed(3)) })),
|
|
3382
|
+
maxFraction: maxFrac
|
|
3383
|
+
}
|
|
3384
|
+
);
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
var isWhitePx = (data, idx) => data[idx + 3] > 16 && data[idx] >= 245 && data[idx + 1] >= 245 && data[idx + 2] >= 245;
|
|
3388
|
+
var isSubjectPx = (data, idx) => data[idx + 3] > 16 && !isWhitePx(data, idx);
|
|
3389
|
+
function greenAlpha(r, g, b, a) {
|
|
3390
|
+
if (a === 0) return 0;
|
|
3391
|
+
const greenStrength = g - Math.max(r, b);
|
|
3392
|
+
const isGreen = g > r * 1.4 && g > b * 1.4 && greenStrength >= 50 && g >= 45;
|
|
3393
|
+
if (isGreen) {
|
|
3394
|
+
return Math.max(160, Math.min(255, Math.round(greenStrength / 180 * 255)));
|
|
3395
|
+
}
|
|
3396
|
+
return 0;
|
|
3397
|
+
}
|
|
3398
|
+
function convertGreenMask(annotatedCrop) {
|
|
3399
|
+
const out = new PNG2({ width: annotatedCrop.width, height: annotatedCrop.height });
|
|
3400
|
+
let filled = 0;
|
|
3401
|
+
let minX = annotatedCrop.width;
|
|
3402
|
+
let minY = annotatedCrop.height;
|
|
3403
|
+
let maxX = -1;
|
|
3404
|
+
let maxY = -1;
|
|
3405
|
+
for (let y = 0; y < annotatedCrop.height; y += 1) {
|
|
3406
|
+
for (let x = 0; x < annotatedCrop.width; x += 1) {
|
|
3407
|
+
const idx = y * annotatedCrop.width + x << 2;
|
|
3408
|
+
const alpha = greenAlpha(
|
|
3409
|
+
annotatedCrop.data[idx],
|
|
3410
|
+
annotatedCrop.data[idx + 1],
|
|
3411
|
+
annotatedCrop.data[idx + 2],
|
|
3412
|
+
annotatedCrop.data[idx + 3]
|
|
3413
|
+
);
|
|
3414
|
+
out.data[idx] = 255;
|
|
3415
|
+
out.data[idx + 1] = 255;
|
|
3416
|
+
out.data[idx + 2] = 255;
|
|
3417
|
+
out.data[idx + 3] = alpha;
|
|
3418
|
+
if (alpha > 0) {
|
|
3419
|
+
filled += 1;
|
|
3420
|
+
minX = Math.min(minX, x);
|
|
3421
|
+
minY = Math.min(minY, y);
|
|
3422
|
+
maxX = Math.max(maxX, x);
|
|
3423
|
+
maxY = Math.max(maxY, y);
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
const coverage = filled / (annotatedCrop.width * annotatedCrop.height);
|
|
3428
|
+
const bbox = filled > 0 ? {
|
|
3429
|
+
x: minX / annotatedCrop.width,
|
|
3430
|
+
y: minY / annotatedCrop.height,
|
|
3431
|
+
w: (maxX - minX + 1) / annotatedCrop.width,
|
|
3432
|
+
h: (maxY - minY + 1) / annotatedCrop.height
|
|
3433
|
+
} : null;
|
|
3434
|
+
return { png: out, coverage, filledPixels: filled, bbox };
|
|
3435
|
+
}
|
|
3436
|
+
function subjectBBox(image) {
|
|
3437
|
+
let pixels = 0;
|
|
3438
|
+
let minX = image.width;
|
|
3439
|
+
let minY = image.height;
|
|
3440
|
+
let maxX = -1;
|
|
3441
|
+
let maxY = -1;
|
|
3442
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
3443
|
+
for (let x = 0; x < image.width; x += 1) {
|
|
3444
|
+
const idx = y * image.width + x << 2;
|
|
3445
|
+
if (!isSubjectPx(image.data, idx)) continue;
|
|
3446
|
+
pixels += 1;
|
|
3447
|
+
minX = Math.min(minX, x);
|
|
3448
|
+
minY = Math.min(minY, y);
|
|
3449
|
+
maxX = Math.max(maxX, x);
|
|
3450
|
+
maxY = Math.max(maxY, y);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
return pixels > 0 ? {
|
|
3454
|
+
pixels,
|
|
3455
|
+
x: minX / image.width,
|
|
3456
|
+
y: minY / image.height,
|
|
3457
|
+
w: (maxX - minX + 1) / image.width,
|
|
3458
|
+
h: (maxY - minY + 1) / image.height
|
|
3459
|
+
} : null;
|
|
3460
|
+
}
|
|
3461
|
+
function bboxDelta(a, b) {
|
|
3462
|
+
if (!a || !b) return Infinity;
|
|
3463
|
+
return Math.max(
|
|
3464
|
+
Math.abs(a.x - b.x),
|
|
3465
|
+
Math.abs(a.y - b.y),
|
|
3466
|
+
Math.abs(a.w - b.w),
|
|
3467
|
+
Math.abs(a.h - b.h)
|
|
3468
|
+
);
|
|
3469
|
+
}
|
|
3470
|
+
function greenComponents(mask, minPixels) {
|
|
3471
|
+
const components = [];
|
|
3472
|
+
scanComponents(
|
|
3473
|
+
mask.width,
|
|
3474
|
+
mask.height,
|
|
3475
|
+
(idx) => mask.data[(idx << 2) + 3] > 0,
|
|
3476
|
+
(stats) => {
|
|
3477
|
+
if (stats.pixels < minPixels) return;
|
|
3478
|
+
components.push({
|
|
3479
|
+
pixels: stats.pixels,
|
|
3480
|
+
bbox: {
|
|
3481
|
+
x: stats.minX / mask.width,
|
|
3482
|
+
y: stats.minY / mask.height,
|
|
3483
|
+
w: (stats.maxX - stats.minX + 1) / mask.width,
|
|
3484
|
+
h: (stats.maxY - stats.minY + 1) / mask.height
|
|
3485
|
+
}
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
);
|
|
3489
|
+
return components;
|
|
3490
|
+
}
|
|
3491
|
+
function blendPixel(image, x, y, rgba) {
|
|
3492
|
+
if (x < 0 || y < 0 || x >= image.width || y >= image.height) return;
|
|
3493
|
+
const idx = y * image.width + x << 2;
|
|
3494
|
+
const a = rgba[3] / 255;
|
|
3495
|
+
image.data[idx] = Math.round(rgba[0] * a + image.data[idx] * (1 - a));
|
|
3496
|
+
image.data[idx + 1] = Math.round(rgba[1] * a + image.data[idx + 1] * (1 - a));
|
|
3497
|
+
image.data[idx + 2] = Math.round(rgba[2] * a + image.data[idx + 2] * (1 - a));
|
|
3498
|
+
image.data[idx + 3] = Math.max(image.data[idx + 3], rgba[3]);
|
|
3499
|
+
}
|
|
3500
|
+
function makeOverlay(clean2, mask) {
|
|
3501
|
+
const out = new PNG2({ width: clean2.width, height: clean2.height });
|
|
3502
|
+
clean2.data.copy(out.data);
|
|
3503
|
+
for (let y = 0; y < mask.height; y += 1) {
|
|
3504
|
+
for (let x = 0; x < mask.width; x += 1) {
|
|
3505
|
+
const idx = y * mask.width + x << 2;
|
|
3506
|
+
const alpha = mask.data[idx + 3];
|
|
3507
|
+
if (alpha > 0) blendPixel(out, x, y, [0, 255, 0, Math.min(190, alpha)]);
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
return out;
|
|
3511
|
+
}
|
|
3512
|
+
async function uiMasks(opts, log) {
|
|
3513
|
+
const input = requireOpt(opts.input, "--in", "masks");
|
|
3514
|
+
const outDir = requireOpt(opts.outDir, "--out-dir", "masks");
|
|
3515
|
+
const pairs = parsePairs(requireOpt(opts.pairs, "--pairs", "masks"));
|
|
3516
|
+
const minCoverage = opts.coverageMin ?? 0.01;
|
|
3517
|
+
const maxCoverage = opts.coverageMax ?? 0.85;
|
|
3518
|
+
const minComponentPixels = opts.componentMinPixels ?? 100;
|
|
3519
|
+
const registrationTolerance = opts.registrationTolerance ?? 0.04;
|
|
3520
|
+
const edgeFlushMax = opts.edgeFlushMax ?? 0.04;
|
|
3521
|
+
const image = await loadPng(input);
|
|
3522
|
+
await fs14.mkdir(outDir, { recursive: true });
|
|
3523
|
+
log.plain(c.bold("genex ui masks"));
|
|
3524
|
+
log.dim(` ${input} (${image.width}x${image.height}), ${pairs.length} pair(s) \u2192 ${outDir}`);
|
|
3525
|
+
const results = [];
|
|
3526
|
+
for (const pair of pairs) {
|
|
3527
|
+
if (pair.cleanBox.w !== pair.annotatedBox.w || pair.cleanBox.h !== pair.annotatedBox.h) {
|
|
3528
|
+
fail("Clean and annotated crop boxes must have identical dimensions.", pair);
|
|
3529
|
+
}
|
|
3530
|
+
assertInside(pair.cleanBox, image, `${pair.name} clean`);
|
|
3531
|
+
assertInside(pair.annotatedBox, image, `${pair.name} annotated`);
|
|
3532
|
+
const clean2 = cropPng(image, pair.cleanBox);
|
|
3533
|
+
const annotated = cropPng(image, pair.annotatedBox);
|
|
3534
|
+
assertNotFlush(clean2, `${pair.name} clean frame`, edgeFlushMax);
|
|
3535
|
+
const cleanBBox = subjectBBox(clean2);
|
|
3536
|
+
const annotatedBBox = subjectBBox(annotated);
|
|
3537
|
+
const registrationDelta = bboxDelta(cleanBBox, annotatedBBox);
|
|
3538
|
+
if (registrationDelta > registrationTolerance) {
|
|
3539
|
+
fail("Annotated copy registration differs too much from the clean asset.", {
|
|
3540
|
+
name: pair.name,
|
|
3541
|
+
registrationDelta: Number(registrationDelta.toFixed(4)),
|
|
3542
|
+
registrationTolerance,
|
|
3543
|
+
cleanBBox,
|
|
3544
|
+
annotatedBBox
|
|
3545
|
+
});
|
|
3546
|
+
}
|
|
3547
|
+
const converted = convertGreenMask(annotated);
|
|
3548
|
+
if (converted.coverage < minCoverage || converted.coverage > maxCoverage) {
|
|
3549
|
+
fail("Green mask coverage outside the accepted range.", {
|
|
3550
|
+
name: pair.name,
|
|
3551
|
+
coverage: converted.coverage,
|
|
3552
|
+
minCoverage,
|
|
3553
|
+
maxCoverage
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3556
|
+
const components = greenComponents(converted.png, minComponentPixels);
|
|
3557
|
+
if (pair.expectedComponents && components.length !== pair.expectedComponents) {
|
|
3558
|
+
fail("Green mask component count mismatch.", {
|
|
3559
|
+
name: pair.name,
|
|
3560
|
+
expectedComponents: pair.expectedComponents,
|
|
3561
|
+
actualComponents: components.length
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
const overlay = makeOverlay(clean2, converted.png);
|
|
3565
|
+
const framePath = path14.join(outDir, `${pair.name}-frame.png`);
|
|
3566
|
+
const maskPath = path14.join(outDir, `${pair.name}-mask.png`);
|
|
3567
|
+
const annotatedPath = path14.join(outDir, `${pair.name}-annotated-source.png`);
|
|
3568
|
+
const overlayPath = path14.join(outDir, `${pair.name}-overlay.png`);
|
|
3569
|
+
const metaPath = path14.join(outDir, `${pair.name}.annotated-progress.json`);
|
|
3570
|
+
await writePng(framePath, clean2);
|
|
3571
|
+
await writePng(maskPath, converted.png);
|
|
3572
|
+
await writePng(annotatedPath, annotated);
|
|
3573
|
+
await writePng(overlayPath, overlay);
|
|
3574
|
+
const meta = {
|
|
3575
|
+
name: pair.name,
|
|
3576
|
+
provenance: "clean asset + annotated duplicate green-key",
|
|
3577
|
+
source: input,
|
|
3578
|
+
// The DOM fill's channel box (FB) — duplicated from mask.bbox at the top
|
|
3579
|
+
// level so the HUD skill reads it without digging into the mask object.
|
|
3580
|
+
fillBox: converted.bbox,
|
|
3581
|
+
clean: {
|
|
3582
|
+
path: framePath,
|
|
3583
|
+
crop: pair.cleanBox,
|
|
3584
|
+
size: { w: clean2.width, h: clean2.height },
|
|
3585
|
+
subjectBBox: cleanBBox
|
|
3586
|
+
},
|
|
3587
|
+
annotated: {
|
|
3588
|
+
path: annotatedPath,
|
|
3589
|
+
crop: pair.annotatedBox,
|
|
3590
|
+
size: { w: annotated.width, h: annotated.height },
|
|
3591
|
+
subjectBBox: annotatedBBox
|
|
3592
|
+
},
|
|
3593
|
+
registration: {
|
|
3594
|
+
bboxDelta: Number(registrationDelta.toFixed(4)),
|
|
3595
|
+
tolerance: registrationTolerance
|
|
3596
|
+
},
|
|
3597
|
+
mask: {
|
|
3598
|
+
path: maskPath,
|
|
3599
|
+
size: { w: converted.png.width, h: converted.png.height },
|
|
3600
|
+
coverage: Number(converted.coverage.toFixed(4)),
|
|
3601
|
+
filledPixels: converted.filledPixels,
|
|
3602
|
+
bbox: converted.bbox,
|
|
3603
|
+
components,
|
|
3604
|
+
expectedComponents: pair.expectedComponents
|
|
3605
|
+
},
|
|
3606
|
+
overlay: overlayPath
|
|
3607
|
+
};
|
|
3608
|
+
await fs14.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}
|
|
3609
|
+
`);
|
|
3610
|
+
results.push(meta);
|
|
3611
|
+
const fb = converted.bbox;
|
|
3612
|
+
const fbText = fb ? ` fillBox ${[fb.x, fb.y, fb.w, fb.h].map((v) => Number(v.toFixed(4))).join(",")},` : "";
|
|
3613
|
+
log.success(
|
|
3614
|
+
`${pair.name}: coverage ${meta.mask.coverage},${fbText} ${components.length} component(s), registration \u0394 ${meta.registration.bboxDelta}`
|
|
3615
|
+
);
|
|
3616
|
+
}
|
|
3617
|
+
const indexPath = path14.join(outDir, "annotated-progress.json");
|
|
3618
|
+
await fs14.writeFile(indexPath, `${JSON.stringify({ input, pairs: results }, null, 2)}
|
|
3619
|
+
`);
|
|
3620
|
+
log.plain("");
|
|
3621
|
+
log.success(`Wrote ${pairs.length} frame+mask pair(s) \u2192 ${outDir} (index: ${indexPath}).`);
|
|
3622
|
+
log.dim(" Eyeball each *-overlay.png \u2014 green must cover exactly the fill region.");
|
|
3623
|
+
}
|
|
3624
|
+
var hexColor = (r, g, b) => `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
3625
|
+
var luminance = (r, g, b) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
3626
|
+
var chromaOf = (r, g, b) => (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
|
|
3627
|
+
var rgbDist = (a, r, g, b) => Math.hypot(a[0] - r, a[1] - g, a[2] - b);
|
|
3628
|
+
var newBucket = () => ({
|
|
3629
|
+
n: 0,
|
|
3630
|
+
r: 0,
|
|
3631
|
+
g: 0,
|
|
3632
|
+
b: 0,
|
|
3633
|
+
minX: Infinity,
|
|
3634
|
+
minY: Infinity,
|
|
3635
|
+
maxX: -1,
|
|
3636
|
+
maxY: -1
|
|
3637
|
+
});
|
|
3638
|
+
function addToBucket(bucket, r, g, b, x, y) {
|
|
3639
|
+
bucket.n += 1;
|
|
3640
|
+
bucket.r += r;
|
|
3641
|
+
bucket.g += g;
|
|
3642
|
+
bucket.b += b;
|
|
3643
|
+
bucket.minX = Math.min(bucket.minX, x);
|
|
3644
|
+
bucket.minY = Math.min(bucket.minY, y);
|
|
3645
|
+
bucket.maxX = Math.max(bucket.maxX, x);
|
|
3646
|
+
bucket.maxY = Math.max(bucket.maxY, y);
|
|
3647
|
+
}
|
|
3648
|
+
function summarizeBucket(bucket, total, box) {
|
|
3649
|
+
if (bucket.n === 0) return null;
|
|
3650
|
+
const r = bucket.r / bucket.n;
|
|
3651
|
+
const g = bucket.g / bucket.n;
|
|
3652
|
+
const b = bucket.b / bucket.n;
|
|
3653
|
+
return {
|
|
3654
|
+
hex: hexColor(r, g, b),
|
|
3655
|
+
rgb: [Math.round(r), Math.round(g), Math.round(b)],
|
|
3656
|
+
coverage: Number((bucket.n / total).toFixed(4)),
|
|
3657
|
+
// bbox of these pixels, as fractions of the sampled box (the footprint)
|
|
3658
|
+
bbox: {
|
|
3659
|
+
x: Number((bucket.minX / box.w).toFixed(3)),
|
|
3660
|
+
y: Number((bucket.minY / box.h).toFixed(3)),
|
|
3661
|
+
w: Number(((bucket.maxX - bucket.minX + 1) / box.w).toFixed(3)),
|
|
3662
|
+
h: Number(((bucket.maxY - bucket.minY + 1) / box.h).toFixed(3))
|
|
3663
|
+
}
|
|
3664
|
+
};
|
|
3665
|
+
}
|
|
3666
|
+
async function uiTextColor(opts, log) {
|
|
3667
|
+
const input = requireOpt(opts.input, "--in", "text-color");
|
|
3668
|
+
const box = parseBox(requireOpt(opts.box, "--box", "text-color"), "sample");
|
|
3669
|
+
const quantize = opts.quantize ?? 12;
|
|
3670
|
+
const inkDist = opts.inkDist ?? 60;
|
|
3671
|
+
const minFrac = opts.minFrac ?? 4e-3;
|
|
3672
|
+
const image = await loadPng(input);
|
|
3673
|
+
if (box.x < 0 || box.y < 0 || box.x + box.w > image.width || box.y + box.h > image.height) {
|
|
3674
|
+
fail("Box is outside the source image.", { box, image: { w: image.width, h: image.height } });
|
|
3675
|
+
}
|
|
3676
|
+
const region = cropPng(image, box);
|
|
3677
|
+
if (opts.cropPath) await writePng(opts.cropPath, region);
|
|
3678
|
+
const total = box.w * box.h;
|
|
3679
|
+
const q = Math.max(1, quantize);
|
|
3680
|
+
const hist = /* @__PURE__ */ new Map();
|
|
3681
|
+
for (let i = 0; i < region.data.length; i += 4) {
|
|
3682
|
+
const r = region.data[i];
|
|
3683
|
+
const g = region.data[i + 1];
|
|
3684
|
+
const b = region.data[i + 2];
|
|
3685
|
+
const key = `${Math.round(r / q) * q},${Math.round(g / q) * q},${Math.round(b / q) * q}`;
|
|
3686
|
+
let e = hist.get(key);
|
|
3687
|
+
if (!e) hist.set(key, e = { n: 0, r: 0, g: 0, b: 0 });
|
|
3688
|
+
e.n += 1;
|
|
3689
|
+
e.r += r;
|
|
3690
|
+
e.g += g;
|
|
3691
|
+
e.b += b;
|
|
3692
|
+
}
|
|
3693
|
+
const dominant = [...hist.values()].sort((a, b) => b.n - a.n).slice(0, 10).map((e) => ({
|
|
3694
|
+
hex: hexColor(e.r / e.n, e.g / e.n, e.b / e.n),
|
|
3695
|
+
rgb: [Math.round(e.r / e.n), Math.round(e.g / e.n), Math.round(e.b / e.n)],
|
|
3696
|
+
coverage: Number((e.n / total).toFixed(4))
|
|
3697
|
+
}));
|
|
3698
|
+
const background = dominant[0];
|
|
3699
|
+
const bg = background.rgb;
|
|
3700
|
+
const ink = newBucket();
|
|
3701
|
+
const dark = newBucket();
|
|
3702
|
+
const light = newBucket();
|
|
3703
|
+
const chromatic = newBucket();
|
|
3704
|
+
for (let y = 0; y < region.height; y += 1) {
|
|
3705
|
+
for (let x = 0; x < region.width; x += 1) {
|
|
3706
|
+
const i = y * region.width + x << 2;
|
|
3707
|
+
const r = region.data[i];
|
|
3708
|
+
const g = region.data[i + 1];
|
|
3709
|
+
const b = region.data[i + 2];
|
|
3710
|
+
if (rgbDist(bg, r, g, b) < inkDist) continue;
|
|
3711
|
+
addToBucket(ink, r, g, b, x, y);
|
|
3712
|
+
const L = luminance(r, g, b);
|
|
3713
|
+
const C = chromaOf(r, g, b);
|
|
3714
|
+
if (C > 0.22) addToBucket(chromatic, r, g, b, x, y);
|
|
3715
|
+
else if (L < 0.4) addToBucket(dark, r, g, b, x, y);
|
|
3716
|
+
else if (L > 0.62) addToBucket(light, r, g, b, x, y);
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
const pick = (bucket) => {
|
|
3720
|
+
const s = summarizeBucket(bucket, total, box);
|
|
3721
|
+
return s && s.coverage >= minFrac ? s : null;
|
|
3722
|
+
};
|
|
3723
|
+
const result = {
|
|
3724
|
+
status: "ok",
|
|
3725
|
+
source: input,
|
|
3726
|
+
box,
|
|
3727
|
+
background,
|
|
3728
|
+
dominant,
|
|
3729
|
+
ink: pick(ink),
|
|
3730
|
+
candidates: {
|
|
3731
|
+
darkInk: pick(dark),
|
|
3732
|
+
lightInk: pick(light),
|
|
3733
|
+
chromaticInk: pick(chromatic)
|
|
3734
|
+
},
|
|
3735
|
+
params: { quantize: q, inkDist, minFrac }
|
|
3736
|
+
};
|
|
3737
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3738
|
+
`);
|
|
3739
|
+
if (opts.out) await fs14.writeFile(opts.out, `${JSON.stringify(result, null, 2)}
|
|
3740
|
+
`);
|
|
3741
|
+
if (opts.cropPath) log.dim(` crop \u2192 ${opts.cropPath}`);
|
|
3742
|
+
}
|
|
3743
|
+
async function uiTrim(opts, log) {
|
|
3744
|
+
const input = requireOpt(opts.input, "--in", "trim");
|
|
3745
|
+
const outPath = opts.out ?? input;
|
|
3746
|
+
if (/^https?:\/\//i.test(outPath)) {
|
|
3747
|
+
fail("--in is a URL \u2014 pass --out <png> to say where the trimmed copy goes.");
|
|
3748
|
+
}
|
|
3749
|
+
const png = await loadPng(input);
|
|
3750
|
+
const bbox = alphaBBoxPx(png);
|
|
3751
|
+
if (!bbox) fail("Image is fully transparent \u2014 nothing to trim.", { input });
|
|
3752
|
+
const cropBox = {
|
|
3753
|
+
x: bbox.minX,
|
|
3754
|
+
y: bbox.minY,
|
|
3755
|
+
w: bbox.maxX - bbox.minX + 1,
|
|
3756
|
+
h: bbox.maxY - bbox.minY + 1
|
|
3757
|
+
};
|
|
3758
|
+
const trimmed = cropPng(png, cropBox);
|
|
3759
|
+
await writePng(outPath, trimmed);
|
|
3760
|
+
const sidecar = computeBBoxes(trimmed);
|
|
3761
|
+
const sidecarPath = outPath.replace(/\.png$/i, "") + ".bbox.json";
|
|
3762
|
+
await fs14.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
|
|
3763
|
+
log.success(
|
|
3764
|
+
`Trimmed ${png.width}x${png.height} \u2192 ${trimmed.width}x${trimmed.height} (${outPath}).`
|
|
3765
|
+
);
|
|
3766
|
+
process.stdout.write(
|
|
3767
|
+
`${JSON.stringify({
|
|
3768
|
+
out: outPath,
|
|
3769
|
+
width: trimmed.width,
|
|
3770
|
+
height: trimmed.height,
|
|
3771
|
+
aspectRatio: sidecar.aspectRatio,
|
|
3772
|
+
outerBBox: sidecar.outerBBox,
|
|
3773
|
+
innerBBox: sidecar.innerBBox,
|
|
3774
|
+
sidecar: sidecarPath
|
|
3775
|
+
})}
|
|
3776
|
+
`
|
|
3777
|
+
);
|
|
3778
|
+
}
|
|
3779
|
+
|
|
2767
3780
|
// src/index.ts
|
|
2768
3781
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
|
|
2769
3782
|
var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize, and publish 3D games.
|
|
@@ -2783,6 +3796,9 @@ ${c.bold("Usage")}
|
|
|
2783
3796
|
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
2784
3797
|
genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
|
|
2785
3798
|
genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
|
|
3799
|
+
genex wait <id> Attach to a generation enqueued with --no-wait and
|
|
3800
|
+
print its asset URL(s) when it finishes. Safe to
|
|
3801
|
+
re-run \u2014 it never creates a new generation.
|
|
2786
3802
|
genex controller <type> [--force] Install a physics controller
|
|
2787
3803
|
(character|car|drone|networked-physics)
|
|
2788
3804
|
into src/controllers (+ assets into public/assets).
|
|
@@ -2793,14 +3809,31 @@ ${c.bold("Usage")}
|
|
|
2793
3809
|
genex explore ["<query>"] [options] Search the curated community gallery \u2014 proven
|
|
2794
3810
|
Three.js systems you can clone or borrow parts
|
|
2795
3811
|
from. No query lists the whole catalog.
|
|
3812
|
+
genex ui <tool> [options] Local pixel toolbox for generated UI art \u2014
|
|
3813
|
+
extract | masks | text-color | trim. Pure
|
|
3814
|
+
local PNG work (no API), reads a file or URL.
|
|
2796
3815
|
|
|
2797
3816
|
${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `video`)")}
|
|
2798
3817
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
2799
3818
|
--duration <sec> (sfx, video) target clip length in seconds.
|
|
2800
3819
|
--transparent (image) transparent background (alpha) \u2014 for decals/stickers.
|
|
2801
3820
|
--aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
|
|
3821
|
+
--quality <q> (image) quality preset: low | medium | high.
|
|
3822
|
+
--candidates <n> (image) generate 2-4 variants in ONE call and print every
|
|
3823
|
+
URL \u2014 pick the best instead of re-rolling. Needs
|
|
3824
|
+
--quality or --size; not with --remove-bg/--transparent.
|
|
3825
|
+
--size <WxH> (image) exact output size in px (e.g. 2560x1440).
|
|
3826
|
+
--edit <url> (image) edit THIS image (asset URL) with the prompt (i2i).
|
|
3827
|
+
--clean <url> (image) ML-remove the background of THIS image (asset URL);
|
|
3828
|
+
prompt is recorded but unused. Defaults --bg-mode sheet.
|
|
3829
|
+
--remove-bg (image) chain ML background removal after the gen/edit.
|
|
3830
|
+
--bg-mode <mode> (image) background-removal model: sprite | glyph | sheet.
|
|
2802
3831
|
--loop (video) generate a seamless loop.
|
|
2803
|
-
--
|
|
3832
|
+
--frame <url> (video) animate from THIS frame back to itself (seamless loop).
|
|
3833
|
+
--first-frame <url> (video) start frame for a two-frame motion \u2026
|
|
3834
|
+
--last-frame <url> (video) \u2026 and its end frame.
|
|
3835
|
+
--no-wait Enqueue only; don't block waiting for the asset. Prints
|
|
3836
|
+
the id that "genex wait <id>" picks the result up with.
|
|
2804
3837
|
--api-url <url> Override the API base URL.
|
|
2805
3838
|
--env <path> Token env file (default: ~/.genex/env).
|
|
2806
3839
|
|
|
@@ -2851,6 +3884,8 @@ ${c.bold("Options for `explore`")}
|
|
|
2851
3884
|
--api-url <url> Override the API base URL.
|
|
2852
3885
|
(No query at all lists the entire curated catalog.)
|
|
2853
3886
|
|
|
3887
|
+
${UI_USAGE}
|
|
3888
|
+
|
|
2854
3889
|
${c.bold("Global")}
|
|
2855
3890
|
--quiet Reduce output.
|
|
2856
3891
|
-h, --help Show this help.
|
|
@@ -2877,8 +3912,12 @@ ${c.bold("Examples")}
|
|
|
2877
3912
|
genex sfx "punchy laser zap" --duration 2
|
|
2878
3913
|
genex texture "mossy cracked cobblestone" --terrain
|
|
2879
3914
|
genex image "retro arcade poster art" --aspect portrait
|
|
2880
|
-
genex image "
|
|
2881
|
-
genex video "
|
|
3915
|
+
genex image "chalk graffiti tag, hand-drawn style" --transparent
|
|
3916
|
+
genex video "candle flame flickering in the dark, seamless loop" --loop
|
|
3917
|
+
genex image "misty citadel at dusk, cinematic wide shot" --aspect 16:9 --quality high
|
|
3918
|
+
genex video "slow drifting fog, embers rising" --frame <that-image-url> --duration 8 --no-wait
|
|
3919
|
+
genex wait <that-generation-id>
|
|
3920
|
+
genex ui extract --in <sheet-url> --out-dir ui/ --names health-bar,minimap,ammo
|
|
2882
3921
|
genex controller character
|
|
2883
3922
|
genex controller networked-physics
|
|
2884
3923
|
genex explore "grass"
|
|
@@ -2906,6 +3945,24 @@ function parseArgs(argv) {
|
|
|
2906
3945
|
"--timeout",
|
|
2907
3946
|
"--duration",
|
|
2908
3947
|
"--aspect",
|
|
3948
|
+
"--quality",
|
|
3949
|
+
"--candidates",
|
|
3950
|
+
"--size",
|
|
3951
|
+
"--edit",
|
|
3952
|
+
"--clean",
|
|
3953
|
+
"--bg-mode",
|
|
3954
|
+
"--frame",
|
|
3955
|
+
"--first-frame",
|
|
3956
|
+
"--last-frame",
|
|
3957
|
+
// `genex ui` string flags (the numeric ones come from UI_NUMBER_FLAGS).
|
|
3958
|
+
"--in",
|
|
3959
|
+
"--out-dir",
|
|
3960
|
+
"--names",
|
|
3961
|
+
"--pairs",
|
|
3962
|
+
"--box",
|
|
3963
|
+
"--out",
|
|
3964
|
+
"--crop",
|
|
3965
|
+
...Object.keys(UI_NUMBER_FLAGS),
|
|
2909
3966
|
"--source-repo-url",
|
|
2910
3967
|
"--source-author",
|
|
2911
3968
|
"--license",
|
|
@@ -2946,6 +4003,9 @@ function parseArgs(argv) {
|
|
|
2946
4003
|
case "--loop":
|
|
2947
4004
|
parsed.options.loop = true;
|
|
2948
4005
|
break;
|
|
4006
|
+
case "--remove-bg":
|
|
4007
|
+
parsed.options.removeBackground = true;
|
|
4008
|
+
break;
|
|
2949
4009
|
case "--force":
|
|
2950
4010
|
parsed.options.force = true;
|
|
2951
4011
|
break;
|
|
@@ -2997,6 +4057,15 @@ function parseArgs(argv) {
|
|
|
2997
4057
|
return parsed;
|
|
2998
4058
|
}
|
|
2999
4059
|
function applyValueFlag(options, flag, value) {
|
|
4060
|
+
const uiNumKey = UI_NUMBER_FLAGS[flag];
|
|
4061
|
+
if (uiNumKey) {
|
|
4062
|
+
const n = Number(value);
|
|
4063
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
4064
|
+
throw new Error(`Invalid ${flag} value: ${value}`);
|
|
4065
|
+
}
|
|
4066
|
+
options[uiNumKey] = n;
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
3000
4069
|
switch (flag) {
|
|
3001
4070
|
case "--dir":
|
|
3002
4071
|
options.dir = value;
|
|
@@ -3065,6 +4134,73 @@ function applyValueFlag(options, flag, value) {
|
|
|
3065
4134
|
case "--aspect":
|
|
3066
4135
|
options.aspect = value;
|
|
3067
4136
|
break;
|
|
4137
|
+
case "--quality":
|
|
4138
|
+
if (!["low", "medium", "high"].includes(value)) {
|
|
4139
|
+
throw new Error(`Invalid --quality value: ${value} (expected low|medium|high)`);
|
|
4140
|
+
}
|
|
4141
|
+
options.quality = value;
|
|
4142
|
+
break;
|
|
4143
|
+
case "--candidates": {
|
|
4144
|
+
const n = Number(value);
|
|
4145
|
+
if (!Number.isInteger(n) || n < 1 || n > 4) {
|
|
4146
|
+
throw new Error(`Invalid --candidates value: ${value} (expected an integer 1-4)`);
|
|
4147
|
+
}
|
|
4148
|
+
options.candidates = n;
|
|
4149
|
+
break;
|
|
4150
|
+
}
|
|
4151
|
+
case "--size": {
|
|
4152
|
+
const m = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(value.trim());
|
|
4153
|
+
const w = m ? Number(m[1]) : NaN;
|
|
4154
|
+
const h = m ? Number(m[2]) : NaN;
|
|
4155
|
+
if (!m || !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0) {
|
|
4156
|
+
throw new Error(`Invalid --size value: ${value} (expected WxH, e.g. 2560x1440)`);
|
|
4157
|
+
}
|
|
4158
|
+
options.width = w;
|
|
4159
|
+
options.height = h;
|
|
4160
|
+
break;
|
|
4161
|
+
}
|
|
4162
|
+
case "--edit":
|
|
4163
|
+
options.editUrl = value;
|
|
4164
|
+
break;
|
|
4165
|
+
case "--clean":
|
|
4166
|
+
options.cleanUrl = value;
|
|
4167
|
+
break;
|
|
4168
|
+
case "--bg-mode":
|
|
4169
|
+
if (!["sprite", "glyph", "sheet"].includes(value)) {
|
|
4170
|
+
throw new Error(`Invalid --bg-mode value: ${value} (expected sprite|glyph|sheet)`);
|
|
4171
|
+
}
|
|
4172
|
+
options.bgMode = value;
|
|
4173
|
+
break;
|
|
4174
|
+
case "--frame":
|
|
4175
|
+
options.frameUrl = value;
|
|
4176
|
+
break;
|
|
4177
|
+
case "--first-frame":
|
|
4178
|
+
options.firstFrameUrl = value;
|
|
4179
|
+
break;
|
|
4180
|
+
case "--last-frame":
|
|
4181
|
+
options.lastFrameUrl = value;
|
|
4182
|
+
break;
|
|
4183
|
+
case "--in":
|
|
4184
|
+
options.input = value;
|
|
4185
|
+
break;
|
|
4186
|
+
case "--out-dir":
|
|
4187
|
+
options.outDir = value;
|
|
4188
|
+
break;
|
|
4189
|
+
case "--names":
|
|
4190
|
+
options.names = value;
|
|
4191
|
+
break;
|
|
4192
|
+
case "--pairs":
|
|
4193
|
+
options.pairs = value;
|
|
4194
|
+
break;
|
|
4195
|
+
case "--box":
|
|
4196
|
+
options.box = value;
|
|
4197
|
+
break;
|
|
4198
|
+
case "--out":
|
|
4199
|
+
options.out = value;
|
|
4200
|
+
break;
|
|
4201
|
+
case "--crop":
|
|
4202
|
+
options.cropPath = value;
|
|
4203
|
+
break;
|
|
3068
4204
|
}
|
|
3069
4205
|
}
|
|
3070
4206
|
async function main() {
|
|
@@ -3135,6 +4271,12 @@ async function main() {
|
|
|
3135
4271
|
case "explore":
|
|
3136
4272
|
await runExplore({ ...parsed.options, query: parsed.options.name });
|
|
3137
4273
|
break;
|
|
4274
|
+
case "ui":
|
|
4275
|
+
await runUi({ ...parsed.options, sub: parsed.options.name });
|
|
4276
|
+
break;
|
|
4277
|
+
case "wait":
|
|
4278
|
+
await runWait({ ...parsed.options, id: parsed.options.name });
|
|
4279
|
+
break;
|
|
3138
4280
|
default:
|
|
3139
4281
|
log.error(`Unknown command: ${parsed.command}`);
|
|
3140
4282
|
log.plain(`Run ${c.cyan("genex --help")} for usage.`);
|