@deepblend/dsh-blender-provider-local 0.2.4 → 0.2.6
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 +340 -26
- package/package.json +2 -2
- package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/bootstrap.py +30 -0
- package/python/deepblend_bridge.py +306 -0
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'
|
|
@@ -142,6 +143,30 @@ export const ProviderConfig = z.object({
|
|
|
142
143
|
capabilitiesCacheMs: z.number().default(60_000),
|
|
143
144
|
/** Retain per-invocation working directories for post-mortem inspection. */
|
|
144
145
|
keepWorkingDirectory: z.boolean().default(false),
|
|
146
|
+
/**
|
|
147
|
+
* Actions served from a KEPT Blender process instead of one process each (SPEC §20 M6).
|
|
148
|
+
*
|
|
149
|
+
* EMPTY BY DEFAULT, AND THAT IS THE HONEST DEFAULT — MEASURED, not cautious. Turning it on for
|
|
150
|
+
* `get_capabilities` and `compile_scene` made three acceptance suites fail, and they were right:
|
|
151
|
+
* a kept session has a NARROWER CONTRACT than a batch invocation. It does not capture per-request
|
|
152
|
+
* stdout (the capture cap and the flood test have nothing to read), a per-request deadline does not
|
|
153
|
+
* kill the process (the deadline test found a Blender still in the process table), and it holds its
|
|
154
|
+
* working directory for as long as it lives (the M0 probe found temp directories left behind).
|
|
155
|
+
*
|
|
156
|
+
* None of that is a bug in the session; it is a different shape with different promises. But a
|
|
157
|
+
* default that silently swaps one set of promises for another is exactly the kind of change this
|
|
158
|
+
* repository's suites exist to catch, so the fast path is OPT IN: an operator who wants it names the
|
|
159
|
+
* actions, and the actions that are safe for it are the pure, short ones — `get_capabilities` and
|
|
160
|
+
* `compile_scene`. MEASURED with it on: three operations cost 832 ms instead of 2594 ms, in one
|
|
161
|
+
* process instead of three (`milestone-status.md` §212.6).
|
|
162
|
+
*
|
|
163
|
+
* The RENDERS are not safe for it in any configuration: cancelling one must kill exactly that
|
|
164
|
+
* render, and the only way this provider can do that is to end the process — which, on an ATTACHED
|
|
165
|
+
* Blender, is the user's own session.
|
|
166
|
+
*/
|
|
167
|
+
sessionActions: z.array(z.string()).default([]),
|
|
168
|
+
/** How long a kept session may sit idle before it is closed. */
|
|
169
|
+
sessionIdleMs: z.number().default(60_000),
|
|
145
170
|
})
|
|
146
171
|
|
|
147
172
|
/**
|
|
@@ -154,6 +179,124 @@ export const ProviderConfig = z.object({
|
|
|
154
179
|
* @property {string} workingDirectory
|
|
155
180
|
*/
|
|
156
181
|
|
|
182
|
+
/** How long an attached Blender gets to say hello before the attach is called a failure. */
|
|
183
|
+
const ATTACH_READY_MS = 10_000
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Connect to a Unix socket, with a deadline.
|
|
187
|
+
*
|
|
188
|
+
* The add-on may be starting when the product asks — a user enables it and the product is already
|
|
189
|
+
* polling — so this retries inside one deadline rather than failing on the first refusal.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} socketPath
|
|
192
|
+
* @param {number} timeoutMs
|
|
193
|
+
* @returns {Promise<import('node:net').Socket>}
|
|
194
|
+
*/
|
|
195
|
+
async function connect(socketPath, timeoutMs) {
|
|
196
|
+
const deadline = Date.now() + Math.min(timeoutMs, 10_000)
|
|
197
|
+
let lastError = null
|
|
198
|
+
for (;;) {
|
|
199
|
+
const socket = await new Promise(resolve => {
|
|
200
|
+
const candidate = net.connect(socketPath)
|
|
201
|
+
candidate.once('connect', () => resolve(candidate))
|
|
202
|
+
candidate.once('error', error => {
|
|
203
|
+
lastError = error
|
|
204
|
+
candidate.destroy()
|
|
205
|
+
resolve(null)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
if (socket !== null) return socket
|
|
209
|
+
if (Date.now() >= deadline) throw lastError ?? new Error('no answer')
|
|
210
|
+
await new Promise(settle => setTimeout(settle, 250))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A live Blender, presented as one shape whatever carries the bytes.
|
|
216
|
+
*
|
|
217
|
+
* WHY THE SEAM EXISTS. A session can be carried two ways, and both are real: the process this
|
|
218
|
+
* provider SPAWNS (`--background`, pipes on stdin/stdout) and the Blender a USER is looking at (the
|
|
219
|
+
* add-on's socket). The conversation — the handshake, the per-request deadline, matching an answer to
|
|
220
|
+
* its request — is identical, and the only thing that differs is who owns the other end. Writing it
|
|
221
|
+
* twice would mean two places to fix the next bug in the deadline, and the second one would be the
|
|
222
|
+
* one nobody runs.
|
|
223
|
+
*
|
|
224
|
+
* The subprocess adapter and the socket adapter below are the whole of the difference.
|
|
225
|
+
*/
|
|
226
|
+
class SubprocessTransport {
|
|
227
|
+
/** @param {object} handle from `ctx.subprocess.spawn` */
|
|
228
|
+
constructor(handle) {
|
|
229
|
+
this.handle = handle
|
|
230
|
+
this.read = handle.stdout
|
|
231
|
+
this.onExit = callback => handle.done?.then?.(() => callback({}), () => callback({}))
|
|
232
|
+
this.pid = null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
write(text) {
|
|
236
|
+
this.handle.stdin.write(text)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
end() {
|
|
240
|
+
try {
|
|
241
|
+
this.handle.stdin.end()
|
|
242
|
+
} catch {
|
|
243
|
+
// Already gone; the exit callback is what decides.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Wait for the process to be gone, bounded. */
|
|
248
|
+
async settle(graceMs) {
|
|
249
|
+
try {
|
|
250
|
+
await this.handle.waitForExit?.(graceMs)
|
|
251
|
+
} catch {
|
|
252
|
+
// A process that had to be terminated is still a settled one.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
class SocketTransport {
|
|
258
|
+
/**
|
|
259
|
+
* @param {import('node:net').Socket} socket
|
|
260
|
+
* @param {{ pid?: number|null }} [info]
|
|
261
|
+
*/
|
|
262
|
+
constructor(socket, info = {}) {
|
|
263
|
+
this.socket = socket
|
|
264
|
+
this.read = socket
|
|
265
|
+
// The pid is the ATTACHED Blender's own, from its handshake — never this process's. A product that
|
|
266
|
+
// reported its own pid here would make "which Blender answered" unanswerable, which is the one
|
|
267
|
+
// question an attach exists to answer.
|
|
268
|
+
this.pid = info.pid ?? null
|
|
269
|
+
this.exitCallbacks = []
|
|
270
|
+
socket.on('close', () => {
|
|
271
|
+
for (const callback of this.exitCallbacks) callback({})
|
|
272
|
+
})
|
|
273
|
+
socket.on('error', () => {
|
|
274
|
+
// An error closes the socket, and the close handler reports it. Swallowing it here keeps a
|
|
275
|
+
// broken pipe from becoming an unhandled 'error' event, which would take the host down.
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
onExit(callback) {
|
|
280
|
+
this.exitCallbacks.push(callback)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
write(text) {
|
|
284
|
+
this.socket.write(text)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
end() {
|
|
288
|
+
try {
|
|
289
|
+
this.socket.end()
|
|
290
|
+
} catch {
|
|
291
|
+
// Already closed.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async settle() {
|
|
296
|
+
this.socket.destroy()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
157
300
|
/**
|
|
158
301
|
* One live Blender process, serving requests until it is closed (SPEC §20 M6).
|
|
159
302
|
*
|
|
@@ -170,10 +313,11 @@ export const ProviderConfig = z.object({
|
|
|
170
313
|
*/
|
|
171
314
|
class BlenderSession {
|
|
172
315
|
/**
|
|
173
|
-
* @param {{
|
|
316
|
+
* @param {{ transport: SubprocessTransport|SocketTransport, directory: string|null, jobId: string, timeoutMs: number, onClose: () => void }} input
|
|
174
317
|
*/
|
|
175
318
|
constructor(input) {
|
|
176
|
-
this.
|
|
319
|
+
this.transport = input.transport
|
|
320
|
+
this.handle = input.transport.handle ?? null
|
|
177
321
|
this.directory = input.directory
|
|
178
322
|
this.jobId = input.jobId
|
|
179
323
|
this.timeoutMs = input.timeoutMs
|
|
@@ -182,7 +326,7 @@ class BlenderSession {
|
|
|
182
326
|
// `done`, `terminate`, `terminateForHostExit`, `waitForExit`. So the pid comes from the process
|
|
183
327
|
// itself, in the `ready` line bootstrap.py opens with — which is better evidence anyway, because
|
|
184
328
|
// it is the pid the script believes it is rather than the one the spawn returned.
|
|
185
|
-
this.pid = null
|
|
329
|
+
this.pid = input.transport.pid ?? null
|
|
186
330
|
/** @type {Map<string, {resolve: Function, reject: Function, timer: object}>} */
|
|
187
331
|
this.waiting = new Map()
|
|
188
332
|
this.progress = []
|
|
@@ -193,15 +337,42 @@ class BlenderSession {
|
|
|
193
337
|
this.readyPromise = null
|
|
194
338
|
this.readyResolve = null
|
|
195
339
|
this.readyReject = null
|
|
340
|
+
this.readySettled = false
|
|
196
341
|
this._read()
|
|
197
342
|
}
|
|
198
343
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Resolve when the other end has announced itself — reject if it dies first, or says nothing.
|
|
346
|
+
*
|
|
347
|
+
* THE DEADLINE IS THE POINT, and a mutation found it missing: with the handshake removed from the
|
|
348
|
+
* bridge, `attachSession` connected successfully and then waited FOREVER, because the socket stayed
|
|
349
|
+
* open and simply never spoke. A hang is worse than either a success or a coded failure — it has no
|
|
350
|
+
* message, no code, and no end — and this repository's rule is that a failure has to be a branchable
|
|
351
|
+
* result. The same deadline that bounds a request bounds the opening of the conversation.
|
|
352
|
+
*/
|
|
353
|
+
ready(timeoutMs = this.timeoutMs) {
|
|
201
354
|
if (this.readyPromise === null) {
|
|
202
355
|
this.readyPromise = new Promise((resolve, reject) => {
|
|
203
|
-
|
|
204
|
-
|
|
356
|
+
const timer = setTimeout(() => {
|
|
357
|
+
if (this.readySettled) return
|
|
358
|
+
this.readySettled = true
|
|
359
|
+
reject(new BlenderError(
|
|
360
|
+
BlenderErrorCode.TIMEOUT,
|
|
361
|
+
`The Blender session (pid ${this.pid ?? 'unknown'}) connected but never announced itself within ${timeoutMs} ms.`,
|
|
362
|
+
{ detail: { pid: this.pid, timeoutMs } },
|
|
363
|
+
))
|
|
364
|
+
}, timeoutMs)
|
|
365
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
366
|
+
this.readyResolve = value => {
|
|
367
|
+
clearTimeout(timer)
|
|
368
|
+
this.readySettled = true
|
|
369
|
+
resolve(value)
|
|
370
|
+
}
|
|
371
|
+
this.readyReject = error => {
|
|
372
|
+
clearTimeout(timer)
|
|
373
|
+
this.readySettled = true
|
|
374
|
+
reject(error)
|
|
375
|
+
}
|
|
205
376
|
})
|
|
206
377
|
}
|
|
207
378
|
return this.readyPromise
|
|
@@ -209,7 +380,7 @@ class BlenderSession {
|
|
|
209
380
|
|
|
210
381
|
/** Read stdout line by line for the life of the process. */
|
|
211
382
|
_read() {
|
|
212
|
-
const stream = this.
|
|
383
|
+
const stream = this.transport.read
|
|
213
384
|
if (stream === undefined || stream === null) {
|
|
214
385
|
this._fail(new BlenderError(BlenderErrorCode.SPAWN_FAILED, 'the session has no stdout stream to read'))
|
|
215
386
|
return
|
|
@@ -229,10 +400,10 @@ class BlenderSession {
|
|
|
229
400
|
// Bounded: stderr is evidence for a failure, not a log to keep.
|
|
230
401
|
this.stderr = (this.stderr + chunk.toString('utf8')).slice(-8192)
|
|
231
402
|
})
|
|
232
|
-
// THE
|
|
233
|
-
//
|
|
234
|
-
// knowable
|
|
235
|
-
this.
|
|
403
|
+
// THE TRANSPORT REPORTS THE END, whatever the end means: for a spawned process the handle's
|
|
404
|
+
// `done` promise, for a socket its 'close'. Neither carries a code — the failure below says what
|
|
405
|
+
// is knowable, which is that the session ended without answering.
|
|
406
|
+
this.transport.onExit(info => this._exited(info))
|
|
236
407
|
}
|
|
237
408
|
|
|
238
409
|
_line(line) {
|
|
@@ -287,7 +458,7 @@ class BlenderSession {
|
|
|
287
458
|
/**
|
|
288
459
|
* Run one action in the live process.
|
|
289
460
|
*
|
|
290
|
-
* @param {{ action: string, payload?: object, jobId?: string }} request
|
|
461
|
+
* @param {{ action: string, payload?: object, args?: string[], jobId?: string }} request
|
|
291
462
|
* @param {{ signal?: AbortSignal }} [options]
|
|
292
463
|
* @returns {Promise<object>} the envelope bootstrap.py built for it.
|
|
293
464
|
*/
|
|
@@ -304,6 +475,11 @@ class BlenderSession {
|
|
|
304
475
|
jobId,
|
|
305
476
|
action: request.action,
|
|
306
477
|
...(request.payload !== undefined ? { payload: request.payload } : {}),
|
|
478
|
+
// PER-REQUEST ARGUMENTS. A session's argv is fixed when the process starts, and the actions that
|
|
479
|
+
// matter need flags (`--scene-spec`, `--output`, …), so the request carries its own and
|
|
480
|
+
// bootstrap.py parses them with the same parser the process used. MEASURED: without this, every
|
|
481
|
+
// real action failed inside a session with "requires --scene-spec".
|
|
482
|
+
...(Array.isArray(request.args) ? { args: request.args } : {}),
|
|
307
483
|
}
|
|
308
484
|
|
|
309
485
|
const answered = new Promise((resolve, reject) => {
|
|
@@ -331,7 +507,7 @@ class BlenderSession {
|
|
|
331
507
|
}
|
|
332
508
|
})
|
|
333
509
|
|
|
334
|
-
this.
|
|
510
|
+
this.transport.write(`${JSON.stringify(document)}\n`)
|
|
335
511
|
return answered
|
|
336
512
|
}
|
|
337
513
|
|
|
@@ -339,17 +515,12 @@ class BlenderSession {
|
|
|
339
515
|
async close() {
|
|
340
516
|
if (this.closed) return
|
|
341
517
|
try {
|
|
342
|
-
this.
|
|
343
|
-
this.
|
|
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)
|
|
518
|
+
this.transport.write(`${JSON.stringify({ action: 'shutdown' })}\n`)
|
|
519
|
+
this.transport.end()
|
|
350
520
|
} catch {
|
|
351
|
-
//
|
|
521
|
+
// The other end may already be gone; the settle below is what decides.
|
|
352
522
|
}
|
|
523
|
+
await this.transport.settle(TERMINATE_GRACE_MS)
|
|
353
524
|
this.closed = true
|
|
354
525
|
this._fail(new BlenderError(
|
|
355
526
|
BlenderErrorCode.ABORTED,
|
|
@@ -376,6 +547,10 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
376
547
|
/** @type {Map<string, import('@deepblend/dsh-blender-contracts').BlenderCapabilities>} */
|
|
377
548
|
_capabilitiesCache = new Map()
|
|
378
549
|
|
|
550
|
+
/** The kept Blender for `sessionActions`, and the timer that closes it when it goes quiet. */
|
|
551
|
+
_session = null
|
|
552
|
+
_sessionTimer = null
|
|
553
|
+
|
|
379
554
|
/**
|
|
380
555
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
381
556
|
* @param {import('z').infer<typeof ProviderConfig>} config - validated by schemastery before construction.
|
|
@@ -389,6 +564,11 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
389
564
|
this.bootstrapPath = this._resolveBootstrapPath(config.bootstrapPath)
|
|
390
565
|
/** Resolved once: the host must be configured with the same value. */
|
|
391
566
|
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot)
|
|
567
|
+
// A KEPT SESSION IS A PROCESS THIS ROW OWNS, so it is disposed with the row rather than left for
|
|
568
|
+
// the host's exit path to find: a plugin that is unloaded must not leave a Blender behind.
|
|
569
|
+
ctx.effect(() => () => {
|
|
570
|
+
this.closeSession().catch(() => {})
|
|
571
|
+
}, 'deepblend kept session teardown')
|
|
392
572
|
}
|
|
393
573
|
|
|
394
574
|
/**
|
|
@@ -692,7 +872,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
692
872
|
})
|
|
693
873
|
|
|
694
874
|
const session = new BlenderSession({
|
|
695
|
-
handle,
|
|
875
|
+
transport: new SubprocessTransport(handle),
|
|
696
876
|
directory,
|
|
697
877
|
jobId,
|
|
698
878
|
timeoutMs: this.config.timeoutMs,
|
|
@@ -702,6 +882,103 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
702
882
|
return session
|
|
703
883
|
}
|
|
704
884
|
|
|
885
|
+
/**
|
|
886
|
+
* Attach to a Blender that is ALREADY RUNNING — the user's own, with the add-on (SPEC §20 M6).
|
|
887
|
+
*
|
|
888
|
+
* THE OTHER HALF OF THE LIVE BRIDGE. `openSession()` spawns a Blender nobody can see; this one talks
|
|
889
|
+
* to the Blender on the user's screen, so the operations happen in the window they are looking at.
|
|
890
|
+
* The conversation is identical — same protocol, same envelopes, same per-request deadline — because
|
|
891
|
+
* both go through `BlenderSession`; the only difference is who owns the other end of the bytes.
|
|
892
|
+
*
|
|
893
|
+
* The pid in the handshake is the ATTACHED Blender's own, and it is what makes "which Blender
|
|
894
|
+
* answered this" answerable. A product that reported its own pid here would make that question
|
|
895
|
+
* unanswerable, which is the one question an attach exists to answer.
|
|
896
|
+
*
|
|
897
|
+
* @param {string} socketPath
|
|
898
|
+
* @param {{ timeoutMs?: number }} [options]
|
|
899
|
+
* @returns {Promise<{ pid: number|null, run: Function, close: Function }>}
|
|
900
|
+
*/
|
|
901
|
+
async attachSession(socketPath, options = {}) {
|
|
902
|
+
if (typeof socketPath !== 'string' || socketPath.length === 0) {
|
|
903
|
+
throw new BlenderError(
|
|
904
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
905
|
+
'Attaching needs the socket path the Blender add-on is listening on.',
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
if (!existsSync(socketPath)) {
|
|
909
|
+
throw new BlenderError(
|
|
910
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
911
|
+
`Nothing is listening at ${socketPath}. Enable the DeepBlend bridge add-on in Blender ` +
|
|
912
|
+
'(View3D > Sidebar > DeepBlend), or start one headless with ' +
|
|
913
|
+
'`blender --background --python deepblend_bridge.py -- --socket <path>`.',
|
|
914
|
+
{ detail: { socketPath } },
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const socket = await connect(socketPath, options.timeoutMs ?? this.config.timeoutMs).catch(cause => {
|
|
919
|
+
throw new BlenderError(
|
|
920
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
921
|
+
`The Blender bridge at ${socketPath} could not be reached: ${cause?.message ?? 'unknown failure'}.`,
|
|
922
|
+
{ detail: { socketPath } },
|
|
923
|
+
)
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
const session = new BlenderSession({
|
|
927
|
+
transport: new SocketTransport(socket),
|
|
928
|
+
directory: null,
|
|
929
|
+
jobId: `attach-${randomUUID()}`,
|
|
930
|
+
timeoutMs: this.config.timeoutMs,
|
|
931
|
+
onClose: () => {},
|
|
932
|
+
})
|
|
933
|
+
// A SHORTER DEADLINE THAN A SPAWN, and the difference is real rather than a preference: a spawned
|
|
934
|
+
// Blender legitimately takes a second or two to boot, while an add-on that is already serving
|
|
935
|
+
// answers the handshake immediately. MEASURED: with the provider's own 180 s timeout, a peer that
|
|
936
|
+
// connected and said nothing took 180 s to report — long enough that a user would conclude the
|
|
937
|
+
// product had hung.
|
|
938
|
+
await session.ready(ATTACH_READY_MS)
|
|
939
|
+
return session
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* The kept session, opened on first use and closed when it has been idle.
|
|
944
|
+
*
|
|
945
|
+
* A SESSION IS NOT A CACHE. It is a Blender process holding a loaded scene, so it is closed on a
|
|
946
|
+
* timer rather than kept forever: an operator who runs one compile and walks away should not find a
|
|
947
|
+
* Blender still resident an hour later. The timer is refreshed by use and cleared on disposal.
|
|
948
|
+
*/
|
|
949
|
+
async _keptSession() {
|
|
950
|
+
if (this._session !== null && !this._session.closed) return this._session
|
|
951
|
+
this._session = await this.openSession()
|
|
952
|
+
return this._session
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
_touchSession() {
|
|
956
|
+
if (this._sessionTimer !== null) clearTimeout(this._sessionTimer)
|
|
957
|
+
this._sessionTimer = setTimeout(() => {
|
|
958
|
+
// NEVER CLOSE UNDER A REQUEST. MEASURED: the first version closed on the timer alone, and a
|
|
959
|
+
// compile that was still running when the idle window expired was rejected with "closed by its
|
|
960
|
+
// caller" — the session ended a request that had done nothing wrong. Idle means idle.
|
|
961
|
+
if (this._session !== null && this._session.waiting.size > 0) {
|
|
962
|
+
this._touchSession()
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
const session = this._session
|
|
966
|
+
this._session = null
|
|
967
|
+
this._sessionTimer = null
|
|
968
|
+
session?.close?.().catch(() => {})
|
|
969
|
+
}, this.config.sessionIdleMs)
|
|
970
|
+
if (typeof this._sessionTimer.unref === 'function') this._sessionTimer.unref()
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Close the kept session now, if there is one. */
|
|
974
|
+
async closeSession() {
|
|
975
|
+
if (this._sessionTimer !== null) clearTimeout(this._sessionTimer)
|
|
976
|
+
this._sessionTimer = null
|
|
977
|
+
const session = this._session
|
|
978
|
+
this._session = null
|
|
979
|
+
if (session !== null) await session.close().catch(() => {})
|
|
980
|
+
}
|
|
981
|
+
|
|
705
982
|
async runBootstrap(request, options = {}) {
|
|
706
983
|
const action = request?.action
|
|
707
984
|
if (typeof action !== 'string' || action.length === 0) {
|
|
@@ -715,6 +992,28 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
715
992
|
)
|
|
716
993
|
}
|
|
717
994
|
|
|
995
|
+
// THE SESSION PATH, for the actions configured to use one. It returns the SAME outcome shape the
|
|
996
|
+
// batch path returns — a caller must not be able to tell which one served it, or the two paths
|
|
997
|
+
// would drift into two behaviours with one name.
|
|
998
|
+
if (this.config.sessionActions.includes(action) && options.session !== false) {
|
|
999
|
+
const session = await this._keptSession()
|
|
1000
|
+
this._touchSession()
|
|
1001
|
+
const startedAt = Date.now()
|
|
1002
|
+
const envelope = await session.run({
|
|
1003
|
+
action,
|
|
1004
|
+
...(request.payload !== undefined ? { payload: request.payload } : {}),
|
|
1005
|
+
...(Array.isArray(options.args) ? { args: options.args } : {}),
|
|
1006
|
+
}, { signal: options.signal })
|
|
1007
|
+
return {
|
|
1008
|
+
envelope,
|
|
1009
|
+
stdout: '',
|
|
1010
|
+
stderr: '',
|
|
1011
|
+
exitCode: envelope.status === 'error' ? 1 : 0,
|
|
1012
|
+
durationMs: Date.now() - startedAt,
|
|
1013
|
+
workingDirectory: session.directory ?? null,
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
718
1017
|
const resolvedExecutable = await this.resolveBlenderExecutable({ signal: options.signal })
|
|
719
1018
|
if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
|
|
720
1019
|
throw resolvedExecutable.error ?? new BlenderError(
|
|
@@ -1065,7 +1364,14 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1065
1364
|
* @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
|
|
1066
1365
|
*/
|
|
1067
1366
|
async compileScene(request) {
|
|
1068
|
-
|
|
1367
|
+
// AN ABSOLUTE, PER-REQUEST OUTPUT PATH, and it is what lets the same action run in either
|
|
1368
|
+
// transport. MEASURED: this used to be the relative `'result.blend'`, which was safe only because
|
|
1369
|
+
// the batch path gives every invocation its own working directory — a session has ONE directory
|
|
1370
|
+
// for every request, so two compiles would write the same file and the caller would be handed a
|
|
1371
|
+
// checkpoint that belonged to a different one. Passing the path explicitly removes the dependency
|
|
1372
|
+
// on a working directory that a kept process does not have per request.
|
|
1373
|
+
const { directory: requestDirectory } = this._createWorkingDirectory()
|
|
1374
|
+
const outputBlend = join(requestDirectory, 'result.blend')
|
|
1069
1375
|
if (typeof request?.sceneSpecPath !== 'string' || request.sceneSpecPath.length === 0) {
|
|
1070
1376
|
throw new BlenderError(
|
|
1071
1377
|
BlenderErrorCode.SCENE_SPEC_INVALID,
|
|
@@ -1083,10 +1389,18 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1083
1389
|
signal: request.signal,
|
|
1084
1390
|
args,
|
|
1085
1391
|
projectRoot: request.projectRoot,
|
|
1086
|
-
onWorkingDirectory:
|
|
1392
|
+
// NOT `onWorkingDirectory` here: the callback has to name the directory that HOLDS the
|
|
1393
|
+
// checkpoint, and that is this method's own (`runBootstrap` would name its invocation
|
|
1394
|
+
// directory, which in a session is a different one). One owner for one path.
|
|
1087
1395
|
},
|
|
1088
1396
|
)
|
|
1089
1397
|
|
|
1398
|
+
// The caller moves the checkpoint somewhere durable before this directory goes away, which is what
|
|
1399
|
+
// lets a compile failure leave no trace in the project.
|
|
1400
|
+
if (typeof request.onWorkingDirectory === 'function') {
|
|
1401
|
+
await request.onWorkingDirectory({ directory: requestDirectory, envelope: run.envelope, jobId: request.jobId })
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1090
1404
|
const report = run.envelope.result ?? {}
|
|
1091
1405
|
return {
|
|
1092
1406
|
report,
|
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.6",
|
|
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.6"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/python/bootstrap.py
CHANGED
|
@@ -429,6 +429,15 @@ def _as_int(value):
|
|
|
429
429
|
raise ActionError("BLENDER_SCRIPT_ERROR", '"%s" is not an integer' % (value,))
|
|
430
430
|
|
|
431
431
|
|
|
432
|
+
# The options a REQUEST may carry, which is the batch path's own list: a session request that names a
|
|
433
|
+
# flag the batch path knows must work, and one it does not must fail the same way.
|
|
434
|
+
KNOWN_SESSION_OPTIONS = (
|
|
435
|
+
"--request", "--result", "--scene-spec", "--blend", "--output-blend",
|
|
436
|
+
"--output", "--camera", "--engine", "--width", "--height", "--samples",
|
|
437
|
+
"--frame", "--profile", "--project-root", "--save-on-failure", "--views",
|
|
438
|
+
"--frames", "--events", "--proc",
|
|
439
|
+
)
|
|
440
|
+
|
|
432
441
|
ACTIONS = {
|
|
433
442
|
"get_capabilities": action_get_capabilities,
|
|
434
443
|
"compile_scene": action_compile_scene,
|
|
@@ -552,6 +561,27 @@ def _session_dispatch(request, options):
|
|
|
552
561
|
"detail": {"action": action, "supported": list(SUPPORTED_ACTIONS)},
|
|
553
562
|
}, [], [])
|
|
554
563
|
|
|
564
|
+
# PER-REQUEST ARGUMENTS, merged over the process's own.
|
|
565
|
+
#
|
|
566
|
+
# MEASURED, and it made sessions useless for the actions that matter: `compile_scene` needs
|
|
567
|
+
# `--scene-spec`, `render_preview` needs `--output`, and the batch path passes those as extra
|
|
568
|
+
# argv — but a session's argv is fixed when the process starts, so the first attempt at reusing a
|
|
569
|
+
# process dropped every one of them and every real action failed with "requires --scene-spec".
|
|
570
|
+
#
|
|
571
|
+
# The fix is not to restart the process with new flags (that is the batch path) but to let a
|
|
572
|
+
# request carry its own, parsed by the SAME parser the process used: one parser, so a flag that
|
|
573
|
+
# works in batch mode works here, including the failure for an unknown one.
|
|
574
|
+
options = options
|
|
575
|
+
if isinstance(request.get("args"), list):
|
|
576
|
+
extra, unknown = parse_args(["--"] + [str(token) for token in request["args"]], KNOWN_SESSION_OPTIONS)
|
|
577
|
+
if unknown:
|
|
578
|
+
return build_envelope(job_id, action, None, {
|
|
579
|
+
"code": "BLENDER_SCRIPT_ERROR",
|
|
580
|
+
"message": "unrecognised arguments: %s" % (", ".join(unknown),),
|
|
581
|
+
}, [], [])
|
|
582
|
+
options = dict(options)
|
|
583
|
+
options.update(extra)
|
|
584
|
+
|
|
555
585
|
eprint("session action=%s job=%s" % (action, job_id))
|
|
556
586
|
# Same claim as the batch path: every request is attributable to this pid, so a session that
|
|
557
587
|
# dies mid-action leaves the same evidence an interrupted render does.
|
|
@@ -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())
|