@genex-ai/cli-demo 1.31.1 → 1.32.0-dev.650

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 (46) hide show
  1. package/README.md +18 -0
  2. package/dist/blender-mcp-Q6PSFYSE.js +241 -0
  3. package/dist/blender-serve-BF4FZ55Z.js +244 -0
  4. package/dist/chunk-2COG4P3T.js +968 -0
  5. package/dist/chunk-HYCSNWYX.js +126 -0
  6. package/dist/index.js +4759 -2830
  7. package/package.json +3 -3
  8. package/templates/blender-service/demo/castle.py +117 -0
  9. package/templates/blender-service/gpu_witness.py +245 -0
  10. package/templates/blender-service/ops.py +225 -0
  11. package/templates/blender-service/pool.py +910 -0
  12. package/templates/blender-service/server.py +611 -0
  13. package/templates/blender-service/supervisor.py +221 -0
  14. package/templates/blender-service/views.py +281 -0
  15. package/templates/controllers/character/follow-camera.ts +16 -1
  16. package/templates/controllers/character/meshy/meshy-loader.ts +3 -2
  17. package/templates/controllers/quality/deadline.ts +117 -0
  18. package/templates/controllers/quality/pick-asset.ts +49 -16
  19. package/templates/controllers/shared/physics-world.ts +6 -4
  20. package/templates/skills/genex-ai-character/SKILL.md +77 -15
  21. package/templates/skills/genex-ai-menu/SKILL.md +15 -11
  22. package/templates/skills/genex-ai-model/SKILL.md +45 -7
  23. package/templates/skills/genex-ai-texture/SKILL.md +1 -1
  24. package/templates/skills/genex-ai-video/SKILL.md +75 -17
  25. package/templates/skills/genex-blender-scene/SKILL.md +243 -0
  26. package/templates/skills/genex-game-director/SKILL.md +112 -46
  27. package/templates/skills/genex-game-director/references/design-contract.md +13 -5
  28. package/templates/skills/genex-game-director/references/routing-map.md +30 -45
  29. package/templates/skills/genex-getting-started/SKILL.md +2 -2
  30. package/templates/skills/genex-lane-card/SKILL.md +78 -0
  31. package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +21 -0
  32. package/templates/skills/genex-threejs-character-controller/SKILL.md +17 -5
  33. package/templates/skills/genex-threejs-creatures/SKILL.md +8 -1
  34. package/templates/skills/genex-threejs-embed-auth/SKILL.md +8 -0
  35. package/templates/skills/genex-threejs-game-ui/SKILL.md +55 -6
  36. package/templates/skills/genex-threejs-procedural-assets/SKILL.md +17 -10
  37. package/templates/skills/genex-threejs-visual-validation/SKILL.md +12 -2
  38. package/templates/skills/genex-tool-audio/SKILL.md +3 -2
  39. package/templates/skills/genex-tool-character/SKILL.md +36 -5
  40. package/templates/skills/genex-tool-image/SKILL.md +4 -2
  41. package/templates/skills/genex-tool-model/SKILL.md +32 -6
  42. package/templates/skills/genex-tool-publish/SKILL.md +100 -0
  43. package/templates/skills/genex-tool-texture/SKILL.md +1 -1
  44. package/templates/skills/genex-tool-video/SKILL.md +28 -5
  45. package/templates/skills/genex-tool-workflow/SKILL.md +4 -1
  46. package/templates/skills/genex-updates/SKILL.md +1 -1
