@genex-ai/cli-demo 1.31.2 → 1.32.0-dev.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/blender-mcp-Q6PSFYSE.js +241 -0
- package/dist/blender-serve-BF4FZ55Z.js +244 -0
- package/dist/chunk-2COG4P3T.js +968 -0
- package/dist/chunk-HYCSNWYX.js +126 -0
- package/dist/index.js +4759 -2830
- package/package.json +3 -3
- package/templates/blender-service/demo/castle.py +117 -0
- package/templates/blender-service/gpu_witness.py +245 -0
- package/templates/blender-service/ops.py +225 -0
- package/templates/blender-service/pool.py +910 -0
- package/templates/blender-service/server.py +611 -0
- package/templates/blender-service/supervisor.py +221 -0
- package/templates/blender-service/views.py +281 -0
- package/templates/controllers/character/follow-camera.ts +16 -1
- package/templates/controllers/character/meshy/meshy-loader.ts +3 -2
- package/templates/controllers/quality/deadline.ts +117 -0
- package/templates/controllers/quality/pick-asset.ts +49 -16
- package/templates/controllers/shared/physics-world.ts +6 -4
- package/templates/skills/genex-ai-character/SKILL.md +77 -15
- package/templates/skills/genex-ai-menu/SKILL.md +15 -11
- package/templates/skills/genex-ai-model/SKILL.md +45 -7
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +75 -17
- package/templates/skills/genex-blender-scene/SKILL.md +243 -0
- package/templates/skills/genex-game-director/SKILL.md +112 -46
- package/templates/skills/genex-game-director/references/design-contract.md +13 -5
- package/templates/skills/genex-game-director/references/routing-map.md +30 -45
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-lane-card/SKILL.md +78 -0
- package/templates/skills/genex-monetization/SKILL.md +11 -17
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +21 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +17 -5
- package/templates/skills/genex-threejs-creatures/SKILL.md +8 -1
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +12 -8
- package/templates/skills/genex-threejs-game-ui/SKILL.md +55 -6
- package/templates/skills/genex-threejs-procedural-assets/SKILL.md +17 -10
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +12 -2
- package/templates/skills/genex-tool-audio/SKILL.md +3 -2
- package/templates/skills/genex-tool-character/SKILL.md +36 -5
- package/templates/skills/genex-tool-image/SKILL.md +4 -2
- package/templates/skills/genex-tool-model/SKILL.md +32 -6
- package/templates/skills/genex-tool-publish/SKILL.md +100 -0
- package/templates/skills/genex-tool-texture/SKILL.md +1 -1
- package/templates/skills/genex-tool-video/SKILL.md +28 -5
- package/templates/skills/genex-tool-workflow/SKILL.md +4 -1
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0-dev.653",
|
|
4
4
|
"description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"pngjs": "^7.0.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@dimforge/rapier3d-compat": "^0.
|
|
43
|
+
"@dimforge/rapier3d-compat": "^0.20.0",
|
|
44
|
+
"@genex-ai/multiplayer": "workspace:*",
|
|
44
45
|
"@genex/meshy-animation-catalog": "workspace:*",
|
|
45
46
|
"@genex/mobile-scan": "workspace:*",
|
|
46
|
-
"@genex-ai/multiplayer": "workspace:*",
|
|
47
47
|
"@pixiv/three-vrm": "^3.5.4",
|
|
48
48
|
"@types/pngjs": "^6.0.5",
|
|
49
49
|
"@types/three": "^0.185.0",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The M0/M1 acceptance artifact: a castle room built from primitives.
|
|
2
|
+
|
|
3
|
+
Deliberately NOT a torture test of Blender -- it is a torture test of the LOOP.
|
|
4
|
+
It exercises the things a real composition step needs (booleans, arrays,
|
|
5
|
+
materials, instancing, transforms) and produces something a human can judge in
|
|
6
|
+
one glance: either the contact sheet shows a castle room, or it does not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import bpy
|
|
12
|
+
|
|
13
|
+
ROOM = 12.0
|
|
14
|
+
WALL_H = 5.0
|
|
15
|
+
WALL_T = 0.6
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _clear():
|
|
19
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mat(name, rgb, rough=0.85):
|
|
23
|
+
m = bpy.data.materials.new(name)
|
|
24
|
+
m.use_nodes = True
|
|
25
|
+
bsdf = m.node_tree.nodes["Principled BSDF"]
|
|
26
|
+
bsdf.inputs["Base Color"].default_value = (*rgb, 1.0)
|
|
27
|
+
# 'Roughness' is stable across 4.x; 'Specular' was RENAMED in 4.0 and is the
|
|
28
|
+
# single most common KeyError in LLM-written bpy. Do not reach for it.
|
|
29
|
+
bsdf.inputs["Roughness"].default_value = rough
|
|
30
|
+
# BOTH, and the second one is the trap: Workbench's MATERIAL colour mode --
|
|
31
|
+
# what every contact sheet renders -- reads `diffuse_color`, NOT the
|
|
32
|
+
# Principled BSDF. Setting only the BSDF gives a scene that exports with
|
|
33
|
+
# correct colours, reports materialCount>0, and renders ENTIRELY GREY.
|
|
34
|
+
# Numbers cannot catch this; the picture can. Measured 2026-08-31.
|
|
35
|
+
m.diffuse_color = (*rgb, 1.0)
|
|
36
|
+
return m
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cube(name, loc, scale, mat):
|
|
40
|
+
bpy.ops.mesh.primitive_cube_add(size=2.0, location=loc)
|
|
41
|
+
o = bpy.context.active_object
|
|
42
|
+
o.name = name
|
|
43
|
+
o.scale = scale
|
|
44
|
+
o.data.materials.append(mat)
|
|
45
|
+
return o
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build():
|
|
49
|
+
_clear()
|
|
50
|
+
stone = _mat("Stone", (0.46, 0.44, 0.41))
|
|
51
|
+
dark = _mat("StoneDark", (0.26, 0.25, 0.24))
|
|
52
|
+
wood = _mat("Wood", (0.24, 0.14, 0.07))
|
|
53
|
+
roofm = _mat("Roof", (0.35, 0.12, 0.12), rough=0.6)
|
|
54
|
+
fire = _mat("Fire", (1.0, 0.45, 0.1), rough=0.4)
|
|
55
|
+
|
|
56
|
+
_cube("Floor", (0, 0, -0.25), (ROOM / 2, ROOM / 2, 0.25), dark)
|
|
57
|
+
|
|
58
|
+
half = ROOM / 2
|
|
59
|
+
# Three solid walls; the south wall is split to leave a doorway.
|
|
60
|
+
_cube("Wall_N", (0, half, WALL_H / 2), (half, WALL_T / 2, WALL_H / 2), stone)
|
|
61
|
+
_cube("Wall_E", (half, 0, WALL_H / 2), (WALL_T / 2, half, WALL_H / 2), stone)
|
|
62
|
+
_cube("Wall_W", (-half, 0, WALL_H / 2), (WALL_T / 2, half, WALL_H / 2), stone)
|
|
63
|
+
door = 2.2
|
|
64
|
+
side = (ROOM - door) / 4
|
|
65
|
+
for sgn in (-1, 1):
|
|
66
|
+
_cube(f"Wall_S{'L' if sgn < 0 else 'R'}",
|
|
67
|
+
(sgn * (door / 2 + side), -half, WALL_H / 2),
|
|
68
|
+
(side, WALL_T / 2, WALL_H / 2), stone)
|
|
69
|
+
_cube("Lintel", (0, -half, WALL_H - 0.5), (door / 2, WALL_T / 2, 0.5), stone)
|
|
70
|
+
|
|
71
|
+
# Crenellations: a real array, not four hand-placed boxes.
|
|
72
|
+
for i in range(9):
|
|
73
|
+
x = -half + 0.6 + i * (ROOM - 1.2) / 8
|
|
74
|
+
for y in (half, -half):
|
|
75
|
+
_cube(f"Cren_{i}_{'n' if y > 0 else 's'}",
|
|
76
|
+
(x, y, WALL_H + 0.35), (0.35, WALL_T / 2, 0.35), stone)
|
|
77
|
+
|
|
78
|
+
# Corner towers with conical roofs.
|
|
79
|
+
for i, (sx, sy) in enumerate(((1, 1), (1, -1), (-1, 1), (-1, -1))):
|
|
80
|
+
cx, cy = sx * half, sy * half
|
|
81
|
+
bpy.ops.mesh.primitive_cylinder_add(radius=1.5, depth=WALL_H + 2.0,
|
|
82
|
+
location=(cx, cy, (WALL_H + 2.0) / 2), vertices=16)
|
|
83
|
+
t = bpy.context.active_object
|
|
84
|
+
t.name = f"Tower_{i}"
|
|
85
|
+
t.data.materials.append(stone)
|
|
86
|
+
bpy.ops.mesh.primitive_cone_add(radius1=2.0, depth=2.4, vertices=16,
|
|
87
|
+
location=(cx, cy, WALL_H + 2.0 + 1.2))
|
|
88
|
+
r = bpy.context.active_object
|
|
89
|
+
r.name = f"TowerRoof_{i}"
|
|
90
|
+
r.data.materials.append(roofm)
|
|
91
|
+
|
|
92
|
+
# A table, so the room reads as inhabited and the sheet has a scale cue.
|
|
93
|
+
_cube("TableTop", (0, 1.0, 1.1), (2.2, 0.9, 0.12), wood)
|
|
94
|
+
for dx in (-1.9, 1.9):
|
|
95
|
+
for dy in (-0.65, 0.65):
|
|
96
|
+
_cube(f"Leg_{dx}_{dy}", (dx, 1.0 + dy, 0.55), (0.12, 0.12, 0.55), wood)
|
|
97
|
+
|
|
98
|
+
# Wall torches -- emissive-ish spheres; they also prove material assignment.
|
|
99
|
+
for i, (tx, ty) in enumerate(((-half + 0.6, 2.5), (-half + 0.6, -2.5),
|
|
100
|
+
(half - 0.6, 2.5), (half - 0.6, -2.5))):
|
|
101
|
+
_cube(f"Sconce_{i}", (tx, ty, 3.0), (0.15, 0.15, 0.4), wood)
|
|
102
|
+
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.32, location=(tx, ty, 3.5), segments=12, ring_count=8)
|
|
103
|
+
f = bpy.context.active_object
|
|
104
|
+
f.name = f"Flame_{i}"
|
|
105
|
+
f.data.materials.append(fire)
|
|
106
|
+
|
|
107
|
+
bpy.ops.object.select_all(action="DESELECT")
|
|
108
|
+
n = len(bpy.context.scene.objects)
|
|
109
|
+
tris = sum(
|
|
110
|
+
sum(max(len(p.vertices) - 2, 0) for p in o.data.polygons)
|
|
111
|
+
for o in bpy.context.scene.objects if o.type == "MESH"
|
|
112
|
+
)
|
|
113
|
+
print(f"castle: {n} objects, {tris} tris")
|
|
114
|
+
return n, tris
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
build()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Fail-closed GPU witness.
|
|
2
|
+
|
|
3
|
+
NAMED gpu_witness, NOT gpu: Blender ships a built-in `gpu` module, and a local
|
|
4
|
+
`gpu.py` is shadowed by it. Measured 2026-09-01 -- `import gpu` returned
|
|
5
|
+
Blender's module, `assert_gpu_or_die` did not exist on it, and the assertion
|
|
6
|
+
never ran. The container still failed closed, which hid the mistake.
|
|
7
|
+
|
|
8
|
+
There are FIVE independent ways to end up rendering on the CPU with a
|
|
9
|
+
correct-looking image, a 200 response and a zero exit code. This module exists
|
|
10
|
+
because any one of them alone would let the GPU tier pass every functional test
|
|
11
|
+
while delivering nothing but a bill:
|
|
12
|
+
|
|
13
|
+
1. EEVEE RENDERS ON THE CPU. Measured on the CPU image (Mesa 22.3.6): a full
|
|
14
|
+
EEVEE contact sheet with correct shadows, 47.5s vs 0.4s for Workbench. So
|
|
15
|
+
"EEVEE worked" is a PERFORMANCE signal, never a GPU signal. Dockerfile.gpu
|
|
16
|
+
installs no Mesa vendor at all, so there is nothing to fall back onto --
|
|
17
|
+
that absence is the backstop, not this module.
|
|
18
|
+
2. `prefs.devices` is EMPTY until refresh_devices() populates it, so the
|
|
19
|
+
obvious enabling loop iterates zero elements and reports success.
|
|
20
|
+
3. `scene.cycles.device` defaults to 'CPU' and is stored PER SCENE inside the
|
|
21
|
+
.blend, so perfect preferences still render on CPU.
|
|
22
|
+
4. Blanket `d.use = True` enables HYBRID CPU+GPU, which every other check
|
|
23
|
+
still calls "GPU".
|
|
24
|
+
5. An uncaught exception under --python exits 0. (Handled by
|
|
25
|
+
--python-exit-code 1 in the CMD; nothing here raises, on purpose.)
|
|
26
|
+
|
|
27
|
+
Nothing in this module raises. Every failure path calls sys.exit() explicitly.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
|
|
34
|
+
import bpy
|
|
35
|
+
|
|
36
|
+
# 4.2/4.5 renamed the engine; it is BLENDER_EEVEE again on 5.0+. One constant.
|
|
37
|
+
def _eevee_engine():
|
|
38
|
+
"""The EEVEE id for THIS Blender. Renamed to BLENDER_EEVEE_NEXT in 4.2 and
|
|
39
|
+
renamed BACK to BLENDER_EEVEE in 5.0, so a literal is wrong on one side of
|
|
40
|
+
that line — and setting an unknown engine id fails at render, far from the
|
|
41
|
+
cause. Resolved from the running build rather than assumed.
|
|
42
|
+
|
|
43
|
+
Mirrored by eeveeEngineFor() in apps/cli/src/lib/blender-local.ts, which
|
|
44
|
+
gates which Blenders the local lane will drive."""
|
|
45
|
+
try:
|
|
46
|
+
items = bpy.types.RenderSettings.bl_rna.properties["engine"].enum_items
|
|
47
|
+
names = {i.identifier for i in items}
|
|
48
|
+
except Exception: # noqa: BLE001
|
|
49
|
+
names = set()
|
|
50
|
+
for candidate in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"):
|
|
51
|
+
if candidate in names:
|
|
52
|
+
return candidate
|
|
53
|
+
return "BLENDER_EEVEE_NEXT" if bpy.app.version < (5, 0, 0) else "BLENDER_EEVEE"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
EEVEE_ENGINE = _eevee_engine()
|
|
57
|
+
|
|
58
|
+
# Preference order, not a hardcoded value. OptiX first because production is
|
|
59
|
+
# NVIDIA, but the vendor is DISCOVERED from what the build actually offers --
|
|
60
|
+
# which is what lets the identical service be exercised on an Apple-silicon Mac
|
|
61
|
+
# (METAL) or an AMD box (HIP) instead of only on the target hardware.
|
|
62
|
+
# GENEX_BLENDER_CYCLES_BACKEND pins one explicitly.
|
|
63
|
+
CYCLES_BACKEND_PREFERENCE = ("OPTIX", "CUDA", "METAL", "HIP", "ONEAPI")
|
|
64
|
+
SOFTWARE_DEVICES = ("SOFTWARE", "NONE", "UNKNOWN")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _force_context():
|
|
68
|
+
"""Render 4x4 with EEVEE. Returns True if it actually produced pixels.
|
|
69
|
+
|
|
70
|
+
ITS SUCCESS IS NOT EVIDENCE OF A GPU. MEASURED 2026-09-01 on the CPU image
|
|
71
|
+
(Debian bookworm, Mesa 22.3.6 llvmpipe): EEVEE renders correctly, with real
|
|
72
|
+
shadows, entirely on the CPU -- 47.5s for a 4-view sheet against 0.4s for
|
|
73
|
+
Workbench. An earlier version of this module treated a successful probe as
|
|
74
|
+
the strongest GPU signal, which would have PASSED on any GPU-less host that
|
|
75
|
+
has Mesa installed.
|
|
76
|
+
|
|
77
|
+
What it is actually for: forcing the lazily-created GPU context so
|
|
78
|
+
gpu.platform can be queried at all, and catching a broken graphics stack.
|
|
79
|
+
The real GPU discriminator is configure_cycles() -- enumerating an OptiX
|
|
80
|
+
device needs a real driver and cannot be faked by a software rasterizer."""
|
|
81
|
+
s = bpy.context.scene
|
|
82
|
+
r = s.render
|
|
83
|
+
ok = False
|
|
84
|
+
keep = (r.engine, r.resolution_x, r.resolution_y, r.filepath,
|
|
85
|
+
r.image_settings.file_format, r.resolution_percentage)
|
|
86
|
+
try:
|
|
87
|
+
r.engine = EEVEE_ENGINE
|
|
88
|
+
r.resolution_x = r.resolution_y = 4
|
|
89
|
+
r.resolution_percentage = 100
|
|
90
|
+
r.image_settings.file_format = "PNG"
|
|
91
|
+
# mkstemp, NOT a fixed path. This is a LIVE SINGLE-TENANT BUG, not
|
|
92
|
+
# only a multi-seat one: a leftover file from any earlier run makes the
|
|
93
|
+
# `getsize() > 0` check below true for a probe that rendered nothing, so
|
|
94
|
+
# a GPU-less box passes an assertion whose entire job is to fail there.
|
|
95
|
+
# With seats it is also a boot race between children.
|
|
96
|
+
fd, probe_path = tempfile.mkstemp(prefix="genex-gpu-probe-", suffix=".png")
|
|
97
|
+
os.close(fd)
|
|
98
|
+
os.unlink(probe_path) # Blender must create it, or it proves nothing.
|
|
99
|
+
r.filepath = probe_path
|
|
100
|
+
# A camera is required and a fresh scene has none, so the probe must
|
|
101
|
+
# bring its own or it fails for a reason that has nothing to do with
|
|
102
|
+
# the GPU. Measured: "Cannot render, no camera".
|
|
103
|
+
cam_data = bpy.data.cameras.new("__genex_probe_cam")
|
|
104
|
+
cam = bpy.data.objects.new("__genex_probe_cam", cam_data)
|
|
105
|
+
s.collection.objects.link(cam)
|
|
106
|
+
prev_cam, s.camera = s.camera, cam
|
|
107
|
+
try:
|
|
108
|
+
bpy.ops.render.render(write_still=True)
|
|
109
|
+
ok = os.path.exists(probe_path) and os.path.getsize(probe_path) > 0
|
|
110
|
+
finally:
|
|
111
|
+
s.camera = prev_cam
|
|
112
|
+
bpy.data.objects.remove(cam, do_unlink=True)
|
|
113
|
+
bpy.data.cameras.remove(cam_data)
|
|
114
|
+
try:
|
|
115
|
+
os.unlink(probe_path)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
except Exception as e: # noqa: BLE001 - a probe failure is a verdict, not a crash
|
|
119
|
+
sys.stderr.write("[gpu] probe render failed: %s\n" % e)
|
|
120
|
+
finally:
|
|
121
|
+
(r.engine, r.resolution_x, r.resolution_y, r.filepath,
|
|
122
|
+
r.image_settings.file_format, r.resolution_percentage) = keep
|
|
123
|
+
return ok
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def configure_cycles():
|
|
127
|
+
"""Enable OptiX devices and return their names. [] means no GPU device."""
|
|
128
|
+
addon = bpy.context.preferences.addons.get("cycles")
|
|
129
|
+
if addon is None:
|
|
130
|
+
return []
|
|
131
|
+
prefs = addon.preferences
|
|
132
|
+
# refresh_devices(), NOT get_devices(): the latter is deprecated on 4.5 and
|
|
133
|
+
# returns None, so the ubiquitous `prefs.get_devices()[0]` idiom raises
|
|
134
|
+
# TypeError. And prefs.devices is empty until this call.
|
|
135
|
+
try:
|
|
136
|
+
prefs.refresh_devices()
|
|
137
|
+
except Exception as e: # noqa: BLE001
|
|
138
|
+
sys.stderr.write("[gpu] refresh_devices failed: %s\n" % e)
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
# bl_rna enum_items is EMPTY for this dynamic enum — get_device_types() is
|
|
142
|
+
# the only reliable way to ask what this build supports.
|
|
143
|
+
try:
|
|
144
|
+
available = {t[0] for t in prefs.get_device_types(bpy.context)}
|
|
145
|
+
except Exception: # noqa: BLE001
|
|
146
|
+
available = set()
|
|
147
|
+
# NONE is always offered and means CPU — never treat it as a GPU backend.
|
|
148
|
+
available.discard("NONE")
|
|
149
|
+
|
|
150
|
+
pinned = os.environ.get("GENEX_BLENDER_CYCLES_BACKEND")
|
|
151
|
+
if pinned:
|
|
152
|
+
backend = pinned if pinned in available else None
|
|
153
|
+
else:
|
|
154
|
+
backend = next((b for b in CYCLES_BACKEND_PREFERENCE if b in available), None)
|
|
155
|
+
if backend is None:
|
|
156
|
+
sys.stderr.write("[gpu] no GPU compute backend offered (have: %s)\n"
|
|
157
|
+
% sorted(available))
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
prefs.compute_device_type = backend
|
|
161
|
+
prefs.refresh_devices()
|
|
162
|
+
|
|
163
|
+
gpus = []
|
|
164
|
+
for d in prefs.devices:
|
|
165
|
+
# NOT `d.use = True`. The CPU is ALWAYS in the candidate list, and new
|
|
166
|
+
# GPU entries already default to use=True, so blanket-enabling silently
|
|
167
|
+
# turns on hybrid CPU+GPU rendering that still reports as "GPU".
|
|
168
|
+
d.use = (d.type == backend)
|
|
169
|
+
if d.use:
|
|
170
|
+
gpus.append("%s (%s)" % (d.name, backend))
|
|
171
|
+
|
|
172
|
+
# Per SCENE, and stored in the .blend — so a restored checkpoint can arrive
|
|
173
|
+
# with device='CPU' even when preferences are perfect.
|
|
174
|
+
for scene in bpy.data.scenes:
|
|
175
|
+
try:
|
|
176
|
+
scene.cycles.device = "GPU"
|
|
177
|
+
except AttributeError:
|
|
178
|
+
pass # cycles addon present but scene not yet initialised
|
|
179
|
+
return gpus
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def witness():
|
|
183
|
+
"""What the renderer actually is, right now. Cheap enough to call at boot."""
|
|
184
|
+
import gpu as bgpu
|
|
185
|
+
|
|
186
|
+
probe_ok = _force_context()
|
|
187
|
+
try:
|
|
188
|
+
return {
|
|
189
|
+
"eeveeProbe": probe_ok,
|
|
190
|
+
"backend": bgpu.platform.backend_type_get(),
|
|
191
|
+
"device": bgpu.platform.device_type_get(),
|
|
192
|
+
"renderer": bgpu.platform.renderer_get(),
|
|
193
|
+
"vendor": bgpu.platform.vendor_get(),
|
|
194
|
+
"glVersion": bgpu.platform.version_get(),
|
|
195
|
+
"cyclesDevices": configure_cycles(),
|
|
196
|
+
"eeveeEngine": EEVEE_ENGINE,
|
|
197
|
+
}
|
|
198
|
+
except Exception as e: # noqa: BLE001
|
|
199
|
+
return {"eeveeProbe": probe_ok, "backend": "NONE", "device": "UNKNOWN",
|
|
200
|
+
"renderer": str(e), "vendor": "", "glVersion": "",
|
|
201
|
+
"cyclesDevices": configure_cycles(), "eeveeEngine": EEVEE_ENGINE}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def assert_gpu_or_die():
|
|
205
|
+
"""Refuse to serve on software. Called BEFORE the socket opens, so a
|
|
206
|
+
misconfigured GPU pod never accepts a request it would bill for and answer
|
|
207
|
+
from the CPU."""
|
|
208
|
+
w = witness()
|
|
209
|
+
# The verdict rests on INDEPENDENT EVIDENCE, not on gpu.platform alone.
|
|
210
|
+
# Blender may decline to answer gpu.platform in background mode even when a
|
|
211
|
+
# GPU is present and working, so treating UNKNOWN as fatal would refuse a
|
|
212
|
+
# healthy pod and take the whole tier down. Explicit SOFTWARE is fatal; an
|
|
213
|
+
# unanswered query is only fatal when the EEVEE probe ALSO produced nothing.
|
|
214
|
+
if w["device"] == "SOFTWARE":
|
|
215
|
+
sys.stderr.write("[svc] FATAL gpu_tier_on_software: device reported SOFTWARE (%s)\n"
|
|
216
|
+
% w["renderer"])
|
|
217
|
+
sys.stderr.flush()
|
|
218
|
+
sys.exit(3)
|
|
219
|
+
if not w["eeveeProbe"]:
|
|
220
|
+
sys.stderr.write(
|
|
221
|
+
"[svc] FATAL eevee_unavailable: probe render produced nothing "
|
|
222
|
+
"(backend=%s device=%s renderer=%r)\n"
|
|
223
|
+
% (w["backend"], w["device"], w["renderer"]))
|
|
224
|
+
sys.stderr.flush()
|
|
225
|
+
sys.exit(3)
|
|
226
|
+
if w["device"] in ("UNKNOWN", "NONE"):
|
|
227
|
+
# Not fatal, but no longer silent: on a healthy NVIDIA pod this answers
|
|
228
|
+
# "NVIDIA", so an unanswered query means the rasterizer path -- the one
|
|
229
|
+
# every contact sheet uses -- is unverified.
|
|
230
|
+
sys.stderr.write(
|
|
231
|
+
"[svc] WARN rasterizer_unverified: gpu.platform reported %s. Cycles "
|
|
232
|
+
"has a GPU device, but EEVEE/Workbench GPU use is unconfirmed.\n"
|
|
233
|
+
% w["device"])
|
|
234
|
+
if not w["cyclesDevices"]:
|
|
235
|
+
# A software rasterizer cannot enumerate one. If this fires on a box you
|
|
236
|
+
# believe has a GPU, the driver or container toolkit is the problem.
|
|
237
|
+
sys.stderr.write("[svc] FATAL no_cycles_device: no GPU compute device "
|
|
238
|
+
"enumerated (this is the real GPU gate; EEVEE rendering "
|
|
239
|
+
"proves nothing)\n")
|
|
240
|
+
sys.stderr.flush()
|
|
241
|
+
sys.exit(4)
|
|
242
|
+
sys.stderr.write("[svc] GPU ok: %s %s | cycles: %s\n"
|
|
243
|
+
% (w["device"], w["renderer"], ", ".join(w["cyclesDevices"])))
|
|
244
|
+
sys.stderr.flush()
|
|
245
|
+
return w
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Scene operations. Every mutating call runs on the HTTP server's own thread,
|
|
2
|
+
which is Blender's main thread -- bpy is not thread-safe and the server is
|
|
3
|
+
deliberately non-threading so this holds without a queue."""
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import traceback
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
import bpy
|
|
14
|
+
|
|
15
|
+
from views import scene_bounds
|
|
16
|
+
|
|
17
|
+
# The exec namespace PERSISTS across calls, so an agent can define a helper in
|
|
18
|
+
# one step and use it in the next. That is the difference between a REPL and a
|
|
19
|
+
# series of unrelated scripts.
|
|
20
|
+
NAMESPACE = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def reset_namespace():
|
|
24
|
+
import math
|
|
25
|
+
import random
|
|
26
|
+
|
|
27
|
+
import bmesh
|
|
28
|
+
import mathutils
|
|
29
|
+
|
|
30
|
+
NAMESPACE.clear()
|
|
31
|
+
NAMESPACE.update(
|
|
32
|
+
{"bpy": bpy, "bmesh": bmesh, "mathutils": mathutils, "math": math, "random": random}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_script(source):
|
|
37
|
+
"""Execute arbitrary Python against the live scene.
|
|
38
|
+
|
|
39
|
+
Arbitrary by design: a fixed tool vocabulary cannot express 'build a castle'.
|
|
40
|
+
The containment story is the container -- disposable, egress-locked in the
|
|
41
|
+
hosted path -- never a sanitizer on this string, which does not work.
|
|
42
|
+
"""
|
|
43
|
+
out, err = io.StringIO(), io.StringIO()
|
|
44
|
+
error = None
|
|
45
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
46
|
+
try:
|
|
47
|
+
exec(compile(source, "<genex-exec>", "exec"), NAMESPACE)
|
|
48
|
+
except Exception:
|
|
49
|
+
error = traceback.format_exc(limit=8)
|
|
50
|
+
return {"stdout": out.getvalue(), "stderr": err.getvalue(), "error": error}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tri_count(obj):
|
|
54
|
+
if obj.type != "MESH" or obj.data is None:
|
|
55
|
+
return 0
|
|
56
|
+
return sum(max(len(p.vertices) - 2, 0) for p in obj.data.polygons)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def scene_info():
|
|
60
|
+
"""The numeric truths. These are the GATE -- the picture is for steering.
|
|
61
|
+
|
|
62
|
+
A screenshot judge is measurably bad at pass/fail (WorldCoder-Bench: a
|
|
63
|
+
screenshot+VLM gate passes 2.7% of outputs), so an agent should assert on
|
|
64
|
+
these counts and use the render to decide what to change.
|
|
65
|
+
"""
|
|
66
|
+
objects, total_tris = [], 0
|
|
67
|
+
for obj in bpy.context.scene.objects:
|
|
68
|
+
tris = _tri_count(obj)
|
|
69
|
+
total_tris += tris
|
|
70
|
+
objects.append(
|
|
71
|
+
{
|
|
72
|
+
"name": obj.name,
|
|
73
|
+
"type": obj.type,
|
|
74
|
+
"tris": tris,
|
|
75
|
+
"location": [round(v, 4) for v in obj.location],
|
|
76
|
+
"dimensions": [round(v, 4) for v in obj.dimensions],
|
|
77
|
+
"materials": [m.name for m in obj.data.materials if m]
|
|
78
|
+
if getattr(obj.data, "materials", None)
|
|
79
|
+
else [],
|
|
80
|
+
"visible": obj.visible_get(),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
center, radius = scene_bounds()
|
|
84
|
+
return {
|
|
85
|
+
"objectCount": len(objects),
|
|
86
|
+
"meshCount": sum(1 for o in objects if o["type"] == "MESH"),
|
|
87
|
+
"totalTris": total_tris,
|
|
88
|
+
"materialCount": len(bpy.data.materials),
|
|
89
|
+
"bounds": {
|
|
90
|
+
"center": [round(v, 4) for v in center],
|
|
91
|
+
"radius": round(radius, 4),
|
|
92
|
+
},
|
|
93
|
+
"objects": objects,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def reset_scene():
|
|
98
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
99
|
+
reset_namespace()
|
|
100
|
+
return scene_info()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# A picture the agent LOOKS AT goes inline; an artifact the GAME SHIPS goes to
|
|
104
|
+
# object storage. Base64 inflates 4/3 plus JSON escaping, so ~7 MB of raw GLB
|
|
105
|
+
# already blows RunPod's 10 MB /run ceiling -- and it fails only on long,
|
|
106
|
+
# successful sessions that generated a lot of texture, which is the worst time.
|
|
107
|
+
INLINE_GLB_MAX_BYTES = 6 * 1024 * 1024
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def export_glb(path):
|
|
111
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
112
|
+
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=False)
|
|
113
|
+
return {"path": path, "bytes": os.path.getsize(path)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def upload_file(path, url, headers=None):
|
|
117
|
+
"""PUT the bytes to a presigned URL. The service never holds an account key
|
|
118
|
+
-- the caller mints a single-object, short-TTL URL, so code running inside
|
|
119
|
+
/exec has nothing durable to steal."""
|
|
120
|
+
import urllib.request
|
|
121
|
+
|
|
122
|
+
with open(path, "rb") as f:
|
|
123
|
+
body = f.read()
|
|
124
|
+
req = urllib.request.Request(url, data=body, method="PUT")
|
|
125
|
+
for k, v in (headers or {}).items():
|
|
126
|
+
req.add_header(k, v)
|
|
127
|
+
with urllib.request.urlopen(req, timeout=300) as r: # noqa: S310 - caller-minted
|
|
128
|
+
return {"status": r.status, "bytes": len(body)}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_checkpoint(path):
|
|
132
|
+
"""Whole-scene snapshot. Measured elsewhere at tens to hundreds of ms, and
|
|
133
|
+
it hides inside the agent's think time. What makes a pod disposable."""
|
|
134
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
135
|
+
bpy.ops.wm.save_as_mainfile(filepath=path, copy=True, compress=True)
|
|
136
|
+
return os.path.getsize(path)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_checkpoint(path):
|
|
140
|
+
"""Restore a snapshot. NOTE: this invalidates every live bpy reference, so
|
|
141
|
+
the exec namespace is reset alongside it -- a held Python handle raises
|
|
142
|
+
`StructRNA has been removed` long after the restore, somewhere unrelated."""
|
|
143
|
+
bpy.ops.wm.open_mainfile(filepath=path)
|
|
144
|
+
reset_namespace()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _host_allowed(url):
|
|
148
|
+
"""Empty allowlist = permissive, which is correct for the admin-only spike
|
|
149
|
+
and wrong for the hosted lane. GENEX_BLENDER_IMPORT_HOSTS narrows it without
|
|
150
|
+
a code change when this reaches a session."""
|
|
151
|
+
allow = [h.strip() for h in os.environ.get("GENEX_BLENDER_IMPORT_HOSTS", "").split(",") if h.strip()]
|
|
152
|
+
if not allow:
|
|
153
|
+
return True
|
|
154
|
+
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
|
155
|
+
return any(host == a.lower() or host.endswith("." + a.lower().lstrip(".")) for a in allow)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def import_glb(url):
|
|
159
|
+
if not _host_allowed(url):
|
|
160
|
+
raise ValueError(f"import host not allowed: {urllib.parse.urlparse(url).hostname}")
|
|
161
|
+
before = {o.name for o in bpy.context.scene.objects}
|
|
162
|
+
fd, tmp = tempfile.mkstemp(suffix=".glb", prefix="genex-import-")
|
|
163
|
+
os.close(fd)
|
|
164
|
+
try:
|
|
165
|
+
with urllib.request.urlopen(url, timeout=60) as r: # noqa: S310 - allowlisted above
|
|
166
|
+
with open(tmp, "wb") as f:
|
|
167
|
+
f.write(r.read())
|
|
168
|
+
bpy.ops.import_scene.gltf(filepath=tmp)
|
|
169
|
+
finally:
|
|
170
|
+
os.unlink(tmp)
|
|
171
|
+
added = [o.name for o in bpy.context.scene.objects if o.name not in before]
|
|
172
|
+
return {"imported": added, "count": len(added)}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def scene_digest():
|
|
176
|
+
"""A fingerprint of everything a render depends on, for the sheet-repeat guard.
|
|
177
|
+
|
|
178
|
+
Built from the data blocks directly, not from scene_info(): that summary
|
|
179
|
+
carries location, dimensions and material NAMES, and nothing else — so a
|
|
180
|
+
rotation, a scale, a modifier, a moved vertex, a dimmed light or a recoloured
|
|
181
|
+
material all hashed identically and the model was told the picture was
|
|
182
|
+
unchanged (review finding). Vertex coordinates go through numpy, so a 600k
|
|
183
|
+
triangle mesh costs one foreach_get and one hash rather than a Python loop.
|
|
184
|
+
"""
|
|
185
|
+
import hashlib
|
|
186
|
+
h = hashlib.sha256()
|
|
187
|
+
scene = bpy.context.scene
|
|
188
|
+
for obj in scene.objects:
|
|
189
|
+
# The RNA transform, NOT matrix_world: a script that sets rotation_euler
|
|
190
|
+
# leaves matrix_world stale until the depsgraph evaluates, which in
|
|
191
|
+
# background mode is not before this runs. Measured live: a rotation
|
|
192
|
+
# hashed identical through matrix_world and the guard said "unchanged".
|
|
193
|
+
parent = obj.parent.name if obj.parent else ""
|
|
194
|
+
h.update(("%s|%s|%s|%s|%s|%s|%d|%s\n" % (
|
|
195
|
+
obj.name, obj.type, parent, tuple(obj.location),
|
|
196
|
+
tuple(obj.rotation_euler), tuple(obj.scale),
|
|
197
|
+
obj.hide_render, [m.type for m in getattr(obj, "modifiers", [])],
|
|
198
|
+
)).encode("utf-8"))
|
|
199
|
+
data = obj.data
|
|
200
|
+
if obj.type == "MESH" and data is not None:
|
|
201
|
+
n = len(data.vertices)
|
|
202
|
+
if n:
|
|
203
|
+
try:
|
|
204
|
+
import numpy as np
|
|
205
|
+
buf = np.empty(n * 3, dtype=np.float32)
|
|
206
|
+
data.vertices.foreach_get("co", buf)
|
|
207
|
+
h.update(buf.tobytes())
|
|
208
|
+
except Exception:
|
|
209
|
+
h.update(str(n).encode())
|
|
210
|
+
for m in data.materials:
|
|
211
|
+
if m is None:
|
|
212
|
+
continue
|
|
213
|
+
h.update(("mat:%s:%s\n" % (m.name, tuple(m.diffuse_color))).encode())
|
|
214
|
+
if m.use_nodes and m.node_tree:
|
|
215
|
+
for node in m.node_tree.nodes:
|
|
216
|
+
if node.type == "BSDF_PRINCIPLED":
|
|
217
|
+
h.update(("bsdf:%s\n" % (tuple(node.inputs["Base Color"].default_value),)).encode())
|
|
218
|
+
elif obj.type == "LIGHT" and data is not None:
|
|
219
|
+
h.update(("light:%s:%s:%s\n" % (data.type, data.energy, tuple(data.color))).encode())
|
|
220
|
+
elif obj.type == "CAMERA" and data is not None:
|
|
221
|
+
h.update(("cam:%s:%s\n" % (data.lens, data.type)).encode())
|
|
222
|
+
w = scene.world
|
|
223
|
+
if w is not None:
|
|
224
|
+
h.update(("world:%s\n" % (tuple(getattr(w, "color", (0, 0, 0))),)).encode())
|
|
225
|
+
return h.hexdigest()
|