@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.
@@ -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
+ # --------------------------------------------------------------------------------------