@@ -0,0 +1,221 @@
1
+ """PID 1. Spawns Blender, holds the clock, restarts it when a request overruns.
2
+
3
+ WHY THIS EXISTS: bpy cannot be interrupted from inside its own process. It is
4
+ not thread-safe, and a watchdog thread that touches bpy produces crashes in
5
+ unrelated drawing code, minutes later, that get blamed on the GPU driver. So a
6
+ runaway `bpy.ops.mesh.subdivide` on a 100M-poly mesh can only be stopped by
7
+ killing the process -- which means the clock has to live outside it.
8
+
9
+ The deadline is a FILE the server writes per request, not a heartbeat. A
10
+ heartbeat cannot tell a wedged process from a legitimate 15-minute bake; a
11
+ per-route deadline can, because the route names its own budget.
12
+
13
+ Runs under Blender's bundled Python (there is no system python3 in the image),
14
+ so: stdlib only, and no bpy -- this process must never import it.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ import time
23
+
24
+ RUN_DIR = os.environ.get("GENEX_BLENDER_RUN_DIR", "/run/genex")
25
+ DEADLINE = os.path.join(RUN_DIR, "deadline")
26
+ RESTART_REASON = os.path.join(RUN_DIR, "restart-reason")
27
+ BLENDER = os.environ.get("GENEX_BLENDER_BIN", "blender")
28
+ SERVER = os.environ.get("GENEX_BLENDER_SERVER", "/app/server.py")
29
+ POLL_S = 1.0
30
+ # Blender has a documented history of hanging on exit in background mode, so a
31
+ # TERM that is ignored must not become a wedged supervisor.
32
+ TERM_GRACE_S = 5.0
33
+ # A crash loop should be loud and finite, not an infinite restart that burns a
34
+ # GPU pod for hours while every /health probe fails.
35
+ MAX_CONSECUTIVE_FAILURES = 5
36
+ FAST_FAIL_S = 20.0
37
+
38
+
39
+ def log(msg):
40
+ sys.stderr.write("[sup] %s\n" % msg)
41
+ sys.stderr.flush()
42
+
43
+
44
+ # Set by the /destroy path via the sentinel file below, and ONLY there.
45
+ #
46
+ # WHY THIS EXISTS: exit code 0 was being read as "the operator asked to stop".
47
+ # But `/exec` runs arbitrary Python, and `sys.exit()` inside a script exits
48
+ # Blender with code 0 too -- so one session could shut the whole service down
49
+ # by accident, and the supervisor would report success. Intent has to be
50
+ # recorded by the thing that has it, not inferred from a number anyone can
51
+ # produce. With seats this stops being a curiosity and becomes one tenant
52
+ # terminating everyone else's work.
53
+ DESTROY_SENTINEL = os.path.join(RUN_DIR, "destroy-requested")
54
+
55
+
56
+ def destroy_requested():
57
+ return os.path.exists(DESTROY_SENTINEL)
58
+
59
+
60
+ def clear_destroy_request():
61
+ try:
62
+ os.unlink(DESTROY_SENTINEL)
63
+ except FileNotFoundError:
64
+ pass
65
+
66
+
67
+ def spawn():
68
+ os.makedirs(RUN_DIR, exist_ok=True)
69
+ argv = [
70
+ BLENDER,
71
+ "--background",
72
+ "--factory-startup",
73
+ "-noaudio",
74
+ # Measured: without this a raising --python script exits 0 and the
75
+ # container looks healthy. See the Dockerfile comment.
76
+ "--python-exit-code",
77
+ "1",
78
+ "--python",
79
+ SERVER,
80
+ ]
81
+ extra = os.environ.get("GENEX_BLENDER_ARGS", "").split()
82
+ if extra:
83
+ # e.g. --gpu-backend vulkan, kept as one variable so the backend is a
84
+ # config experiment rather than an image rebuild.
85
+ argv[1:1] = extra
86
+ log("spawn: %s" % " ".join(argv))
87
+ return subprocess.Popen(argv)
88
+
89
+
90
+ def clear_deadline():
91
+ try:
92
+ os.unlink(DEADLINE)
93
+ except FileNotFoundError:
94
+ pass
95
+
96
+
97
+ def overrun():
98
+ """The route whose deadline has passed, or None. Never raises: a partially
99
+ written file is a race with the server, not a reason to kill Blender."""
100
+ try:
101
+ with open(DEADLINE) as f:
102
+ d = json.load(f)
103
+ except (FileNotFoundError, ValueError, OSError):
104
+ return None
105
+ try:
106
+ if time.time() > float(d["deadline"]):
107
+ return d
108
+ except (KeyError, TypeError, ValueError):
109
+ return None
110
+ return None
111
+
112
+
113
+ def stop(proc, why):
114
+ log("killing Blender: %s" % why)
115
+ try:
116
+ proc.terminate()
117
+ except OSError:
118
+ pass
119
+ deadline = time.time() + TERM_GRACE_S
120
+ while time.time() < deadline:
121
+ if proc.poll() is not None:
122
+ return
123
+ time.sleep(0.1)
124
+ log("SIGTERM ignored, sending SIGKILL")
125
+ try:
126
+ proc.kill()
127
+ except OSError:
128
+ pass
129
+ proc.wait()
130
+
131
+
132
+ def main():
133
+ os.makedirs(RUN_DIR, exist_ok=True)
134
+ clear_deadline()
135
+ clear_destroy_request()
136
+ failures = 0
137
+ stopping = {"v": False}
138
+
139
+ def forward(signum, _frame):
140
+ # Container stop: pass it on and do not restart.
141
+ stopping["v"] = True
142
+ log("got signal %s, shutting down" % signum)
143
+
144
+ signal.signal(signal.SIGTERM, forward)
145
+ signal.signal(signal.SIGINT, forward)
146
+
147
+ while not stopping["v"]:
148
+ started = time.time()
149
+ proc = spawn()
150
+ killed_for = None
151
+
152
+ while True:
153
+ if stopping["v"]:
154
+ stop(proc, "supervisor shutting down")
155
+ return 0
156
+ code = proc.poll()
157
+ if code is not None:
158
+ break
159
+ hit = overrun()
160
+ if hit:
161
+ killed_for = hit
162
+ stop(proc, "%s exceeded its %ss budget" % (hit.get("route"), hit.get("budget")))
163
+ clear_deadline()
164
+ break
165
+ time.sleep(POLL_S)
166
+
167
+ ran_for = time.time() - started
168
+ code = proc.poll()
169
+
170
+ if killed_for is not None:
171
+ # Tell the next boot that the scene it restores is a recovery, so
172
+ # the agent is TOLD a step may be missing. A silent restore reads as
173
+ # the model forgetting what it was just asked to do.
174
+ with open(RESTART_REASON, "w") as f:
175
+ json.dump({"route": killed_for.get("route"), "at": time.time(),
176
+ "reason": "deadline_exceeded"}, f)
177
+ # A killed RUNAWAY REQUEST is not a crash loop -- the next boot
178
+ # restores and carries on. A killed BOOT is: it means the process
179
+ # cannot come up at all (a hung GPU probe is the measured case), and
180
+ # without counting it the supervisor would respawn forever on a pod
181
+ # that bills at full rate and never serves.
182
+ if killed_for.get("route") == "/boot":
183
+ failures += 1
184
+ log("boot exceeded its budget (%d consecutive)" % failures)
185
+ if failures >= MAX_CONSECUTIVE_FAILURES:
186
+ log("FATAL: boot never completed — giving up")
187
+ return 1
188
+ else:
189
+ failures = 0
190
+ elif code == 0 and destroy_requested():
191
+ log("Blender exited 0 after a routed /destroy — done")
192
+ clear_destroy_request()
193
+ return 0
194
+ elif code == 0:
195
+ # Exit 0 WITHOUT the sentinel: a script called sys.exit(). That is a
196
+ # crashed session, not a shutdown request -- restart it.
197
+ failures = failures + 1 if ran_for < FAST_FAIL_S else 1
198
+ log("Blender exited 0 with no destroy request (a script called "
199
+ "sys.exit?) after %.1fs — restarting (%d)" % (ran_for, failures))
200
+ with open(RESTART_REASON, "w") as f:
201
+ json.dump({"at": time.time(), "reason": "unexpected_clean_exit"}, f)
202
+ if failures >= MAX_CONSECUTIVE_FAILURES:
203
+ log("FATAL: %d consecutive fast exits — giving up" % failures)
204
+ return 1
205
+ else:
206
+ failures = failures + 1 if ran_for < FAST_FAIL_S else 1
207
+ with open(RESTART_REASON, "w") as f:
208
+ json.dump({"at": time.time(), "reason": "crashed", "exitCode": code}, f)
209
+ log("Blender exited %s after %.1fs (consecutive fast failures: %d)"
210
+ % (code, ran_for, failures))
211
+ if failures >= MAX_CONSECUTIVE_FAILURES:
212
+ # Exit loudly rather than restarting forever: on a GPU pod an
213
+ # infinite loop here bills at full rate while nothing works.
214
+ log("FATAL: %d consecutive fast failures — giving up" % failures)
215
+ return 1
216
+ time.sleep(0.5)
217
+ return 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ sys.exit(main())
@@ -0,0 +1,281 @@
1
+ """Deterministic verification renders.
2
+
3
+ The contact sheet is the whole point of this service: every /exec answers with a
4
+ picture, from FIXED cameras, so an agent never has to remember to look and two
5
+ steps are comparable pixel-for-pixel. Cameras are derived from scene bounds
6
+ only -- never from whatever camera a script happened to leave behind -- because
7
+ a moving camera makes before/after diffs meaningless.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import tempfile
13
+
14
+ import bpy
15
+ import numpy as np
16
+ from mathutils import Vector
17
+
18
+ # One constant, shared with the witness: the engine was renamed in 4.2 and
19
+ # renamed BACK in 5.0, so a literal here would rot on the next LTS.
20
+ try:
21
+ from gpu_witness import EEVEE_ENGINE
22
+ except Exception: # noqa: BLE001 - views.py must work without the GPU module
23
+ EEVEE_ENGINE = "BLENDER_EEVEE_NEXT"
24
+
25
+ TILE = 512
26
+
27
+ # MEASURED 2026-09-01 on the real 46-object castle sheet, 1024x1024:
28
+ #
29
+ # PNG WebP q80 JPEG q80
30
+ # solid 451,498 16,542 (27.3x) 40,780 (11.1x)
31
+ # lit 995,523 12,630 (78.8x) 37,525 (26.5x)
32
+ # normals 470,629 22,044 (21.3x) 44,835 (10.5x)
33
+ #
34
+ # WebP q80 wins on every mode. JPEG is REJECTED and the numbers say why: on
35
+ # synthetic flat-region content it came out LARGER than PNG (35,102 vs 23,240),
36
+ # because flat regions are what PNG is good at and what JPEG adds noise to.
37
+ #
38
+ # `normals` was checked by eye at q80 -- a matcap colour boundary is exactly
39
+ # what chroma subsampling softens -- and facing directions stay legible, so
40
+ # there is no per-mode format rule.
41
+ SHEET_FORMATS = {"webp": ("WEBP", "image/webp"), "png": ("PNG", "image/png")}
42
+ # MEASURED 2026-09-02, same scene mutated per call so the repeat guard never
43
+ # fired: webp 22,067 B / 3.03 s wall / 1,600 ms server; png 1,437,529 B /
44
+ # 28.02 s wall / 1,367 ms server. The whole 25 s saving is transport -- server
45
+ # time moved by -0.23 s -- and note the sign of that: WebP costs MORE server CPU
46
+ # than PNG. The win is bytes, and only bytes. That run was laptop-driven over a
47
+ # ~54 KB/s link; an API server in a datacenter sees a far smaller absolute
48
+ # saving, which is why the repeat guard beside this, not the codec, is the
49
+ # load-bearing half for the hosted lane.
50
+ SHEET_QUALITY = 80
51
+ # Order is display order, reading left-to-right, top-to-bottom.
52
+ VIEWS = (
53
+ ("front", Vector((0.0, -1.0, 0.0))),
54
+ ("side", Vector((1.0, 0.0, 0.0))),
55
+ ("top", Vector((0.0, 0.0, 1.0))),
56
+ ("hero", Vector((1.0, -1.0, 0.7))),
57
+ )
58
+ _FOV = 0.7 # radians, ~40 deg
59
+ _MARGIN = 1.18
60
+
61
+
62
+ def scene_bounds():
63
+ """World-space (center, radius) over every evaluated mesh. Never raises."""
64
+ lo = Vector((1e18, 1e18, 1e18))
65
+ hi = Vector((-1e18, -1e18, -1e18))
66
+ found = False
67
+ for obj in bpy.context.scene.objects:
68
+ if obj.type not in {"MESH", "CURVE", "SURFACE", "META", "FONT"}:
69
+ continue
70
+ # RENDER visibility, not viewport: the sheet is a render, and a hidden
71
+ # object still framed the shot (review finding).
72
+ if obj.hide_render or not obj.visible_get():
73
+ continue
74
+ for corner in obj.bound_box:
75
+ p = obj.matrix_world @ Vector(corner)
76
+ lo = Vector((min(lo[i], p[i]) for i in range(3)))
77
+ hi = Vector((max(hi[i], p[i]) for i in range(3)))
78
+ found = True
79
+ if not found:
80
+ return Vector((0.0, 0.0, 0.0)), 1.0
81
+ center = (lo + hi) * 0.5
82
+ radius = max((hi - lo).length * 0.5, 1e-3)
83
+ return center, radius
84
+
85
+
86
+ class _RenderState:
87
+ """Save/restore what we touch, so a script's own engine choice survives."""
88
+
89
+ _KEYS = (
90
+ "engine", "resolution_x", "resolution_y", "resolution_percentage", "filepath",
91
+ )
92
+
93
+ def __enter__(self):
94
+ r = bpy.context.scene.render
95
+ self._render = {k: getattr(r, k) for k in self._KEYS}
96
+ self._fmt = r.image_settings.file_format
97
+ self._camera = bpy.context.scene.camera
98
+ d = bpy.context.scene.display.shading
99
+ self._shading = (d.type, d.light, d.color_type, d.studio_light)
100
+ return self
101
+
102
+ def __exit__(self, *exc):
103
+ r = bpy.context.scene.render
104
+ for k, v in self._render.items():
105
+ setattr(r, k, v)
106
+ r.image_settings.file_format = self._fmt
107
+ bpy.context.scene.camera = self._camera
108
+ d = bpy.context.scene.display.shading
109
+ try:
110
+ d.type, d.light, d.color_type, d.studio_light = self._shading
111
+ except Exception:
112
+ pass # a studio_light can become invalid if the shading type moved
113
+ return False
114
+
115
+
116
+ def _ensure_preview_light():
117
+ """EEVEE on a scene with no lights renders BLACK, which reads as a broken
118
+ GPU rather than an unlit scene. Add a sun + a grey world only when the scene
119
+ has neither, and hand back an undo so the caller's scene is unchanged."""
120
+ scene = bpy.context.scene
121
+ added = []
122
+ if not any(o.type == "LIGHT" for o in scene.objects):
123
+ data = bpy.data.lights.new("__genex_preview_sun", type="SUN")
124
+ data.energy = 3.0
125
+ obj = bpy.data.objects.new("__genex_preview_sun", data)
126
+ obj.rotation_euler = (0.9, 0.0, 0.8)
127
+ scene.collection.objects.link(obj)
128
+ added.append((obj, data))
129
+
130
+ world = scene.world
131
+ made_world = False
132
+ if world is None:
133
+ world = bpy.data.worlds.new("__genex_preview_world")
134
+ scene.world = world
135
+ made_world = True
136
+ world.use_nodes = True
137
+ world.node_tree.nodes["Background"].inputs[0].default_value = (0.05, 0.05, 0.06, 1)
138
+
139
+ def undo():
140
+ for obj, data in added:
141
+ bpy.data.objects.remove(obj, do_unlink=True)
142
+ bpy.data.lights.remove(data)
143
+ if made_world:
144
+ scene.world = None
145
+ bpy.data.worlds.remove(world)
146
+
147
+ return undo
148
+
149
+
150
+ def _apply_shading(mode):
151
+ d = bpy.context.scene.display.shading
152
+ if mode == "wireframe":
153
+ d.type = "WIREFRAME"
154
+ d.color_type = "SINGLE"
155
+ elif mode == "normals":
156
+ # A matcap that paints +Y green and inverted faces magenta-ish: the
157
+ # cheapest way to SEE flipped normals, which survive every numeric check.
158
+ d.type = "SOLID"
159
+ d.light = "MATCAP"
160
+ try:
161
+ d.studio_light = "check_normal+y.exr"
162
+ except Exception:
163
+ d.light = "STUDIO"
164
+ else:
165
+ d.type = "SOLID"
166
+ d.light = "STUDIO"
167
+ d.color_type = "MATERIAL"
168
+
169
+
170
+ def _render_view(cam, direction, center, radius, out_path):
171
+ dist = radius / max(np.sin(_FOV * 0.5), 1e-6) * _MARGIN
172
+ cam.location = center + direction.normalized() * dist
173
+ cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler()
174
+ cam.data.angle = _FOV
175
+ cam.data.clip_start = max(dist * 1e-4, 1e-4)
176
+ cam.data.clip_end = dist * 10.0
177
+ bpy.context.scene.render.filepath = out_path
178
+ bpy.ops.render.render(write_still=True)
179
+
180
+
181
+ def _load_tile(path):
182
+ img = bpy.data.images.load(path)
183
+ try:
184
+ buf = np.empty(len(img.pixels), dtype=np.float32)
185
+ img.pixels.foreach_get(buf)
186
+ return buf.reshape(img.size[1], img.size[0], 4)
187
+ finally:
188
+ bpy.data.images.remove(img)
189
+
190
+
191
+ def _save_sheet(img, out_path, fmt):
192
+ """Write the composited sheet. Returns the mime type.
193
+
194
+ `quality=` is passed OPTIONALLY rather than gated on a Blender version: the
195
+ local lane accepts 4.2+ and we can only test what is installed, so a
196
+ TypeError falls back to the format's default rather than raising in a user's
197
+ face. A version check would have to be maintained against builds nobody here
198
+ can run.
199
+ """
200
+ blender_fmt, mime = SHEET_FORMATS[fmt]
201
+ img.filepath_raw = out_path
202
+ img.file_format = blender_fmt
203
+ if fmt == "png":
204
+ img.save()
205
+ return mime
206
+ try:
207
+ img.save(quality=SHEET_QUALITY)
208
+ except TypeError:
209
+ sys.stderr.write("[views] Image.save(quality=) unsupported; using the "
210
+ "format default\n")
211
+ img.save()
212
+ return mime
213
+
214
+
215
+ def contact_sheet(out_path, mode="solid", fmt="png"):
216
+ """Render the 4 fixed views into one 1024x1024 sheet at out_path.
217
+
218
+ Returns the MIME TYPE written, not the path — the caller already has the
219
+ path, and the format is negotiated so it cannot assume one.
220
+
221
+ Uses BLENDER_WORKBENCH via render.render(), NOT render.opengl -- the latter
222
+ is guarded on G.background and cannot run headless. Workbench needs no GL
223
+ stack, no llvmpipe and no Xvfb; it is the one preview engine that does.
224
+ """
225
+ center, radius = scene_bounds()
226
+ undo_light = None
227
+ mime = SHEET_FORMATS[fmt][1]
228
+ with _RenderState():
229
+ scene = bpy.context.scene
230
+ if mode == "lit":
231
+ # The GPU tier's whole point. EEVEE needs a real GPU (Blender's gate
232
+ # is < 12 SSBO blocks; a software rasterizer either fails or, worse,
233
+ # passes on a modern Mesa and renders this on the CPU). The GPU
234
+ # image asserts at boot, so reaching here means a GPU is present.
235
+ scene.render.engine = EEVEE_ENGINE
236
+ undo_light = _ensure_preview_light()
237
+ else:
238
+ scene.render.engine = "BLENDER_WORKBENCH"
239
+ scene.render.resolution_x = TILE
240
+ scene.render.resolution_y = TILE
241
+ scene.render.resolution_percentage = 100
242
+ scene.render.image_settings.file_format = "PNG"
243
+ if mode != "lit":
244
+ _apply_shading(mode)
245
+
246
+ cam_data = bpy.data.cameras.new("__genex_verify_cam")
247
+ cam = bpy.data.objects.new("__genex_verify_cam", cam_data)
248
+ scene.collection.objects.link(cam)
249
+ scene.camera = cam
250
+
251
+ tmp = tempfile.mkdtemp(prefix="genex-sheet-")
252
+ try:
253
+ # Blender image buffers are BOTTOM-UP, so display row 0 (the top of
254
+ # the sheet) is the HIGHEST band in the array. Getting this backwards
255
+ # silently produces a vertically mirrored sheet that still "looks
256
+ # fine" until you compare it against the game.
257
+ sheet = np.zeros((TILE * 2, TILE * 2, 4), dtype=np.float32)
258
+ sheet[..., 3] = 1.0
259
+ for i, (name, direction) in enumerate(VIEWS):
260
+ p = os.path.join(tmp, f"{i}_{name}.png")
261
+ _render_view(cam, direction, center, radius, p)
262
+ tile = _load_tile(p)
263
+ disp_row, col = divmod(i, 2)
264
+ r0 = (1 - disp_row) * TILE
265
+ sheet[r0:r0 + TILE, col * TILE:(col + 1) * TILE] = tile
266
+
267
+ out = bpy.data.images.new("__genex_sheet", TILE * 2, TILE * 2, alpha=True)
268
+ try:
269
+ out.pixels.foreach_set(sheet.ravel())
270
+ mime = _save_sheet(out, out_path, fmt)
271
+ finally:
272
+ bpy.data.images.remove(out)
273
+ finally:
274
+ bpy.data.objects.remove(cam, do_unlink=True)
275
+ bpy.data.cameras.remove(cam_data)
276
+ if undo_light:
277
+ undo_light()
278
+ for f in os.listdir(tmp):
279
+ os.unlink(os.path.join(tmp, f))
280
+ os.rmdir(tmp)
281
+ return mime
@@ -376,7 +376,22 @@ export class FollowCamera {
376
376
  if (this._aimState === "unlocked" && e.pointerType === "mouse" && e.button === 0) {
377
377
  this._requestLock();
378
378
  }
379
- this._domElement.setPointerCapture(e.pointerId);
379
+ // Capture may be REFUSED, and a refusal must not throw out of the
380
+ // handler. MEASURED 2026-09-04 across five graded games (`Uncaught
381
+ // InvalidStateError: Failed to execute 'setPointerCapture'`): the click
382
+ // above requests pointer lock, and once the lock lands Chromium retires
383
+ // the pointer, so the capture call on that same pointerdown throws — an
384
+ // uncaught exception on the first click of every game with this camera,
385
+ // which failed the eval prober's no-errors check while the drag path
386
+ // beneath it still worked. Same guard the touch kit uses (drag-zone.ts).
387
+ if (typeof this._domElement.setPointerCapture === "function") {
388
+ try {
389
+ this._domElement.setPointerCapture(e.pointerId);
390
+ } catch {
391
+ // the pointer is already gone (pointer lock took it, or it was released
392
+ // between the event and the call) — the drag works without capture
393
+ }
394
+ }
380
395
  this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
381
396
  if (this._pointers.size === 1) {
382
397
  this._orbiting = true;
@@ -23,7 +23,8 @@ export interface MeshyCharacterManifest {
23
23
  characterId: string;
24
24
  revision: number;
25
25
  manifestVersion: number;
26
- rig: "meshy-biped";
26
+ /** `meshy-biped`, or `uthana-biped` for a body imported with `genex character import` (same manifest shape). */
27
+ rig: "meshy-biped" | "uthana-biped";
27
28
  controllerPack?: {
28
29
  key: string;
29
30
  version: number;
@@ -95,7 +96,7 @@ function validateManifest(value: unknown): MeshyCharacterManifest {
95
96
  if (
96
97
  !manifest ||
97
98
  manifest.schema !== 1 ||
98
- manifest.rig !== "meshy-biped" ||
99
+ (manifest.rig !== "meshy-biped" && manifest.rig !== "uthana-biped") ||
99
100
  !manifest.model?.url ||
100
101
  !manifest.model.skeletonSignature ||
101
102
  !Array.isArray(manifest.clips) ||
@@ -0,0 +1,117 @@
1
+ // Genex adaptive-quality: BOUNDED loading.
2
+ //
3
+ // WHY THIS EXISTS. Before 2026-09-07 there was no timeout anywhere in the
4
+ // vendored kits — `grep -rn "setTimeout|AbortController|AbortSignal|timeout"`
5
+ // across quality/ and character/ returned NOTHING. Every fallback in
6
+ // `pick-asset.ts` lives inside a `catch`, so it advances on a REJECTION and
7
+ // never on silence: a rung that hangs rather than 404s parks the boot forever,
8
+ // and the player watches a black canvas with no error in the console. The one
9
+ // local Opus run that shipped had to hand-write its own `withDeadline` in
10
+ // `src/main.ts` to get around exactly this, which is the clearest possible
11
+ // signal that the kit owed it.
12
+ //
13
+ // WHAT THIS BOUNDS, HONESTLY. `withDeadline` bounds the WAIT, not the fetch.
14
+ // The abandoned request keeps running in the background until the browser gives
15
+ // up on it; what changes is that the fallback chain ADVANCES instead of
16
+ // stalling, which is the failure being fixed. Real cancellation needs the
17
+ // caller to own the request (`fetchArrayBufferWithDeadline` below is that door,
18
+ // for callers that can hand bytes to `loader.parse`).
19
+ //
20
+ // Zero dependencies and browser-only APIs, because this ships inside a player's
21
+ // game bundle.
22
+
23
+ /** Thrown when a load outlives its deadline. Distinct so a caller can tell a
24
+ * timeout from a genuine 404 — they mean different things about the asset. */
25
+ export class DeadlineError extends Error {
26
+ readonly label: string;
27
+ readonly ms: number;
28
+ constructor(label: string, ms: number) {
29
+ super(`[genex-quality] ${label} exceeded ${ms}ms`);
30
+ this.name = "DeadlineError";
31
+ this.label = label;
32
+ this.ms = ms;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolve `work`, or reject with `DeadlineError` after `ms`.
38
+ *
39
+ * The timer is always cleared, including on the success path: a pending timer
40
+ * keeps a closure over the promise alive, and twenty of them per boot is a leak
41
+ * a long session notices.
42
+ */
43
+ export function withDeadline<T>(work: Promise<T>, ms: number, label: string): Promise<T> {
44
+ if (!(ms > 0) || !Number.isFinite(ms)) return work;
45
+ let timer: ReturnType<typeof setTimeout> | undefined;
46
+ const bell = new Promise<never>((_resolve, reject) => {
47
+ timer = setTimeout(() => reject(new DeadlineError(label, ms)), ms);
48
+ });
49
+ return Promise.race([work, bell]).finally(() => {
50
+ if (timer !== undefined) clearTimeout(timer);
51
+ }) as Promise<T>;
52
+ }
53
+
54
+ /**
55
+ * A budget shared across a boot, so N slow assets cannot SERIALISE into a wait
56
+ * no per-attempt deadline would ever catch.
57
+ *
58
+ * A per-attempt deadline alone is not enough and the arithmetic is the reason:
59
+ * twenty props at a 15 s rung deadline is five minutes of black screen, with
60
+ * every individual attempt inside its limit. `remaining()` is what makes the
61
+ * ceiling the BOOT's rather than each asset's.
62
+ *
63
+ * Never returns a negative, and `expired()` is the honest question to ask
64
+ * before starting more optional work.
65
+ */
66
+ export function createBootBudget(totalMs: number, now: () => number = () => Date.now()) {
67
+ const startedAt = now();
68
+ return {
69
+ remaining(): number {
70
+ return Math.max(0, totalMs - (now() - startedAt));
71
+ },
72
+ expired(): boolean {
73
+ return now() - startedAt >= totalMs;
74
+ },
75
+ /** The deadline to give the next attempt: its own cap, clipped to what is
76
+ * left of the boot. */
77
+ slice(attemptMs: number): number {
78
+ return Math.min(attemptMs, Math.max(0, totalMs - (now() - startedAt)));
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * REAL cancellation, for a caller that can parse bytes itself:
85
+ *
86
+ * const buf = await fetchArrayBufferWithDeadline(url, 15000);
87
+ * const gltf = await loader.parseAsync(buf, "");
88
+ *
89
+ * Safe for OUR generated rungs specifically, because they embed their textures
90
+ * — a GLB with external resources would lose its base URL this way, which is
91
+ * why the loaders do NOT use this by default.
92
+ */
93
+ export async function fetchArrayBufferWithDeadline(
94
+ url: string,
95
+ ms: number,
96
+ label = url,
97
+ ): Promise<ArrayBuffer> {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), ms);
100
+ try {
101
+ const res = await fetch(url, { signal: controller.signal });
102
+ if (!res.ok) throw new Error(`[genex-quality] ${label} → HTTP ${res.status}`);
103
+ return await res.arrayBuffer();
104
+ } catch (err) {
105
+ // An abort is a deadline, and the caller must be able to tell the two apart.
106
+ if (err instanceof Error && err.name === "AbortError") throw new DeadlineError(label, ms);
107
+ throw err;
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+
113
+ /** Per-attempt defaults. A rung is small by construction; the original is the
114
+ * archival asset and is allowed to be slower, because reaching it at all means
115
+ * every rung already failed. */
116
+ export const RUNG_DEADLINE_MS = 15_000;
117
+ export const ORIGINAL_DEADLINE_MS = 30_000;