@deepblend/dsh-blender-provider-local 0.2.3 → 0.2.5
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
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawnSync } from 'node:child_process'
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import net from 'node:net'
|
|
24
25
|
import { readFile } from 'node:fs/promises'
|
|
25
26
|
import { delimiter, isAbsolute, join, resolve, sep } from 'node:path'
|
|
26
27
|
import { randomUUID } from 'node:crypto'
|
|
@@ -154,6 +155,352 @@ export const ProviderConfig = z.object({
|
|
|
154
155
|
* @property {string} workingDirectory
|
|
155
156
|
*/
|
|
156
157
|
|
|
158
|
+
/** How long an attached Blender gets to say hello before the attach is called a failure. */
|
|
159
|
+
const ATTACH_READY_MS = 10_000
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Connect to a Unix socket, with a deadline.
|
|
163
|
+
*
|
|
164
|
+
* The add-on may be starting when the product asks — a user enables it and the product is already
|
|
165
|
+
* polling — so this retries inside one deadline rather than failing on the first refusal.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} socketPath
|
|
168
|
+
* @param {number} timeoutMs
|
|
169
|
+
* @returns {Promise<import('node:net').Socket>}
|
|
170
|
+
*/
|
|
171
|
+
async function connect(socketPath, timeoutMs) {
|
|
172
|
+
const deadline = Date.now() + Math.min(timeoutMs, 10_000)
|
|
173
|
+
let lastError = null
|
|
174
|
+
for (;;) {
|
|
175
|
+
const socket = await new Promise(resolve => {
|
|
176
|
+
const candidate = net.connect(socketPath)
|
|
177
|
+
candidate.once('connect', () => resolve(candidate))
|
|
178
|
+
candidate.once('error', error => {
|
|
179
|
+
lastError = error
|
|
180
|
+
candidate.destroy()
|
|
181
|
+
resolve(null)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
if (socket !== null) return socket
|
|
185
|
+
if (Date.now() >= deadline) throw lastError ?? new Error('no answer')
|
|
186
|
+
await new Promise(settle => setTimeout(settle, 250))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A live Blender, presented as one shape whatever carries the bytes.
|
|
192
|
+
*
|
|
193
|
+
* WHY THE SEAM EXISTS. A session can be carried two ways, and both are real: the process this
|
|
194
|
+
* provider SPAWNS (`--background`, pipes on stdin/stdout) and the Blender a USER is looking at (the
|
|
195
|
+
* add-on's socket). The conversation — the handshake, the per-request deadline, matching an answer to
|
|
196
|
+
* its request — is identical, and the only thing that differs is who owns the other end. Writing it
|
|
197
|
+
* twice would mean two places to fix the next bug in the deadline, and the second one would be the
|
|
198
|
+
* one nobody runs.
|
|
199
|
+
*
|
|
200
|
+
* The subprocess adapter and the socket adapter below are the whole of the difference.
|
|
201
|
+
*/
|
|
202
|
+
class SubprocessTransport {
|
|
203
|
+
/** @param {object} handle from `ctx.subprocess.spawn` */
|
|
204
|
+
constructor(handle) {
|
|
205
|
+
this.handle = handle
|
|
206
|
+
this.read = handle.stdout
|
|
207
|
+
this.onExit = callback => handle.done?.then?.(() => callback({}), () => callback({}))
|
|
208
|
+
this.pid = null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
write(text) {
|
|
212
|
+
this.handle.stdin.write(text)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
end() {
|
|
216
|
+
try {
|
|
217
|
+
this.handle.stdin.end()
|
|
218
|
+
} catch {
|
|
219
|
+
// Already gone; the exit callback is what decides.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Wait for the process to be gone, bounded. */
|
|
224
|
+
async settle(graceMs) {
|
|
225
|
+
try {
|
|
226
|
+
await this.handle.waitForExit?.(graceMs)
|
|
227
|
+
} catch {
|
|
228
|
+
// A process that had to be terminated is still a settled one.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class SocketTransport {
|
|
234
|
+
/**
|
|
235
|
+
* @param {import('node:net').Socket} socket
|
|
236
|
+
* @param {{ pid?: number|null }} [info]
|
|
237
|
+
*/
|
|
238
|
+
constructor(socket, info = {}) {
|
|
239
|
+
this.socket = socket
|
|
240
|
+
this.read = socket
|
|
241
|
+
// The pid is the ATTACHED Blender's own, from its handshake — never this process's. A product that
|
|
242
|
+
// reported its own pid here would make "which Blender answered" unanswerable, which is the one
|
|
243
|
+
// question an attach exists to answer.
|
|
244
|
+
this.pid = info.pid ?? null
|
|
245
|
+
this.exitCallbacks = []
|
|
246
|
+
socket.on('close', () => {
|
|
247
|
+
for (const callback of this.exitCallbacks) callback({})
|
|
248
|
+
})
|
|
249
|
+
socket.on('error', () => {
|
|
250
|
+
// An error closes the socket, and the close handler reports it. Swallowing it here keeps a
|
|
251
|
+
// broken pipe from becoming an unhandled 'error' event, which would take the host down.
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onExit(callback) {
|
|
256
|
+
this.exitCallbacks.push(callback)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
write(text) {
|
|
260
|
+
this.socket.write(text)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
end() {
|
|
264
|
+
try {
|
|
265
|
+
this.socket.end()
|
|
266
|
+
} catch {
|
|
267
|
+
// Already closed.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async settle() {
|
|
272
|
+
this.socket.destroy()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One live Blender process, serving requests until it is closed (SPEC §20 M6).
|
|
278
|
+
*
|
|
279
|
+
* The class exists so the SESSION's own lifecycle — the handshake, the per-request deadline, the
|
|
280
|
+
* "the process is gone" answer, and the shutdown — is one object with one state, rather than a set of
|
|
281
|
+
* promises the provider keeps in a map. Everything about an individual action still belongs to
|
|
282
|
+
* `bootstrap.py`'s `ACTIONS`; this only carries the conversation.
|
|
283
|
+
*
|
|
284
|
+
* LINE PROTOCOL. bootstrap.py writes one JSON document per line to stdout, and it already did before
|
|
285
|
+
* sessions existed: `report_progress` sends `{"type": "progress", …}` (SPEC §9.3) and a session sends
|
|
286
|
+
* `{"kind": "ready"|"result"|"bye", …}`. The two are distinguishable BY PARSING rather than by
|
|
287
|
+
* pattern-matching prose, which is the property that made progress lines safe to add in the first
|
|
288
|
+
* place.
|
|
289
|
+
*/
|
|
290
|
+
class BlenderSession {
|
|
291
|
+
/**
|
|
292
|
+
* @param {{ transport: SubprocessTransport|SocketTransport, directory: string|null, jobId: string, timeoutMs: number, onClose: () => void }} input
|
|
293
|
+
*/
|
|
294
|
+
constructor(input) {
|
|
295
|
+
this.transport = input.transport
|
|
296
|
+
this.handle = input.transport.handle ?? null
|
|
297
|
+
this.directory = input.directory
|
|
298
|
+
this.jobId = input.jobId
|
|
299
|
+
this.timeoutMs = input.timeoutMs
|
|
300
|
+
this.onClose = input.onClose
|
|
301
|
+
// THE HANDLE HAS NO `pid`, MEASURED: its keys are `stdin`, `stdout`, `stderr`, `collected`,
|
|
302
|
+
// `done`, `terminate`, `terminateForHostExit`, `waitForExit`. So the pid comes from the process
|
|
303
|
+
// itself, in the `ready` line bootstrap.py opens with — which is better evidence anyway, because
|
|
304
|
+
// it is the pid the script believes it is rather than the one the spawn returned.
|
|
305
|
+
this.pid = input.transport.pid ?? null
|
|
306
|
+
/** @type {Map<string, {resolve: Function, reject: Function, timer: object}>} */
|
|
307
|
+
this.waiting = new Map()
|
|
308
|
+
this.progress = []
|
|
309
|
+
/** Whether the process said goodbye before it went: the difference between a shutdown and a death. */
|
|
310
|
+
this.saidBye = false
|
|
311
|
+
this.stderr = ''
|
|
312
|
+
this.closed = false
|
|
313
|
+
this.readyPromise = null
|
|
314
|
+
this.readyResolve = null
|
|
315
|
+
this.readyReject = null
|
|
316
|
+
this.readySettled = false
|
|
317
|
+
this._read()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resolve when the other end has announced itself — reject if it dies first, or says nothing.
|
|
322
|
+
*
|
|
323
|
+
* THE DEADLINE IS THE POINT, and a mutation found it missing: with the handshake removed from the
|
|
324
|
+
* bridge, `attachSession` connected successfully and then waited FOREVER, because the socket stayed
|
|
325
|
+
* open and simply never spoke. A hang is worse than either a success or a coded failure — it has no
|
|
326
|
+
* message, no code, and no end — and this repository's rule is that a failure has to be a branchable
|
|
327
|
+
* result. The same deadline that bounds a request bounds the opening of the conversation.
|
|
328
|
+
*/
|
|
329
|
+
ready(timeoutMs = this.timeoutMs) {
|
|
330
|
+
if (this.readyPromise === null) {
|
|
331
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
332
|
+
const timer = setTimeout(() => {
|
|
333
|
+
if (this.readySettled) return
|
|
334
|
+
this.readySettled = true
|
|
335
|
+
reject(new BlenderError(
|
|
336
|
+
BlenderErrorCode.TIMEOUT,
|
|
337
|
+
`The Blender session (pid ${this.pid ?? 'unknown'}) connected but never announced itself within ${timeoutMs} ms.`,
|
|
338
|
+
{ detail: { pid: this.pid, timeoutMs } },
|
|
339
|
+
))
|
|
340
|
+
}, timeoutMs)
|
|
341
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
342
|
+
this.readyResolve = value => {
|
|
343
|
+
clearTimeout(timer)
|
|
344
|
+
this.readySettled = true
|
|
345
|
+
resolve(value)
|
|
346
|
+
}
|
|
347
|
+
this.readyReject = error => {
|
|
348
|
+
clearTimeout(timer)
|
|
349
|
+
this.readySettled = true
|
|
350
|
+
reject(error)
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
return this.readyPromise
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Read stdout line by line for the life of the process. */
|
|
358
|
+
_read() {
|
|
359
|
+
const stream = this.transport.read
|
|
360
|
+
if (stream === undefined || stream === null) {
|
|
361
|
+
this._fail(new BlenderError(BlenderErrorCode.SPAWN_FAILED, 'the session has no stdout stream to read'))
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
let buffer = ''
|
|
365
|
+
stream.on('data', chunk => {
|
|
366
|
+
buffer += chunk.toString('utf8')
|
|
367
|
+
let index = buffer.indexOf('\n')
|
|
368
|
+
while (index >= 0) {
|
|
369
|
+
const line = buffer.slice(0, index).trim()
|
|
370
|
+
buffer = buffer.slice(index + 1)
|
|
371
|
+
if (line.length > 0) this._line(line)
|
|
372
|
+
index = buffer.indexOf('\n')
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
this.handle?.stderr?.on?.('data', chunk => {
|
|
376
|
+
// Bounded: stderr is evidence for a failure, not a log to keep.
|
|
377
|
+
this.stderr = (this.stderr + chunk.toString('utf8')).slice(-8192)
|
|
378
|
+
})
|
|
379
|
+
// THE TRANSPORT REPORTS THE END, whatever the end means: for a spawned process the handle's
|
|
380
|
+
// `done` promise, for a socket its 'close'. Neither carries a code — the failure below says what
|
|
381
|
+
// is knowable, which is that the session ended without answering.
|
|
382
|
+
this.transport.onExit(info => this._exited(info))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
_line(line) {
|
|
386
|
+
let document
|
|
387
|
+
try {
|
|
388
|
+
document = JSON.parse(line)
|
|
389
|
+
} catch {
|
|
390
|
+
// Blender itself prints to stdout; anything that is not a JSON document is not ours.
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (document?.type === 'progress') {
|
|
394
|
+
this.progress.push(document)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
if (document?.kind === 'ready') {
|
|
398
|
+
if (typeof document.pid === 'number') this.pid = document.pid
|
|
399
|
+
this.readyResolve?.(this)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
if (document?.kind === 'bye') {
|
|
403
|
+
this.saidBye = true
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
const jobId = document?.jobId
|
|
407
|
+
const pending = typeof jobId === 'string' ? this.waiting.get(jobId) : undefined
|
|
408
|
+
if (pending === undefined) return
|
|
409
|
+
clearTimeout(pending.timer)
|
|
410
|
+
this.waiting.delete(jobId)
|
|
411
|
+
pending.resolve(document)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_fail(error) {
|
|
415
|
+
this.readyReject?.(error)
|
|
416
|
+
for (const [, pending] of this.waiting) {
|
|
417
|
+
clearTimeout(pending.timer)
|
|
418
|
+
pending.reject(error)
|
|
419
|
+
}
|
|
420
|
+
this.waiting.clear()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_exited(info) {
|
|
424
|
+
if (this.closed) return
|
|
425
|
+
this.closed = true
|
|
426
|
+
const detail = this.stderr.trim().length > 0 ? ` stderr: ${this.stderr.trim().slice(-500)}` : ''
|
|
427
|
+
this._fail(new BlenderError(
|
|
428
|
+
BlenderErrorCode.NONZERO_EXIT,
|
|
429
|
+
`the Blender session (pid ${this.pid ?? 'unknown'}) ended before answering${info?.code === undefined ? '' : ` (exit ${info.code})`}.${detail}`,
|
|
430
|
+
{ detail: { pid: this.pid, code: info?.code ?? null, signal: info?.signal ?? null } },
|
|
431
|
+
))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run one action in the live process.
|
|
436
|
+
*
|
|
437
|
+
* @param {{ action: string, payload?: object, jobId?: string }} request
|
|
438
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
439
|
+
* @returns {Promise<object>} the envelope bootstrap.py built for it.
|
|
440
|
+
*/
|
|
441
|
+
async run(request, options = {}) {
|
|
442
|
+
if (this.closed) {
|
|
443
|
+
throw new BlenderError(
|
|
444
|
+
BlenderErrorCode.NONZERO_EXIT,
|
|
445
|
+
`the Blender session (pid ${this.pid ?? 'unknown'}) is closed, so "${request?.action}" was not sent.`,
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
const jobId = typeof request.jobId === 'string' && request.jobId.length > 0 ? request.jobId : `${this.jobId}-${this.waiting.size + 1}`
|
|
449
|
+
const document = {
|
|
450
|
+
protocolVersion: BLENDER_PROTOCOL_VERSION,
|
|
451
|
+
jobId,
|
|
452
|
+
action: request.action,
|
|
453
|
+
...(request.payload !== undefined ? { payload: request.payload } : {}),
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const answered = new Promise((resolve, reject) => {
|
|
457
|
+
const timer = setTimeout(() => {
|
|
458
|
+
this.waiting.delete(jobId)
|
|
459
|
+
reject(new BlenderError(
|
|
460
|
+
BlenderErrorCode.TIMEOUT,
|
|
461
|
+
`the Blender session did not answer "${request.action}" within ${this.timeoutMs} ms.`,
|
|
462
|
+
{ detail: { jobId, action: request.action, pid: this.pid } },
|
|
463
|
+
))
|
|
464
|
+
}, this.timeoutMs)
|
|
465
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
466
|
+
this.waiting.set(jobId, { resolve, reject, timer })
|
|
467
|
+
if (options.signal !== undefined) {
|
|
468
|
+
options.signal.addEventListener('abort', () => {
|
|
469
|
+
if (!this.waiting.has(jobId)) return
|
|
470
|
+
clearTimeout(timer)
|
|
471
|
+
this.waiting.delete(jobId)
|
|
472
|
+
reject(new BlenderError(
|
|
473
|
+
BlenderErrorCode.ABORTED,
|
|
474
|
+
`"${request.action}" was aborted before the Blender session answered.`,
|
|
475
|
+
{ detail: { jobId, action: request.action, pid: this.pid } },
|
|
476
|
+
))
|
|
477
|
+
}, { once: true })
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
this.transport.write(`${JSON.stringify(document)}\n`)
|
|
482
|
+
return answered
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Ask the process to stop, and wait for it. Idempotent. */
|
|
486
|
+
async close() {
|
|
487
|
+
if (this.closed) return
|
|
488
|
+
try {
|
|
489
|
+
this.transport.write(`${JSON.stringify({ action: 'shutdown' })}\n`)
|
|
490
|
+
this.transport.end()
|
|
491
|
+
} catch {
|
|
492
|
+
// The other end may already be gone; the settle below is what decides.
|
|
493
|
+
}
|
|
494
|
+
await this.transport.settle(TERMINATE_GRACE_MS)
|
|
495
|
+
this.closed = true
|
|
496
|
+
this._fail(new BlenderError(
|
|
497
|
+
BlenderErrorCode.ABORTED,
|
|
498
|
+
`the Blender session (pid ${this.pid ?? 'unknown'}) was closed by its caller.`,
|
|
499
|
+
))
|
|
500
|
+
this.onClose()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
157
504
|
/**
|
|
158
505
|
* Local Blender provider.
|
|
159
506
|
*
|
|
@@ -419,6 +766,141 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
419
766
|
* }} [options]
|
|
420
767
|
* @returns {Promise<BootstrapRunOutcome>}
|
|
421
768
|
*/
|
|
769
|
+
/**
|
|
770
|
+
* Open a LIVE SESSION: one Blender process that answers many requests (SPEC §20 M6).
|
|
771
|
+
*
|
|
772
|
+
* WHY THIS EXISTS. Every other method here is one process per operation —
|
|
773
|
+
* `blender --background --factory-startup --python bootstrap.py -- --request … --result …` —
|
|
774
|
+
* which loads the scene, does the thing, and exits. That is right for a render and wrong for a
|
|
775
|
+
* conversation: opening a scene costs seconds, and a caller that compiles, previews and compiles
|
|
776
|
+
* again pays it every time. MEASURED on this machine: `get_capabilities` alone is ~1.4 s of
|
|
777
|
+
* process start, and `compile_scene` on the demo project is dominated by scene load.
|
|
778
|
+
*
|
|
779
|
+
* WHAT IT IS NOT. This is the TRANSPORT half of the M6 item, not the item: the GUI attach — the
|
|
780
|
+
* user's own Blender, with the add-on, so they can watch it work — is the next thing on that list.
|
|
781
|
+
* What this establishes is what everything else rests on: one Blender, many operations, each one
|
|
782
|
+
* attributable to a pid and each one able to fail on its own.
|
|
783
|
+
*
|
|
784
|
+
* THE PROTOCOL is bootstrap.py's own, unchanged: the same request document shape, the same
|
|
785
|
+
* `ACTIONS` table, the same envelope. A second implementation of "what does compile_scene do"
|
|
786
|
+
* would be a second answer to that question, so there is only ever one.
|
|
787
|
+
*
|
|
788
|
+
* @param {{ cwd?: string, args?: string[], signal?: AbortSignal }} [options]
|
|
789
|
+
* @returns {Promise<{ pid: number|null, run: (request: object, options?: object) => Promise<object>, close: () => Promise<void> }>}
|
|
790
|
+
*/
|
|
791
|
+
async openSession(options = {}) {
|
|
792
|
+
if (!existsSync(this.bootstrapPath)) {
|
|
793
|
+
throw new BlenderError(
|
|
794
|
+
BlenderErrorCode.BOOTSTRAP_MISSING,
|
|
795
|
+
`bootstrap.py not found at ${this.bootstrapPath}.`,
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
const resolvedExecutable = await this.resolveBlenderExecutable({ signal: options.signal })
|
|
799
|
+
if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
|
|
800
|
+
throw resolvedExecutable.error ?? new BlenderError(
|
|
801
|
+
BlenderErrorCode.NOT_FOUND,
|
|
802
|
+
`Blender executable could not be resolved from "${resolvedExecutable.requested}".`,
|
|
803
|
+
)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const { jobId, directory } = this._createWorkingDirectory()
|
|
807
|
+
const argv = [
|
|
808
|
+
resolvedExecutable.resolved,
|
|
809
|
+
'--background',
|
|
810
|
+
'--factory-startup',
|
|
811
|
+
'--python',
|
|
812
|
+
this.bootstrapPath,
|
|
813
|
+
'--',
|
|
814
|
+
// `--session=1` rather than a bare `--session`: `parse_args` reads `--opt value` PAIRS, so a
|
|
815
|
+
// valueless flag would swallow the next token as its value. The session needs no value, so it
|
|
816
|
+
// carries the smallest one that cannot be mistaken for something else.
|
|
817
|
+
'--session=1',
|
|
818
|
+
...(options.args ?? []),
|
|
819
|
+
]
|
|
820
|
+
|
|
821
|
+
const handle = this.ctx.subprocess.spawn({
|
|
822
|
+
argv,
|
|
823
|
+
cwd: options.cwd ?? directory,
|
|
824
|
+
stdio: {
|
|
825
|
+
// THE DIFFERENCE FROM EVERY OTHER CALL IN THIS FILE. Batch mode ignores stdin and collects
|
|
826
|
+
// stdout into a bounded tail; a session needs both as live pipes, because the conversation
|
|
827
|
+
// IS the pipes.
|
|
828
|
+
stdin: 'pipe',
|
|
829
|
+
stdout: 'pipe',
|
|
830
|
+
stderr: { maxBytes: this.config.maxOutputBytes, spill: { maxBytes: this.config.maxSpillBytes } },
|
|
831
|
+
},
|
|
832
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
833
|
+
signal: options.signal,
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
const session = new BlenderSession({
|
|
837
|
+
transport: new SubprocessTransport(handle),
|
|
838
|
+
directory,
|
|
839
|
+
jobId,
|
|
840
|
+
timeoutMs: this.config.timeoutMs,
|
|
841
|
+
onClose: () => this._cleanup(directory),
|
|
842
|
+
})
|
|
843
|
+
await session.ready()
|
|
844
|
+
return session
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Attach to a Blender that is ALREADY RUNNING — the user's own, with the add-on (SPEC §20 M6).
|
|
849
|
+
*
|
|
850
|
+
* THE OTHER HALF OF THE LIVE BRIDGE. `openSession()` spawns a Blender nobody can see; this one talks
|
|
851
|
+
* to the Blender on the user's screen, so the operations happen in the window they are looking at.
|
|
852
|
+
* The conversation is identical — same protocol, same envelopes, same per-request deadline — because
|
|
853
|
+
* both go through `BlenderSession`; the only difference is who owns the other end of the bytes.
|
|
854
|
+
*
|
|
855
|
+
* The pid in the handshake is the ATTACHED Blender's own, and it is what makes "which Blender
|
|
856
|
+
* answered this" answerable. A product that reported its own pid here would make that question
|
|
857
|
+
* unanswerable, which is the one question an attach exists to answer.
|
|
858
|
+
*
|
|
859
|
+
* @param {string} socketPath
|
|
860
|
+
* @param {{ timeoutMs?: number }} [options]
|
|
861
|
+
* @returns {Promise<{ pid: number|null, run: Function, close: Function }>}
|
|
862
|
+
*/
|
|
863
|
+
async attachSession(socketPath, options = {}) {
|
|
864
|
+
if (typeof socketPath !== 'string' || socketPath.length === 0) {
|
|
865
|
+
throw new BlenderError(
|
|
866
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
867
|
+
'Attaching needs the socket path the Blender add-on is listening on.',
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
if (!existsSync(socketPath)) {
|
|
871
|
+
throw new BlenderError(
|
|
872
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
873
|
+
`Nothing is listening at ${socketPath}. Enable the DeepBlend bridge add-on in Blender ` +
|
|
874
|
+
'(View3D > Sidebar > DeepBlend), or start one headless with ' +
|
|
875
|
+
'`blender --background --python deepblend_bridge.py -- --socket <path>`.',
|
|
876
|
+
{ detail: { socketPath } },
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const socket = await connect(socketPath, options.timeoutMs ?? this.config.timeoutMs).catch(cause => {
|
|
881
|
+
throw new BlenderError(
|
|
882
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
883
|
+
`The Blender bridge at ${socketPath} could not be reached: ${cause?.message ?? 'unknown failure'}.`,
|
|
884
|
+
{ detail: { socketPath } },
|
|
885
|
+
)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
const session = new BlenderSession({
|
|
889
|
+
transport: new SocketTransport(socket),
|
|
890
|
+
directory: null,
|
|
891
|
+
jobId: `attach-${randomUUID()}`,
|
|
892
|
+
timeoutMs: this.config.timeoutMs,
|
|
893
|
+
onClose: () => {},
|
|
894
|
+
})
|
|
895
|
+
// A SHORTER DEADLINE THAN A SPAWN, and the difference is real rather than a preference: a spawned
|
|
896
|
+
// Blender legitimately takes a second or two to boot, while an add-on that is already serving
|
|
897
|
+
// answers the handshake immediately. MEASURED: with the provider's own 180 s timeout, a peer that
|
|
898
|
+
// connected and said nothing took 180 s to report — long enough that a user would conclude the
|
|
899
|
+
// product had hung.
|
|
900
|
+
await session.ready(ATTACH_READY_MS)
|
|
901
|
+
return session
|
|
902
|
+
}
|
|
903
|
+
|
|
422
904
|
async runBootstrap(request, options = {}) {
|
|
423
905
|
const action = request?.action
|
|
424
906
|
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.5",
|
|
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.5"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
Binary file
|
|
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:
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""DeepBlend Studio — the Blender-side bridge (SPEC §20 M6 "Blender Add-on" / "Live Bridge").
|
|
2
|
+
|
|
3
|
+
WHAT THIS IS
|
|
4
|
+
------------
|
|
5
|
+
One file with two entry points, and that is deliberate:
|
|
6
|
+
|
|
7
|
+
* **As a Blender add-on** (the GUI case): the user installs it, enables it, and their own Blender
|
|
8
|
+
opens a local socket and shows a panel saying whether DeepBlend is attached. Every operation then
|
|
9
|
+
runs in the Blender they are LOOKING AT, which is the whole point of the M6 item — the batch round
|
|
10
|
+
trip stops being the only shape.
|
|
11
|
+
* **As a headless server** (the case a test can drive): `blender --background --python
|
|
12
|
+
deepblend_bridge.py -- --socket <path>` serves exactly the same protocol with no GUI at all.
|
|
13
|
+
|
|
14
|
+
Two entry points rather than two files, because the protocol and the dispatch are one thing: a second
|
|
15
|
+
copy for the headless case would be a second answer to "what does compile_scene do", and it would rot
|
|
16
|
+
first because nothing runs it.
|
|
17
|
+
|
|
18
|
+
WHAT IT REUSES
|
|
19
|
+
--------------
|
|
20
|
+
`bootstrap.py`'s dispatch, unchanged: the same `ACTIONS` table, the same protocol-version check, the
|
|
21
|
+
same envelope builder, the same per-request process identity. This file adds a TRANSPORT (a socket)
|
|
22
|
+
and a GUI (a panel), not a second implementation.
|
|
23
|
+
|
|
24
|
+
THE PROTOCOL is the session protocol, one JSON document per line: the product sends
|
|
25
|
+
`{protocolVersion, jobId, action, payload?}`, this answers with the envelope bootstrap.py builds.
|
|
26
|
+
`{"action": "shutdown"}` closes the connection.
|
|
27
|
+
|
|
28
|
+
Owner: DeepBlend Studio — SPEC §20 M6
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import socket
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
|
|
37
|
+
import bpy # noqa: F401 — present in Blender; the import is what makes this an add-on module
|
|
38
|
+
|
|
39
|
+
def _find_bootstrap():
|
|
40
|
+
"""Import `bootstrap` from wherever it actually is, or say what to set.
|
|
41
|
+
|
|
42
|
+
MEASURED PROBLEM, and it is the user's case rather than the test's: Blender COPIES an add-on into
|
|
43
|
+
its own add-ons directory, so the file that lands there is this one ALONE — `bootstrap.py` is not
|
|
44
|
+
beside it, and `import bootstrap` fails. The headless entry point never sees this, because there
|
|
45
|
+
the file still sits in the package.
|
|
46
|
+
|
|
47
|
+
So the search is: beside me (headless), then the directory an operator points at, then the one a
|
|
48
|
+
user can paste into the add-on's preferences. When all three miss, the failure names the setting
|
|
49
|
+
instead of raising an ImportError into a panel that shows nothing.
|
|
50
|
+
"""
|
|
51
|
+
candidates = [os.path.dirname(os.path.abspath(__file__))]
|
|
52
|
+
configured = os.environ.get("DEEPBLEND_BOOTSTRAP_DIR", "")
|
|
53
|
+
if configured:
|
|
54
|
+
candidates.append(configured)
|
|
55
|
+
try:
|
|
56
|
+
preferences = bpy.context.preferences.addons[__name__].preferences
|
|
57
|
+
if getattr(preferences, "bootstrap_dir", ""):
|
|
58
|
+
candidates.insert(0, preferences.bootstrap_dir)
|
|
59
|
+
except Exception:
|
|
60
|
+
# No preferences yet (headless, or before registration): the other two are enough.
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
for candidate in candidates:
|
|
64
|
+
if candidate and os.path.exists(os.path.join(candidate, "bootstrap.py")):
|
|
65
|
+
if candidate not in sys.path:
|
|
66
|
+
sys.path.insert(0, candidate)
|
|
67
|
+
import bootstrap # noqa: F401
|
|
68
|
+
return sys.modules["bootstrap"]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
bootstrap = _find_bootstrap()
|
|
73
|
+
BOOTSTRAP_PROBLEM = None if bootstrap is not None else (
|
|
74
|
+
"bootstrap.py was not found. Set DEEPBLEND_BOOTSTRAP_DIR to the provider package's python "
|
|
75
|
+
"directory (…/provider-local/python), or paste it into this add-on's preferences."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
bl_info = {
|
|
79
|
+
"name": "DeepBlend Studio Bridge",
|
|
80
|
+
"author": "DeepBlend Studio",
|
|
81
|
+
"version": (0, 2, 4),
|
|
82
|
+
"blender": (4, 0, 0),
|
|
83
|
+
"location": "View3D > Sidebar > DeepBlend",
|
|
84
|
+
"description": "Serve DeepBlend operations inside this Blender, so the product drives the window you are looking at",
|
|
85
|
+
"category": "Scene",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# The default socket path. One per workspace, so two stores on one machine do not collide — and the
|
|
89
|
+
# product passes its own path when it starts the headless server, so this default is only what the
|
|
90
|
+
# add-on uses when a user enables it with no configuration.
|
|
91
|
+
DEFAULT_SOCKET = os.path.join(os.path.expanduser("~"), ".deepblend-bridge.sock")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Bridge:
|
|
95
|
+
"""The serving state: one socket, one thread, and the last thing that happened."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, socket_path, options=None):
|
|
98
|
+
self.socket_path = socket_path
|
|
99
|
+
self.options = options or {}
|
|
100
|
+
self.server = None
|
|
101
|
+
self.thread = None
|
|
102
|
+
self.requests = 0
|
|
103
|
+
self.last_action = None
|
|
104
|
+
self.last_error = None
|
|
105
|
+
self.connected = False
|
|
106
|
+
|
|
107
|
+
# -- lifecycle ---------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def start(self):
|
|
110
|
+
if os.path.exists(self.socket_path):
|
|
111
|
+
# A socket file left by a process that died. Refusing to start would leave the user with
|
|
112
|
+
# no way forward except deleting a file they cannot see, so it is replaced — the same
|
|
113
|
+
# decision the render job's recovery path makes about a stale pid file.
|
|
114
|
+
os.unlink(self.socket_path)
|
|
115
|
+
self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
116
|
+
self.server.bind(self.socket_path)
|
|
117
|
+
self.server.listen(4)
|
|
118
|
+
self.thread = threading.Thread(target=self._serve, name="deepblend-bridge", daemon=True)
|
|
119
|
+
self.thread.start()
|
|
120
|
+
|
|
121
|
+
def stop(self):
|
|
122
|
+
try:
|
|
123
|
+
if self.server is not None:
|
|
124
|
+
self.server.close()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
self.server = None
|
|
128
|
+
try:
|
|
129
|
+
if os.path.exists(self.socket_path):
|
|
130
|
+
os.unlink(self.socket_path)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
# -- serving -----------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _serve(self):
|
|
137
|
+
while self.server is not None:
|
|
138
|
+
try:
|
|
139
|
+
connection, _ = self.server.accept()
|
|
140
|
+
except Exception:
|
|
141
|
+
return
|
|
142
|
+
self.connected = True
|
|
143
|
+
try:
|
|
144
|
+
self._converse(connection)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
self.last_error = bootstrap.error_text(exc)
|
|
147
|
+
finally:
|
|
148
|
+
self.connected = False
|
|
149
|
+
try:
|
|
150
|
+
connection.close()
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def _converse(self, connection):
|
|
155
|
+
"""One connection: a handshake, then a line in and an envelope out, until the peer stops.
|
|
156
|
+
|
|
157
|
+
THE HANDSHAKE IS THE SESSION PROTOCOL'S OWN, and it took a hang to notice it was missing: the
|
|
158
|
+
headless entry point printed `{"kind": "ready"}` to stdout at startup, and a CONNECTION got
|
|
159
|
+
nothing — so the product's `BlenderSession.ready()` waited forever on a peer that was already
|
|
160
|
+
serving. A transport that answers the same conversation has to open it the same way.
|
|
161
|
+
"""
|
|
162
|
+
connection.sendall((json.dumps({"kind": "ready", "pid": os.getpid(), "socket": self.socket_path}) + "\n").encode("utf-8"))
|
|
163
|
+
buffer = b""
|
|
164
|
+
while True:
|
|
165
|
+
chunk = connection.recv(65536)
|
|
166
|
+
if not chunk:
|
|
167
|
+
return
|
|
168
|
+
buffer += chunk
|
|
169
|
+
while b"\n" in buffer:
|
|
170
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
171
|
+
text = line.decode("utf-8", "replace").strip()
|
|
172
|
+
if text == "":
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
request = json.loads(text)
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
answer = bootstrap.build_envelope(None, None, None, {
|
|
178
|
+
"code": "BLENDER_SCRIPT_ERROR",
|
|
179
|
+
"message": "the request line is not valid JSON: %s" % (bootstrap.error_text(exc),),
|
|
180
|
+
}, [], [])
|
|
181
|
+
else:
|
|
182
|
+
if isinstance(request, dict) and request.get("action") == "shutdown":
|
|
183
|
+
connection.sendall((json.dumps({"kind": "bye", "pid": os.getpid()}) + "\n").encode("utf-8"))
|
|
184
|
+
return
|
|
185
|
+
self.requests += 1
|
|
186
|
+
self.last_action = request.get("action") if isinstance(request, dict) else None
|
|
187
|
+
answer = bootstrap._session_dispatch(request, self.options)
|
|
188
|
+
answer["kind"] = "result"
|
|
189
|
+
connection.sendall((json.dumps(answer, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
190
|
+
|
|
191
|
+
# -- what the panel shows ----------------------------------------------
|
|
192
|
+
|
|
193
|
+
def status(self):
|
|
194
|
+
if self.server is None:
|
|
195
|
+
return "off"
|
|
196
|
+
return "attached" if self.connected else "listening on %s" % (self.socket_path,)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_BRIDGE = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class DeepBlendBridgePreferences(bpy.types.AddonPreferences):
|
|
203
|
+
"""Where the user points the add-on at the package, because Blender copies add-ons alone."""
|
|
204
|
+
|
|
205
|
+
bl_idname = __name__
|
|
206
|
+
|
|
207
|
+
bootstrap_dir: bpy.props.StringProperty( # type: ignore[valid-type]
|
|
208
|
+
name="bootstrap.py directory",
|
|
209
|
+
description="The provider package's python directory (…/provider-local/python)",
|
|
210
|
+
default="",
|
|
211
|
+
subtype="DIR_PATH",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def draw(self, context):
|
|
215
|
+
layout = self.layout
|
|
216
|
+
layout.prop(self, "bootstrap_dir")
|
|
217
|
+
if BOOTSTRAP_PROBLEM is not None:
|
|
218
|
+
layout.label(text=BOOTSTRAP_PROBLEM, icon="ERROR")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class DEEPBLEND_PT_bridge(bpy.types.Panel):
|
|
222
|
+
"""The panel, because an add-on a user cannot see is an add-on they will not trust."""
|
|
223
|
+
|
|
224
|
+
bl_label = "DeepBlend Studio"
|
|
225
|
+
bl_idname = "DEEPBLEND_PT_bridge"
|
|
226
|
+
bl_space_type = "VIEW_3D"
|
|
227
|
+
bl_region_type = "UI"
|
|
228
|
+
bl_category = "DeepBlend"
|
|
229
|
+
|
|
230
|
+
def draw(self, context):
|
|
231
|
+
layout = self.layout
|
|
232
|
+
if BOOTSTRAP_PROBLEM is not None:
|
|
233
|
+
layout.label(text="bootstrap.py not found", icon="ERROR")
|
|
234
|
+
layout.label(text="Set it in this add-on's preferences")
|
|
235
|
+
return
|
|
236
|
+
if _BRIDGE is None:
|
|
237
|
+
layout.label(text="Bridge is not running", icon="ERROR")
|
|
238
|
+
return
|
|
239
|
+
layout.label(text="Status: %s" % (_BRIDGE.status(),))
|
|
240
|
+
layout.label(text="Socket: %s" % (os.path.basename(_BRIDGE.socket_path),))
|
|
241
|
+
layout.label(text="Operations served: %d" % (_BRIDGE.requests,))
|
|
242
|
+
if _BRIDGE.last_action is not None:
|
|
243
|
+
layout.label(text="Last: %s" % (_BRIDGE.last_action,))
|
|
244
|
+
if _BRIDGE.last_error is not None:
|
|
245
|
+
layout.label(text="Last error: %s" % (_BRIDGE.last_error[:60],), icon="ERROR")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def register():
|
|
249
|
+
global _BRIDGE
|
|
250
|
+
socket_path = os.environ.get("DEEPBLEND_BRIDGE_SOCKET", DEFAULT_SOCKET)
|
|
251
|
+
_BRIDGE = _Bridge(socket_path)
|
|
252
|
+
try:
|
|
253
|
+
_BRIDGE.start()
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
# A bridge that cannot listen must not stop Blender from starting: the user's session is more
|
|
256
|
+
# important than this panel, and the panel says what went wrong.
|
|
257
|
+
_BRIDGE.last_error = bootstrap.error_text(exc)
|
|
258
|
+
bpy.utils.register_class(DeepBlendBridgePreferences)
|
|
259
|
+
bpy.utils.register_class(DEEPBLEND_PT_bridge)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def unregister():
|
|
263
|
+
global _BRIDGE
|
|
264
|
+
bpy.utils.unregister_class(DEEPBLEND_PT_bridge)
|
|
265
|
+
bpy.utils.unregister_class(DeepBlendBridgePreferences)
|
|
266
|
+
if _BRIDGE is not None:
|
|
267
|
+
_BRIDGE.stop()
|
|
268
|
+
_BRIDGE = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main():
|
|
272
|
+
"""Headless entry point: serve until the process is asked to stop."""
|
|
273
|
+
argv = sys.argv
|
|
274
|
+
options = {}
|
|
275
|
+
if "--" in argv:
|
|
276
|
+
tokens = argv[argv.index("--") + 1:]
|
|
277
|
+
index = 0
|
|
278
|
+
while index < len(tokens):
|
|
279
|
+
if tokens[index] == "--socket" and index + 1 < len(tokens):
|
|
280
|
+
options["socket"] = tokens[index + 1]
|
|
281
|
+
index += 2
|
|
282
|
+
continue
|
|
283
|
+
index += 1
|
|
284
|
+
socket_path = options.get("socket", DEFAULT_SOCKET)
|
|
285
|
+
|
|
286
|
+
if bootstrap is None:
|
|
287
|
+
print(json.dumps({"kind": "error", "message": BOOTSTRAP_PROBLEM}), flush=True)
|
|
288
|
+
return 2
|
|
289
|
+
|
|
290
|
+
global _BRIDGE
|
|
291
|
+
_BRIDGE = _Bridge(socket_path, {"proc": None})
|
|
292
|
+
_BRIDGE.start()
|
|
293
|
+
print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path}), flush=True)
|
|
294
|
+
try:
|
|
295
|
+
# The serve thread is a daemon, so this loop is what keeps the process alive. A SIGTERM (or
|
|
296
|
+
# the product closing the socket and asking for shutdown) ends it.
|
|
297
|
+
_BRIDGE.thread.join()
|
|
298
|
+
except KeyboardInterrupt:
|
|
299
|
+
pass
|
|
300
|
+
finally:
|
|
301
|
+
_BRIDGE.stop()
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
sys.exit(main())
|