@genex-ai/cli-demo 1.31.2 → 1.32.0-dev.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/blender-mcp-Q6PSFYSE.js +241 -0
- package/dist/blender-serve-BF4FZ55Z.js +244 -0
- package/dist/chunk-2COG4P3T.js +968 -0
- package/dist/chunk-HYCSNWYX.js +126 -0
- package/dist/index.js +4759 -2830
- package/package.json +3 -3
- package/templates/blender-service/demo/castle.py +117 -0
- package/templates/blender-service/gpu_witness.py +245 -0
- package/templates/blender-service/ops.py +225 -0
- package/templates/blender-service/pool.py +910 -0
- package/templates/blender-service/server.py +611 -0
- package/templates/blender-service/supervisor.py +221 -0
- package/templates/blender-service/views.py +281 -0
- package/templates/controllers/character/follow-camera.ts +16 -1
- package/templates/controllers/character/meshy/meshy-loader.ts +3 -2
- package/templates/controllers/quality/deadline.ts +117 -0
- package/templates/controllers/quality/pick-asset.ts +49 -16
- package/templates/controllers/shared/physics-world.ts +6 -4
- package/templates/skills/genex-ai-character/SKILL.md +77 -15
- package/templates/skills/genex-ai-menu/SKILL.md +15 -11
- package/templates/skills/genex-ai-model/SKILL.md +45 -7
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +75 -17
- package/templates/skills/genex-blender-scene/SKILL.md +243 -0
- package/templates/skills/genex-game-director/SKILL.md +112 -46
- package/templates/skills/genex-game-director/references/design-contract.md +13 -5
- package/templates/skills/genex-game-director/references/routing-map.md +30 -45
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-lane-card/SKILL.md +78 -0
- package/templates/skills/genex-monetization/SKILL.md +11 -17
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +21 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +17 -5
- package/templates/skills/genex-threejs-creatures/SKILL.md +8 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +12 -8
- package/templates/skills/genex-threejs-game-ui/SKILL.md +55 -6
- package/templates/skills/genex-threejs-procedural-assets/SKILL.md +17 -10
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +12 -2
- package/templates/skills/genex-tool-audio/SKILL.md +3 -2
- package/templates/skills/genex-tool-character/SKILL.md +36 -5
- package/templates/skills/genex-tool-image/SKILL.md +4 -2
- package/templates/skills/genex-tool-model/SKILL.md +32 -6
- package/templates/skills/genex-tool-publish/SKILL.md +100 -0
- package/templates/skills/genex-tool-texture/SKILL.md +1 -1
- package/templates/skills/genex-tool-video/SKILL.md +28 -5
- package/templates/skills/genex-tool-workflow/SKILL.md +4 -1
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@ This is the `cli` app of the [genex monorepo](../../README.md).
|
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
genex init <name> # authorize + create the draft project
|
|
8
|
+
genex remix <slug> # make a new draft from a source snapshot
|
|
9
|
+
genex remix <slug> --source-only # download a reference into .genex/refs without linking
|
|
8
10
|
genex link <slug> # re-link this folder to an existing game of yours (recovery)
|
|
9
11
|
genex list # list your games — slug, status, page link (find the slug to link)
|
|
10
12
|
genex preview # build + push to the hosted draft URL (unlisted)
|
|
@@ -34,6 +36,22 @@ genex ui <tool> # local pixel toolbox for generated UI art: extract |
|
|
|
34
36
|
> never a different global `genex`). `genex <cmd>` works too if you install it
|
|
35
37
|
> globally. The bare command forms below name the command; prefix with `npx` to run.
|
|
36
38
|
|
|
39
|
+
`genex remix <slug-or-Genex-page-URL> [directory]` prepares an independent draft
|
|
40
|
+
from a source snapshot. It downloads the real asset bytes, gives the copy its own
|
|
41
|
+
project identity and CLI dependency, and installs concise remix guidance. Your
|
|
42
|
+
request decides whether to make a small change, borrow a system, restyle the game,
|
|
43
|
+
or redesign it completely. An existing destination is refused unless it belongs
|
|
44
|
+
to the same interrupted setup; retry the command to resume it. `--preview` opts
|
|
45
|
+
into publishing a baseline draft. The default prepares the workspace first.
|
|
46
|
+
|
|
47
|
+
`--source-only` downloads reference source under `.genex/refs/` (or into a named
|
|
48
|
+
empty directory), without creating a project, installing dependencies or changing
|
|
49
|
+
the current project's identity. Keep required credits and license notices with
|
|
50
|
+
reused source; Genex does not require a new in-game credit overlay. Owners can use
|
|
51
|
+
their private source; other users can use eligible public games. When an exact
|
|
52
|
+
release-to-source match is unavailable, the command explicitly labels the source
|
|
53
|
+
as the latest available snapshot.
|
|
54
|
+
|
|
37
55
|
`genex init` does four things:
|
|
38
56
|
|
|
39
57
|
1. **Installs the skills for your coding agents** — copies the bundled templates
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SHEET_FORMATS,
|
|
3
|
+
acquireSeat,
|
|
4
|
+
blenderCall,
|
|
5
|
+
blenderEndpoint,
|
|
6
|
+
blenderSetupHint,
|
|
7
|
+
isRenderMode,
|
|
8
|
+
sceneSummary,
|
|
9
|
+
sheetOf
|
|
10
|
+
} from "./chunk-2COG4P3T.js";
|
|
11
|
+
import {
|
|
12
|
+
getCliVersion
|
|
13
|
+
} from "./chunk-HYCSNWYX.js";
|
|
14
|
+
|
|
15
|
+
// src/commands/blender-mcp.ts
|
|
16
|
+
import fs from "fs/promises";
|
|
17
|
+
import path from "path";
|
|
18
|
+
var FALLBACK_PROTOCOL = "2025-06-18";
|
|
19
|
+
var MODE_SCHEMA = {
|
|
20
|
+
type: "string",
|
|
21
|
+
enum: ["solid", "wireframe", "normals", "lit"],
|
|
22
|
+
description: "solid = material colours; wireframe = topology; normals = matcap that exposes inverted faces; lit = EEVEE, real lighting and shadows \u2014 GPU tier only, and it errors on the CPU tier rather than quietly falling back."
|
|
23
|
+
};
|
|
24
|
+
function sheet(reply2) {
|
|
25
|
+
const s = sheetOf(reply2);
|
|
26
|
+
return s ? [{ type: "image", data: s.b64, mimeType: s.mime }] : [];
|
|
27
|
+
}
|
|
28
|
+
var TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: "blender_exec",
|
|
31
|
+
description: "Run Python (bpy) against the live Blender scene and get back a 4-view contact sheet plus the scene's numeric truths. The scene PERSISTS between calls, so you can build incrementally and a helper defined in one call is available in the next. Assert on the numbers; use the picture to decide what to change next.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
script: { type: "string", description: "Python source. `bpy`, `bmesh`, `mathutils`, `math`, `random` are in scope." },
|
|
36
|
+
mode: MODE_SCHEMA,
|
|
37
|
+
verify: {
|
|
38
|
+
type: "boolean",
|
|
39
|
+
description: "Set false to skip the render for a bulk sub-step. Defaults true \u2014 you should almost always look."
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: ["script"]
|
|
43
|
+
},
|
|
44
|
+
run: async (a, base) => {
|
|
45
|
+
const r = await blenderCall(base, "/exec", {
|
|
46
|
+
script: String(a.script ?? ""),
|
|
47
|
+
// Omitted rather than defaulted: the server picks per tier (`lit` on
|
|
48
|
+
// GPU, `solid` on CPU) and echoes what it used.
|
|
49
|
+
...isRenderMode(a.mode) ? { mode: a.mode } : {},
|
|
50
|
+
sheet: { formats: SHEET_FORMATS },
|
|
51
|
+
verify: a.verify !== false
|
|
52
|
+
});
|
|
53
|
+
const lines = [];
|
|
54
|
+
if (r.stdout?.trim()) lines.push(r.stdout.trimEnd());
|
|
55
|
+
if (r.stderr?.trim()) lines.push(r.stderr.trimEnd());
|
|
56
|
+
if (r.error) {
|
|
57
|
+
lines.push(`ERROR:
|
|
58
|
+
${r.error.trimEnd()}`);
|
|
59
|
+
}
|
|
60
|
+
if (r.sceneRestored?.restored) {
|
|
61
|
+
lines.push(
|
|
62
|
+
`NOTE: the service restarted (${r.sceneRestored.reason ?? "unknown"}) and the scene was restored from a checkpoint. Your most recent step may be missing \u2014 check the scene before continuing.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (r.contactSheetUnchanged) {
|
|
66
|
+
lines.push("The scene is unchanged since the last render, so no new image was produced.");
|
|
67
|
+
}
|
|
68
|
+
lines.push(sceneSummary(r.scene));
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: lines.filter(Boolean).join("\n") }, ...sheet(r)],
|
|
71
|
+
// A raised script is a failed call, even though HTTP said 200 and the
|
|
72
|
+
// sheet came back. Without this the model reads "ERROR:" as prose.
|
|
73
|
+
isError: Boolean(r.error)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "blender_render",
|
|
79
|
+
description: "Re-render the current scene without changing it. Use `normals` to check for inverted faces and `wireframe` to check topology.",
|
|
80
|
+
inputSchema: { type: "object", properties: { mode: MODE_SCHEMA } },
|
|
81
|
+
run: async (a, base) => {
|
|
82
|
+
const r = await blenderCall(base, "/render", {
|
|
83
|
+
...isRenderMode(a.mode) ? { mode: a.mode } : {},
|
|
84
|
+
sheet: { formats: SHEET_FORMATS }
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{ type: "text", text: `rendered ${String(r.mode ?? "")} in ${r.ms ?? "?"}ms`.replace(" ", " ") },
|
|
89
|
+
...sheet(r)
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "blender_scene_info",
|
|
96
|
+
description: "The scene graph as JSON \u2014 every object with its type, triangle count, location, dimensions and materials, plus scene bounds. This is the gate: a render judge is unreliable for pass/fail, so verify structure here.",
|
|
97
|
+
inputSchema: { type: "object", properties: {} },
|
|
98
|
+
run: async (_a, base) => {
|
|
99
|
+
const r = await blenderCall(base, "/scene");
|
|
100
|
+
return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "blender_import_model",
|
|
105
|
+
description: "Import a GLB into the current scene by URL, so a generated asset can be composed with the rest.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: { url: { type: "string", description: "Public URL of a .glb" } },
|
|
109
|
+
required: ["url"]
|
|
110
|
+
},
|
|
111
|
+
run: async (a, base) => {
|
|
112
|
+
const r = await blenderCall(base, "/import", { url: String(a.url ?? "") });
|
|
113
|
+
return { content: [{ type: "text", text: `imported ${r.count ?? 0}: ${(r.imported ?? []).join(", ")}` }] };
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "blender_export_glb",
|
|
118
|
+
description: "Export the scene as a GLB. With `savePath` the bytes are written to that local file, which is how the result gets into a game.",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
savePath: { type: "string", description: "Local path to write, e.g. public/assets/scene.glb" }
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
run: async (a, base) => {
|
|
126
|
+
const r = await blenderCall(base, "/export", { path: "/tmp/genex-export.glb" });
|
|
127
|
+
let where = "(not saved locally \u2014 pass savePath to write it)";
|
|
128
|
+
if (typeof a.savePath === "string" && a.savePath.trim()) {
|
|
129
|
+
const target = path.resolve(a.savePath.trim());
|
|
130
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
131
|
+
await fs.writeFile(target, Buffer.from(r.glbBase64 ?? "", "base64"));
|
|
132
|
+
where = target;
|
|
133
|
+
}
|
|
134
|
+
return { content: [{ type: "text", text: `exported ${r.bytes ?? 0} bytes \u2192 ${where}` }] };
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "blender_reset",
|
|
139
|
+
description: "Empty the scene and the Python namespace. Start here when beginning something unrelated.",
|
|
140
|
+
inputSchema: { type: "object", properties: {} },
|
|
141
|
+
run: async (_a, base) => {
|
|
142
|
+
const r = await blenderCall(base, "/reset", {});
|
|
143
|
+
return { content: [{ type: "text", text: `reset. ${sceneSummary(r.scene)}` }] };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
];
|
|
147
|
+
function write(msg) {
|
|
148
|
+
process.stdout.write(`${JSON.stringify(msg)}
|
|
149
|
+
`);
|
|
150
|
+
}
|
|
151
|
+
function reply(id, result) {
|
|
152
|
+
write({ jsonrpc: "2.0", id, result });
|
|
153
|
+
}
|
|
154
|
+
function fail(id, code, message) {
|
|
155
|
+
write({ jsonrpc: "2.0", id, error: { code, message } });
|
|
156
|
+
}
|
|
157
|
+
async function handle(msg) {
|
|
158
|
+
const { method, id } = msg;
|
|
159
|
+
if (id === void 0 || id === null) return;
|
|
160
|
+
if (method === "initialize") {
|
|
161
|
+
const asked = msg.params?.protocolVersion ?? FALLBACK_PROTOCOL;
|
|
162
|
+
return reply(id, {
|
|
163
|
+
protocolVersion: asked,
|
|
164
|
+
capabilities: { tools: {} },
|
|
165
|
+
serverInfo: { name: "genex-blender", version: getCliVersion() }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (method === "ping") return reply(id, {});
|
|
169
|
+
if (method === "tools/list") {
|
|
170
|
+
return reply(id, {
|
|
171
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (method === "tools/call") {
|
|
175
|
+
const name = msg.params?.name;
|
|
176
|
+
const args = msg.params?.arguments ?? {};
|
|
177
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
178
|
+
if (!tool) return fail(id, -32602, `Unknown tool: ${name}`);
|
|
179
|
+
try {
|
|
180
|
+
const base = await mcpEndpoint();
|
|
181
|
+
if (!base) {
|
|
182
|
+
return reply(id, { content: [{ type: "text", text: blenderSetupHint() }], isError: true });
|
|
183
|
+
}
|
|
184
|
+
return reply(id, await tool.run(args, base));
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return reply(id, {
|
|
187
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
188
|
+
isError: true
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
fail(id, -32601, `Method not found: ${method}`);
|
|
193
|
+
}
|
|
194
|
+
var stderrLog = {
|
|
195
|
+
step: (m) => process.stderr.write(`[genex-blender-mcp] ${m}
|
|
196
|
+
`),
|
|
197
|
+
warn: (m) => process.stderr.write(`[genex-blender-mcp] ${m}
|
|
198
|
+
`),
|
|
199
|
+
error: (m) => process.stderr.write(`[genex-blender-mcp] ${m}
|
|
200
|
+
`)
|
|
201
|
+
};
|
|
202
|
+
async function mcpEndpoint() {
|
|
203
|
+
const seat = await acquireSeat({ log: stderrLog });
|
|
204
|
+
if (seat.kind === "granted") {
|
|
205
|
+
await blenderCall(seat.grant.url, "/health");
|
|
206
|
+
return seat.grant.url;
|
|
207
|
+
}
|
|
208
|
+
if (seat.kind === "refused") stderrLog.error(`no Blender seat: ${seat.reason}`);
|
|
209
|
+
if (seat.kind === "warming") stderrLog.warn("the Blender seat is still warming \u2014 the next call will try again");
|
|
210
|
+
return blenderEndpoint();
|
|
211
|
+
}
|
|
212
|
+
async function runBlenderMcp() {
|
|
213
|
+
process.stderr.write(`[genex-blender-mcp] ${TOOLS.length} tools, cli ${getCliVersion()}
|
|
214
|
+
`);
|
|
215
|
+
if (!blenderEndpoint()) {
|
|
216
|
+
process.stderr.write("[genex-blender-mcp] GENEX_BLENDER_URL unset \u2014 tools will explain setup\n");
|
|
217
|
+
}
|
|
218
|
+
let buf = "";
|
|
219
|
+
process.stdin.setEncoding("utf8");
|
|
220
|
+
for await (const chunk of process.stdin) {
|
|
221
|
+
buf += chunk;
|
|
222
|
+
let nl;
|
|
223
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
224
|
+
const line = buf.slice(0, nl).trim();
|
|
225
|
+
buf = buf.slice(nl + 1);
|
|
226
|
+
if (!line) continue;
|
|
227
|
+
let msg;
|
|
228
|
+
try {
|
|
229
|
+
msg = JSON.parse(line);
|
|
230
|
+
} catch {
|
|
231
|
+
fail(null, -32700, "Parse error");
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
await handle(msg);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
runBlenderMcp
|
|
241
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
c,
|
|
3
|
+
getTemplatesDir
|
|
4
|
+
} from "./chunk-HYCSNWYX.js";
|
|
5
|
+
|
|
6
|
+
// src/lib/blender-serve.ts
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import fs2 from "fs";
|
|
9
|
+
import os2 from "os";
|
|
10
|
+
import path2 from "path";
|
|
11
|
+
import "url";
|
|
12
|
+
|
|
13
|
+
// src/lib/blender-local.ts
|
|
14
|
+
import { spawnSync } from "child_process";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import path from "path";
|
|
18
|
+
var CANDIDATES = {
|
|
19
|
+
darwin: [
|
|
20
|
+
"/Applications/Blender.app/Contents/MacOS/Blender",
|
|
21
|
+
path.join(os.homedir(), "Applications/Blender.app/Contents/MacOS/Blender")
|
|
22
|
+
],
|
|
23
|
+
win32: [
|
|
24
|
+
"C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe",
|
|
25
|
+
"C:\\Program Files\\Blender Foundation\\Blender 4.5\\blender.exe",
|
|
26
|
+
"C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe"
|
|
27
|
+
],
|
|
28
|
+
linux: ["/usr/bin/blender", "/usr/local/bin/blender", "/snap/bin/blender"]
|
|
29
|
+
};
|
|
30
|
+
function probe(bin) {
|
|
31
|
+
if (!fs.existsSync(bin)) return null;
|
|
32
|
+
const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 2e4 });
|
|
33
|
+
const m = /Blender\s+(\d+)\.(\d+)(?:\.(\d+))?/.exec(r.stdout ?? "");
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
const major = Number(m[1]);
|
|
36
|
+
const minor = Number(m[2]);
|
|
37
|
+
return { version: `${major}.${minor}${m[3] ? `.${m[3]}` : ""}`, major, minor };
|
|
38
|
+
}
|
|
39
|
+
function findBlender() {
|
|
40
|
+
const pinned = process.env.GENEX_BLENDER_BIN?.trim();
|
|
41
|
+
if (pinned) {
|
|
42
|
+
const v = probe(pinned);
|
|
43
|
+
return v ? { bin: pinned, ...v, source: "env" } : null;
|
|
44
|
+
}
|
|
45
|
+
const onPath = spawnSync(process.platform === "win32" ? "where" : "which", ["blender"], {
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
timeout: 1e4
|
|
48
|
+
});
|
|
49
|
+
const first = (onPath.stdout ?? "").split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
50
|
+
if (first) {
|
|
51
|
+
const v = probe(first);
|
|
52
|
+
if (v) return { bin: first, ...v, source: "path" };
|
|
53
|
+
}
|
|
54
|
+
for (const bin of CANDIDATES[process.platform] ?? []) {
|
|
55
|
+
const v = probe(bin);
|
|
56
|
+
if (v) return { bin, ...v, source: "install" };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function vetBlender(info) {
|
|
61
|
+
const warnings = [];
|
|
62
|
+
if (info.major < 4 || info.major === 4 && info.minor < 2) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
warnings,
|
|
66
|
+
reason: `Blender ${info.version} is too old \u2014 this needs 4.2 or newer (EEVEE Next). Install a current Blender, or point GENEX_BLENDER_BIN at one.`
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (info.major > 5) {
|
|
70
|
+
warnings.push(
|
|
71
|
+
`Blender ${info.version} is newer than anything this has been tested against (4.2\u20135.x). If renders look wrong, that is the first thing to suspect.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, warnings };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/lib/blender-serve.ts
|
|
78
|
+
function serviceDir() {
|
|
79
|
+
return path2.join(getTemplatesDir(), "blender-service");
|
|
80
|
+
}
|
|
81
|
+
var POLL_MS = 1e3;
|
|
82
|
+
var TERM_GRACE_MS = 5e3;
|
|
83
|
+
async function serveLocalBlender(opts) {
|
|
84
|
+
const { log } = opts;
|
|
85
|
+
const info = findBlender();
|
|
86
|
+
if (!info) {
|
|
87
|
+
log.error("No Blender found on this machine.");
|
|
88
|
+
log.plain(
|
|
89
|
+
[
|
|
90
|
+
" Install Blender 4.2 or newer, then re-run. Or point at one you already have:",
|
|
91
|
+
` ${c.cyan("export GENEX_BLENDER_BIN=/path/to/blender")}`,
|
|
92
|
+
"",
|
|
93
|
+
` ${c.dim("macOS:")} https://www.blender.org/download/ (or: brew install --cask blender)`,
|
|
94
|
+
` ${c.dim("Linux:")} your distro's blender package, or the tarball from blender.org`,
|
|
95
|
+
` ${c.dim("Windows:")} https://www.blender.org/download/`
|
|
96
|
+
].join("\n")
|
|
97
|
+
);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const verdict = vetBlender(info);
|
|
101
|
+
if (!verdict.ok) {
|
|
102
|
+
log.error(verdict.reason ?? "This Blender cannot be used.");
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
for (const w of verdict.warnings) log.warn(w);
|
|
106
|
+
const server = path2.join(serviceDir(), "server.py");
|
|
107
|
+
if (!fs2.existsSync(server)) {
|
|
108
|
+
log.error(`Service files are missing: ${server}`);
|
|
109
|
+
log.plain(` ${c.dim("From a source checkout, run:")} node apps/cli/scripts/sync-blender-service.mjs`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
const stateDir = opts.stateDir ?? path2.join(os2.homedir(), ".genex", "blender");
|
|
113
|
+
fs2.mkdirSync(stateDir, { recursive: true });
|
|
114
|
+
const deadlineFile = path2.join(stateDir, "deadline");
|
|
115
|
+
try {
|
|
116
|
+
fs2.unlinkSync(deadlineFile);
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
log.step(`Blender ${info.version} ${c.dim(`(${info.source}: ${info.bin})`)}`);
|
|
120
|
+
log.plain(
|
|
121
|
+
` ${c.dim("This runs on YOUR machine and YOUR GPU. Nothing is uploaded, nothing is rented.")}`
|
|
122
|
+
);
|
|
123
|
+
let stopping = false;
|
|
124
|
+
let child = null;
|
|
125
|
+
const stop = (signal) => {
|
|
126
|
+
stopping = true;
|
|
127
|
+
if (child) child.kill(signal);
|
|
128
|
+
};
|
|
129
|
+
process.on("SIGINT", () => stop("SIGINT"));
|
|
130
|
+
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
131
|
+
while (!stopping) {
|
|
132
|
+
const started = Date.now();
|
|
133
|
+
child = spawnBlender(info, server, stateDir, opts.port);
|
|
134
|
+
const exit = await superviseUntilExit(child, deadlineFile, log);
|
|
135
|
+
child = null;
|
|
136
|
+
if (stopping) return 0;
|
|
137
|
+
if (exit.code === 0 && !exit.killed) {
|
|
138
|
+
if (fs2.existsSync(path2.join(stateDir, "destroy-requested"))) {
|
|
139
|
+
log.info("Blender exited cleanly.");
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
log.warn("Blender exited 0 without being asked to (a script called sys.exit()?). Restarting; the scene is restored from its checkpoint.");
|
|
143
|
+
}
|
|
144
|
+
if (!exit.killed && Date.now() - started < 15e3) {
|
|
145
|
+
log.error(`Blender exited ${exit.code} almost immediately \u2014 not restarting.`);
|
|
146
|
+
log.plain(` ${c.dim("Run with GENEX_BLENDER_DEBUG=1 to see its output.")}`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
log.warn(
|
|
150
|
+
exit.killed ? `Killed ${exit.route ?? "a request"} \u2014 it exceeded its ${exit.budget ?? "?"}s budget. Restarting; the scene is restored from its checkpoint.` : `Blender exited ${exit.code}. Restarting.`
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
fs2.writeFileSync(
|
|
154
|
+
path2.join(stateDir, "restart-reason"),
|
|
155
|
+
JSON.stringify({ at: Date.now() / 1e3, reason: exit.killed ? "deadline_exceeded" : "crashed", route: exit.route })
|
|
156
|
+
);
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
function spawnBlender(info, server, stateDir, port) {
|
|
163
|
+
return spawn(
|
|
164
|
+
info.bin,
|
|
165
|
+
[
|
|
166
|
+
"--background",
|
|
167
|
+
"--factory-startup",
|
|
168
|
+
"-noaudio",
|
|
169
|
+
// Without this a script that raises exits 0 and the failure looks healthy.
|
|
170
|
+
"--python-exit-code",
|
|
171
|
+
"1",
|
|
172
|
+
"--python",
|
|
173
|
+
server,
|
|
174
|
+
"--",
|
|
175
|
+
"--port",
|
|
176
|
+
String(port)
|
|
177
|
+
],
|
|
178
|
+
{
|
|
179
|
+
env: {
|
|
180
|
+
...process.env,
|
|
181
|
+
// The local lane is a GPU lane when the machine has one, but it must not
|
|
182
|
+
// REFUSE when it does not — unlike the pod, where software rendering
|
|
183
|
+
// means paying GPU rates for CPU work.
|
|
184
|
+
// Blender writes __pycache__ beside whatever it imports, and the
|
|
185
|
+
// service lives inside the installed package — so without this a user's
|
|
186
|
+
// node_modules accumulates .pyc files, and in a source checkout they
|
|
187
|
+
// land in templates/ and get vendored into the next publish. Caught by
|
|
188
|
+
// blender-parity.test.ts after exactly that happened.
|
|
189
|
+
PYTHONDONTWRITEBYTECODE: "1",
|
|
190
|
+
GENEX_BLENDER_REQUIRE_GPU: "detect",
|
|
191
|
+
GENEX_BLENDER_RUN_DIR: stateDir,
|
|
192
|
+
GENEX_BLENDER_CHECKPOINT: path2.join(stateDir, "checkpoint.blend")
|
|
193
|
+
},
|
|
194
|
+
stdio: process.env.GENEX_BLENDER_DEBUG === "1" ? "inherit" : ["ignore", "ignore", "pipe"]
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
function superviseUntilExit(child, deadlineFile, log) {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
let done = false;
|
|
201
|
+
let tail = "";
|
|
202
|
+
child.stderr?.on("data", (d) => {
|
|
203
|
+
tail = (tail + String(d)).slice(-2e3);
|
|
204
|
+
});
|
|
205
|
+
const timer = setInterval(() => {
|
|
206
|
+
let d = null;
|
|
207
|
+
try {
|
|
208
|
+
d = JSON.parse(fs2.readFileSync(deadlineFile, "utf8"));
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (typeof d.deadline === "number" && Date.now() / 1e3 > d.deadline) {
|
|
213
|
+
clearInterval(timer);
|
|
214
|
+
if (done) return;
|
|
215
|
+
done = true;
|
|
216
|
+
const route = d.route;
|
|
217
|
+
const budget = d.budget;
|
|
218
|
+
child.kill("SIGTERM");
|
|
219
|
+
const hard = setTimeout(() => child.kill("SIGKILL"), TERM_GRACE_MS);
|
|
220
|
+
child.once("exit", () => {
|
|
221
|
+
clearTimeout(hard);
|
|
222
|
+
try {
|
|
223
|
+
fs2.unlinkSync(deadlineFile);
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
resolve({ code: null, killed: true, route, budget });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}, POLL_MS);
|
|
230
|
+
child.once("exit", (code) => {
|
|
231
|
+
clearInterval(timer);
|
|
232
|
+
if (done) return;
|
|
233
|
+
done = true;
|
|
234
|
+
if (code !== 0 && tail.trim() && process.env.GENEX_BLENDER_DEBUG !== "1") {
|
|
235
|
+
log.plain(c.dim(tail.trim().split("\n").slice(-6).join("\n")));
|
|
236
|
+
}
|
|
237
|
+
resolve({ code, killed: false });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
export {
|
|
242
|
+
serveLocalBlender,
|
|
243
|
+
serviceDir
|
|
244
|
+
};
|