@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/dist/index.js CHANGED
@@ -445,7 +445,7 @@ async function authorize(authBaseUrl, options) {
445
445
  cleanup();
446
446
  resolve(token);
447
447
  };
448
- const fail = (err) => {
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) => fail(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
- fail(
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
- log.error(`Generation request failed (HTTP ${res.status}).`);
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}). Re-run without --no-wait to get the finished asset URL.`);
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
- --no-wait Enqueue only; don't block waiting for the asset.
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 "neon graffiti tag, spray-paint style" --transparent
2893
- genex video "swirling neon plasma, seamless loop" --loop
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.`);