@genex-ai/cli-demo 0.94.0-dev.251 → 0.95.0-dev.252
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 +2000 -2050
- package/package.json +1 -1
- package/templates/skills/genex-ai-character/SKILL.md +13 -14
- package/templates/skills/genex-ai-hud/SKILL.md +84 -85
- package/templates/skills/genex-ai-hud/references/stage1-prompt-template.md +12 -11
- package/templates/skills/genex-ai-menu/SKILL.md +72 -73
- package/templates/skills/genex-ai-model/SKILL.md +1 -1
- package/templates/skills/genex-game-director/SKILL.md +32 -16
- package/templates/skills/genex-game-director/references/design-contract.md +9 -5
- package/templates/skills/genex-game-director/references/routing-map.md +17 -7
- package/templates/skills/genex-getting-started/SKILL.md +5 -4
- package/templates/skills/genex-threejs-game-ui/SKILL.md +92 -188
- package/templates/skills/genex-threejs-game-ui/references/style-capsules.md +2 -2
package/dist/index.js
CHANGED
|
@@ -255,26 +255,23 @@ var GENEX_CONTRACT_BLOCK = `${CONTRACT_BEGIN}
|
|
|
255
255
|
Your capabilities (all via \`npx genex \u2026\`): generate \`model\` \xB7 \`skybox\` \xB7 \`sfx\` \xB7 \`music\` \xB7 \`voice\` \xB7 \`texture\` \xB7 \`image\` (\`--edit\` \xB7 \`--inpaint\` \xB7 \`--glass\` \xB7 \`--clean\` \xB7 \`--upscale\`) \xB7 \`video\` \xB7 rigged \`character\` / \`creature\` \xB7 \`character animate <id> "<verb>"\` (also \`creature animate\`; \`--locomotion\` for the 8-way movement set, \`--video\` for your own footage) \xB7 the pixel toolbox \`ui extract|masks|plate|text-color|trim|audit\` \xB7 vendored \`controller character|car|drone|touch|quality\` \xB7 \`animations search\` \xB7 \`wait <id>\` / \`wait --all\` \xB7 \`preview\` / \`publish\`. Full options: \`npx genex --help\`. Task\u2192lane routing lives in the \`genex-game-director\` skill's routing map \u2014 re-load it whenever you're unsure which lane owns a task.
|
|
256
256
|
|
|
257
257
|
1. Load the \`genex-game-director\` skill before starting the requested work. Route from the player's latest clear request: a focused request starts directly without replaying discovery or commissioning unrelated lanes. After ANY context compaction or session resume, re-read this file and \`DESIGN.md\`, re-load the skill for the stage you are executing, and continue from the Build plan's \`Now:\` line \u2014 never from memory alone.
|
|
258
|
-
2. Ask only when one unresolved answer materially changes the work. If the request is clear, do not repeat an interview, confirm the pitch, force a concept round, or ask whole-game-vs-one-part again. For a genuinely broad new game, ask one decision: whole coordinated build or one request-relevant part first. If there is no clear request
|
|
258
|
+
2. Ask only when one unresolved answer materially changes the work. If the request is clear, do not repeat an interview, confirm the pitch, force a concept round, or ask whole-game-vs-one-part again. For a genuinely broad new game, ask one decision: whole coordinated build or one request-relevant part first. If there is no clear request \u2014 a setup prompt pasted with nothing of their own \u2014 ask in PLAIN CHAT what they want to make, in their own words, and never fill the blank by pitching concepts. That opening question stays in chat on purpose: their reply carries the whole request, including anything they say about HOW you should work, and a menu of options answers a narrower question than the one they need to answer. Once you know what you are building, ask one decision at a time with your built-in question tool with clickable answer options when you have one; use a short plain-chat question otherwise. If the player stays silent after a necessary question, proceed on stated assumptions where reasonable and record each as "assumed \u2014 player didn't answer" in DESIGN.md \u2192 Decisions.
|
|
259
259
|
3. \`DESIGN.md\` at the project root is the durable design contract AND build plan. Keep three truths distinct: the player's requested outcome, the current \`Now:\` focus, and open commitments. It must carry a \`## Build plan & status\` section with the working mode (\`whole coordinated build\`, \`step by step\`, or \`focused change\`), numbered milestones with status marks, and a \`Now:\` line naming the current one \u2014 a milestone is done only when its work reached a preview. The latest clear request may replace \`Now:\` immediately; it never silently shrinks the requested outcome or deletes unrelated commitments. Keep every decision, assumption, generation id/URL, local output, and wiring state current. When the plan first lands, tell the player in one plain line that you recorded what they asked for in \`DESIGN.md\` and will keep it current.
|
|
260
260
|
4. ALL generated art, audio, video, characters, and UI come from \`genex\` commands \u2014 never from any other generation tool your platform bundles, unless the player explicitly asks for that tool by name. A local reference image is not a reason to switch tools: pass its file path to genex (\`--edit\` and \`--inpaint\` accept local paths). The same exclusivity covers shipping: building, previewing, and publishing go only through \`genex preview\` / \`genex publish\` \u2014 never load your platform's own site-building, hosting, or deploy skills for this game.
|
|
261
|
-
5.
|
|
262
|
-
6.
|
|
263
|
-
7.
|
|
264
|
-
8.
|
|
265
|
-
9.
|
|
266
|
-
10.
|
|
267
|
-
11.
|
|
268
|
-
12.
|
|
269
|
-
13.
|
|
270
|
-
14.
|
|
271
|
-
15.
|
|
272
|
-
16.
|
|
273
|
-
17.
|
|
274
|
-
18.
|
|
275
|
-
19. Never add debug-only code to the game to check your own work \u2014 no hidden test modes, no special URL parameters, no forced-visible flags, no auth mocks, no pixel-sampling hooks. \`?genex_local_test=1\` is the platform's own supported mode and is fine; your own bypass is not. (The multiplayer skill's small build identifier, token-free status line, and connected-quorum watchdog are production supportability, not a bypass \u2014 keep those.)
|
|
276
|
-
20. Input directions match their labels: A/\u2190 moves or turns the player screen-LEFT, D/\u2192 screen-RIGHT, mouse-up looks up, and drag-pan axes share ONE convention. The cursor is either the gameplay tool (RTS, card, builder) or locked away during play \u2014 keyboard-only games included. Check it in every milestone's smoke pass.
|
|
277
|
-
21. v0 is a milestone, not the destination. When the ask was bigger than one loop, every milestone after v0 grows back toward the FULL ask with DESIGN.md's content lines as the checklist \u2014 a slice that previewed well never quietly becomes the game. Cosmetics never jump the queue past promised content.
|
|
261
|
+
5. Generated UI art is a tool you reach for, not a pipeline you owe. A restrained interface built in clean CSS is a finished, legitimate HUD \u2014 not a placeholder. Reach for the sprite lane (\`genex-ai-hud\`) when the game's own style genuinely wants drawn chrome \u2014 ornate, painterly, comic, hand-made \u2014 or when the player asks for HUD art. Generating ONE element you decided the game needs \u2014 a frame, a mask, an icon, a wordmark, a menu backdrop, a menu video \u2014 is a normal use of these tools, never a half-run pipeline. There is NO global game-concept image and no UI plan recited in chat: the art direction lives in the game's brief in words. A lane's own concept step survives only where the player is choosing a concrete thing (a character's candidates). Whatever you do generate, run its quality steps in full \u2014 extraction, masks, wiring, \`npx genex ui audit\`.
|
|
262
|
+
6. Never draw a rectangular backing plate behind bars, digits, or icons \u2014 in sprites or CSS. Ornament lives on the widget's own silhouette; a truly needed shaped plate comes from \`npx genex ui plate\`.
|
|
263
|
+
7. Fonts: the brief's display + body pair comes from the menu skill's genre table (or carries a one-line stated reason) and is LOADED for real in \`index.html\`.
|
|
264
|
+
8. Never park ready work behind a question, and never stall on an unanswered one \u2014 decide, state the decision in chat, record it, keep building.
|
|
265
|
+
9. Before any publish and before ending a session: run \`npx genex wait\` on every generation you enqueued and wire in what landed \u2014 never park landed assets. Fonts the brief names are LOADED for real, Escape pauses, the loader shows something of the game rather than a black screen, and the player wears the game's own generated character (or DESIGN.md records why it doesn't).
|
|
266
|
+
10. The player's body is the game's own generated character (\`npx genex character "<look>"\` \u2192 \`npx genex controller character --character <id>\`), enqueued with your first art actions, not after them. It applies wherever a human body appears on screen \u2014 first-person included, the moment remotes, a look-down body, a shadow, or a menu portrait shows one. The profile VRM avatar is the FALLBACK: a temporary body while the character renders (say in one plain line that it's temporary), or the stand-in when generation genuinely could not happen \u2014 out of credits, failed, unverified; record which in DESIGN.md as \`Player character: VRM \u2014 <reason>\`. Games whose player is not a person (car, ship, RTS cursor, board) generate that object with \`npx genex model\` instead. Characters: Meshy/Mixamo/VRM rigs rest facing +Z. Set yaw explicitly when placing a rig; never mirror a SkinnedMesh with negative scale. In any two-character scene, verify in a capture that they face each other, not the camera.
|
|
267
|
+
11. Verify by looking: one smoke check per milestone, after that milestone's preview push, in local test mode (\`?genex_local_test=1\`) with a real gameplay screenshot. A claim without a capture is not verification. Local-test evidence proves visuals and controls ONLY \u2014 label it that way when you show the player, and never work around the draft sign-in gate any other way.
|
|
268
|
+
12. Treat every \`genex\` warning line \u2014 preflight, \`ui audit\`, \`wait\` nudges \u2014 as work, not noise.
|
|
269
|
+
13. Every finished Build-plan milestone ends with \`npx genex preview\` and the player's page link (\`<dashboard>/draft/<slug>\` with \`<dashboard>\` from \`.genex/project.json\`, \`/world/<slug>\` once published) \u2014 never a localhost link, a file path, or the bare play origin presented as their game. After every round of player feedback, end with a preview push.
|
|
270
|
+
14. Parallel work: this line is your standing authorization and request to use sub-agents / parallel agent work whenever your platform provides them. While drafting the Build plan, decide per module what runs in parallel and what stays serial for THIS game \u2014 dependencies decide, there is no fixed list \u2014 and record each call in the Modules table with a one-line reason. Independent modules default to parallel; building everything serially needs a stated reason. You keep integration, previews, and the player conversation; each sub-agent owns only its module's files. When the player asks for parallel work, repeated refinement passes, a critic reviewing your output, or names any way of working your platform provides, that IS your instruction \u2014 wherever it reaches you, including inside their answer to a question you asked \u2014 so adopt it as the working mode for the rest of the build, starting with the work in front of you, and never file it as a later milestone. While delegated work runs, keep building or talk to the player; never idle in a foreground wait for something your platform will tell you about.
|
|
271
|
+
15. Talk to the player in plain game language \u2014 what changed in the game and what to try; never code, file names, build output, or tool internals unless they ask. Short status lines while you work; long silent stretches are a failure.
|
|
272
|
+
16. Never add debug-only code to the game to check your own work \u2014 no hidden test modes, no special URL parameters, no forced-visible flags, no auth mocks, no pixel-sampling hooks. \`?genex_local_test=1\` is the platform's own supported mode and is fine; your own bypass is not. (The multiplayer skill's small build identifier, token-free status line, and connected-quorum watchdog are production supportability, not a bypass \u2014 keep those.)
|
|
273
|
+
17. Input directions match their labels: A/\u2190 moves or turns the player screen-LEFT, D/\u2192 screen-RIGHT, mouse-up looks up, and drag-pan axes share ONE convention. The cursor is either the gameplay tool (RTS, card, builder) or locked away during play \u2014 keyboard-only games included. Check it in every milestone's smoke pass.
|
|
274
|
+
18. v0 is a milestone, not the destination. When the ask was bigger than one loop, every milestone after v0 grows back toward the FULL ask with DESIGN.md's content lines as the checklist \u2014 a slice that previewed well never quietly becomes the game. Cosmetics never jump the queue past promised content.
|
|
278
275
|
${CONTRACT_END}
|
|
279
276
|
`;
|
|
280
277
|
var CLAUDE_IMPORT_LINE = "@AGENTS.md";
|
|
@@ -516,6 +513,11 @@ function isCacheFresh(cache, nowMs) {
|
|
|
516
513
|
const at = Date.parse(cache.checkedAt);
|
|
517
514
|
return Number.isFinite(at) && nowMs - at < CHECK_INTERVAL_MS;
|
|
518
515
|
}
|
|
516
|
+
function wasRecentlyNotified(cache, name, latest, nowMs) {
|
|
517
|
+
if (cache.notified?.[name] !== latest) return false;
|
|
518
|
+
const at = Date.parse(cache.notifiedAt ?? "");
|
|
519
|
+
return Number.isFinite(at) && nowMs - at < CHECK_INTERVAL_MS;
|
|
520
|
+
}
|
|
519
521
|
async function readUpdateCache() {
|
|
520
522
|
try {
|
|
521
523
|
const raw = await fs4.readFile(getUpdateCachePath(), "utf8");
|
|
@@ -551,7 +553,12 @@ function startUpdateCheck() {
|
|
|
551
553
|
results.forEach((r, i) => {
|
|
552
554
|
if (r.status === "fulfilled" && r.value) latest[PUBLISHED_PACKAGES[i].name] = r.value;
|
|
553
555
|
});
|
|
554
|
-
const next = {
|
|
556
|
+
const next = {
|
|
557
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
558
|
+
latest,
|
|
559
|
+
...cached?.notified ? { notified: cached.notified } : {},
|
|
560
|
+
...cached?.notifiedAt ? { notifiedAt: cached.notifiedAt } : {}
|
|
561
|
+
};
|
|
555
562
|
await fs4.mkdir(getGenexDir(), { recursive: true });
|
|
556
563
|
await fs4.writeFile(getUpdateCachePath(), JSON.stringify(next, null, 2) + "\n");
|
|
557
564
|
return next;
|
|
@@ -572,20 +579,24 @@ async function installedPackageVersion(cwd, name) {
|
|
|
572
579
|
function formatNudge(label, latest, installed, install) {
|
|
573
580
|
return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install}`;
|
|
574
581
|
}
|
|
575
|
-
async function reportUpdateNudges(check, log, cwd = process.cwd()) {
|
|
582
|
+
async function reportUpdateNudges(check, log, cwd = process.cwd(), cachePath = getUpdateCachePath()) {
|
|
576
583
|
try {
|
|
577
584
|
const cache = await check;
|
|
578
585
|
if (!cache) return;
|
|
586
|
+
const now = Date.now();
|
|
579
587
|
const lines = [];
|
|
588
|
+
const announced = {};
|
|
580
589
|
let whatsNew = null;
|
|
581
590
|
for (const pkg of PUBLISHED_PACKAGES) {
|
|
582
591
|
if (pkg.name === "@genex-ai/cli-demo" && CLI_CHANNEL === "dev") continue;
|
|
583
592
|
const latest = cache.latest[pkg.name];
|
|
584
593
|
if (!latest) continue;
|
|
594
|
+
if (wasRecentlyNotified(cache, pkg.name, latest, now)) continue;
|
|
585
595
|
const installed = pkg.name === "@genex-ai/cli-demo" ? getCliVersion() : await installedPackageVersion(cwd, pkg.name);
|
|
586
596
|
if (!installed) continue;
|
|
587
597
|
if (isNewerVersion(latest, installed)) {
|
|
588
598
|
lines.push(formatNudge(pkg.label, latest, installed, pkg.install));
|
|
599
|
+
announced[pkg.name] = latest;
|
|
589
600
|
whatsNew ??= `https://www.npmjs.com/package/${pkg.name}`;
|
|
590
601
|
}
|
|
591
602
|
}
|
|
@@ -594,6 +605,19 @@ async function reportUpdateNudges(check, log, cwd = process.cwd()) {
|
|
|
594
605
|
log.dim(
|
|
595
606
|
` Apply at a safe moment (never mid-task) \u2014 see the genex-updates skill. What's new: ${whatsNew}`
|
|
596
607
|
);
|
|
608
|
+
await recordNotified(cache, announced, cachePath);
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function recordNotified(cache, announced, cachePath) {
|
|
613
|
+
try {
|
|
614
|
+
const next = {
|
|
615
|
+
...cache,
|
|
616
|
+
notified: { ...cache.notified ?? {}, ...announced },
|
|
617
|
+
notifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
618
|
+
};
|
|
619
|
+
await fs4.mkdir(path4.dirname(cachePath), { recursive: true });
|
|
620
|
+
await fs4.writeFile(cachePath, JSON.stringify(next, null, 2) + "\n");
|
|
597
621
|
} catch {
|
|
598
622
|
}
|
|
599
623
|
}
|
|
@@ -984,22 +1008,22 @@ async function createDraftProject(opts) {
|
|
|
984
1008
|
} catch (err) {
|
|
985
1009
|
log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
|
|
986
1010
|
log.dim(` ${String(err)}`);
|
|
987
|
-
return null;
|
|
1011
|
+
return { meta: null };
|
|
988
1012
|
}
|
|
989
1013
|
if (res.status === 409 && i === 0) continue;
|
|
990
1014
|
if (res.status === 401) {
|
|
991
1015
|
log.warn("Not authorized to create the project (token rejected).");
|
|
992
|
-
return null;
|
|
1016
|
+
return { meta: null, unauthorized: true };
|
|
993
1017
|
}
|
|
994
1018
|
if (!res.ok) {
|
|
995
1019
|
log.warn(`Couldn't create the project (HTTP ${res.status}).`);
|
|
996
|
-
return null;
|
|
1020
|
+
return { meta: null };
|
|
997
1021
|
}
|
|
998
1022
|
const data = await res.json().catch(() => null);
|
|
999
1023
|
const project = data?.project;
|
|
1000
1024
|
if (!project || !project.cloneUrl) {
|
|
1001
1025
|
log.warn("Project created, but the API response was unexpected.");
|
|
1002
|
-
return null;
|
|
1026
|
+
return { meta: null };
|
|
1003
1027
|
}
|
|
1004
1028
|
log.success(`Created project ${c.cyan(project.slug)}.`);
|
|
1005
1029
|
log.plain(
|
|
@@ -1008,19 +1032,21 @@ async function createDraftProject(opts) {
|
|
|
1008
1032
|
if (project.playUrl) log.dim(` play (after preview/publish): ${project.playUrl}`);
|
|
1009
1033
|
log.dim(` dashboard: ${dashboardUrl}/dashboard`);
|
|
1010
1034
|
return {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1035
|
+
meta: {
|
|
1036
|
+
id: project.id,
|
|
1037
|
+
slug: project.slug,
|
|
1038
|
+
cloneUrl: project.cloneUrl,
|
|
1039
|
+
apiUrl,
|
|
1040
|
+
playUrl: project.playUrl ?? void 0,
|
|
1041
|
+
status: "draft",
|
|
1042
|
+
// Derived client-side from the auth/web origin the CLI just authorized
|
|
1043
|
+
// against (respects --auth-url overrides) — no API contract change.
|
|
1044
|
+
dashboardOrigins: [new URL(dashboardUrl).origin]
|
|
1045
|
+
}
|
|
1020
1046
|
};
|
|
1021
1047
|
}
|
|
1022
1048
|
log.warn("Couldn't create a project with a unique name. Try `--name <unique>`.");
|
|
1023
|
-
return null;
|
|
1049
|
+
return { meta: null };
|
|
1024
1050
|
}
|
|
1025
1051
|
function randomSuffix() {
|
|
1026
1052
|
return crypto2.randomBytes(3).toString("hex");
|
|
@@ -1180,6 +1206,17 @@ async function writeUserToken(token, envPath) {
|
|
|
1180
1206
|
const { path: written } = await writeEnvVar(getGenexEnvPath(envPath), ENV_TOKEN_KEY, token);
|
|
1181
1207
|
return { path: written };
|
|
1182
1208
|
}
|
|
1209
|
+
async function rotateRejectedEnv(envPath) {
|
|
1210
|
+
const file = getGenexEnvPath(envPath);
|
|
1211
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1212
|
+
const aside = `${file}.rejected-${stamp}`;
|
|
1213
|
+
try {
|
|
1214
|
+
await fs7.rename(file, aside);
|
|
1215
|
+
return aside;
|
|
1216
|
+
} catch {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1183
1220
|
async function readUserToken(envPath) {
|
|
1184
1221
|
const fromGenex = await readTokenFromFile(getGenexEnvPath(envPath));
|
|
1185
1222
|
if (fromGenex) return fromGenex;
|
|
@@ -1359,6 +1396,7 @@ async function runInit(opts) {
|
|
|
1359
1396
|
}
|
|
1360
1397
|
const authBaseUrl = getAuthUrl(opts.authUrl);
|
|
1361
1398
|
let token = await readUserToken(opts.envPath);
|
|
1399
|
+
let savedToken = Boolean(token);
|
|
1362
1400
|
if (!token) {
|
|
1363
1401
|
try {
|
|
1364
1402
|
token = await authorize(authBaseUrl, {
|
|
@@ -1373,18 +1411,22 @@ async function runInit(opts) {
|
|
|
1373
1411
|
const { path: tokenPath } = await writeUserToken(token, opts.envPath);
|
|
1374
1412
|
log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
|
|
1375
1413
|
log.plain("");
|
|
1414
|
+
savedToken = false;
|
|
1376
1415
|
}
|
|
1377
1416
|
await writeGitignore(process.cwd(), log);
|
|
1378
1417
|
if (await writeAgentsContract(process.cwd())) {
|
|
1379
1418
|
log.dim(" wrote the Genex build contract into AGENTS.md (managed block) + CLAUDE.md stub");
|
|
1380
1419
|
}
|
|
1381
1420
|
const apiUrl = getApiUrl(opts.apiUrl);
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1421
|
+
const echoIdentity = async () => {
|
|
1422
|
+
const email = await fetchSignedInEmail(apiUrl, token);
|
|
1423
|
+
if (email) log.plain(` signed in as ${c.cyan(email)}`);
|
|
1424
|
+
};
|
|
1425
|
+
await echoIdentity();
|
|
1384
1426
|
const projectName = opts.name?.trim() || path9.basename(process.cwd());
|
|
1385
|
-
const
|
|
1427
|
+
const create = (bearer) => createDraftProject({
|
|
1386
1428
|
apiUrl,
|
|
1387
|
-
token,
|
|
1429
|
+
token: bearer,
|
|
1388
1430
|
name: projectName,
|
|
1389
1431
|
repoUrl: opts.repo?.trim() || void 0,
|
|
1390
1432
|
private: opts.private,
|
|
@@ -1392,6 +1434,28 @@ async function runInit(opts) {
|
|
|
1392
1434
|
dashboardUrl: authBaseUrl,
|
|
1393
1435
|
log
|
|
1394
1436
|
});
|
|
1437
|
+
let created = await create(token);
|
|
1438
|
+
if (!created.meta && created.unauthorized && savedToken) {
|
|
1439
|
+
log.plain("");
|
|
1440
|
+
log.warn("Your saved sign-in was rejected \u2014 reconnecting in the browser\u2026");
|
|
1441
|
+
const aside = await rotateRejectedEnv(opts.envPath);
|
|
1442
|
+
if (aside) log.dim(` moved the rejected credential to ${c.cyan(aside)}`);
|
|
1443
|
+
try {
|
|
1444
|
+
token = await authorize(authBaseUrl, {
|
|
1445
|
+
log,
|
|
1446
|
+
timeoutMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
|
|
1447
|
+
});
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
1450
|
+
log.dim("Your workspace files were installed. Re-run `genex init` to finish authorizing.");
|
|
1451
|
+
throw err;
|
|
1452
|
+
}
|
|
1453
|
+
const { path: tokenPath } = await writeUserToken(token, opts.envPath);
|
|
1454
|
+
log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)} (${ENV_TOKEN_KEY}).`);
|
|
1455
|
+
await echoIdentity();
|
|
1456
|
+
created = await create(token);
|
|
1457
|
+
}
|
|
1458
|
+
const meta = created.meta;
|
|
1395
1459
|
if (!meta) {
|
|
1396
1460
|
log.plain("");
|
|
1397
1461
|
log.warn("Setup finished, but creating your game failed \u2014 fix the error above and re-run `genex init`.");
|
|
@@ -1644,11 +1708,11 @@ function relTime(iso) {
|
|
|
1644
1708
|
}
|
|
1645
1709
|
|
|
1646
1710
|
// src/lib/deploy.ts
|
|
1647
|
-
import { spawn as
|
|
1711
|
+
import { spawn as spawn3 } from "child_process";
|
|
1648
1712
|
import crypto3 from "crypto";
|
|
1649
|
-
import
|
|
1713
|
+
import fs12 from "fs/promises";
|
|
1650
1714
|
import os4 from "os";
|
|
1651
|
-
import
|
|
1715
|
+
import path12 from "path";
|
|
1652
1716
|
|
|
1653
1717
|
// ../../packages/mobile-scan/src/image-dims.ts
|
|
1654
1718
|
function u32be(b, o) {
|
|
@@ -3735,2259 +3799,2153 @@ async function uiAudit(opts, log) {
|
|
|
3735
3799
|
if (opts.strict || hard.length > 0) process.exitCode = 1;
|
|
3736
3800
|
}
|
|
3737
3801
|
|
|
3738
|
-
// src/lib/
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3802
|
+
// src/lib/deploy.ts
|
|
3803
|
+
function run(cmd, args, env) {
|
|
3804
|
+
return new Promise((resolve) => {
|
|
3805
|
+
let child;
|
|
3806
|
+
try {
|
|
3807
|
+
child = spawn3(cmd, args, { env: env ? { ...process.env, ...env } : process.env });
|
|
3808
|
+
} catch {
|
|
3809
|
+
resolve({ code: -1, out: "", err: `${cmd} not found` });
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
let out = "";
|
|
3813
|
+
let err = "";
|
|
3814
|
+
child.stdout?.on("data", (d) => out += String(d));
|
|
3815
|
+
child.stderr?.on("data", (d) => err += String(d));
|
|
3816
|
+
child.on("error", () => resolve({ code: -1, out, err: `${cmd} not found` }));
|
|
3817
|
+
child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
|
|
3818
|
+
});
|
|
3819
|
+
}
|
|
3820
|
+
function printMobilePreflight(files, log) {
|
|
3743
3821
|
try {
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3822
|
+
const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
|
|
3823
|
+
const externalDims = /* @__PURE__ */ new Map();
|
|
3824
|
+
for (const url of scan.genexAssetUrls) {
|
|
3825
|
+
const role = url.split("/").pop() ?? "";
|
|
3826
|
+
if (scan.markers.adaptiveQuality && role === SKYBOX_ROLE) {
|
|
3827
|
+
externalDims.set(url, { width: 2048, height: 1024 });
|
|
3828
|
+
} else if (scan.markers.modelRungs && role.endsWith("-glb")) {
|
|
3829
|
+
externalDims.set(url, { width: 1024, height: 2048, glb: true });
|
|
3830
|
+
} else {
|
|
3831
|
+
externalDims.set(url, null);
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
const est = estimateVram(scan, externalDims);
|
|
3835
|
+
const tier = tierFor(est.estVramMb);
|
|
3836
|
+
const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
|
|
3837
|
+
if (tier === "mobile-broad") {
|
|
3838
|
+
log.dim(` ${line}`);
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3841
|
+
log.warn(line);
|
|
3842
|
+
for (const url of est.unresolvedExternals) {
|
|
3843
|
+
const role = url.split("/").pop() ?? "";
|
|
3844
|
+
if (role === SKYBOX_ROLE) {
|
|
3845
|
+
log.warn(
|
|
3846
|
+
` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
|
|
3847
|
+
);
|
|
3848
|
+
} else if (role.endsWith("-glb")) {
|
|
3849
|
+
log.warn(
|
|
3850
|
+
` Model loaded without pickModel (${url.slice(0, 80)}\u2026) \u2014 phones fetch the full provider-raw GLB. Load it through loadModelWithFallback (genex-threejs-adaptive-quality) so phones get the @1024 rung.`
|
|
3851
|
+
);
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
if (est.textureMaxDim > 2048) {
|
|
3855
|
+
log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
|
|
3856
|
+
}
|
|
3857
|
+
if (scan.markers.postStack && scan.markers.dprCapLiteral == null) {
|
|
3858
|
+
log.warn(
|
|
3859
|
+
" Post stack detected with no devicePixelRatio cap \u2014 cap at 1.5 on phones (genex-threejs-adaptive-quality)."
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
if (!scan.markers.contextLossHandler) {
|
|
3863
|
+
log.warn(" No context-loss handler found \u2014 one GPU reset shows a dead canvas on phones.");
|
|
3864
|
+
}
|
|
3747
3865
|
} catch {
|
|
3748
3866
|
}
|
|
3749
3867
|
}
|
|
3750
|
-
async function
|
|
3751
|
-
let raw;
|
|
3868
|
+
async function printUiAuditPreflight(log) {
|
|
3752
3869
|
try {
|
|
3753
|
-
|
|
3870
|
+
const findings = await collectUiAuditFindings("public/assets/hud", "src");
|
|
3871
|
+
if (!findings || findings.length === 0) return;
|
|
3872
|
+
log.warn(
|
|
3873
|
+
`HUD wiring audit: ${findings.length} finding(s) \u2014 heuristics, check each (\`npx genex ui audit\` for the full report):`
|
|
3874
|
+
);
|
|
3875
|
+
for (const finding of findings.slice(0, 6)) log.warn(` [${finding.kind}] ${finding.message}`);
|
|
3876
|
+
if (findings.length > 6) log.dim(` \u2026and ${findings.length - 6} more.`);
|
|
3754
3877
|
} catch {
|
|
3755
|
-
return [];
|
|
3756
3878
|
}
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
} else if (e.t === "done" && typeof e.id === "string") {
|
|
3774
|
-
const entry = entries.get(e.id);
|
|
3775
|
-
if (entry && (e.status === "completed" || e.status === "failed")) {
|
|
3776
|
-
entry.status = e.status;
|
|
3777
|
-
if (Array.isArray(e.urls)) entry.urls = e.urls.filter((u) => typeof u === "string");
|
|
3778
|
-
}
|
|
3879
|
+
}
|
|
3880
|
+
async function printPipelineStatePreflight(log, cwd = process.cwd()) {
|
|
3881
|
+
try {
|
|
3882
|
+
const design = await fs12.readFile(path12.join(cwd, "DESIGN.md"), "utf8").catch(() => "");
|
|
3883
|
+
const warnings = [];
|
|
3884
|
+
if (design.length > 0 && !/##\s*build plan & status/i.test(design)) {
|
|
3885
|
+
warnings.push(
|
|
3886
|
+
"DESIGN.md has no `## Build plan & status` section \u2014 numbered milestones with a `Now:` line are how a compacted or resumed session knows where the build is (genex-game-director's design contract has the shape)."
|
|
3887
|
+
);
|
|
3888
|
+
}
|
|
3889
|
+
if (!/player character:/i.test(design)) {
|
|
3890
|
+
const hasCharacter = await fs12.access(path12.join(cwd, "public", "assets", "meshy-character.json")).then(() => true, () => false);
|
|
3891
|
+
if (!hasCharacter && await loadsPlayerBody(cwd)) {
|
|
3892
|
+
warnings.push(
|
|
3893
|
+
`Player character is the stock avatar \u2014 no generated character is wired. The game's own generated character is the player's body wherever a human body appears on screen, first-person included (genex-ai-character). Generate it, or record "Player character: VRM \u2014 <reason>" in DESIGN.md (no human body in this game / out of credits / player declined).`
|
|
3894
|
+
);
|
|
3779
3895
|
}
|
|
3780
|
-
} catch {
|
|
3781
3896
|
}
|
|
3897
|
+
for (const w of warnings) log.warn(w);
|
|
3898
|
+
} catch {
|
|
3782
3899
|
}
|
|
3783
|
-
return [...entries.values()];
|
|
3784
|
-
}
|
|
3785
|
-
async function recordQueued(id, kind, prompt, cwd = process.cwd()) {
|
|
3786
|
-
await append(cwd, {
|
|
3787
|
-
t: "q",
|
|
3788
|
-
id,
|
|
3789
|
-
kind,
|
|
3790
|
-
prompt: prompt.slice(0, 120),
|
|
3791
|
-
...prompt.length > 120 ? { tail: prompt.slice(-120) } : {},
|
|
3792
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3793
|
-
});
|
|
3794
|
-
}
|
|
3795
|
-
async function recordTerminal(id, status, urls, cwd = process.cwd()) {
|
|
3796
|
-
const existing = (await readLedger(cwd)).find((e) => e.id === id);
|
|
3797
|
-
if (existing && existing.status === status) return;
|
|
3798
|
-
await append(cwd, { t: "done", id, status, ...urls?.length ? { urls } : {} });
|
|
3799
3900
|
}
|
|
3800
|
-
async function
|
|
3801
|
-
|
|
3901
|
+
async function loadsPlayerBody(cwd) {
|
|
3902
|
+
const BODY_LOADERS = /\bloadPlayerCharacter\s*\(|\bloadVrm(?:Clone)?\s*\(/;
|
|
3903
|
+
try {
|
|
3904
|
+
const entries = await fs12.readdir(path12.join(cwd, "src"), { recursive: true });
|
|
3905
|
+
for (const rel of entries) {
|
|
3906
|
+
if (rel.includes("node_modules")) continue;
|
|
3907
|
+
if (rel.split(path12.sep)[0] === "controllers" || rel.startsWith("controllers/")) continue;
|
|
3908
|
+
if (!/\.(ts|tsx|js|jsx)$/.test(rel)) continue;
|
|
3909
|
+
const text = await fs12.readFile(path12.join(cwd, "src", rel), "utf8").catch(() => "");
|
|
3910
|
+
if (BODY_LOADERS.test(text)) return true;
|
|
3911
|
+
}
|
|
3912
|
+
} catch {
|
|
3913
|
+
}
|
|
3914
|
+
return false;
|
|
3802
3915
|
}
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
import path13 from "path";
|
|
3807
|
-
import { PNG as PNG4 } from "pngjs";
|
|
3808
|
-
|
|
3809
|
-
// src/lib/glass.ts
|
|
3810
|
-
import { PNG as PNG3 } from "pngjs";
|
|
3811
|
-
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.";
|
|
3812
|
-
var GLASS_MIN_FLATNESS = 0.99;
|
|
3813
|
-
var GLASS_MIN_KEY_STRENGTH = 180;
|
|
3814
|
-
var FLATNESS_DEV_MAX = 12;
|
|
3815
|
-
var clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
|
3816
|
-
function median(values) {
|
|
3817
|
-
const s = values.slice().sort((a, b) => a - b);
|
|
3818
|
-
return s[Math.floor(s.length / 2)] ?? 0;
|
|
3916
|
+
var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
|
|
3917
|
+
function isSecretEnvFile(name) {
|
|
3918
|
+
return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
|
|
3819
3919
|
}
|
|
3820
|
-
function
|
|
3821
|
-
const
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
if (!inRing) continue;
|
|
3831
|
-
const i = (y * W + x) * 4;
|
|
3832
|
-
ringIdx.push(i);
|
|
3833
|
-
rs.push(data[i]);
|
|
3834
|
-
gs.push(data[i + 1]);
|
|
3835
|
-
bs.push(data[i + 2]);
|
|
3920
|
+
async function deployGame(ctx, opts, log) {
|
|
3921
|
+
const cwd = process.cwd();
|
|
3922
|
+
if (!opts.noBuild && await hasBuildScript(cwd)) {
|
|
3923
|
+
log.step("Building the production bundle\u2026");
|
|
3924
|
+
const built = await run("npm", ["run", "build"]);
|
|
3925
|
+
if (built.code !== 0) {
|
|
3926
|
+
log.error("Build failed \u2014 your game was NOT deployed.");
|
|
3927
|
+
const tail = (built.err || built.out).trim().split("\n").slice(-12).join("\n");
|
|
3928
|
+
if (tail) log.dim(tail);
|
|
3929
|
+
return false;
|
|
3836
3930
|
}
|
|
3931
|
+
log.success("Built.");
|
|
3837
3932
|
}
|
|
3838
|
-
const
|
|
3839
|
-
const
|
|
3840
|
-
const
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
Math.abs(data[i + 2] - K.b)
|
|
3847
|
-
);
|
|
3848
|
-
if (dev < FLATNESS_DEV_MAX) flat++;
|
|
3933
|
+
const distDir = path12.join(cwd, "dist");
|
|
3934
|
+
const siteDir = await isDir2(distDir) ? distDir : cwd;
|
|
3935
|
+
const rel = path12.relative(cwd, siteDir) || ".";
|
|
3936
|
+
if (siteDir === cwd) await writeGitignore(cwd, log);
|
|
3937
|
+
const files = await collectFiles(siteDir);
|
|
3938
|
+
if (files.length === 0) {
|
|
3939
|
+
log.warn("Nothing to deploy \u2014 the build produced no files.");
|
|
3940
|
+
return false;
|
|
3849
3941
|
}
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
return {
|
|
3854
|
-
...base,
|
|
3855
|
-
png: source,
|
|
3856
|
-
ok: false,
|
|
3857
|
-
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.`
|
|
3858
|
-
};
|
|
3942
|
+
if (!files.some((f) => f.relPath === "index.html")) {
|
|
3943
|
+
log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
|
|
3944
|
+
return false;
|
|
3859
3945
|
}
|
|
3860
|
-
const
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
const
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
if (a > 1) a = 1;
|
|
3871
|
-
if (a <= 0.02) {
|
|
3872
|
-
out.data[i] = 0;
|
|
3873
|
-
out.data[i + 1] = 0;
|
|
3874
|
-
out.data[i + 2] = 0;
|
|
3875
|
-
out.data[i + 3] = 0;
|
|
3876
|
-
continue;
|
|
3877
|
-
}
|
|
3878
|
-
out.data[i] = clamp255((R - (1 - a) * K.r) / a);
|
|
3879
|
-
out.data[i + 1] = clamp255((G - (1 - a) * K.g) / a);
|
|
3880
|
-
out.data[i + 2] = clamp255((B - (1 - a) * K.b) / a);
|
|
3881
|
-
out.data[i + 3] = Math.round(a * 255);
|
|
3946
|
+
const lfsPointers = files.filter(
|
|
3947
|
+
(f) => f.bytes.length < 1024 && f.bytes.subarray(0, 64).toString("latin1").startsWith("version https://git-lfs.github.com/spec/v")
|
|
3948
|
+
);
|
|
3949
|
+
if (lfsPointers.length > 0) {
|
|
3950
|
+
log.error(
|
|
3951
|
+
`${lfsPointers.length} file${lfsPointers.length > 1 ? "s" : ""} in ${rel} ${lfsPointers.length > 1 ? "are" : "is"} an unresolved Git LFS pointer, not the real asset \u2014 the game would fail to load at runtime. Run \`git lfs pull\` and rebuild. Nothing deployed.`
|
|
3952
|
+
);
|
|
3953
|
+
for (const p of lfsPointers.slice(0, 8)) log.dim(` ${p.relPath}`);
|
|
3954
|
+
if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
|
|
3955
|
+
return false;
|
|
3882
3956
|
}
|
|
3883
|
-
|
|
3957
|
+
printMobilePreflight(files, log);
|
|
3958
|
+
await printUiAuditPreflight(log);
|
|
3959
|
+
await printPipelineStatePreflight(log);
|
|
3960
|
+
const commit = contentCommit(files);
|
|
3961
|
+
log.step("Requesting an upload token\u2026");
|
|
3962
|
+
const grant = await getUploadToken(ctx, commit, log);
|
|
3963
|
+
if (!grant) return false;
|
|
3964
|
+
const totalBytes = files.reduce((n, f) => n + f.bytes.length, 0);
|
|
3965
|
+
const mb = (n) => (n / 1048576).toFixed(n < 1048576 ? 2 : 1);
|
|
3966
|
+
if (files.length > grant.limits.maxFiles) {
|
|
3967
|
+
log.error(`Too many files (${files.length} > ${grant.limits.maxFiles}). Trim the build and retry.`);
|
|
3968
|
+
return false;
|
|
3969
|
+
}
|
|
3970
|
+
const oversized = files.find((f) => f.bytes.length > grant.limits.maxFileBytes);
|
|
3971
|
+
if (oversized) {
|
|
3972
|
+
log.error(`${oversized.relPath} is ${mb(oversized.bytes.length)} MB \u2014 over the ${mb(grant.limits.maxFileBytes)} MB per-file limit.`);
|
|
3973
|
+
return false;
|
|
3974
|
+
}
|
|
3975
|
+
if (totalBytes > grant.limits.maxTotalBytes) {
|
|
3976
|
+
log.error(`Build is ${mb(totalBytes)} MB \u2014 over the ${mb(grant.limits.maxTotalBytes)} MB limit.`);
|
|
3977
|
+
return false;
|
|
3978
|
+
}
|
|
3979
|
+
log.step(`Uploading ${files.length} files\u2026`);
|
|
3980
|
+
if (!await uploadAll(grant, files, log)) {
|
|
3981
|
+
log.error("Couldn't upload your game \u2014 please try again.");
|
|
3982
|
+
return false;
|
|
3983
|
+
}
|
|
3984
|
+
if (!await pushSource(cwd, ctx, log)) return false;
|
|
3985
|
+
log.step("Publishing\u2026");
|
|
3986
|
+
if (!await callPublish(ctx, commit, opts, log)) return false;
|
|
3987
|
+
const index = files.find((f) => f.relPath === "index.html");
|
|
3988
|
+
await waitUntilLive(grant.playUrl, fingerprintOf(index.bytes.toString("utf8")), opts.liveTimeoutMs ?? 2e4, log);
|
|
3989
|
+
return true;
|
|
3884
3990
|
}
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3991
|
+
async function hasBuildScript(cwd) {
|
|
3992
|
+
try {
|
|
3993
|
+
const pkg = JSON.parse(await fs12.readFile(path12.join(cwd, "package.json"), "utf8"));
|
|
3994
|
+
return Boolean(pkg.scripts?.build);
|
|
3995
|
+
} catch {
|
|
3996
|
+
return false;
|
|
3997
|
+
}
|
|
3890
3998
|
}
|
|
3891
|
-
function
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
case "darwin":
|
|
3902
|
-
command = "open";
|
|
3903
|
-
args = [url];
|
|
3904
|
-
break;
|
|
3905
|
-
case "win32":
|
|
3906
|
-
command = "cmd";
|
|
3907
|
-
args = ["/c", "start", "", url];
|
|
3908
|
-
break;
|
|
3909
|
-
default:
|
|
3910
|
-
command = "xdg-open";
|
|
3911
|
-
args = [url];
|
|
3912
|
-
break;
|
|
3999
|
+
async function collectFiles(root) {
|
|
4000
|
+
const out = [];
|
|
4001
|
+
const walk2 = async (dir, prefix) => {
|
|
4002
|
+
for (const e of await fs12.readdir(dir, { withFileTypes: true })) {
|
|
4003
|
+
const relPath = prefix ? `${prefix}/${e.name}` : e.name;
|
|
4004
|
+
if (e.isDirectory()) {
|
|
4005
|
+
if (!EXCLUDE_DIRS.has(e.name)) await walk2(path12.join(dir, e.name), relPath);
|
|
4006
|
+
} else if (e.isFile() && !isSecretEnvFile(e.name)) {
|
|
4007
|
+
out.push({ relPath, bytes: await fs12.readFile(path12.join(dir, e.name)) });
|
|
4008
|
+
}
|
|
3913
4009
|
}
|
|
4010
|
+
};
|
|
4011
|
+
await walk2(root, "");
|
|
4012
|
+
return out;
|
|
4013
|
+
}
|
|
4014
|
+
function contentCommit(files) {
|
|
4015
|
+
const h = crypto3.createHash("sha1");
|
|
4016
|
+
for (const f of [...files].sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)) {
|
|
4017
|
+
h.update(f.relPath, "utf8");
|
|
4018
|
+
h.update("\0");
|
|
4019
|
+
h.update(f.bytes);
|
|
3914
4020
|
}
|
|
4021
|
+
return h.digest("hex");
|
|
4022
|
+
}
|
|
4023
|
+
function retryAfterHint(res) {
|
|
4024
|
+
const raw = Number(res.headers.get("retry-after"));
|
|
4025
|
+
if (!Number.isFinite(raw) || raw <= 0) return "";
|
|
4026
|
+
const secs = Math.ceil(raw);
|
|
4027
|
+
return ` Try again in ${secs < 90 ? `${secs}s` : `~${Math.ceil(secs / 60)} min`}.`;
|
|
4028
|
+
}
|
|
4029
|
+
async function getUploadToken(ctx, commit, log) {
|
|
4030
|
+
let res;
|
|
3915
4031
|
try {
|
|
3916
|
-
|
|
3917
|
-
|
|
4032
|
+
res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
|
|
4033
|
+
method: "POST",
|
|
4034
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
|
|
4035
|
+
body: JSON.stringify({ commit })
|
|
3918
4036
|
});
|
|
3919
|
-
|
|
3920
|
-
|
|
4037
|
+
} catch (err) {
|
|
4038
|
+
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
4039
|
+
return null;
|
|
4040
|
+
}
|
|
4041
|
+
if (res.status === 401) {
|
|
4042
|
+
log.error("Not authorized (HTTP 401) \u2014 your sign-in expired or was revoked.");
|
|
4043
|
+
log.plain(" Fix: run `genex link <your-game>` and sign in as the game's owner, then re-run this command.");
|
|
4044
|
+
return null;
|
|
4045
|
+
}
|
|
4046
|
+
if (res.status === 404) {
|
|
4047
|
+
log.error("This account doesn't own this game (HTTP 404) \u2014 you're signed in as a different account.");
|
|
4048
|
+
log.plain(" Fix: run `genex link <your-game>` and sign in as the OWNER account, then re-run this command.");
|
|
4049
|
+
return null;
|
|
4050
|
+
}
|
|
4051
|
+
if (res.status === 429) {
|
|
4052
|
+
log.error(`Rate limited requesting an upload token (HTTP 429).${retryAfterHint(res)}`);
|
|
4053
|
+
return null;
|
|
4054
|
+
}
|
|
4055
|
+
if (!res.ok) {
|
|
4056
|
+
log.error(`Couldn't get an upload token (HTTP ${res.status}).`);
|
|
4057
|
+
return null;
|
|
3921
4058
|
}
|
|
4059
|
+
return await res.json();
|
|
3922
4060
|
}
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
const
|
|
3927
|
-
const
|
|
3928
|
-
let
|
|
3929
|
-
let
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
}
|
|
3949
|
-
if (line.startsWith(":")) continue;
|
|
3950
|
-
const colon = line.indexOf(":");
|
|
3951
|
-
const field = colon === -1 ? line : line.slice(0, colon);
|
|
3952
|
-
let value2 = colon === -1 ? "" : line.slice(colon + 1);
|
|
3953
|
-
if (value2.startsWith(" ")) value2 = value2.slice(1);
|
|
3954
|
-
if (field === "event") event = value2;
|
|
3955
|
-
else if (field === "data") data.push(value2);
|
|
4061
|
+
async function uploadAll(grant, files, log) {
|
|
4062
|
+
const base = grant.uploadUrl.replace(/\/+$/, "");
|
|
4063
|
+
const threshold = grant.limits.singlePutMaxBytes ?? grant.limits.maxFileBytes;
|
|
4064
|
+
const small = files.filter((f) => f.bytes.length <= threshold);
|
|
4065
|
+
const large = files.filter((f) => f.bytes.length > threshold);
|
|
4066
|
+
let failed = false;
|
|
4067
|
+
let next = 0;
|
|
4068
|
+
const worker = async () => {
|
|
4069
|
+
while (!failed) {
|
|
4070
|
+
const i = next++;
|
|
4071
|
+
if (i >= small.length) return;
|
|
4072
|
+
const f = small[i];
|
|
4073
|
+
const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
|
|
4074
|
+
let res = null;
|
|
4075
|
+
try {
|
|
4076
|
+
res = await fetch(`${base}/${encoded}`, {
|
|
4077
|
+
method: "PUT",
|
|
4078
|
+
headers: { Authorization: `Bearer ${grant.token}` },
|
|
4079
|
+
body: f.bytes
|
|
4080
|
+
});
|
|
4081
|
+
} catch {
|
|
4082
|
+
res = null;
|
|
4083
|
+
}
|
|
4084
|
+
if (!res || !res.ok) {
|
|
4085
|
+
failed = true;
|
|
4086
|
+
log.warn(`Upload failed for ${f.relPath}${res ? ` (HTTP ${res.status})` : " (network error)"}.`);
|
|
3956
4087
|
}
|
|
3957
4088
|
}
|
|
3958
|
-
}
|
|
3959
|
-
|
|
4089
|
+
};
|
|
4090
|
+
await Promise.all(Array.from({ length: Math.min(8, small.length) }, () => worker()));
|
|
4091
|
+
if (failed) return false;
|
|
4092
|
+
for (const f of large) {
|
|
4093
|
+
if (!await uploadMultipart(base, grant.token, f, grant.limits.partSizeBytes, log)) return false;
|
|
3960
4094
|
}
|
|
4095
|
+
return true;
|
|
3961
4096
|
}
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
".jpg": "image/jpeg",
|
|
3969
|
-
".jpeg": "image/jpeg",
|
|
3970
|
-
".webp": "image/webp"
|
|
3971
|
-
};
|
|
3972
|
-
var isRemoteRef = (value) => /^(https?:\/\/|data:)/i.test(value);
|
|
3973
|
-
async function inlineLocalImage(filePath, flag) {
|
|
3974
|
-
let bytes;
|
|
4097
|
+
async function uploadMultipart(base, uploadToken, f, partSize, log) {
|
|
4098
|
+
const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
|
|
4099
|
+
const auth = { Authorization: `Bearer ${uploadToken}` };
|
|
4100
|
+
const totalParts = Math.ceil(f.bytes.length / partSize);
|
|
4101
|
+
log.step(`Uploading ${f.relPath} (${(f.bytes.length / 1048576).toFixed(1)} MB in ${totalParts} parts)\u2026`);
|
|
4102
|
+
let uploadId;
|
|
3975
4103
|
try {
|
|
3976
|
-
|
|
4104
|
+
const res = await fetch(`${base}/${encoded}?action=mpu-create`, { method: "POST", headers: auth });
|
|
4105
|
+
if (!res.ok) {
|
|
4106
|
+
log.warn(`Upload failed for ${f.relPath} (HTTP ${res.status} starting multipart).`);
|
|
4107
|
+
return false;
|
|
4108
|
+
}
|
|
4109
|
+
uploadId = (await res.json()).uploadId;
|
|
3977
4110
|
} catch {
|
|
3978
|
-
|
|
4111
|
+
log.warn(`Upload failed for ${f.relPath} (network error starting multipart).`);
|
|
4112
|
+
return false;
|
|
3979
4113
|
}
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
|
|
3988
|
-
}
|
|
3989
|
-
var SKYBOX_ENVIRONMENT_SUFFIX = ". The image contains ONLY sky: cloud, atmosphere, light, weather and distant haze at the horizon. Every structure, object, plant and ground surface is outside the frame.";
|
|
3990
|
-
var SKYBOX_PROMPT_MAX = 2e3;
|
|
3991
|
-
var SKYBOX_STRUCTURE_NOUNS = [
|
|
3992
|
-
"building",
|
|
3993
|
-
"buildings",
|
|
3994
|
-
"house",
|
|
3995
|
-
"houses",
|
|
3996
|
-
"hut",
|
|
3997
|
-
"huts",
|
|
3998
|
-
"cabin",
|
|
3999
|
-
"cabins",
|
|
4000
|
-
"tower",
|
|
4001
|
-
"towers",
|
|
4002
|
-
"castle",
|
|
4003
|
-
"castles",
|
|
4004
|
-
"cathedral",
|
|
4005
|
-
"cathedrals",
|
|
4006
|
-
"church",
|
|
4007
|
-
"churches",
|
|
4008
|
-
"temple",
|
|
4009
|
-
"temples",
|
|
4010
|
-
"shrine",
|
|
4011
|
-
"shrines",
|
|
4012
|
-
"monastery",
|
|
4013
|
-
"palace",
|
|
4014
|
-
"fortress",
|
|
4015
|
-
"citadel",
|
|
4016
|
-
"ruin",
|
|
4017
|
-
"ruins",
|
|
4018
|
-
"city",
|
|
4019
|
-
"cityscape",
|
|
4020
|
-
"skyline",
|
|
4021
|
-
"town",
|
|
4022
|
-
"village",
|
|
4023
|
-
"settlement",
|
|
4024
|
-
"street",
|
|
4025
|
-
"streets",
|
|
4026
|
-
"road",
|
|
4027
|
-
"roads",
|
|
4028
|
-
"courtyard",
|
|
4029
|
-
"plaza",
|
|
4030
|
-
"bridge",
|
|
4031
|
-
"bridges",
|
|
4032
|
-
"wall",
|
|
4033
|
-
"walls",
|
|
4034
|
-
"gate",
|
|
4035
|
-
"gates",
|
|
4036
|
-
"fence",
|
|
4037
|
-
"fences",
|
|
4038
|
-
"pillar",
|
|
4039
|
-
"pillars",
|
|
4040
|
-
"column",
|
|
4041
|
-
"columns",
|
|
4042
|
-
"statue",
|
|
4043
|
-
"statues",
|
|
4044
|
-
"monument",
|
|
4045
|
-
"obelisk",
|
|
4046
|
-
"spire",
|
|
4047
|
-
"spires",
|
|
4048
|
-
"rooftop",
|
|
4049
|
-
"rooftops",
|
|
4050
|
-
"tree",
|
|
4051
|
-
"trees",
|
|
4052
|
-
"forest",
|
|
4053
|
-
"jungle",
|
|
4054
|
-
"ship",
|
|
4055
|
-
"ships",
|
|
4056
|
-
"boat",
|
|
4057
|
-
"boats",
|
|
4058
|
-
"car",
|
|
4059
|
-
"cars",
|
|
4060
|
-
"vehicle",
|
|
4061
|
-
"vehicles",
|
|
4062
|
-
"person",
|
|
4063
|
-
"people",
|
|
4064
|
-
"figure",
|
|
4065
|
-
"figures",
|
|
4066
|
-
"character",
|
|
4067
|
-
"characters",
|
|
4068
|
-
"creature",
|
|
4069
|
-
"creatures",
|
|
4070
|
-
"dragon",
|
|
4071
|
-
"banner",
|
|
4072
|
-
"banners",
|
|
4073
|
-
"flag",
|
|
4074
|
-
"flags",
|
|
4075
|
-
"torch",
|
|
4076
|
-
"torches",
|
|
4077
|
-
"lamp",
|
|
4078
|
-
"lamps"
|
|
4079
|
-
];
|
|
4080
|
-
var SKYBOX_NOUN_RE = new RegExp(`\\b(${SKYBOX_STRUCTURE_NOUNS.join("|")})\\b`, "gi");
|
|
4081
|
-
function skyboxStructureNouns(prompt) {
|
|
4082
|
-
const found = /* @__PURE__ */ new Set();
|
|
4083
|
-
for (const m of prompt.matchAll(SKYBOX_NOUN_RE)) found.add(m[1].toLowerCase());
|
|
4084
|
-
return [...found];
|
|
4085
|
-
}
|
|
4086
|
-
function applySkyboxGuard(prompt, raw) {
|
|
4087
|
-
if (raw) return { prompt, guarded: false, nouns: [] };
|
|
4088
|
-
const nouns = skyboxStructureNouns(prompt);
|
|
4089
|
-
const guarded = `${prompt}${SKYBOX_ENVIRONMENT_SUFFIX}`;
|
|
4090
|
-
if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false, nouns };
|
|
4091
|
-
return { prompt: guarded, guarded: true, nouns };
|
|
4092
|
-
}
|
|
4093
|
-
function buildGenOptions(kind, opts) {
|
|
4094
|
-
const options = { ...opts.generationOptions };
|
|
4095
|
-
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
4096
|
-
if ((kind === "sfx" || kind === "music") && opts.duration) options.durationSeconds = opts.duration;
|
|
4097
|
-
if (kind === "voice") {
|
|
4098
|
-
if (opts.voice) options.voice = opts.voice;
|
|
4099
|
-
if (opts.voiceId) options.voiceId = opts.voiceId;
|
|
4100
|
-
}
|
|
4101
|
-
if (kind === "image") {
|
|
4102
|
-
if (opts.transparent) options.transparent = true;
|
|
4103
|
-
if (opts.aspect) options.aspect = opts.aspect;
|
|
4104
|
-
if (opts.quality) options.quality = opts.quality;
|
|
4105
|
-
if (opts.candidates !== void 0 && opts.candidates > 1) options.candidates = opts.candidates;
|
|
4106
|
-
if (opts.width && opts.height) {
|
|
4107
|
-
options.width = opts.width;
|
|
4108
|
-
options.height = opts.height;
|
|
4109
|
-
}
|
|
4110
|
-
if (opts.editUrl) {
|
|
4111
|
-
options.sourceUrl = opts.editUrl;
|
|
4112
|
-
options.op = "edit";
|
|
4114
|
+
const abort = async () => {
|
|
4115
|
+
try {
|
|
4116
|
+
await fetch(`${base}/${encoded}?action=mpu-abort&uploadId=${encodeURIComponent(uploadId)}`, {
|
|
4117
|
+
method: "DELETE",
|
|
4118
|
+
headers: auth
|
|
4119
|
+
});
|
|
4120
|
+
} catch {
|
|
4113
4121
|
}
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4122
|
+
};
|
|
4123
|
+
const parts = [];
|
|
4124
|
+
let failed = false;
|
|
4125
|
+
let next = 0;
|
|
4126
|
+
const partWorker = async () => {
|
|
4127
|
+
while (!failed) {
|
|
4128
|
+
const i = next++;
|
|
4129
|
+
if (i >= totalParts) return;
|
|
4130
|
+
const partNumber = i + 1;
|
|
4131
|
+
const bytes = f.bytes.subarray(i * partSize, Math.min((i + 1) * partSize, f.bytes.length));
|
|
4132
|
+
const part = await uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, bytes);
|
|
4133
|
+
if (!part) {
|
|
4134
|
+
failed = true;
|
|
4135
|
+
log.warn(`Upload failed for ${f.relPath} (part ${partNumber}/${totalParts}).`);
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
4138
|
+
parts.push(part);
|
|
4118
4139
|
}
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4140
|
+
};
|
|
4141
|
+
await Promise.all(Array.from({ length: Math.min(4, totalParts) }, () => partWorker()));
|
|
4142
|
+
if (failed) {
|
|
4143
|
+
await abort();
|
|
4144
|
+
return false;
|
|
4145
|
+
}
|
|
4146
|
+
parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
4147
|
+
try {
|
|
4148
|
+
const res = await fetch(`${base}/${encoded}?action=mpu-complete&uploadId=${encodeURIComponent(uploadId)}`, {
|
|
4149
|
+
method: "POST",
|
|
4150
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
4151
|
+
body: JSON.stringify({ parts })
|
|
4152
|
+
});
|
|
4153
|
+
if (!res.ok) {
|
|
4154
|
+
log.warn(`Upload failed for ${f.relPath} (HTTP ${res.status} completing multipart).`);
|
|
4155
|
+
await abort();
|
|
4156
|
+
return false;
|
|
4122
4157
|
}
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4158
|
+
} catch {
|
|
4159
|
+
log.warn(`Upload failed for ${f.relPath} (network error completing multipart).`);
|
|
4160
|
+
await abort();
|
|
4161
|
+
return false;
|
|
4126
4162
|
}
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4163
|
+
return true;
|
|
4164
|
+
}
|
|
4165
|
+
async function uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, bytes) {
|
|
4166
|
+
for (let attempt = 0; ; attempt++) {
|
|
4167
|
+
let res = null;
|
|
4168
|
+
try {
|
|
4169
|
+
res = await fetch(
|
|
4170
|
+
`${base}/${encoded}?action=mpu-part&uploadId=${encodeURIComponent(uploadId)}&partNumber=${partNumber}`,
|
|
4171
|
+
{ method: "PUT", headers: auth, body: bytes }
|
|
4172
|
+
);
|
|
4173
|
+
if (res.ok) return await res.json();
|
|
4174
|
+
} catch {
|
|
4175
|
+
res = null;
|
|
4176
|
+
}
|
|
4177
|
+
if (res && res.status < 500) return null;
|
|
4178
|
+
if (attempt >= 2) return null;
|
|
4179
|
+
await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 500 : 2e3));
|
|
4134
4180
|
}
|
|
4135
|
-
return options;
|
|
4136
4181
|
}
|
|
4137
|
-
async function
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4182
|
+
async function callPublish(ctx, commit, opts, log) {
|
|
4183
|
+
let res;
|
|
4184
|
+
try {
|
|
4185
|
+
res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
|
|
4186
|
+
method: "POST",
|
|
4187
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
|
|
4188
|
+
// Detections ride every go-live (preview AND publish): matchmaking is
|
|
4189
|
+
// tri-state (object = upsert, null = clear); embedSdkVersion/multiplayer
|
|
4190
|
+
// are sent only when detected (absent = server keeps the stored value).
|
|
4191
|
+
body: JSON.stringify({
|
|
4192
|
+
commit,
|
|
4193
|
+
matchmaking: opts.matchmaking ?? null,
|
|
4194
|
+
...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
|
|
4195
|
+
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
|
|
4196
|
+
...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
|
|
4197
|
+
})
|
|
4198
|
+
});
|
|
4199
|
+
} catch (err) {
|
|
4200
|
+
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
4201
|
+
return false;
|
|
4144
4202
|
}
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
`A skybox is sky only \u2014 this prompt names ${skyboxGuard.nouns.join(", ")}.`
|
|
4149
|
-
);
|
|
4150
|
-
log.plain(
|
|
4151
|
-
[
|
|
4152
|
-
" Structures painted into a sky render at infinite distance: they never get closer",
|
|
4153
|
-
" as the player moves, and they read as broken the moment the camera strafes.",
|
|
4154
|
-
` ${c.cyan("\u2192")} Build them as scene geometry (${c.cyan("npx genex model")}); describe only light, colour,`,
|
|
4155
|
-
" cloud, weather and time of day here.",
|
|
4156
|
-
` ${c.cyan("\u2192")} Genuinely want it painted into the sky (a space-station panorama for a scene`,
|
|
4157
|
-
` with no world geometry)? Re-run with ${c.cyan("--raw")}.`
|
|
4158
|
-
].join("\n")
|
|
4159
|
-
);
|
|
4160
|
-
process.exitCode = 1;
|
|
4161
|
-
return;
|
|
4203
|
+
if (res.status === 429) {
|
|
4204
|
+
log.error(`Rate limited publishing (HTTP 429).${retryAfterHint(res)}`);
|
|
4205
|
+
return false;
|
|
4162
4206
|
}
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
log.error(
|
|
4168
|
-
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.`
|
|
4169
|
-
);
|
|
4170
|
-
process.exitCode = 1;
|
|
4171
|
-
return;
|
|
4172
|
-
}
|
|
4173
|
-
prompt = `${prompt}${GLASS_PROMPT_SUFFIX}`;
|
|
4207
|
+
if (!res.ok) {
|
|
4208
|
+
const detail = await res.text().catch(() => "");
|
|
4209
|
+
log.error(`Publish failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}.`);
|
|
4210
|
+
return false;
|
|
4174
4211
|
}
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4212
|
+
return true;
|
|
4213
|
+
}
|
|
4214
|
+
async function pushSource(cwd, ctx, log) {
|
|
4215
|
+
const target = await fetchPushUrl(ctx, log);
|
|
4216
|
+
if (!target) return false;
|
|
4217
|
+
if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
|
|
4218
|
+
if (!target.managed) return false;
|
|
4219
|
+
log.info("Retrying the source push\u2026");
|
|
4220
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
4221
|
+
const fresh = await fetchPushUrl(ctx, log);
|
|
4222
|
+
if (!fresh) return false;
|
|
4223
|
+
return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
|
|
4224
|
+
}
|
|
4225
|
+
function urlHasEmbeddedCredentials(pushUrl) {
|
|
4226
|
+
return /^[a-z][a-z0-9+.-]*:\/\/[^/@]+@/i.test(pushUrl);
|
|
4227
|
+
}
|
|
4228
|
+
async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
4229
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
4230
|
+
const failed = () => {
|
|
4231
|
+
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
4232
|
+
return false;
|
|
4233
|
+
};
|
|
4234
|
+
const gitDir = await fs12.mkdtemp(path12.join(os4.tmpdir(), "genex-source-"));
|
|
4235
|
+
const base = { GIT_DIR: gitDir };
|
|
4236
|
+
if (urlHasEmbeddedCredentials(pushUrl)) {
|
|
4237
|
+
base.GIT_CONFIG_COUNT = "1";
|
|
4238
|
+
base.GIT_CONFIG_KEY_0 = "credential.helper";
|
|
4239
|
+
base.GIT_CONFIG_VALUE_0 = "";
|
|
4240
|
+
}
|
|
4241
|
+
const ident = {
|
|
4242
|
+
GIT_AUTHOR_NAME: "genex",
|
|
4243
|
+
GIT_AUTHOR_EMAIL: "agent@genex.local",
|
|
4244
|
+
GIT_COMMITTER_NAME: "genex",
|
|
4245
|
+
GIT_COMMITTER_EMAIL: "agent@genex.local"
|
|
4246
|
+
};
|
|
4247
|
+
try {
|
|
4248
|
+
if ((await run("git", ["init", "-q"], base)).code !== 0) return failed();
|
|
4249
|
+
await fs12.writeFile(
|
|
4250
|
+
path12.join(gitDir, "info", "exclude"),
|
|
4251
|
+
// .env* are secrets — never publish them; `!` keeps the non-secret template.
|
|
4252
|
+
["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
|
|
4253
|
+
);
|
|
4254
|
+
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path12.join(gitDir, "index-source") };
|
|
4255
|
+
let lfs = (await run("git", ["lfs", "version"], base)).code !== 0 ? false : true;
|
|
4256
|
+
if (!lfs) {
|
|
4257
|
+
log.step("Installing git-lfs (keeps large binary assets out of the source push)\u2026");
|
|
4258
|
+
const [bin, args] = process.platform === "darwin" ? ["brew", ["install", "git-lfs"]] : ["sudo", ["-n", "apt-get", "install", "-y", "git-lfs"]];
|
|
4259
|
+
await run(bin, args, base);
|
|
4260
|
+
lfs = (await run("git", ["lfs", "version"], base)).code === 0;
|
|
4261
|
+
if (!lfs) {
|
|
4262
|
+
log.warn(
|
|
4263
|
+
"Couldn't install git-lfs \u2014 large binary assets won't be saved to your source (the game still publishes)."
|
|
4264
|
+
);
|
|
4265
|
+
}
|
|
4180
4266
|
}
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4267
|
+
if (lfs) {
|
|
4268
|
+
await writeGitattributes(cwd, log);
|
|
4269
|
+
const filters = [
|
|
4270
|
+
["filter.lfs.clean", "git-lfs clean -- %f"],
|
|
4271
|
+
["filter.lfs.smudge", "git-lfs smudge -- %f"],
|
|
4272
|
+
["filter.lfs.process", "git-lfs filter-process"],
|
|
4273
|
+
["filter.lfs.required", "true"]
|
|
4274
|
+
];
|
|
4275
|
+
for (const [key, value] of filters) await run("git", ["config", key, value], base);
|
|
4188
4276
|
}
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
process.exitCode = 1;
|
|
4195
|
-
return;
|
|
4277
|
+
await run("git", ["add", "-A"], env);
|
|
4278
|
+
const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
|
|
4279
|
+
if (!tree || tree === EMPTY_TREE) {
|
|
4280
|
+
log.error("Nothing to publish \u2014 the project has no files.");
|
|
4281
|
+
return false;
|
|
4196
4282
|
}
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
log.error(inlined.error);
|
|
4203
|
-
process.exitCode = 1;
|
|
4204
|
-
return;
|
|
4283
|
+
const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
|
|
4284
|
+
await run("git", ["update-ref", "refs/heads/main", commit], base);
|
|
4285
|
+
if (lfs && (await run("git", ["lfs", "push", "--all", pushUrl, "main"], base)).code !== 0) {
|
|
4286
|
+
log.error("Couldn't upload your game's binary assets to the source store.");
|
|
4287
|
+
return false;
|
|
4205
4288
|
}
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
`Voice text is ${prompt.length} chars \u2014 the server clamps at 1000 (billing follows the clamp). Split longer copy into separate lines.`
|
|
4289
|
+
const push = await run(
|
|
4290
|
+
"git",
|
|
4291
|
+
["-c", "http.postBuffer=536870912", "push", "-q", pushUrl, "+refs/heads/main:main"],
|
|
4292
|
+
base
|
|
4211
4293
|
);
|
|
4294
|
+
if (push.code === 0) return true;
|
|
4295
|
+
if (!managed) {
|
|
4296
|
+
log.error(
|
|
4297
|
+
"Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
|
|
4298
|
+
);
|
|
4299
|
+
return false;
|
|
4300
|
+
}
|
|
4301
|
+
return failed();
|
|
4302
|
+
} catch {
|
|
4303
|
+
return failed();
|
|
4304
|
+
} finally {
|
|
4305
|
+
await fs12.rm(gitDir, { recursive: true, force: true }).catch(() => {
|
|
4306
|
+
});
|
|
4212
4307
|
}
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
process.exitCode = 1;
|
|
4217
|
-
return;
|
|
4218
|
-
}
|
|
4219
|
-
const meta = await readProject();
|
|
4220
|
-
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
4221
|
-
const options = buildGenOptions(kind, opts);
|
|
4222
|
-
log.plain(c.bold(`genex ${kind}`));
|
|
4223
|
-
log.dim(` ${prompt}`);
|
|
4224
|
-
if (skyboxGuard?.guarded) log.dim(" \u21B3 environment-only guard applied (--raw to disable)");
|
|
4225
|
-
log.plain("");
|
|
4226
|
-
let id;
|
|
4308
|
+
}
|
|
4309
|
+
async function fetchPushUrl(ctx, log) {
|
|
4310
|
+
let res;
|
|
4227
4311
|
try {
|
|
4228
|
-
|
|
4312
|
+
res = await apiFetch(`${ctx.apiUrl}/api/projects/${ctx.projectId}/push-token`, {
|
|
4229
4313
|
method: "POST",
|
|
4230
|
-
headers: {
|
|
4231
|
-
body: JSON.stringify({ kind, prompt, options })
|
|
4314
|
+
headers: { Authorization: `Bearer ${ctx.token}` }
|
|
4232
4315
|
});
|
|
4233
|
-
if (res.status === 401) {
|
|
4234
|
-
log.error("Unauthorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
4235
|
-
process.exitCode = 1;
|
|
4236
|
-
return;
|
|
4237
|
-
}
|
|
4238
|
-
if (printedStructuredError(res)) {
|
|
4239
|
-
process.exitCode = 1;
|
|
4240
|
-
return;
|
|
4241
|
-
}
|
|
4242
|
-
if (!res.ok) {
|
|
4243
|
-
let message;
|
|
4244
|
-
if (res.status >= 400 && res.status < 500) {
|
|
4245
|
-
try {
|
|
4246
|
-
const body = await res.json();
|
|
4247
|
-
if (typeof body.message === "string" && body.message.trim()) message = body.message;
|
|
4248
|
-
} catch {
|
|
4249
|
-
}
|
|
4250
|
-
}
|
|
4251
|
-
log.error(message ?? `Generation request failed (HTTP ${res.status}).`);
|
|
4252
|
-
process.exitCode = 1;
|
|
4253
|
-
return;
|
|
4254
|
-
}
|
|
4255
|
-
({ id } = await res.json());
|
|
4256
4316
|
} catch (err) {
|
|
4257
|
-
log.error(`Couldn't reach the API
|
|
4258
|
-
|
|
4259
|
-
|
|
4317
|
+
log.error(`Couldn't reach the API to authorize the source push: ${String(err)}`);
|
|
4318
|
+
return null;
|
|
4319
|
+
}
|
|
4320
|
+
if (res.status === 401) {
|
|
4321
|
+
log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
4322
|
+
return null;
|
|
4260
4323
|
}
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
writeJson({
|
|
4265
|
-
kind,
|
|
4266
|
-
id,
|
|
4267
|
-
status: "pending",
|
|
4268
|
-
nextCommand: `genex wait ${id} --json`
|
|
4269
|
-
});
|
|
4270
|
-
} else {
|
|
4271
|
-
log.success(`Queued (${id}).`);
|
|
4272
|
-
log.plain(` Pick it up any time with: ${c.cyan(`npx genex wait ${id}`)}`);
|
|
4273
|
-
log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
|
|
4274
|
-
}
|
|
4275
|
-
return id;
|
|
4324
|
+
if (res.status === 429) {
|
|
4325
|
+
log.error(`Rate limited authorizing the source push (HTTP 429).${retryAfterHint(res)}`);
|
|
4326
|
+
return null;
|
|
4276
4327
|
}
|
|
4277
|
-
if (
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
apiUrl,
|
|
4281
|
-
token,
|
|
4282
|
-
id,
|
|
4283
|
-
kind,
|
|
4284
|
-
opts.json ? () => {
|
|
4285
|
-
} : (p) => log.dim(` ${p}%`)
|
|
4286
|
-
);
|
|
4287
|
-
if (!view) {
|
|
4288
|
-
if (opts.json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
|
|
4289
|
-
else log.error("Timed out waiting for the generation.");
|
|
4290
|
-
process.exitCode = 1;
|
|
4291
|
-
return id;
|
|
4292
|
-
}
|
|
4293
|
-
await reportGlassTerminal(view, opts.outDir ?? ".", log, opts.json ?? false);
|
|
4294
|
-
return id;
|
|
4328
|
+
if (!res.ok) {
|
|
4329
|
+
log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
|
|
4330
|
+
return null;
|
|
4295
4331
|
}
|
|
4296
|
-
|
|
4297
|
-
|
|
4332
|
+
const data = await res.json().catch(() => null);
|
|
4333
|
+
if (!data?.pushUrl) {
|
|
4334
|
+
log.error("The API didn't return a push URL.");
|
|
4335
|
+
return null;
|
|
4336
|
+
}
|
|
4337
|
+
return { pushUrl: data.pushUrl, managed: data.managed !== false };
|
|
4298
4338
|
}
|
|
4299
|
-
async function
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4339
|
+
async function isDir2(p) {
|
|
4340
|
+
try {
|
|
4341
|
+
return (await fs12.stat(p)).isDirectory();
|
|
4342
|
+
} catch {
|
|
4343
|
+
return false;
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4347
|
+
function fingerprintOf(html) {
|
|
4348
|
+
const m = html.match(/[A-Za-z0-9_]+-[A-Za-z0-9_-]{8}\.(?:js|css)/);
|
|
4349
|
+
return m ? m[0] : null;
|
|
4350
|
+
}
|
|
4351
|
+
async function waitUntilLive(playUrl, fingerprint, timeoutMs, log) {
|
|
4352
|
+
if (!fingerprint) {
|
|
4353
|
+
log.success(`Live. \u{1F310} ${playUrl}`);
|
|
4303
4354
|
return;
|
|
4304
4355
|
}
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
for (let i = 0; i < files.length; i++) {
|
|
4309
|
-
const f = files[i];
|
|
4310
|
-
let png;
|
|
4356
|
+
const deadline = Date.now() + timeoutMs;
|
|
4357
|
+
let n = 0;
|
|
4358
|
+
while (Date.now() < deadline) {
|
|
4311
4359
|
try {
|
|
4312
|
-
const res = await fetch(
|
|
4313
|
-
if (
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
url: f.url,
|
|
4319
|
-
keyHex: "",
|
|
4320
|
-
keyStrength: 0,
|
|
4321
|
-
flatness: 0,
|
|
4322
|
-
rejected: `couldn't fetch the candidate (${String(err)}) \u2014 re-run later; the URL stays live.`
|
|
4323
|
-
});
|
|
4324
|
-
continue;
|
|
4325
|
-
}
|
|
4326
|
-
const r = solveMagentaGlass(png);
|
|
4327
|
-
if (!r.ok) {
|
|
4328
|
-
solved.push({
|
|
4329
|
-
path: null,
|
|
4330
|
-
url: f.url,
|
|
4331
|
-
keyHex: r.keyHex,
|
|
4332
|
-
keyStrength: r.keyStrength,
|
|
4333
|
-
flatness: r.flatness,
|
|
4334
|
-
rejected: r.rejected
|
|
4335
|
-
});
|
|
4336
|
-
continue;
|
|
4360
|
+
const res = await fetch(`${playUrl}?_genexlive=${n++}`, { cache: "no-store" });
|
|
4361
|
+
if (res.ok && (await res.text()).includes(fingerprint)) {
|
|
4362
|
+
log.success(`Live. \u{1F310} ${playUrl}`);
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
} catch {
|
|
4337
4366
|
}
|
|
4338
|
-
|
|
4339
|
-
await fs13.writeFile(outPath, PNG4.sync.write(r.png));
|
|
4340
|
-
solved.push({
|
|
4341
|
-
path: outPath,
|
|
4342
|
-
url: f.url,
|
|
4343
|
-
keyHex: r.keyHex,
|
|
4344
|
-
keyStrength: r.keyStrength,
|
|
4345
|
-
flatness: r.flatness
|
|
4346
|
-
});
|
|
4347
|
-
}
|
|
4348
|
-
const shipped = solved.filter((s) => s.path !== null);
|
|
4349
|
-
if (json) {
|
|
4350
|
-
writeJson({ kind: "image", id: view.id, status: view.status, glass: solved });
|
|
4351
|
-
if (shipped.length === 0) process.exitCode = 1;
|
|
4352
|
-
return;
|
|
4367
|
+
await sleep(1500);
|
|
4353
4368
|
}
|
|
4369
|
+
log.success(`Published. \u{1F310} ${playUrl}`);
|
|
4370
|
+
log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
|
|
4371
|
+
}
|
|
4372
|
+
|
|
4373
|
+
// src/commands/make-remixable.ts
|
|
4374
|
+
async function runMakeRemixable(opts) {
|
|
4375
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
4376
|
+
log.plain(c.bold("genex make-remixable"));
|
|
4354
4377
|
log.plain("");
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
log.plain(` ${s.path} ${c.dim(`key ${s.keyHex}, strength ${s.keyStrength}, flatness ${s.flatness.toFixed(3)}`)}`);
|
|
4361
|
-
} else {
|
|
4362
|
-
log.error(`Candidate rejected: ${s.rejected}`);
|
|
4363
|
-
log.dim(` raw candidate stays at ${s.url}`);
|
|
4364
|
-
}
|
|
4378
|
+
const meta = await readProject();
|
|
4379
|
+
if (!meta) {
|
|
4380
|
+
log.error("No genex project here. Run `genex init` in this directory first.");
|
|
4381
|
+
process.exitCode = 1;
|
|
4382
|
+
return;
|
|
4365
4383
|
}
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
log.
|
|
4369
|
-
" 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."
|
|
4370
|
-
);
|
|
4371
|
-
} else {
|
|
4384
|
+
const token = await readUserToken(opts.envPath);
|
|
4385
|
+
if (!token) {
|
|
4386
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
4372
4387
|
process.exitCode = 1;
|
|
4388
|
+
return;
|
|
4373
4389
|
}
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
apiUrl
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
);
|
|
4385
|
-
if (!view) {
|
|
4386
|
-
if (json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
|
|
4387
|
-
else log.error("Timed out waiting for the generation.");
|
|
4390
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
4391
|
+
log.step("Preparing a public repo for your game\u2026");
|
|
4392
|
+
let res;
|
|
4393
|
+
try {
|
|
4394
|
+
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/make-remixable`, {
|
|
4395
|
+
method: "POST",
|
|
4396
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
4397
|
+
});
|
|
4398
|
+
} catch (err) {
|
|
4399
|
+
log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
|
|
4388
4400
|
process.exitCode = 1;
|
|
4389
4401
|
return;
|
|
4390
4402
|
}
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
async function waitForTerminalView(apiUrl, token, id, kind, onProgress = () => {
|
|
4394
|
-
}) {
|
|
4395
|
-
const timeoutMs = waitTimeoutFor(kind);
|
|
4396
|
-
const deadline = Date.now() + timeoutMs;
|
|
4397
|
-
const streamed = await waitViaSSE(apiUrl, token, id, onProgress, deadline);
|
|
4398
|
-
return streamed === "unsupported" ? poll(apiUrl, token, id, onProgress, timeoutMs) : streamed;
|
|
4399
|
-
}
|
|
4400
|
-
async function reportTerminal(kind, view, log, open = false, json = false) {
|
|
4401
|
-
if (view.status !== "completed") {
|
|
4402
|
-
if (json) writeJson({ kind, id: view.id, status: view.status, error: view.error ?? null });
|
|
4403
|
-
else log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
4404
|
-
if (view.status === "failed") {
|
|
4405
|
-
await recordTerminal(view.id, "failed");
|
|
4406
|
-
if (kind === "video") {
|
|
4407
|
-
const failures = await countFailed("video");
|
|
4408
|
-
(failures >= 2 ? log.plain : log.dim)(videoFailureAdvice(failures));
|
|
4409
|
-
}
|
|
4410
|
-
}
|
|
4403
|
+
if (res.status === 401) {
|
|
4404
|
+
log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
4411
4405
|
process.exitCode = 1;
|
|
4412
4406
|
return;
|
|
4413
4407
|
}
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
if (json) writeJson({ kind, id: view.id, status: "failed", error: "Generation completed but produced no files." });
|
|
4417
|
-
else log.error("Generation completed but produced no files.");
|
|
4408
|
+
if (res.status === 404) {
|
|
4409
|
+
log.error("That game wasn't found on your account. Re-run `genex init` or `genex link <slug>`.");
|
|
4418
4410
|
process.exitCode = 1;
|
|
4419
4411
|
return;
|
|
4420
4412
|
}
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4413
|
+
if (!res.ok) {
|
|
4414
|
+
log.error(`Couldn't make the game public (HTTP ${res.status}).`);
|
|
4415
|
+
process.exitCode = 1;
|
|
4424
4416
|
return;
|
|
4425
4417
|
}
|
|
4426
|
-
|
|
4427
|
-
|
|
4418
|
+
const data = await res.json().catch(() => null);
|
|
4419
|
+
if (!data?.pushUrl) {
|
|
4420
|
+
log.error("The API didn't return a push URL.");
|
|
4421
|
+
process.exitCode = 1;
|
|
4428
4422
|
return;
|
|
4429
4423
|
}
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
id: view.id,
|
|
4434
|
-
status: view.status,
|
|
4435
|
-
files,
|
|
4436
|
-
...kind === "character" ? {
|
|
4437
|
-
characterId: view.id,
|
|
4438
|
-
nextCommand: `genex controller character --character ${view.id}`
|
|
4439
|
-
} : {}
|
|
4440
|
-
});
|
|
4424
|
+
log.step("Copying your game's source to the public repo\u2026");
|
|
4425
|
+
if (!await pushWorktree(process.cwd(), data.pushUrl, true, log)) {
|
|
4426
|
+
process.exitCode = 1;
|
|
4441
4427
|
return;
|
|
4442
4428
|
}
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
const label = files.length > 1 ? `${c.dim(`${f.role.replace(/^texture-/, "")}: `)}` : "";
|
|
4447
|
-
log.plain(` ${label}${f.url}`);
|
|
4429
|
+
const newCloneUrl = data.project?.cloneUrl;
|
|
4430
|
+
if (newCloneUrl && newCloneUrl !== meta.cloneUrl) {
|
|
4431
|
+
await writeProject({ ...meta, cloneUrl: newCloneUrl });
|
|
4448
4432
|
}
|
|
4449
4433
|
log.plain("");
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
);
|
|
4456
|
-
log.plain(` ${files[0].url}`);
|
|
4457
|
-
log.plain("");
|
|
4434
|
+
log.success("Your game is public \u2014 anyone can remix it now. \u{1F310}");
|
|
4435
|
+
const dashboard = meta.dashboardOrigins?.[0];
|
|
4436
|
+
if (dashboard) {
|
|
4437
|
+
const page = meta.status === "published" ? "world" : "draft";
|
|
4438
|
+
log.plain(` your game's page: ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
4458
4439
|
}
|
|
4459
|
-
printHint(kind, view, files, log);
|
|
4460
|
-
await maybePrintHudStageNudge(view, log);
|
|
4461
|
-
}
|
|
4462
|
-
var STAGE1_PROMPT_MARKER = "A screenshot of a complete game HUD";
|
|
4463
|
-
var STAGE2_PROMPT_MARKER = "Look at this game HUD screenshot";
|
|
4464
|
-
var MENU_STILL_PROMPT_MARKER = "No UI elements, no text, no buttons in the frame.";
|
|
4465
|
-
var STAGE1_LANDING_NUDGE = "\u2192 HUD mockup landed: you are the art director \u2014 enqueue the Stage-2 sheet + menu still + logotype now (--no-wait). The keep/change answer can loop the concept later \u2014 do not wait for it (genex-ai-hud).";
|
|
4466
|
-
async function shouldNudgeStage1(id, cwd = process.cwd()) {
|
|
4467
|
-
const ledger = await readLedger(cwd);
|
|
4468
|
-
const idx = ledger.findIndex((e) => e.id === id);
|
|
4469
|
-
const me = ledger[idx];
|
|
4470
|
-
if (!me || me.kind !== "image" || !me.prompt.startsWith(STAGE1_PROMPT_MARKER)) return false;
|
|
4471
|
-
return !ledger.some(
|
|
4472
|
-
(e, i) => i > idx && e.kind === "image" && e.prompt.startsWith(STAGE2_PROMPT_MARKER)
|
|
4473
|
-
);
|
|
4474
4440
|
}
|
|
4475
|
-
|
|
4441
|
+
|
|
4442
|
+
// src/lib/detect-features.ts
|
|
4443
|
+
import fs14 from "fs/promises";
|
|
4444
|
+
import path14 from "path";
|
|
4445
|
+
|
|
4446
|
+
// src/lib/generation-ledger.ts
|
|
4447
|
+
import fs13 from "fs/promises";
|
|
4448
|
+
import path13 from "path";
|
|
4449
|
+
var ledgerPath = (cwd) => path13.join(cwd, ".genex", "generations.ndjson");
|
|
4450
|
+
async function append(cwd, event) {
|
|
4476
4451
|
try {
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
log.plain(` ${c.bold(STAGE1_LANDING_NUDGE)}`);
|
|
4481
|
-
}
|
|
4452
|
+
await fs13.access(path13.join(cwd, ".genex"));
|
|
4453
|
+
await fs13.appendFile(ledgerPath(cwd), `${JSON.stringify(event)}
|
|
4454
|
+
`, "utf8");
|
|
4482
4455
|
} catch {
|
|
4483
4456
|
}
|
|
4484
4457
|
}
|
|
4485
|
-
function
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
var WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
4492
|
-
var KIND_WAIT_TIMEOUT_MS = {
|
|
4493
|
-
video: 15 * 60 * 1e3,
|
|
4494
|
-
// 15 min
|
|
4495
|
-
motion: 20 * 60 * 1e3,
|
|
4496
|
-
// 8 takes ≈ 8-9 GPU min + a serverless cold start
|
|
4497
|
-
character_concept: 15 * 60 * 1e3,
|
|
4498
|
-
character_preview: 30 * 60 * 1e3,
|
|
4499
|
-
character: 30 * 60 * 1e3,
|
|
4500
|
-
character_animation: 20 * 60 * 1e3,
|
|
4501
|
-
// Per clip this lane is fast (locomotion ~2s, video ~51s, described ~130s) —
|
|
4502
|
-
// the BATCH is what takes time. A 20-clip standard set is the sizing case.
|
|
4503
|
-
character_motion: 30 * 60 * 1e3
|
|
4504
|
-
};
|
|
4505
|
-
var waitTimeoutFor = (kind) => KIND_WAIT_TIMEOUT_MS[kind] ?? WAIT_TIMEOUT_MS;
|
|
4506
|
-
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
4507
|
-
async function waitViaSSE(apiUrl, token, id, onProgress, deadline) {
|
|
4508
|
-
let connectFailures = 0;
|
|
4509
|
-
let last = -1;
|
|
4510
|
-
while (Date.now() < deadline) {
|
|
4511
|
-
const abort = new AbortController();
|
|
4512
|
-
const timer = setTimeout(() => abort.abort(), deadline - Date.now());
|
|
4513
|
-
try {
|
|
4514
|
-
let res;
|
|
4515
|
-
try {
|
|
4516
|
-
res = await apiFetch(`${apiUrl}/api/generations/${id}/events`, {
|
|
4517
|
-
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
4518
|
-
signal: abort.signal
|
|
4519
|
-
});
|
|
4520
|
-
} catch {
|
|
4521
|
-
if (++connectFailures >= 3) return "unsupported";
|
|
4522
|
-
await sleep(2e3);
|
|
4523
|
-
continue;
|
|
4524
|
-
}
|
|
4525
|
-
if (res.status === 404 || res.status === 405) return "unsupported";
|
|
4526
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
4527
|
-
if (!res.ok || !res.body || !contentType.includes("text/event-stream")) {
|
|
4528
|
-
if (++connectFailures >= 3) return "unsupported";
|
|
4529
|
-
await sleep(2e3);
|
|
4530
|
-
continue;
|
|
4531
|
-
}
|
|
4532
|
-
connectFailures = 0;
|
|
4533
|
-
try {
|
|
4534
|
-
for await (const ev of readSSE(res.body)) {
|
|
4535
|
-
if (ev.event !== "status") continue;
|
|
4536
|
-
let view;
|
|
4537
|
-
try {
|
|
4538
|
-
view = JSON.parse(ev.data);
|
|
4539
|
-
} catch {
|
|
4540
|
-
continue;
|
|
4541
|
-
}
|
|
4542
|
-
if (view.progress !== last) {
|
|
4543
|
-
last = view.progress;
|
|
4544
|
-
onProgress(view.progress);
|
|
4545
|
-
}
|
|
4546
|
-
if (TERMINAL.has(view.status)) return view;
|
|
4547
|
-
}
|
|
4548
|
-
} catch {
|
|
4549
|
-
}
|
|
4550
|
-
await sleep(1e3);
|
|
4551
|
-
} finally {
|
|
4552
|
-
clearTimeout(timer);
|
|
4553
|
-
abort.abort();
|
|
4554
|
-
}
|
|
4458
|
+
async function readLedger(cwd = process.cwd()) {
|
|
4459
|
+
let raw;
|
|
4460
|
+
try {
|
|
4461
|
+
raw = await fs13.readFile(ledgerPath(cwd), "utf8");
|
|
4462
|
+
} catch {
|
|
4463
|
+
return [];
|
|
4555
4464
|
}
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
const deadline = Date.now() + timeoutMs;
|
|
4560
|
-
let last = -1;
|
|
4561
|
-
while (Date.now() < deadline) {
|
|
4465
|
+
const entries = /* @__PURE__ */ new Map();
|
|
4466
|
+
for (const line of raw.split("\n")) {
|
|
4467
|
+
if (!line.trim()) continue;
|
|
4562
4468
|
try {
|
|
4563
|
-
const
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4469
|
+
const e = JSON.parse(line);
|
|
4470
|
+
if (e.t === "q" && typeof e.id === "string" && typeof e.kind === "string") {
|
|
4471
|
+
if (!entries.has(e.id)) {
|
|
4472
|
+
entries.set(e.id, {
|
|
4473
|
+
id: e.id,
|
|
4474
|
+
kind: e.kind,
|
|
4475
|
+
prompt: typeof e.prompt === "string" ? e.prompt : "",
|
|
4476
|
+
...typeof e.tail === "string" ? { promptTail: e.tail } : {},
|
|
4477
|
+
queuedAt: typeof e.at === "string" ? e.at : "",
|
|
4478
|
+
status: "queued"
|
|
4479
|
+
});
|
|
4571
4480
|
}
|
|
4572
|
-
|
|
4573
|
-
|
|
4481
|
+
} else if (e.t === "done" && typeof e.id === "string") {
|
|
4482
|
+
const entry = entries.get(e.id);
|
|
4483
|
+
if (entry && (e.status === "completed" || e.status === "failed")) {
|
|
4484
|
+
entry.status = e.status;
|
|
4485
|
+
if (Array.isArray(e.urls)) entry.urls = e.urls.filter((u) => typeof u === "string");
|
|
4574
4486
|
}
|
|
4575
4487
|
}
|
|
4576
4488
|
} catch {
|
|
4577
4489
|
}
|
|
4578
|
-
await sleep(3e3);
|
|
4579
|
-
}
|
|
4580
|
-
return null;
|
|
4581
|
-
}
|
|
4582
|
-
async function reportTextureSeam(url, log) {
|
|
4583
|
-
try {
|
|
4584
|
-
const res = await fetch(url);
|
|
4585
|
-
if (!res.ok) return;
|
|
4586
|
-
const png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
|
|
4587
|
-
if (png.width < 4 || png.height < 4) return;
|
|
4588
|
-
const r = measureSeam(png);
|
|
4589
|
-
if (r.seamless) {
|
|
4590
|
-
log.dim(` Tiling check: seam ${r.worstRatio}\xD7 this texture's own detail \u2014 tiles cleanly.`);
|
|
4591
|
-
return;
|
|
4592
|
-
}
|
|
4593
|
-
log.error(
|
|
4594
|
-
`Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
|
|
4595
|
-
);
|
|
4596
|
-
log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
|
|
4597
|
-
log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground).');
|
|
4598
|
-
log.plain(" Do NOT hide it by tiling less: a hand-picked repeat trades a seam you can name for a");
|
|
4599
|
-
log.plain(" stretched texture you can't. Scale belongs in the UVs (worldUV \u2014 genex-ai-texture).");
|
|
4600
|
-
log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
|
|
4601
|
-
} catch {
|
|
4602
|
-
}
|
|
4603
|
-
}
|
|
4604
|
-
function printHint(kind, view, files, log) {
|
|
4605
|
-
const url = files[0]?.url ?? "";
|
|
4606
|
-
const hint = {
|
|
4607
|
-
model: `Standard GLB \u2014 load with GLTFLoader straight from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
|
|
4608
|
-
skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
|
|
4609
|
-
sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. url = "${url}"`,
|
|
4610
|
-
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}"`,
|
|
4611
|
-
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}"`,
|
|
4612
|
-
texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`,
|
|
4613
|
-
image: `Load with TextureLoader (set colorSpace = SRGBColorSpace) onto any mesh/plane/sprite \u2014 see genex-ai-image. url = "${url}"`,
|
|
4614
|
-
video: `Wire an HTMLVideoElement (crossOrigin="anonymous", muted, loop, playsInline) into a THREE.VideoTexture \u2014 see genex-ai-video. url = "${url}"`,
|
|
4615
|
-
motion: `Raw ARDY takes + verify report \u2014 download the takes, rename them meaningfully (routing is by filename), then: genex motion verify \u2192 compile \u2192 install. Full procedure: the genex-ai-character skill's motion reference.`,
|
|
4616
|
-
character_concept: "Show all three concept candidates to the user and wait for an explicit selection.",
|
|
4617
|
-
character_preview: "Show all four preview views to the user and wait for explicit approval before finalizing.",
|
|
4618
|
-
character: `Install the controller and current manifest with genex controller character --character ${view.id}.`,
|
|
4619
|
-
character_animation: "The action is now part of the character's current manifest; refresh the local controller manifest before testing.",
|
|
4620
|
-
character_motion: `The clips are part of the character's current manifest \u2014 install them with genex controller character --character ${view.id}, then play the game and watch the motion on the real character before calling it done.`
|
|
4621
|
-
};
|
|
4622
|
-
log.dim(` ${hint[kind]}`);
|
|
4623
|
-
log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
|
|
4624
|
-
}
|
|
4625
|
-
var CONCEPT_STOP = "STOP \u2014 show all three concept images to the user and wait for an explicit selection. Do not create the 3D preview until they choose candidate 1, 2, or 3.";
|
|
4626
|
-
function metadataOf(view) {
|
|
4627
|
-
return view.metadata && typeof view.metadata === "object" ? view.metadata : {};
|
|
4628
|
-
}
|
|
4629
|
-
function stringValue(value) {
|
|
4630
|
-
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
4631
|
-
}
|
|
4632
|
-
function finiteNumber(value) {
|
|
4633
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
4634
|
-
}
|
|
4635
|
-
function conceptCandidates(view) {
|
|
4636
|
-
const candidates = [];
|
|
4637
|
-
for (const file of view.files ?? []) {
|
|
4638
|
-
const match = /^(?:concept-candidate|character-concept)-([123])(?:\.[a-z0-9]+)?$/i.exec(file.role);
|
|
4639
|
-
if (!match) continue;
|
|
4640
|
-
candidates.push({ candidate: Number(match[1]), role: file.role, url: file.url });
|
|
4641
4490
|
}
|
|
4642
|
-
return
|
|
4643
|
-
}
|
|
4644
|
-
function conceptContactSheet(view) {
|
|
4645
|
-
return (view.files ?? []).find(
|
|
4646
|
-
(file) => /^(?:concept-gallery|character-concepts-contact-sheet)(?:\.[a-z0-9]+)?$/i.test(file.role)
|
|
4647
|
-
);
|
|
4491
|
+
return [...entries.values()];
|
|
4648
4492
|
}
|
|
4649
|
-
function
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
};
|
|
4493
|
+
async function recordQueued(id, kind, prompt, cwd = process.cwd()) {
|
|
4494
|
+
await append(cwd, {
|
|
4495
|
+
t: "q",
|
|
4496
|
+
id,
|
|
4497
|
+
kind,
|
|
4498
|
+
prompt: prompt.slice(0, 120),
|
|
4499
|
+
...prompt.length > 120 ? { tail: prompt.slice(-120) } : {},
|
|
4500
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4501
|
+
});
|
|
4658
4502
|
}
|
|
4659
|
-
function
|
|
4660
|
-
const
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
"finalize",
|
|
4664
|
-
previewId,
|
|
4665
|
-
"--user-approved",
|
|
4666
|
-
"--approve-remesh",
|
|
4667
|
-
"10000"
|
|
4668
|
-
];
|
|
4669
|
-
if (intent.heightMeters !== void 0) args.push("--height", String(intent.heightMeters));
|
|
4670
|
-
for (const actionId of intent.actionIds) args.push("--animation", String(actionId));
|
|
4671
|
-
return args.join(" ");
|
|
4503
|
+
async function recordTerminal(id, status, urls, cwd = process.cwd()) {
|
|
4504
|
+
const existing = (await readLedger(cwd)).find((e) => e.id === id);
|
|
4505
|
+
if (existing && existing.status === status) return;
|
|
4506
|
+
await append(cwd, { t: "done", id, status, ...urls?.length ? { urls } : {} });
|
|
4672
4507
|
}
|
|
4673
|
-
function
|
|
4674
|
-
|
|
4675
|
-
if (candidates.length !== 3 || candidates.some((entry, index) => entry.candidate !== index + 1)) {
|
|
4676
|
-
const error = "Character concept generation completed without exactly candidates 1, 2, and 3.";
|
|
4677
|
-
if (opts.json) writeJson({ kind: "character_concept", id: view.id, status: "failed", error });
|
|
4678
|
-
else log.error(error);
|
|
4679
|
-
process.exitCode = 1;
|
|
4680
|
-
return;
|
|
4681
|
-
}
|
|
4682
|
-
const metadata = metadataOf(view);
|
|
4683
|
-
const contactSheet = conceptContactSheet(view);
|
|
4684
|
-
const nextCommand = `genex character preview ${view.id} --candidate <n> --user-approved`;
|
|
4685
|
-
if (opts.open) openUrl(contactSheet?.url ?? candidates[0].url);
|
|
4686
|
-
if (opts.json) {
|
|
4687
|
-
writeJson({
|
|
4688
|
-
kind: "character_concept",
|
|
4689
|
-
stage: "concept",
|
|
4690
|
-
id: view.id,
|
|
4691
|
-
status: view.status,
|
|
4692
|
-
provider: stringValue(metadata.provider),
|
|
4693
|
-
model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
|
|
4694
|
-
candidates,
|
|
4695
|
-
...contactSheet ? { contactSheetUrl: contactSheet.url } : {},
|
|
4696
|
-
...opts.quote ? { quote: opts.quote } : {},
|
|
4697
|
-
requiresUserApproval: true,
|
|
4698
|
-
nextCommand
|
|
4699
|
-
});
|
|
4700
|
-
return;
|
|
4701
|
-
}
|
|
4702
|
-
log.success("Three character concept candidates are ready:");
|
|
4703
|
-
for (const candidate of candidates) log.plain(` ${candidate.candidate}. ${candidate.url}`);
|
|
4704
|
-
if (contactSheet) log.plain(` Contact sheet: ${contactSheet.url}`);
|
|
4705
|
-
log.plain("");
|
|
4706
|
-
log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all three numbered links, not only your favorite.`);
|
|
4707
|
-
log.plain(` ${c.bold(CONCEPT_STOP)}`);
|
|
4708
|
-
log.plain(` After approval: ${c.cyan(nextCommand)}`);
|
|
4709
|
-
log.plain("");
|
|
4508
|
+
async function countFailed(kind, cwd = process.cwd()) {
|
|
4509
|
+
return (await readLedger(cwd)).filter((e) => e.kind === kind && e.status === "failed").length;
|
|
4710
4510
|
}
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
const
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
})?.url;
|
|
4722
|
-
result[direction] = fromMetadata ?? fromFile;
|
|
4511
|
+
|
|
4512
|
+
// src/lib/detect-features.ts
|
|
4513
|
+
async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
4514
|
+
try {
|
|
4515
|
+
const raw = await fs14.readFile(path14.join(cwd, "package.json"), "utf8");
|
|
4516
|
+
const pkg = JSON.parse(raw);
|
|
4517
|
+
const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
|
|
4518
|
+
return typeof version === "string" && version ? version : null;
|
|
4519
|
+
} catch {
|
|
4520
|
+
return null;
|
|
4723
4521
|
}
|
|
4724
|
-
return result;
|
|
4725
4522
|
}
|
|
4726
|
-
function
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4523
|
+
async function detectMultiplayer(cwd = process.cwd()) {
|
|
4524
|
+
try {
|
|
4525
|
+
const raw = await fs14.readFile(path14.join(cwd, "package.json"), "utf8");
|
|
4526
|
+
const pkg = JSON.parse(raw);
|
|
4527
|
+
return Boolean(
|
|
4528
|
+
pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
|
|
4529
|
+
);
|
|
4530
|
+
} catch {
|
|
4531
|
+
return false;
|
|
4532
|
+
}
|
|
4730
4533
|
}
|
|
4731
|
-
function
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
sourceFaceCount === void 0 ? "source face count" : "",
|
|
4739
|
-
missingViews.length > 0 ? `${missingViews.join(", ")} view${missingViews.length === 1 ? "" : "s"}` : ""
|
|
4740
|
-
].filter(Boolean).join(" and ");
|
|
4741
|
-
const error = `Character preview completed without ${detail}.`;
|
|
4742
|
-
if (opts.json) writeJson({ kind: "character_preview", id: view.id, status: "failed", error });
|
|
4743
|
-
else log.error(error);
|
|
4744
|
-
process.exitCode = 1;
|
|
4745
|
-
return;
|
|
4534
|
+
async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
4535
|
+
let pkg;
|
|
4536
|
+
try {
|
|
4537
|
+
pkg = JSON.parse(await fs14.readFile(path14.join(cwd, "package.json"), "utf8"));
|
|
4538
|
+
} catch (err) {
|
|
4539
|
+
log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
|
|
4540
|
+
return null;
|
|
4746
4541
|
}
|
|
4747
|
-
const
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
kind: "character_preview",
|
|
4755
|
-
stage: "preview",
|
|
4756
|
-
id: view.id,
|
|
4757
|
-
status: view.status,
|
|
4758
|
-
provider: stringValue(metadata.provider),
|
|
4759
|
-
model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
|
|
4760
|
-
poseMode: stringValue(metadata.poseMode),
|
|
4761
|
-
shouldRemesh: metadata.shouldRemesh === true,
|
|
4762
|
-
sourceFaceCount,
|
|
4763
|
-
views,
|
|
4764
|
-
...sourceUrl ? { sourceUrl } : {},
|
|
4765
|
-
requestedBuild: intent,
|
|
4766
|
-
...opts.quote ? { quote: opts.quote } : {},
|
|
4767
|
-
requiresUserApproval: true,
|
|
4768
|
-
nextCommand
|
|
4769
|
-
});
|
|
4770
|
-
return;
|
|
4542
|
+
const mm = pkg.genex?.matchmaking;
|
|
4543
|
+
if (mm && typeof mm.preset === "string" && mm.preset) {
|
|
4544
|
+
return {
|
|
4545
|
+
preset: mm.preset,
|
|
4546
|
+
...mm.winCondition ? { winCondition: mm.winCondition } : {},
|
|
4547
|
+
...mm.config ? { config: mm.config } : {}
|
|
4548
|
+
};
|
|
4771
4549
|
}
|
|
4772
|
-
|
|
4773
|
-
for (const direction of PREVIEW_DIRECTIONS) log.plain(` ${direction}: ${views[direction]}`);
|
|
4774
|
-
if (sourceUrl) log.plain(` High-detail source: ${sourceUrl}`);
|
|
4775
|
-
log.plain("");
|
|
4776
|
-
log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all four views.`);
|
|
4777
|
-
log.plain(` ${c.bold(stop)}`);
|
|
4778
|
-
log.plain(` After approval: ${c.cyan(nextCommand)}`);
|
|
4779
|
-
log.plain("");
|
|
4780
|
-
}
|
|
4781
|
-
function writeJson(value) {
|
|
4782
|
-
process.stdout.write(`${JSON.stringify(value)}
|
|
4783
|
-
`);
|
|
4550
|
+
return null;
|
|
4784
4551
|
}
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4552
|
+
var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
|
|
4553
|
+
async function detectMobileControls(cwd = process.cwd()) {
|
|
4554
|
+
try {
|
|
4555
|
+
const raw = await fs14.readFile(path14.join(cwd, "package.json"), "utf8");
|
|
4556
|
+
const pkg = JSON.parse(raw);
|
|
4557
|
+
if (pkg.genex?.mobileControls === true) return true;
|
|
4558
|
+
} catch {
|
|
4559
|
+
}
|
|
4560
|
+
const srcDir = path14.join(cwd, "src");
|
|
4561
|
+
let entries;
|
|
4562
|
+
try {
|
|
4563
|
+
entries = await fs14.readdir(srcDir, { recursive: true });
|
|
4564
|
+
} catch {
|
|
4565
|
+
return false;
|
|
4566
|
+
}
|
|
4567
|
+
for (const rel of entries) {
|
|
4568
|
+
if (rel.includes("node_modules")) continue;
|
|
4569
|
+
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
4790
4570
|
try {
|
|
4791
|
-
|
|
4571
|
+
const content = await fs14.readFile(path14.join(srcDir, rel), "utf8");
|
|
4572
|
+
if (TOUCH_KIT_MARKERS.test(content)) return true;
|
|
4792
4573
|
} catch {
|
|
4793
|
-
resolve({ code: -1, out: "", err: `${cmd} not found` });
|
|
4794
|
-
return;
|
|
4795
4574
|
}
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
child.stdout?.on("data", (d) => out += String(d));
|
|
4799
|
-
child.stderr?.on("data", (d) => err += String(d));
|
|
4800
|
-
child.on("error", () => resolve({ code: -1, out, err: `${cmd} not found` }));
|
|
4801
|
-
child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
|
|
4802
|
-
});
|
|
4575
|
+
}
|
|
4576
|
+
return false;
|
|
4803
4577
|
}
|
|
4804
|
-
|
|
4578
|
+
var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
|
|
4579
|
+
async function detectGameStateUsage(cwd = process.cwd()) {
|
|
4580
|
+
const srcDir = path14.join(cwd, "src");
|
|
4581
|
+
let entries;
|
|
4805
4582
|
try {
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
}
|
|
4818
|
-
const est = estimateVram(scan, externalDims);
|
|
4819
|
-
const tier = tierFor(est.estVramMb);
|
|
4820
|
-
const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
|
|
4821
|
-
if (tier === "mobile-broad") {
|
|
4822
|
-
log.dim(` ${line}`);
|
|
4823
|
-
return;
|
|
4824
|
-
}
|
|
4825
|
-
log.warn(line);
|
|
4826
|
-
for (const url of est.unresolvedExternals) {
|
|
4827
|
-
const role = url.split("/").pop() ?? "";
|
|
4828
|
-
if (role === SKYBOX_ROLE) {
|
|
4829
|
-
log.warn(
|
|
4830
|
-
` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
|
|
4831
|
-
);
|
|
4832
|
-
} else if (role.endsWith("-glb")) {
|
|
4833
|
-
log.warn(
|
|
4834
|
-
` Model loaded without pickModel (${url.slice(0, 80)}\u2026) \u2014 phones fetch the full provider-raw GLB. Load it through loadModelWithFallback (genex-threejs-adaptive-quality) so phones get the @1024 rung.`
|
|
4835
|
-
);
|
|
4836
|
-
}
|
|
4837
|
-
}
|
|
4838
|
-
if (est.textureMaxDim > 2048) {
|
|
4839
|
-
log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
|
|
4583
|
+
entries = await fs14.readdir(srcDir, { recursive: true });
|
|
4584
|
+
} catch {
|
|
4585
|
+
return false;
|
|
4586
|
+
}
|
|
4587
|
+
for (const rel of entries) {
|
|
4588
|
+
if (rel.includes("node_modules")) continue;
|
|
4589
|
+
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
4590
|
+
try {
|
|
4591
|
+
const content = await fs14.readFile(path14.join(srcDir, rel), "utf8");
|
|
4592
|
+
if (GAME_STATE_CALLS.test(content)) return true;
|
|
4593
|
+
} catch {
|
|
4840
4594
|
}
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4595
|
+
}
|
|
4596
|
+
return false;
|
|
4597
|
+
}
|
|
4598
|
+
function ctorArgs(content, ctor) {
|
|
4599
|
+
const out = [];
|
|
4600
|
+
const re = new RegExp(`new\\s+(?:THREE\\.)?${ctor}\\s*\\(`, "g");
|
|
4601
|
+
let m;
|
|
4602
|
+
while (m = re.exec(content)) {
|
|
4603
|
+
let i = re.lastIndex;
|
|
4604
|
+
let depth = 1;
|
|
4605
|
+
while (i < content.length && depth > 0) {
|
|
4606
|
+
const ch = content[i];
|
|
4607
|
+
if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
4608
|
+
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
|
4609
|
+
i++;
|
|
4845
4610
|
}
|
|
4846
|
-
|
|
4847
|
-
|
|
4611
|
+
out.push({ text: content.slice(re.lastIndex, i - 1), index: m.index });
|
|
4612
|
+
}
|
|
4613
|
+
return out;
|
|
4614
|
+
}
|
|
4615
|
+
function splitArgs(text) {
|
|
4616
|
+
const out = [];
|
|
4617
|
+
let depth = 0;
|
|
4618
|
+
let start = 0;
|
|
4619
|
+
for (let i = 0; i < text.length; i++) {
|
|
4620
|
+
const ch = text[i];
|
|
4621
|
+
if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
4622
|
+
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
|
4623
|
+
else if (ch === "," && depth === 0) {
|
|
4624
|
+
out.push(text.slice(start, i).trim());
|
|
4625
|
+
start = i + 1;
|
|
4848
4626
|
}
|
|
4849
|
-
} catch {
|
|
4850
4627
|
}
|
|
4628
|
+
out.push(text.slice(start).trim());
|
|
4629
|
+
return out;
|
|
4851
4630
|
}
|
|
4852
|
-
|
|
4631
|
+
var lineOf = (content, index) => content.slice(0, index).split("\n").length;
|
|
4632
|
+
var DEPTH_RATIO_LIMIT = 1e6;
|
|
4633
|
+
async function detectSurfaceScan(cwd = process.cwd()) {
|
|
4634
|
+
const found = { guessedRepeat: [], squarePoints: [], depthRange: [] };
|
|
4635
|
+
const srcDir = path14.join(cwd, "src");
|
|
4636
|
+
let entries;
|
|
4853
4637
|
try {
|
|
4854
|
-
|
|
4855
|
-
if (!findings || findings.length === 0) return;
|
|
4856
|
-
log.warn(
|
|
4857
|
-
`HUD wiring audit: ${findings.length} finding(s) \u2014 heuristics, check each (\`npx genex ui audit\` for the full report):`
|
|
4858
|
-
);
|
|
4859
|
-
for (const finding of findings.slice(0, 6)) log.warn(` [${finding.kind}] ${finding.message}`);
|
|
4860
|
-
if (findings.length > 6) log.dim(` \u2026and ${findings.length - 6} more.`);
|
|
4638
|
+
entries = await fs14.readdir(srcDir, { recursive: true });
|
|
4861
4639
|
} catch {
|
|
4640
|
+
return found;
|
|
4862
4641
|
}
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
const
|
|
4867
|
-
|
|
4868
|
-
const
|
|
4869
|
-
const
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
(
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
);
|
|
4880
|
-
const videoEnqueued = ledger.some((e) => e.kind === "video");
|
|
4881
|
-
const videoDeclined = /menu video:\s*declined/i.test(design);
|
|
4882
|
-
if (menuStillDone && !videoEnqueued && !videoDeclined) {
|
|
4883
|
-
warnings.push(
|
|
4884
|
-
'Menu video not enqueued \u2014 the animated menu backdrop is the default for every game. Enqueue it (genex-ai-menu), or record "Menu video: declined (reason)" in DESIGN.md if the player declined it.'
|
|
4885
|
-
);
|
|
4886
|
-
}
|
|
4887
|
-
if (design.length > 0 && !/##\s*build plan & status/i.test(design)) {
|
|
4888
|
-
warnings.push(
|
|
4889
|
-
"DESIGN.md has no `## Build plan & status` section \u2014 numbered milestones with a `Now:` line are how a compacted or resumed session knows where the build is (genex-game-director's design contract has the shape)."
|
|
4890
|
-
);
|
|
4642
|
+
for (const rel of entries) {
|
|
4643
|
+
if (rel.includes("node_modules")) continue;
|
|
4644
|
+
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
4645
|
+
const raw = await fs14.readFile(path14.join(srcDir, rel), "utf8").catch(() => "");
|
|
4646
|
+
if (!raw) continue;
|
|
4647
|
+
const content = raw.replace(/\/\*[\s\S]*?\*\//g, (m2) => m2.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m2, p1) => p1 + " ".repeat(m2.length - p1.length));
|
|
4648
|
+
const repeatRe = /\.repeat\.set\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
|
|
4649
|
+
let m;
|
|
4650
|
+
while (m = repeatRe.exec(content)) {
|
|
4651
|
+
const u = Number(m[1]);
|
|
4652
|
+
const v = Number(m[2]);
|
|
4653
|
+
if (u === v) continue;
|
|
4654
|
+
found.guessedRepeat.push({
|
|
4655
|
+
where: `${rel}:${lineOf(content, m.index)}`,
|
|
4656
|
+
detail: `repeat.set(${m[1]}, ${m[2]})`
|
|
4657
|
+
});
|
|
4891
4658
|
}
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
}
|
|
4659
|
+
for (const call of ctorArgs(content, "PointsMaterial")) {
|
|
4660
|
+
if (/\b(map|alphaMap)\s*:/.test(call.text)) continue;
|
|
4661
|
+
found.squarePoints.push({
|
|
4662
|
+
where: `${rel}:${lineOf(content, call.index)}`,
|
|
4663
|
+
detail: "PointsMaterial with no map/alphaMap"
|
|
4664
|
+
});
|
|
4899
4665
|
}
|
|
4900
|
-
|
|
4901
|
-
const
|
|
4902
|
-
if (
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4666
|
+
for (const call of ctorArgs(content, "PerspectiveCamera")) {
|
|
4667
|
+
const args = splitArgs(call.text);
|
|
4668
|
+
if (args.length < 4) continue;
|
|
4669
|
+
const near = Number(args[2]);
|
|
4670
|
+
const far = Number(args[3]);
|
|
4671
|
+
if (!isFinite(near) || !isFinite(far) || near <= 0 || far <= near) continue;
|
|
4672
|
+
const ratio = far / near;
|
|
4673
|
+
if (ratio <= DEPTH_RATIO_LIMIT) continue;
|
|
4674
|
+
found.depthRange.push({
|
|
4675
|
+
where: `${rel}:${lineOf(content, call.index)}`,
|
|
4676
|
+
detail: `near ${near} / far ${far}`,
|
|
4677
|
+
ratio
|
|
4678
|
+
});
|
|
4907
4679
|
}
|
|
4908
|
-
for (const w of warnings) log.warn(w);
|
|
4909
|
-
} catch {
|
|
4910
4680
|
}
|
|
4681
|
+
return found;
|
|
4911
4682
|
}
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4683
|
+
var WIRED_BY_URL = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "music", "texture", "video"]);
|
|
4684
|
+
var UNPICKED_AFTER_MS = 15 * 60 * 1e3;
|
|
4685
|
+
async function detectGenerationAudit(cwd = process.cwd()) {
|
|
4686
|
+
const ledger = await readLedger(cwd);
|
|
4687
|
+
const audited = ledger.filter((e) => WIRED_BY_URL.has(e.kind));
|
|
4688
|
+
if (audited.length === 0) return { unwired: [], unpicked: [], unresolved: [], deadWired: [] };
|
|
4689
|
+
let haystack = "";
|
|
4690
|
+
const read = async (file) => {
|
|
4691
|
+
try {
|
|
4692
|
+
haystack += await fs14.readFile(file, "utf8");
|
|
4693
|
+
} catch {
|
|
4694
|
+
}
|
|
4695
|
+
};
|
|
4915
4696
|
try {
|
|
4916
4697
|
for (const entry of await fs14.readdir(cwd, { withFileTypes: true })) {
|
|
4917
|
-
if (entry.isFile() && /\.(html|
|
|
4698
|
+
if (entry.isFile() && /\.(ts|tsx|js|jsx|css|html|json)$/.test(entry.name)) {
|
|
4699
|
+
await read(path14.join(cwd, entry.name));
|
|
4700
|
+
}
|
|
4918
4701
|
}
|
|
4919
4702
|
} catch {
|
|
4920
4703
|
}
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4704
|
+
for (const sub of ["src", "public"]) {
|
|
4705
|
+
try {
|
|
4706
|
+
const entries = await fs14.readdir(path14.join(cwd, sub), { recursive: true });
|
|
4707
|
+
for (const rel of entries) {
|
|
4708
|
+
if (rel.includes("node_modules")) continue;
|
|
4709
|
+
if (!/\.(ts|tsx|js|jsx|css|html|json|txt)$/.test(rel)) continue;
|
|
4710
|
+
await read(path14.join(cwd, sub, rel));
|
|
4711
|
+
}
|
|
4712
|
+
} catch {
|
|
4926
4713
|
}
|
|
4927
|
-
} catch {
|
|
4928
|
-
}
|
|
4929
|
-
for (const file of files) {
|
|
4930
|
-
const text = await fs14.readFile(file, "utf8").catch(() => "");
|
|
4931
|
-
if (HUD_MARKERS.test(text)) return true;
|
|
4932
4714
|
}
|
|
4933
|
-
|
|
4715
|
+
const ref = ({ id, kind, prompt }) => ({
|
|
4716
|
+
id,
|
|
4717
|
+
kind,
|
|
4718
|
+
prompt
|
|
4719
|
+
});
|
|
4720
|
+
const now = Date.now();
|
|
4721
|
+
const stale = (e) => e.status === "queued" && now - Date.parse(e.queuedAt) > UNPICKED_AFTER_MS;
|
|
4722
|
+
return {
|
|
4723
|
+
unwired: audited.filter((e) => e.status === "completed" && !haystack.includes(e.id)).map(ref),
|
|
4724
|
+
unpicked: audited.filter((e) => stale(e) && !haystack.includes(e.id)).map(ref),
|
|
4725
|
+
// Wired in but never picked up: the deterministic URL let the agent wire
|
|
4726
|
+
// the asset before it finished, so nobody ever learned whether it DID
|
|
4727
|
+
// finish — a provider-side failure (credits, moderation) leaves a dead URL
|
|
4728
|
+
// that renders as a silently missing asset. Kept separate from `unpicked`
|
|
4729
|
+
// because the fix is a status check, not wiring.
|
|
4730
|
+
unresolved: audited.filter((e) => stale(e) && haystack.includes(e.id)).map(ref),
|
|
4731
|
+
// Known-failed AND still wired: the worst case, and it must keep firing
|
|
4732
|
+
// AFTER the status check converges the ledger to "failed" (otherwise the
|
|
4733
|
+
// audit self-erases the moment the agent runs the recommended command and
|
|
4734
|
+
// the dead URL ships silently anyway).
|
|
4735
|
+
deadWired: audited.filter((e) => e.status === "failed" && haystack.includes(e.id)).map(ref)
|
|
4736
|
+
};
|
|
4934
4737
|
}
|
|
4935
|
-
async function
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
}
|
|
4946
|
-
} catch {
|
|
4947
|
-
}
|
|
4948
|
-
return false;
|
|
4738
|
+
async function detectFeatures(log, cwd = process.cwd()) {
|
|
4739
|
+
return {
|
|
4740
|
+
embedSdkVersion: await detectEmbedSdkVersion(cwd),
|
|
4741
|
+
multiplayer: await detectMultiplayer(cwd),
|
|
4742
|
+
matchmaking: await detectMatchmaking(log, cwd),
|
|
4743
|
+
gameStateUsed: await detectGameStateUsage(cwd),
|
|
4744
|
+
mobileControls: await detectMobileControls(cwd),
|
|
4745
|
+
surfaces: await detectSurfaceScan(cwd),
|
|
4746
|
+
generations: await detectGenerationAudit(cwd)
|
|
4747
|
+
};
|
|
4949
4748
|
}
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
(rel) => rel.endsWith(".annotated-progress.json") || rel.endsWith(".bbox.json")
|
|
4749
|
+
function advisoryNudges(log, d) {
|
|
4750
|
+
if (!d.embedSdkVersion) {
|
|
4751
|
+
log.warn(
|
|
4752
|
+
"This game has no player identity or saves \u2014 players lose everything on refresh. The genex-threejs-embed-auth skill wires it with one call (initEmbed) \u2014 load it before publishing."
|
|
4955
4753
|
);
|
|
4956
|
-
} catch {
|
|
4957
|
-
return false;
|
|
4958
|
-
}
|
|
4959
|
-
}
|
|
4960
|
-
var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
|
|
4961
|
-
function isSecretEnvFile(name) {
|
|
4962
|
-
return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
|
|
4963
|
-
}
|
|
4964
|
-
async function deployGame(ctx, opts, log) {
|
|
4965
|
-
const cwd = process.cwd();
|
|
4966
|
-
if (!opts.noBuild && await hasBuildScript(cwd)) {
|
|
4967
|
-
log.step("Building the production bundle\u2026");
|
|
4968
|
-
const built = await run("npm", ["run", "build"]);
|
|
4969
|
-
if (built.code !== 0) {
|
|
4970
|
-
log.error("Build failed \u2014 your game was NOT deployed.");
|
|
4971
|
-
const tail = (built.err || built.out).trim().split("\n").slice(-12).join("\n");
|
|
4972
|
-
if (tail) log.dim(tail);
|
|
4973
|
-
return false;
|
|
4974
|
-
}
|
|
4975
|
-
log.success("Built.");
|
|
4976
|
-
}
|
|
4977
|
-
const distDir = path14.join(cwd, "dist");
|
|
4978
|
-
const siteDir = await isDir2(distDir) ? distDir : cwd;
|
|
4979
|
-
const rel = path14.relative(cwd, siteDir) || ".";
|
|
4980
|
-
if (siteDir === cwd) await writeGitignore(cwd, log);
|
|
4981
|
-
const files = await collectFiles(siteDir);
|
|
4982
|
-
if (files.length === 0) {
|
|
4983
|
-
log.warn("Nothing to deploy \u2014 the build produced no files.");
|
|
4984
|
-
return false;
|
|
4985
|
-
}
|
|
4986
|
-
if (!files.some((f) => f.relPath === "index.html")) {
|
|
4987
|
-
log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
|
|
4988
|
-
return false;
|
|
4989
4754
|
}
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
if (lfsPointers.length > 0) {
|
|
4994
|
-
log.error(
|
|
4995
|
-
`${lfsPointers.length} file${lfsPointers.length > 1 ? "s" : ""} in ${rel} ${lfsPointers.length > 1 ? "are" : "is"} an unresolved Git LFS pointer, not the real asset \u2014 the game would fail to load at runtime. Run \`git lfs pull\` and rebuild. Nothing deployed.`
|
|
4755
|
+
if (d.multiplayer && !d.embedSdkVersion) {
|
|
4756
|
+
log.warn(
|
|
4757
|
+
"Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
|
|
4996
4758
|
);
|
|
4997
|
-
for (const p of lfsPointers.slice(0, 8)) log.dim(` ${p.relPath}`);
|
|
4998
|
-
if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
|
|
4999
|
-
return false;
|
|
5000
|
-
}
|
|
5001
|
-
printMobilePreflight(files, log);
|
|
5002
|
-
await printUiAuditPreflight(log);
|
|
5003
|
-
await printPipelineStatePreflight(log);
|
|
5004
|
-
const commit = contentCommit(files);
|
|
5005
|
-
log.step("Requesting an upload token\u2026");
|
|
5006
|
-
const grant = await getUploadToken(ctx, commit, log);
|
|
5007
|
-
if (!grant) return false;
|
|
5008
|
-
const totalBytes = files.reduce((n, f) => n + f.bytes.length, 0);
|
|
5009
|
-
const mb = (n) => (n / 1048576).toFixed(n < 1048576 ? 2 : 1);
|
|
5010
|
-
if (files.length > grant.limits.maxFiles) {
|
|
5011
|
-
log.error(`Too many files (${files.length} > ${grant.limits.maxFiles}). Trim the build and retry.`);
|
|
5012
|
-
return false;
|
|
5013
|
-
}
|
|
5014
|
-
const oversized = files.find((f) => f.bytes.length > grant.limits.maxFileBytes);
|
|
5015
|
-
if (oversized) {
|
|
5016
|
-
log.error(`${oversized.relPath} is ${mb(oversized.bytes.length)} MB \u2014 over the ${mb(grant.limits.maxFileBytes)} MB per-file limit.`);
|
|
5017
|
-
return false;
|
|
5018
|
-
}
|
|
5019
|
-
if (totalBytes > grant.limits.maxTotalBytes) {
|
|
5020
|
-
log.error(`Build is ${mb(totalBytes)} MB \u2014 over the ${mb(grant.limits.maxTotalBytes)} MB limit.`);
|
|
5021
|
-
return false;
|
|
5022
4759
|
}
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
4760
|
+
if (d.matchmaking && !d.multiplayer) {
|
|
4761
|
+
log.warn(
|
|
4762
|
+
"package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
|
|
4763
|
+
);
|
|
5027
4764
|
}
|
|
5028
|
-
|
|
5029
|
-
log.
|
|
5030
|
-
if (!await callPublish(ctx, commit, opts, log)) return false;
|
|
5031
|
-
const index = files.find((f) => f.relPath === "index.html");
|
|
5032
|
-
await waitUntilLive(grant.playUrl, fingerprintOf(index.bytes.toString("utf8")), opts.liveTimeoutMs ?? 2e4, log);
|
|
5033
|
-
return true;
|
|
4765
|
+
surfaceNudges(log, d.surfaces);
|
|
4766
|
+
generationNudges(log, d.generations);
|
|
5034
4767
|
}
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
4768
|
+
function generationNudges(log, g) {
|
|
4769
|
+
const describe = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
|
|
4770
|
+
if (g.unpicked.length) {
|
|
4771
|
+
log.warn(
|
|
4772
|
+
`${g.unpicked.length} generation(s) enqueued 15+ minutes ago were never picked up: ${describe(g.unpicked)}. Run npx genex wait <id> on each (npx genex wait --all shows every status at once) \u2014 landed assets get wired in before the last push of a session, never parked for "later".`
|
|
4773
|
+
);
|
|
5041
4774
|
}
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
for (const e of await fs14.readdir(dir, { withFileTypes: true })) {
|
|
5047
|
-
const relPath = prefix ? `${prefix}/${e.name}` : e.name;
|
|
5048
|
-
if (e.isDirectory()) {
|
|
5049
|
-
if (!EXCLUDE_DIRS.has(e.name)) await walk2(path14.join(dir, e.name), relPath);
|
|
5050
|
-
} else if (e.isFile() && !isSecretEnvFile(e.name)) {
|
|
5051
|
-
out.push({ relPath, bytes: await fs14.readFile(path14.join(dir, e.name)) });
|
|
5052
|
-
}
|
|
5053
|
-
}
|
|
5054
|
-
};
|
|
5055
|
-
await walk2(root, "");
|
|
5056
|
-
return out;
|
|
5057
|
-
}
|
|
5058
|
-
function contentCommit(files) {
|
|
5059
|
-
const h = crypto3.createHash("sha1");
|
|
5060
|
-
for (const f of [...files].sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)) {
|
|
5061
|
-
h.update(f.relPath, "utf8");
|
|
5062
|
-
h.update("\0");
|
|
5063
|
-
h.update(f.bytes);
|
|
4775
|
+
if (g.unresolved.length) {
|
|
4776
|
+
log.warn(
|
|
4777
|
+
`${g.unresolved.length} generation(s) are wired into the game but were never picked up, so their real status is unknown: ${describe(g.unresolved)}. A generation that failed server-side (credits, moderation) leaves a dead URL \u2014 the asset just silently never appears. One call checks them all: npx genex wait --all.`
|
|
4778
|
+
);
|
|
5064
4779
|
}
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
if (!Number.isFinite(raw) || raw <= 0) return "";
|
|
5070
|
-
const secs = Math.ceil(raw);
|
|
5071
|
-
return ` Try again in ${secs < 90 ? `${secs}s` : `~${Math.ceil(secs / 60)} min`}.`;
|
|
5072
|
-
}
|
|
5073
|
-
async function getUploadToken(ctx, commit, log) {
|
|
5074
|
-
let res;
|
|
5075
|
-
try {
|
|
5076
|
-
res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/upload-token`, {
|
|
5077
|
-
method: "POST",
|
|
5078
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.token}` },
|
|
5079
|
-
body: JSON.stringify({ commit })
|
|
5080
|
-
});
|
|
5081
|
-
} catch (err) {
|
|
5082
|
-
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
5083
|
-
return null;
|
|
4780
|
+
if (g.deadWired.length) {
|
|
4781
|
+
log.warn(
|
|
4782
|
+
`${g.deadWired.length} FAILED generation(s) are still wired into the game: ${describe(g.deadWired)}. Those URLs will never exist \u2014 each asset silently never appears. Replace the wiring or re-generate (that bills a new one), and tell the user what changed.`
|
|
4783
|
+
);
|
|
5084
4784
|
}
|
|
5085
|
-
if (
|
|
5086
|
-
log.
|
|
5087
|
-
|
|
5088
|
-
|
|
4785
|
+
if (g.unwired.length) {
|
|
4786
|
+
log.warn(
|
|
4787
|
+
`${g.unwired.length} finished generation(s) are wired nowhere in the game: ${describe(g.unwired)}. Each was paid for \u2014 wire it in (its URL embeds the id) or tell the user in one plain line why it stays unused.`
|
|
4788
|
+
);
|
|
5089
4789
|
}
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
4790
|
+
}
|
|
4791
|
+
function surfaceNudges(log, s) {
|
|
4792
|
+
if (s.guessedRepeat.length) {
|
|
4793
|
+
const list = s.guessedRepeat.map((d) => `${d.where} \u2014 ${d.detail}`).join("; ");
|
|
4794
|
+
log.warn(
|
|
4795
|
+
`Texture tiling picked by eye, not derived from the mesh: ${list}. A hand-chosen repeat is right for at most ONE face \u2014 a BoxGeometry gives all six faces 0..1 UVs whatever their world size, and repeat lives on the Texture, so every mesh sharing that texture inherits it. Derive the UVs from world size instead: genex-ai-texture ships worldUV(geometry, TILE_M), after which repeat stays (1, 1) and cannot be wrong.`
|
|
4796
|
+
);
|
|
5094
4797
|
}
|
|
5095
|
-
if (
|
|
5096
|
-
|
|
5097
|
-
|
|
4798
|
+
if (s.squarePoints.length) {
|
|
4799
|
+
const list = s.squarePoints.map((d) => d.where).join(", ");
|
|
4800
|
+
log.warn(
|
|
4801
|
+
`Particles will render as hard SQUARES: ${list} builds a PointsMaterial with no map/alphaMap, so every point is an opaque camera-facing quad. Give it a soft sprite (genex image --transparent) or a round gl_PointCoord cutout \u2014 and check the particle belongs there at all: genex-threejs-procedural-vfx decides that per moment.`
|
|
4802
|
+
);
|
|
5098
4803
|
}
|
|
5099
|
-
|
|
5100
|
-
log.
|
|
5101
|
-
|
|
4804
|
+
for (const d of s.depthRange) {
|
|
4805
|
+
log.warn(
|
|
4806
|
+
`Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get. If the range is genuinely astronomical, that is what WebGLRenderer's logarithmicDepthBuffer is for: genex-threejs-camera-direction.`
|
|
4807
|
+
);
|
|
5102
4808
|
}
|
|
5103
|
-
return await res.json();
|
|
5104
4809
|
}
|
|
5105
|
-
async function
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
const encoded = f.relPath.split("/").map(encodeURIComponent).join("/");
|
|
5118
|
-
let res = null;
|
|
5119
|
-
try {
|
|
5120
|
-
res = await fetch(`${base}/${encoded}`, {
|
|
5121
|
-
method: "PUT",
|
|
5122
|
-
headers: { Authorization: `Bearer ${grant.token}` },
|
|
5123
|
-
body: f.bytes
|
|
5124
|
-
});
|
|
5125
|
-
} catch {
|
|
5126
|
-
res = null;
|
|
5127
|
-
}
|
|
5128
|
-
if (!res || !res.ok) {
|
|
5129
|
-
failed = true;
|
|
5130
|
-
log.warn(`Upload failed for ${f.relPath}${res ? ` (HTTP ${res.status})` : " (network error)"}.`);
|
|
5131
|
-
}
|
|
4810
|
+
async function exploreReuseNudge(log, meta, cwd = process.cwd()) {
|
|
4811
|
+
if (!meta.exploreRanAt || meta.reuseNudgedAt) return;
|
|
4812
|
+
if (await borrowEvidence(meta, cwd)) return;
|
|
4813
|
+
log.warn(
|
|
4814
|
+
"This project browsed the curated catalog (genex explore) but no borrowed catalog project is visible in the game. If the plan mapped systems to the catalog \u2014 grass, terrain, water, vehicles \u2014 land those borrows or cancel each in one plain line to the user: a planned borrow that silently becomes hand-rolled code is a scope cut."
|
|
4815
|
+
);
|
|
4816
|
+
meta.reuseNudgedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4817
|
+
try {
|
|
4818
|
+
const onDisk = await readProject(cwd);
|
|
4819
|
+
if (onDisk) {
|
|
4820
|
+
onDisk.reuseNudgedAt = meta.reuseNudgedAt;
|
|
4821
|
+
await writeProject(onDisk, cwd);
|
|
5132
4822
|
}
|
|
5133
|
-
}
|
|
5134
|
-
await Promise.all(Array.from({ length: Math.min(8, small.length) }, () => worker()));
|
|
5135
|
-
if (failed) return false;
|
|
5136
|
-
for (const f of large) {
|
|
5137
|
-
if (!await uploadMultipart(base, grant.token, f, grant.limits.partSizeBytes, log)) return false;
|
|
4823
|
+
} catch {
|
|
5138
4824
|
}
|
|
5139
|
-
return true;
|
|
5140
4825
|
}
|
|
5141
|
-
async function
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
const totalParts = Math.ceil(f.bytes.length / partSize);
|
|
5145
|
-
log.step(`Uploading ${f.relPath} (${(f.bytes.length / 1048576).toFixed(1)} MB in ${totalParts} parts)\u2026`);
|
|
5146
|
-
let uploadId;
|
|
4826
|
+
async function borrowEvidence(meta, cwd) {
|
|
4827
|
+
let host;
|
|
4828
|
+
let ownPath;
|
|
5147
4829
|
try {
|
|
5148
|
-
const
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
return false;
|
|
5152
|
-
}
|
|
5153
|
-
uploadId = (await res.json()).uploadId;
|
|
4830
|
+
const own = new URL(meta.cloneUrl);
|
|
4831
|
+
host = own.host;
|
|
4832
|
+
ownPath = own.pathname;
|
|
5154
4833
|
} catch {
|
|
5155
|
-
|
|
5156
|
-
return false;
|
|
4834
|
+
return true;
|
|
5157
4835
|
}
|
|
5158
|
-
const
|
|
4836
|
+
const gitConfig = await fs14.readFile(path14.join(cwd, ".git", "config"), "utf8").catch(() => "");
|
|
4837
|
+
for (const m of gitConfig.matchAll(/url\s*=\s*(\S+)/g)) {
|
|
5159
4838
|
try {
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
headers: auth
|
|
5163
|
-
});
|
|
4839
|
+
const u = new URL(m[1]);
|
|
4840
|
+
if (u.host === host && u.pathname !== ownPath) return true;
|
|
5164
4841
|
} catch {
|
|
5165
4842
|
}
|
|
5166
|
-
}
|
|
5167
|
-
const
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
4843
|
+
}
|
|
4844
|
+
const readme = await fs14.readFile(path14.join(cwd, "README.md"), "utf8").catch(() => "");
|
|
4845
|
+
return readme.includes(host) || /\b(upstream|originally by|ported from|borrowed from)\b/i.test(readme);
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
// src/commands/publish.ts
|
|
4849
|
+
async function runPublish(opts) {
|
|
4850
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
4851
|
+
log.plain(c.bold("genex publish"));
|
|
4852
|
+
log.plain("");
|
|
4853
|
+
const token = opts.token ?? await readUserToken(opts.envPath);
|
|
4854
|
+
if (!token) {
|
|
4855
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
4856
|
+
process.exitCode = 1;
|
|
4857
|
+
return;
|
|
4858
|
+
}
|
|
4859
|
+
const meta = await readProject();
|
|
4860
|
+
if (!meta) {
|
|
4861
|
+
log.error("No genex project here. Run `genex init` in this directory first.");
|
|
4862
|
+
process.exitCode = 1;
|
|
4863
|
+
return;
|
|
4864
|
+
}
|
|
4865
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
|
|
4866
|
+
const detections = await detectFeatures(log);
|
|
4867
|
+
advisoryNudges(log, detections);
|
|
4868
|
+
if (!opts.noPush) {
|
|
4869
|
+
const ok = await deployGame(
|
|
4870
|
+
{ projectId: meta.id, apiUrl, token },
|
|
4871
|
+
{
|
|
4872
|
+
noBuild: opts.noBuild,
|
|
4873
|
+
matchmaking: detections.matchmaking,
|
|
4874
|
+
embedSdkVersion: detections.embedSdkVersion,
|
|
4875
|
+
multiplayer: detections.multiplayer,
|
|
4876
|
+
mobileControls: detections.mobileControls
|
|
4877
|
+
},
|
|
4878
|
+
log
|
|
4879
|
+
);
|
|
4880
|
+
if (!ok) {
|
|
4881
|
+
process.exitCode = 1;
|
|
4882
|
+
return;
|
|
5183
4883
|
}
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
4884
|
+
const onDisk = await readProject();
|
|
4885
|
+
const merged = { ...onDisk ?? meta, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4886
|
+
await writeProject(merged);
|
|
4887
|
+
meta.lastDeployAt = merged.lastDeployAt;
|
|
4888
|
+
} else {
|
|
4889
|
+
log.info("Skipping build + deploy (--no-push).");
|
|
4890
|
+
}
|
|
4891
|
+
log.step("Listing it in the gallery\u2026");
|
|
4892
|
+
let res;
|
|
4893
|
+
try {
|
|
4894
|
+
const body = {};
|
|
4895
|
+
if (opts.title) body.title = opts.title;
|
|
4896
|
+
if (opts.description) body.description = opts.description;
|
|
4897
|
+
if (opts.regenerateCover) body.regenerateCover = true;
|
|
4898
|
+
if (opts.categories?.length) body.categories = opts.categories;
|
|
4899
|
+
if (opts.sourceRepoUrl) body.sourceRepoUrl = opts.sourceRepoUrl;
|
|
4900
|
+
if (opts.sourceAuthor) body.sourceAuthor = opts.sourceAuthor;
|
|
4901
|
+
if (opts.license) body.license = opts.license;
|
|
4902
|
+
if (opts.purchaseUrl) body.purchaseUrl = opts.purchaseUrl;
|
|
4903
|
+
if (opts.productName) body.productName = opts.productName;
|
|
4904
|
+
if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
|
|
4905
|
+
body.multiplayer = detections.multiplayer;
|
|
4906
|
+
body.matchmaking = detections.matchmaking ?? null;
|
|
4907
|
+
body.gameStateUsed = detections.gameStateUsed;
|
|
4908
|
+
body.mobileControls = detections.mobileControls;
|
|
4909
|
+
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
4910
|
+
method: "POST",
|
|
4911
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
4912
|
+
body: JSON.stringify(body)
|
|
4913
|
+
});
|
|
4914
|
+
} catch {
|
|
4915
|
+
log.error("Couldn't reach Genex \u2014 please try again.");
|
|
4916
|
+
process.exitCode = 1;
|
|
4917
|
+
return;
|
|
4918
|
+
}
|
|
4919
|
+
if (!res.ok) {
|
|
4920
|
+
log.error("Publish failed \u2014 please try again.");
|
|
4921
|
+
process.exitCode = 1;
|
|
4922
|
+
return;
|
|
4923
|
+
}
|
|
4924
|
+
log.plain("");
|
|
4925
|
+
log.success("Published. \u{1F389}");
|
|
4926
|
+
const dashboard = meta.dashboardOrigins?.[0];
|
|
4927
|
+
if (dashboard) log.plain(` world page (share this link): ${c.cyan(`${dashboard}/world/${meta.slug}`)}`);
|
|
4928
|
+
if (meta.playUrl) log.dim(` play: ${meta.playUrl}`);
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
// src/commands/preview.ts
|
|
4932
|
+
async function runPreview(opts) {
|
|
4933
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
4934
|
+
log.plain(c.bold("genex preview"));
|
|
4935
|
+
log.plain("");
|
|
4936
|
+
const meta = await readProject();
|
|
4937
|
+
if (!meta) {
|
|
4938
|
+
log.error("No genex project here. Run `genex init` in this directory first.");
|
|
4939
|
+
process.exitCode = 1;
|
|
4940
|
+
return;
|
|
4941
|
+
}
|
|
4942
|
+
const token = await readUserToken(opts.envPath);
|
|
4943
|
+
if (!token) {
|
|
4944
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
4945
|
+
process.exitCode = 1;
|
|
4946
|
+
return;
|
|
4947
|
+
}
|
|
4948
|
+
const detections = await detectFeatures(log);
|
|
4949
|
+
advisoryNudges(log, detections);
|
|
4950
|
+
await exploreReuseNudge(log, meta);
|
|
4951
|
+
const apiUrl = getApiUrl(meta.apiUrl);
|
|
4952
|
+
const ok = await deployGame(
|
|
4953
|
+
{ projectId: meta.id, apiUrl, token },
|
|
4954
|
+
// Detections reach the server on every preview too: matchmaking so a draft's
|
|
4955
|
+
// declared preset doesn't silently run the default (null clears a removed
|
|
4956
|
+
// config), embedSdkVersion/multiplayer so the dashboard's Publish button can
|
|
4957
|
+
// list the draft without wrongly treating it as a pre-embed-auth bundle.
|
|
4958
|
+
{
|
|
4959
|
+
noBuild: opts.noBuild,
|
|
4960
|
+
matchmaking: detections.matchmaking,
|
|
4961
|
+
embedSdkVersion: detections.embedSdkVersion,
|
|
4962
|
+
multiplayer: detections.multiplayer,
|
|
4963
|
+
mobileControls: detections.mobileControls
|
|
4964
|
+
},
|
|
4965
|
+
log
|
|
4966
|
+
);
|
|
4967
|
+
if (!ok) {
|
|
4968
|
+
process.exitCode = 1;
|
|
4969
|
+
return;
|
|
4970
|
+
}
|
|
4971
|
+
const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
|
|
4972
|
+
if (freshStatus && freshStatus !== meta.status) {
|
|
4973
|
+
meta.status = freshStatus;
|
|
5189
4974
|
}
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
return false;
|
|
4975
|
+
const onDisk = await readProject();
|
|
4976
|
+
const merged = { ...onDisk ?? meta, status: meta.status, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4977
|
+
await writeProject(merged);
|
|
4978
|
+
meta.lastDeployAt = merged.lastDeployAt;
|
|
4979
|
+
const published = meta.status === "published";
|
|
4980
|
+
log.plain("");
|
|
4981
|
+
if (published) {
|
|
4982
|
+
log.success("Update deployed \u2014 this game is PUBLISHED, so the new build is already live for everyone.");
|
|
4983
|
+
} else {
|
|
4984
|
+
log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
|
|
4985
|
+
}
|
|
4986
|
+
const dashboard = meta.dashboardOrigins?.[0];
|
|
4987
|
+
if (dashboard) {
|
|
4988
|
+
const page = published ? "world" : "draft";
|
|
4989
|
+
log.plain(` your game's page (share this link): ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
5206
4990
|
}
|
|
5207
|
-
return true;
|
|
5208
4991
|
}
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
4992
|
+
|
|
4993
|
+
// src/commands/generate.ts
|
|
4994
|
+
import fs15 from "fs/promises";
|
|
4995
|
+
import path15 from "path";
|
|
4996
|
+
import { PNG as PNG4 } from "pngjs";
|
|
4997
|
+
|
|
4998
|
+
// src/lib/glass.ts
|
|
4999
|
+
import { PNG as PNG3 } from "pngjs";
|
|
5000
|
+
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.";
|
|
5001
|
+
var GLASS_MIN_FLATNESS = 0.99;
|
|
5002
|
+
var GLASS_MIN_KEY_STRENGTH = 180;
|
|
5003
|
+
var FLATNESS_DEV_MAX = 12;
|
|
5004
|
+
var clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
|
5005
|
+
function median(values) {
|
|
5006
|
+
const s = values.slice().sort((a, b) => a - b);
|
|
5007
|
+
return s[Math.floor(s.length / 2)] ?? 0;
|
|
5008
|
+
}
|
|
5009
|
+
function solveMagentaGlass(source) {
|
|
5010
|
+
const { width: W, height: H, data } = source;
|
|
5011
|
+
const ringW = Math.max(2, Math.round(Math.min(W, H) * 0.04));
|
|
5012
|
+
const rs = [];
|
|
5013
|
+
const gs = [];
|
|
5014
|
+
const bs = [];
|
|
5015
|
+
const ringIdx = [];
|
|
5016
|
+
for (let y = 0; y < H; y++) {
|
|
5017
|
+
for (let x = 0; x < W; x++) {
|
|
5018
|
+
const inRing = x < ringW || y < ringW || x >= W - ringW || y >= H - ringW;
|
|
5019
|
+
if (!inRing) continue;
|
|
5020
|
+
const i = (y * W + x) * 4;
|
|
5021
|
+
ringIdx.push(i);
|
|
5022
|
+
rs.push(data[i]);
|
|
5023
|
+
gs.push(data[i + 1]);
|
|
5024
|
+
bs.push(data[i + 2]);
|
|
5220
5025
|
}
|
|
5221
|
-
if (res && res.status < 500) return null;
|
|
5222
|
-
if (attempt >= 2) return null;
|
|
5223
|
-
await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 500 : 2e3));
|
|
5224
5026
|
}
|
|
5225
|
-
}
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
commit,
|
|
5237
|
-
matchmaking: opts.matchmaking ?? null,
|
|
5238
|
-
...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
|
|
5239
|
-
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
|
|
5240
|
-
...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
|
|
5241
|
-
})
|
|
5242
|
-
});
|
|
5243
|
-
} catch (err) {
|
|
5244
|
-
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
5245
|
-
return false;
|
|
5027
|
+
const K = { r: median(rs), g: median(gs), b: median(bs) };
|
|
5028
|
+
const keyHex = `#${[K.r, K.g, K.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
5029
|
+
const keyStrength = Math.min(K.r, K.b) - K.g;
|
|
5030
|
+
let flat = 0;
|
|
5031
|
+
for (const i of ringIdx) {
|
|
5032
|
+
const dev = Math.max(
|
|
5033
|
+
Math.abs(data[i] - K.r),
|
|
5034
|
+
Math.abs(data[i + 1] - K.g),
|
|
5035
|
+
Math.abs(data[i + 2] - K.b)
|
|
5036
|
+
);
|
|
5037
|
+
if (dev < FLATNESS_DEV_MAX) flat++;
|
|
5246
5038
|
}
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5039
|
+
const flatness = ringIdx.length === 0 ? 0 : flat / ringIdx.length;
|
|
5040
|
+
const base = { keyColor: K, keyHex, keyStrength, flatness };
|
|
5041
|
+
if (flatness < GLASS_MIN_FLATNESS || keyStrength < GLASS_MIN_KEY_STRENGTH) {
|
|
5042
|
+
return {
|
|
5043
|
+
...base,
|
|
5044
|
+
png: source,
|
|
5045
|
+
ok: false,
|
|
5046
|
+
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.`
|
|
5047
|
+
};
|
|
5250
5048
|
}
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5049
|
+
const out = new PNG3({ width: W, height: H });
|
|
5050
|
+
const mKey = Math.max(1, keyStrength);
|
|
5051
|
+
for (let p = 0; p < W * H; p++) {
|
|
5052
|
+
const i = p * 4;
|
|
5053
|
+
const R = data[i];
|
|
5054
|
+
const G = data[i + 1];
|
|
5055
|
+
const B = data[i + 2];
|
|
5056
|
+
const m = Math.max(0, Math.min(R, B) - G);
|
|
5057
|
+
let a = 1 - m / mKey;
|
|
5058
|
+
if (a < 0) a = 0;
|
|
5059
|
+
if (a > 1) a = 1;
|
|
5060
|
+
if (a <= 0.02) {
|
|
5061
|
+
out.data[i] = 0;
|
|
5062
|
+
out.data[i + 1] = 0;
|
|
5063
|
+
out.data[i + 2] = 0;
|
|
5064
|
+
out.data[i + 3] = 0;
|
|
5065
|
+
continue;
|
|
5066
|
+
}
|
|
5067
|
+
out.data[i] = clamp255((R - (1 - a) * K.r) / a);
|
|
5068
|
+
out.data[i + 1] = clamp255((G - (1 - a) * K.g) / a);
|
|
5069
|
+
out.data[i + 2] = clamp255((B - (1 - a) * K.b) / a);
|
|
5070
|
+
out.data[i + 3] = Math.round(a * 255);
|
|
5255
5071
|
}
|
|
5256
|
-
return true;
|
|
5257
|
-
}
|
|
5258
|
-
async function pushSource(cwd, ctx, log) {
|
|
5259
|
-
const target = await fetchPushUrl(ctx, log);
|
|
5260
|
-
if (!target) return false;
|
|
5261
|
-
if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
|
|
5262
|
-
if (!target.managed) return false;
|
|
5263
|
-
log.info("Retrying the source push\u2026");
|
|
5264
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
5265
|
-
const fresh = await fetchPushUrl(ctx, log);
|
|
5266
|
-
if (!fresh) return false;
|
|
5267
|
-
return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
|
|
5072
|
+
return { ...base, png: out, ok: true };
|
|
5268
5073
|
}
|
|
5269
|
-
|
|
5270
|
-
|
|
5074
|
+
|
|
5075
|
+
// src/lib/open.ts
|
|
5076
|
+
import { spawn as spawn4 } from "child_process";
|
|
5077
|
+
function tokenize(cmd) {
|
|
5078
|
+
return cmd.trim().split(/\s+/).filter(Boolean);
|
|
5271
5079
|
}
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
path14.join(gitDir, "info", "exclude"),
|
|
5295
|
-
// .env* are secrets — never publish them; `!` keeps the non-secret template.
|
|
5296
|
-
["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
|
|
5297
|
-
);
|
|
5298
|
-
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path14.join(gitDir, "index-source") };
|
|
5299
|
-
let lfs = (await run("git", ["lfs", "version"], base)).code !== 0 ? false : true;
|
|
5300
|
-
if (!lfs) {
|
|
5301
|
-
log.step("Installing git-lfs (keeps large binary assets out of the source push)\u2026");
|
|
5302
|
-
const [bin, args] = process.platform === "darwin" ? ["brew", ["install", "git-lfs"]] : ["sudo", ["-n", "apt-get", "install", "-y", "git-lfs"]];
|
|
5303
|
-
await run(bin, args, base);
|
|
5304
|
-
lfs = (await run("git", ["lfs", "version"], base)).code === 0;
|
|
5305
|
-
if (!lfs) {
|
|
5306
|
-
log.warn(
|
|
5307
|
-
"Couldn't install git-lfs \u2014 large binary assets won't be saved to your source (the game still publishes)."
|
|
5308
|
-
);
|
|
5309
|
-
}
|
|
5310
|
-
}
|
|
5311
|
-
if (lfs) {
|
|
5312
|
-
await writeGitattributes(cwd, log);
|
|
5313
|
-
const filters = [
|
|
5314
|
-
["filter.lfs.clean", "git-lfs clean -- %f"],
|
|
5315
|
-
["filter.lfs.smudge", "git-lfs smudge -- %f"],
|
|
5316
|
-
["filter.lfs.process", "git-lfs filter-process"],
|
|
5317
|
-
["filter.lfs.required", "true"]
|
|
5318
|
-
];
|
|
5319
|
-
for (const [key, value] of filters) await run("git", ["config", key, value], base);
|
|
5320
|
-
}
|
|
5321
|
-
await run("git", ["add", "-A"], env);
|
|
5322
|
-
const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
|
|
5323
|
-
if (!tree || tree === EMPTY_TREE) {
|
|
5324
|
-
log.error("Nothing to publish \u2014 the project has no files.");
|
|
5325
|
-
return false;
|
|
5326
|
-
}
|
|
5327
|
-
const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
|
|
5328
|
-
await run("git", ["update-ref", "refs/heads/main", commit], base);
|
|
5329
|
-
if (lfs && (await run("git", ["lfs", "push", "--all", pushUrl, "main"], base)).code !== 0) {
|
|
5330
|
-
log.error("Couldn't upload your game's binary assets to the source store.");
|
|
5331
|
-
return false;
|
|
5332
|
-
}
|
|
5333
|
-
const push = await run(
|
|
5334
|
-
"git",
|
|
5335
|
-
["-c", "http.postBuffer=536870912", "push", "-q", pushUrl, "+refs/heads/main:main"],
|
|
5336
|
-
base
|
|
5337
|
-
);
|
|
5338
|
-
if (push.code === 0) return true;
|
|
5339
|
-
if (!managed) {
|
|
5340
|
-
log.error(
|
|
5341
|
-
"Couldn't push to your repo. Check that you have push access (SSH key or git credentials) and that the repo exists."
|
|
5342
|
-
);
|
|
5343
|
-
return false;
|
|
5080
|
+
function openUrl(url) {
|
|
5081
|
+
let command;
|
|
5082
|
+
let args;
|
|
5083
|
+
const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
|
|
5084
|
+
const customParts = custom ? tokenize(custom) : [];
|
|
5085
|
+
if (customParts.length > 0) {
|
|
5086
|
+
command = customParts[0];
|
|
5087
|
+
args = [...customParts.slice(1), url];
|
|
5088
|
+
} else {
|
|
5089
|
+
switch (process.platform) {
|
|
5090
|
+
case "darwin":
|
|
5091
|
+
command = "open";
|
|
5092
|
+
args = [url];
|
|
5093
|
+
break;
|
|
5094
|
+
case "win32":
|
|
5095
|
+
command = "cmd";
|
|
5096
|
+
args = ["/c", "start", "", url];
|
|
5097
|
+
break;
|
|
5098
|
+
default:
|
|
5099
|
+
command = "xdg-open";
|
|
5100
|
+
args = [url];
|
|
5101
|
+
break;
|
|
5344
5102
|
}
|
|
5345
|
-
return failed();
|
|
5346
|
-
} catch {
|
|
5347
|
-
return failed();
|
|
5348
|
-
} finally {
|
|
5349
|
-
await fs14.rm(gitDir, { recursive: true, force: true }).catch(() => {
|
|
5350
|
-
});
|
|
5351
5103
|
}
|
|
5352
|
-
}
|
|
5353
|
-
async function fetchPushUrl(ctx, log) {
|
|
5354
|
-
let res;
|
|
5355
5104
|
try {
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
headers: { Authorization: `Bearer ${ctx.token}` }
|
|
5105
|
+
const child = spawn4(command, args, { stdio: "ignore", detached: true });
|
|
5106
|
+
child.on("error", () => {
|
|
5359
5107
|
});
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
return null;
|
|
5363
|
-
}
|
|
5364
|
-
if (res.status === 401) {
|
|
5365
|
-
log.error("Not authorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
5366
|
-
return null;
|
|
5367
|
-
}
|
|
5368
|
-
if (res.status === 429) {
|
|
5369
|
-
log.error(`Rate limited authorizing the source push (HTTP 429).${retryAfterHint(res)}`);
|
|
5370
|
-
return null;
|
|
5371
|
-
}
|
|
5372
|
-
if (!res.ok) {
|
|
5373
|
-
log.error(`Couldn't authorize the source push (HTTP ${res.status}).`);
|
|
5374
|
-
return null;
|
|
5108
|
+
child.unref();
|
|
5109
|
+
} catch {
|
|
5375
5110
|
}
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
// src/lib/sse.ts
|
|
5114
|
+
async function* readSSE(body) {
|
|
5115
|
+
const decoder = new TextDecoder();
|
|
5116
|
+
const reader = body.getReader();
|
|
5117
|
+
let buf = "";
|
|
5118
|
+
let event = "";
|
|
5119
|
+
let data = [];
|
|
5120
|
+
try {
|
|
5121
|
+
while (true) {
|
|
5122
|
+
const { done, value } = await reader.read();
|
|
5123
|
+
if (done) break;
|
|
5124
|
+
buf += decoder.decode(value, { stream: true });
|
|
5125
|
+
while (true) {
|
|
5126
|
+
const nl = buf.search(/[\r\n]/);
|
|
5127
|
+
if (nl === -1) break;
|
|
5128
|
+
if (buf[nl] === "\r" && nl === buf.length - 1) break;
|
|
5129
|
+
const sep = buf[nl] === "\r" && buf[nl + 1] === "\n" ? 2 : 1;
|
|
5130
|
+
const line = buf.slice(0, nl);
|
|
5131
|
+
buf = buf.slice(nl + sep);
|
|
5132
|
+
if (line === "") {
|
|
5133
|
+
if (data.length > 0) yield { event: event || "message", data: data.join("\n") };
|
|
5134
|
+
event = "";
|
|
5135
|
+
data = [];
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
if (line.startsWith(":")) continue;
|
|
5139
|
+
const colon = line.indexOf(":");
|
|
5140
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
5141
|
+
let value2 = colon === -1 ? "" : line.slice(colon + 1);
|
|
5142
|
+
if (value2.startsWith(" ")) value2 = value2.slice(1);
|
|
5143
|
+
if (field === "event") event = value2;
|
|
5144
|
+
else if (field === "data") data.push(value2);
|
|
5145
|
+
}
|
|
5146
|
+
}
|
|
5147
|
+
} finally {
|
|
5148
|
+
reader.releaseLock();
|
|
5380
5149
|
}
|
|
5381
|
-
return { pushUrl: data.pushUrl, managed: data.managed !== false };
|
|
5382
5150
|
}
|
|
5383
|
-
|
|
5151
|
+
|
|
5152
|
+
// src/commands/generate.ts
|
|
5153
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5154
|
+
var INLINE_IMAGE_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
5155
|
+
var IMAGE_MIME_BY_EXT = {
|
|
5156
|
+
".png": "image/png",
|
|
5157
|
+
".jpg": "image/jpeg",
|
|
5158
|
+
".jpeg": "image/jpeg",
|
|
5159
|
+
".webp": "image/webp"
|
|
5160
|
+
};
|
|
5161
|
+
var isRemoteRef = (value) => /^(https?:\/\/|data:)/i.test(value);
|
|
5162
|
+
async function inlineLocalImage(filePath, flag) {
|
|
5163
|
+
let bytes;
|
|
5384
5164
|
try {
|
|
5385
|
-
|
|
5165
|
+
bytes = await fs15.readFile(filePath);
|
|
5386
5166
|
} catch {
|
|
5387
|
-
return false;
|
|
5167
|
+
return { ok: false, error: `Couldn't read the ${flag} file at ${filePath}.` };
|
|
5168
|
+
}
|
|
5169
|
+
if (bytes.length > INLINE_IMAGE_LIMIT_BYTES) {
|
|
5170
|
+
return {
|
|
5171
|
+
ok: false,
|
|
5172
|
+
error: `${flag} file is ${(bytes.length / 1048576).toFixed(1)} MB \u2014 over the ~4 MB inline limit. Downscale/compress it first, or pass an asset URL instead.`
|
|
5173
|
+
};
|
|
5388
5174
|
}
|
|
5175
|
+
const mime = IMAGE_MIME_BY_EXT[path15.extname(filePath).toLowerCase()] ?? "image/png";
|
|
5176
|
+
return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
|
|
5389
5177
|
}
|
|
5390
|
-
var
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5178
|
+
var SKYBOX_ENVIRONMENT_SUFFIX = ". The image contains ONLY sky: cloud, atmosphere, light, weather and distant haze at the horizon. Every structure, object, plant and ground surface is outside the frame.";
|
|
5179
|
+
var SKYBOX_PROMPT_MAX = 2e3;
|
|
5180
|
+
var SKYBOX_STRUCTURE_NOUNS = [
|
|
5181
|
+
"building",
|
|
5182
|
+
"buildings",
|
|
5183
|
+
"house",
|
|
5184
|
+
"houses",
|
|
5185
|
+
"hut",
|
|
5186
|
+
"huts",
|
|
5187
|
+
"cabin",
|
|
5188
|
+
"cabins",
|
|
5189
|
+
"tower",
|
|
5190
|
+
"towers",
|
|
5191
|
+
"castle",
|
|
5192
|
+
"castles",
|
|
5193
|
+
"cathedral",
|
|
5194
|
+
"cathedrals",
|
|
5195
|
+
"church",
|
|
5196
|
+
"churches",
|
|
5197
|
+
"temple",
|
|
5198
|
+
"temples",
|
|
5199
|
+
"shrine",
|
|
5200
|
+
"shrines",
|
|
5201
|
+
"monastery",
|
|
5202
|
+
"palace",
|
|
5203
|
+
"fortress",
|
|
5204
|
+
"citadel",
|
|
5205
|
+
"ruin",
|
|
5206
|
+
"ruins",
|
|
5207
|
+
"city",
|
|
5208
|
+
"cityscape",
|
|
5209
|
+
"skyline",
|
|
5210
|
+
"town",
|
|
5211
|
+
"village",
|
|
5212
|
+
"settlement",
|
|
5213
|
+
"street",
|
|
5214
|
+
"streets",
|
|
5215
|
+
"road",
|
|
5216
|
+
"roads",
|
|
5217
|
+
"courtyard",
|
|
5218
|
+
"plaza",
|
|
5219
|
+
"bridge",
|
|
5220
|
+
"bridges",
|
|
5221
|
+
"wall",
|
|
5222
|
+
"walls",
|
|
5223
|
+
"gate",
|
|
5224
|
+
"gates",
|
|
5225
|
+
"fence",
|
|
5226
|
+
"fences",
|
|
5227
|
+
"pillar",
|
|
5228
|
+
"pillars",
|
|
5229
|
+
"column",
|
|
5230
|
+
"columns",
|
|
5231
|
+
"statue",
|
|
5232
|
+
"statues",
|
|
5233
|
+
"monument",
|
|
5234
|
+
"obelisk",
|
|
5235
|
+
"spire",
|
|
5236
|
+
"spires",
|
|
5237
|
+
"rooftop",
|
|
5238
|
+
"rooftops",
|
|
5239
|
+
"tree",
|
|
5240
|
+
"trees",
|
|
5241
|
+
"forest",
|
|
5242
|
+
"jungle",
|
|
5243
|
+
"ship",
|
|
5244
|
+
"ships",
|
|
5245
|
+
"boat",
|
|
5246
|
+
"boats",
|
|
5247
|
+
"car",
|
|
5248
|
+
"cars",
|
|
5249
|
+
"vehicle",
|
|
5250
|
+
"vehicles",
|
|
5251
|
+
"person",
|
|
5252
|
+
"people",
|
|
5253
|
+
"figure",
|
|
5254
|
+
"figures",
|
|
5255
|
+
"character",
|
|
5256
|
+
"characters",
|
|
5257
|
+
"creature",
|
|
5258
|
+
"creatures",
|
|
5259
|
+
"dragon",
|
|
5260
|
+
"banner",
|
|
5261
|
+
"banners",
|
|
5262
|
+
"flag",
|
|
5263
|
+
"flags",
|
|
5264
|
+
"torch",
|
|
5265
|
+
"torches",
|
|
5266
|
+
"lamp",
|
|
5267
|
+
"lamps"
|
|
5268
|
+
];
|
|
5269
|
+
var SKYBOX_NOUN_RE = new RegExp(`\\b(${SKYBOX_STRUCTURE_NOUNS.join("|")})\\b`, "gi");
|
|
5270
|
+
function skyboxStructureNouns(prompt) {
|
|
5271
|
+
const found = /* @__PURE__ */ new Set();
|
|
5272
|
+
for (const m of prompt.matchAll(SKYBOX_NOUN_RE)) found.add(m[1].toLowerCase());
|
|
5273
|
+
return [...found];
|
|
5394
5274
|
}
|
|
5395
|
-
|
|
5396
|
-
if (
|
|
5397
|
-
|
|
5398
|
-
|
|
5275
|
+
function applySkyboxGuard(prompt, raw) {
|
|
5276
|
+
if (raw) return { prompt, guarded: false, nouns: [] };
|
|
5277
|
+
const nouns = skyboxStructureNouns(prompt);
|
|
5278
|
+
const guarded = `${prompt}${SKYBOX_ENVIRONMENT_SUFFIX}`;
|
|
5279
|
+
if (guarded.length > SKYBOX_PROMPT_MAX) return { prompt, guarded: false, nouns };
|
|
5280
|
+
return { prompt: guarded, guarded: true, nouns };
|
|
5281
|
+
}
|
|
5282
|
+
function buildGenOptions(kind, opts) {
|
|
5283
|
+
const options = { ...opts.generationOptions };
|
|
5284
|
+
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
5285
|
+
if ((kind === "sfx" || kind === "music") && opts.duration) options.durationSeconds = opts.duration;
|
|
5286
|
+
if (kind === "voice") {
|
|
5287
|
+
if (opts.voice) options.voice = opts.voice;
|
|
5288
|
+
if (opts.voiceId) options.voiceId = opts.voiceId;
|
|
5399
5289
|
}
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5290
|
+
if (kind === "image") {
|
|
5291
|
+
if (opts.transparent) options.transparent = true;
|
|
5292
|
+
if (opts.aspect) options.aspect = opts.aspect;
|
|
5293
|
+
if (opts.quality) options.quality = opts.quality;
|
|
5294
|
+
if (opts.candidates !== void 0 && opts.candidates > 1) options.candidates = opts.candidates;
|
|
5295
|
+
if (opts.width && opts.height) {
|
|
5296
|
+
options.width = opts.width;
|
|
5297
|
+
options.height = opts.height;
|
|
5298
|
+
}
|
|
5299
|
+
if (opts.editUrl) {
|
|
5300
|
+
options.sourceUrl = opts.editUrl;
|
|
5301
|
+
options.op = "edit";
|
|
5302
|
+
}
|
|
5303
|
+
if (opts.cleanUrl) {
|
|
5304
|
+
options.sourceUrl = opts.cleanUrl;
|
|
5305
|
+
options.op = "remove-bg";
|
|
5306
|
+
options.bgMode = opts.bgMode ?? "sheet";
|
|
5307
|
+
}
|
|
5308
|
+
if (opts.upscaleUrl) {
|
|
5309
|
+
options.sourceUrl = opts.upscaleUrl;
|
|
5310
|
+
options.op = "upscale";
|
|
5410
5311
|
}
|
|
5411
|
-
|
|
5312
|
+
if (opts.inpaintUrl) options.maskUrl = opts.inpaintUrl;
|
|
5313
|
+
if (opts.removeBackground) options.removeBackground = true;
|
|
5314
|
+
if (opts.bgMode) options.bgMode = opts.bgMode;
|
|
5315
|
+
}
|
|
5316
|
+
if (kind === "video") {
|
|
5317
|
+
if (opts.duration) options.durationSeconds = opts.duration;
|
|
5318
|
+
if (opts.resolution) options.resolution = opts.resolution;
|
|
5319
|
+
if (opts.loop) options.loop = true;
|
|
5320
|
+
if (opts.frameUrl) options.frameUrl = opts.frameUrl;
|
|
5321
|
+
if (opts.firstFrameUrl) options.firstFrameUrl = opts.firstFrameUrl;
|
|
5322
|
+
if (opts.lastFrameUrl) options.lastFrameUrl = opts.lastFrameUrl;
|
|
5412
5323
|
}
|
|
5413
|
-
|
|
5414
|
-
log.dim(" (If the previous build shows, hard-refresh in a few seconds.)");
|
|
5324
|
+
return options;
|
|
5415
5325
|
}
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
log.plain("");
|
|
5422
|
-
const meta = await readProject();
|
|
5423
|
-
if (!meta) {
|
|
5424
|
-
log.error("No genex project here. Run `genex init` in this directory first.");
|
|
5326
|
+
async function runGenerate(kind, opts) {
|
|
5327
|
+
const log = createLogger({ quiet: opts.quiet || opts.json });
|
|
5328
|
+
const typedPrompt = opts.prompt?.trim();
|
|
5329
|
+
if (!typedPrompt) {
|
|
5330
|
+
log.error(`Missing prompt. Usage: ${c.cyan(`genex ${kind} "<prompt>"`)}`);
|
|
5425
5331
|
process.exitCode = 1;
|
|
5426
5332
|
return;
|
|
5427
5333
|
}
|
|
5428
|
-
const
|
|
5429
|
-
if (
|
|
5430
|
-
log.error(
|
|
5334
|
+
const skyboxGuard = kind === "skybox" ? applySkyboxGuard(typedPrompt, opts.raw) : void 0;
|
|
5335
|
+
if (skyboxGuard && skyboxGuard.nouns.length > 0) {
|
|
5336
|
+
log.error(
|
|
5337
|
+
`A skybox is sky only \u2014 this prompt names ${skyboxGuard.nouns.join(", ")}.`
|
|
5338
|
+
);
|
|
5339
|
+
log.plain(
|
|
5340
|
+
[
|
|
5341
|
+
" Structures painted into a sky render at infinite distance: they never get closer",
|
|
5342
|
+
" as the player moves, and they read as broken the moment the camera strafes.",
|
|
5343
|
+
` ${c.cyan("\u2192")} Build them as scene geometry (${c.cyan("npx genex model")}); describe only light, colour,`,
|
|
5344
|
+
" cloud, weather and time of day here.",
|
|
5345
|
+
` ${c.cyan("\u2192")} Genuinely want it painted into the sky (a space-station panorama for a scene`,
|
|
5346
|
+
` with no world geometry)? Re-run with ${c.cyan("--raw")}.`
|
|
5347
|
+
].join("\n")
|
|
5348
|
+
);
|
|
5431
5349
|
process.exitCode = 1;
|
|
5432
5350
|
return;
|
|
5433
5351
|
}
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
return;
|
|
5352
|
+
let prompt = skyboxGuard?.prompt ?? typedPrompt;
|
|
5353
|
+
if (kind === "image" && opts.glass) {
|
|
5354
|
+
const conflict = opts.transparent ? "--transparent" : opts.removeBackground ? "--remove-bg" : opts.editUrl ? "--edit" : opts.cleanUrl ? "--clean" : opts.upscaleUrl ? "--upscale" : opts.noWait ? "--no-wait" : void 0;
|
|
5355
|
+
if (conflict) {
|
|
5356
|
+
log.error(
|
|
5357
|
+
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.`
|
|
5358
|
+
);
|
|
5359
|
+
process.exitCode = 1;
|
|
5360
|
+
return;
|
|
5361
|
+
}
|
|
5362
|
+
prompt = `${prompt}${GLASS_PROMPT_SUFFIX}`;
|
|
5446
5363
|
}
|
|
5447
|
-
if (
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5364
|
+
if (kind === "image" && opts.inpaintUrl && !/^https?:\/\//i.test(opts.inpaintUrl)) {
|
|
5365
|
+
if (!opts.editUrl) {
|
|
5366
|
+
log.error("--inpaint targets an edit \u2014 pass --edit <asset-url> alongside it.");
|
|
5367
|
+
process.exitCode = 1;
|
|
5368
|
+
return;
|
|
5369
|
+
}
|
|
5370
|
+
try {
|
|
5371
|
+
const bytes = await fs15.readFile(opts.inpaintUrl);
|
|
5372
|
+
opts = { ...opts, inpaintUrl: `data:image/png;base64,${bytes.toString("base64")}` };
|
|
5373
|
+
} catch {
|
|
5374
|
+
log.error(`Couldn't read the --inpaint mask at ${opts.inpaintUrl}.`);
|
|
5375
|
+
process.exitCode = 1;
|
|
5376
|
+
return;
|
|
5377
|
+
}
|
|
5451
5378
|
}
|
|
5452
|
-
if (
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5379
|
+
if (kind === "image" && opts.editUrl && !isRemoteRef(opts.editUrl)) {
|
|
5380
|
+
const inlined = await inlineLocalImage(opts.editUrl, "--edit");
|
|
5381
|
+
if (!inlined.ok) {
|
|
5382
|
+
log.error(inlined.error);
|
|
5383
|
+
process.exitCode = 1;
|
|
5384
|
+
return;
|
|
5385
|
+
}
|
|
5386
|
+
opts = { ...opts, editUrl: inlined.dataUri };
|
|
5456
5387
|
}
|
|
5457
|
-
if (!
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5388
|
+
if (kind === "video" && opts.frameUrl && !isRemoteRef(opts.frameUrl)) {
|
|
5389
|
+
const inlined = await inlineLocalImage(opts.frameUrl, "--frame");
|
|
5390
|
+
if (!inlined.ok) {
|
|
5391
|
+
log.error(inlined.error);
|
|
5392
|
+
process.exitCode = 1;
|
|
5393
|
+
return;
|
|
5394
|
+
}
|
|
5395
|
+
opts = { ...opts, frameUrl: inlined.dataUri };
|
|
5461
5396
|
}
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
return;
|
|
5397
|
+
if (kind === "voice" && prompt.length > 1e3) {
|
|
5398
|
+
log.warn(
|
|
5399
|
+
`Voice text is ${prompt.length} chars \u2014 the server clamps at 1000 (billing follows the clamp). Split longer copy into separate lines.`
|
|
5400
|
+
);
|
|
5467
5401
|
}
|
|
5468
|
-
|
|
5469
|
-
if (!
|
|
5402
|
+
const token = opts.token ?? await readUserToken(opts.envPath);
|
|
5403
|
+
if (!token) {
|
|
5404
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
5470
5405
|
process.exitCode = 1;
|
|
5471
5406
|
return;
|
|
5472
5407
|
}
|
|
5473
|
-
const
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
}
|
|
5408
|
+
const meta = await readProject();
|
|
5409
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
5410
|
+
const options = buildGenOptions(kind, opts);
|
|
5411
|
+
log.plain(c.bold(`genex ${kind}`));
|
|
5412
|
+
log.dim(` ${prompt}`);
|
|
5413
|
+
if (skyboxGuard?.guarded) log.dim(" \u21B3 environment-only guard applied (--raw to disable)");
|
|
5477
5414
|
log.plain("");
|
|
5478
|
-
|
|
5479
|
-
const dashboard = meta.dashboardOrigins?.[0];
|
|
5480
|
-
if (dashboard) {
|
|
5481
|
-
const page = meta.status === "published" ? "world" : "draft";
|
|
5482
|
-
log.plain(` your game's page: ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
5483
|
-
}
|
|
5484
|
-
}
|
|
5485
|
-
|
|
5486
|
-
// src/lib/detect-features.ts
|
|
5487
|
-
import fs15 from "fs/promises";
|
|
5488
|
-
import path15 from "path";
|
|
5489
|
-
async function detectEmbedSdkVersion(cwd = process.cwd()) {
|
|
5490
|
-
try {
|
|
5491
|
-
const raw = await fs15.readFile(path15.join(cwd, "package.json"), "utf8");
|
|
5492
|
-
const pkg = JSON.parse(raw);
|
|
5493
|
-
const version = pkg.dependencies?.["@genex-ai/embed-sdk"] ?? pkg.devDependencies?.["@genex-ai/embed-sdk"];
|
|
5494
|
-
return typeof version === "string" && version ? version : null;
|
|
5495
|
-
} catch {
|
|
5496
|
-
return null;
|
|
5497
|
-
}
|
|
5498
|
-
}
|
|
5499
|
-
async function detectMultiplayer(cwd = process.cwd()) {
|
|
5500
|
-
try {
|
|
5501
|
-
const raw = await fs15.readFile(path15.join(cwd, "package.json"), "utf8");
|
|
5502
|
-
const pkg = JSON.parse(raw);
|
|
5503
|
-
return Boolean(
|
|
5504
|
-
pkg.dependencies?.["@genex-ai/multiplayer"] ?? pkg.devDependencies?.["@genex-ai/multiplayer"]
|
|
5505
|
-
);
|
|
5506
|
-
} catch {
|
|
5507
|
-
return false;
|
|
5508
|
-
}
|
|
5509
|
-
}
|
|
5510
|
-
async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
5511
|
-
let pkg;
|
|
5512
|
-
try {
|
|
5513
|
-
pkg = JSON.parse(await fs15.readFile(path15.join(cwd, "package.json"), "utf8"));
|
|
5514
|
-
} catch (err) {
|
|
5515
|
-
log.dim(` (skipping matchmaking \u2014 couldn't read package.json: ${String(err)})`);
|
|
5516
|
-
return null;
|
|
5517
|
-
}
|
|
5518
|
-
const mm = pkg.genex?.matchmaking;
|
|
5519
|
-
if (mm && typeof mm.preset === "string" && mm.preset) {
|
|
5520
|
-
return {
|
|
5521
|
-
preset: mm.preset,
|
|
5522
|
-
...mm.winCondition ? { winCondition: mm.winCondition } : {},
|
|
5523
|
-
...mm.config ? { config: mm.config } : {}
|
|
5524
|
-
};
|
|
5525
|
-
}
|
|
5526
|
-
return null;
|
|
5527
|
-
}
|
|
5528
|
-
var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
|
|
5529
|
-
async function detectMobileControls(cwd = process.cwd()) {
|
|
5530
|
-
try {
|
|
5531
|
-
const raw = await fs15.readFile(path15.join(cwd, "package.json"), "utf8");
|
|
5532
|
-
const pkg = JSON.parse(raw);
|
|
5533
|
-
if (pkg.genex?.mobileControls === true) return true;
|
|
5534
|
-
} catch {
|
|
5535
|
-
}
|
|
5536
|
-
const srcDir = path15.join(cwd, "src");
|
|
5537
|
-
let entries;
|
|
5415
|
+
let id;
|
|
5538
5416
|
try {
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
if (
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
if (TOUCH_KIT_MARKERS.test(content)) return true;
|
|
5549
|
-
} catch {
|
|
5417
|
+
const res = await apiFetch(`${apiUrl}/api/generations`, {
|
|
5418
|
+
method: "POST",
|
|
5419
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
5420
|
+
body: JSON.stringify({ kind, prompt, options })
|
|
5421
|
+
});
|
|
5422
|
+
if (res.status === 401) {
|
|
5423
|
+
log.error("Unauthorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
5424
|
+
process.exitCode = 1;
|
|
5425
|
+
return;
|
|
5550
5426
|
}
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
|
|
5555
|
-
async function detectGameStateUsage(cwd = process.cwd()) {
|
|
5556
|
-
const srcDir = path15.join(cwd, "src");
|
|
5557
|
-
let entries;
|
|
5558
|
-
try {
|
|
5559
|
-
entries = await fs15.readdir(srcDir, { recursive: true });
|
|
5560
|
-
} catch {
|
|
5561
|
-
return false;
|
|
5562
|
-
}
|
|
5563
|
-
for (const rel of entries) {
|
|
5564
|
-
if (rel.includes("node_modules")) continue;
|
|
5565
|
-
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
5566
|
-
try {
|
|
5567
|
-
const content = await fs15.readFile(path15.join(srcDir, rel), "utf8");
|
|
5568
|
-
if (GAME_STATE_CALLS.test(content)) return true;
|
|
5569
|
-
} catch {
|
|
5427
|
+
if (printedStructuredError(res)) {
|
|
5428
|
+
process.exitCode = 1;
|
|
5429
|
+
return;
|
|
5570
5430
|
}
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
if (!/\.(ts|js|mts|mjs|tsx|jsx|html|css)$/.test(rel)) continue;
|
|
5584
|
-
const content = await fs15.readFile(path15.join(srcDir, rel), "utf8").catch(() => "");
|
|
5585
|
-
if (UI_PHASE_MARKERS.test(content)) return true;
|
|
5431
|
+
if (!res.ok) {
|
|
5432
|
+
let message;
|
|
5433
|
+
if (res.status >= 400 && res.status < 500) {
|
|
5434
|
+
try {
|
|
5435
|
+
const body = await res.json();
|
|
5436
|
+
if (typeof body.message === "string" && body.message.trim()) message = body.message;
|
|
5437
|
+
} catch {
|
|
5438
|
+
}
|
|
5439
|
+
}
|
|
5440
|
+
log.error(message ?? `Generation request failed (HTTP ${res.status}).`);
|
|
5441
|
+
process.exitCode = 1;
|
|
5442
|
+
return;
|
|
5586
5443
|
}
|
|
5587
|
-
|
|
5588
|
-
} catch {
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5444
|
+
({ id } = await res.json());
|
|
5445
|
+
} catch (err) {
|
|
5446
|
+
log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
|
|
5447
|
+
process.exitCode = 1;
|
|
5448
|
+
return;
|
|
5449
|
+
}
|
|
5450
|
+
await recordQueued(id, kind, prompt);
|
|
5451
|
+
if (opts.noWait) {
|
|
5452
|
+
if (opts.json) {
|
|
5453
|
+
writeJson({
|
|
5454
|
+
kind,
|
|
5455
|
+
id,
|
|
5456
|
+
status: "pending",
|
|
5457
|
+
nextCommand: `genex wait ${id} --json`
|
|
5458
|
+
});
|
|
5459
|
+
} else {
|
|
5460
|
+
log.success(`Queued (${id}).`);
|
|
5461
|
+
log.plain(` Pick it up any time with: ${c.cyan(`npx genex wait ${id}`)}`);
|
|
5462
|
+
log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
|
|
5604
5463
|
}
|
|
5605
|
-
|
|
5464
|
+
return id;
|
|
5606
5465
|
}
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5466
|
+
if (kind === "image" && opts.glass) {
|
|
5467
|
+
if (!opts.json) log.step("Generating\u2026 (this can take up to a minute)");
|
|
5468
|
+
const view = await waitForTerminalView(
|
|
5469
|
+
apiUrl,
|
|
5470
|
+
token,
|
|
5471
|
+
id,
|
|
5472
|
+
kind,
|
|
5473
|
+
opts.json ? () => {
|
|
5474
|
+
} : (p) => log.dim(` ${p}%`)
|
|
5475
|
+
);
|
|
5476
|
+
if (!view) {
|
|
5477
|
+
if (opts.json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
|
|
5478
|
+
else log.error("Timed out waiting for the generation.");
|
|
5479
|
+
process.exitCode = 1;
|
|
5480
|
+
return id;
|
|
5620
5481
|
}
|
|
5482
|
+
await reportGlassTerminal(view, opts.outDir ?? ".", log, opts.json ?? false);
|
|
5483
|
+
return id;
|
|
5621
5484
|
}
|
|
5622
|
-
|
|
5623
|
-
return
|
|
5485
|
+
await awaitAndReport(apiUrl, token, id, kind, log, opts.open, opts.json);
|
|
5486
|
+
return id;
|
|
5624
5487
|
}
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
let entries;
|
|
5631
|
-
try {
|
|
5632
|
-
entries = await fs15.readdir(srcDir, { recursive: true });
|
|
5633
|
-
} catch {
|
|
5634
|
-
return found;
|
|
5488
|
+
async function reportGlassTerminal(view, outDir, log, json) {
|
|
5489
|
+
const files = (view.files ?? []).filter((f) => /^image-(main|alt-\d+)$/.test(f.role));
|
|
5490
|
+
if (view.status !== "completed" || files.length === 0) {
|
|
5491
|
+
await reportTerminal("image", view, log, false, json);
|
|
5492
|
+
return;
|
|
5635
5493
|
}
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
if (/\b(map|alphaMap)\s*:/.test(call.text)) continue;
|
|
5655
|
-
found.squarePoints.push({
|
|
5656
|
-
where: `${rel}:${lineOf(content, call.index)}`,
|
|
5657
|
-
detail: "PointsMaterial with no map/alphaMap"
|
|
5494
|
+
await recordTerminal(view.id, "completed", files.map((f) => f.url));
|
|
5495
|
+
await fs15.mkdir(outDir, { recursive: true });
|
|
5496
|
+
const solved = [];
|
|
5497
|
+
for (let i = 0; i < files.length; i++) {
|
|
5498
|
+
const f = files[i];
|
|
5499
|
+
let png;
|
|
5500
|
+
try {
|
|
5501
|
+
const res = await fetch(f.url);
|
|
5502
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
5503
|
+
png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
|
|
5504
|
+
} catch (err) {
|
|
5505
|
+
solved.push({
|
|
5506
|
+
path: null,
|
|
5507
|
+
url: f.url,
|
|
5508
|
+
keyHex: "",
|
|
5509
|
+
keyStrength: 0,
|
|
5510
|
+
flatness: 0,
|
|
5511
|
+
rejected: `couldn't fetch the candidate (${String(err)}) \u2014 re-run later; the URL stays live.`
|
|
5658
5512
|
});
|
|
5513
|
+
continue;
|
|
5659
5514
|
}
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
where: `${rel}:${lineOf(content, call.index)}`,
|
|
5670
|
-
detail: `near ${near} / far ${far}`,
|
|
5671
|
-
ratio
|
|
5515
|
+
const r = solveMagentaGlass(png);
|
|
5516
|
+
if (!r.ok) {
|
|
5517
|
+
solved.push({
|
|
5518
|
+
path: null,
|
|
5519
|
+
url: f.url,
|
|
5520
|
+
keyHex: r.keyHex,
|
|
5521
|
+
keyStrength: r.keyStrength,
|
|
5522
|
+
flatness: r.flatness,
|
|
5523
|
+
rejected: r.rejected
|
|
5672
5524
|
});
|
|
5525
|
+
continue;
|
|
5673
5526
|
}
|
|
5527
|
+
const outPath = path15.join(outDir, `glass-${i + 1}.png`);
|
|
5528
|
+
await fs15.writeFile(outPath, PNG4.sync.write(r.png));
|
|
5529
|
+
solved.push({
|
|
5530
|
+
path: outPath,
|
|
5531
|
+
url: f.url,
|
|
5532
|
+
keyHex: r.keyHex,
|
|
5533
|
+
keyStrength: r.keyStrength,
|
|
5534
|
+
flatness: r.flatness
|
|
5535
|
+
});
|
|
5674
5536
|
}
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
const ledger = await readLedger(cwd);
|
|
5681
|
-
const audited = ledger.filter((e) => WIRED_BY_URL.has(e.kind));
|
|
5682
|
-
if (audited.length === 0) return { unwired: [], unpicked: [], unresolved: [], deadWired: [] };
|
|
5683
|
-
let haystack = "";
|
|
5684
|
-
const read = async (file) => {
|
|
5685
|
-
try {
|
|
5686
|
-
haystack += await fs15.readFile(file, "utf8");
|
|
5687
|
-
} catch {
|
|
5688
|
-
}
|
|
5689
|
-
};
|
|
5690
|
-
try {
|
|
5691
|
-
for (const entry of await fs15.readdir(cwd, { withFileTypes: true })) {
|
|
5692
|
-
if (entry.isFile() && /\.(ts|tsx|js|jsx|css|html|json)$/.test(entry.name)) {
|
|
5693
|
-
await read(path15.join(cwd, entry.name));
|
|
5694
|
-
}
|
|
5695
|
-
}
|
|
5696
|
-
} catch {
|
|
5537
|
+
const shipped = solved.filter((s) => s.path !== null);
|
|
5538
|
+
if (json) {
|
|
5539
|
+
writeJson({ kind: "image", id: view.id, status: view.status, glass: solved });
|
|
5540
|
+
if (shipped.length === 0) process.exitCode = 1;
|
|
5541
|
+
return;
|
|
5697
5542
|
}
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5543
|
+
log.plain("");
|
|
5544
|
+
if (shipped.length > 0) {
|
|
5545
|
+
log.success(`Solved ${shipped.length} glass panel(s) \u2014 real translucency baked in the alpha:`);
|
|
5546
|
+
}
|
|
5547
|
+
for (const s of solved) {
|
|
5548
|
+
if (s.path) {
|
|
5549
|
+
log.plain(` ${s.path} ${c.dim(`key ${s.keyHex}, strength ${s.keyStrength}, flatness ${s.flatness.toFixed(3)}`)}`);
|
|
5550
|
+
} else {
|
|
5551
|
+
log.error(`Candidate rejected: ${s.rejected}`);
|
|
5552
|
+
log.dim(` raw candidate stays at ${s.url}`);
|
|
5707
5553
|
}
|
|
5708
5554
|
}
|
|
5709
|
-
|
|
5555
|
+
if (shipped.length > 0) {
|
|
5556
|
+
log.plain("");
|
|
5557
|
+
log.dim(
|
|
5558
|
+
" 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."
|
|
5559
|
+
);
|
|
5560
|
+
} else {
|
|
5561
|
+
process.exitCode = 1;
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
async function awaitAndReport(apiUrl, token, id, kind, log, open = false, json = false) {
|
|
5565
|
+
if (!json) log.step("Generating\u2026 (this can take up to a minute)");
|
|
5566
|
+
const view = await waitForTerminalView(
|
|
5567
|
+
apiUrl,
|
|
5568
|
+
token,
|
|
5710
5569
|
id,
|
|
5711
5570
|
kind,
|
|
5712
|
-
|
|
5713
|
-
})
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
// that renders as a silently missing asset. Kept separate from `unpicked`
|
|
5723
|
-
// because the fix is a status check, not wiring.
|
|
5724
|
-
unresolved: audited.filter((e) => stale(e) && haystack.includes(e.id)).map(ref),
|
|
5725
|
-
// Known-failed AND still wired: the worst case, and it must keep firing
|
|
5726
|
-
// AFTER the status check converges the ledger to "failed" (otherwise the
|
|
5727
|
-
// audit self-erases the moment the agent runs the recommended command and
|
|
5728
|
-
// the dead URL ships silently anyway).
|
|
5729
|
-
deadWired: audited.filter((e) => e.status === "failed" && haystack.includes(e.id)).map(ref)
|
|
5730
|
-
};
|
|
5571
|
+
json ? () => {
|
|
5572
|
+
} : (p) => log.dim(` ${p}%`)
|
|
5573
|
+
);
|
|
5574
|
+
if (!view) {
|
|
5575
|
+
if (json) writeJson({ kind, id, status: "failed", error: "Timed out waiting for the generation." });
|
|
5576
|
+
else log.error("Timed out waiting for the generation.");
|
|
5577
|
+
process.exitCode = 1;
|
|
5578
|
+
return;
|
|
5579
|
+
}
|
|
5580
|
+
await reportTerminal(kind, view, log, open, json);
|
|
5731
5581
|
}
|
|
5732
|
-
async function
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
mobileControls: await detectMobileControls(cwd),
|
|
5739
|
-
uiPhases: await detectUiPhases(cwd),
|
|
5740
|
-
surfaces: await detectSurfaceScan(cwd),
|
|
5741
|
-
generations: await detectGenerationAudit(cwd)
|
|
5742
|
-
};
|
|
5582
|
+
async function waitForTerminalView(apiUrl, token, id, kind, onProgress = () => {
|
|
5583
|
+
}) {
|
|
5584
|
+
const timeoutMs = waitTimeoutFor(kind);
|
|
5585
|
+
const deadline = Date.now() + timeoutMs;
|
|
5586
|
+
const streamed = await waitViaSSE(apiUrl, token, id, onProgress, deadline);
|
|
5587
|
+
return streamed === "unsupported" ? poll(apiUrl, token, id, onProgress, timeoutMs) : streamed;
|
|
5743
5588
|
}
|
|
5744
|
-
function
|
|
5745
|
-
if (
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
)
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5589
|
+
async function reportTerminal(kind, view, log, open = false, json = false) {
|
|
5590
|
+
if (view.status !== "completed") {
|
|
5591
|
+
if (json) writeJson({ kind, id: view.id, status: view.status, error: view.error ?? null });
|
|
5592
|
+
else log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
5593
|
+
if (view.status === "failed") {
|
|
5594
|
+
await recordTerminal(view.id, "failed");
|
|
5595
|
+
if (kind === "video") {
|
|
5596
|
+
const failures = await countFailed("video");
|
|
5597
|
+
(failures >= 2 ? log.plain : log.dim)(videoFailureAdvice(failures));
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
process.exitCode = 1;
|
|
5601
|
+
return;
|
|
5754
5602
|
}
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
);
|
|
5603
|
+
const files = view.files ?? [];
|
|
5604
|
+
if (files.length === 0) {
|
|
5605
|
+
if (json) writeJson({ kind, id: view.id, status: "failed", error: "Generation completed but produced no files." });
|
|
5606
|
+
else log.error("Generation completed but produced no files.");
|
|
5607
|
+
process.exitCode = 1;
|
|
5608
|
+
return;
|
|
5759
5609
|
}
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5610
|
+
await recordTerminal(view.id, "completed", files.map((f) => f.url));
|
|
5611
|
+
if (kind === "character_concept") {
|
|
5612
|
+
reportCharacterConceptTerminal(view, log, { json, open });
|
|
5613
|
+
return;
|
|
5764
5614
|
}
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
function generationNudges(log, g) {
|
|
5769
|
-
const describe = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
|
|
5770
|
-
if (g.unpicked.length) {
|
|
5771
|
-
log.warn(
|
|
5772
|
-
`${g.unpicked.length} generation(s) enqueued 15+ minutes ago were never picked up: ${describe(g.unpicked)}. Run npx genex wait <id> on each (npx genex wait --all shows every status at once) \u2014 landed assets get wired in before the last push of a session, never parked for "later".`
|
|
5773
|
-
);
|
|
5615
|
+
if (kind === "character_preview") {
|
|
5616
|
+
reportCharacterPreviewTerminal(view, log, { json, open });
|
|
5617
|
+
return;
|
|
5774
5618
|
}
|
|
5775
|
-
if (
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5619
|
+
if (json) {
|
|
5620
|
+
writeJson({
|
|
5621
|
+
kind,
|
|
5622
|
+
id: view.id,
|
|
5623
|
+
status: view.status,
|
|
5624
|
+
files,
|
|
5625
|
+
...kind === "character" ? {
|
|
5626
|
+
characterId: view.id,
|
|
5627
|
+
nextCommand: `genex controller character --character ${view.id}`
|
|
5628
|
+
} : {}
|
|
5629
|
+
});
|
|
5630
|
+
return;
|
|
5779
5631
|
}
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
);
|
|
5632
|
+
log.plain("");
|
|
5633
|
+
log.success("Done \u2014 live on R2 (nothing downloaded or committed):");
|
|
5634
|
+
for (const f of files) {
|
|
5635
|
+
const label = files.length > 1 ? `${c.dim(`${f.role.replace(/^texture-/, "")}: `)}` : "";
|
|
5636
|
+
log.plain(` ${label}${f.url}`);
|
|
5784
5637
|
}
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5638
|
+
log.plain("");
|
|
5639
|
+
if (kind === "texture" && files[0]) await reportTextureSeam(files[0].url, log);
|
|
5640
|
+
if (open && files[0]) {
|
|
5641
|
+
openUrl(files[0].url);
|
|
5642
|
+
log.plain(
|
|
5643
|
+
` ${c.bold("\u{1F441} Show this to the user")} \u2014 it just opened in their browser; paste this link in chat too so they can open it themselves:`
|
|
5788
5644
|
);
|
|
5645
|
+
log.plain(` ${files[0].url}`);
|
|
5646
|
+
log.plain("");
|
|
5789
5647
|
}
|
|
5648
|
+
printHint(kind, view, files, log);
|
|
5790
5649
|
}
|
|
5791
|
-
function
|
|
5792
|
-
if (
|
|
5793
|
-
|
|
5794
|
-
log.warn(
|
|
5795
|
-
`Texture tiling picked by eye, not derived from the mesh: ${list}. A hand-chosen repeat is right for at most ONE face \u2014 a BoxGeometry gives all six faces 0..1 UVs whatever their world size, and repeat lives on the Texture, so every mesh sharing that texture inherits it. Derive the UVs from world size instead: genex-ai-texture ships worldUV(geometry, TILE_M), after which repeat stays (1, 1) and cannot be wrong.`
|
|
5796
|
-
);
|
|
5797
|
-
}
|
|
5798
|
-
if (s.squarePoints.length) {
|
|
5799
|
-
const list = s.squarePoints.map((d) => d.where).join(", ");
|
|
5800
|
-
log.warn(
|
|
5801
|
-
`Particles will render as hard SQUARES: ${list} builds a PointsMaterial with no map/alphaMap, so every point is an opaque camera-facing quad. Give it a soft sprite (genex image --transparent) or a round gl_PointCoord cutout \u2014 and check the particle belongs there at all: genex-threejs-procedural-vfx decides that per moment.`
|
|
5802
|
-
);
|
|
5803
|
-
}
|
|
5804
|
-
for (const d of s.depthRange) {
|
|
5805
|
-
log.warn(
|
|
5806
|
-
`Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get. If the range is genuinely astronomical, that is what WebGLRenderer's logarithmicDepthBuffer is for: genex-threejs-camera-direction.`
|
|
5807
|
-
);
|
|
5650
|
+
function videoFailureAdvice(failures) {
|
|
5651
|
+
if (failures >= 2) {
|
|
5652
|
+
return ` ${c.bold(`\u270B ${failures} failed videos in this project \u2014 don't queue another attempt of the same clip.`)} One retry per clip is the budget. Use its key-art still as the backdrop instead (the genex-ai-menu skill's built-in fallback; a slow CSS pan gives it life), tell the user in one plain line, and spend the minutes in the game.`;
|
|
5808
5653
|
}
|
|
5654
|
+
return " Video renders fail server-side sometimes. One retry is fair \u2014 shorter clip (4\u20136 s), simpler motion. After a second failure, switch to the key-art still (genex-ai-menu's fallback) instead of a third attempt.";
|
|
5809
5655
|
}
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5656
|
+
var WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
5657
|
+
var KIND_WAIT_TIMEOUT_MS = {
|
|
5658
|
+
video: 15 * 60 * 1e3,
|
|
5659
|
+
// 15 min
|
|
5660
|
+
motion: 20 * 60 * 1e3,
|
|
5661
|
+
// 8 takes ≈ 8-9 GPU min + a serverless cold start
|
|
5662
|
+
character_concept: 15 * 60 * 1e3,
|
|
5663
|
+
character_preview: 30 * 60 * 1e3,
|
|
5664
|
+
character: 30 * 60 * 1e3,
|
|
5665
|
+
character_animation: 20 * 60 * 1e3,
|
|
5666
|
+
// Per clip this lane is fast (locomotion ~2s, video ~51s, described ~130s) —
|
|
5667
|
+
// the BATCH is what takes time. A 20-clip standard set is the sizing case.
|
|
5668
|
+
character_motion: 30 * 60 * 1e3
|
|
5669
|
+
};
|
|
5670
|
+
var waitTimeoutFor = (kind) => KIND_WAIT_TIMEOUT_MS[kind] ?? WAIT_TIMEOUT_MS;
|
|
5671
|
+
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
5672
|
+
async function waitViaSSE(apiUrl, token, id, onProgress, deadline) {
|
|
5673
|
+
let connectFailures = 0;
|
|
5674
|
+
let last = -1;
|
|
5675
|
+
while (Date.now() < deadline) {
|
|
5676
|
+
const abort = new AbortController();
|
|
5677
|
+
const timer = setTimeout(() => abort.abort(), deadline - Date.now());
|
|
5678
|
+
try {
|
|
5679
|
+
let res;
|
|
5680
|
+
try {
|
|
5681
|
+
res = await apiFetch(`${apiUrl}/api/generations/${id}/events`, {
|
|
5682
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
5683
|
+
signal: abort.signal
|
|
5684
|
+
});
|
|
5685
|
+
} catch {
|
|
5686
|
+
if (++connectFailures >= 3) return "unsupported";
|
|
5687
|
+
await sleep2(2e3);
|
|
5688
|
+
continue;
|
|
5689
|
+
}
|
|
5690
|
+
if (res.status === 404 || res.status === 405) return "unsupported";
|
|
5691
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
5692
|
+
if (!res.ok || !res.body || !contentType.includes("text/event-stream")) {
|
|
5693
|
+
if (++connectFailures >= 3) return "unsupported";
|
|
5694
|
+
await sleep2(2e3);
|
|
5695
|
+
continue;
|
|
5696
|
+
}
|
|
5697
|
+
connectFailures = 0;
|
|
5698
|
+
try {
|
|
5699
|
+
for await (const ev of readSSE(res.body)) {
|
|
5700
|
+
if (ev.event !== "status") continue;
|
|
5701
|
+
let view;
|
|
5702
|
+
try {
|
|
5703
|
+
view = JSON.parse(ev.data);
|
|
5704
|
+
} catch {
|
|
5705
|
+
continue;
|
|
5706
|
+
}
|
|
5707
|
+
if (view.progress !== last) {
|
|
5708
|
+
last = view.progress;
|
|
5709
|
+
onProgress(view.progress);
|
|
5710
|
+
}
|
|
5711
|
+
if (TERMINAL.has(view.status)) return view;
|
|
5712
|
+
}
|
|
5713
|
+
} catch {
|
|
5714
|
+
}
|
|
5715
|
+
await sleep2(1e3);
|
|
5716
|
+
} finally {
|
|
5717
|
+
clearTimeout(timer);
|
|
5718
|
+
abort.abort();
|
|
5822
5719
|
}
|
|
5823
|
-
} catch {
|
|
5824
5720
|
}
|
|
5721
|
+
return null;
|
|
5825
5722
|
}
|
|
5826
|
-
async function
|
|
5827
|
-
|
|
5828
|
-
let
|
|
5829
|
-
|
|
5830
|
-
const own = new URL(meta.cloneUrl);
|
|
5831
|
-
host = own.host;
|
|
5832
|
-
ownPath = own.pathname;
|
|
5833
|
-
} catch {
|
|
5834
|
-
return true;
|
|
5835
|
-
}
|
|
5836
|
-
const gitConfig = await fs15.readFile(path15.join(cwd, ".git", "config"), "utf8").catch(() => "");
|
|
5837
|
-
for (const m of gitConfig.matchAll(/url\s*=\s*(\S+)/g)) {
|
|
5723
|
+
async function poll(apiUrl, token, id, onProgress, timeoutMs) {
|
|
5724
|
+
const deadline = Date.now() + timeoutMs;
|
|
5725
|
+
let last = -1;
|
|
5726
|
+
while (Date.now() < deadline) {
|
|
5838
5727
|
try {
|
|
5839
|
-
const
|
|
5840
|
-
|
|
5728
|
+
const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
|
|
5729
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
5730
|
+
});
|
|
5731
|
+
if (res.ok) {
|
|
5732
|
+
const { generation } = await res.json();
|
|
5733
|
+
if (generation.progress !== last) {
|
|
5734
|
+
last = generation.progress;
|
|
5735
|
+
onProgress(generation.progress);
|
|
5736
|
+
}
|
|
5737
|
+
if (generation.status === "completed" || generation.status === "failed") {
|
|
5738
|
+
return generation;
|
|
5739
|
+
}
|
|
5740
|
+
}
|
|
5841
5741
|
} catch {
|
|
5842
5742
|
}
|
|
5743
|
+
await sleep2(3e3);
|
|
5843
5744
|
}
|
|
5844
|
-
|
|
5845
|
-
return readme.includes(host) || /\b(upstream|originally by|ported from|borrowed from)\b/i.test(readme);
|
|
5745
|
+
return null;
|
|
5846
5746
|
}
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5747
|
+
async function reportTextureSeam(url, log) {
|
|
5748
|
+
try {
|
|
5749
|
+
const res = await fetch(url);
|
|
5750
|
+
if (!res.ok) return;
|
|
5751
|
+
const png = PNG4.sync.read(Buffer.from(await res.arrayBuffer()));
|
|
5752
|
+
if (png.width < 4 || png.height < 4) return;
|
|
5753
|
+
const r = measureSeam(png);
|
|
5754
|
+
if (r.seamless) {
|
|
5755
|
+
log.dim(` Tiling check: seam ${r.worstRatio}\xD7 this texture's own detail \u2014 tiles cleanly.`);
|
|
5756
|
+
return;
|
|
5757
|
+
}
|
|
5758
|
+
log.error(
|
|
5759
|
+
`Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
|
|
5760
|
+
);
|
|
5761
|
+
log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
|
|
5762
|
+
log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground).');
|
|
5763
|
+
log.plain(" Do NOT hide it by tiling less: a hand-picked repeat trades a seam you can name for a");
|
|
5764
|
+
log.plain(" stretched texture you can't. Scale belongs in the UVs (worldUV \u2014 genex-ai-texture).");
|
|
5765
|
+
log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
|
|
5766
|
+
} catch {
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
function printHint(kind, view, files, log) {
|
|
5770
|
+
const url = files[0]?.url ?? "";
|
|
5771
|
+
const hint = {
|
|
5772
|
+
model: `Standard GLB \u2014 load with GLTFLoader straight from the URL \u2014 see the genex-ai-model skill. url = "${url}"`,
|
|
5773
|
+
skybox: `Load as an equirectangular texture \u2192 scene.background + scene.environment \u2014 see genex-ai-skybox. url = "${url}"`,
|
|
5774
|
+
sfx: `Load with AudioLoader into a THREE.PositionalAudio (camera needs an AudioListener) \u2014 see genex-ai-sfx. url = "${url}"`,
|
|
5775
|
+
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}"`,
|
|
5776
|
+
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}"`,
|
|
5777
|
+
texture: `Load each map with TextureLoader (RepeatWrapping) into a MeshStandardMaterial \u2014 see genex-ai-texture. Use the URLs above by role.`,
|
|
5778
|
+
image: `Load with TextureLoader (set colorSpace = SRGBColorSpace) onto any mesh/plane/sprite \u2014 see genex-ai-image. url = "${url}"`,
|
|
5779
|
+
video: `Wire an HTMLVideoElement (crossOrigin="anonymous", muted, loop, playsInline) into a THREE.VideoTexture \u2014 see genex-ai-video. url = "${url}"`,
|
|
5780
|
+
motion: `Raw ARDY takes + verify report \u2014 download the takes, rename them meaningfully (routing is by filename), then: genex motion verify \u2192 compile \u2192 install. Full procedure: the genex-ai-character skill's motion reference.`,
|
|
5781
|
+
character_concept: "Show all three concept candidates to the user and wait for an explicit selection.",
|
|
5782
|
+
character_preview: "Show all four preview views to the user and wait for explicit approval before finalizing.",
|
|
5783
|
+
character: `Install the controller and current manifest with genex controller character --character ${view.id}.`,
|
|
5784
|
+
character_animation: "The action is now part of the character's current manifest; refresh the local controller manifest before testing.",
|
|
5785
|
+
character_motion: `The clips are part of the character's current manifest \u2014 install them with genex controller character --character ${view.id}, then play the game and watch the motion on the real character before calling it done.`
|
|
5786
|
+
};
|
|
5787
|
+
log.dim(` ${hint[kind]}`);
|
|
5788
|
+
log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
|
|
5789
|
+
}
|
|
5790
|
+
var CONCEPT_STOP = "STOP \u2014 show all three concept images to the user and wait for an explicit selection. Do not create the 3D preview until they choose candidate 1, 2, or 3.";
|
|
5791
|
+
function metadataOf(view) {
|
|
5792
|
+
return view.metadata && typeof view.metadata === "object" ? view.metadata : {};
|
|
5793
|
+
}
|
|
5794
|
+
function stringValue(value) {
|
|
5795
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
5796
|
+
}
|
|
5797
|
+
function finiteNumber(value) {
|
|
5798
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
5799
|
+
}
|
|
5800
|
+
function conceptCandidates(view) {
|
|
5801
|
+
const candidates = [];
|
|
5802
|
+
for (const file of view.files ?? []) {
|
|
5803
|
+
const match = /^(?:concept-candidate|character-concept)-([123])(?:\.[a-z0-9]+)?$/i.exec(file.role);
|
|
5804
|
+
if (!match) continue;
|
|
5805
|
+
candidates.push({ candidate: Number(match[1]), role: file.role, url: file.url });
|
|
5858
5806
|
}
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5807
|
+
return candidates.sort((a, b) => a.candidate - b.candidate);
|
|
5808
|
+
}
|
|
5809
|
+
function conceptContactSheet(view) {
|
|
5810
|
+
return (view.files ?? []).find(
|
|
5811
|
+
(file) => /^(?:concept-gallery|character-concepts-contact-sheet)(?:\.[a-z0-9]+)?$/i.test(file.role)
|
|
5812
|
+
);
|
|
5813
|
+
}
|
|
5814
|
+
function requestedBuildOf(view) {
|
|
5815
|
+
const metadata = metadataOf(view);
|
|
5816
|
+
const raw = metadata.requestedBuild && typeof metadata.requestedBuild === "object" ? metadata.requestedBuild : {};
|
|
5817
|
+
const actionIds = Array.isArray(raw.actionIds) ? raw.actionIds.filter((id) => typeof id === "number" && Number.isInteger(id)) : [];
|
|
5818
|
+
return {
|
|
5819
|
+
actionIds,
|
|
5820
|
+
controllerPack: raw.controllerPack !== false,
|
|
5821
|
+
...finiteNumber(raw.heightMeters) === void 0 ? {} : { heightMeters: finiteNumber(raw.heightMeters) }
|
|
5822
|
+
};
|
|
5823
|
+
}
|
|
5824
|
+
function characterFinalizeCommand(previewId, intent) {
|
|
5825
|
+
const args = [
|
|
5826
|
+
"genex",
|
|
5827
|
+
"character",
|
|
5828
|
+
"finalize",
|
|
5829
|
+
previewId,
|
|
5830
|
+
"--user-approved",
|
|
5831
|
+
"--approve-remesh",
|
|
5832
|
+
"10000"
|
|
5833
|
+
];
|
|
5834
|
+
if (intent.heightMeters !== void 0) args.push("--height", String(intent.heightMeters));
|
|
5835
|
+
for (const actionId of intent.actionIds) args.push("--animation", String(actionId));
|
|
5836
|
+
return args.join(" ");
|
|
5837
|
+
}
|
|
5838
|
+
function reportCharacterConceptTerminal(view, log, opts = {}) {
|
|
5839
|
+
const candidates = conceptCandidates(view);
|
|
5840
|
+
if (candidates.length !== 3 || candidates.some((entry, index) => entry.candidate !== index + 1)) {
|
|
5841
|
+
const error = "Character concept generation completed without exactly candidates 1, 2, and 3.";
|
|
5842
|
+
if (opts.json) writeJson({ kind: "character_concept", id: view.id, status: "failed", error });
|
|
5843
|
+
else log.error(error);
|
|
5862
5844
|
process.exitCode = 1;
|
|
5863
5845
|
return;
|
|
5864
5846
|
}
|
|
5865
|
-
const
|
|
5866
|
-
const
|
|
5867
|
-
|
|
5868
|
-
if (
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
return;
|
|
5883
|
-
}
|
|
5884
|
-
const onDisk = await readProject();
|
|
5885
|
-
const merged = { ...onDisk ?? meta, lastDeployAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5886
|
-
await writeProject(merged);
|
|
5887
|
-
meta.lastDeployAt = merged.lastDeployAt;
|
|
5888
|
-
} else {
|
|
5889
|
-
log.info("Skipping build + deploy (--no-push).");
|
|
5890
|
-
}
|
|
5891
|
-
log.step("Listing it in the gallery\u2026");
|
|
5892
|
-
let res;
|
|
5893
|
-
try {
|
|
5894
|
-
const body = {};
|
|
5895
|
-
if (opts.title) body.title = opts.title;
|
|
5896
|
-
if (opts.description) body.description = opts.description;
|
|
5897
|
-
if (opts.regenerateCover) body.regenerateCover = true;
|
|
5898
|
-
if (opts.categories?.length) body.categories = opts.categories;
|
|
5899
|
-
if (opts.sourceRepoUrl) body.sourceRepoUrl = opts.sourceRepoUrl;
|
|
5900
|
-
if (opts.sourceAuthor) body.sourceAuthor = opts.sourceAuthor;
|
|
5901
|
-
if (opts.license) body.license = opts.license;
|
|
5902
|
-
if (opts.purchaseUrl) body.purchaseUrl = opts.purchaseUrl;
|
|
5903
|
-
if (opts.productName) body.productName = opts.productName;
|
|
5904
|
-
if (detections.embedSdkVersion) body.embedSdkVersion = detections.embedSdkVersion;
|
|
5905
|
-
body.multiplayer = detections.multiplayer;
|
|
5906
|
-
body.matchmaking = detections.matchmaking ?? null;
|
|
5907
|
-
body.gameStateUsed = detections.gameStateUsed;
|
|
5908
|
-
body.mobileControls = detections.mobileControls;
|
|
5909
|
-
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
5910
|
-
method: "POST",
|
|
5911
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
5912
|
-
body: JSON.stringify(body)
|
|
5847
|
+
const metadata = metadataOf(view);
|
|
5848
|
+
const contactSheet = conceptContactSheet(view);
|
|
5849
|
+
const nextCommand = `genex character preview ${view.id} --candidate <n> --user-approved`;
|
|
5850
|
+
if (opts.open) openUrl(contactSheet?.url ?? candidates[0].url);
|
|
5851
|
+
if (opts.json) {
|
|
5852
|
+
writeJson({
|
|
5853
|
+
kind: "character_concept",
|
|
5854
|
+
stage: "concept",
|
|
5855
|
+
id: view.id,
|
|
5856
|
+
status: view.status,
|
|
5857
|
+
provider: stringValue(metadata.provider),
|
|
5858
|
+
model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
|
|
5859
|
+
candidates,
|
|
5860
|
+
...contactSheet ? { contactSheetUrl: contactSheet.url } : {},
|
|
5861
|
+
...opts.quote ? { quote: opts.quote } : {},
|
|
5862
|
+
requiresUserApproval: true,
|
|
5863
|
+
nextCommand
|
|
5913
5864
|
});
|
|
5914
|
-
} catch {
|
|
5915
|
-
log.error("Couldn't reach Genex \u2014 please try again.");
|
|
5916
|
-
process.exitCode = 1;
|
|
5917
|
-
return;
|
|
5918
|
-
}
|
|
5919
|
-
if (!res.ok) {
|
|
5920
|
-
log.error("Publish failed \u2014 please try again.");
|
|
5921
|
-
process.exitCode = 1;
|
|
5922
5865
|
return;
|
|
5923
5866
|
}
|
|
5867
|
+
log.success("Three character concept candidates are ready:");
|
|
5868
|
+
for (const candidate of candidates) log.plain(` ${candidate.candidate}. ${candidate.url}`);
|
|
5869
|
+
if (contactSheet) log.plain(` Contact sheet: ${contactSheet.url}`);
|
|
5924
5870
|
log.plain("");
|
|
5925
|
-
log.
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
if (meta.playUrl) log.dim(` play: ${meta.playUrl}`);
|
|
5929
|
-
}
|
|
5930
|
-
|
|
5931
|
-
// src/commands/preview.ts
|
|
5932
|
-
async function runPreview(opts) {
|
|
5933
|
-
const log = createLogger({ quiet: opts.quiet });
|
|
5934
|
-
log.plain(c.bold("genex preview"));
|
|
5871
|
+
log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all three numbered links, not only your favorite.`);
|
|
5872
|
+
log.plain(` ${c.bold(CONCEPT_STOP)}`);
|
|
5873
|
+
log.plain(` After approval: ${c.cyan(nextCommand)}`);
|
|
5935
5874
|
log.plain("");
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5875
|
+
}
|
|
5876
|
+
var PREVIEW_DIRECTIONS = ["front", "back", "left", "right"];
|
|
5877
|
+
function previewViews(view) {
|
|
5878
|
+
const metadata = metadataOf(view);
|
|
5879
|
+
const rawViews = metadata.views && typeof metadata.views === "object" ? metadata.views : {};
|
|
5880
|
+
const result = {};
|
|
5881
|
+
for (const direction of PREVIEW_DIRECTIONS) {
|
|
5882
|
+
const fromMetadata = stringValue(rawViews[direction]);
|
|
5883
|
+
const fromFile = (view.files ?? []).find((file) => {
|
|
5884
|
+
const role = file.role.toLowerCase();
|
|
5885
|
+
return file.contentType.startsWith("image/") && role.includes(direction);
|
|
5886
|
+
})?.url;
|
|
5887
|
+
result[direction] = fromMetadata ?? fromFile;
|
|
5941
5888
|
}
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5889
|
+
return result;
|
|
5890
|
+
}
|
|
5891
|
+
function previewSource(view) {
|
|
5892
|
+
return (view.files ?? []).find(
|
|
5893
|
+
(file) => file.contentType === "model/gltf-binary" || /(?:source-highpoly|character-preview)(?:\.glb)?$/i.test(file.role)
|
|
5894
|
+
)?.url;
|
|
5895
|
+
}
|
|
5896
|
+
function reportCharacterPreviewTerminal(view, log, opts = {}) {
|
|
5897
|
+
const metadata = metadataOf(view);
|
|
5898
|
+
const views = previewViews(view);
|
|
5899
|
+
const sourceFaceCount = finiteNumber(metadata.sourceFaceCount);
|
|
5900
|
+
const missingViews = PREVIEW_DIRECTIONS.filter((direction) => !views[direction]);
|
|
5901
|
+
if (sourceFaceCount === void 0 || missingViews.length > 0) {
|
|
5902
|
+
const detail = [
|
|
5903
|
+
sourceFaceCount === void 0 ? "source face count" : "",
|
|
5904
|
+
missingViews.length > 0 ? `${missingViews.join(", ")} view${missingViews.length === 1 ? "" : "s"}` : ""
|
|
5905
|
+
].filter(Boolean).join(" and ");
|
|
5906
|
+
const error = `Character preview completed without ${detail}.`;
|
|
5907
|
+
if (opts.json) writeJson({ kind: "character_preview", id: view.id, status: "failed", error });
|
|
5908
|
+
else log.error(error);
|
|
5945
5909
|
process.exitCode = 1;
|
|
5946
5910
|
return;
|
|
5947
5911
|
}
|
|
5948
|
-
const
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
const
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5912
|
+
const intent = requestedBuildOf(view);
|
|
5913
|
+
const nextCommand = characterFinalizeCommand(view.id, intent);
|
|
5914
|
+
const sourceUrl = previewSource(view);
|
|
5915
|
+
const stop = `STOP \u2014 show the front, back, left, and right views to the user. The high-detail source has ${sourceFaceCount} faces and remains preserved in R2. Ask them to approve a separate 10,000-face triangle rigging copy.`;
|
|
5916
|
+
if (opts.open) openUrl(views.front);
|
|
5917
|
+
if (opts.json) {
|
|
5918
|
+
writeJson({
|
|
5919
|
+
kind: "character_preview",
|
|
5920
|
+
stage: "preview",
|
|
5921
|
+
id: view.id,
|
|
5922
|
+
status: view.status,
|
|
5923
|
+
provider: stringValue(metadata.provider),
|
|
5924
|
+
model: stringValue(metadata.aiModel) ?? stringValue(metadata.providerModel),
|
|
5925
|
+
poseMode: stringValue(metadata.poseMode),
|
|
5926
|
+
shouldRemesh: metadata.shouldRemesh === true,
|
|
5927
|
+
sourceFaceCount,
|
|
5928
|
+
views,
|
|
5929
|
+
...sourceUrl ? { sourceUrl } : {},
|
|
5930
|
+
requestedBuild: intent,
|
|
5931
|
+
...opts.quote ? { quote: opts.quote } : {},
|
|
5932
|
+
requiresUserApproval: true,
|
|
5933
|
+
nextCommand
|
|
5934
|
+
});
|
|
5969
5935
|
return;
|
|
5970
5936
|
}
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
meta.lastDeployAt = merged.lastDeployAt;
|
|
5979
|
-
const published = meta.status === "published";
|
|
5937
|
+
log.success("The high-detail Meshy character preview is ready:");
|
|
5938
|
+
for (const direction of PREVIEW_DIRECTIONS) log.plain(` ${direction}: ${views[direction]}`);
|
|
5939
|
+
if (sourceUrl) log.plain(` High-detail source: ${sourceUrl}`);
|
|
5940
|
+
log.plain("");
|
|
5941
|
+
log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all four views.`);
|
|
5942
|
+
log.plain(` ${c.bold(stop)}`);
|
|
5943
|
+
log.plain(` After approval: ${c.cyan(nextCommand)}`);
|
|
5980
5944
|
log.plain("");
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
}
|
|
5984
|
-
|
|
5985
|
-
}
|
|
5986
|
-
const dashboard = meta.dashboardOrigins?.[0];
|
|
5987
|
-
if (dashboard) {
|
|
5988
|
-
const page = published ? "world" : "draft";
|
|
5989
|
-
log.plain(` your game's page (share this link): ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
5990
|
-
}
|
|
5945
|
+
}
|
|
5946
|
+
function writeJson(value) {
|
|
5947
|
+
process.stdout.write(`${JSON.stringify(value)}
|
|
5948
|
+
`);
|
|
5991
5949
|
}
|
|
5992
5950
|
|
|
5993
5951
|
// src/commands/wait.ts
|
|
@@ -6129,15 +6087,6 @@ async function runWaitAll(opts) {
|
|
|
6129
6087
|
if (count("running") + count("queued") > 0) {
|
|
6130
6088
|
log.dim(` Attach to any single one with: ${c.cyan("npx genex wait <id>")}`);
|
|
6131
6089
|
}
|
|
6132
|
-
const stage1Stalled = rows.some(
|
|
6133
|
-
(r, i) => r.kind === "image" && r.status === "done" && r.prompt.startsWith(STAGE1_PROMPT_MARKER) && !rows.some(
|
|
6134
|
-
(s, j) => j > i && s.kind === "image" && s.prompt.startsWith(STAGE2_PROMPT_MARKER)
|
|
6135
|
-
)
|
|
6136
|
-
);
|
|
6137
|
-
if (stage1Stalled) {
|
|
6138
|
-
log.plain("");
|
|
6139
|
-
log.plain(` ${c.bold(STAGE1_LANDING_NUDGE)}`);
|
|
6140
|
-
}
|
|
6141
6090
|
log.plain("");
|
|
6142
6091
|
log.dim(
|
|
6143
6092
|
" \u{1F4DC} Resumed or compacted? Re-read AGENTS.md + DESIGN.md (continue from its Now: line) before building on."
|
|
@@ -19190,7 +19139,8 @@ async function main() {
|
|
|
19190
19139
|
log.plain(HELP);
|
|
19191
19140
|
return;
|
|
19192
19141
|
}
|
|
19193
|
-
const
|
|
19142
|
+
const quietUpdates = Boolean(parsed.options.quiet || parsed.options.json);
|
|
19143
|
+
const updateLog = createLogger({ quiet: quietUpdates });
|
|
19194
19144
|
if (parsed.command !== "init") await syncSkills(updateLog);
|
|
19195
19145
|
const updateCheck = startUpdateCheck();
|
|
19196
19146
|
Sentry2.setTag("command", parsed.command);
|
|
@@ -19311,7 +19261,7 @@ async function main() {
|
|
|
19311
19261
|
process.exitCode = 1;
|
|
19312
19262
|
}
|
|
19313
19263
|
} finally {
|
|
19314
|
-
await reportUpdateNudges(updateCheck, updateLog);
|
|
19264
|
+
if (!quietUpdates) await reportUpdateNudges(updateCheck, updateLog);
|
|
19315
19265
|
await flushSentry();
|
|
19316
19266
|
}
|
|
19317
19267
|
}
|