@deepblend/dsh-blender-provider-local 0.2.3 → 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 +283 -0
- package/package.json +2 -2
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/bootstrap.py +114 -1
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.
|
|
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.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.2.4"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
Binary file
|
package/python/bootstrap.py
CHANGED
|
@@ -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:
|