@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/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
+ }
@@ -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())