@genex-ai/cli-demo 1.30.0-dev.645 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +0 -18
  2. package/dist/index.js +2839 -4768
  3. package/package.json +3 -3
  4. package/templates/controllers/character/follow-camera.ts +1 -16
  5. package/templates/controllers/character/meshy/meshy-loader.ts +2 -3
  6. package/templates/controllers/quality/pick-asset.ts +16 -49
  7. package/templates/controllers/shared/physics-world.ts +4 -6
  8. package/templates/skills/genex-ai-character/SKILL.md +15 -77
  9. package/templates/skills/genex-ai-menu/SKILL.md +11 -15
  10. package/templates/skills/genex-ai-model/SKILL.md +7 -45
  11. package/templates/skills/genex-ai-texture/SKILL.md +1 -1
  12. package/templates/skills/genex-ai-video/SKILL.md +17 -75
  13. package/templates/skills/genex-game-director/SKILL.md +46 -112
  14. package/templates/skills/genex-game-director/references/design-contract.md +5 -13
  15. package/templates/skills/genex-game-director/references/routing-map.md +45 -30
  16. package/templates/skills/genex-getting-started/SKILL.md +2 -2
  17. package/templates/skills/genex-monetization/SKILL.md +171 -0
  18. package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +0 -21
  19. package/templates/skills/genex-threejs-character-controller/SKILL.md +5 -17
  20. package/templates/skills/genex-threejs-creatures/SKILL.md +1 -8
  21. package/templates/skills/genex-threejs-embed-auth/SKILL.md +52 -8
  22. package/templates/skills/genex-threejs-game-ui/SKILL.md +6 -55
  23. package/templates/skills/genex-threejs-procedural-assets/SKILL.md +10 -17
  24. package/templates/skills/genex-threejs-visual-validation/SKILL.md +2 -12
  25. package/templates/skills/genex-tool-audio/SKILL.md +2 -3
  26. package/templates/skills/genex-tool-character/SKILL.md +5 -36
  27. package/templates/skills/genex-tool-image/SKILL.md +2 -4
  28. package/templates/skills/genex-tool-model/SKILL.md +6 -32
  29. package/templates/skills/genex-tool-texture/SKILL.md +1 -1
  30. package/templates/skills/genex-tool-video/SKILL.md +5 -28
  31. package/templates/skills/genex-tool-workflow/SKILL.md +1 -4
  32. package/templates/skills/genex-updates/SKILL.md +1 -1
  33. package/dist/blender-mcp-Q6PSFYSE.js +0 -241
  34. package/dist/blender-serve-BF4FZ55Z.js +0 -244
  35. package/dist/chunk-2COG4P3T.js +0 -968
  36. package/dist/chunk-HYCSNWYX.js +0 -126
  37. package/templates/blender-service/demo/castle.py +0 -117
  38. package/templates/blender-service/gpu_witness.py +0 -245
  39. package/templates/blender-service/ops.py +0 -225
  40. package/templates/blender-service/pool.py +0 -910
  41. package/templates/blender-service/server.py +0 -611
  42. package/templates/blender-service/supervisor.py +0 -221
  43. package/templates/blender-service/views.py +0 -281
  44. package/templates/controllers/quality/deadline.ts +0 -117
  45. package/templates/skills/genex-blender-scene/SKILL.md +0 -243
  46. package/templates/skills/genex-lane-card/SKILL.md +0 -78
  47. package/templates/skills/genex-tool-publish/SKILL.md +0 -100
