@genex-ai/cli-demo 0.47.0-dev.71 → 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 +1145 -15
- 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/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
|
)
|
|
@@ -2011,6 +2011,40 @@ async function* readSSE(body) {
|
|
|
2011
2011
|
|
|
2012
2012
|
// src/commands/generate.ts
|
|
2013
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
|
+
}
|
|
2014
2048
|
async function runGenerate(kind, opts) {
|
|
2015
2049
|
const log = createLogger({ quiet: opts.quiet });
|
|
2016
2050
|
const prompt = opts.prompt?.trim();
|
|
@@ -2027,13 +2061,7 @@ async function runGenerate(kind, opts) {
|
|
|
2027
2061
|
}
|
|
2028
2062
|
const meta = await readProject();
|
|
2029
2063
|
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
2030
|
-
const options =
|
|
2031
|
-
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
2032
|
-
if (kind === "sfx" && opts.duration) options.durationSeconds = opts.duration;
|
|
2033
|
-
if (kind === "image" && opts.transparent) options.transparent = true;
|
|
2034
|
-
if (kind === "image" && opts.aspect) options.aspect = opts.aspect;
|
|
2035
|
-
if (kind === "video" && opts.duration) options.durationSeconds = opts.duration;
|
|
2036
|
-
if (kind === "video" && opts.loop) options.loop = true;
|
|
2064
|
+
const options = buildGenOptions(kind, opts);
|
|
2037
2065
|
log.plain(c.bold(`genex ${kind}`));
|
|
2038
2066
|
log.dim(` ${prompt}`);
|
|
2039
2067
|
log.plain("");
|
|
@@ -2054,7 +2082,15 @@ async function runGenerate(kind, opts) {
|
|
|
2054
2082
|
return;
|
|
2055
2083
|
}
|
|
2056
2084
|
if (!res.ok) {
|
|
2057
|
-
|
|
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}).`);
|
|
2058
2094
|
process.exitCode = 1;
|
|
2059
2095
|
return;
|
|
2060
2096
|
}
|
|
@@ -2065,9 +2101,14 @@ async function runGenerate(kind, opts) {
|
|
|
2065
2101
|
return;
|
|
2066
2102
|
}
|
|
2067
2103
|
if (opts.noWait) {
|
|
2068
|
-
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.)");
|
|
2069
2107
|
return;
|
|
2070
2108
|
}
|
|
2109
|
+
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2110
|
+
}
|
|
2111
|
+
async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
2071
2112
|
log.step("Generating\u2026 (this can take up to a minute)");
|
|
2072
2113
|
const onProgress = (p) => log.dim(` ${p}%`);
|
|
2073
2114
|
const timeoutMs = waitTimeoutFor(kind);
|
|
@@ -2079,6 +2120,9 @@ async function runGenerate(kind, opts) {
|
|
|
2079
2120
|
process.exitCode = 1;
|
|
2080
2121
|
return;
|
|
2081
2122
|
}
|
|
2123
|
+
await reportTerminal(kind, view, log);
|
|
2124
|
+
}
|
|
2125
|
+
async function reportTerminal(kind, view, log) {
|
|
2082
2126
|
if (view.status !== "completed") {
|
|
2083
2127
|
log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
2084
2128
|
process.exitCode = 1;
|
|
@@ -2196,6 +2240,63 @@ function printHint(kind, files, log) {
|
|
|
2196
2240
|
log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
|
|
2197
2241
|
}
|
|
2198
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
|
+
|
|
2199
2300
|
// src/commands/controller.ts
|
|
2200
2301
|
import fs12 from "fs/promises";
|
|
2201
2302
|
import path13 from "path";
|
|
@@ -2776,6 +2877,906 @@ function rank(items, query) {
|
|
|
2776
2877
|
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || b.p.playsCount - a.p.playsCount).map((x) => x.p);
|
|
2777
2878
|
}
|
|
2778
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
|
+
|
|
2779
3780
|
// src/index.ts
|
|
2780
3781
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
|
|
2781
3782
|
var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize, and publish 3D games.
|
|
@@ -2795,6 +3796,9 @@ ${c.bold("Usage")}
|
|
|
2795
3796
|
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
2796
3797
|
genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
|
|
2797
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.
|
|
2798
3802
|
genex controller <type> [--force] Install a physics controller
|
|
2799
3803
|
(character|car|drone|networked-physics)
|
|
2800
3804
|
into src/controllers (+ assets into public/assets).
|
|
@@ -2805,14 +3809,31 @@ ${c.bold("Usage")}
|
|
|
2805
3809
|
genex explore ["<query>"] [options] Search the curated community gallery \u2014 proven
|
|
2806
3810
|
Three.js systems you can clone or borrow parts
|
|
2807
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.
|
|
2808
3815
|
|
|
2809
3816
|
${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `video`)")}
|
|
2810
3817
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
2811
3818
|
--duration <sec> (sfx, video) target clip length in seconds.
|
|
2812
3819
|
--transparent (image) transparent background (alpha) \u2014 for decals/stickers.
|
|
2813
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.
|
|
2814
3831
|
--loop (video) generate a seamless loop.
|
|
2815
|
-
--
|
|
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.
|
|
2816
3837
|
--api-url <url> Override the API base URL.
|
|
2817
3838
|
--env <path> Token env file (default: ~/.genex/env).
|
|
2818
3839
|
|
|
@@ -2863,6 +3884,8 @@ ${c.bold("Options for `explore`")}
|
|
|
2863
3884
|
--api-url <url> Override the API base URL.
|
|
2864
3885
|
(No query at all lists the entire curated catalog.)
|
|
2865
3886
|
|
|
3887
|
+
${UI_USAGE}
|
|
3888
|
+
|
|
2866
3889
|
${c.bold("Global")}
|
|
2867
3890
|
--quiet Reduce output.
|
|
2868
3891
|
-h, --help Show this help.
|
|
@@ -2889,8 +3912,12 @@ ${c.bold("Examples")}
|
|
|
2889
3912
|
genex sfx "punchy laser zap" --duration 2
|
|
2890
3913
|
genex texture "mossy cracked cobblestone" --terrain
|
|
2891
3914
|
genex image "retro arcade poster art" --aspect portrait
|
|
2892
|
-
genex image "
|
|
2893
|
-
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
|
|
2894
3921
|
genex controller character
|
|
2895
3922
|
genex controller networked-physics
|
|
2896
3923
|
genex explore "grass"
|
|
@@ -2918,6 +3945,24 @@ function parseArgs(argv) {
|
|
|
2918
3945
|
"--timeout",
|
|
2919
3946
|
"--duration",
|
|
2920
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),
|
|
2921
3966
|
"--source-repo-url",
|
|
2922
3967
|
"--source-author",
|
|
2923
3968
|
"--license",
|
|
@@ -2958,6 +4003,9 @@ function parseArgs(argv) {
|
|
|
2958
4003
|
case "--loop":
|
|
2959
4004
|
parsed.options.loop = true;
|
|
2960
4005
|
break;
|
|
4006
|
+
case "--remove-bg":
|
|
4007
|
+
parsed.options.removeBackground = true;
|
|
4008
|
+
break;
|
|
2961
4009
|
case "--force":
|
|
2962
4010
|
parsed.options.force = true;
|
|
2963
4011
|
break;
|
|
@@ -3009,6 +4057,15 @@ function parseArgs(argv) {
|
|
|
3009
4057
|
return parsed;
|
|
3010
4058
|
}
|
|
3011
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
|
+
}
|
|
3012
4069
|
switch (flag) {
|
|
3013
4070
|
case "--dir":
|
|
3014
4071
|
options.dir = value;
|
|
@@ -3077,6 +4134,73 @@ function applyValueFlag(options, flag, value) {
|
|
|
3077
4134
|
case "--aspect":
|
|
3078
4135
|
options.aspect = value;
|
|
3079
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;
|
|
3080
4204
|
}
|
|
3081
4205
|
}
|
|
3082
4206
|
async function main() {
|
|
@@ -3147,6 +4271,12 @@ async function main() {
|
|
|
3147
4271
|
case "explore":
|
|
3148
4272
|
await runExplore({ ...parsed.options, query: parsed.options.name });
|
|
3149
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;
|
|
3150
4280
|
default:
|
|
3151
4281
|
log.error(`Unknown command: ${parsed.command}`);
|
|
3152
4282
|
log.plain(`Run ${c.cyan("genex --help")} for usage.`);
|