@genex-ai/cli-demo 0.80.2-dev.213 → 0.84.0-dev.215

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.
Files changed (25) hide show
  1. package/dist/index.js +801 -83
  2. package/package.json +1 -1
  3. package/templates/controllers/character/first-person.ts +54 -0
  4. package/templates/controllers/character/follow-camera.ts +35 -1
  5. package/templates/skills/genex-ai-character/SKILL.md +25 -5
  6. package/templates/skills/genex-ai-hud/SKILL.md +131 -36
  7. package/templates/skills/genex-ai-hud/references/masked-fill.md +19 -13
  8. package/templates/skills/genex-ai-hud/references/stage2-prompt-template.md +2 -2
  9. package/templates/skills/genex-ai-image/SKILL.md +40 -2
  10. package/templates/skills/genex-ai-menu/SKILL.md +35 -17
  11. package/templates/skills/genex-ai-model/SKILL.md +8 -0
  12. package/templates/skills/genex-ai-music/SKILL.md +142 -0
  13. package/templates/skills/genex-ai-skybox/SKILL.md +21 -5
  14. package/templates/skills/genex-ai-video/SKILL.md +13 -7
  15. package/templates/skills/genex-ai-voice/SKILL.md +151 -0
  16. package/templates/skills/genex-game-director/SKILL.md +128 -44
  17. package/templates/skills/genex-game-director/references/design-contract.md +7 -2
  18. package/templates/skills/genex-game-director/references/routing-map.md +4 -1
  19. package/templates/skills/genex-threejs-camera-direction/SKILL.md +4 -1
  20. package/templates/skills/genex-threejs-character-controller/SKILL.md +6 -1
  21. package/templates/skills/genex-threejs-character-controller/references/wiring.md +13 -2
  22. package/templates/skills/genex-threejs-creatures/SKILL.md +193 -0
  23. package/templates/skills/genex-threejs-game-ui/SKILL.md +5 -1
  24. package/templates/skills/genex-threejs-multiplayer/SKILL.md +6 -1
  25. package/templates/skills/genex-threejs-touch-controls/SKILL.md +7 -0
package/dist/index.js CHANGED
@@ -2006,21 +2006,27 @@ var UI_NUMBER_FLAGS = {
2006
2006
  "--quantize": "quantize",
2007
2007
  "--ink-dist": "inkDist",
2008
2008
  "--min-frac": "minFrac",
2009
- "--seam-tolerance": "seamTolerance"
2009
+ "--seam-tolerance": "seamTolerance",
2010
+ "--erode": "erode"
2010
2011
  };
2011
2012
  var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or https URL>)")}
2012
2013
  ui extract --out-dir <dir> --names a,b,c (reading order) \u2014 split an
2013
2014
  asset sheet into per-element transparent PNGs + .bbox.json
2014
- sidecars. [--expect N: hard-error unless exactly N
2015
- components] [--min-pixels 2000] [--padding 8] [--chroma 240]
2016
- [--dilate 0]
2015
+ sidecars. Where extract keys the white itself it also
2016
+ defringes the 2px sprite rim (matte un-blend \u2014 kills the
2017
+ white halo over dark scenes). [--expect N: hard-error
2018
+ unless exactly N components] [--min-pixels 2000]
2019
+ [--padding 8] [--chroma 240] [--dilate 0] [--no-defringe]
2017
2020
  ui masks --out-dir <dir> --pairs "name:cX,cY,w,h:aX,aY,w,h[:n];\u2026"
2018
2021
  OR --auto (detect clean/annotated cell pairs itself) \u2014
2019
2022
  clean frame + green-annotated duplicate \u2192 frame + fill mask
2020
2023
  + fillBox + per-segment fillBoxes (+ overlay + provenance
2021
- JSON). [--names a,b,c with --auto] [--registration-
2024
+ JSON). Hard-refuses provable sheet defects (white-painted
2025
+ fill trough, a crop catching a neighboring element).
2026
+ [--names a,b,c with --auto] [--expect N with --auto: every
2027
+ pair must yield exactly N fill components] [--registration-
2022
2028
  tolerance 0.04] [--coverage-min 0.01] [--coverage-max 0.85]
2023
- [--component-min-pixels 100] [--edge-flush-max 0.04]
2029
+ [--component-min-pixels 100]
2024
2030
  ui text-color --box x,y,w,h \u2014 eyedropper a text region: plate color +
2025
2031
  ink candidates as JSON. [--quantize 12] [--ink-dist 60]
2026
2032
  [--min-frac 0.004] [--out <json>] [--crop <png>]
@@ -2029,11 +2035,21 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
2029
2035
  ui seams measure a tiling texture's wrap seam (opposite edges vs
2030
2036
  interior detail) \u2192 ratio + verdict as JSON; nonzero exit on
2031
2037
  a visible seam. [--seam-tolerance 3]