@@ -1,611 +0,0 @@
1
- """Genex Blender service -- HTTP over a live, stateful Blender.
2
-
3
- Run as: blender --background --python server.py -- --port 8080
4
-
5
- Two deliberate choices, each load-bearing:
6
-
7
- * `HTTPServer`, never `ThreadingHTTPServer`. bpy is not thread-safe, and a
8
- plain HTTPServer dispatches every request on the thread that called
9
- serve_forever() -- Blender's main thread. Requests serialize, which is what
10
- one-Blender-per-session wants anyway. Swapping in the threading variant
11
- would look like a throughput win and corrupt the scene instead.
12
-
13
- * The process blocks in serve_forever(). `blender --background --python x.py`
14
- exits the moment the script returns, so the scene lives exactly as long as
15
- this call does.
16
- """
17
-
18
- import sys
19
-
20
- # BEFORE any local import. Blender writes __pycache__ beside whatever it
21
- # imports, and in the local lane the service lives inside the user's installed
22
- # node_modules -- or, in a source checkout, inside templates/, where the .pyc
23
- # files then get vendored into the next publish. MEASURED: that is exactly what
24
- # happened, and blender-parity.test.ts caught it. The PYTHONDONTWRITEBYTECODE
25
- # env var does NOT work here (Blender's embedded Python ignores it), so it has
26
- # to be set in-process, above the imports that would trigger the write.
27
- sys.dont_write_bytecode = True
28
-
29
- import argparse
30
- import base64
31
- import hashlib
32
- import json
33
- import os
34
- import tempfile
35
- import time
36
- from http.server import BaseHTTPRequestHandler, HTTPServer
37
-
38
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
-
40
- import ops # noqa: E402
41
- import views # noqa: E402
42
-
43
- SHARED_SECRET = os.environ.get("GENEX_BLENDER_SECRET", "")
44
- # Deliberate, not incidental: this bounds how large a /exec script or an inlined
45
- # asset may be. 8 MB is a generous script and a firm ceiling.
46
- MAX_BODY = 8 * 1024 * 1024
47
- # 4 MiB of base64 ~= 3 MiB of image. A PNG sheet has been measured at ~1 MB;
48
- # WebP at ~22 KB. Anything past this is a bug, not a big scene.
49
- SHEET_INLINE_MAX_B64 = 4 * 1024 * 1024
50
-
51
- # /run/genex in a container; a user's own directory in the local lane, where
52
- # / is not writable and a laptop has no business being told otherwise.
53
- RUN_DIR = os.environ.get("GENEX_BLENDER_RUN_DIR", "/run/genex")
54
- DEADLINE_FILE = os.path.join(RUN_DIR, "deadline")
55
- RESTART_REASON = os.path.join(RUN_DIR, "restart-reason")
56
- CHECKPOINT = os.environ.get("GENEX_BLENDER_CHECKPOINT", "/workspace/checkpoint.blend")
57
-
58
- # Per-route budgets, enforced by supervisor.py (this process cannot interrupt
59
- # itself -- bpy is not thread-safe). A request may lower its own budget but the
60
- # ceiling is not negotiable from the wire.
61
- # /render matches /exec at 120s: a genuinely cold pod can pay 30-44s of
62
- # EEVEE shader compilation before the first pixel, which fits inside 60s
63
- # but not comfortably. Measured on a fresh 4090 pod the compile had
64
- # already been paid by the boot probe, so it was never observed at the
65
- # route -- which is exactly why the headroom should not depend on that.
66
- ROUTE_BUDGET_S = {"/exec": 120.0, "/render": 120.0, "/bake": 900.0,
67
- "/import": 300.0, "/export": 300.0, "/reset": 60.0}
68
- # Boot gets its own budget because the GPU probe can HANG rather than fail: a
69
- # GPU-less host was measured blocking indefinitely inside EGL/EEVEE init instead
70
- # of erroring. A pod that never becomes healthy and never exits is worse than
71
- # one that refuses -- it bills forever and no crash-loop guard ever fires.
72
- # Generous, because a cold GPU pod pays 30-44s of shader compilation here.
73
- BOOT_BUDGET_S = 180.0
74
- BUDGET_CEILING_S = 900.0
75
-
76
- # True when the scene we are serving came back from a checkpoint rather than
77
- # from the caller's own steps. Reported ONCE, on the next response: a silent
78
- # restore is a scene that quietly forgot the last thing it was told, which reads
79
- # as the model being stupid.
80
- RESTORED = None
81
- # Whether this process is allowed to render on the CPU. The GPU image flips it.
82
- GPU_WITNESS = None
83
-
84
- # The contact-sheet mode used when a request does not name one. TIER-AWARE, and
85
- # that is the whole point: `lit` (EEVEE) is what the GPU is for, and MEASURED on
86
- # the CPU tier it costs 47.5s per sheet against 0.4s for Workbench. So the GPU
87
- # tier defaults to the good picture and the CPU tier defaults to the fast one --
88
- # one default would be wrong on one of them.
89
- # THREE states, not two, because the local lane is neither of the others:
90
- # "1" require a GPU, refuse to serve without one (rented pod: software
91
- # rendering there means paying GPU rates for CPU work)
92
- # "detect" use the GPU if this machine has one, else fall back (a user's own
93
- # machine: refusing would be absurd, and so would forcing a 47s
94
- # default onto someone with no GPU)
95
- # else CPU tier (Cloudflare sandbox)
96
- GPU_MODE = os.environ.get("GENEX_BLENDER_REQUIRE_GPU", "")
97
- IS_GPU_TIER = GPU_MODE == "1"
98
-
99
-
100
- def _gpu_present(w):
101
- """A real GPU behind the RASTERIZER (EEVEE/Workbench), which is what the
102
- contact sheet uses. cyclesDevices is about Cycles and is not the same
103
- question — see gpu_witness for why that distinction cost a correction."""
104
- if not w:
105
- return False
106
- if IS_GPU_TIER:
107
- # The witness already died if there were no GPU (assert_gpu_or_die runs
108
- # before the socket opens). Keying the default on gpu.platform alone sent
109
- # a healthy rented 4090 to Workbench `solid` whenever that module declined
110
- # to answer in background mode — the CPU picture at GPU rates (review).
111
- return True
112
- if w.get("device") not in ("SOFTWARE", "NONE", "UNKNOWN", None):
113
- return True
114
- # UNKNOWN from gpu.platform, but the rest of the witness says a GPU rendered.
115
- return bool(w.get("cyclesDevices")) and w.get("eeveeProbe") is True
116
-
117
-
118
- # Overwritten at boot once the witness has run.
119
- DEFAULT_MODE = "solid"
120
-
121
-
122
- def _set_deadline(route, budget_s):
123
- """Publish the clock the supervisor enforces. Best-effort: a service that
124
- cannot write here must still serve, it just loses the runaway guard."""
125
- try:
126
- os.makedirs(RUN_DIR, exist_ok=True)
127
- with open(DEADLINE_FILE, "w") as f:
128
- json.dump({"route": route, "started": time.time(),
129
- "budget": budget_s, "deadline": time.time() + budget_s}, f)
130
- except OSError:
131
- pass
132
-
133
-
134
- def _clear_deadline():
135
- try:
136
- os.unlink(DEADLINE_FILE)
137
- except (FileNotFoundError, OSError):
138
- pass
139
-
140
-
141
- def _budget_for(route, body):
142
- asked = body.get("budgetSeconds") if isinstance(body, dict) else None
143
- base = ROUTE_BUDGET_S.get(route, 120.0)
144
- try:
145
- if asked is not None:
146
- base = float(asked)
147
- except (TypeError, ValueError):
148
- pass
149
- return max(1.0, min(base, BUDGET_CEILING_S))
150
-
151
-
152
- def _checkpoint():
153
- """Snapshot after every mutating call. Best-effort by design: losing a
154
- checkpoint must never fail the call that produced the scene."""
155
- try:
156
- ops.save_checkpoint(CHECKPOINT)
157
- except Exception as e: # noqa: BLE001
158
- sys.stderr.write("[svc] checkpoint failed: %s\n" % e)
159
-
160
-
161
- def _restore_if_recovering():
162
- """Called once at boot. Restores only when the supervisor says the last
163
- process died -- a normal cold start must begin with an EMPTY scene, not
164
- with somebody else's leftovers."""
165
- global RESTORED
166
- if not os.path.exists(RESTART_REASON):
167
- return
168
- try:
169
- with open(RESTART_REASON) as f:
170
- reason = json.load(f)
171
- except (ValueError, OSError):
172
- reason = {"reason": "unknown"}
173
- try:
174
- os.unlink(RESTART_REASON)
175
- except OSError:
176
- pass
177
- if not os.path.exists(CHECKPOINT):
178
- RESTORED = dict(reason, restored=False, note="no checkpoint existed")
179
- return
180
- try:
181
- ops.load_checkpoint(CHECKPOINT)
182
- RESTORED = dict(reason, restored=True,
183
- lostAfter=os.path.getmtime(CHECKPOINT))
184
- sys.stderr.write("[svc] scene restored from checkpoint (%s)\n"
185
- % reason.get("reason"))
186
- except Exception as e: # noqa: BLE001
187
- RESTORED = dict(reason, restored=False, note=str(e))
188
-
189
-
190
- # Routes that must never consume the recovery notice: /health is the container
191
- # healthcheck's target and would swallow it on a timer.
192
- _NO_RESTORE_NOTICE = frozenset({"/health"})
193
-
194
-
195
- def _take_restored():
196
- """Report the recovery exactly once."""
197
- global RESTORED
198
- r, RESTORED = RESTORED, None
199
- return r
200
-
201
-
202
- # The digest of the last sheet we actually sent: (scene fingerprint, mode, fmt).
203
- # Used by the repeat guard below.
204
- _LAST_SHEET = None
205
-
206
-
207
- def _scene_digest():
208
- """A cheap fingerprint of what a render would SHOW.
209
-
210
- Lives in ops.scene_digest(): a walk over what a RENDER depends on —
211
- transforms including rotation and scale, render visibility, modifiers,
212
- mesh vertex coordinates (numpy, hashed), light energy/colour, material
213
- colours. The first cut hashed scene_info() alone, which is blind to every one
214
- of those, so a rotated object or a subdivided mesh answered "unchanged" and
215
- the MCP told the model not to look (review finding).
216
- """
217
- return ops.scene_digest()
218
-
219
-
220
- def _negotiate_format(body):
221
- """Pick a sheet format from the request BODY, never a header.
222
-
223
- A header would be dropped by the pod router's inward allowlist, and every
224
- hosted session would silently get PNG while the local lane got WebP -- the
225
- compression work appearing not to have shipped on exactly the lane it was
226
- built for.
227
- """
228
- want = None
229
- sheet = body.get("sheet") if isinstance(body, dict) else None
230
- if isinstance(sheet, dict):
231
- fmts = sheet.get("formats")
232
- if isinstance(fmts, str):
233
- fmts = [fmts]
234
- if isinstance(fmts, list):
235
- want = next((f for f in fmts if f in views.SHEET_FORMATS), None)
236
- # No negotiation = an older client = PNG under the legacy field. New clients
237
- # ask; old ones must keep working byte-for-byte.
238
- return want
239
-
240
-
241
- def _sheet_b64(mode=None, fmt="png"):
242
- mode = mode or DEFAULT_MODE
243
- fd, path = tempfile.mkstemp(suffix="." + fmt, prefix="genex-sheet-")
244
- os.close(fd)
245
- try:
246
- mime = views.contact_sheet(path, mode=mode, fmt=fmt)
247
- with open(path, "rb") as f:
248
- return base64.b64encode(f.read()).decode("ascii"), mime
249
- finally:
250
- if os.path.exists(path):
251
- os.unlink(path)
252
-
253
-
254
- def _attach_sheet(result, body, mode):
255
- """Render a sheet into `result`, or say it is unchanged.
256
-
257
- THE REPEAT GUARD IS THE POINT, not the compression. WebP cut the sheet 79x
258
- in BYTES and 0% in MODEL TOKENS -- vision cost is a function of resolution,
259
- not encoding -- so the transport win removes a wall-clock brake from a loop
260
- whose real cost is tokens. This repo has a measured precedent: $11.86 for one
261
- hour of a look-only loop. An unchanged scene sends no image at all.
262
- """
263
- global _LAST_SHEET
264
- fmt = _negotiate_format(body)
265
- digest = (_scene_digest(), mode, fmt or "png")
266
- if _LAST_SHEET == digest:
267
- result["contactSheetUnchanged"] = True
268
- result["mode"] = mode
269
- return
270
- b64, mime = _sheet_b64(mode, fmt or "png")
271
- # A latent bug /export already guards against: a sheet big enough to blow a
272
- # transport ceiling fails only on the long, successful sessions that made the
273
- # most geometry. WebP makes this nearly unreachable (measured max 22 KB) --
274
- # which is exactly why it should be a ceiling and not a hope.
275
- if len(b64) > SHEET_INLINE_MAX_B64:
276
- result["contactSheetTooLarge"] = {
277
- "b64Bytes": len(b64), "limit": SHEET_INLINE_MAX_B64,
278
- "detail": "ask for {\"sheet\":{\"formats\":[\"webp\"]}} or a smaller mode",
279
- }
280
- _LAST_SHEET = None
281
- result["mode"] = mode
282
- return
283
- _LAST_SHEET = digest
284
- result["mode"] = mode
285
- if fmt is None:
286
- # Legacy field, PNG forever. Never both — that would double the bytes.
287
- result["contactSheetPng"] = b64
288
- else:
289
- result["contactSheet"] = {"mime": mime, "b64": b64}
290
-
291
-
292
- class Handler(BaseHTTPRequestHandler):
293
- protocol_version = "HTTP/1.1"
294
- server_version = "genex-blender/0"
295
- # MEASURED-CLASS BUG, and it only bites with more than one client.
296
- # `socketserver` handles one CONNECTION at a time and BaseHTTPRequestHandler
297
- # loops `handle_one_request` until the peer closes. With HTTP/1.1 keep-alive
298
- # and no timeout, the FIRST client to connect owns the server for as long as
299
- # it holds the socket open -- a second caller does not queue, it HANGS. Node
300
- # `fetch` keep-alives by default, so this is the normal path, not an edge.
301
- #
302
- # `timeout` makes `rfile.readline()` give the connection back to the accept
303
- # loop when an idle peer has nothing to say.
304
- #
305
- # THIS BOUNDS THE DAMAGE, IT DOES NOT REMOVE IT. Measured after the fix: a
306
- # second client is served in ~5.0s instead of never, and two clients then
307
- # ping-pong that wait. That is the difference between broken and slow, and
308
- # it is the right scope here -- the real fix is ONE PROCESS PER SEAT behind
309
- # a router, where two callers never share a socketserver at all.
310
- #
311
- # Threading the server is NOT the fix and must not be attempted: bpy is
312
- # main-thread-only, so a ThreadingHTTPServer would have to marshal every
313
- # call back to one worker anyway, and calling bpy.ops from a handler thread
314
- # crashes elsewhere, later, in unrelated code.
315
- #
316
- # 5s rather than something shorter because an agent thinks for seconds
317
- # between calls; a tighter window would churn connections for a client that
318
- # is behaving normally.
319
- timeout = 5.0
320
-
321
- def handle_one_request(self):
322
- # BaseHTTPRequestHandler does not handle the timeout it enables: on
323
- # expiry `readline()` raises and the connection dies with a traceback in
324
- # the log. Closing quietly is the whole point of setting it.
325
- try:
326
- super().handle_one_request()
327
- except TimeoutError:
328
- self.close_connection = True
329
- except OSError:
330
- self.close_connection = True
331
-
332
- def log_message(self, fmt, *args):
333
- sys.stderr.write("[svc] %s\n" % (fmt % args))
334
-
335
- def _send(self, code, payload, close=False):
336
- # The recovery notice rides EVERY response, not just /exec's.
337
- # Previously only the /exec branch called _take_restored(), so a
338
- # /render or /scene right after a crash returned a picture of a scene
339
- # that had silently lost work -- and by the time the next /exec carried
340
- # the notice, the agent had already reasoned from the wrong image and
341
- # possibly re-applied an edit the restore had kept.
342
- # ...but NOT on /health. MEASURED: the container healthcheck polls it
343
- # every 30s, so attaching the notice there consumed it before any real
344
- # client could see it -- the notice went to a probe that discards it.
345
- # Only routes a caller actually asks for may take it.
346
- route = (self.path or "").split("?")[0]
347
- # ...and only on a SUCCESSFUL response. MEASURED: a mistaken request that
348
- # 404'd consumed the notice, so the scene really had been restored and
349
- # the caller was never told. A failed call delivered nothing the agent
350
- # can act on, so it has no business taking the one-shot notice with it.
351
- if (200 <= code < 300
352
- and route not in _NO_RESTORE_NOTICE
353
- and isinstance(payload, dict) and "sceneRestored" not in payload):
354
- restored = _take_restored()
355
- if restored:
356
- payload = dict(payload, sceneRestored=restored)
357
- body = json.dumps(payload).encode("utf-8")
358
- self.send_response(code)
359
- self.send_header("Content-Type", "application/json")
360
- self.send_header("Content-Length", str(len(body)))
361
- if close:
362
- self.send_header("Connection", "close")
363
- self.end_headers()
364
- self.wfile.write(body)
365
-
366
- def _authed(self):
367
- # Constant-time-ish compare; an unset secret means the spike is open,
368
- # matching the admin-only/disposable posture. A DEPLOYED service can
369
- # never reach here with an empty secret -- main() exits 5 first.
370
- if not SHARED_SECRET:
371
- return True
372
- got = self.headers.get("x-genex-internal", "")
373
- if len(got) != len(SHARED_SECRET):
374
- return False
375
- return sum(a != b for a, b in zip(got, SHARED_SECRET)) == 0
376
-
377
- def _body(self):
378
- n = int(self.headers.get("Content-Length") or 0)
379
- if n > MAX_BODY:
380
- raise ValueError(f"body too large: {n} > {MAX_BODY}")
381
- return json.loads(self.rfile.read(n) or b"{}") if n else {}
382
-
383
- def do_GET(self):
384
- if self.path == "/health":
385
- # The API REFUSES to route a session to a pod whose device is
386
- # SOFTWARE. Without that, the boot assertion is a log line nobody
387
- # reads and a CPU render bills at GPU rates.
388
- return self._send(200, {"ok": True, "blender": _blender_version(),
389
- "gpu": GPU_WITNESS, "defaultMode": DEFAULT_MODE})
390
- if not self._authed():
391
- return self._send(401, {"error": "unauthorized"})
392
- if self.path == "/scene":
393
- return self._send(200, ops.scene_info())
394
- if self.path == "/gpu":
395
- return self._send(200, {"gpu": GPU_WITNESS})
396
- self._send(404, {"error": "not_found"})
397
-
398
- def do_POST(self):
399
- if not self._authed():
400
- return self._send(401, {"error": "unauthorized"})
401
- try:
402
- body = self._body()
403
- except Exception as e:
404
- return self._send(400, {"error": "bad_request", "detail": str(e)})
405
-
406
- started = time.time()
407
- route = self.path.split("?")[0]
408
- _set_deadline(route, _budget_for(route, body))
409
- stage = route.lstrip("/")
410
- script_started = False
411
- try:
412
- if route == "/exec":
413
- stage = "script"
414
- script_started = True
415
- result = ops.run_script(body.get("script", ""))
416
- stage = "scene_info"
417
- result["scene"] = ops.scene_info()
418
- result["gpu"] = GPU_WITNESS
419
- # The sheet rides EVERY exec by default. verify:false is for
420
- # bulk sub-steps only -- an agent that has to ask for the
421
- # picture is an agent that will forget to.
422
- if body.get("verify", True):
423
- stage = "verification"
424
- _attach_sheet(result, body, body.get("mode") or DEFAULT_MODE)
425
- result["ms"] = int((time.time() - started) * 1000)
426
- _checkpoint()
427
- return self._send(200, result)
428
-
429
- if route == "/render":
430
- out = {"gpu": GPU_WITNESS}
431
- # /render is an EXPLICIT ask, so it bypasses the repeat guard --
432
- # a caller that asked again wants the bytes (a different mode, or
433
- # simply to look again). Only /exec's automatic sheet is guarded.
434
- fmt = _negotiate_format(body)
435
- mode = body.get("mode") or DEFAULT_MODE
436
- b64, mime = _sheet_b64(mode, fmt or "png")
437
- out["mode"] = mode
438
- if fmt is None:
439
- out["contactSheetPng"] = b64
440
- else:
441
- out["contactSheet"] = {"mime": mime, "b64": b64}
442
- out["ms"] = int((time.time() - started) * 1000)
443
- return self._send(200, out)
444
-
445
- if route == "/export":
446
- path = body.get("path") or os.path.join(tempfile.gettempdir(), "scene.glb")
447
- res = ops.export_glb(path)
448
- upload = body.get("upload")
449
- if isinstance(upload, dict) and upload.get("url"):
450
- # The artifact the game ships: straight to object storage.
451
- ops.upload_file(res["path"], upload["url"], upload.get("headers"))
452
- res["uploaded"] = True
453
- elif res["bytes"] > ops.INLINE_GLB_MAX_BYTES:
454
- # Refuse rather than emit a body that a 10 MB transport
455
- # ceiling would truncate on exactly the long, successful
456
- # sessions that generated the most texture.
457
- return self._send(413, {
458
- "error": "payload_too_large",
459
- "bytes": res["bytes"],
460
- "limit": ops.INLINE_GLB_MAX_BYTES,
461
- "detail": "pass upload:{url,headers} with a presigned PUT to export this scene",
462
- })
463
- else:
464
- with open(res["path"], "rb") as f:
465
- res["glbBase64"] = base64.b64encode(f.read()).decode("ascii")
466
- res["ms"] = int((time.time() - started) * 1000)
467
- return self._send(200, res)
468
-
469
- if route == "/import":
470
- out = ops.import_glb(body["url"])
471
- _checkpoint()
472
- return self._send(200, out)
473
-
474
- if route in ("/reset", "/session"):
475
- info = ops.reset_scene()
476
- _checkpoint()
477
- return self._send(200, {"ok": True, "scene": info})
478
-
479
- if route == "/destroy":
480
- # The process is about to exit, so the connection cannot be
481
- # reused. Saying so lets the client close cleanly instead of
482
- # holding a keep-alive socket to a corpse until its own timeout.
483
- self.close_connection = True
484
- # Record the INTENT before exiting. The supervisor no longer
485
- # infers "the operator asked to stop" from exit code 0, because
486
- # a script calling sys.exit() produces the same code.
487
- try:
488
- os.makedirs(RUN_DIR, exist_ok=True)
489
- with open(os.path.join(RUN_DIR, "destroy-requested"), "w") as f:
490
- f.write(str(time.time()))
491
- except OSError:
492
- pass
493
- self._send(200, {"ok": True}, close=True)
494
- self.server._should_stop = True
495
- return
496
-
497
- self._send(404, {"error": "not_found"})
498
- except Exception as e:
499
- import traceback
500
-
501
- self._send(500, {"error": "exec_failed", "detail": str(e), "trace": traceback.format_exc(limit=6),
502
- "stage": stage, "scriptStarted": script_started})
503
- finally:
504
- # MUST run: a stale deadline file makes the supervisor kill a
505
- # perfectly healthy Blender on its next poll.
506
- _clear_deadline()
507
-
508
-
509
- def _blender_version():
510
- import bpy
511
-
512
- return ".".join(str(v) for v in bpy.app.version)
513
-
514
-
515
- class Server(HTTPServer):
516
- _should_stop = False
517
-
518
- def service_actions(self):
519
- if self._should_stop:
520
- raise SystemExit(0)
521
-
522
-
523
- def main():
524
- argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
525
- ap = argparse.ArgumentParser()
526
- ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8080")))
527
- ap.add_argument("--host", default="0.0.0.0")
528
- args = ap.parse_args(argv)
529
-
530
- # Boot into the SAME state /reset gives, not Blender's factory scene. The
531
- # factory scene ships a Cube, a Light and a Camera, so a bare /exec would
532
- # silently build on top of a stray cube nobody asked for -- and the agent
533
- # would see objectCount 3 before it had made anything.
534
- ops.reset_scene()
535
-
536
- # ORDER MATTERS, and this is the whole safety argument of the GPU tier:
537
- # assert BEFORE the socket opens, so a pod that would answer from the CPU
538
- # never accepts a request it bills at GPU rates. GENEX_BLENDER_REQUIRE_GPU
539
- # is set by Dockerfile.gpu and unset on the CPU image, which is how one
540
- # server.py serves both tiers without a second code path.
541
- global GPU_WITNESS, DEFAULT_MODE
542
- # Under the supervisor's clock from here: see BOOT_BUDGET_S.
543
- _set_deadline("/boot", BOOT_BUDGET_S)
544
- # NOT `gpu` — Blender ships a BUILT-IN module of that name, and it shadows
545
- # any local gpu.py. Measured: `import gpu` inside the service returned
546
- # Blender's module and AttributeError'd on assert_gpu_or_die, so the
547
- # assertion silently never ran.
548
- if IS_GPU_TIER:
549
- import gpu_witness
550
-
551
- GPU_WITNESS = gpu_witness.assert_gpu_or_die()
552
- elif GPU_MODE == "detect":
553
- import gpu_witness
554
-
555
- GPU_WITNESS = gpu_witness.witness()
556
- GPU_WITNESS["tier"] = "local"
557
- else:
558
- GPU_WITNESS = {"backend": "SOFTWARE", "device": "SOFTWARE",
559
- "renderer": "llvmpipe (CPU tier)", "cyclesDevices": [],
560
- "tier": "cpu"}
561
-
562
- # Resolved AFTER the witness, because on the local lane the answer depends
563
- # on what was actually found. `lit` costs 0.8s on a GPU and 47s without one,
564
- # so the default has to follow the hardware rather than the tier name.
565
- DEFAULT_MODE = "lit" if _gpu_present(GPU_WITNESS) else "solid"
566
-
567
- # After the assertion: a recovering process restores its scene, a cold one
568
- # does not. Doing this before the socket means the first request already
569
- # sees the recovered scene rather than an empty one.
570
- _restore_if_recovering()
571
- _clear_deadline()
572
-
573
- srv = Server((args.host, args.port), Handler)
574
- sys.stderr.write(
575
- f"[svc] genex-blender on {args.host}:{args.port} (blender {_blender_version()})\n"
576
- )
577
- # Deployed anywhere reachable, a missing secret is FATAL, not a warning:
578
- # /exec runs arbitrary Python for anyone who can reach the port. Enforced
579
- # here rather than as a compose `${VAR:?}`, because compose interpolates the
580
- # whole file before selecting services -- MEASURED, that form aborted
581
- # `up -d traefik web api ...` too and would have taken the entire dev deploy
582
- # down over a service that is off by default.
583
- if os.environ.get("GENEX_BLENDER_REQUIRE_SECRET") == "1" and not SHARED_SECRET:
584
- sys.stderr.write(
585
- "[svc] FATAL no_shared_secret: GENEX_BLENDER_REQUIRE_SECRET=1 but "
586
- "GENEX_BLENDER_SECRET is empty - refusing to serve an "
587
- "arbitrary-exec endpoint unauthenticated.\n")
588
- sys.stderr.flush()
589
- sys.exit(5)
590
- if not SHARED_SECRET:
591
- # Loud, and on purpose. Unset means /exec runs arbitrary Python for
592
- # ANY caller that can reach this port -- correct for a laptop, and a
593
- # remote-code-execution endpoint the moment it is behind a hostname.
594
- # The deployed compose makes the secret mandatory (`:?`), so this line
595
- # firing on a server is a misconfiguration you want to see in the log.
596
- sys.stderr.write(
597
- "[svc] WARNING: GENEX_BLENDER_SECRET is unset - every route is OPEN "
598
- "and /exec runs arbitrary Python. Safe on localhost, never on a "
599
- "reachable host.\n"
600
- )
601
- sys.stderr.flush()
602
- try:
603
- srv.serve_forever(poll_interval=0.2)
604
- except (KeyboardInterrupt, SystemExit):
605
- pass
606
- finally:
607
- srv.server_close()
608
-
609
-
610
- if __name__ == "__main__":
611
- main()