@deepblend/dsh-blender-provider-local 0.2.5 → 0.2.7
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 +137 -3
- 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/lib/index.js
CHANGED
|
@@ -143,6 +143,44 @@ export const ProviderConfig = z.object({
|
|
|
143
143
|
capabilitiesCacheMs: z.number().default(60_000),
|
|
144
144
|
/** Retain per-invocation working directories for post-mortem inspection. */
|
|
145
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),
|
|
170
|
+
/**
|
|
171
|
+
* The socket a Blender bridge is listening on, when the operator wants the session actions served by
|
|
172
|
+
* THE USER'S OWN BLENDER rather than one this provider spawns (SPEC §20 M6 "Live Bridge").
|
|
173
|
+
*
|
|
174
|
+
* Empty means "spawn one", which is what every deployment did before this key existed. Setting it is
|
|
175
|
+
* how a user says *use the Blender I already have open* — and the difference is not speed, it is that
|
|
176
|
+
* they can watch the operations happen in the window they are looking at.
|
|
177
|
+
*
|
|
178
|
+
* A CONFIGURED SOCKET THAT DOES NOT ANSWER IS AN ERROR, not a fallback to spawning. Silently doing
|
|
179
|
+
* something else than what was asked is the failure mode this repository keeps paying for: the user
|
|
180
|
+
* would see operations succeed and never learn that their Blender was not the one doing them. The
|
|
181
|
+
* refusal names the add-on, because that is what has to be enabled.
|
|
182
|
+
*/
|
|
183
|
+
sessionSocket: z.string().default(''),
|
|
146
184
|
})
|
|
147
185
|
|
|
148
186
|
/**
|
|
@@ -434,7 +472,7 @@ class BlenderSession {
|
|
|
434
472
|
/**
|
|
435
473
|
* Run one action in the live process.
|
|
436
474
|
*
|
|
437
|
-
* @param {{ action: string, payload?: object, jobId?: string }} request
|
|
475
|
+
* @param {{ action: string, payload?: object, args?: string[], jobId?: string }} request
|
|
438
476
|
* @param {{ signal?: AbortSignal }} [options]
|
|
439
477
|
* @returns {Promise<object>} the envelope bootstrap.py built for it.
|
|
440
478
|
*/
|
|
@@ -451,6 +489,11 @@ class BlenderSession {
|
|
|
451
489
|
jobId,
|
|
452
490
|
action: request.action,
|
|
453
491
|
...(request.payload !== undefined ? { payload: request.payload } : {}),
|
|
492
|
+
// PER-REQUEST ARGUMENTS. A session's argv is fixed when the process starts, and the actions that
|
|
493
|
+
// matter need flags (`--scene-spec`, `--output`, …), so the request carries its own and
|
|
494
|
+
// bootstrap.py parses them with the same parser the process used. MEASURED: without this, every
|
|
495
|
+
// real action failed inside a session with "requires --scene-spec".
|
|
496
|
+
...(Array.isArray(request.args) ? { args: request.args } : {}),
|
|
454
497
|
}
|
|
455
498
|
|
|
456
499
|
const answered = new Promise((resolve, reject) => {
|
|
@@ -518,6 +561,10 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
518
561
|
/** @type {Map<string, import('@deepblend/dsh-blender-contracts').BlenderCapabilities>} */
|
|
519
562
|
_capabilitiesCache = new Map()
|
|
520
563
|
|
|
564
|
+
/** The kept Blender for `sessionActions`, and the timer that closes it when it goes quiet. */
|
|
565
|
+
_session = null
|
|
566
|
+
_sessionTimer = null
|
|
567
|
+
|
|
521
568
|
/**
|
|
522
569
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
523
570
|
* @param {import('z').infer<typeof ProviderConfig>} config - validated by schemastery before construction.
|
|
@@ -531,6 +578,11 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
531
578
|
this.bootstrapPath = this._resolveBootstrapPath(config.bootstrapPath)
|
|
532
579
|
/** Resolved once: the host must be configured with the same value. */
|
|
533
580
|
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot)
|
|
581
|
+
// A KEPT SESSION IS A PROCESS THIS ROW OWNS, so it is disposed with the row rather than left for
|
|
582
|
+
// the host's exit path to find: a plugin that is unloaded must not leave a Blender behind.
|
|
583
|
+
ctx.effect(() => () => {
|
|
584
|
+
this.closeSession().catch(() => {})
|
|
585
|
+
}, 'deepblend kept session teardown')
|
|
534
586
|
}
|
|
535
587
|
|
|
536
588
|
/**
|
|
@@ -901,6 +953,51 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
901
953
|
return session
|
|
902
954
|
}
|
|
903
955
|
|
|
956
|
+
/**
|
|
957
|
+
* The kept session, opened on first use and closed when it has been idle.
|
|
958
|
+
*
|
|
959
|
+
* A SESSION IS NOT A CACHE. It is a Blender process holding a loaded scene, so it is closed on a
|
|
960
|
+
* timer rather than kept forever: an operator who runs one compile and walks away should not find a
|
|
961
|
+
* Blender still resident an hour later. The timer is refreshed by use and cleared on disposal.
|
|
962
|
+
*/
|
|
963
|
+
async _keptSession() {
|
|
964
|
+
if (this._session !== null && !this._session.closed) return this._session
|
|
965
|
+
const socket = this.config.sessionSocket
|
|
966
|
+
// THE USER'S BLENDER, when they have named one. `attachSession` owns the refusal and its message;
|
|
967
|
+
// this only decides which of the two transports the configured actions travel over.
|
|
968
|
+
this._session = typeof socket === 'string' && socket.length > 0
|
|
969
|
+
? await this.attachSession(socket)
|
|
970
|
+
: await this.openSession()
|
|
971
|
+
return this._session
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
_touchSession() {
|
|
975
|
+
if (this._sessionTimer !== null) clearTimeout(this._sessionTimer)
|
|
976
|
+
this._sessionTimer = setTimeout(() => {
|
|
977
|
+
// NEVER CLOSE UNDER A REQUEST. MEASURED: the first version closed on the timer alone, and a
|
|
978
|
+
// compile that was still running when the idle window expired was rejected with "closed by its
|
|
979
|
+
// caller" — the session ended a request that had done nothing wrong. Idle means idle.
|
|
980
|
+
if (this._session !== null && this._session.waiting.size > 0) {
|
|
981
|
+
this._touchSession()
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
const session = this._session
|
|
985
|
+
this._session = null
|
|
986
|
+
this._sessionTimer = null
|
|
987
|
+
session?.close?.().catch(() => {})
|
|
988
|
+
}, this.config.sessionIdleMs)
|
|
989
|
+
if (typeof this._sessionTimer.unref === 'function') this._sessionTimer.unref()
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Close the kept session now, if there is one. */
|
|
993
|
+
async closeSession() {
|
|
994
|
+
if (this._sessionTimer !== null) clearTimeout(this._sessionTimer)
|
|
995
|
+
this._sessionTimer = null
|
|
996
|
+
const session = this._session
|
|
997
|
+
this._session = null
|
|
998
|
+
if (session !== null) await session.close().catch(() => {})
|
|
999
|
+
}
|
|
1000
|
+
|
|
904
1001
|
async runBootstrap(request, options = {}) {
|
|
905
1002
|
const action = request?.action
|
|
906
1003
|
if (typeof action !== 'string' || action.length === 0) {
|
|
@@ -914,6 +1011,28 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
914
1011
|
)
|
|
915
1012
|
}
|
|
916
1013
|
|
|
1014
|
+
// THE SESSION PATH, for the actions configured to use one. It returns the SAME outcome shape the
|
|
1015
|
+
// batch path returns — a caller must not be able to tell which one served it, or the two paths
|
|
1016
|
+
// would drift into two behaviours with one name.
|
|
1017
|
+
if (this.config.sessionActions.includes(action) && options.session !== false) {
|
|
1018
|
+
const session = await this._keptSession()
|
|
1019
|
+
this._touchSession()
|
|
1020
|
+
const startedAt = Date.now()
|
|
1021
|
+
const envelope = await session.run({
|
|
1022
|
+
action,
|
|
1023
|
+
...(request.payload !== undefined ? { payload: request.payload } : {}),
|
|
1024
|
+
...(Array.isArray(options.args) ? { args: options.args } : {}),
|
|
1025
|
+
}, { signal: options.signal })
|
|
1026
|
+
return {
|
|
1027
|
+
envelope,
|
|
1028
|
+
stdout: '',
|
|
1029
|
+
stderr: '',
|
|
1030
|
+
exitCode: envelope.status === 'error' ? 1 : 0,
|
|
1031
|
+
durationMs: Date.now() - startedAt,
|
|
1032
|
+
workingDirectory: session.directory ?? null,
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
917
1036
|
const resolvedExecutable = await this.resolveBlenderExecutable({ signal: options.signal })
|
|
918
1037
|
if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
|
|
919
1038
|
throw resolvedExecutable.error ?? new BlenderError(
|
|
@@ -1264,7 +1383,14 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1264
1383
|
* @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
|
|
1265
1384
|
*/
|
|
1266
1385
|
async compileScene(request) {
|
|
1267
|
-
|
|
1386
|
+
// AN ABSOLUTE, PER-REQUEST OUTPUT PATH, and it is what lets the same action run in either
|
|
1387
|
+
// transport. MEASURED: this used to be the relative `'result.blend'`, which was safe only because
|
|
1388
|
+
// the batch path gives every invocation its own working directory — a session has ONE directory
|
|
1389
|
+
// for every request, so two compiles would write the same file and the caller would be handed a
|
|
1390
|
+
// checkpoint that belonged to a different one. Passing the path explicitly removes the dependency
|
|
1391
|
+
// on a working directory that a kept process does not have per request.
|
|
1392
|
+
const { directory: requestDirectory } = this._createWorkingDirectory()
|
|
1393
|
+
const outputBlend = join(requestDirectory, 'result.blend')
|
|
1268
1394
|
if (typeof request?.sceneSpecPath !== 'string' || request.sceneSpecPath.length === 0) {
|
|
1269
1395
|
throw new BlenderError(
|
|
1270
1396
|
BlenderErrorCode.SCENE_SPEC_INVALID,
|
|
@@ -1282,10 +1408,18 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1282
1408
|
signal: request.signal,
|
|
1283
1409
|
args,
|
|
1284
1410
|
projectRoot: request.projectRoot,
|
|
1285
|
-
onWorkingDirectory:
|
|
1411
|
+
// NOT `onWorkingDirectory` here: the callback has to name the directory that HOLDS the
|
|
1412
|
+
// checkpoint, and that is this method's own (`runBootstrap` would name its invocation
|
|
1413
|
+
// directory, which in a session is a different one). One owner for one path.
|
|
1286
1414
|
},
|
|
1287
1415
|
)
|
|
1288
1416
|
|
|
1417
|
+
// The caller moves the checkpoint somewhere durable before this directory goes away, which is what
|
|
1418
|
+
// lets a compile failure leave no trace in the project.
|
|
1419
|
+
if (typeof request.onWorkingDirectory === 'function') {
|
|
1420
|
+
await request.onWorkingDirectory({ directory: requestDirectory, envelope: run.envelope, jobId: request.jobId })
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1289
1423
|
const report = run.envelope.result ?? {}
|
|
1290
1424
|
return {
|
|
1291
1425
|
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.7",
|
|
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.7"
|
|
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.
|