2032
- ui audit mechanical HUD-wiring scan (no --in): extracted-but-never-
2033
- wired sprites, generated-but-unreferenced masks, naive
2034
- JS %-width fills, phones-preload-video heuristic. Warn-only
2035
- (findings may be fine \u2014 check them). [--dir public/assets/
2036
- hud] [--src src] [--strict: exit 1 on findings]`;
2038
+ ui plate [--out <png>] \u2014 trace a frame sprite's REAL interior
2039
+ (art footprint + enclosed cavity) into a silhouette mask
2040
+ PNG. Wire it as the CSS plate's mask-image so rgba/
2041
+ backdrop-filter plates fit arbitrary art exactly \u2014 never
2042
+ a bare rounded rectangle. [--erode 2]
2043
+ ui audit mechanical HUD-wiring scan (no --in): mask\u2194frame pairing
2044
+ mismatches (recorded crops disagree \u2014 PROVABLE, exit 1),
2045
+ white-painted meter troughs (baked sheet-white inside a
2046
+ fill channel \u2014 PROVABLE, exit 1), loosened-check stamps,
2047
+ extracted-but-never-wired sprites, generated-but-
2048
+ unreferenced masks, naive JS %-width fills, phones-
2049
+ preload-video heuristic. Everything but the two provable
2050
+ kinds is warn-only (findings may be fine \u2014 check them).
2051
+ [--dir public/assets/hud] [--src src] [--strict: exit 1 on
2052
+ any findings]`;
2037
2053
  async function runUi(opts) {
2038
2054
  const log = createLogger({ quiet: opts.quiet });
2039
2055
  try {
@@ -2053,12 +2069,15 @@ async function runUi(opts) {
2053
2069
  case "seams":
2054
2070
  await uiSeams(opts, log);
2055
2071
  return;
2072
+ case "plate":
2073
+ await uiPlate(opts, log);
2074
+ return;
2056
2075
  case "audit":
2057
2076
  await uiAudit(opts, log);
2058
2077
  return;
2059
2078
  default:
2060
2079
  log.error(
2061
- opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim, seams, audit.` : "Missing ui tool. Tools: extract, masks, text-color, trim, seams, audit."
2080
+ opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim, seams, plate, audit.` : "Missing ui tool. Tools: extract, masks, text-color, trim, seams, plate, audit."
2062
2081
  );
2063
2082
  log.plain(UI_USAGE);
2064
2083
  process.exitCode = 1;
@@ -2240,6 +2259,33 @@ async function uiExtract(opts, log) {
2240
2259
  band.items.sort((a, b) => a.cx - b.cx);
2241
2260
  sorted.push(...band.items);
2242
2261
  }
2262
+ const nameOf = (i) => i < names.length ? names[i] : `extra-${i - names.length}`;
2263
+ for (let i = 0; i < sorted.length; i++) {
2264
+ const comp = sorted[i];
2265
+ const x0 = Math.max(0, comp.minX - padding);
2266
+ const y0 = Math.max(0, comp.minY - padding);
2267
+ const x1 = Math.min(W, comp.maxX + padding + 1);
2268
+ const y1 = Math.min(H, comp.maxY + padding + 1);
2269
+ for (let j = 0; j < sorted.length; j++) {
2270
+ if (j === i) continue;
2271
+ const other = sorted[j];
2272
+ const oMinX = other.minX + 2;
2273
+ const oMinY = other.minY + 2;
2274
+ const oMaxX = other.maxX - 2;
2275
+ const oMaxY = other.maxY - 2;
2276
+ if (oMinX < x1 && oMaxX >= x0 && oMinY < y1 && oMaxY >= y0) {
2277
+ const w = other.maxX - other.minX + 1;
2278
+ const h = other.maxY - other.minY + 1;
2279
+ fail(
2280
+ `"${nameOf(i)}": the crop (component + ${padding}px padding) catches "${nameOf(j)}" at ${other.minX},${other.minY} (${w}x${h}) \u2014 its edge would ship baked into this sprite. Nothing written. Regenerate the sheet with more margin between elements, or lower --padding.`,
2281
+ {
2282
+ crop: { x: x0, y: y0, w: x1 - x0, h: y1 - y0 },
2283
+ neighbor: { name: nameOf(j), x: other.minX, y: other.minY, w, h }
2284
+ }
2285
+ );
2286
+ }
2287
+ }
2288
+ }
2243
2289
  const extractions = [];
2244
2290
  for (let i = 0; i < sorted.length; i++) {
2245
2291
  const comp = sorted[i];
@@ -2276,6 +2322,10 @@ async function uiExtract(opts, log) {
2276
2322
  }
2277
2323
  }
2278
2324
  }
2325
+ let defringed = false;
2326
+ if (inputMode === "chroma" && opts.defringe !== false) {
2327
+ defringed = defringeAgainstMatte(out) > 0;
2328
+ }
2279
2329
  const outPath = path10.join(outDir, `${name}.png`);
2280
2330
  await writePng(outPath, out);
2281
2331
  const sidecar = {
@@ -2291,7 +2341,8 @@ async function uiExtract(opts, log) {
2291
2341
  },
2292
2342
  cropDims: { w: cropW, h: cropH },
2293
2343
  aspectRatio: +(cropW / cropH).toFixed(3),
2294
- componentPixels: comp.pixels
2344
+ componentPixels: comp.pixels,
2345
+ defringed
2295
2346
  };
2296
2347
  const { name: _n, out: _o, ...sidecarBody } = sidecar;
2297
2348
  await fs10.writeFile(
@@ -2395,7 +2446,7 @@ function assertNotFlush(png, label, maxFrac) {
2395
2446
  const flush2 = Object.entries(edges).filter(([, frac]) => frac > maxFrac);
2396
2447
  if (flush2.length > 0) {
2397
2448
  fail(
2398
- `${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).`,
2449
+ `${label}: the crop box is slicing the element \u2014 opaque content is flush against the crop edge. The sheet needs more margin here: widen the crop box if there is room, otherwise region-edit or regenerate the sheet with more spacing around this element (see genex-ai-hud troubleshooting), then re-run.`,
2399
2450
  {
2400
2451
  flushEdges: flush2.map(([edge, frac]) => ({ edge, opaqueFraction: Number(frac.toFixed(3)) })),
2401
2452
  maxFraction: maxFrac
@@ -2403,6 +2454,70 @@ function assertNotFlush(png, label, maxFrac) {
2403
2454
  );
2404
2455
  }
2405
2456
  }
2457
+ function whiteTroughFraction(clean2, mask) {
2458
+ let fill = 0;
2459
+ let white = 0;
2460
+ for (let i = 0; i < mask.width * mask.height; i += 1) {
2461
+ const idx = i << 2;
2462
+ if (mask.data[idx + 3] === 0) continue;
2463
+ fill += 1;
2464
+ if (clean2.data[idx + 3] > 200 && clean2.data[idx] >= 240 && clean2.data[idx + 1] >= 240 && clean2.data[idx + 2] >= 240) {
2465
+ white += 1;
2466
+ }
2467
+ }
2468
+ return fill === 0 ? 0 : white / fill;
2469
+ }
2470
+ var WHITE_TROUGH_MAX = 0.4;
2471
+ function detectSheetComponents(image, minPixels) {
2472
+ const { width: W, height: H, data } = image;
2473
+ let hasTransparency = false;
2474
+ for (let i = 0; i < W * H; i += 1) {
2475
+ if (data[i * 4 + 3] < 250) {
2476
+ hasTransparency = true;
2477
+ break;
2478
+ }
2479
+ }
2480
+ const isSubjectIdx = (idx) => {
2481
+ const o = idx * 4;
2482
+ if (data[o + 3] <= ALPHA_TRANSPARENT_MAX) return false;
2483
+ if (hasTransparency) return true;
2484
+ return !(data[o] >= 240 && data[o + 1] >= 240 && data[o + 2] >= 240);
2485
+ };
2486
+ const components = [];
2487
+ scanComponents(W, H, isSubjectIdx, (stats) => {
2488
+ if (stats.pixels < minPixels) return;
2489
+ components.push({
2490
+ minX: stats.minX,
2491
+ minY: stats.minY,
2492
+ maxX: stats.maxX,
2493
+ maxY: stats.maxY,
2494
+ pixels: stats.pixels
2495
+ });
2496
+ });
2497
+ return components;
2498
+ }
2499
+ var OVERLAP_TOLERANCE_PX = 2;
2500
+ function assertNoNeighborInCrop(box, components, label) {
2501
+ for (const comp of components) {
2502
+ const cx = (comp.minX + comp.maxX) / 2;
2503
+ const cy = (comp.minY + comp.maxY) / 2;
2504
+ const centerInside = cx >= box.x && cx < box.x + box.w && cy >= box.y && cy < box.y + box.h;
2505
+ if (centerInside) continue;
2506
+ const nMinX = comp.minX + OVERLAP_TOLERANCE_PX;
2507
+ const nMinY = comp.minY + OVERLAP_TOLERANCE_PX;
2508
+ const nMaxX = comp.maxX - OVERLAP_TOLERANCE_PX;
2509
+ const nMaxY = comp.maxY - OVERLAP_TOLERANCE_PX;
2510
+ const intersects = nMinX < box.x + box.w && nMaxX >= box.x && nMinY < box.y + box.h && nMaxY >= box.y;
2511
+ if (intersects) {
2512
+ const w = comp.maxX - comp.minX + 1;
2513
+ const h = comp.maxY - comp.minY + 1;
2514
+ fail(
2515
+ `${label}: the crop catches another sheet element at ${comp.minX},${comp.minY} (${w}x${h}) \u2014 its edge would ship baked into this asset. Regenerate the sheet with more margin between elements, or tighten the crop so it stays clear of the neighbor.`,
2516
+ { crop: box, neighbor: { x: comp.minX, y: comp.minY, w, h } }
2517
+ );
2518
+ }
2519
+ }
2520
+ }
2406
2521
  var isWhitePx = (data, idx) => data[idx + 3] > 16 && data[idx] >= 245 && data[idx + 1] >= 245 && data[idx + 2] >= 245;
2407
2522
  var isSubjectPx = (data, idx) => data[idx + 3] > 16 && !isWhitePx(data, idx);
2408
2523
  function greenAlpha(r, g, b, a) {
@@ -2677,7 +2792,10 @@ function autoDetectPairs(image, opts, log) {
2677
2792
  name: names[i] ?? `meter-${i + 1}`,
2678
2793
  cleanBox: place(best.cell.minX, best.cell.minY, cw, ch),
2679
2794
  annotatedBox: place(ann.minX, ann.minY, aw, ah),
2680
- expectedComponents: null
2795
+ // D6: --auto used to leave the component count ungated (null). --expect N
2796
+ // now gates every auto pair (a continuous bar is --expect 1; a segmented
2797
+ // meter its segment count).
2798
+ expectedComponents: opts.expect ?? null
2681
2799
  });
2682
2800
  });
2683
2801
  log.dim(
@@ -2698,9 +2816,11 @@ async function uiMasks(opts, log) {
2698
2816
  const minComponentPixels = opts.componentMinPixels ?? 100;
2699
2817
  const registrationTolerance = opts.registrationTolerance ?? 0.04;
2700
2818
  const edgeFlushMax = opts.edgeFlushMax ?? 0.04;
2819
+ const loosened = registrationTolerance > 0.04 || edgeFlushMax > 0.04 || minCoverage < 0.01 || maxCoverage > 0.85;
2701
2820
  await fs10.mkdir(outDir, { recursive: true });
2702
2821
  log.plain(c.bold("genex ui masks"));
2703
2822
  log.dim(` ${input} (${image.width}x${image.height}), ${pairs.length} pair(s) \u2192 ${outDir}`);
2823
+ const sheetComponents = detectSheetComponents(image, opts.minPixels ?? 2e3);
2704
2824
  const results = [];
2705
2825
  for (const pair of pairs) {
2706
2826
  if (pair.cleanBox.w !== pair.annotatedBox.w || pair.cleanBox.h !== pair.annotatedBox.h) {
@@ -2708,6 +2828,8 @@ async function uiMasks(opts, log) {
2708
2828
  }
2709
2829
  assertInside(pair.cleanBox, image, `${pair.name} clean`);
2710
2830
  assertInside(pair.annotatedBox, image, `${pair.name} annotated`);
2831
+ assertNoNeighborInCrop(pair.cleanBox, sheetComponents, `${pair.name} clean frame`);
2832
+ assertNoNeighborInCrop(pair.annotatedBox, sheetComponents, `${pair.name} annotated copy`);
2711
2833
  const clean2 = cropPng(image, pair.cleanBox);
2712
2834
  const annotated = cropPng(image, pair.annotatedBox);
2713
2835
  assertNotFlush(clean2, `${pair.name} clean frame`, edgeFlushMax);
@@ -2732,6 +2854,13 @@ async function uiMasks(opts, log) {
2732
2854
  maxCoverage
2733
2855
  });
2734
2856
  }
2857
+ const whiteTrough = whiteTroughFraction(clean2, converted.png);
2858
+ if (whiteTrough >= WHITE_TROUGH_MAX) {
2859
+ fail(
2860
+ `${pair.name}: the fill channel is painted filled in the frame art \u2014 ${Math.round(whiteTrough * 100)}% of the fill region is opaque near-white in the clean frame. The sheet model ignored "REMOVE the fill": as the value drops, that baked white shows through the mask. Regenerate the sheet (region-edit the bar to an empty trough) and re-run \u2014 do not wire this frame.`,
2861
+ { name: pair.name, whiteTroughFraction: Number(whiteTrough.toFixed(3)) }
2862
+ );
2863
+ }
2735
2864
  const components = greenComponents(converted.png, minComponentPixels);
2736
2865
  if (pair.expectedComponents && components.length !== pair.expectedComponents) {
2737
2866
  fail("Green mask component count mismatch.", {
@@ -2778,6 +2907,9 @@ async function uiMasks(opts, log) {
2778
2907
  bboxDelta: Number(registrationDelta.toFixed(4)),
2779
2908
  tolerance: registrationTolerance
2780
2909
  },
2910
+ // D6: true when any gate tolerance was relaxed beyond its default for
2911
+ // this run — preview/publish read this and warn (they never block).
2912
+ loosened,
2781
2913
  mask: {
2782
2914
  path: maskPath,
2783
2915
  size: { w: converted.png.width, h: converted.png.height },
@@ -2806,11 +2938,16 @@ async function uiMasks(opts, log) {
2806
2938
  }
2807
2939
  }
2808
2940
  const indexPath = path10.join(outDir, "annotated-progress.json");
2809
- await fs10.writeFile(indexPath, `${JSON.stringify({ input, pairs: results }, null, 2)}
2941
+ await fs10.writeFile(indexPath, `${JSON.stringify({ input, loosened, pairs: results }, null, 2)}
2810
2942
  `);
2811
2943
  log.plain("");
2812
2944
  log.success(`Wrote ${pairs.length} frame+mask pair(s) \u2192 ${outDir} (index: ${indexPath}).`);
2813
2945
  log.dim(" Eyeball each *-overlay.png \u2014 green must cover exactly the fill region.");
2946
+ if (loosened) {
2947
+ log.warn(
2948
+ "These masks were produced with loosened checks (a tolerance beyond its default). That is stamped in the metadata; eyeball the wired result in-game \u2014 with the meter NOT at full \u2014 before shipping."
2949
+ );
2950
+ }
2814
2951
  }
2815
2952
  var hexColor = (r, g, b) => `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
2816
2953
  var luminance = (r, g, b) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
@@ -2988,6 +3125,179 @@ async function uiSeams(opts, log) {
2988
3125
  `Visible ${r.worstAxis} tiling seam in ${name}: the tile boundary jumps ${r.worstRatio}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable. Don't hide it behind a hand-picked UV repeat \u2014 that trades a seam for a stretched texture (scale belongs in the UVs: worldUV, genex-ai-texture).`
2989
3126
  );
2990
3127
  }
3128
+ function traceInterior(png, erode = 0) {
3129
+ const { width: W, height: H, data } = png;
3130
+ const opaque = (idx) => data[idx * 4 + 3] > ALPHA_TRANSPARENT_MAX;
3131
+ const visited = new Uint8Array(W * H);
3132
+ const queue = new Int32Array(W * H);
3133
+ const inRegion = (idx) => !opaque(idx);
3134
+ for (let x = 0; x < W; x++) {
3135
+ for (const y of [0, H - 1]) {
3136
+ const idx = y * W + x;
3137
+ if (!visited[idx] && inRegion(idx)) floodFill(W, H, visited, queue, x, y, inRegion);
3138
+ }
3139
+ }
3140
+ for (let y = 0; y < H; y++) {
3141
+ for (const x of [0, W - 1]) {
3142
+ const idx = y * W + x;
3143
+ if (!visited[idx] && inRegion(idx)) floodFill(W, H, visited, queue, x, y, inRegion);
3144
+ }
3145
+ }
3146
+ let interior = new Uint8Array(W * H);
3147
+ for (let i = 0; i < W * H; i++) interior[i] = visited[i] ? 0 : 1;
3148
+ for (let pass = 0; pass < erode; pass++) {
3149
+ const next = new Uint8Array(W * H);
3150
+ for (let y = 0; y < H; y++) {
3151
+ for (let x = 0; x < W; x++) {
3152
+ const i = y * W + x;
3153
+ if (!interior[i]) continue;
3154
+ const ok = x > 0 && interior[i - 1] && x < W - 1 && interior[i + 1] && y > 0 && interior[i - W] && y < H - 1 && interior[i + W];
3155
+ if (ok) next[i] = 1;
3156
+ }
3157
+ }
3158
+ interior = next;
3159
+ }
3160
+ return interior;
3161
+ }
3162
+ async function uiPlate(opts, log) {
3163
+ const input = requireOpt(opts.input, "--in", "plate");
3164
+ let outPath = opts.out;
3165
+ if (!outPath) {
3166
+ if (/^https?:\/\//i.test(input)) fail("--in is a URL \u2014 pass --out <png> to say where the plate mask goes.");
3167
+ outPath = input.replace(/\.png$/i, "") + "-plate.png";
3168
+ }
3169
+ const erode = opts.erode ?? 2;
3170
+ const png = await loadPng(input);
3171
+ const { width: W, height: H } = png;
3172
+ const interior = traceInterior(png, erode);
3173
+ let count = 0;
3174
+ const out = new PNG2({ width: W, height: H });
3175
+ for (let i = 0; i < W * H; i++) {
3176
+ const o = i * 4;
3177
+ out.data[o] = 255;
3178
+ out.data[o + 1] = 255;
3179
+ out.data[o + 2] = 255;
3180
+ out.data[o + 3] = interior[i] ? 255 : 0;
3181
+ if (interior[i]) count++;
3182
+ }
3183
+ if (count === 0) {
3184
+ fail("No interior found \u2014 the image is fully transparent (or erode ate everything). Check --in / lower --erode.");
3185
+ }
3186
+ await writePng(outPath, out);
3187
+ const name = path10.basename(outPath);
3188
+ log.plain(c.bold("genex ui plate"));
3189
+ log.success(`${outPath} ${W}x${H}, interior ${(count / (W * H) * 100).toFixed(1)}% (erode ${erode}px)`);
3190
+ log.dim(" Wire it as the plate's silhouette (same box as the frame <img>, plate UNDER the art):");
3191
+ log.dim(` .plate { position:absolute; inset:0; background:rgba(14,10,22,.55); backdrop-filter:blur(5px);`);
3192
+ log.dim(` -webkit-mask-image:url(${name}); mask-image:url(${name});`);
3193
+ log.dim(` -webkit-mask-size:100% 100%; mask-size:100% 100%; mask-repeat:no-repeat; }`);
3194
+ process.stdout.write(
3195
+ `${JSON.stringify({ out: outPath, width: W, height: H, interiorFraction: Number((count / (W * H)).toFixed(4)), erode })}
3196
+ `
3197
+ );
3198
+ }
3199
+ var DEFRINGE_BAND_PX = 2;
3200
+ function defringeAgainstMatte(png, matte = { r: 255, g: 255, b: 255 }) {
3201
+ const { width: W, height: H, data } = png;
3202
+ const opaque = (i) => data[i * 4 + 3] > ALPHA_TRANSPARENT_MAX;
3203
+ const dist = new Uint8Array(W * H);
3204
+ let ring = [];
3205
+ for (let y = 0; y < H; y++) {
3206
+ for (let x = 0; x < W; x++) {
3207
+ const i = y * W + x;
3208
+ if (!opaque(i)) continue;
3209
+ const nearTransparent = x > 0 && !opaque(i - 1) || x < W - 1 && !opaque(i + 1) || y > 0 && !opaque(i - W) || y < H - 1 && !opaque(i + W) || x === 0 || x === W - 1 || y === 0 || y === H - 1;
3210
+ if (nearTransparent) {
3211
+ dist[i] = 1;
3212
+ ring.push(i);
3213
+ }
3214
+ }
3215
+ }
3216
+ for (let d = 2; d <= DEFRINGE_BAND_PX; d++) {
3217
+ const next = [];
3218
+ for (const i of ring) {
3219
+ const x = i % W;
3220
+ const y = i / W | 0;
3221
+ for (const n of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, y > 0 ? i - W : -1, y < H - 1 ? i + W : -1]) {
3222
+ if (n >= 0 && opaque(n) && dist[n] === 0) {
3223
+ dist[n] = d;
3224
+ next.push(n);
3225
+ }
3226
+ }
3227
+ }
3228
+ ring = next;
3229
+ }
3230
+ const CH = [0, 1, 2];
3231
+ const matteCh = [matte.r, matte.g, matte.b];
3232
+ let rewritten = 0;
3233
+ for (let y = 0; y < H; y++) {
3234
+ for (let x = 0; x < W; x++) {
3235
+ const i = y * W + x;
3236
+ if (dist[i] === 0) continue;
3237
+ const cIn = [0, 0, 0];
3238
+ let n = 0;
3239
+ outer: for (let r = 1; r <= 6; r++) {
3240
+ for (let dy = -r; dy <= r; dy++) {
3241
+ for (let dx = -r; dx <= r; dx++) {
3242
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
3243
+ const nx = x + dx;
3244
+ const ny = y + dy;
3245
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
3246
+ const ni = ny * W + nx;
3247
+ if (!opaque(ni) || dist[ni] !== 0) continue;
3248
+ cIn[0] += data[ni * 4];
3249
+ cIn[1] += data[ni * 4 + 1];
3250
+ cIn[2] += data[ni * 4 + 2];
3251
+ n++;
3252
+ if (n >= 24) break outer;
3253
+ }
3254
+ }
3255
+ }
3256
+ if (n === 0) continue;
3257
+ for (const ch of CH) cIn[ch] = cIn[ch] / n;
3258
+ let alphaSum = 0;
3259
+ let alphaN = 0;
3260
+ for (const ch of CH) {
3261
+ const contrast = matteCh[ch] - cIn[ch];
3262
+ if (Math.abs(contrast) < 30) continue;
3263
+ const a = (matteCh[ch] - data[i * 4 + ch]) / contrast;
3264
+ alphaSum += Math.min(1, Math.max(0, a));
3265
+ alphaN++;
3266
+ }
3267
+ if (alphaN === 0) continue;
3268
+ const alpha = alphaSum / alphaN;
3269
+ if (alpha > 0.98) continue;
3270
+ data[i * 4] = Math.round(cIn[0]);
3271
+ data[i * 4 + 1] = Math.round(cIn[1]);
3272
+ data[i * 4 + 2] = Math.round(cIn[2]);
3273
+ data[i * 4 + 3] = Math.round(alpha * 255);
3274
+ rewritten++;
3275
+ }
3276
+ }
3277
+ return rewritten;
3278
+ }
3279
+ var HARD_AUDIT_KINDS = /* @__PURE__ */ new Set([
3280
+ "mask-frame-mismatch",
3281
+ "white-trough"
3282
+ ]);
3283
+ var TROUGH_OPAQUE_MIN_ALPHA = 200;
3284
+ var TROUGH_WHITE_MIN = 235;
3285
+ var TROUGH_WHITE_MAX_FRACTION = 0.25;
3286
+ function whiteTroughAuditFraction(frame, mask) {
3287
+ const interior = traceInterior(frame, 0);
3288
+ let channel = 0;
3289
+ let white = 0;
3290
+ for (let i = 0; i < mask.width * mask.height; i++) {
3291
+ if (mask.data[i * 4 + 3] === 0) continue;
3292
+ if (!interior[i]) continue;
3293
+ channel++;
3294
+ const o = i * 4;
3295
+ if (frame.data[o + 3] > TROUGH_OPAQUE_MIN_ALPHA && frame.data[o] >= TROUGH_WHITE_MIN && frame.data[o + 1] >= TROUGH_WHITE_MIN && frame.data[o + 2] >= TROUGH_WHITE_MIN) {
3296
+ white++;
3297
+ }
3298
+ }
3299
+ return channel === 0 ? 0 : white / channel;
3300
+ }
2991
3301
  var AUDIT_SRC_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".css", ".html"]);
2992
3302
  async function walkFiles(dir) {
2993
3303
  const out = [];
@@ -3006,11 +3316,29 @@ async function walkFiles(dir) {
3006
3316
  return out;
3007
3317
  }
3008
3318
  async function collectUiAuditFindings(assetDir, srcDir, cwd = process.cwd()) {
3319
+ const viewportFindings = [];
3320
+ try {
3321
+ const indexHtml = await fs10.readFile(path10.join(cwd, "index.html"), "utf8");
3322
+ if (!/<meta[^>]+name=["']viewport["']/i.test(indexHtml)) {
3323
+ viewportFindings.push({
3324
+ kind: "viewport-meta",
3325
+ message: `index.html has no viewport meta \u2014 phones will render a zoomed-out desktop canvas. Add: <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
3326
+ });
3327
+ } else if (!/viewport-fit\s*=/i.test(indexHtml)) {
3328
+ viewportFindings.push({
3329
+ kind: "viewport-meta",
3330
+ message: `index.html's viewport meta lacks viewport-fit=cover \u2014 env(safe-area-inset-*) (touch-kit widget offsets) is 0 without it. Append ", viewport-fit=cover" to the meta's content.`
3331
+ });
3332
+ }
3333
+ } catch {
3334
+ }
3009
3335
  const absAssets = path10.resolve(cwd, assetDir);
3010
3336
  try {
3011
- if (!(await fs10.stat(absAssets)).isDirectory()) return null;
3337
+ if (!(await fs10.stat(absAssets)).isDirectory()) {
3338
+ return viewportFindings.length > 0 ? viewportFindings : null;
3339
+ }
3012
3340
  } catch {
3013
- return null;
3341
+ return viewportFindings.length > 0 ? viewportFindings : null;
3014
3342
  }
3015
3343
  const srcFiles = (await walkFiles(path10.resolve(cwd, srcDir))).filter(
3016
3344
  (p) => AUDIT_SRC_EXTS.has(path10.extname(p).toLowerCase())
@@ -3030,8 +3358,94 @@ async function collectUiAuditFindings(assetDir, srcDir, cwd = process.cwd()) {
3030
3358
  }
3031
3359
  }
3032
3360
  const referenced = (basename) => sources.some((s) => s.text.includes(basename));
3033
- const findings = [];
3361
+ const findings = [...viewportFindings];
3034
3362
  const assetFiles = await walkFiles(absAssets);
3363
+ const metaByName = /* @__PURE__ */ new Map();
3364
+ const bboxByPng = /* @__PURE__ */ new Map();
3365
+ for (const p of assetFiles) {
3366
+ const base = path10.basename(p);
3367
+ const metaMatch = /^(.+)\.annotated-progress\.json$/.exec(base);
3368
+ if (metaMatch) {
3369
+ try {
3370
+ const meta = JSON.parse(await fs10.readFile(p, "utf8"));
3371
+ metaByName.set(metaMatch[1], {
3372
+ cleanCrop: meta.clean?.crop ?? null,
3373
+ loosened: meta.loosened === true
3374
+ });
3375
+ } catch {
3376
+ }
3377
+ continue;
3378
+ }
3379
+ if (base.endsWith(".bbox.json")) {
3380
+ try {
3381
+ const sidecar = JSON.parse(await fs10.readFile(p, "utf8"));
3382
+ if (sidecar.sheetBBox) bboxByPng.set(base.replace(/\.bbox\.json$/, ".png"), sidecar.sheetBBox);
3383
+ } catch {
3384
+ }
3385
+ }
3386
+ }
3387
+ for (const [name, meta] of metaByName) {
3388
+ if (!meta.cleanCrop || !referenced(`${name}-mask.png`)) continue;
3389
+ for (const p of assetFiles) {
3390
+ const base = path10.basename(p);
3391
+ if (!base.toLowerCase().endsWith(".png")) continue;
3392
+ if (base !== `${name}.png` && !base.startsWith(`${name}-`)) continue;
3393
+ if (/-mask\.png$|-frame\.png$|-overlay\.png$|-annotated-source\.png$/.test(base)) continue;
3394
+ if (!referenced(base)) continue;
3395
+ const cut = bboxByPng.get(base);
3396
+ if (!cut) continue;
3397
+ const crop = meta.cleanCrop;
3398
+ const delta = Math.max(
3399
+ Math.abs(cut.x - crop.x),
3400
+ Math.abs(cut.y - crop.y),
3401
+ Math.abs(cut.w - crop.w),
3402
+ Math.abs(cut.h - crop.h)
3403
+ );
3404
+ if (delta > 4) {
3405
+ findings.push({
3406
+ kind: "mask-frame-mismatch",
3407
+ message: `${name}: ${name}-mask.png was derived against the sheet crop ${crop.x},${crop.y} (${crop.w}x${crop.h}), but the shipped ${base} was cut at ${cut.x},${cut.y} (${cut.w}x${cut.h}) \u2014 stretched over one box, the fill floats off the frame. Re-derive the mask against the shipped frame (genex ui masks --in <sheet> \u2026) or ship ${name}-frame.png (the crop this mask matches).`
3408
+ });
3409
+ }
3410
+ }
3411
+ }
3412
+ const pngByBase = /* @__PURE__ */ new Map();
3413
+ for (const p of assetFiles) {
3414
+ const base = path10.basename(p);
3415
+ if (base.toLowerCase().endsWith(".png")) pngByBase.set(base, p);
3416
+ }
3417
+ for (const [maskBase, maskPath] of pngByBase) {
3418
+ const m = /^(.+)-mask\.png$/.exec(maskBase);
3419
+ if (!m) continue;
3420
+ const name = m[1];
3421
+ for (const frameBase of [`${name}-frame.png`, `${name}.png`]) {
3422
+ const framePath = pngByBase.get(frameBase);
3423
+ if (!framePath) continue;
3424
+ let frame;
3425
+ let mask;
3426
+ try {
3427
+ frame = PNG2.sync.read(await fs10.readFile(framePath));
3428
+ mask = PNG2.sync.read(await fs10.readFile(maskPath));
3429
+ } catch {
3430
+ continue;
3431
+ }
3432
+ if (frame.width !== mask.width || frame.height !== mask.height) continue;
3433
+ const fraction = whiteTroughAuditFraction(frame, mask);
3434
+ if (fraction > TROUGH_WHITE_MAX_FRACTION) {
3435
+ findings.push({
3436
+ kind: "white-trough",
3437
+ message: `${frameBase}: the fill channel contains baked sheet-white (${Math.round(fraction * 100)}% of the channel \u2014 the model drew the trough white and the cleaner can't reach enclosed regions). Repair: regenerate the sheet with the empty-dark-track rule, or region-repaint the trough (genex image "<in-style empty track>" --edit <sheet-url> [--inpaint <channel-mask>]), then re-clean and re-extract. Do not ship this frame.`
3438
+ });
3439
+ }
3440
+ }
3441
+ }
3442
+ const loosenedNames = [...metaByName.entries()].filter(([, meta]) => meta.loosened).map(([name]) => name);
3443
+ if (loosenedNames.length > 0) {
3444
+ findings.push({
3445
+ kind: "loosened-masks",
3446
+ message: `HUD masks for ${loosenedNames.join(", ")} were produced with loosened checks \u2014 eyeball each wired meter in-game, NOT at full value, before shipping.`
3447
+ });
3448
+ }
3035
3449
  const maskReported = /* @__PURE__ */ new Set();
3036
3450
  for (const p of assetFiles) {
3037
3451
  const m = /^(.+)\.annotated-progress\.json$/.exec(path10.basename(p));
@@ -3095,11 +3509,14 @@ async function uiAudit(opts, log) {
3095
3509
  log.success("HUD wiring audit clean \u2014 every asset referenced, no naive %-fill patterns.");
3096
3510
  return;
3097
3511
  }
3098
- log.warn(
3099
- `${findings.length} finding(s). These are heuristics \u2014 check each one; some may be fine:`
3100
- );
3101
- for (const finding of findings) log.warn(` [${finding.kind}] ${finding.message}`);
3102
- if (opts.strict) process.exitCode = 1;
3512
+ const hard = findings.filter((finding) => HARD_AUDIT_KINDS.has(finding.kind));
3513
+ const soft = findings.filter((finding) => !HARD_AUDIT_KINDS.has(finding.kind));
3514
+ for (const finding of hard) log.error(`[${finding.kind}] ${finding.message}`);
3515
+ if (soft.length > 0) {
3516
+ log.warn(`${soft.length} finding(s). These are heuristics \u2014 check each one; some may be fine:`);
3517
+ for (const finding of soft) log.warn(` [${finding.kind}] ${finding.message}`);
3518
+ }
3519
+ if (opts.strict || hard.length > 0) process.exitCode = 1;
3103
3520
  }
3104
3521
 
3105
3522
  // src/lib/deploy.ts
@@ -3943,7 +4360,7 @@ async function detectSurfaceScan(cwd = process.cwd()) {
3943
4360
  }
3944
4361
  return found;
3945
4362
  }
3946
- var WIRED_BY_URL = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "video"]);
4363
+ var WIRED_BY_URL = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "music", "texture", "video"]);
3947
4364
  var UNPICKED_AFTER_MS = 15 * 60 * 1e3;
3948
4365
  async function detectGenerationAudit(cwd = process.cwd()) {
3949
4366
  const ledger = await readLedger(cwd);
@@ -4267,7 +4684,86 @@ async function runPreview(opts) {
4267
4684
  }
4268
4685
 
4269
4686
  // src/commands/generate.ts
4687
+ import fs14 from "fs/promises";
4688
+ import path14 from "path";
4689
+ import { PNG as PNG4 } from "pngjs";
4690
+
4691
+ // src/lib/glass.ts
4270
4692
  import { PNG as PNG3 } from "pngjs";
4693
+ var GLASS_PROMPT_SUFFIX = " Rendered on a perfectly flat, uniform, solid pure magenta background, hex #FF00FF, like a studio chroma-key screen: absolutely no shading, no gradient, no vignette, no glow, no shadows on the background. Subject centered, sharp edge silhouette.";
4694
+ var GLASS_MIN_FLATNESS = 0.99;
4695
+ var GLASS_MIN_KEY_STRENGTH = 180;
4696
+ var FLATNESS_DEV_MAX = 12;
4697
+ var clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
4698
+ function median(values) {
4699
+ const s = values.slice().sort((a, b) => a - b);
4700
+ return s[Math.floor(s.length / 2)] ?? 0;
4701
+ }
4702
+ function solveMagentaGlass(source) {
4703
+ const { width: W, height: H, data } = source;
4704
+ const ringW = Math.max(2, Math.round(Math.min(W, H) * 0.04));
4705
+ const rs = [];
4706
+ const gs = [];
4707
+ const bs = [];
4708
+ const ringIdx = [];
4709
+ for (let y = 0; y < H; y++) {
4710
+ for (let x = 0; x < W; x++) {
4711
+ const inRing = x < ringW || y < ringW || x >= W - ringW || y >= H - ringW;
4712
+ if (!inRing) continue;
4713
+ const i = (y * W + x) * 4;
4714
+ ringIdx.push(i);
4715
+ rs.push(data[i]);
4716
+ gs.push(data[i + 1]);
4717
+ bs.push(data[i + 2]);
4718
+ }
4719
+ }
4720
+ const K = { r: median(rs), g: median(gs), b: median(bs) };
4721
+ const keyHex = `#${[K.r, K.g, K.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
4722
+ const keyStrength = Math.min(K.r, K.b) - K.g;
4723
+ let flat = 0;
4724
+ for (const i of ringIdx) {
4725
+ const dev = Math.max(
4726
+ Math.abs(data[i] - K.r),
4727
+ Math.abs(data[i + 1] - K.g),
4728
+ Math.abs(data[i + 2] - K.b)
4729
+ );
4730
+ if (dev < FLATNESS_DEV_MAX) flat++;
4731
+ }
4732
+ const flatness = ringIdx.length === 0 ? 0 : flat / ringIdx.length;
4733
+ const base = { keyColor: K, keyHex, keyStrength, flatness };
4734
+ if (flatness < GLASS_MIN_FLATNESS || keyStrength < GLASS_MIN_KEY_STRENGTH) {
4735
+ return {
4736
+ ...base,
4737
+ png: source,
4738
+ ok: false,
4739
+ rejected: `backdrop not keyable (flatness ${flatness.toFixed(3)}, key strength ${keyStrength} \u2014 want \u2265${GLASS_MIN_FLATNESS}/${GLASS_MIN_KEY_STRENGTH}): the model shaded the magenta screen instead of painting it flat. Regenerate (same prompt \u2014 this is a rare roll), or steer harder: keep the --glass suffix intact and avoid magenta/pink art in the subject.`
4740
+ };
4741
+ }
4742
+ const out = new PNG3({ width: W, height: H });
4743
+ const mKey = Math.max(1, keyStrength);
4744
+ for (let p = 0; p < W * H; p++) {
4745
+ const i = p * 4;
4746
+ const R = data[i];
4747
+ const G = data[i + 1];
4748
+ const B = data[i + 2];
4749
+ const m = Math.max(0, Math.min(R, B) - G);
4750
+ let a = 1 - m / mKey;
4751
+ if (a < 0) a = 0;
4752
+ if (a > 1) a = 1;
4753
+ if (a <= 0.02) {
4754
+ out.data[i] = 0;
4755
+ out.data[i + 1] = 0;
4756
+ out.data[i + 2] = 0;
4757
+ out.data[i + 3] = 0;
4758
+ continue;
4759
+ }
4760
+ out.data[i] = clamp255((R - (1 - a) * K.r) / a);
4761
+ out.data[i + 1] = clamp255((G - (1 - a) * K.g) / a);
4762
+ out.data[i + 2] = clamp255((B - (1 - a) * K.b) / a);
4763
+ out.data[i + 3] = Math.round(a * 255);
4764
+ }
4765
+ return { ...base, png: out, ok: true };
4766
+ }
4271
4767
 
4272
4768
  // src/lib/open.ts
4273
4769
  import { spawn as spawn4 } from "child_process";
@@ -4348,10 +4844,22 @@ async function* readSSE(body) {
4348
4844
 
4349
4845
  // src/commands/generate.ts
4350
4846
  var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4847
+ var SKYBOX_ENVIRONMENT_SUFFIX = ". Pure environment: sky, clouds, atmosphere, distant horizon haze. No buildings, no structures, no trees, no objects, no ground detail.";
4848
+ var SKYBOX_PROMPT_MAX = 2e3;
4849
+ function applySkyboxGuard(prompt, raw) {
4850
+ if (raw) return { prompt, guarded: false };
4851
+ const guarded = `${prompt}${SKYBOX_ENVIRONMENT_SUFFIX}`;
4852
+ if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false };
4853
+ return { prompt: guarded, guarded: true };
4854
+ }
4351
4855
  function buildGenOptions(kind, opts) {
4352
4856
  const options = { ...opts.generationOptions };
4353
4857
  if (kind === "texture" && opts.terrain) options.terrain = true;
4354
- if (kind === "sfx" && opts.duration) options.durationSeconds = opts.duration;
4858
+ if ((kind === "sfx" || kind === "music") && opts.duration) options.durationSeconds = opts.duration;
4859
+ if (kind === "voice") {
4860
+ if (opts.voice) options.voice = opts.voice;
4861
+ if (opts.voiceId) options.voiceId = opts.voiceId;
4862
+ }
4355
4863
  if (kind === "image") {
4356
4864
  if (opts.transparent) options.transparent = true;
4357
4865
  if (opts.aspect) options.aspect = opts.aspect;
@@ -4370,6 +4878,11 @@ function buildGenOptions(kind, opts) {
4370
4878
  options.op = "remove-bg";
4371
4879
  options.bgMode = opts.bgMode ?? "sheet";
4372
4880
  }
4881
+ if (opts.upscaleUrl) {
4882
+ options.sourceUrl = opts.upscaleUrl;
4883
+ options.op = "upscale";
4884
+ }
4885
+ if (opts.inpaintUrl) options.maskUrl = opts.inpaintUrl;
4373
4886
  if (opts.removeBackground) options.removeBackground = true;
4374
4887
  if (opts.bgMode) options.bgMode = opts.bgMode;
4375
4888
  }
@@ -4385,12 +4898,45 @@ function buildGenOptions(kind, opts) {
4385
4898
  }
4386
4899
  async function runGenerate(kind, opts) {
4387
4900
  const log = createLogger({ quiet: opts.quiet || opts.json });
4388
- const prompt = opts.prompt?.trim();
4389
- if (!prompt) {
4901
+ const typedPrompt = opts.prompt?.trim();
4902
+ if (!typedPrompt) {
4390
4903
  log.error(`Missing prompt. Usage: ${c.cyan(`genex ${kind} "<prompt>"`)}`);
4391
4904
  process.exitCode = 1;
4392
4905
  return;
4393
4906
  }
4907
+ const skyboxGuard = kind === "skybox" ? applySkyboxGuard(typedPrompt, opts.raw) : void 0;
4908
+ let prompt = skyboxGuard?.prompt ?? typedPrompt;
4909
+ if (kind === "image" && opts.glass) {
4910
+ const conflict = opts.transparent ? "--transparent" : opts.removeBackground ? "--remove-bg" : opts.editUrl ? "--edit" : opts.cleanUrl ? "--clean" : opts.upscaleUrl ? "--upscale" : opts.noWait ? "--no-wait" : void 0;
4911
+ if (conflict) {
4912
+ log.error(
4913
+ conflict === "--no-wait" ? "--glass runs a local key-solve on the finished image \u2014 it has to wait. Drop --no-wait." : `--glass generates a NEW panel on a magenta key screen \u2014 it cannot combine with ${conflict}. The solved RGBA ships real translucency; no removal pass applies.`
4914
+ );
4915
+ process.exitCode = 1;
4916
+ return;
4917
+ }
4918
+ prompt = `${prompt}${GLASS_PROMPT_SUFFIX}`;
4919
+ }
4920
+ if (kind === "image" && opts.inpaintUrl && !/^https?:\/\//i.test(opts.inpaintUrl)) {
4921
+ if (!opts.editUrl) {
4922
+ log.error("--inpaint targets an edit \u2014 pass --edit <asset-url> alongside it.");
4923
+ process.exitCode = 1;
4924
+ return;
4925
+ }
4926
+ try {
4927
+ const bytes = await fs14.readFile(opts.inpaintUrl);
4928
+ opts = { ...opts, inpaintUrl: `data:image/png;base64,${bytes.toString("base64")}` };
4929
+ } catch {
4930
+ log.error(`Couldn't read the --inpaint mask at ${opts.inpaintUrl}.`);
4931
+ process.exitCode = 1;
4932
+ return;
4933
+ }
4934
+ }
4935
+ if (kind === "voice" && prompt.length > 1e3) {
4936
+ log.warn(
4937
+ `Voice text is ${prompt.length} chars \u2014 the server clamps at 1000 (billing follows the clamp). Split longer copy into separate lines.`
4938
+ );
4939
+ }
4394
4940
  const token = opts.token ?? await readUserToken(opts.envPath);
4395
4941
  if (!token) {
4396
4942
  log.error("Not authorized. Run `genex init` first to sign in.");
@@ -4402,6 +4948,7 @@ async function runGenerate(kind, opts) {
4402
4948
  const options = buildGenOptions(kind, opts);
4403
4949
  log.plain(c.bold(`genex ${kind}`));
4404
4950
  log.dim(` ${prompt}`);
4951
+ if (skyboxGuard?.guarded) log.dim(" \u21B3 environment-only guard applied (--raw to disable)");
4405
4952
  log.plain("");
4406
4953
  let id;
4407
4954
  try {
@@ -4454,9 +5001,104 @@ async function runGenerate(kind, opts) {
4454
5001
  }
4455
5002
  return id;
4456
5003
  }
5004
+ if (kind === "image" && opts.glass) {
5005
+ if (!opts.json) log.step("Generating\u2026 (this can take up to a minute)");
5006
+ const view = await waitForTerminalView(
5007
+ apiUrl,
5008
+ token,
5009
+ id,
5010
+ kind,
5011
+ opts.json ? () => {
5012
+ } : (p) => log.dim(` ${p}%`)
5013
+ );
5014
+ if (!view) {
5015
+ if (opts.json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
5016
+ else log.error("Timed out waiting for the generation.");
5017
+ process.exitCode = 1;
5018
+ return id;
5019
+ }
5020
+ await reportGlassTerminal(view, opts.outDir ?? ".", log, opts.json ?? false);
5021
+ return id;
5022
+ }
4457
5023
  await awaitAndReport(apiUrl, token, id, kind, log, opts.open, opts.json);
4458
5024
  return id;
4459
5025
  }
5026
+ async function reportGlassTerminal(view, outDir, log, json) {
5027
+ const files = (view.files ?? []).filter((f) => /^image-(main|alt-\d+)$/.test(f.role));
5028
+ if (view.status !== "completed" || files.length === 0) {
5029
+ await reportTerminal("image", view, log, false, json);
5030
+ return;
5031
+ }
5032
+ await recordTerminal(view.id, "completed", files.map((f) => f.url));
5033
+ await fs14.mkdir(outDir, { recursive: true });
5034
+ const solved = [];
5035
+ for (let i = 0; i < files.length; i++) {
5036
+ const f = files[i];
5037
+ let png;
5038
+ try {
5039
+ const res = await fetch(f.url);
5040
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
5041
+ png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
5042
+ } catch (err) {
5043
+ solved.push({
5044
+ path: null,
5045
+ url: f.url,
5046
+ keyHex: "",
5047
+ keyStrength: 0,
5048
+ flatness: 0,
5049
+ rejected: `couldn't fetch the candidate (${String(err)}) \u2014 re-run later; the URL stays live.`
5050
+ });
5051
+ continue;
5052
+ }
5053
+ const r = solveMagentaGlass(png);
5054
+ if (!r.ok) {
5055
+ solved.push({
5056
+ path: null,
5057
+ url: f.url,
5058
+ keyHex: r.keyHex,
5059
+ keyStrength: r.keyStrength,
5060
+ flatness: r.flatness,
5061
+ rejected: r.rejected
5062
+ });
5063
+ continue;
5064
+ }
5065
+ const outPath = path14.join(outDir, `glass-${i + 1}.png`);
5066
+ await fs14.writeFile(outPath, PNG4.sync.write(r.png));
5067
+ solved.push({
5068
+ path: outPath,
5069
+ url: f.url,
5070
+ keyHex: r.keyHex,
5071
+ keyStrength: r.keyStrength,
5072
+ flatness: r.flatness
5073
+ });
5074
+ }
5075
+ const shipped = solved.filter((s) => s.path !== null);
5076
+ if (json) {
5077
+ writeJson({ kind: "image", id: view.id, status: view.status, glass: solved });
5078
+ if (shipped.length === 0) process.exitCode = 1;
5079
+ return;
5080
+ }
5081
+ log.plain("");
5082
+ if (shipped.length > 0) {
5083
+ log.success(`Solved ${shipped.length} glass panel(s) \u2014 real translucency baked in the alpha:`);
5084
+ }
5085
+ for (const s of solved) {
5086
+ if (s.path) {
5087
+ log.plain(` ${s.path} ${c.dim(`key ${s.keyHex}, strength ${s.keyStrength}, flatness ${s.flatness.toFixed(3)}`)}`);
5088
+ } else {
5089
+ log.error(`Candidate rejected: ${s.rejected}`);
5090
+ log.dim(` raw candidate stays at ${s.url}`);
5091
+ }
5092
+ }
5093
+ if (shipped.length > 0) {
5094
+ log.plain("");
5095
+ log.dim(
5096
+ " Wire the PNG like any sprite (TextureLoader / <img>): its alpha IS the glass \u2014 no CSS opacity, no plate underneath. See genex-ai-image (--glass) and genex-ai-menu."
5097
+ );
5098
+ } else {
5099
+ process.exitCode = 1;
5100
+ }
5101
+ }
4460
5102
  async function awaitAndReport(apiUrl, token, id, kind, log, open = false, json = false) {
4461
5103
  if (!json) log.step("Generating\u2026 (this can take up to a minute)");
4462
5104
  const view = await waitForTerminalView(
@@ -4640,7 +5282,7 @@ async function reportTextureSeam(url, log) {
4640
5282
  try {
4641
5283
  const res = await fetch(url);
4642
5284
  if (!res.ok) return;
4643
- const png = PNG3.sync.read(Buffer.from(await res.arrayBuffer()));
5285
+ const png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
4644
5286
  if (png.width < 4 || png.height < 4) return;
4645
5287
  const r = measureSeam(png);
4646
5288
  if (r.seamless) {
@@ -4664,6 +5306,8 @@ function printHint(kind, view, files, log) {
4664
5306
  model: `Standard GLB \u2014 load with GLTFLoader straight from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
4665
5307
  skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
4666
5308
  sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. url = "${url}"`,
5309
+ music: `Load with AudioLoader into a non-positional THREE.Audio: setLoop(true), setVolume(0.30), start on the first user gesture \u2014 and the settings screen MUST have Music + SFX volume sliders \u2014 see genex-ai-music. url = "${url}"`,
5310
+ voice: `Load with AudioLoader into a THREE.Audio (or PositionalAudio at the speaker); voice lines are content \u2014 keep them short, SUBTITLED, and skippable \u2014 see genex-ai-voice. url = "${url}"`,
4667
5311
  texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`,
4668
5312
  image: `Load with TextureLoader (set colorSpace = SRGBColorSpace) onto any mesh/plane/sprite \u2014 see genex-ai-image. url = "${url}"`,
4669
5313
  video: `Wire an HTMLVideoElement (crossOrigin="anonymous", muted, loop, playsInline) into a THREE.VideoTexture \u2014 see genex-ai-video. url = "${url}"`,
@@ -4998,8 +5642,8 @@ async function toRow(e, v, cwd) {
4998
5642
  }
4999
5643
 
5000
5644
  // src/commands/controller.ts
5001
- import fs15 from "fs/promises";
5002
- import path15 from "path";
5645
+ import fs16 from "fs/promises";
5646
+ import path16 from "path";
5003
5647
 
5004
5648
  // ../../packages/meshy-animation-catalog/src/index.ts
5005
5649
  import { createHash } from "crypto";
@@ -14129,9 +14773,9 @@ function searchMeshyAnimations(query, options = {}) {
14129
14773
  }
14130
14774
 
14131
14775
  // src/lib/anims.ts
14132
- import fs14 from "fs/promises";
14133
- import path14 from "path";
14134
- var ANIMS_DEST = path14.join("public", "assets", "anims");
14776
+ import fs15 from "fs/promises";
14777
+ import path15 from "path";
14778
+ var ANIMS_DEST = path15.join("public", "assets", "anims");
14135
14779
  var HIDDEN_TAG = "reference";
14136
14780
  async function runAnims(opts) {
14137
14781
  const log = createLogger({ quiet: opts.quiet });
@@ -14147,7 +14791,7 @@ async function runAnims(opts) {
14147
14791
  printCatalog(log, manifest, selectors);
14148
14792
  return;
14149
14793
  }
14150
- const controllerMarker = path14.join(root, "src", "controllers", "character");
14794
+ const controllerMarker = path15.join(root, "src", "controllers", "character");
14151
14795
  if (!await exists2(controllerMarker)) {
14152
14796
  log.error(
14153
14797
  `No character controller in this game (missing ${c.cyan("src/controllers/character/")}).`
@@ -14156,11 +14800,11 @@ async function runAnims(opts) {
14156
14800
  process.exitCode = 1;
14157
14801
  return;
14158
14802
  }
14159
- const destDir = path14.join(root, ANIMS_DEST);
14160
- const gameManifestPath = path14.join(destDir, "manifest.json");
14803
+ const destDir = path15.join(root, ANIMS_DEST);
14804
+ const gameManifestPath = path15.join(destDir, "manifest.json");
14161
14805
  if (opts.reset) {
14162
- await fs14.rm(destDir, { recursive: true, force: true });
14163
- log.step(`Cleared ${c.cyan(ANIMS_DEST + path14.sep)} (--reset)`);
14806
+ await fs15.rm(destDir, { recursive: true, force: true });
14807
+ log.step(`Cleared ${c.cyan(ANIMS_DEST + path15.sep)} (--reset)`);
14164
14808
  }
14165
14809
  if (selectors.length === 0) {
14166
14810
  const installed = await readGameManifest(gameManifestPath);
@@ -14198,35 +14842,35 @@ async function runAnims(opts) {
14198
14842
  }
14199
14843
  }
14200
14844
  const wanted = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
14201
- const cacheDir = path14.join(
14845
+ const cacheDir = path15.join(
14202
14846
  opts.cacheDir ?? getAnimsCacheDir(),
14203
14847
  `${manifest.library}-v${manifest.version}`
14204
14848
  );
14205
- await fs14.mkdir(cacheDir, { recursive: true });
14206
- await fs14.mkdir(destDir, { recursive: true });
14849
+ await fs15.mkdir(cacheDir, { recursive: true });
14850
+ await fs15.mkdir(destDir, { recursive: true });
14207
14851
  const base = getAnimsBase(opts.animsBase);
14208
14852
  let installedCount = 0;
14209
14853
  let presentCount = 0;
14210
14854
  let addedBytes = 0;
14211
14855
  const failures = [];
14212
14856
  for (const entry of wanted) {
14213
- const dest = path14.join(destDir, entry.file);
14857
+ const dest = path15.join(destDir, entry.file);
14214
14858
  if (await hasSize(dest, entry.bytes)) {
14215
14859
  presentCount++;
14216
14860
  continue;
14217
14861
  }
14218
14862
  try {
14219
- const cached = path14.join(cacheDir, entry.file);
14863
+ const cached = path15.join(cacheDir, entry.file);
14220
14864
  if (!await hasSize(cached, entry.bytes)) {
14221
14865
  const res = await fetch(base + entry.file);
14222
14866
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
14223
14867
  const buf = Buffer.from(await res.arrayBuffer());
14224
- await fs14.writeFile(cached, buf);
14868
+ await fs15.writeFile(cached, buf);
14225
14869
  }
14226
- await fs14.copyFile(cached, dest);
14870
+ await fs15.copyFile(cached, dest);
14227
14871
  installedCount++;
14228
14872
  addedBytes += entry.bytes;
14229
- log.dim(` ${path14.join(ANIMS_DEST, entry.file)} (${formatMb(entry.bytes)})`);
14873
+ log.dim(` ${path15.join(ANIMS_DEST, entry.file)} (${formatMb(entry.bytes)})`);
14230
14874
  } catch (err) {
14231
14875
  failures.push(`${entry.name} (${err instanceof Error ? err.message : String(err)})`);
14232
14876
  }
@@ -14242,13 +14886,13 @@ async function runAnims(opts) {
14242
14886
  version: manifest.version,
14243
14887
  clips: [...union].sort((a, b) => a.localeCompare(b))
14244
14888
  };
14245
- await fs14.writeFile(gameManifestPath, JSON.stringify(gameManifest, null, 2) + "\n");
14889
+ await fs15.writeFile(gameManifestPath, JSON.stringify(gameManifest, null, 2) + "\n");
14246
14890
  log.plain("");
14247
14891
  const parts = [`${installedCount} clip${installedCount === 1 ? "" : "s"} installed`];
14248
14892
  if (presentCount > 0) parts.push(`${presentCount} already present`);
14249
14893
  if (bundledSkips > 0) parts.push(`${bundledSkips} already bundled in animation-library.glb`);
14250
14894
  log.success(
14251
- `${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path14.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
14895
+ `${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path15.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
14252
14896
  );
14253
14897
  for (const [selector, entries] of resolved) {
14254
14898
  const names = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
@@ -14280,8 +14924,8 @@ async function loadManifest(baseOverride) {
14280
14924
  }
14281
14925
  } catch {
14282
14926
  }
14283
- const snapshotPath = path14.join(getTemplatesDir(), "controllers", "assets", "anims-manifest.json");
14284
- const manifest = JSON.parse(await fs14.readFile(snapshotPath, "utf8"));
14927
+ const snapshotPath = path15.join(getTemplatesDir(), "controllers", "assets", "anims-manifest.json");
14928
+ const manifest = JSON.parse(await fs15.readFile(snapshotPath, "utf8"));
14285
14929
  return { manifest, source: "snapshot" };
14286
14930
  }
14287
14931
  function resolveSelectors(manifest, selectors) {
@@ -14399,21 +15043,21 @@ function printCatalog(log, manifest, selectors) {
14399
15043
  }
14400
15044
  async function readGameManifest(file) {
14401
15045
  try {
14402
- return JSON.parse(await fs14.readFile(file, "utf8"));
15046
+ return JSON.parse(await fs15.readFile(file, "utf8"));
14403
15047
  } catch {
14404
15048
  return null;
14405
15049
  }
14406
15050
  }
14407
15051
  async function hasSize(file, bytes) {
14408
15052
  try {
14409
- return (await fs14.stat(file)).size === bytes;
15053
+ return (await fs15.stat(file)).size === bytes;
14410
15054
  } catch {
14411
15055
  return false;
14412
15056
  }
14413
15057
  }
14414
15058
  async function exists2(p) {
14415
15059
  try {
14416
- await fs14.access(p);
15060
+ await fs15.access(p);
14417
15061
  return true;
14418
15062
  } catch {
14419
15063
  return false;
@@ -14445,6 +15089,7 @@ var TOUCH_KIT = [
14445
15089
  var INPUT_AND_CAMERA = [
14446
15090
  "character/follow-camera.ts",
14447
15091
  "character/aim-cue.ts",
15092
+ "character/first-person.ts",
14448
15093
  "character/keyboard-input.ts",
14449
15094
  "character/touch-joystick.ts",
14450
15095
  ...TOUCH_KIT
@@ -14571,8 +15216,8 @@ var CONTROLLER_FILE_SETS = {
14571
15216
  ]
14572
15217
  }
14573
15218
  };
14574
- var CODE_DEST = path15.join("src", "controllers");
14575
- var ASSETS_DEST = path15.join("public", "assets");
15219
+ var CODE_DEST = path16.join("src", "controllers");
15220
+ var ASSETS_DEST = path16.join("public", "assets");
14576
15221
  async function runController(opts) {
14577
15222
  const log = createLogger({ quiet: opts.quiet });
14578
15223
  if (opts.kind?.trim() === "anims") {
@@ -14589,31 +15234,31 @@ async function runController(opts) {
14589
15234
  process.exitCode = 1;
14590
15235
  return;
14591
15236
  }
14592
- const srcDir = path15.join(getTemplatesDir(), "controllers");
15237
+ const srcDir = path16.join(getTemplatesDir(), "controllers");
14593
15238
  const root = opts.cwd ?? process.cwd();
14594
15239
  const set = CONTROLLER_FILE_SETS[kind];
14595
15240
  log.plain(c.bold(`genex controller ${kind}`));
14596
15241
  log.plain("");
14597
- log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path15.sep)}`);
15242
+ log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path16.sep)}`);
14598
15243
  const plan = [
14599
- ...set.code.map((rel) => ({ from: rel, rel: path15.join(CODE_DEST, rel) })),
15244
+ ...set.code.map((rel) => ({ from: rel, rel: path16.join(CODE_DEST, rel) })),
14600
15245
  ...set.assets.map((rel) => ({
14601
15246
  from: rel,
14602
- rel: path15.join(ASSETS_DEST, path15.basename(rel))
15247
+ rel: path16.join(ASSETS_DEST, path16.basename(rel))
14603
15248
  }))
14604
15249
  ];
14605
15250
  let copied = 0;
14606
15251
  let skipped = 0;
14607
15252
  try {
14608
15253
  for (const file of plan) {
14609
- const dest = path15.join(root, file.rel);
15254
+ const dest = path16.join(root, file.rel);
14610
15255
  if (!opts.force && await exists3(dest)) {
14611
15256
  skipped++;
14612
15257
  log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
14613
15258
  continue;
14614
15259
  }
14615
- await fs15.mkdir(path15.dirname(dest), { recursive: true });
14616
- await fs15.copyFile(path15.join(srcDir, file.from), dest);
15260
+ await fs16.mkdir(path16.dirname(dest), { recursive: true });
15261
+ await fs16.copyFile(path16.join(srcDir, file.from), dest);
14617
15262
  copied++;
14618
15263
  log.dim(` ${file.rel}`);
14619
15264
  }
@@ -14698,9 +15343,9 @@ async function installMeshyCharacterManifest(args) {
14698
15343
  throw new Error("The API returned an invalid Meshy character manifest.");
14699
15344
  }
14700
15345
  assertCompleteMeshyControllerPack(manifest);
14701
- const destination = path15.join(args.root, ASSETS_DEST, "meshy-character.json");
14702
- await fs15.mkdir(path15.dirname(destination), { recursive: true });
14703
- await fs15.writeFile(
15346
+ const destination = path16.join(args.root, ASSETS_DEST, "meshy-character.json");
15347
+ await fs16.mkdir(path16.dirname(destination), { recursive: true });
15348
+ await fs16.writeFile(
14704
15349
  destination,
14705
15350
  `${JSON.stringify(manifest, null, 2)}
14706
15351
  `
@@ -14837,8 +15482,8 @@ function assertCompleteMeshyControllerPack(manifest) {
14837
15482
  }
14838
15483
  async function installOwnerAvatar(args) {
14839
15484
  const { root, srcDir, apiUrl, token, log } = args;
14840
- const dest = path15.join(root, ASSETS_DEST, "avatar.vrm");
14841
- await fs15.mkdir(path15.dirname(dest), { recursive: true });
15485
+ const dest = path16.join(root, ASSETS_DEST, "avatar.vrm");
15486
+ await fs16.mkdir(path16.dirname(dest), { recursive: true });
14842
15487
  if (token) {
14843
15488
  try {
14844
15489
  const res = await apiFetch(`${apiUrl}/api/avatars/me`, {
@@ -14850,7 +15495,7 @@ async function installOwnerAvatar(args) {
14850
15495
  const vrmRes = await fetch(me.vrmUrl);
14851
15496
  if (vrmRes.ok) {
14852
15497
  const buf = Buffer.from(await vrmRes.arrayBuffer());
14853
- await fs15.writeFile(dest, buf);
15498
+ await fs16.writeFile(dest, buf);
14854
15499
  log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
14855
15500
  return;
14856
15501
  }
@@ -14861,14 +15506,14 @@ async function installOwnerAvatar(args) {
14861
15506
  log.dim(" avatar fetch failed (offline?); using the bundled default.");
14862
15507
  }
14863
15508
  }
14864
- await fs15.copyFile(path15.join(srcDir, "assets", "default-avatar.vrm"), dest);
15509
+ await fs16.copyFile(path16.join(srcDir, "assets", "default-avatar.vrm"), dest);
14865
15510
  log.dim(
14866
15511
  token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
14867
15512
  );
14868
15513
  }
14869
15514
  async function exists3(p) {
14870
15515
  try {
14871
- await fs15.access(p);
15516
+ await fs16.access(p);
14872
15517
  return true;
14873
15518
  } catch {
14874
15519
  return false;
@@ -15464,7 +16109,7 @@ function rank(items, query) {
15464
16109
  }
15465
16110
 
15466
16111
  // src/index.ts
15467
- var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
16112
+ var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "music", "voice", "texture", "image", "video"]);
15468
16113
  var HELP = `${c.bold("genex")} \u2014 set up your project's agent workspace, authorize, and publish 3D games.
15469
16114
 
15470
16115
  ${c.bold("Usage")}
@@ -15479,10 +16124,17 @@ ${c.bold("Usage")}
15479
16124
  genex publish [options] Build + push, then list the game in the gallery.
15480
16125
  genex make-remixable [options] Make THIS game remixable by everyone \u2014 migrates a
15481
16126
  private source onto a public managed genex repo.
15482
- genex model "<prompt>" [options] Generate a 3D model (GLB) into public/assets/models.
15483
- genex skybox "<prompt>" [options] Generate a skybox (equirect) into public/assets/skybox.
15484
- genex sfx "<prompt>" [options] Generate a sound effect (mp3) into public/assets/sfx.
15485
- genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
16127
+ genex model "<prompt>" [options] Generate a 3D model (GLB); prints a public asset URL.
16128
+ genex skybox "<prompt>" [options] Generate a skybox (equirect); prints a public asset URL.
16129
+ genex sfx "<prompt>" [options] Generate a sound effect (mp3); prints a public asset URL.
16130
+ genex music "<prompt>" [options] Generate an instrumental music track (mp3); prints a
16131
+ public asset URL. --duration 10-300s (default 90).
16132
+ genex voice "<line>" [options] Speak a short line (mp3, ElevenLabs TTS); prints a
16133
+ public asset URL. --voice picks a cast archetype
16134
+ (narrator|heroine|gruff|elder|robot|imp; default
16135
+ narrator), --voice-id any raw ElevenLabs voice id.
16136
+ Billed per character, hard-capped at 1000.
16137
+ genex texture "<prompt>" [options] Generate a PBR texture; prints public asset URLs.
15486
16138
  genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
15487
16139
  genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
15488
16140
  genex character "<brief>" Generate 3 character concepts for user selection.
@@ -15493,6 +16145,10 @@ ${c.bold("Usage")}
15493
16145
  Finalize an approved preview as a 10,000-face
15494
16146
  controller-ready Meshy character.
15495
16147
  genex character animate <id> Add Meshy library actions to that character.
16148
+ genex creature "<desc>" One-shot rigged enemy/creature (biped-shaped
16149
+ bodies only \u2014 Meshy rig limit): model \u2192 rig \u2192
16150
+ bind library clips via --animation. No approval
16151
+ ceremony, no player controller pack.
15496
16152
  genex animations search "<intent>" Search Meshy's animation library by gameplay intent.
15497
16153
  genex wait <id> Attach to a generation enqueued with --no-wait and
15498
16154
  print its asset URL(s) when it finishes. Safe to
@@ -15519,10 +16175,19 @@ ${c.bold("Usage")}
15519
16175
  Pure local work (no API); the PNG tools read
15520
16176
  a file or URL, audit scans the project.
15521
16177
 
15522
- ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `video`)")}
16178
+ ${c.bold("Options for the generators (`model` `skybox` `sfx` `music` `voice` `texture` `image` `video`)")}
15523
16179
  --terrain (texture) seamless tiling surface for terrain/ground.
15524
- --duration <sec> (sfx, video) target clip length in seconds.
16180
+ --raw (skybox) send the prompt exactly as written \u2014 skip the
16181
+ appended environment-only guard.
16182
+ --duration <sec> (sfx, video, music) target clip length in seconds
16183
+ (music: 10-300, default 90 \u2014 a looping gameplay track).
15525
16184
  --transparent (image) transparent background (alpha) \u2014 for decals/stickers.
16185
+ --glass (image) magenta-key glass lane: generate the panel on a
16186
+ flat #FF00FF screen, then locally solve REAL per-pixel
16187
+ translucency and write RGBA files (glass-1.png\u2026) into
16188
+ --out-dir (default .). For genuinely glassy panels; not
16189
+ for magenta/pink-hued art. Conflicts with --edit/--clean/
16190
+ --upscale/--transparent/--remove-bg/--no-wait.
15526
16191
  --open (image/video) open the finished asset in the user's browser +
15527
16192
  print a link \u2014 show them the concept frame / anything they approve.
15528
16193
  --aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
@@ -15534,11 +16199,20 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `
15534
16199
  --edit <url> (image) edit THIS image (asset URL) with the prompt (i2i).
15535
16200
  --clean <url> (image) ML-remove the background of THIS image (asset URL);
15536
16201
  prompt is recorded but unused. Defaults --bg-mode sheet.
16202
+ --upscale <url> (image) upscale THIS image 2x (clarity upscaler); prompt
16203
+ is recorded but unused. ~2 credits class.
16204
+ --inpaint <mask> (image, with --edit) region mask: local PNG path or asset
16205
+ URL; TRANSPARENT hole = the zone to change. Targets WHERE
16206
+ the edit lands (other regions keep their design) but the
16207
+ whole image still re-renders and alpha is destroyed \u2014
16208
+ re-run --clean + re-extract afterwards.
15537
16209
  --remove-bg (image) chain ML background removal after the gen/edit.
15538
- --bg-mode <mode> (image) background-removal model: sprite | glyph | sheet.
16210
+ --bg-mode <mode> (image) background-removal model: sprite | glyph | sheet |
16211
+ matte (BiRefNet soft alpha \u2014 hair/glow/smoke edges).
15539
16212
  --loop (video) generate a seamless loop.
15540
- --resolution <r> (video) 720p | 1080p (default 1080p; use 720p to save
15541
- credits \u2014 loop clips ignore it, model default).
16213
+ --resolution <r> (video) 720p | 1080p (default 1080p; 720p halves the cost
16214
+ when quality genuinely doesn't matter \u2014 loop clips
16215
+ ignore it, model default).
15542
16216
  --frame <url> (video) animate from THIS frame back to itself (seamless loop).
15543
16217
  --first-frame <url> (video) start frame for a two-frame motion \u2026
15544
16218
  --last-frame <url> (video) \u2026 and its end frame.
@@ -15649,11 +16323,14 @@ ${c.bold("Examples")}
15649
16323
  genex model "weathered wooden barrel with iron bands"
15650
16324
  genex skybox "golden hour over a misty mountain range"
15651
16325
  genex sfx "punchy laser zap" --duration 2
16326
+ genex music "brooding orchestral battle loop, seamless loop, no intro or outro" --no-wait
16327
+ genex voice "You shall not pass!" --voice gruff
15652
16328
  genex texture "mossy cracked cobblestone" --terrain
15653
16329
  genex image "retro arcade poster art" --aspect portrait
15654
16330
  genex image "chalk graffiti tag, hand-drawn style" --transparent
15655
16331
  genex video "candle flame flickering in the dark, seamless loop" --loop
15656
16332
  genex image "misty citadel at dusk, cinematic wide shot" --aspect 16:9 --quality high
16333
+ genex image "frosted translucent sci-fi status panel, about 40 percent opacity" --glass --size 1280x832 --quality high
15657
16334
  genex video "slow drifting fog, embers rising" --frame <that-image-url> --duration 8 --no-wait
15658
16335
  genex wait <that-generation-id>
15659
16336
  genex ui extract --in <sheet-url> --out-dir ui/ --names health-bar,minimap,ammo
@@ -15664,6 +16341,7 @@ ${c.bold("Examples")}
15664
16341
  genex character finalize <preview-id> --user-approved --approve-remesh 10000 --animation 466
15665
16342
  genex character "stylized sci-fi courier" --direct-text --no-wait
15666
16343
  genex character animate <character-id> --action <action-id>
16344
+ genex creature "hulking bone seraph, upright stance" --animation <walk-id> --animation <attack-id> --no-wait
15667
16345
  genex controller character --character <character-id>
15668
16346
  genex controller anims sword pistol
15669
16347
  genex controller touch
@@ -15696,7 +16374,11 @@ function parseArgs(argv) {
15696
16374
  "--size",
15697
16375
  "--edit",
15698
16376
  "--clean",
16377
+ "--upscale",
16378
+ "--inpaint",
15699
16379
  "--bg-mode",
16380
+ "--voice",
16381
+ "--voice-id",
15700
16382
  "--frame",
15701
16383
  "--first-frame",
15702
16384
  "--last-frame",
@@ -15770,9 +16452,18 @@ function parseArgs(argv) {
15770
16452
  case "--terrain":
15771
16453
  parsed.options.terrain = true;
15772
16454
  break;
16455
+ case "--raw":
16456
+ parsed.options.raw = true;
16457
+ break;
15773
16458
  case "--transparent":
15774
16459
  parsed.options.transparent = true;
15775
16460
  break;
16461
+ case "--glass":
16462
+ parsed.options.glass = true;
16463
+ break;
16464
+ case "--no-defringe":
16465
+ parsed.options.defringe = false;
16466
+ break;
15776
16467
  case "--loop":
15777
16468
  parsed.options.loop = true;
15778
16469
  break;
@@ -15841,6 +16532,8 @@ function parseArgs(argv) {
15841
16532
  parsed.error = `Unexpected argument: ${arg}`;
15842
16533
  return parsed;
15843
16534
  }
16535
+ } else if (parsed.command === "creature") {
16536
+ parsed.options.name = `${parsed.options.name} ${arg}`;
15844
16537
  } else if (parsed.command === "animations") {
15845
16538
  parsed.options.query = parsed.options.query ? `${parsed.options.query} ${arg}` : arg;
15846
16539
  } else {
@@ -16014,9 +16707,21 @@ function applyValueFlag(options, flag, value) {
16014
16707
  case "--clean":
16015
16708
  options.cleanUrl = value;
16016
16709
  break;
16710
+ case "--upscale":
16711
+ options.upscaleUrl = value;
16712
+ break;
16713
+ case "--inpaint":
16714
+ options.inpaintUrl = value;
16715
+ break;
16716
+ case "--voice":
16717
+ options.voice = value;
16718
+ break;
16719
+ case "--voice-id":
16720
+ options.voiceId = value;
16721
+ break;
16017
16722
  case "--bg-mode":
16018
- if (!["sprite", "glyph", "sheet"].includes(value)) {
16019
- throw new Error(`Invalid --bg-mode value: ${value} (expected sprite|glyph|sheet)`);
16723
+ if (!["sprite", "glyph", "sheet", "matte"].includes(value)) {
16724
+ throw new Error(`Invalid --bg-mode value: ${value} (expected sprite|glyph|sheet|matte)`);
16020
16725
  }
16021
16726
  options.bgMode = value;
16022
16727
  break;
@@ -16137,6 +16842,19 @@ async function main() {
16137
16842
  await runCharacter({ ...parsed.options, prompt: parsed.options.name });
16138
16843
  }
16139
16844
  break;
16845
+ case "creature":
16846
+ if (!parsed.options.json && !parsed.options.quiet) {
16847
+ log.dim(
16848
+ " Biped-shaped creatures only (Meshy rig limit) \u2014 for true quadrupeds/fliers use a static model + the genex-threejs-creatures procedural recipes."
16849
+ );
16850
+ }
16851
+ await runCharacter({
16852
+ ...parsed.options,
16853
+ prompt: parsed.options.name,
16854
+ directText: true,
16855
+ controllerPack: false
16856
+ });
16857
+ break;
16140
16858
  case "animations":
16141
16859
  if (parsed.options.name !== "search") {
16142
16860
  log.error('Unknown animations command. Use `genex animations search "<intent>"`.');