@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
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepblend/dsh-blender-provider-local",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.1.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/pearjelly/deep-blend.git"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@deepseek-ai/cordis": ">=4.0.0 <5",
|
|
23
|
+
"@deepseek-ai/schemastery": ">=3.18.0 <4"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""DeepBlend Blender bootstrap -- the batch action dispatcher.
|
|
4
|
+
|
|
5
|
+
CONTRACT
|
|
6
|
+
--------
|
|
7
|
+
Executed by the Blender binary itself, headlessly, as::
|
|
8
|
+
|
|
9
|
+
Blender --background --factory-startup --python bootstrap.py -- \\
|
|
10
|
+
--request <path/to/request.json> --result <path/to/result.json> \\
|
|
11
|
+
[--scene-spec <spec.json>] [--blend <in.blend>] [--output-blend <out.blend>] \\
|
|
12
|
+
[--output <image.png>]
|
|
13
|
+
|
|
14
|
+
``--factory-startup`` guarantees a pristine configuration, so a compiled scene
|
|
15
|
+
describes the SceneSpec rather than whatever state a user left behind.
|
|
16
|
+
``--background`` means no UI and no window.
|
|
17
|
+
|
|
18
|
+
The result is communicated **only** by writing one JSON document to ``--result``.
|
|
19
|
+
Nothing is ever printed to stdout as a result channel: Blender writes its own
|
|
20
|
+
unstructured banners and add-on chatter there, and a caller that scrapes stdout
|
|
21
|
+
is parsing around noise. Progress lines ARE emitted on stdout as one JSON object
|
|
22
|
+
per line (``{"type":"progress",...}``) because SPEC §9.3 asks for JSONL progress;
|
|
23
|
+
they are advisory and never authoritative.
|
|
24
|
+
|
|
25
|
+
The result file is written **atomically** and on *every* exit path — success,
|
|
26
|
+
protocol failure, bad arguments, a refused action, and an unexpected internal
|
|
27
|
+
exception. A caller may therefore always expect a parseable envelope, and must
|
|
28
|
+
still check the exit code, which is non-zero whenever ``status != "success"``.
|
|
29
|
+
|
|
30
|
+
ACTIONS (M3)
|
|
31
|
+
------------
|
|
32
|
+
``render_frames`` an explicit LIST of frames of one camera out of one checkpoint,
|
|
33
|
+
at a named SceneSpec render profile, into a persistent
|
|
34
|
+
directory. Long-running and resumable: the host passes only
|
|
35
|
+
the frames the frame ledger says are missing.
|
|
36
|
+
|
|
37
|
+
ACTIONS (M2)
|
|
38
|
+
------------
|
|
39
|
+
``render_preview`` one frame from one camera out of a checkpoint ``.blend``.
|
|
40
|
+
``render_views`` a PLAN of (camera, frame) views out of one checkpoint, in one
|
|
41
|
+
process, each carrying its deterministic measurements.
|
|
42
|
+
|
|
43
|
+
ACTIONS (M0)
|
|
44
|
+
------------
|
|
45
|
+
``get_capabilities`` behavioral capability probe (M0; moved to its own module).
|
|
46
|
+
``compile_scene`` SceneSpec -> real Blender scene -> ``.blend`` checkpoint,
|
|
47
|
+
with technical validation of the result.
|
|
48
|
+
|
|
49
|
+
WHY ONE DISPATCHER AND NOT THREE SCRIPTS
|
|
50
|
+
----------------------------------------
|
|
51
|
+
Blender's startup cost dominates every one of these actions, and the three share
|
|
52
|
+
the same argument parsing, error classification and result-writing rules. Three
|
|
53
|
+
scripts would mean three copies of the contract, and the copy that drifts is
|
|
54
|
+
always the error path — which is the one nobody exercises until it matters.
|
|
55
|
+
|
|
56
|
+
SECURITY BOUNDARY
|
|
57
|
+
-----------------
|
|
58
|
+
This file is a trust boundary between the Node.js host process and an embedded
|
|
59
|
+
CPython interpreter with full Blender API access. It reads only the paths it is
|
|
60
|
+
given, launches no other process, touches no network, and writes only to the
|
|
61
|
+
paths the caller supplied. It never evaluates caller-supplied Python: the
|
|
62
|
+
``request.json`` document names an ACTION, and every action's behaviour is fixed
|
|
63
|
+
in this repository. The SceneSpec is data, and the compiler treats it as data —
|
|
64
|
+
there is no path by which a spec value becomes code.
|
|
65
|
+
|
|
66
|
+
Standard library plus ``bpy``/``addon_utils`` (both shipped inside Blender) only.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
import json
|
|
70
|
+
import os
|
|
71
|
+
import sys
|
|
72
|
+
import time
|
|
73
|
+
import traceback
|
|
74
|
+
|
|
75
|
+
# --------------------------------------------------------------------------------------
|
|
76
|
+
# Make the sibling modules importable
|
|
77
|
+
# --------------------------------------------------------------------------------------
|
|
78
|
+
# Blender executes this file by path, so its own directory is not automatically on
|
|
79
|
+
# sys.path. Adding it explicitly is what lets the runtime be split across modules
|
|
80
|
+
# instead of one file that has to be read top to bottom.
|
|
81
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
82
|
+
if _HERE not in sys.path:
|
|
83
|
+
sys.path.insert(0, _HERE)
|
|
84
|
+
|
|
85
|
+
from deepblend_util import ( # noqa: E402 (path set up above)
|
|
86
|
+
PROTOCOL_VERSION,
|
|
87
|
+
ActionError,
|
|
88
|
+
eprint,
|
|
89
|
+
error_text,
|
|
90
|
+
parse_args,
|
|
91
|
+
read_json,
|
|
92
|
+
report_progress,
|
|
93
|
+
write_json_atomic,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
#: Actions this dispatcher accepts. Anything else is refused with a stable code
|
|
97
|
+
#: rather than silently doing nothing — a silently no-op action is indistinguishable
|
|
98
|
+
#: from a successful one that produced no output.
|
|
99
|
+
SUPPORTED_ACTIONS = (
|
|
100
|
+
"get_capabilities",
|
|
101
|
+
"compile_scene",
|
|
102
|
+
"render_preview",
|
|
103
|
+
"render_views",
|
|
104
|
+
"render_frames",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Process exit codes. 0 is reserved for a successfully written success envelope.
|
|
108
|
+
EXIT_OK = 0
|
|
109
|
+
EXIT_BAD_ARGS = 2
|
|
110
|
+
EXIT_REQUEST_UNREADABLE = 3
|
|
111
|
+
EXIT_REQUEST_INVALID = 4
|
|
112
|
+
EXIT_PROTOCOL_MISMATCH = 5
|
|
113
|
+
EXIT_UNSUPPORTED_ACTION = 6
|
|
114
|
+
EXIT_ACTION_FAILED = 7
|
|
115
|
+
|
|
116
|
+
#: Action failure code -> process exit code, so a caller shelling out can branch
|
|
117
|
+
#: without parsing the envelope.
|
|
118
|
+
EXIT_CODE_BY_ERROR = {
|
|
119
|
+
"BLENDER_UNSUPPORTED_ACTION": EXIT_UNSUPPORTED_ACTION,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------------------
|
|
124
|
+
# Envelope
|
|
125
|
+
# --------------------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_envelope(job_id, action, result, error, warnings, notices):
|
|
129
|
+
"""Assemble the result document written to the caller's ``--result`` path.
|
|
130
|
+
|
|
131
|
+
``capabilities`` keeps its M0 name and meaning for ``get_capabilities`` so the
|
|
132
|
+
existing provider code and tests keep working unchanged; new actions carry
|
|
133
|
+
their payload under ``result``.
|
|
134
|
+
"""
|
|
135
|
+
envelope = {
|
|
136
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
137
|
+
"jobId": job_id,
|
|
138
|
+
"action": action,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if error is not None:
|
|
142
|
+
envelope["status"] = "error"
|
|
143
|
+
envelope["error"] = {
|
|
144
|
+
"code": error.get("code", "BLENDER_SCRIPT_ERROR"),
|
|
145
|
+
"message": error.get("message", "unspecified failure"),
|
|
146
|
+
}
|
|
147
|
+
if error.get("detail") is not None:
|
|
148
|
+
envelope["error"]["detail"] = error["detail"]
|
|
149
|
+
envelope["capabilities"] = None
|
|
150
|
+
envelope["result"] = None
|
|
151
|
+
envelope["warnings"] = list(warnings)
|
|
152
|
+
envelope["notices"] = list(notices)
|
|
153
|
+
return envelope
|
|
154
|
+
|
|
155
|
+
envelope["status"] = "success"
|
|
156
|
+
envelope["error"] = None
|
|
157
|
+
# `capabilities` is non-null ONLY for the action that produces it. A stub
|
|
158
|
+
# empty report here would be worse than null: it would look like a probe that
|
|
159
|
+
# found nothing.
|
|
160
|
+
capabilities = result.get("capabilities") if isinstance(result, dict) else None
|
|
161
|
+
envelope["capabilities"] = capabilities
|
|
162
|
+
envelope["result"] = None if capabilities is not None else result
|
|
163
|
+
envelope["warnings"] = list(warnings)
|
|
164
|
+
envelope["notices"] = list(notices)
|
|
165
|
+
return envelope
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------------------
|
|
169
|
+
# Actions
|
|
170
|
+
# --------------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def action_get_capabilities(request, options):
|
|
174
|
+
"""Behavioral capability probe (M0)."""
|
|
175
|
+
from deepblend_capabilities import collect_capabilities
|
|
176
|
+
|
|
177
|
+
report_progress("probe", 5)
|
|
178
|
+
capabilities, warnings = collect_capabilities()
|
|
179
|
+
report_progress("probe_done", 95)
|
|
180
|
+
return {"capabilities": capabilities}, warnings, []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def action_compile_scene(request, options):
|
|
184
|
+
"""Compile a SceneSpec into a Blender scene and save a checkpoint."""
|
|
185
|
+
from deepblend_scene import build_scene, describe_objects
|
|
186
|
+
from deepblend_util import Guard
|
|
187
|
+
from deepblend_validate import validate_scene
|
|
188
|
+
import bpy
|
|
189
|
+
|
|
190
|
+
spec_path = options.get("scene_spec")
|
|
191
|
+
output_blend = options.get("output_blend")
|
|
192
|
+
if not spec_path:
|
|
193
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "compile_scene requires --scene-spec <path>")
|
|
194
|
+
if not output_blend:
|
|
195
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "compile_scene requires --output-blend <path>")
|
|
196
|
+
|
|
197
|
+
if not os.path.isfile(spec_path):
|
|
198
|
+
raise ActionError(
|
|
199
|
+
"BLENDER_SCRIPT_ERROR",
|
|
200
|
+
'the SceneSpec document does not exist at "%s"' % (spec_path,),
|
|
201
|
+
{"sceneSpec": spec_path},
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
spec = read_json(spec_path)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
raise ActionError(
|
|
207
|
+
"BLENDER_SCRIPT_ERROR",
|
|
208
|
+
'could not read the SceneSpec at "%s": %s' % (spec_path, error_text(exc)),
|
|
209
|
+
{"sceneSpec": spec_path},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
guard = Guard()
|
|
213
|
+
build_options = {
|
|
214
|
+
"profile": options.get("profile"),
|
|
215
|
+
"project_root": options.get("project_root"),
|
|
216
|
+
}
|
|
217
|
+
report = build_scene(spec, build_options, guard)
|
|
218
|
+
|
|
219
|
+
report_progress("validate", 92)
|
|
220
|
+
validation = validate_scene(bpy.context.scene, spec, guard)
|
|
221
|
+
|
|
222
|
+
payload = {
|
|
223
|
+
"sceneSpec": spec_path,
|
|
224
|
+
"objects": describe_objects(),
|
|
225
|
+
"actions": report["actions"],
|
|
226
|
+
"renderConfig": report["renderConfig"],
|
|
227
|
+
"engine": report["engine"],
|
|
228
|
+
"requestedEngine": report["requestedEngine"],
|
|
229
|
+
"profileName": report["profileName"],
|
|
230
|
+
"validation": validation,
|
|
231
|
+
"sceneFingerprint": scene_fingerprint(spec, describe_objects()),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# The checkpoint is written even when validation reported errors: the caller
|
|
235
|
+
# decides whether to publish, and a diagnostic blend is far more useful than
|
|
236
|
+
# no blend at all when a compile went wrong. Nothing is published by THIS
|
|
237
|
+
# process either way — publication is the host's atomic rename.
|
|
238
|
+
if validation["ok"] or options.get("save_on_failure") == "true":
|
|
239
|
+
report_progress("save_checkpoint", 96)
|
|
240
|
+
write_checkpoint(output_blend)
|
|
241
|
+
payload["outputBlend"] = output_blend
|
|
242
|
+
payload["outputBytes"] = os.path.getsize(output_blend)
|
|
243
|
+
else:
|
|
244
|
+
payload["outputBlend"] = None
|
|
245
|
+
payload["checkpointSkipped"] = "validation reported errors"
|
|
246
|
+
guard.note(
|
|
247
|
+
"SCENE_VALIDATION_FAILED",
|
|
248
|
+
"checkpoint not written because technical validation reported %d error(s)"
|
|
249
|
+
% (len(validation["errors"]),),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if not validation["ok"]:
|
|
253
|
+
raise ActionError(
|
|
254
|
+
"SCENE_VALIDATION_FAILED",
|
|
255
|
+
"the compiled scene failed technical validation: %s"
|
|
256
|
+
% ("; ".join(entry["message"] for entry in validation["errors"][:3]),),
|
|
257
|
+
{"validation": validation},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return payload, guard.warnings, guard.notices
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def action_render_preview(request, options):
|
|
264
|
+
"""Render one preview frame out of a checkpoint ``.blend``."""
|
|
265
|
+
from deepblend_render import render_preview
|
|
266
|
+
from deepblend_util import Guard
|
|
267
|
+
|
|
268
|
+
checkpoint = options.get("blend")
|
|
269
|
+
output = options.get("output")
|
|
270
|
+
if not checkpoint:
|
|
271
|
+
raise ActionError("REVISION_CHECKPOINT_MISSING", "render_preview requires --blend <checkpoint.blend>")
|
|
272
|
+
if not output:
|
|
273
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "render_preview requires --output <image.png>")
|
|
274
|
+
|
|
275
|
+
guard = Guard()
|
|
276
|
+
overrides = {
|
|
277
|
+
"engine": options.get("engine"),
|
|
278
|
+
"width": _as_int(options.get("width")),
|
|
279
|
+
"height": _as_int(options.get("height")),
|
|
280
|
+
"samples": _as_int(options.get("samples")),
|
|
281
|
+
"frame": _as_int(options.get("frame")),
|
|
282
|
+
}
|
|
283
|
+
payload = render_preview(
|
|
284
|
+
options=overrides,
|
|
285
|
+
guard=guard,
|
|
286
|
+
checkpoint=checkpoint,
|
|
287
|
+
output=output,
|
|
288
|
+
camera_id=options.get("camera"),
|
|
289
|
+
)
|
|
290
|
+
return payload, guard.warnings, guard.notices
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def action_render_views(request, options):
|
|
294
|
+
"""Render a plan of views out of one checkpoint, measuring each one.
|
|
295
|
+
|
|
296
|
+
The plan travels as a FILE rather than as many ``--flag`` pairs because a plan
|
|
297
|
+
is a list of records, and a list of records is the one shape a flat argument
|
|
298
|
+
list expresses badly: an N-view plan would need N parallel arrays that can
|
|
299
|
+
disagree in length. As a document it is validated once, at the top, and a
|
|
300
|
+
malformed plan is one error rather than a partially-rendered result.
|
|
301
|
+
"""
|
|
302
|
+
from deepblend_util import Guard
|
|
303
|
+
from deepblend_views import render_views
|
|
304
|
+
|
|
305
|
+
plan_path = options.get("views")
|
|
306
|
+
if not plan_path:
|
|
307
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "render_views requires --views <plan.json>")
|
|
308
|
+
if not os.path.isfile(plan_path):
|
|
309
|
+
raise ActionError(
|
|
310
|
+
"BLENDER_SCRIPT_ERROR",
|
|
311
|
+
'the view plan does not exist at "%s"' % (plan_path,),
|
|
312
|
+
{"views": plan_path},
|
|
313
|
+
)
|
|
314
|
+
try:
|
|
315
|
+
plan = read_json(plan_path)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
raise ActionError(
|
|
318
|
+
"BLENDER_SCRIPT_ERROR",
|
|
319
|
+
'could not read the view plan at "%s": %s' % (plan_path, error_text(exc)),
|
|
320
|
+
{"views": plan_path},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
guard = Guard()
|
|
324
|
+
payload = render_views({"views": plan}, guard)
|
|
325
|
+
return payload, guard.warnings, guard.notices
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def action_render_frames(request, options):
|
|
329
|
+
"""Render an explicit frame list at a named render profile (M3).
|
|
330
|
+
|
|
331
|
+
The plan travels as a FILE for the same reason the view plan does, and the
|
|
332
|
+
event journal is a SECOND file because of a fact about the runtime that only
|
|
333
|
+
shows up after a crash: the host's subprocess collector is an in-memory buffer
|
|
334
|
+
inside the host process, so every progress line this process printed is gone
|
|
335
|
+
the moment the host dies. The journal is flushed and fsynced per line so the
|
|
336
|
+
frames that landed before a kill -9 are still knowable.
|
|
337
|
+
"""
|
|
338
|
+
from deepblend_frames import render_frames
|
|
339
|
+
from deepblend_util import Guard
|
|
340
|
+
|
|
341
|
+
plan_path = options.get("frames")
|
|
342
|
+
if not plan_path:
|
|
343
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "render_frames requires --frames <plan.json>")
|
|
344
|
+
if not os.path.isfile(plan_path):
|
|
345
|
+
raise ActionError(
|
|
346
|
+
"BLENDER_SCRIPT_ERROR",
|
|
347
|
+
'the frame plan does not exist at "%s"' % (plan_path,),
|
|
348
|
+
{"frames": plan_path},
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
plan = read_json(plan_path)
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
raise ActionError(
|
|
354
|
+
"BLENDER_SCRIPT_ERROR",
|
|
355
|
+
'could not read the frame plan at "%s": %s' % (plan_path, error_text(exc)),
|
|
356
|
+
{"frames": plan_path},
|
|
357
|
+
)
|
|
358
|
+
if not isinstance(plan, dict):
|
|
359
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "the frame plan must be a JSON object")
|
|
360
|
+
# The journal path is a bootstrap argument, not a plan field: it identifies
|
|
361
|
+
# where THIS invocation reports, and a plan is a durable document that a
|
|
362
|
+
# resumed attempt rewrites with a new one.
|
|
363
|
+
plan["events"] = options.get("events") or plan.get("events")
|
|
364
|
+
|
|
365
|
+
guard = Guard()
|
|
366
|
+
payload = render_frames(plan, guard)
|
|
367
|
+
return payload, guard.warnings, guard.notices
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def write_process_identity(path, job_id, action, attempt_token=None):
|
|
371
|
+
"""Record this process's own pid, atomically, before any Blender work.
|
|
372
|
+
|
|
373
|
+
WHY A FILE AND WHY FROM THE CHILD
|
|
374
|
+
---------------------------------
|
|
375
|
+
`ctx.subprocess.spawn` returns a handle of `{stdin, stdout, stderr, collected,
|
|
376
|
+
done, terminate, waitForExit}` — measured against the installed runtime, it
|
|
377
|
+
exposes no pid. So the only way the HOST can learn which process is rendering
|
|
378
|
+
is for the process to say so, and the only way that fact survives a crash of
|
|
379
|
+
the host is for it to be on disk.
|
|
380
|
+
|
|
381
|
+
It is written first, before bpy is touched, so a render killed during scene
|
|
382
|
+
load is still attributable to a pid. On macOS the deepest subprocess
|
|
383
|
+
containment available is a process GROUP (`detached: true` makes the child its
|
|
384
|
+
own group leader, so pgid == pid), which is why the recovery path signals
|
|
385
|
+
`-pid` rather than `pid`: an orphaned Blender may have children of its own.
|
|
386
|
+
"""
|
|
387
|
+
if not path:
|
|
388
|
+
return
|
|
389
|
+
try:
|
|
390
|
+
write_json_atomic(path, {
|
|
391
|
+
"schemaVersion": "deepblend.process/v1",
|
|
392
|
+
"jobId": job_id,
|
|
393
|
+
"action": action,
|
|
394
|
+
"pid": os.getpid(),
|
|
395
|
+
"ppid": os.getppid(),
|
|
396
|
+
"processGroupId": os.getpgid(0),
|
|
397
|
+
"startedAt": time.time(),
|
|
398
|
+
"attemptToken": attempt_token,
|
|
399
|
+
})
|
|
400
|
+
except Exception as exc: # noqa: BLE001
|
|
401
|
+
eprint("could not write the process identity document: %s" % (error_text(exc),))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _attempt_token_from_plan(action, options):
|
|
405
|
+
"""The attempt token, when the action's input document carries one.
|
|
406
|
+
|
|
407
|
+
Only `render_frames` has a plan file; every other action answers None, and the
|
|
408
|
+
identity document simply records no token. Read here rather than threaded
|
|
409
|
+
through every action because the token belongs to the PROCESS, not to the work.
|
|
410
|
+
"""
|
|
411
|
+
if action != "render_frames":
|
|
412
|
+
return None
|
|
413
|
+
plan_path = options.get("frames")
|
|
414
|
+
if not plan_path or not os.path.isfile(plan_path):
|
|
415
|
+
return None
|
|
416
|
+
try:
|
|
417
|
+
return read_json(plan_path).get("attemptToken")
|
|
418
|
+
except Exception:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _as_int(value):
|
|
423
|
+
"""Parse an optional integer argument, or return ``None``."""
|
|
424
|
+
if value is None or value == "":
|
|
425
|
+
return None
|
|
426
|
+
try:
|
|
427
|
+
return int(value)
|
|
428
|
+
except (TypeError, ValueError):
|
|
429
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", '"%s" is not an integer' % (value,))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
ACTIONS = {
|
|
433
|
+
"get_capabilities": action_get_capabilities,
|
|
434
|
+
"compile_scene": action_compile_scene,
|
|
435
|
+
"render_preview": action_render_preview,
|
|
436
|
+
"render_views": action_render_views,
|
|
437
|
+
"render_frames": action_render_frames,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def write_checkpoint(path):
|
|
442
|
+
"""Save the current file as the compiled checkpoint.
|
|
443
|
+
|
|
444
|
+
``compress=False`` trades size for speed, which is the right way round for a
|
|
445
|
+
checkpoint that is about to be re-opened by the renderer in the next process.
|
|
446
|
+
The ``.blend1`` backup is suppressed because a checkpoint is an immutable
|
|
447
|
+
published artifact: a stray previous version beside it would make "the
|
|
448
|
+
checkpoint" ambiguous.
|
|
449
|
+
"""
|
|
450
|
+
import bpy
|
|
451
|
+
|
|
452
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
453
|
+
try:
|
|
454
|
+
bpy.context.preferences.filepaths.save_version = 0
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
bpy.ops.wm.save_as_mainfile(filepath=path, compress=False, copy=False)
|
|
458
|
+
# `save_as_mainfile` can still emit a backup when the preference is read-only;
|
|
459
|
+
# removing it is cheaper than trusting the preference to have taken effect.
|
|
460
|
+
backup = path + "1"
|
|
461
|
+
if os.path.isfile(backup):
|
|
462
|
+
try:
|
|
463
|
+
os.unlink(backup)
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def scene_fingerprint(spec, objects):
|
|
469
|
+
"""A cheap structural fingerprint of the compiled scene.
|
|
470
|
+
|
|
471
|
+
Used by the revision manifest so a recompile that produced a structurally
|
|
472
|
+
different scene is visible even when the SceneSpec digest is unchanged, which
|
|
473
|
+
is exactly the case a compiler regression would show up in.
|
|
474
|
+
"""
|
|
475
|
+
counts = {"MESH": 0, "LIGHT": 0, "CAMERA": 0, "EMPTY": 0}
|
|
476
|
+
vertices = 0
|
|
477
|
+
polygons = 0
|
|
478
|
+
for obj in objects:
|
|
479
|
+
counts[obj["type"]] = counts.get(obj["type"], 0) + 1
|
|
480
|
+
vertices += obj.get("vertexCount", 0) or 0
|
|
481
|
+
polygons += obj.get("polygonCount", 0) or 0
|
|
482
|
+
return {
|
|
483
|
+
"specSchemaVersion": spec.get("schemaVersion"),
|
|
484
|
+
"objectCounts": counts,
|
|
485
|
+
"totalVertices": vertices,
|
|
486
|
+
"totalPolygons": polygons,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# --------------------------------------------------------------------------------------
|
|
491
|
+
# Entry point
|
|
492
|
+
# --------------------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def main():
|
|
496
|
+
argv = sys.argv
|
|
497
|
+
options, unknown = parse_args(
|
|
498
|
+
argv,
|
|
499
|
+
(
|
|
500
|
+
"--request", "--result", "--scene-spec", "--blend", "--output-blend",
|
|
501
|
+
"--output", "--camera", "--engine", "--width", "--height", "--samples",
|
|
502
|
+
"--frame", "--profile", "--project-root", "--save-on-failure", "--views",
|
|
503
|
+
"--frames", "--events", "--proc",
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
request_path = options.get("request")
|
|
508
|
+
result_path = options.get("result")
|
|
509
|
+
if result_path is None:
|
|
510
|
+
eprint("no --result path was supplied; nothing to write")
|
|
511
|
+
return EXIT_BAD_ARGS
|
|
512
|
+
|
|
513
|
+
job_id = None
|
|
514
|
+
action = None
|
|
515
|
+
|
|
516
|
+
def finish(envelope, exit_code):
|
|
517
|
+
try:
|
|
518
|
+
write_json_atomic(result_path, envelope)
|
|
519
|
+
except Exception as exc:
|
|
520
|
+
eprint("could not write the result document: %s" % (error_text(exc),))
|
|
521
|
+
return EXIT_BAD_ARGS
|
|
522
|
+
return exit_code
|
|
523
|
+
|
|
524
|
+
if request_path is None:
|
|
525
|
+
envelope = build_envelope(
|
|
526
|
+
None, None, None,
|
|
527
|
+
{"code": "BLENDER_SCRIPT_ERROR", "message": "no --request path was supplied"},
|
|
528
|
+
[], [],
|
|
529
|
+
)
|
|
530
|
+
return finish(envelope, EXIT_BAD_ARGS)
|
|
531
|
+
|
|
532
|
+
if unknown:
|
|
533
|
+
envelope = build_envelope(
|
|
534
|
+
None, None, None,
|
|
535
|
+
{"code": "BLENDER_SCRIPT_ERROR", "message": "unrecognised arguments: %s" % (", ".join(unknown),)},
|
|
536
|
+
[], [],
|
|
537
|
+
)
|
|
538
|
+
return finish(envelope, EXIT_BAD_ARGS)
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
request = read_json(request_path)
|
|
542
|
+
except Exception as exc:
|
|
543
|
+
envelope = build_envelope(
|
|
544
|
+
None, None, None,
|
|
545
|
+
{"code": "BLENDER_SCRIPT_ERROR", "message": 'could not read the request at "%s": %s' % (request_path, error_text(exc))},
|
|
546
|
+
[], [],
|
|
547
|
+
)
|
|
548
|
+
return finish(envelope, EXIT_REQUEST_UNREADABLE)
|
|
549
|
+
|
|
550
|
+
if not isinstance(request, dict):
|
|
551
|
+
envelope = build_envelope(
|
|
552
|
+
None, None, None,
|
|
553
|
+
{"code": "BLENDER_SCRIPT_ERROR", "message": "the request document must be a JSON object"},
|
|
554
|
+
[], [],
|
|
555
|
+
)
|
|
556
|
+
return finish(envelope, EXIT_REQUEST_INVALID)
|
|
557
|
+
|
|
558
|
+
job_id = request.get("jobId")
|
|
559
|
+
action = request.get("action")
|
|
560
|
+
|
|
561
|
+
if request.get("protocolVersion") != PROTOCOL_VERSION:
|
|
562
|
+
envelope = build_envelope(
|
|
563
|
+
job_id, action, None,
|
|
564
|
+
{
|
|
565
|
+
"code": "BLENDER_PROTOCOL_VERSION_MISMATCH",
|
|
566
|
+
"message": 'request declares protocolVersion "%s", expected "%s"'
|
|
567
|
+
% (request.get("protocolVersion"), PROTOCOL_VERSION),
|
|
568
|
+
},
|
|
569
|
+
[], [],
|
|
570
|
+
)
|
|
571
|
+
return finish(envelope, EXIT_PROTOCOL_MISMATCH)
|
|
572
|
+
|
|
573
|
+
handler = ACTIONS.get(action)
|
|
574
|
+
if handler is None:
|
|
575
|
+
envelope = build_envelope(
|
|
576
|
+
job_id, action, None,
|
|
577
|
+
{
|
|
578
|
+
"code": "BLENDER_UNSUPPORTED_ACTION",
|
|
579
|
+
"message": 'bootstrap.py does not implement action "%s"; supported actions are %s'
|
|
580
|
+
% (action, ", ".join(SUPPORTED_ACTIONS)),
|
|
581
|
+
"detail": {"action": action, "supported": list(SUPPORTED_ACTIONS)},
|
|
582
|
+
},
|
|
583
|
+
[], [],
|
|
584
|
+
)
|
|
585
|
+
return finish(envelope, EXIT_UNSUPPORTED_ACTION)
|
|
586
|
+
|
|
587
|
+
eprint("action=%s job=%s" % (action, job_id))
|
|
588
|
+
# Claim this process on disk BEFORE the action runs. A render killed during
|
|
589
|
+
# scene load must still be attributable to a pid, because the recovery path's
|
|
590
|
+
# only way to stop an orphan is the pid this line recorded (see the docstring
|
|
591
|
+
# on `write_process_identity`).
|
|
592
|
+
write_process_identity(
|
|
593
|
+
options.get("proc"),
|
|
594
|
+
job_id,
|
|
595
|
+
action,
|
|
596
|
+
attempt_token=_attempt_token_from_plan(action, options),
|
|
597
|
+
)
|
|
598
|
+
try:
|
|
599
|
+
payload, warnings, notices = handler(request, options)
|
|
600
|
+
envelope = build_envelope(job_id, action, payload, None, warnings, notices)
|
|
601
|
+
return finish(envelope, EXIT_OK)
|
|
602
|
+
except ActionError as exc:
|
|
603
|
+
envelope = build_envelope(
|
|
604
|
+
job_id, action, None,
|
|
605
|
+
{"code": exc.code, "message": exc.message, "detail": exc.detail},
|
|
606
|
+
[], [],
|
|
607
|
+
)
|
|
608
|
+
return finish(envelope, EXIT_CODE_BY_ERROR.get(exc.code, EXIT_ACTION_FAILED))
|
|
609
|
+
except Exception as exc: # noqa: BLE001 - the boundary must catch everything
|
|
610
|
+
trace = traceback.format_exc()
|
|
611
|
+
eprint(trace)
|
|
612
|
+
envelope = build_envelope(
|
|
613
|
+
job_id, action, None,
|
|
614
|
+
{
|
|
615
|
+
"code": "BLENDER_SCRIPT_ERROR",
|
|
616
|
+
"message": error_text(exc),
|
|
617
|
+
"detail": {"traceback": trace},
|
|
618
|
+
},
|
|
619
|
+
[], [],
|
|
620
|
+
)
|
|
621
|
+
return finish(envelope, EXIT_ACTION_FAILED)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
if __name__ == "__main__":
|
|
625
|
+
sys.exit(main())
|