@deepblend/dsh-blender-provider-local 0.2.2 → 0.2.4

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 CHANGED
@@ -154,6 +154,211 @@ export const ProviderConfig = z.object({
154
154
  * @property {string} workingDirectory
155
155
  */
156
156
 
157
+ /**
158
+ * One live Blender process, serving requests until it is closed (SPEC §20 M6).
159
+ *
160
+ * The class exists so the SESSION's own lifecycle — the handshake, the per-request deadline, the
161
+ * "the process is gone" answer, and the shutdown — is one object with one state, rather than a set of
162
+ * promises the provider keeps in a map. Everything about an individual action still belongs to
163
+ * `bootstrap.py`'s `ACTIONS`; this only carries the conversation.
164
+ *
165
+ * LINE PROTOCOL. bootstrap.py writes one JSON document per line to stdout, and it already did before
166
+ * sessions existed: `report_progress` sends `{"type": "progress", …}` (SPEC §9.3) and a session sends
167
+ * `{"kind": "ready"|"result"|"bye", …}`. The two are distinguishable BY PARSING rather than by
168
+ * pattern-matching prose, which is the property that made progress lines safe to add in the first
169
+ * place.
170
+ */
171
+ class BlenderSession {
172
+ /**
173
+ * @param {{ handle: object, directory: string, jobId: string, timeoutMs: number, onClose: () => void }} input
174
+ */
175
+ constructor(input) {
176
+ this.handle = input.handle
177
+ this.directory = input.directory
178
+ this.jobId = input.jobId
179
+ this.timeoutMs = input.timeoutMs
180
+ this.onClose = input.onClose
181
+ // THE HANDLE HAS NO `pid`, MEASURED: its keys are `stdin`, `stdout`, `stderr`, `collected`,
182
+ // `done`, `terminate`, `terminateForHostExit`, `waitForExit`. So the pid comes from the process
183
+ // itself, in the `ready` line bootstrap.py opens with — which is better evidence anyway, because
184
+ // it is the pid the script believes it is rather than the one the spawn returned.
185
+ this.pid = null
186
+ /** @type {Map<string, {resolve: Function, reject: Function, timer: object}>} */
187
+ this.waiting = new Map()
188
+ this.progress = []
189
+ /** Whether the process said goodbye before it went: the difference between a shutdown and a death. */
190
+ this.saidBye = false
191
+ this.stderr = ''
192
+ this.closed = false
193
+ this.readyPromise = null
194
+ this.readyResolve = null
195
+ this.readyReject = null
196
+ this._read()
197
+ }
198
+
199
+ /** Resolve when bootstrap.py has announced itself, reject if the process dies first. */
200
+ ready() {
201
+ if (this.readyPromise === null) {
202
+ this.readyPromise = new Promise((resolve, reject) => {
203
+ this.readyResolve = resolve
204
+ this.readyReject = reject
205
+ })
206
+ }
207
+ return this.readyPromise
208
+ }
209
+
210
+ /** Read stdout line by line for the life of the process. */
211
+ _read() {
212
+ const stream = this.handle?.stdout
213
+ if (stream === undefined || stream === null) {
214
+ this._fail(new BlenderError(BlenderErrorCode.SPAWN_FAILED, 'the session has no stdout stream to read'))
215
+ return
216
+ }
217
+ let buffer = ''
218
+ stream.on('data', chunk => {
219
+ buffer += chunk.toString('utf8')
220
+ let index = buffer.indexOf('\n')
221
+ while (index >= 0) {
222
+ const line = buffer.slice(0, index).trim()
223
+ buffer = buffer.slice(index + 1)
224
+ if (line.length > 0) this._line(line)
225
+ index = buffer.indexOf('\n')
226
+ }
227
+ })
228
+ this.handle?.stderr?.on?.('data', chunk => {
229
+ // Bounded: stderr is evidence for a failure, not a log to keep.
230
+ this.stderr = (this.stderr + chunk.toString('utf8')).slice(-8192)
231
+ })
232
+ // THE HANDLE'S EXIT SIGNAL IS A PROMISE, NOT AN EVENT (`done`), and it carries nothing — so the
233
+ // exit code is read from `waitForExit()` where one is needed, and the failure below says what is
234
+ // knowable: the session ended without answering.
235
+ this.handle?.done?.then?.(() => this._exited({}), () => this._exited({}))
236
+ }
237
+
238
+ _line(line) {
239
+ let document
240
+ try {
241
+ document = JSON.parse(line)
242
+ } catch {
243
+ // Blender itself prints to stdout; anything that is not a JSON document is not ours.
244
+ return
245
+ }
246
+ if (document?.type === 'progress') {
247
+ this.progress.push(document)
248
+ return
249
+ }
250
+ if (document?.kind === 'ready') {
251
+ if (typeof document.pid === 'number') this.pid = document.pid
252
+ this.readyResolve?.(this)
253
+ return
254
+ }
255
+ if (document?.kind === 'bye') {
256
+ this.saidBye = true
257
+ return
258
+ }
259
+ const jobId = document?.jobId
260
+ const pending = typeof jobId === 'string' ? this.waiting.get(jobId) : undefined
261
+ if (pending === undefined) return
262
+ clearTimeout(pending.timer)
263
+ this.waiting.delete(jobId)
264
+ pending.resolve(document)
265
+ }
266
+
267
+ _fail(error) {
268
+ this.readyReject?.(error)
269
+ for (const [, pending] of this.waiting) {
270
+ clearTimeout(pending.timer)
271
+ pending.reject(error)
272
+ }
273
+ this.waiting.clear()
274
+ }
275
+
276
+ _exited(info) {
277
+ if (this.closed) return
278
+ this.closed = true
279
+ const detail = this.stderr.trim().length > 0 ? ` stderr: ${this.stderr.trim().slice(-500)}` : ''
280
+ this._fail(new BlenderError(
281
+ BlenderErrorCode.NONZERO_EXIT,
282
+ `the Blender session (pid ${this.pid ?? 'unknown'}) ended before answering${info?.code === undefined ? '' : ` (exit ${info.code})`}.${detail}`,
283
+ { detail: { pid: this.pid, code: info?.code ?? null, signal: info?.signal ?? null } },
284
+ ))
285
+ }
286
+
287
+ /**
288
+ * Run one action in the live process.
289
+ *
290
+ * @param {{ action: string, payload?: object, jobId?: string }} request
291
+ * @param {{ signal?: AbortSignal }} [options]
292
+ * @returns {Promise<object>} the envelope bootstrap.py built for it.
293
+ */
294
+ async run(request, options = {}) {
295
+ if (this.closed) {
296
+ throw new BlenderError(
297
+ BlenderErrorCode.NONZERO_EXIT,
298
+ `the Blender session (pid ${this.pid ?? 'unknown'}) is closed, so "${request?.action}" was not sent.`,
299
+ )
300
+ }
301
+ const jobId = typeof request.jobId === 'string' && request.jobId.length > 0 ? request.jobId : `${this.jobId}-${this.waiting.size + 1}`
302
+ const document = {
303
+ protocolVersion: BLENDER_PROTOCOL_VERSION,
304
+ jobId,
305
+ action: request.action,
306
+ ...(request.payload !== undefined ? { payload: request.payload } : {}),
307
+ }
308
+
309
+ const answered = new Promise((resolve, reject) => {
310
+ const timer = setTimeout(() => {
311
+ this.waiting.delete(jobId)
312
+ reject(new BlenderError(
313
+ BlenderErrorCode.TIMEOUT,
314
+ `the Blender session did not answer "${request.action}" within ${this.timeoutMs} ms.`,
315
+ { detail: { jobId, action: request.action, pid: this.pid } },
316
+ ))
317
+ }, this.timeoutMs)
318
+ if (typeof timer.unref === 'function') timer.unref()
319
+ this.waiting.set(jobId, { resolve, reject, timer })
320
+ if (options.signal !== undefined) {
321
+ options.signal.addEventListener('abort', () => {
322
+ if (!this.waiting.has(jobId)) return
323
+ clearTimeout(timer)
324
+ this.waiting.delete(jobId)
325
+ reject(new BlenderError(
326
+ BlenderErrorCode.ABORTED,
327
+ `"${request.action}" was aborted before the Blender session answered.`,
328
+ { detail: { jobId, action: request.action, pid: this.pid } },
329
+ ))
330
+ }, { once: true })
331
+ }
332
+ })
333
+
334
+ this.handle.stdin.write(`${JSON.stringify(document)}\n`)
335
+ return answered
336
+ }
337
+
338
+ /** Ask the process to stop, and wait for it. Idempotent. */
339
+ async close() {
340
+ if (this.closed) return
341
+ try {
342
+ this.handle.stdin.write(`${JSON.stringify({ action: 'shutdown' })}\n`)
343
+ this.handle.stdin.end()
344
+ } catch {
345
+ // The process may already be gone; the wait below is what decides.
346
+ }
347
+ try {
348
+ // `waitForExit` is the handle's own way to wait, and it takes a deadline in milliseconds.
349
+ await this.handle.waitForExit?.(TERMINATE_GRACE_MS)
350
+ } catch {
351
+ // A process that had to be terminated is still a closed session.
352
+ }
353
+ this.closed = true
354
+ this._fail(new BlenderError(
355
+ BlenderErrorCode.ABORTED,
356
+ `the Blender session (pid ${this.pid ?? 'unknown'}) was closed by its caller.`,
357
+ ))
358
+ this.onClose()
359
+ }
360
+ }
361
+
157
362
  /**
158
363
  * Local Blender provider.
159
364
  *
@@ -419,6 +624,84 @@ export default class LocalBlenderRuntime extends Service {
419
624
  * }} [options]
420
625
  * @returns {Promise<BootstrapRunOutcome>}
421
626
  */
627
+ /**
628
+ * Open a LIVE SESSION: one Blender process that answers many requests (SPEC §20 M6).
629
+ *
630
+ * WHY THIS EXISTS. Every other method here is one process per operation —
631
+ * `blender --background --factory-startup --python bootstrap.py -- --request … --result …` —
632
+ * which loads the scene, does the thing, and exits. That is right for a render and wrong for a
633
+ * conversation: opening a scene costs seconds, and a caller that compiles, previews and compiles
634
+ * again pays it every time. MEASURED on this machine: `get_capabilities` alone is ~1.4 s of
635
+ * process start, and `compile_scene` on the demo project is dominated by scene load.
636
+ *
637
+ * WHAT IT IS NOT. This is the TRANSPORT half of the M6 item, not the item: the GUI attach — the
638
+ * user's own Blender, with the add-on, so they can watch it work — is the next thing on that list.
639
+ * What this establishes is what everything else rests on: one Blender, many operations, each one
640
+ * attributable to a pid and each one able to fail on its own.
641
+ *
642
+ * THE PROTOCOL is bootstrap.py's own, unchanged: the same request document shape, the same
643
+ * `ACTIONS` table, the same envelope. A second implementation of "what does compile_scene do"
644
+ * would be a second answer to that question, so there is only ever one.
645
+ *
646
+ * @param {{ cwd?: string, args?: string[], signal?: AbortSignal }} [options]
647
+ * @returns {Promise<{ pid: number|null, run: (request: object, options?: object) => Promise<object>, close: () => Promise<void> }>}
648
+ */
649
+ async openSession(options = {}) {
650
+ if (!existsSync(this.bootstrapPath)) {
651
+ throw new BlenderError(
652
+ BlenderErrorCode.BOOTSTRAP_MISSING,
653
+ `bootstrap.py not found at ${this.bootstrapPath}.`,
654
+ )
655
+ }
656
+ const resolvedExecutable = await this.resolveBlenderExecutable({ signal: options.signal })
657
+ if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
658
+ throw resolvedExecutable.error ?? new BlenderError(
659
+ BlenderErrorCode.NOT_FOUND,
660
+ `Blender executable could not be resolved from "${resolvedExecutable.requested}".`,
661
+ )
662
+ }
663
+
664
+ const { jobId, directory } = this._createWorkingDirectory()
665
+ const argv = [
666
+ resolvedExecutable.resolved,
667
+ '--background',
668
+ '--factory-startup',
669
+ '--python',
670
+ this.bootstrapPath,
671
+ '--',
672
+ // `--session=1` rather than a bare `--session`: `parse_args` reads `--opt value` PAIRS, so a
673
+ // valueless flag would swallow the next token as its value. The session needs no value, so it
674
+ // carries the smallest one that cannot be mistaken for something else.
675
+ '--session=1',
676
+ ...(options.args ?? []),
677
+ ]
678
+
679
+ const handle = this.ctx.subprocess.spawn({
680
+ argv,
681
+ cwd: options.cwd ?? directory,
682
+ stdio: {
683
+ // THE DIFFERENCE FROM EVERY OTHER CALL IN THIS FILE. Batch mode ignores stdin and collects
684
+ // stdout into a bounded tail; a session needs both as live pipes, because the conversation
685
+ // IS the pipes.
686
+ stdin: 'pipe',
687
+ stdout: 'pipe',
688
+ stderr: { maxBytes: this.config.maxOutputBytes, spill: { maxBytes: this.config.maxSpillBytes } },
689
+ },
690
+ graceMs: TERMINATE_GRACE_MS,
691
+ signal: options.signal,
692
+ })
693
+
694
+ const session = new BlenderSession({
695
+ handle,
696
+ directory,
697
+ jobId,
698
+ timeoutMs: this.config.timeoutMs,
699
+ onClose: () => this._cleanup(directory),
700
+ })
701
+ await session.ready()
702
+ return session
703
+ }
704
+
422
705
  async runBootstrap(request, options = {}) {
423
706
  const action = request?.action
424
707
  if (typeof action !== 'string' || action.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.2.2"
14
+ "@deepblend/dsh-blender-contracts": "0.2.4"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -492,6 +492,114 @@ def scene_fingerprint(spec, objects):
492
492
  # --------------------------------------------------------------------------------------
493
493
 
494
494
 
495
+ # ---------------------------------------------------------------------------
496
+ # Session mode (SPEC §20 M6 "Blender Live Bridge")
497
+ #
498
+ # WHY THIS EXISTS. Every operation in this product is one process: `blender --background
499
+ # --factory-startup --python bootstrap.py -- --request … --result …`, which loads the scene,
500
+ # does the thing, and exits. That is the right shape for a render and the wrong one for a
501
+ # CONVERSATION: opening a scene costs seconds, and a caller that compiles, then previews, then
502
+ # compiles again pays it every time.
503
+ #
504
+ # Session mode is the same dispatch with the process kept open: one JSON request per line on
505
+ # stdin, one JSON envelope per line on stdout, until EOF or an explicit shutdown. Nothing about
506
+ # an individual action changes — `ACTIONS`, the protocol check and the envelope are the same
507
+ # ones the batch path uses, because a second implementation of "what does compile_scene do"
508
+ # would be a second answer to that question.
509
+ #
510
+ # WHAT IT IS NOT (yet). This is the TRANSPORT half of the M6 item, not the whole item: the
511
+ # GUI attach — the user's own Blender, with the add-on, so they can watch it work — is the
512
+ # next item on that list. What this proves is the part everything else rests on: one Blender,
513
+ # many operations, each one attributable to a pid and each one able to fail on its own.
514
+ # ---------------------------------------------------------------------------
515
+
516
+
517
+ def _session_line(payload):
518
+ """One compact JSON line, flushed, because the caller is waiting on it."""
519
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
520
+ sys.stdout.flush()
521
+
522
+
523
+ def _session_dispatch(request, options):
524
+ """The batch dispatch, applied to one line of a session.
525
+
526
+ Deliberately the same checks in the same order as `main()`: a session request that names an
527
+ unknown action must fail exactly as a batch request would, or the two paths would disagree
528
+ about the protocol and only one of them would be tested.
529
+ """
530
+ if not isinstance(request, dict):
531
+ return build_envelope(None, None, None, {
532
+ "code": "BLENDER_SCRIPT_ERROR",
533
+ "message": "the request document must be a JSON object",
534
+ }, [], [])
535
+
536
+ job_id = request.get("jobId")
537
+ action = request.get("action")
538
+
539
+ if request.get("protocolVersion") != PROTOCOL_VERSION:
540
+ return build_envelope(job_id, action, None, {
541
+ "code": "BLENDER_PROTOCOL_VERSION_MISMATCH",
542
+ "message": 'request declares protocolVersion "%s", expected "%s"'
543
+ % (request.get("protocolVersion"), PROTOCOL_VERSION),
544
+ }, [], [])
545
+
546
+ handler = ACTIONS.get(action)
547
+ if handler is None:
548
+ return build_envelope(job_id, action, None, {
549
+ "code": "BLENDER_UNSUPPORTED_ACTION",
550
+ "message": 'bootstrap.py does not implement action "%s"; supported actions are %s'
551
+ % (action, ", ".join(SUPPORTED_ACTIONS)),
552
+ "detail": {"action": action, "supported": list(SUPPORTED_ACTIONS)},
553
+ }, [], [])
554
+
555
+ eprint("session action=%s job=%s" % (action, job_id))
556
+ # Same claim as the batch path: every request is attributable to this pid, so a session that
557
+ # dies mid-action leaves the same evidence an interrupted render does.
558
+ write_process_identity(
559
+ options.get("proc"),
560
+ job_id,
561
+ action,
562
+ attempt_token=_attempt_token_from_plan(action, options),
563
+ )
564
+ try:
565
+ payload, warnings, notices = handler(request, options)
566
+ return build_envelope(job_id, action, payload, None, warnings, notices)
567
+ except Exception as exc:
568
+ return build_envelope(job_id, action, None, {
569
+ "code": getattr(exc, "code", "BLENDER_SCRIPT_ERROR"),
570
+ "message": error_text(exc),
571
+ }, [], [])
572
+
573
+
574
+ def run_session(options):
575
+ """Serve requests until stdin ends or a caller asks to stop.
576
+
577
+ EXIT CODE IS ABOUT THE SESSION, NOT THE ACTIONS: an action that fails is reported in its own
578
+ envelope and the session carries on, because a caller that wanted the process to stop would
579
+ have closed the pipe. Only the session's own health can end it non-zero.
580
+ """
581
+ _session_line({"kind": "ready", "protocolVersion": PROTOCOL_VERSION, "pid": os.getpid()})
582
+ for line in sys.stdin:
583
+ text = line.strip()
584
+ if text == "":
585
+ continue
586
+ try:
587
+ request = json.loads(text)
588
+ except Exception as exc:
589
+ _session_line(build_envelope(None, None, None, {
590
+ "code": "BLENDER_SCRIPT_ERROR",
591
+ "message": "the request line is not valid JSON: %s" % (error_text(exc),),
592
+ }, [], []))
593
+ continue
594
+ if isinstance(request, dict) and request.get("action") == "shutdown":
595
+ _session_line({"kind": "bye", "pid": os.getpid()})
596
+ return 0
597
+ envelope = _session_dispatch(request, options)
598
+ envelope["kind"] = "result"
599
+ _session_line(envelope)
600
+ return 0
601
+
602
+
495
603
  def main():
496
604
  argv = sys.argv
497
605
  options, unknown = parse_args(
@@ -500,10 +608,15 @@ def main():
500
608
  "--request", "--result", "--scene-spec", "--blend", "--output-blend",
501
609
  "--output", "--camera", "--engine", "--width", "--height", "--samples",
502
610
  "--frame", "--profile", "--project-root", "--save-on-failure", "--views",
503
- "--frames", "--events", "--proc",
611
+ "--frames", "--events", "--proc", "--session",
504
612
  ),
505
613
  )
506
614
 
615
+ # SESSION MODE FIRST: it shares every check below, so it must not fall through to the batch
616
+ # path's `--result` requirement (a session has no result file; its answers go to stdout).
617
+ if options.get("session") is not None:
618
+ return run_session(options)
619
+
507
620
  request_path = options.get("request")
508
621
  result_path = options.get("result")
509
622
  if result_path is None: