@deepblend/dsh-blender-provider-local 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +1610 -0
- package/package.json +25 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
- package/python/bootstrap.py +625 -0
- package/python/deepblend_capabilities.py +633 -0
- package/python/deepblend_frames.py +402 -0
- package/python/deepblend_render.py +299 -0
- package/python/deepblend_scene.py +1537 -0
- package/python/deepblend_util.py +264 -0
- package/python/deepblend_validate.py +358 -0
- package/python/deepblend_views.py +745 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""DeepBlend Blender capability probe.
|
|
4
|
+
|
|
5
|
+
Answers "what can this Blender build actually do?" by DOING it, not by reading
|
|
6
|
+
declared metadata. Two findings from the M0 runtime audit are the reason for that
|
|
7
|
+
stance and are load-bearing here:
|
|
8
|
+
|
|
9
|
+
* **D1 — the engine enum lies.** On Blender 5.2.1 the static ``engine`` enum
|
|
10
|
+
reports only ``BLENDER_EEVEE``, while ``CYCLES`` and ``BLENDER_WORKBENCH`` both
|
|
11
|
+
assign and render. Availability is therefore decided by assigning an identifier
|
|
12
|
+
and reading it back; the enum is reported separately as a diagnostic so an
|
|
13
|
+
operator can see the discrepancy for themselves.
|
|
14
|
+
* **D10 — ``hasattr`` is not availability.** ``bpy.ops.export_scene.obj`` exists as
|
|
15
|
+
an attribute but is not registered, so calling it fails. Formats are detected by
|
|
16
|
+
checking operator REGISTRATION, not attribute presence.
|
|
17
|
+
|
|
18
|
+
Moved out of ``bootstrap.py`` in M1, when bootstrap became a dispatcher for more
|
|
19
|
+
than one action. The logic below is unchanged from M0 except for its imports:
|
|
20
|
+
its behaviour is pinned by ``deepblend/tests/contract/capabilities-moved.test.mjs``
|
|
21
|
+
and by the M0 Blender integration suite, both of which must keep passing.
|
|
22
|
+
|
|
23
|
+
Runs inside Blender's embedded interpreter.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import platform
|
|
28
|
+
import shutil
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
|
|
32
|
+
import bpy
|
|
33
|
+
import addon_utils
|
|
34
|
+
|
|
35
|
+
from deepblend_util import error_text as _error_text
|
|
36
|
+
from deepblend_util import eprint as _eprint
|
|
37
|
+
from deepblend_util import as_text as _as_text
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------------------
|
|
40
|
+
# Constants
|
|
41
|
+
# --------------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
#: Render engines we probe behaviorally. NOTE: we deliberately do NOT trust
|
|
44
|
+
#: ``bpy.types.RenderSettings.bl_rna.properties['engine'].enum_items`` for availability --
|
|
45
|
+
#: in this Blender build that enum misleadingly reports only ``['BLENDER_EEVEE']`` while
|
|
46
|
+
#: Cycles demonstrably assigns and renders. The enum is reported as an informational
|
|
47
|
+
#: diagnostic only; availability is decided by actually assigning and reading back.
|
|
48
|
+
CANDIDATE_ENGINES = ["BLENDER_EEVEE", "CYCLES", "BLENDER_WORKBENCH"]
|
|
49
|
+
|
|
50
|
+
#: Preference order for choosing the engine used by the smoke render (best first).
|
|
51
|
+
ENGINE_PREFERENCE = ["CYCLES", "BLENDER_EEVEE", "BLENDER_WORKBENCH"]
|
|
52
|
+
|
|
53
|
+
#: Cycles compute backends we probe. Most are compiled out on any given platform, in which
|
|
54
|
+
#: case assigning the enum raises rather than returning an empty device list.
|
|
55
|
+
CANDIDATE_GPU_BACKENDS = ["OPTIX", "METAL", "CUDA", "HIP", "ONEAPI"]
|
|
56
|
+
|
|
57
|
+
#: Probe render size -- large enough to prove the whole pipeline, small enough to be free.
|
|
58
|
+
PROBE_WIDTH = 64
|
|
59
|
+
PROBE_HEIGHT = 36
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def collect_identity():
|
|
63
|
+
"""Static identity of this Blender/Python build. Pure reads, no state changes."""
|
|
64
|
+
identity = {
|
|
65
|
+
"blenderVersion": None,
|
|
66
|
+
"blenderVersionTuple": None,
|
|
67
|
+
"pythonVersion": None,
|
|
68
|
+
"buildHash": None,
|
|
69
|
+
"binaryPath": None,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
identity["blenderVersion"] = _as_text(bpy.app.version_string)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
_eprint("blenderVersion unavailable: %s" % (_error_text(exc),))
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
version = tuple(int(part) for part in bpy.app.version[:3])
|
|
79
|
+
identity["blenderVersionTuple"] = [version[0], version[1], version[2]]
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
_eprint("blenderVersionTuple unavailable: %s" % (_error_text(exc),))
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# platform.python_version() gives the clean "3.13.13" form.
|
|
85
|
+
identity["pythonVersion"] = platform.python_version()
|
|
86
|
+
except Exception:
|
|
87
|
+
try:
|
|
88
|
+
identity["pythonVersion"] = _as_text(sys.version.split()[0])
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
_eprint("pythonVersion unavailable: %s" % (_error_text(exc),))
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
identity["buildHash"] = _as_text(bpy.app.build_hash)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
_eprint("buildHash unavailable: %s" % (_error_text(exc),))
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
identity["binaryPath"] = _as_text(bpy.app.binary_path)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
_eprint("binaryPath unavailable: %s" % (_error_text(exc),))
|
|
101
|
+
|
|
102
|
+
return identity
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def collect_host_platform():
|
|
106
|
+
"""Host platform facts -- useful for confirming the macOS/arm64 assumption."""
|
|
107
|
+
host = {"system": None, "machine": None, "processor": None, "platform": None}
|
|
108
|
+
try:
|
|
109
|
+
host["system"] = platform.system()
|
|
110
|
+
host["machine"] = platform.machine()
|
|
111
|
+
host["processor"] = platform.processor() or None
|
|
112
|
+
host["platform"] = platform.platform()
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
_eprint("platform info unavailable: %s" % (_error_text(exc),))
|
|
115
|
+
return host
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_scene():
|
|
119
|
+
"""Return the active scene, falling back to the first datablock if necessary."""
|
|
120
|
+
scene = getattr(bpy.context, "scene", None)
|
|
121
|
+
if scene is not None:
|
|
122
|
+
return scene
|
|
123
|
+
try:
|
|
124
|
+
if len(bpy.data.scenes) > 0:
|
|
125
|
+
return bpy.data.scenes[0]
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def probe_engine_assignability(scene):
|
|
132
|
+
"""Behaviorally probe each candidate engine.
|
|
133
|
+
|
|
134
|
+
For every candidate we save nothing but the *original* engine once, then for each
|
|
135
|
+
candidate: assign it, read the property back, and treat "assignment took effect"
|
|
136
|
+
(readback equals the requested identifier) as the definition of available. Any raised
|
|
137
|
+
exception is captured per candidate. The original engine is restored at the end.
|
|
138
|
+
|
|
139
|
+
Returns ``(results, original_engine, restored_ok)``.
|
|
140
|
+
"""
|
|
141
|
+
results = {}
|
|
142
|
+
original_engine = None
|
|
143
|
+
restored_ok = False
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
original_engine = _as_text(scene.render.engine)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
_eprint("could not read original render engine: %s" % (_error_text(exc),))
|
|
149
|
+
|
|
150
|
+
for candidate in CANDIDATE_ENGINES:
|
|
151
|
+
entry = {"assignable": False, "readback": None, "error": None}
|
|
152
|
+
try:
|
|
153
|
+
scene.render.engine = candidate
|
|
154
|
+
readback = _as_text(scene.render.engine)
|
|
155
|
+
entry["readback"] = readback
|
|
156
|
+
entry["assignable"] = readback == candidate
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
entry["error"] = _error_text(exc)
|
|
159
|
+
# A failed assignment can still leave a readable current value; report it.
|
|
160
|
+
try:
|
|
161
|
+
entry["readback"] = _as_text(scene.render.engine)
|
|
162
|
+
except Exception:
|
|
163
|
+
entry["readback"] = None
|
|
164
|
+
results[candidate] = entry
|
|
165
|
+
|
|
166
|
+
# Always try to hand the scene back exactly as we found it.
|
|
167
|
+
if original_engine is not None:
|
|
168
|
+
try:
|
|
169
|
+
scene.render.engine = original_engine
|
|
170
|
+
restored_ok = _as_text(scene.render.engine) == original_engine
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
_eprint("could not restore render engine %r: %s" % (original_engine, _error_text(exc)))
|
|
173
|
+
|
|
174
|
+
return results, original_engine, restored_ok
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def collect_engine_enum_items_informational():
|
|
178
|
+
"""Raw ``engine`` enum identifiers -- INFORMATIONAL ONLY, never used for availability.
|
|
179
|
+
|
|
180
|
+
In Blender 5.2.1 LTS this returns ``['BLENDER_EEVEE']`` even though Cycles assigns and
|
|
181
|
+
renders correctly, which is exactly why engine availability is probed behaviorally.
|
|
182
|
+
"""
|
|
183
|
+
diagnostic = {"identifiers": [], "staticIdentifiers": [], "error": None}
|
|
184
|
+
try:
|
|
185
|
+
prop = bpy.types.RenderSettings.bl_rna.properties["engine"]
|
|
186
|
+
diagnostic["identifiers"] = [str(item.identifier) for item in prop.enum_items]
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
diagnostic["error"] = _error_text(exc)
|
|
189
|
+
try:
|
|
190
|
+
prop = bpy.types.RenderSettings.bl_rna.properties["engine"]
|
|
191
|
+
diagnostic["staticIdentifiers"] = [str(item.identifier) for item in prop.enum_items_static]
|
|
192
|
+
except Exception:
|
|
193
|
+
# enum_items_static does not exist in every build; absence is not an error.
|
|
194
|
+
diagnostic["staticIdentifiers"] = []
|
|
195
|
+
return diagnostic
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def enable_cycles_addon():
|
|
199
|
+
"""Enable the bundled Cycles addon inside this process.
|
|
200
|
+
|
|
201
|
+
``default_set=True`` only marks the addon as enabled for the current session; we never
|
|
202
|
+
call ``bpy.ops.wm.save_userpref()``, so nothing is persisted to the user's config and
|
|
203
|
+
the caller-visible filesystem stays untouched.
|
|
204
|
+
"""
|
|
205
|
+
report = {"name": "cycles", "attempted": True, "enabled": False, "modulePath": None, "error": None}
|
|
206
|
+
try:
|
|
207
|
+
module = addon_utils.enable("cycles", default_set=True)
|
|
208
|
+
report["enabled"] = module is not None
|
|
209
|
+
report["modulePath"] = _as_text(getattr(module, "__file__", None))
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
report["error"] = _error_text(exc)
|
|
212
|
+
return report
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def probe_gpu_devices():
|
|
216
|
+
"""Probe Cycles compute backends and enumerate devices for each.
|
|
217
|
+
|
|
218
|
+
Most backends are compiled out on a given platform, and assigning an unavailable enum
|
|
219
|
+
value raises ``TypeError`` instead of yielding an empty list -- so every candidate gets
|
|
220
|
+
its own try/except and its own error string. The original ``compute_device_type`` is
|
|
221
|
+
restored afterwards.
|
|
222
|
+
"""
|
|
223
|
+
result = {
|
|
224
|
+
"backendSupport": {},
|
|
225
|
+
"availableBackends": [],
|
|
226
|
+
"gpuDeviceNames": [],
|
|
227
|
+
"cpuDeviceNames": [],
|
|
228
|
+
"preferredBackend": None,
|
|
229
|
+
"error": None,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
prefs = None
|
|
233
|
+
original_backend = None
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
addon = bpy.context.preferences.addons.get("cycles")
|
|
237
|
+
if addon is None:
|
|
238
|
+
result["error"] = "Cycles addon preferences unavailable (addon not enabled?)"
|
|
239
|
+
return result
|
|
240
|
+
prefs = addon.preferences
|
|
241
|
+
if prefs is None:
|
|
242
|
+
result["error"] = "Cycles preferences object unavailable"
|
|
243
|
+
return result
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
result["error"] = _error_text(exc)
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
original_backend = _as_text(prefs.compute_device_type)
|
|
250
|
+
except Exception:
|
|
251
|
+
original_backend = None
|
|
252
|
+
|
|
253
|
+
for backend in CANDIDATE_GPU_BACKENDS:
|
|
254
|
+
entry = {"supported": False, "gpuDevices": [], "cpuDevices": [], "error": None}
|
|
255
|
+
try:
|
|
256
|
+
prefs.compute_device_type = backend
|
|
257
|
+
prefs.get_devices()
|
|
258
|
+
gpu_names = []
|
|
259
|
+
cpu_names = []
|
|
260
|
+
for device in prefs.devices:
|
|
261
|
+
name = _as_text(getattr(device, "name", None))
|
|
262
|
+
device_type = _as_text(getattr(device, "type", None))
|
|
263
|
+
if name is None:
|
|
264
|
+
continue
|
|
265
|
+
if device_type == "CPU":
|
|
266
|
+
cpu_names.append(name)
|
|
267
|
+
else:
|
|
268
|
+
gpu_names.append(name)
|
|
269
|
+
entry["gpuDevices"] = gpu_names
|
|
270
|
+
entry["cpuDevices"] = cpu_names
|
|
271
|
+
entry["supported"] = True
|
|
272
|
+
if gpu_names:
|
|
273
|
+
result["availableBackends"].append(backend)
|
|
274
|
+
# First backend that actually exposes a GPU wins as the preferred one.
|
|
275
|
+
if result["preferredBackend"] is None:
|
|
276
|
+
result["preferredBackend"] = backend
|
|
277
|
+
result["gpuDeviceNames"] = gpu_names
|
|
278
|
+
if cpu_names and not result["cpuDeviceNames"]:
|
|
279
|
+
result["cpuDeviceNames"] = cpu_names
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
entry["error"] = _error_text(exc)
|
|
282
|
+
result["backendSupport"][backend] = entry
|
|
283
|
+
|
|
284
|
+
# Restore whatever backend selection was in force when we arrived.
|
|
285
|
+
if original_backend is not None:
|
|
286
|
+
try:
|
|
287
|
+
prefs.compute_device_type = original_backend
|
|
288
|
+
prefs.get_devices()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
_eprint("could not restore compute_device_type %r: %s" % (original_backend, _error_text(exc)))
|
|
291
|
+
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def collect_operator_formats():
|
|
296
|
+
"""List registered ``bpy.ops.export_scene.*`` / ``bpy.ops.import_scene.*`` operators.
|
|
297
|
+
|
|
298
|
+
``dir()`` on the ops namespace reflects *registered* operators only, which is what
|
|
299
|
+
"available" should mean; ``hasattr`` is misleadingly True for unregistered ones (for
|
|
300
|
+
example ``bpy.ops.export_scene.obj`` exists as an attribute in this build but is absent
|
|
301
|
+
from ``dir()`` and cannot be invoked). Names are validated once more against the RNA
|
|
302
|
+
registry, and any attribute-present-but-unregistered name is reported as a diagnostic.
|
|
303
|
+
"""
|
|
304
|
+
formats = {
|
|
305
|
+
"exportFormats": [],
|
|
306
|
+
"importFormats": [],
|
|
307
|
+
"exportUnregistered": [],
|
|
308
|
+
"importUnregistered": [],
|
|
309
|
+
"errors": [],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for label, namespace, target in (
|
|
313
|
+
("export", bpy.ops.export_scene, "exportFormats"),
|
|
314
|
+
("import", bpy.ops.import_scene, "importFormats"),
|
|
315
|
+
):
|
|
316
|
+
registered = set()
|
|
317
|
+
try:
|
|
318
|
+
candidates = sorted(name for name in dir(namespace) if not name.startswith("_"))
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
formats["errors"].append("%s: %s" % (label, _error_text(exc)))
|
|
321
|
+
candidates = []
|
|
322
|
+
|
|
323
|
+
for name in candidates:
|
|
324
|
+
try:
|
|
325
|
+
getattr(namespace, name).get_rna_type()
|
|
326
|
+
registered.add(name)
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
formats["errors"].append("%s.%s: %s" % (label, name, _error_text(exc)))
|
|
329
|
+
|
|
330
|
+
formats[target] = sorted(registered)
|
|
331
|
+
|
|
332
|
+
# Informational: names reachable as attributes but not registered as operators.
|
|
333
|
+
unregistered = []
|
|
334
|
+
for name in ("obj", "fbx", "gltf", "stl", "ply", "usd", "abc", "dae", "x3d"):
|
|
335
|
+
if name in registered:
|
|
336
|
+
continue
|
|
337
|
+
try:
|
|
338
|
+
if hasattr(namespace, name):
|
|
339
|
+
unregistered.append(name)
|
|
340
|
+
except Exception:
|
|
341
|
+
pass
|
|
342
|
+
formats["%sUnregistered" % (label,)] = sorted(unregistered)
|
|
343
|
+
|
|
344
|
+
return formats
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def smoke_render(scene, engine):
|
|
348
|
+
"""Render one tiny image headlessly and report the outcome.
|
|
349
|
+
|
|
350
|
+
This is the only action in the whole script that produces a file, and it stays inside a
|
|
351
|
+
private ``tempfile`` directory that is removed again before returning. Render settings
|
|
352
|
+
the probe perturbs are saved and restored so the scene is left as found.
|
|
353
|
+
"""
|
|
354
|
+
entry = {"attempted": True, "engine": engine, "ok": False, "bytes": 0, "error": None}
|
|
355
|
+
|
|
356
|
+
work_dir = None
|
|
357
|
+
saved = {}
|
|
358
|
+
try:
|
|
359
|
+
render = scene.render
|
|
360
|
+
|
|
361
|
+
# --- save state -----------------------------------------------------------------
|
|
362
|
+
saved["engine"] = render.engine
|
|
363
|
+
saved["filepath"] = render.filepath
|
|
364
|
+
saved["resolution_x"] = render.resolution_x
|
|
365
|
+
saved["resolution_y"] = render.resolution_y
|
|
366
|
+
saved["resolution_percentage"] = render.resolution_percentage
|
|
367
|
+
saved["file_format"] = render.image_settings.file_format
|
|
368
|
+
saved["cycles_samples"] = getattr(scene.cycles, "samples", None)
|
|
369
|
+
saved["cycles_use_denoising"] = getattr(scene.cycles, "use_denoising", None)
|
|
370
|
+
saved["eevee_samples"] = getattr(scene.eevee, "taa_render_samples", None)
|
|
371
|
+
|
|
372
|
+
# --- configure a minimal, fast render -------------------------------------------
|
|
373
|
+
render.engine = engine
|
|
374
|
+
render.resolution_x = PROBE_WIDTH
|
|
375
|
+
render.resolution_y = PROBE_HEIGHT
|
|
376
|
+
render.resolution_percentage = 100
|
|
377
|
+
render.image_settings.file_format = "PNG"
|
|
378
|
+
if engine == "CYCLES":
|
|
379
|
+
# 1 sample and no denoise keeps the probe effectively instantaneous.
|
|
380
|
+
try:
|
|
381
|
+
scene.cycles.samples = 1
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
try:
|
|
385
|
+
scene.cycles.use_denoising = False
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
elif engine == "BLENDER_EEVEE":
|
|
389
|
+
try:
|
|
390
|
+
scene.eevee.taa_render_samples = 1
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
# Private scratch directory inside the system temp dir; removed in `finally`.
|
|
395
|
+
work_dir = tempfile.mkdtemp(prefix="deepblend-probe-", dir=tempfile.gettempdir())
|
|
396
|
+
output_path = os.path.join(work_dir, "probe.png")
|
|
397
|
+
render.filepath = output_path
|
|
398
|
+
|
|
399
|
+
# --- render ---------------------------------------------------------------------
|
|
400
|
+
bpy.ops.render.render(write_still=True)
|
|
401
|
+
|
|
402
|
+
# Blender may or may not append the extension depending on settings; check both.
|
|
403
|
+
written = None
|
|
404
|
+
for candidate_path in (output_path, output_path + ".png"):
|
|
405
|
+
if os.path.exists(candidate_path):
|
|
406
|
+
written = candidate_path
|
|
407
|
+
break
|
|
408
|
+
entry["bytes"] = int(os.path.getsize(written)) if written else 0
|
|
409
|
+
entry["ok"] = entry["bytes"] > 0
|
|
410
|
+
if not entry["ok"]:
|
|
411
|
+
entry["error"] = "render completed but produced no image data"
|
|
412
|
+
except Exception as exc:
|
|
413
|
+
entry["error"] = _error_text(exc)
|
|
414
|
+
finally:
|
|
415
|
+
# --- restore state --------------------------------------------------------------
|
|
416
|
+
try:
|
|
417
|
+
render = scene.render
|
|
418
|
+
if saved.get("engine") is not None:
|
|
419
|
+
render.engine = saved["engine"]
|
|
420
|
+
if saved.get("filepath") is not None:
|
|
421
|
+
render.filepath = saved["filepath"]
|
|
422
|
+
if saved.get("resolution_x") is not None:
|
|
423
|
+
render.resolution_x = saved["resolution_x"]
|
|
424
|
+
if saved.get("resolution_y") is not None:
|
|
425
|
+
render.resolution_y = saved["resolution_y"]
|
|
426
|
+
if saved.get("resolution_percentage") is not None:
|
|
427
|
+
render.resolution_percentage = saved["resolution_percentage"]
|
|
428
|
+
if saved.get("file_format") is not None:
|
|
429
|
+
render.image_settings.file_format = saved["file_format"]
|
|
430
|
+
if saved.get("cycles_samples") is not None:
|
|
431
|
+
scene.cycles.samples = saved["cycles_samples"]
|
|
432
|
+
if saved.get("cycles_use_denoising") is not None:
|
|
433
|
+
scene.cycles.use_denoising = saved["cycles_use_denoising"]
|
|
434
|
+
if saved.get("eevee_samples") is not None:
|
|
435
|
+
scene.eevee.taa_render_samples = saved["eevee_samples"]
|
|
436
|
+
except Exception as exc:
|
|
437
|
+
_eprint("could not fully restore render settings: %s" % (_error_text(exc),))
|
|
438
|
+
|
|
439
|
+
# --- clean up the probe image ---------------------------------------------------
|
|
440
|
+
if work_dir:
|
|
441
|
+
try:
|
|
442
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
return entry
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def collect_capabilities():
|
|
450
|
+
"""Run every probe and assemble ``(capabilities, warnings)``.
|
|
451
|
+
|
|
452
|
+
Raises only if the scene itself is unreachable; every individual probe is guarded so one
|
|
453
|
+
missing feature degrades into a warning instead of losing the whole report.
|
|
454
|
+
"""
|
|
455
|
+
warnings = []
|
|
456
|
+
|
|
457
|
+
identity = collect_identity()
|
|
458
|
+
host_platform = collect_host_platform()
|
|
459
|
+
|
|
460
|
+
scene = resolve_scene()
|
|
461
|
+
if scene is None:
|
|
462
|
+
raise RuntimeError("no scene available to probe in this Blender session")
|
|
463
|
+
|
|
464
|
+
# Pass 1: behavioral engine probe *before* the Cycles addon is enabled.
|
|
465
|
+
pre_enable_results, original_engine, _ = probe_engine_assignability(scene)
|
|
466
|
+
|
|
467
|
+
# Enable Cycles, then re-run the probes -- this second pass is authoritative.
|
|
468
|
+
cycles_addon = enable_cycles_addon()
|
|
469
|
+
if not cycles_addon["enabled"]:
|
|
470
|
+
warnings.append(
|
|
471
|
+
"Cycles addon could not be enabled: %s" % (cycles_addon["error"] or "unknown reason",)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
render_engines, original_engine_after, restored_ok = probe_engine_assignability(scene)
|
|
475
|
+
if not restored_ok:
|
|
476
|
+
warnings.append("Original render engine %r could not be restored" % (original_engine_after,))
|
|
477
|
+
if original_engine is not None and original_engine_after is None:
|
|
478
|
+
original_engine_after = original_engine
|
|
479
|
+
|
|
480
|
+
engine_enum_diagnostic = collect_engine_enum_items_informational()
|
|
481
|
+
diagnostics_enum = engine_enum_diagnostic["identifiers"]
|
|
482
|
+
|
|
483
|
+
for candidate in CANDIDATE_ENGINES:
|
|
484
|
+
entry = render_engines.get(candidate, {})
|
|
485
|
+
if not entry.get("assignable"):
|
|
486
|
+
warnings.append(
|
|
487
|
+
"Render engine %s is not assignable (%s)"
|
|
488
|
+
% (candidate, entry.get("error") or "assignment did not take effect")
|
|
489
|
+
)
|
|
490
|
+
if render_engines.get("CYCLES", {}).get("assignable") is not True:
|
|
491
|
+
warnings.append("Cycles not assignable")
|
|
492
|
+
|
|
493
|
+
# The enum's own view disagrees with behaviour in this build; surface that loudly.
|
|
494
|
+
behaviorally_available = sorted(
|
|
495
|
+
name for name, entry in render_engines.items() if entry.get("assignable")
|
|
496
|
+
)
|
|
497
|
+
if behaviorally_available != sorted(diagnostics_enum):
|
|
498
|
+
warnings.append(
|
|
499
|
+
"engine enum_items %r disagrees with behavioral probe %r; behavioral result is authoritative"
|
|
500
|
+
% (diagnostics_enum, behaviorally_available)
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
best_available = None
|
|
504
|
+
for preferred in ENGINE_PREFERENCE:
|
|
505
|
+
if render_engines.get(preferred, {}).get("assignable"):
|
|
506
|
+
best_available = preferred
|
|
507
|
+
break
|
|
508
|
+
if best_available is None:
|
|
509
|
+
warnings.append("No candidate render engine is assignable; smoke render will be skipped")
|
|
510
|
+
|
|
511
|
+
gpu_devices = probe_gpu_devices()
|
|
512
|
+
if gpu_devices.get("error"):
|
|
513
|
+
warnings.append("GPU device probe failed: %s" % (gpu_devices["error"],))
|
|
514
|
+
elif not gpu_devices["availableBackends"]:
|
|
515
|
+
warnings.append("No Cycles GPU compute backend exposed a GPU device")
|
|
516
|
+
else:
|
|
517
|
+
_eprint(
|
|
518
|
+
"GPU backend(s) available: %s; devices: %s"
|
|
519
|
+
% (", ".join(gpu_devices["availableBackends"]), ", ".join(gpu_devices["gpuDeviceNames"]))
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
formats = collect_operator_formats()
|
|
523
|
+
for collection, label in (("exportFormats", "export"), ("importFormats", "import")):
|
|
524
|
+
names = formats[collection]
|
|
525
|
+
if not names:
|
|
526
|
+
warnings.append("No %s operators are registered" % (label,))
|
|
527
|
+
for expected, pretty in (("obj", "OBJ"), ("fbx", "FBX"), ("gltf", "glTF")):
|
|
528
|
+
if expected not in names:
|
|
529
|
+
warnings.append("%s %s not available" % (pretty, label))
|
|
530
|
+
|
|
531
|
+
# --- render smoke tests -------------------------------------------------------------
|
|
532
|
+
if best_available is None:
|
|
533
|
+
render_smoke_test = {
|
|
534
|
+
"attempted": False,
|
|
535
|
+
"engine": None,
|
|
536
|
+
"ok": False,
|
|
537
|
+
"bytes": 0,
|
|
538
|
+
"error": "no assignable render engine",
|
|
539
|
+
}
|
|
540
|
+
else:
|
|
541
|
+
render_smoke_test = smoke_render(scene, best_available)
|
|
542
|
+
if not render_smoke_test["ok"]:
|
|
543
|
+
warnings.append(
|
|
544
|
+
"Headless render smoke test failed on %s: %s"
|
|
545
|
+
% (best_available, render_smoke_test["error"] or "unknown error")
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Cycles gets its own smoke render, but only when the behavioral probe says it is there.
|
|
549
|
+
cycles_smoke_test = None
|
|
550
|
+
if render_engines.get("CYCLES", {}).get("assignable"):
|
|
551
|
+
if best_available == "CYCLES":
|
|
552
|
+
# The primary probe already proved Cycles renders; do not render twice.
|
|
553
|
+
cycles_smoke_test = dict(render_smoke_test)
|
|
554
|
+
cycles_smoke_test["reusedFromPrimary"] = True
|
|
555
|
+
else:
|
|
556
|
+
cycles_smoke_test = smoke_render(scene, "CYCLES")
|
|
557
|
+
cycles_smoke_test["reusedFromPrimary"] = False
|
|
558
|
+
if not cycles_smoke_test["ok"]:
|
|
559
|
+
warnings.append(
|
|
560
|
+
"Cycles smoke render failed: %s" % (cycles_smoke_test["error"] or "unknown error",)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
text_block_api = False
|
|
564
|
+
try:
|
|
565
|
+
text_block_api = bool(hasattr(bpy.data, "texts"))
|
|
566
|
+
except Exception as exc:
|
|
567
|
+
warnings.append("Text datablock API probe failed: %s" % (_error_text(exc),))
|
|
568
|
+
if not text_block_api:
|
|
569
|
+
warnings.append("bpy.data.texts is unavailable (text block API missing)")
|
|
570
|
+
|
|
571
|
+
frame_api = False
|
|
572
|
+
frame_api_error = None
|
|
573
|
+
try:
|
|
574
|
+
frame_api = bool(hasattr(scene, "frame_start"))
|
|
575
|
+
if frame_api:
|
|
576
|
+
# Touch the value so a property that exists but cannot be read still shows up.
|
|
577
|
+
_ = int(scene.frame_start)
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
frame_api = False
|
|
580
|
+
frame_api_error = _error_text(exc)
|
|
581
|
+
warnings.append("Scene frame API probe failed: %s" % (frame_api_error,))
|
|
582
|
+
if not frame_api and frame_api_error is None:
|
|
583
|
+
warnings.append("scene.frame_start is unavailable (frame API missing)")
|
|
584
|
+
|
|
585
|
+
capabilities = {
|
|
586
|
+
# --- identity -------------------------------------------------------------------
|
|
587
|
+
"blenderVersion": identity["blenderVersion"],
|
|
588
|
+
"blenderVersionTuple": identity["blenderVersionTuple"],
|
|
589
|
+
"pythonVersion": identity["pythonVersion"],
|
|
590
|
+
"buildHash": identity["buildHash"],
|
|
591
|
+
"binaryPath": identity["binaryPath"],
|
|
592
|
+
"hostPlatform": host_platform,
|
|
593
|
+
# --- render engines (behavioral, authoritative) ---------------------------------
|
|
594
|
+
"renderEngines": render_engines,
|
|
595
|
+
"bestAvailableEngine": best_available,
|
|
596
|
+
"renderEngineDiagnostics": {
|
|
597
|
+
# Informational ONLY -- must never be used to decide engine availability.
|
|
598
|
+
"engineEnumItemsInformational": engine_enum_diagnostic,
|
|
599
|
+
"note": (
|
|
600
|
+
"engineEnumItemsInformational is not authoritative: in this build the enum "
|
|
601
|
+
"reports fewer engines than are actually assignable. Use renderEngines."
|
|
602
|
+
),
|
|
603
|
+
"preAddonEnableProbe": pre_enable_results,
|
|
604
|
+
"cyclesAddon": cycles_addon,
|
|
605
|
+
"originalEngine": original_engine_after,
|
|
606
|
+
"originalEngineRestored": restored_ok,
|
|
607
|
+
"behaviorallyAvailable": behaviorally_available,
|
|
608
|
+
},
|
|
609
|
+
# --- GPU ------------------------------------------------------------------------
|
|
610
|
+
"gpuDevices": gpu_devices,
|
|
611
|
+
# --- operators ------------------------------------------------------------------
|
|
612
|
+
"exportFormats": formats["exportFormats"],
|
|
613
|
+
"importFormats": formats["importFormats"],
|
|
614
|
+
"formatDiagnostics": {
|
|
615
|
+
"exportUnregisteredButPresent": formats["exportUnregistered"],
|
|
616
|
+
"importUnregisteredButPresent": formats["importUnregistered"],
|
|
617
|
+
"errors": formats["errors"],
|
|
618
|
+
},
|
|
619
|
+
# --- render proof ---------------------------------------------------------------
|
|
620
|
+
"renderSmokeTest": render_smoke_test,
|
|
621
|
+
"cyclesSmokeTest": cycles_smoke_test,
|
|
622
|
+
"renderProbeSize": {"width": PROBE_WIDTH, "height": PROBE_HEIGHT},
|
|
623
|
+
# --- lightweight APIs -----------------------------------------------------------
|
|
624
|
+
"textBlockApi": text_block_api,
|
|
625
|
+
"frameApi": frame_api,
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return capabilities, warnings
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# --------------------------------------------------------------------------------------
|
|
632
|
+
# Entry point
|
|
633
|
+
# --------------------------------------------------------------------------------------
|