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