@genex-ai/cli-demo 0.81.0-dev.214 → 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.
- package/dist/index.js +780 -80
- package/package.json +1 -1
- package/templates/controllers/character/first-person.ts +54 -0
- package/templates/controllers/character/follow-camera.ts +35 -1
- package/templates/skills/genex-ai-character/SKILL.md +25 -5
- package/templates/skills/genex-ai-hud/SKILL.md +130 -36
- package/templates/skills/genex-ai-hud/references/masked-fill.md +19 -13
- package/templates/skills/genex-ai-hud/references/stage2-prompt-template.md +2 -2
- package/templates/skills/genex-ai-image/SKILL.md +40 -2
- package/templates/skills/genex-ai-menu/SKILL.md +35 -17
- package/templates/skills/genex-ai-model/SKILL.md +8 -0
- package/templates/skills/genex-ai-music/SKILL.md +142 -0
- package/templates/skills/genex-ai-skybox/SKILL.md +21 -5
- package/templates/skills/genex-ai-video/SKILL.md +13 -7
- package/templates/skills/genex-ai-voice/SKILL.md +151 -0
- package/templates/skills/genex-game-director/SKILL.md +128 -44
- package/templates/skills/genex-game-director/references/design-contract.md +7 -2
- package/templates/skills/genex-game-director/references/routing-map.md +4 -1
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +4 -1
- package/templates/skills/genex-threejs-character-controller/SKILL.md +6 -1
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +13 -2
- package/templates/skills/genex-threejs-creatures/SKILL.md +193 -0
- package/templates/skills/genex-threejs-game-ui/SKILL.md +5 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +6 -1
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.
|
|
2015
|
-
|
|
2016
|
-
[--
|
|
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).
|
|
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]
|
|
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
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
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
|
|
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
|
-
|
|
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 = [];
|
|
@@ -3050,6 +3360,92 @@ async function collectUiAuditFindings(assetDir, srcDir, cwd = process.cwd()) {
|
|
|
3050
3360
|
const referenced = (basename) => sources.some((s) => s.text.includes(basename));
|
|
3051
3361
|
const findings = [...viewportFindings];
|
|
3052
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
|
+
}
|
|
3053
3449
|
const maskReported = /* @__PURE__ */ new Set();
|
|
3054
3450
|
for (const p of assetFiles) {
|
|
3055
3451
|
const m = /^(.+)\.annotated-progress\.json$/.exec(path10.basename(p));
|
|
@@ -3113,11 +3509,14 @@ async function uiAudit(opts, log) {
|
|
|
3113
3509
|
log.success("HUD wiring audit clean \u2014 every asset referenced, no naive %-fill patterns.");
|
|
3114
3510
|
return;
|
|
3115
3511
|
}
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
);
|
|
3119
|
-
|
|
3120
|
-
|
|
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;
|
|
3121
3520
|
}
|
|
3122
3521
|
|
|
3123
3522
|
// src/lib/deploy.ts
|
|
@@ -3961,7 +4360,7 @@ async function detectSurfaceScan(cwd = process.cwd()) {
|
|
|
3961
4360
|
}
|
|
3962
4361
|
return found;
|
|
3963
4362
|
}
|
|
3964
|
-
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"]);
|
|
3965
4364
|
var UNPICKED_AFTER_MS = 15 * 60 * 1e3;
|
|
3966
4365
|
async function detectGenerationAudit(cwd = process.cwd()) {
|
|
3967
4366
|
const ledger = await readLedger(cwd);
|
|
@@ -4285,7 +4684,86 @@ async function runPreview(opts) {
|
|
|
4285
4684
|
}
|
|
4286
4685
|
|
|
4287
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
|
|
4288
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
|
+
}
|
|
4289
4767
|
|
|
4290
4768
|
// src/lib/open.ts
|
|
4291
4769
|
import { spawn as spawn4 } from "child_process";
|
|
@@ -4366,10 +4844,22 @@ async function* readSSE(body) {
|
|
|
4366
4844
|
|
|
4367
4845
|
// src/commands/generate.ts
|
|
4368
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
|
+
}
|
|
4369
4855
|
function buildGenOptions(kind, opts) {
|
|
4370
4856
|
const options = { ...opts.generationOptions };
|
|
4371
4857
|
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
4372
|
-
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
|
+
}
|
|
4373
4863
|
if (kind === "image") {
|
|
4374
4864
|
if (opts.transparent) options.transparent = true;
|
|
4375
4865
|
if (opts.aspect) options.aspect = opts.aspect;
|
|
@@ -4388,6 +4878,11 @@ function buildGenOptions(kind, opts) {
|
|
|
4388
4878
|
options.op = "remove-bg";
|
|
4389
4879
|
options.bgMode = opts.bgMode ?? "sheet";
|
|
4390
4880
|
}
|
|
4881
|
+
if (opts.upscaleUrl) {
|
|
4882
|
+
options.sourceUrl = opts.upscaleUrl;
|
|
4883
|
+
options.op = "upscale";
|
|
4884
|
+
}
|
|
4885
|
+
if (opts.inpaintUrl) options.maskUrl = opts.inpaintUrl;
|
|
4391
4886
|
if (opts.removeBackground) options.removeBackground = true;
|
|
4392
4887
|
if (opts.bgMode) options.bgMode = opts.bgMode;
|
|
4393
4888
|
}
|
|
@@ -4403,12 +4898,45 @@ function buildGenOptions(kind, opts) {
|
|
|
4403
4898
|
}
|
|
4404
4899
|
async function runGenerate(kind, opts) {
|
|
4405
4900
|
const log = createLogger({ quiet: opts.quiet || opts.json });
|
|
4406
|
-
const
|
|
4407
|
-
if (!
|
|
4901
|
+
const typedPrompt = opts.prompt?.trim();
|
|
4902
|
+
if (!typedPrompt) {
|
|
4408
4903
|
log.error(`Missing prompt. Usage: ${c.cyan(`genex ${kind} "<prompt>"`)}`);
|
|
4409
4904
|
process.exitCode = 1;
|
|
4410
4905
|
return;
|
|
4411
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
|
+
}
|
|
4412
4940
|
const token = opts.token ?? await readUserToken(opts.envPath);
|
|
4413
4941
|
if (!token) {
|
|
4414
4942
|
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
@@ -4420,6 +4948,7 @@ async function runGenerate(kind, opts) {
|
|
|
4420
4948
|
const options = buildGenOptions(kind, opts);
|
|
4421
4949
|
log.plain(c.bold(`genex ${kind}`));
|
|
4422
4950
|
log.dim(` ${prompt}`);
|
|
4951
|
+
if (skyboxGuard?.guarded) log.dim(" \u21B3 environment-only guard applied (--raw to disable)");
|
|
4423
4952
|
log.plain("");
|
|
4424
4953
|
let id;
|
|
4425
4954
|
try {
|
|
@@ -4472,9 +5001,104 @@ async function runGenerate(kind, opts) {
|
|
|
4472
5001
|
}
|
|
4473
5002
|
return id;
|
|
4474
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
|
+
}
|
|
4475
5023
|
await awaitAndReport(apiUrl, token, id, kind, log, opts.open, opts.json);
|
|
4476
5024
|
return id;
|
|
4477
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
|
+
}
|
|
4478
5102
|
async function awaitAndReport(apiUrl, token, id, kind, log, open = false, json = false) {
|
|
4479
5103
|
if (!json) log.step("Generating\u2026 (this can take up to a minute)");
|
|
4480
5104
|
const view = await waitForTerminalView(
|
|
@@ -4658,7 +5282,7 @@ async function reportTextureSeam(url, log) {
|
|
|
4658
5282
|
try {
|
|
4659
5283
|
const res = await fetch(url);
|
|
4660
5284
|
if (!res.ok) return;
|
|
4661
|
-
const png =
|
|
5285
|
+
const png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
|
|
4662
5286
|
if (png.width < 4 || png.height < 4) return;
|
|
4663
5287
|
const r = measureSeam(png);
|
|
4664
5288
|
if (r.seamless) {
|
|
@@ -4682,6 +5306,8 @@ function printHint(kind, view, files, log) {
|
|
|
4682
5306
|
model: `Standard GLB \u2014 load with GLTFLoader straight from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
|
|
4683
5307
|
skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
|
|
4684
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}"`,
|
|
4685
5311
|
texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`,
|
|
4686
5312
|
image: `Load with TextureLoader (set colorSpace = SRGBColorSpace) onto any mesh/plane/sprite \u2014 see genex-ai-image. url = "${url}"`,
|
|
4687
5313
|
video: `Wire an HTMLVideoElement (crossOrigin="anonymous", muted, loop, playsInline) into a THREE.VideoTexture \u2014 see genex-ai-video. url = "${url}"`,
|
|
@@ -5016,8 +5642,8 @@ async function toRow(e, v, cwd) {
|
|
|
5016
5642
|
}
|
|
5017
5643
|
|
|
5018
5644
|
// src/commands/controller.ts
|
|
5019
|
-
import
|
|
5020
|
-
import
|
|
5645
|
+
import fs16 from "fs/promises";
|
|
5646
|
+
import path16 from "path";
|
|
5021
5647
|
|
|
5022
5648
|
// ../../packages/meshy-animation-catalog/src/index.ts
|
|
5023
5649
|
import { createHash } from "crypto";
|
|
@@ -14147,9 +14773,9 @@ function searchMeshyAnimations(query, options = {}) {
|
|
|
14147
14773
|
}
|
|
14148
14774
|
|
|
14149
14775
|
// src/lib/anims.ts
|
|
14150
|
-
import
|
|
14151
|
-
import
|
|
14152
|
-
var ANIMS_DEST =
|
|
14776
|
+
import fs15 from "fs/promises";
|
|
14777
|
+
import path15 from "path";
|
|
14778
|
+
var ANIMS_DEST = path15.join("public", "assets", "anims");
|
|
14153
14779
|
var HIDDEN_TAG = "reference";
|
|
14154
14780
|
async function runAnims(opts) {
|
|
14155
14781
|
const log = createLogger({ quiet: opts.quiet });
|
|
@@ -14165,7 +14791,7 @@ async function runAnims(opts) {
|
|
|
14165
14791
|
printCatalog(log, manifest, selectors);
|
|
14166
14792
|
return;
|
|
14167
14793
|
}
|
|
14168
|
-
const controllerMarker =
|
|
14794
|
+
const controllerMarker = path15.join(root, "src", "controllers", "character");
|
|
14169
14795
|
if (!await exists2(controllerMarker)) {
|
|
14170
14796
|
log.error(
|
|
14171
14797
|
`No character controller in this game (missing ${c.cyan("src/controllers/character/")}).`
|
|
@@ -14174,11 +14800,11 @@ async function runAnims(opts) {
|
|
|
14174
14800
|
process.exitCode = 1;
|
|
14175
14801
|
return;
|
|
14176
14802
|
}
|
|
14177
|
-
const destDir =
|
|
14178
|
-
const gameManifestPath =
|
|
14803
|
+
const destDir = path15.join(root, ANIMS_DEST);
|
|
14804
|
+
const gameManifestPath = path15.join(destDir, "manifest.json");
|
|
14179
14805
|
if (opts.reset) {
|
|
14180
|
-
await
|
|
14181
|
-
log.step(`Cleared ${c.cyan(ANIMS_DEST +
|
|
14806
|
+
await fs15.rm(destDir, { recursive: true, force: true });
|
|
14807
|
+
log.step(`Cleared ${c.cyan(ANIMS_DEST + path15.sep)} (--reset)`);
|
|
14182
14808
|
}
|
|
14183
14809
|
if (selectors.length === 0) {
|
|
14184
14810
|
const installed = await readGameManifest(gameManifestPath);
|
|
@@ -14216,35 +14842,35 @@ async function runAnims(opts) {
|
|
|
14216
14842
|
}
|
|
14217
14843
|
}
|
|
14218
14844
|
const wanted = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
14219
|
-
const cacheDir =
|
|
14845
|
+
const cacheDir = path15.join(
|
|
14220
14846
|
opts.cacheDir ?? getAnimsCacheDir(),
|
|
14221
14847
|
`${manifest.library}-v${manifest.version}`
|
|
14222
14848
|
);
|
|
14223
|
-
await
|
|
14224
|
-
await
|
|
14849
|
+
await fs15.mkdir(cacheDir, { recursive: true });
|
|
14850
|
+
await fs15.mkdir(destDir, { recursive: true });
|
|
14225
14851
|
const base = getAnimsBase(opts.animsBase);
|
|
14226
14852
|
let installedCount = 0;
|
|
14227
14853
|
let presentCount = 0;
|
|
14228
14854
|
let addedBytes = 0;
|
|
14229
14855
|
const failures = [];
|
|
14230
14856
|
for (const entry of wanted) {
|
|
14231
|
-
const dest =
|
|
14857
|
+
const dest = path15.join(destDir, entry.file);
|
|
14232
14858
|
if (await hasSize(dest, entry.bytes)) {
|
|
14233
14859
|
presentCount++;
|
|
14234
14860
|
continue;
|
|
14235
14861
|
}
|
|
14236
14862
|
try {
|
|
14237
|
-
const cached =
|
|
14863
|
+
const cached = path15.join(cacheDir, entry.file);
|
|
14238
14864
|
if (!await hasSize(cached, entry.bytes)) {
|
|
14239
14865
|
const res = await fetch(base + entry.file);
|
|
14240
14866
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
14241
14867
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
14242
|
-
await
|
|
14868
|
+
await fs15.writeFile(cached, buf);
|
|
14243
14869
|
}
|
|
14244
|
-
await
|
|
14870
|
+
await fs15.copyFile(cached, dest);
|
|
14245
14871
|
installedCount++;
|
|
14246
14872
|
addedBytes += entry.bytes;
|
|
14247
|
-
log.dim(` ${
|
|
14873
|
+
log.dim(` ${path15.join(ANIMS_DEST, entry.file)} (${formatMb(entry.bytes)})`);
|
|
14248
14874
|
} catch (err) {
|
|
14249
14875
|
failures.push(`${entry.name} (${err instanceof Error ? err.message : String(err)})`);
|
|
14250
14876
|
}
|
|
@@ -14260,13 +14886,13 @@ async function runAnims(opts) {
|
|
|
14260
14886
|
version: manifest.version,
|
|
14261
14887
|
clips: [...union].sort((a, b) => a.localeCompare(b))
|
|
14262
14888
|
};
|
|
14263
|
-
await
|
|
14889
|
+
await fs15.writeFile(gameManifestPath, JSON.stringify(gameManifest, null, 2) + "\n");
|
|
14264
14890
|
log.plain("");
|
|
14265
14891
|
const parts = [`${installedCount} clip${installedCount === 1 ? "" : "s"} installed`];
|
|
14266
14892
|
if (presentCount > 0) parts.push(`${presentCount} already present`);
|
|
14267
14893
|
if (bundledSkips > 0) parts.push(`${bundledSkips} already bundled in animation-library.glb`);
|
|
14268
14894
|
log.success(
|
|
14269
|
-
`${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST +
|
|
14895
|
+
`${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path15.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
|
|
14270
14896
|
);
|
|
14271
14897
|
for (const [selector, entries] of resolved) {
|
|
14272
14898
|
const names = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
|
|
@@ -14298,8 +14924,8 @@ async function loadManifest(baseOverride) {
|
|
|
14298
14924
|
}
|
|
14299
14925
|
} catch {
|
|
14300
14926
|
}
|
|
14301
|
-
const snapshotPath =
|
|
14302
|
-
const manifest = JSON.parse(await
|
|
14927
|
+
const snapshotPath = path15.join(getTemplatesDir(), "controllers", "assets", "anims-manifest.json");
|
|
14928
|
+
const manifest = JSON.parse(await fs15.readFile(snapshotPath, "utf8"));
|
|
14303
14929
|
return { manifest, source: "snapshot" };
|
|
14304
14930
|
}
|
|
14305
14931
|
function resolveSelectors(manifest, selectors) {
|
|
@@ -14417,21 +15043,21 @@ function printCatalog(log, manifest, selectors) {
|
|
|
14417
15043
|
}
|
|
14418
15044
|
async function readGameManifest(file) {
|
|
14419
15045
|
try {
|
|
14420
|
-
return JSON.parse(await
|
|
15046
|
+
return JSON.parse(await fs15.readFile(file, "utf8"));
|
|
14421
15047
|
} catch {
|
|
14422
15048
|
return null;
|
|
14423
15049
|
}
|
|
14424
15050
|
}
|
|
14425
15051
|
async function hasSize(file, bytes) {
|
|
14426
15052
|
try {
|
|
14427
|
-
return (await
|
|
15053
|
+
return (await fs15.stat(file)).size === bytes;
|
|
14428
15054
|
} catch {
|
|
14429
15055
|
return false;
|
|
14430
15056
|
}
|
|
14431
15057
|
}
|
|
14432
15058
|
async function exists2(p) {
|
|
14433
15059
|
try {
|
|
14434
|
-
await
|
|
15060
|
+
await fs15.access(p);
|
|
14435
15061
|
return true;
|
|
14436
15062
|
} catch {
|
|
14437
15063
|
return false;
|
|
@@ -14463,6 +15089,7 @@ var TOUCH_KIT = [
|
|
|
14463
15089
|
var INPUT_AND_CAMERA = [
|
|
14464
15090
|
"character/follow-camera.ts",
|
|
14465
15091
|
"character/aim-cue.ts",
|
|
15092
|
+
"character/first-person.ts",
|
|
14466
15093
|
"character/keyboard-input.ts",
|
|
14467
15094
|
"character/touch-joystick.ts",
|
|
14468
15095
|
...TOUCH_KIT
|
|
@@ -14589,8 +15216,8 @@ var CONTROLLER_FILE_SETS = {
|
|
|
14589
15216
|
]
|
|
14590
15217
|
}
|
|
14591
15218
|
};
|
|
14592
|
-
var CODE_DEST =
|
|
14593
|
-
var ASSETS_DEST =
|
|
15219
|
+
var CODE_DEST = path16.join("src", "controllers");
|
|
15220
|
+
var ASSETS_DEST = path16.join("public", "assets");
|
|
14594
15221
|
async function runController(opts) {
|
|
14595
15222
|
const log = createLogger({ quiet: opts.quiet });
|
|
14596
15223
|
if (opts.kind?.trim() === "anims") {
|
|
@@ -14607,31 +15234,31 @@ async function runController(opts) {
|
|
|
14607
15234
|
process.exitCode = 1;
|
|
14608
15235
|
return;
|
|
14609
15236
|
}
|
|
14610
|
-
const srcDir =
|
|
15237
|
+
const srcDir = path16.join(getTemplatesDir(), "controllers");
|
|
14611
15238
|
const root = opts.cwd ?? process.cwd();
|
|
14612
15239
|
const set = CONTROLLER_FILE_SETS[kind];
|
|
14613
15240
|
log.plain(c.bold(`genex controller ${kind}`));
|
|
14614
15241
|
log.plain("");
|
|
14615
|
-
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST +
|
|
15242
|
+
log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path16.sep)}`);
|
|
14616
15243
|
const plan = [
|
|
14617
|
-
...set.code.map((rel) => ({ from: rel, rel:
|
|
15244
|
+
...set.code.map((rel) => ({ from: rel, rel: path16.join(CODE_DEST, rel) })),
|
|
14618
15245
|
...set.assets.map((rel) => ({
|
|
14619
15246
|
from: rel,
|
|
14620
|
-
rel:
|
|
15247
|
+
rel: path16.join(ASSETS_DEST, path16.basename(rel))
|
|
14621
15248
|
}))
|
|
14622
15249
|
];
|
|
14623
15250
|
let copied = 0;
|
|
14624
15251
|
let skipped = 0;
|
|
14625
15252
|
try {
|
|
14626
15253
|
for (const file of plan) {
|
|
14627
|
-
const dest =
|
|
15254
|
+
const dest = path16.join(root, file.rel);
|
|
14628
15255
|
if (!opts.force && await exists3(dest)) {
|
|
14629
15256
|
skipped++;
|
|
14630
15257
|
log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
|
|
14631
15258
|
continue;
|
|
14632
15259
|
}
|
|
14633
|
-
await
|
|
14634
|
-
await
|
|
15260
|
+
await fs16.mkdir(path16.dirname(dest), { recursive: true });
|
|
15261
|
+
await fs16.copyFile(path16.join(srcDir, file.from), dest);
|
|
14635
15262
|
copied++;
|
|
14636
15263
|
log.dim(` ${file.rel}`);
|
|
14637
15264
|
}
|
|
@@ -14716,9 +15343,9 @@ async function installMeshyCharacterManifest(args) {
|
|
|
14716
15343
|
throw new Error("The API returned an invalid Meshy character manifest.");
|
|
14717
15344
|
}
|
|
14718
15345
|
assertCompleteMeshyControllerPack(manifest);
|
|
14719
|
-
const destination =
|
|
14720
|
-
await
|
|
14721
|
-
await
|
|
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(
|
|
14722
15349
|
destination,
|
|
14723
15350
|
`${JSON.stringify(manifest, null, 2)}
|
|
14724
15351
|
`
|
|
@@ -14855,8 +15482,8 @@ function assertCompleteMeshyControllerPack(manifest) {
|
|
|
14855
15482
|
}
|
|
14856
15483
|
async function installOwnerAvatar(args) {
|
|
14857
15484
|
const { root, srcDir, apiUrl, token, log } = args;
|
|
14858
|
-
const dest =
|
|
14859
|
-
await
|
|
15485
|
+
const dest = path16.join(root, ASSETS_DEST, "avatar.vrm");
|
|
15486
|
+
await fs16.mkdir(path16.dirname(dest), { recursive: true });
|
|
14860
15487
|
if (token) {
|
|
14861
15488
|
try {
|
|
14862
15489
|
const res = await apiFetch(`${apiUrl}/api/avatars/me`, {
|
|
@@ -14868,7 +15495,7 @@ async function installOwnerAvatar(args) {
|
|
|
14868
15495
|
const vrmRes = await fetch(me.vrmUrl);
|
|
14869
15496
|
if (vrmRes.ok) {
|
|
14870
15497
|
const buf = Buffer.from(await vrmRes.arrayBuffer());
|
|
14871
|
-
await
|
|
15498
|
+
await fs16.writeFile(dest, buf);
|
|
14872
15499
|
log.dim(` public/assets/avatar.vrm (your avatar \u2014 ${(buf.length / 1e6).toFixed(1)} MB)`);
|
|
14873
15500
|
return;
|
|
14874
15501
|
}
|
|
@@ -14879,14 +15506,14 @@ async function installOwnerAvatar(args) {
|
|
|
14879
15506
|
log.dim(" avatar fetch failed (offline?); using the bundled default.");
|
|
14880
15507
|
}
|
|
14881
15508
|
}
|
|
14882
|
-
await
|
|
15509
|
+
await fs16.copyFile(path16.join(srcDir, "assets", "default-avatar.vrm"), dest);
|
|
14883
15510
|
log.dim(
|
|
14884
15511
|
token ? " public/assets/avatar.vrm (bundled default)" : " public/assets/avatar.vrm (bundled default \u2014 sign in and re-run for your own)"
|
|
14885
15512
|
);
|
|
14886
15513
|
}
|
|
14887
15514
|
async function exists3(p) {
|
|
14888
15515
|
try {
|
|
14889
|
-
await
|
|
15516
|
+
await fs16.access(p);
|
|
14890
15517
|
return true;
|
|
14891
15518
|
} catch {
|
|
14892
15519
|
return false;
|
|
@@ -15482,7 +16109,7 @@ function rank(items, query) {
|
|
|
15482
16109
|
}
|
|
15483
16110
|
|
|
15484
16111
|
// src/index.ts
|
|
15485
|
-
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"]);
|
|
15486
16113
|
var HELP = `${c.bold("genex")} \u2014 set up your project's agent workspace, authorize, and publish 3D games.
|
|
15487
16114
|
|
|
15488
16115
|
${c.bold("Usage")}
|
|
@@ -15497,10 +16124,17 @@ ${c.bold("Usage")}
|
|
|
15497
16124
|
genex publish [options] Build + push, then list the game in the gallery.
|
|
15498
16125
|
genex make-remixable [options] Make THIS game remixable by everyone \u2014 migrates a
|
|
15499
16126
|
private source onto a public managed genex repo.
|
|
15500
|
-
genex model "<prompt>" [options] Generate a 3D model (GLB)
|
|
15501
|
-
genex skybox "<prompt>" [options] Generate a skybox (equirect)
|
|
15502
|
-
genex sfx "<prompt>" [options] Generate a sound effect (mp3)
|
|
15503
|
-
genex
|
|
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.
|
|
15504
16138
|
genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
|
|
15505
16139
|
genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
|
|
15506
16140
|
genex character "<brief>" Generate 3 character concepts for user selection.
|
|
@@ -15511,6 +16145,10 @@ ${c.bold("Usage")}
|
|
|
15511
16145
|
Finalize an approved preview as a 10,000-face
|
|
15512
16146
|
controller-ready Meshy character.
|
|
15513
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.
|
|
15514
16152
|
genex animations search "<intent>" Search Meshy's animation library by gameplay intent.
|
|
15515
16153
|
genex wait <id> Attach to a generation enqueued with --no-wait and
|
|
15516
16154
|
print its asset URL(s) when it finishes. Safe to
|
|
@@ -15537,10 +16175,19 @@ ${c.bold("Usage")}
|
|
|
15537
16175
|
Pure local work (no API); the PNG tools read
|
|
15538
16176
|
a file or URL, audit scans the project.
|
|
15539
16177
|
|
|
15540
|
-
${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`)")}
|
|
15541
16179
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
15542
|
-
--
|
|
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).
|
|
15543
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.
|
|
15544
16191
|
--open (image/video) open the finished asset in the user's browser +
|
|
15545
16192
|
print a link \u2014 show them the concept frame / anything they approve.
|
|
15546
16193
|
--aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
|
|
@@ -15552,11 +16199,20 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `
|
|
|
15552
16199
|
--edit <url> (image) edit THIS image (asset URL) with the prompt (i2i).
|
|
15553
16200
|
--clean <url> (image) ML-remove the background of THIS image (asset URL);
|
|
15554
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.
|
|
15555
16209
|
--remove-bg (image) chain ML background removal after the gen/edit.
|
|
15556
|
-
--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).
|
|
15557
16212
|
--loop (video) generate a seamless loop.
|
|
15558
|
-
--resolution <r> (video) 720p | 1080p (default 1080p;
|
|
15559
|
-
|
|
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).
|
|
15560
16216
|
--frame <url> (video) animate from THIS frame back to itself (seamless loop).
|
|
15561
16217
|
--first-frame <url> (video) start frame for a two-frame motion \u2026
|
|
15562
16218
|
--last-frame <url> (video) \u2026 and its end frame.
|
|
@@ -15667,11 +16323,14 @@ ${c.bold("Examples")}
|
|
|
15667
16323
|
genex model "weathered wooden barrel with iron bands"
|
|
15668
16324
|
genex skybox "golden hour over a misty mountain range"
|
|
15669
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
|
|
15670
16328
|
genex texture "mossy cracked cobblestone" --terrain
|
|
15671
16329
|
genex image "retro arcade poster art" --aspect portrait
|
|
15672
16330
|
genex image "chalk graffiti tag, hand-drawn style" --transparent
|
|
15673
16331
|
genex video "candle flame flickering in the dark, seamless loop" --loop
|
|
15674
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
|
|
15675
16334
|
genex video "slow drifting fog, embers rising" --frame <that-image-url> --duration 8 --no-wait
|
|
15676
16335
|
genex wait <that-generation-id>
|
|
15677
16336
|
genex ui extract --in <sheet-url> --out-dir ui/ --names health-bar,minimap,ammo
|
|
@@ -15682,6 +16341,7 @@ ${c.bold("Examples")}
|
|
|
15682
16341
|
genex character finalize <preview-id> --user-approved --approve-remesh 10000 --animation 466
|
|
15683
16342
|
genex character "stylized sci-fi courier" --direct-text --no-wait
|
|
15684
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
|
|
15685
16345
|
genex controller character --character <character-id>
|
|
15686
16346
|
genex controller anims sword pistol
|
|
15687
16347
|
genex controller touch
|
|
@@ -15714,7 +16374,11 @@ function parseArgs(argv) {
|
|
|
15714
16374
|
"--size",
|
|
15715
16375
|
"--edit",
|
|
15716
16376
|
"--clean",
|
|
16377
|
+
"--upscale",
|
|
16378
|
+
"--inpaint",
|
|
15717
16379
|
"--bg-mode",
|
|
16380
|
+
"--voice",
|
|
16381
|
+
"--voice-id",
|
|
15718
16382
|
"--frame",
|
|
15719
16383
|
"--first-frame",
|
|
15720
16384
|
"--last-frame",
|
|
@@ -15788,9 +16452,18 @@ function parseArgs(argv) {
|
|
|
15788
16452
|
case "--terrain":
|
|
15789
16453
|
parsed.options.terrain = true;
|
|
15790
16454
|
break;
|
|
16455
|
+
case "--raw":
|
|
16456
|
+
parsed.options.raw = true;
|
|
16457
|
+
break;
|
|
15791
16458
|
case "--transparent":
|
|
15792
16459
|
parsed.options.transparent = true;
|
|
15793
16460
|
break;
|
|
16461
|
+
case "--glass":
|
|
16462
|
+
parsed.options.glass = true;
|
|
16463
|
+
break;
|
|
16464
|
+
case "--no-defringe":
|
|
16465
|
+
parsed.options.defringe = false;
|
|
16466
|
+
break;
|
|
15794
16467
|
case "--loop":
|
|
15795
16468
|
parsed.options.loop = true;
|
|
15796
16469
|
break;
|
|
@@ -15859,6 +16532,8 @@ function parseArgs(argv) {
|
|
|
15859
16532
|
parsed.error = `Unexpected argument: ${arg}`;
|
|
15860
16533
|
return parsed;
|
|
15861
16534
|
}
|
|
16535
|
+
} else if (parsed.command === "creature") {
|
|
16536
|
+
parsed.options.name = `${parsed.options.name} ${arg}`;
|
|
15862
16537
|
} else if (parsed.command === "animations") {
|
|
15863
16538
|
parsed.options.query = parsed.options.query ? `${parsed.options.query} ${arg}` : arg;
|
|
15864
16539
|
} else {
|
|
@@ -16032,9 +16707,21 @@ function applyValueFlag(options, flag, value) {
|
|
|
16032
16707
|
case "--clean":
|
|
16033
16708
|
options.cleanUrl = value;
|
|
16034
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;
|
|
16035
16722
|
case "--bg-mode":
|
|
16036
|
-
if (!["sprite", "glyph", "sheet"].includes(value)) {
|
|
16037
|
-
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)`);
|
|
16038
16725
|
}
|
|
16039
16726
|
options.bgMode = value;
|
|
16040
16727
|
break;
|
|
@@ -16155,6 +16842,19 @@ async function main() {
|
|
|
16155
16842
|
await runCharacter({ ...parsed.options, prompt: parsed.options.name });
|
|
16156
16843
|
}
|
|
16157
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;
|
|
16158
16858
|
case "animations":
|
|
16159
16859
|
if (parsed.options.name !== "search") {
|
|
16160
16860
|
log.error('Unknown animations command. Use `genex animations search "<intent>"`.');
|