@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.
- package/README.md +0 -18
- package/dist/index.js +2839 -4768
- package/package.json +3 -3
- package/templates/controllers/character/follow-camera.ts +1 -16
- package/templates/controllers/character/meshy/meshy-loader.ts +2 -3
- package/templates/controllers/quality/pick-asset.ts +16 -49
- package/templates/controllers/shared/physics-world.ts +4 -6
- package/templates/skills/genex-ai-character/SKILL.md +15 -77
- package/templates/skills/genex-ai-menu/SKILL.md +11 -15
- package/templates/skills/genex-ai-model/SKILL.md +7 -45
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +17 -75
- package/templates/skills/genex-game-director/SKILL.md +46 -112
- package/templates/skills/genex-game-director/references/design-contract.md +5 -13
- package/templates/skills/genex-game-director/references/routing-map.md +45 -30
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-monetization/SKILL.md +171 -0
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +0 -21
- package/templates/skills/genex-threejs-character-controller/SKILL.md +5 -17
- package/templates/skills/genex-threejs-creatures/SKILL.md +1 -8
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +52 -8
- package/templates/skills/genex-threejs-game-ui/SKILL.md +6 -55
- package/templates/skills/genex-threejs-procedural-assets/SKILL.md +10 -17
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +2 -12
- package/templates/skills/genex-tool-audio/SKILL.md +2 -3
- package/templates/skills/genex-tool-character/SKILL.md +5 -36
- package/templates/skills/genex-tool-image/SKILL.md +2 -4
- package/templates/skills/genex-tool-model/SKILL.md +6 -32
- package/templates/skills/genex-tool-texture/SKILL.md +1 -1
- package/templates/skills/genex-tool-video/SKILL.md +5 -28
- package/templates/skills/genex-tool-workflow/SKILL.md +1 -4
- package/templates/skills/genex-updates/SKILL.md +1 -1
- package/dist/blender-mcp-Q6PSFYSE.js +0 -241
- package/dist/blender-serve-BF4FZ55Z.js +0 -244
- package/dist/chunk-2COG4P3T.js +0 -968
- package/dist/chunk-HYCSNWYX.js +0 -126
- package/templates/blender-service/demo/castle.py +0 -117
- package/templates/blender-service/gpu_witness.py +0 -245
- package/templates/blender-service/ops.py +0 -225
- package/templates/blender-service/pool.py +0 -910
- package/templates/blender-service/server.py +0 -611
- package/templates/blender-service/supervisor.py +0 -221
- package/templates/blender-service/views.py +0 -281
- package/templates/controllers/quality/deadline.ts +0 -117
- package/templates/skills/genex-blender-scene/SKILL.md +0 -243
- package/templates/skills/genex-lane-card/SKILL.md +0 -78
- 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()
|