@deepblend/dsh-blender-provider-local 0.1.0
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 +1610 -0
- package/package.json +25 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.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/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
- package/python/bootstrap.py +625 -0
- package/python/deepblend_capabilities.py +633 -0
- package/python/deepblend_frames.py +402 -0
- package/python/deepblend_render.py +299 -0
- package/python/deepblend_scene.py +1537 -0
- package/python/deepblend_util.py +264 -0
- package/python/deepblend_validate.py +358 -0
- package/python/deepblend_views.py +745 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deepblend/dsh-blender-provider-local
|
|
3
|
+
*
|
|
4
|
+
* The Local Blender Provider: the concrete `BlenderRuntime` Service over
|
|
5
|
+
* `ctx.subprocess` (SPEC §7.1, §9.1, §9.2).
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities, and deliberately nothing more:
|
|
8
|
+
* - resolve and allowlist the Blender executable (SPEC §15.2);
|
|
9
|
+
* - prepare an isolated per-invocation working directory;
|
|
10
|
+
* - launch `Blender --background --factory-startup --python bootstrap.py -- ...`
|
|
11
|
+
* as an **argv array** through `ctx.subprocess.spawn` — never a shell string;
|
|
12
|
+
* - read, validate and type the JSON result envelope bootstrap.py writes;
|
|
13
|
+
* - classify every failure into a stable error code (SPEC §9.4);
|
|
14
|
+
* - own the deadline, the AbortSignal and process termination.
|
|
15
|
+
*
|
|
16
|
+
* It holds NO business state machine, NO revision logic and NO orchestration.
|
|
17
|
+
*
|
|
18
|
+
* Owner: DeepBlend Studio — M0
|
|
19
|
+
* Plane: Host composition (registered by @deepblend/dsh-blender-bundle)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { readFile } from 'node:fs/promises'
|
|
25
|
+
import { delimiter, isAbsolute, join, resolve, sep } from 'node:path'
|
|
26
|
+
import { randomUUID } from 'node:crypto'
|
|
27
|
+
|
|
28
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
29
|
+
import z from '@deepseek-ai/schemastery'
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
BLENDER_ENGINE_BY_KEY,
|
|
33
|
+
BLENDER_PROTOCOL_VERSION,
|
|
34
|
+
FRAME_PLAN_VERSION,
|
|
35
|
+
BlenderError,
|
|
36
|
+
BlenderErrorCode,
|
|
37
|
+
CANDIDATE_RENDER_ENGINES,
|
|
38
|
+
EXPECTED_EXPORT_FORMATS,
|
|
39
|
+
EXPECTED_IMPORT_FORMATS,
|
|
40
|
+
BlenderWarningCode,
|
|
41
|
+
assertKnownConfigKeys,
|
|
42
|
+
managedBlenderCandidates,
|
|
43
|
+
resolveWorkspaceRoot,
|
|
44
|
+
warning,
|
|
45
|
+
} from '@deepblend/dsh-blender-contracts'
|
|
46
|
+
|
|
47
|
+
/** Service key registered into the Cordis context. */
|
|
48
|
+
export const BLENDER_RUNTIME_SERVICE = 'blenderRuntime'
|
|
49
|
+
|
|
50
|
+
/** Default cap for captured Blender stdout/stderr before spilling. */
|
|
51
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024
|
|
52
|
+
/** Default cap for the spill file that holds the complete stream. */
|
|
53
|
+
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
/** Grace period between SIGTERM and SIGKILL when terminating Blender. */
|
|
56
|
+
const TERMINATE_GRACE_MS = 10_000
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The `blenderPath` value meaning "find one": managed install first, then PATH.
|
|
60
|
+
* @see ProviderConfig.blenderPath
|
|
61
|
+
*/
|
|
62
|
+
const AUTO_BLENDER_PATH = 'auto'
|
|
63
|
+
|
|
64
|
+
/** What `'auto'` falls back to when no managed install exists. */
|
|
65
|
+
const FALLBACK_BLENDER_NAME = 'blender'
|
|
66
|
+
|
|
67
|
+
/** How far up from `lib/` the managed-install search walks before giving up. */
|
|
68
|
+
const MANAGED_ROOT_SEARCH_DEPTH = 6
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Directories a resolved Blender executable is permitted to live in.
|
|
72
|
+
*
|
|
73
|
+
* The first entry is the workspace-managed install produced during M0 setup.
|
|
74
|
+
* An explicit absolute `blenderPath` in configuration is additionally allowed,
|
|
75
|
+
* because an operator who writes an absolute path has already made a deliberate
|
|
76
|
+
* statement; a *bare name* resolves through PATH and must land inside this list.
|
|
77
|
+
* This is the SPEC §15.2 allowlist, made realpath-aware so a symlink cannot
|
|
78
|
+
* escape it.
|
|
79
|
+
*/
|
|
80
|
+
function defaultAllowlist() {
|
|
81
|
+
const home = process.env.HOME ?? ''
|
|
82
|
+
return [
|
|
83
|
+
'/Applications',
|
|
84
|
+
'/opt/homebrew/bin',
|
|
85
|
+
'/usr/local/bin',
|
|
86
|
+
home ? join(home, 'Applications') : '',
|
|
87
|
+
].filter(entry => entry.length > 0)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Configuration schema. Every field is validated at load time (SPEC §17), so a
|
|
92
|
+
* malformed composition row fails loudly at mount rather than at first use.
|
|
93
|
+
*
|
|
94
|
+
* Named `ProviderConfig`, NOT `Config`. The class below declares `static Config`,
|
|
95
|
+
* and a module-scope `Config` would be shadowed by that class field inside the
|
|
96
|
+
* class body — making `static Config = Config` a temporal-dead-zone
|
|
97
|
+
* ReferenceError during module evaluation. That throws at bundle load and takes
|
|
98
|
+
* the whole row down with it, which no import-time test can see.
|
|
99
|
+
*/
|
|
100
|
+
export const ProviderConfig = z.object({
|
|
101
|
+
/**
|
|
102
|
+
* Where to find Blender, in one of three forms:
|
|
103
|
+
*
|
|
104
|
+
* `'auto'` (the default) — the workspace-managed install that
|
|
105
|
+
* `deepblend/tools/install-blender.mjs` produces, if one exists,
|
|
106
|
+
* otherwise a bare `blender` resolved through PATH;
|
|
107
|
+
* an absolute path — trusted as a deliberate operator choice (SPEC §15.2),
|
|
108
|
+
* with the allowlist bypassed;
|
|
109
|
+
* a bare name — resolved through the scrubbed PATH and required to land
|
|
110
|
+
* inside the allowlist.
|
|
111
|
+
*
|
|
112
|
+
* The managed install is tried FIRST because it is the build the integration
|
|
113
|
+
* suites measured; preferring whatever happens to be on PATH would let the
|
|
114
|
+
* product run a different Blender from the one the tests verified.
|
|
115
|
+
*/
|
|
116
|
+
blenderPath: z.string().default('auto'),
|
|
117
|
+
/**
|
|
118
|
+
* Absolute path to bootstrap.py. Left unset, the copy shipped inside THIS
|
|
119
|
+
* package is used (`lib/` → `../python/bootstrap.py`), which is why the bundle
|
|
120
|
+
* patch no longer needs to name a machine's path here.
|
|
121
|
+
*/
|
|
122
|
+
bootstrapPath: z.string().default(''),
|
|
123
|
+
/**
|
|
124
|
+
* Workspace root. Per-invocation working directories are created here, keeping
|
|
125
|
+
* every write inside the project workspace (SPEC §15.2 "工作区路径边界").
|
|
126
|
+
*
|
|
127
|
+
* Unset, it defaults to `<DSH_HOME>/deepblend` (SPEC §17). It must be the SAME
|
|
128
|
+
* root the host row resolves, or a staging path the host hands out is one this
|
|
129
|
+
* provider refuses; both resolve it through `resolveWorkspaceRoot`, so they
|
|
130
|
+
* agree by construction rather than by an operator remembering to set both.
|
|
131
|
+
*/
|
|
132
|
+
workspaceRoot: z.string().default(''),
|
|
133
|
+
/** Deadline for a single bootstrap invocation, in ms. */
|
|
134
|
+
timeoutMs: z.number().default(180_000),
|
|
135
|
+
/** Cap on captured stdout bytes before spilling to a file. */
|
|
136
|
+
maxOutputBytes: z.number().default(DEFAULT_MAX_OUTPUT_BYTES),
|
|
137
|
+
/** Cap on the spill file holding the complete stream. */
|
|
138
|
+
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
|
139
|
+
/** Extra directories added to the executable allowlist. */
|
|
140
|
+
executableAllowlist: z.array(z.string()).default([]),
|
|
141
|
+
/** How long a capabilities result may be reused before re-probing. */
|
|
142
|
+
capabilitiesCacheMs: z.number().default(60_000),
|
|
143
|
+
/** Retain per-invocation working directories for post-mortem inspection. */
|
|
144
|
+
keepWorkingDirectory: z.boolean().default(false),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @typedef {object} BootstrapRunOutcome
|
|
149
|
+
* @property {Record<string, unknown>} envelope - the parsed result document.
|
|
150
|
+
* @property {string} stdout - captured stdout tail.
|
|
151
|
+
* @property {string} stderr - captured stderr tail.
|
|
152
|
+
* @property {number|null} exitCode
|
|
153
|
+
* @property {number} durationMs
|
|
154
|
+
* @property {string} workingDirectory
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Local Blender provider.
|
|
159
|
+
*
|
|
160
|
+
* Register by loading this package as a host composition row; it publishes the
|
|
161
|
+
* `blenderRuntime` Service.
|
|
162
|
+
*/
|
|
163
|
+
export default class LocalBlenderRuntime extends Service {
|
|
164
|
+
// `subprocess` is a hard dependency: this provider cannot function without it,
|
|
165
|
+
// so declaring it makes Cordis hold the row in `waiting` until it appears
|
|
166
|
+
// rather than throwing at first call.
|
|
167
|
+
static inject = ['subprocess']
|
|
168
|
+
|
|
169
|
+
static Config = ProviderConfig
|
|
170
|
+
|
|
171
|
+
/** @type {Map<string, import('@deepblend/dsh-blender-contracts').BlenderCapabilities>} */
|
|
172
|
+
_capabilitiesCache = new Map()
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
176
|
+
* @param {import('z').infer<typeof ProviderConfig>} config - validated by schemastery before construction.
|
|
177
|
+
*/
|
|
178
|
+
constructor(ctx, config) {
|
|
179
|
+
super(ctx, BLENDER_RUNTIME_SERVICE)
|
|
180
|
+
// See the host's constructor: a key this row does not read is a startup error, because the schema
|
|
181
|
+
// accepts it silently (SPEC §17's nested groups versus this package's flat keys).
|
|
182
|
+
assertKnownConfigKeys(ProviderConfig, config, 'deepblend-blender-runtime')
|
|
183
|
+
this.config = config
|
|
184
|
+
this.bootstrapPath = this._resolveBootstrapPath(config.bootstrapPath)
|
|
185
|
+
/** Resolved once: the host must be configured with the same value. */
|
|
186
|
+
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The `blenderPath` request to hand to `ctx.subprocess.resolveExecutable`.
|
|
191
|
+
*
|
|
192
|
+
* `'auto'` means "the managed install if there is one, else PATH" — and the
|
|
193
|
+
* CHOICE is made here, once, because two other places need to agree with it:
|
|
194
|
+
* `_assertAllowed` decides whether the allowlist applies from this string, and
|
|
195
|
+
* `getCapabilities` keys its cache on it. Resolving it twice is how a probe
|
|
196
|
+
* reports one binary while a render launches another.
|
|
197
|
+
*
|
|
198
|
+
* @returns {string} an absolute path, or a bare name for PATH resolution
|
|
199
|
+
*/
|
|
200
|
+
_requestedBlenderPath() {
|
|
201
|
+
const configured = this.config.blenderPath
|
|
202
|
+
if (configured !== AUTO_BLENDER_PATH) return configured
|
|
203
|
+
for (const candidate of managedBlenderCandidates(this._managedRoots())) {
|
|
204
|
+
if (existsSync(candidate)) return candidate
|
|
205
|
+
}
|
|
206
|
+
return FALLBACK_BLENDER_NAME
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Directories that might contain the `.tools` managed install.
|
|
211
|
+
*
|
|
212
|
+
* Derived from THIS package's location rather than configured, because a
|
|
213
|
+
* package can always find its own repository and no configuration can be
|
|
214
|
+
* correct on a machine it was not written on. Walking up a bounded number of
|
|
215
|
+
* levels covers both a plain checkout (`packages/deepblend/provider-local/lib`)
|
|
216
|
+
* and a profile symlink pointing into one; the first level that has a `.tools`
|
|
217
|
+
* directory wins, and a published package outside any repository simply finds
|
|
218
|
+
* nothing and falls back to PATH.
|
|
219
|
+
*
|
|
220
|
+
* @returns {string[]}
|
|
221
|
+
*/
|
|
222
|
+
_managedRoots() {
|
|
223
|
+
const roots = []
|
|
224
|
+
let directory = import.meta.dirname
|
|
225
|
+
for (let level = 0; level < MANAGED_ROOT_SEARCH_DEPTH; level += 1) {
|
|
226
|
+
const parent = resolve(directory, '..')
|
|
227
|
+
if (parent === directory) break
|
|
228
|
+
roots.push(parent)
|
|
229
|
+
directory = parent
|
|
230
|
+
}
|
|
231
|
+
return roots
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolve bootstrap.py, preferring the configured path and falling back to the
|
|
236
|
+
* copy shipped beside this package so the default composition needs no config.
|
|
237
|
+
* @param {string} configured
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
_resolveBootstrapPath(configured) {
|
|
241
|
+
if (configured && configured.trim().length > 0) return resolve(configured)
|
|
242
|
+
// lib/ -> package root -> python/bootstrap.py
|
|
243
|
+
return resolve(import.meta.dirname, '..', 'python', 'bootstrap.py')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the Blender executable WITHOUT throwing, so `getCapabilities` can
|
|
248
|
+
* report absence as data (SPEC §9.4 distinguishes "executable not found" from
|
|
249
|
+
* every other failure; M0 must describe a machine that has no Blender).
|
|
250
|
+
*
|
|
251
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
252
|
+
* @returns {Promise<{ resolved: string|null, requested: string, error: BlenderError|null }>}
|
|
253
|
+
*/
|
|
254
|
+
async resolveBlenderExecutable(options = {}) {
|
|
255
|
+
const requested = this._requestedBlenderPath()
|
|
256
|
+
try {
|
|
257
|
+
// `ctx.subprocess.resolveExecutable` verifies absolute paths and resolves
|
|
258
|
+
// bare names against the provider's scrubbed PATH. Relative paths with
|
|
259
|
+
// separators are rejected by the service itself.
|
|
260
|
+
const resolved = await this.ctx.subprocess.resolveExecutable(requested, undefined, options.signal)
|
|
261
|
+
const canonical = this._assertAllowed(resolved, requested)
|
|
262
|
+
return { resolved: canonical, requested, error: null }
|
|
263
|
+
} catch (cause) {
|
|
264
|
+
// The ADVICE travels with the failure, because two readers need the same sentence: the settings
|
|
265
|
+
// card a human opens and the capability text a model reads. Composing it in either of them would
|
|
266
|
+
// be a second copy of "what to do when there is no Blender", and this repository has paid for
|
|
267
|
+
// that shape often enough (see `_blenderPathAdvice`).
|
|
268
|
+
const advice = blenderPathAdvice({ requested, fallbackName: FALLBACK_BLENDER_NAME })
|
|
269
|
+
if (cause instanceof BlenderError) return { resolved: null, requested, error: cause, advice }
|
|
270
|
+
return {
|
|
271
|
+
resolved: null,
|
|
272
|
+
requested,
|
|
273
|
+
advice,
|
|
274
|
+
error: new BlenderError(
|
|
275
|
+
BlenderErrorCode.NOT_FOUND,
|
|
276
|
+
`Blender executable could not be resolved from "${requested}". ` +
|
|
277
|
+
`Run deepblend/tools/install-blender.mjs, install Blender, or set ` +
|
|
278
|
+
`deepblend.blenderPath to its absolute path.`,
|
|
279
|
+
{ cause },
|
|
280
|
+
),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Enforce the executable allowlist against the real (symlink-resolved) path.
|
|
287
|
+
*
|
|
288
|
+
* The rule depends on WHAT WAS ASKED FOR, not on what the config literally
|
|
289
|
+
* says. `'auto'` is not what gets resolved — `_requestedBlenderPath()` turns it
|
|
290
|
+
* into either the managed install's absolute path (a deliberate choice, like an
|
|
291
|
+
* operator's) or the bare name `blender` (a PATH lookup, which must satisfy the
|
|
292
|
+
* allowlist). Reading `this.config.blenderPath` here instead would test the
|
|
293
|
+
* string `'auto'`, find it is not absolute, and then refuse the managed install
|
|
294
|
+
* the repository ships — the exact failure this distinction exists to avoid.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} candidate
|
|
297
|
+
* @param {string} requested - what `_requestedBlenderPath()` produced
|
|
298
|
+
* @returns {string} the canonical path
|
|
299
|
+
*/
|
|
300
|
+
_assertAllowed(candidate, requested) {
|
|
301
|
+
if (!isAbsolute(candidate)) {
|
|
302
|
+
throw new BlenderError(
|
|
303
|
+
BlenderErrorCode.NOT_FOUND,
|
|
304
|
+
`Resolved Blender path is not absolute: ${candidate}`,
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let canonical
|
|
309
|
+
try {
|
|
310
|
+
canonical = realpathSync(candidate)
|
|
311
|
+
} catch (cause) {
|
|
312
|
+
throw new BlenderError(
|
|
313
|
+
BlenderErrorCode.NOT_FOUND,
|
|
314
|
+
`Blender executable does not exist: ${candidate}`,
|
|
315
|
+
{ cause },
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let stats
|
|
320
|
+
try {
|
|
321
|
+
stats = statSync(canonical)
|
|
322
|
+
} catch (cause) {
|
|
323
|
+
throw new BlenderError(
|
|
324
|
+
BlenderErrorCode.NOT_FOUND,
|
|
325
|
+
`Blender executable is not stat-able: ${canonical}`,
|
|
326
|
+
{ cause },
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
if (stats.isDirectory()) {
|
|
330
|
+
throw new BlenderError(
|
|
331
|
+
BlenderErrorCode.EXECUTABLE_NOT_EXECUTABLE,
|
|
332
|
+
`Configured Blender path is a directory, not an executable: ${canonical}`,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A bare name was resolved through PATH, so it must satisfy the allowlist.
|
|
337
|
+
// An absolute request — the operator's or the managed install's — is trusted.
|
|
338
|
+
const requestedIsAbsolute = isAbsolute(requested)
|
|
339
|
+
if (!requestedIsAbsolute && !this._insideAllowlist(canonical)) {
|
|
340
|
+
throw new BlenderError(
|
|
341
|
+
BlenderErrorCode.EXECUTABLE_OUTSIDE_ALLOWLIST,
|
|
342
|
+
`Blender resolved from PATH to ${canonical}, which is outside the configured allowlist. ` +
|
|
343
|
+
`Add its directory to deepblend.executableAllowlist to permit it, or set ` +
|
|
344
|
+
`deepblend.blenderPath to its absolute path.`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
return canonical
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} canonical realpath'd candidate
|
|
352
|
+
* @returns {boolean}
|
|
353
|
+
*/
|
|
354
|
+
_insideAllowlist(canonical) {
|
|
355
|
+
const roots = [...defaultAllowlist(), ...this.config.executableAllowlist]
|
|
356
|
+
for (const root of roots) {
|
|
357
|
+
let canonicalRoot
|
|
358
|
+
try {
|
|
359
|
+
canonicalRoot = realpathSync(root)
|
|
360
|
+
} catch {
|
|
361
|
+
continue // A non-existent allowlist root simply permits nothing.
|
|
362
|
+
}
|
|
363
|
+
if (canonical === canonicalRoot) return true
|
|
364
|
+
if (canonical.startsWith(canonicalRoot.endsWith(sep) ? canonicalRoot : canonicalRoot + sep)) {
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Create an isolated per-invocation working directory inside the workspace.
|
|
373
|
+
* @returns {{ jobId: string, directory: string }}
|
|
374
|
+
*/
|
|
375
|
+
_createWorkingDirectory() {
|
|
376
|
+
const jobId = `m0-${randomUUID()}`
|
|
377
|
+
const root = resolve(this.workspaceRoot, 'tmp')
|
|
378
|
+
mkdirSync(root, { recursive: true })
|
|
379
|
+
const directory = join(root, jobId)
|
|
380
|
+
mkdirSync(directory, { recursive: true })
|
|
381
|
+
return { jobId, directory }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Read a collected stream from offset 0.
|
|
386
|
+
*
|
|
387
|
+
* `SubprocessHandle.collected` readers are offset-based and non-consuming, so
|
|
388
|
+
* readFrom(0) always returns the whole captured stream.
|
|
389
|
+
* @param {import('@deepseek-ai/dsh-subprocess').SubprocessOutputReader|undefined} reader
|
|
390
|
+
* @returns {string}
|
|
391
|
+
*/
|
|
392
|
+
_readAll(reader) {
|
|
393
|
+
if (!reader) return ''
|
|
394
|
+
try {
|
|
395
|
+
return reader.readFrom(0).text ?? ''
|
|
396
|
+
} catch {
|
|
397
|
+
return ''
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Run one bootstrap action end-to-end.
|
|
403
|
+
*
|
|
404
|
+
* The result is transported via a JSON **file**, not stdout: Blender writes its
|
|
405
|
+
* own banners and add-on chatter to stdout, so a stdout-scraping protocol
|
|
406
|
+
* would be fragile. stdout/stderr are captured only for diagnostics.
|
|
407
|
+
*
|
|
408
|
+
* @param {import('@deepblend/dsh-blender-contracts').BlenderBootstrapRequest} request
|
|
409
|
+
* @param {{
|
|
410
|
+
* signal?: AbortSignal,
|
|
411
|
+
* args?: string[],
|
|
412
|
+
* cwd?: string,
|
|
413
|
+
* projectRoot?: string,
|
|
414
|
+
* prepareDirectory?: (info: { directory: string, jobId: string }) => void|Promise<void>,
|
|
415
|
+
* onWorkingDirectory?: (info: {
|
|
416
|
+
* directory: string, jobId: string, envelope: object,
|
|
417
|
+
* requestPath: string, resultPath: string,
|
|
418
|
+
* }) => void|Promise<void>,
|
|
419
|
+
* }} [options]
|
|
420
|
+
* @returns {Promise<BootstrapRunOutcome>}
|
|
421
|
+
*/
|
|
422
|
+
async runBootstrap(request, options = {}) {
|
|
423
|
+
const action = request?.action
|
|
424
|
+
if (typeof action !== 'string' || action.length === 0) {
|
|
425
|
+
throw new BlenderError(BlenderErrorCode.UNSUPPORTED_ACTION, 'A bootstrap action name is required.')
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!existsSync(this.bootstrapPath)) {
|
|
429
|
+
throw new BlenderError(
|
|
430
|
+
BlenderErrorCode.BOOTSTRAP_MISSING,
|
|
431
|
+
`bootstrap.py not found at ${this.bootstrapPath}.`,
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const resolvedExecutable = await this.resolveBlenderExecutable({ signal: options.signal })
|
|
436
|
+
if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
|
|
437
|
+
throw resolvedExecutable.error ?? new BlenderError(
|
|
438
|
+
BlenderErrorCode.NOT_FOUND,
|
|
439
|
+
`Blender executable could not be resolved from "${resolvedExecutable.requested}".`,
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const { jobId, directory } = this._createWorkingDirectory()
|
|
444
|
+
const correlationId = typeof request.jobId === 'string' && request.jobId.length > 0
|
|
445
|
+
? request.jobId
|
|
446
|
+
: jobId
|
|
447
|
+
const requestPath = join(directory, 'request.json')
|
|
448
|
+
const resultPath = join(directory, 'result.json')
|
|
449
|
+
|
|
450
|
+
// Some actions need input that is not a flat flag: a view PLAN is a list of
|
|
451
|
+
// records, and expressing it as parallel `--camera`/`--frame` arrays would let
|
|
452
|
+
// them disagree in length. `prepareDirectory` writes such a document into the
|
|
453
|
+
// invocation directory before the process starts, so the action reads it by
|
|
454
|
+
// relative path and the file dies with the directory.
|
|
455
|
+
if (typeof options.prepareDirectory === 'function') {
|
|
456
|
+
await options.prepareDirectory({ directory, jobId })
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const requestDocument = {
|
|
460
|
+
protocolVersion: BLENDER_PROTOCOL_VERSION,
|
|
461
|
+
jobId: correlationId,
|
|
462
|
+
action,
|
|
463
|
+
...request.payload !== undefined ? { payload: request.payload } : {},
|
|
464
|
+
}
|
|
465
|
+
writeFileSync(requestPath, JSON.stringify(requestDocument, null, 2), 'utf8')
|
|
466
|
+
|
|
467
|
+
const argv = [
|
|
468
|
+
resolvedExecutable.resolved,
|
|
469
|
+
'--background',
|
|
470
|
+
'--factory-startup',
|
|
471
|
+
'--python',
|
|
472
|
+
this.bootstrapPath,
|
|
473
|
+
'--',
|
|
474
|
+
'--request',
|
|
475
|
+
requestPath,
|
|
476
|
+
'--result',
|
|
477
|
+
resultPath,
|
|
478
|
+
...(options.args ?? []),
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
// Compose the caller's signal with our own deadline so either can terminate
|
|
482
|
+
// the managed process range (SPEC §9.4 "超时" and "用户取消").
|
|
483
|
+
const deadlineController = new AbortController()
|
|
484
|
+
const timer = setTimeout(() => deadlineController.abort(), this.config.timeoutMs)
|
|
485
|
+
const signals = [deadlineController.signal]
|
|
486
|
+
if (options.signal) signals.push(options.signal)
|
|
487
|
+
const composedSignal = AbortSignal.any(signals)
|
|
488
|
+
|
|
489
|
+
const startedAt = Date.now()
|
|
490
|
+
let handle
|
|
491
|
+
try {
|
|
492
|
+
handle = this.ctx.subprocess.spawn({
|
|
493
|
+
argv,
|
|
494
|
+
cwd: options.cwd ?? directory,
|
|
495
|
+
stdio: {
|
|
496
|
+
stdin: 'ignore',
|
|
497
|
+
stdout: { maxBytes: this.config.maxOutputBytes, spill: { maxBytes: this.config.maxSpillBytes } },
|
|
498
|
+
stderr: { maxBytes: this.config.maxOutputBytes, spill: { maxBytes: this.config.maxSpillBytes } },
|
|
499
|
+
},
|
|
500
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
501
|
+
signal: composedSignal,
|
|
502
|
+
env: {
|
|
503
|
+
// Scrub anything that could carry a secret into the child (SPEC §15.5:
|
|
504
|
+
// the DeepSeek API key must never reach the Blender subprocess).
|
|
505
|
+
PATH: process.env.PATH ?? '',
|
|
506
|
+
HOME: process.env.HOME ?? '',
|
|
507
|
+
TMPDIR: process.env.TMPDIR ?? '',
|
|
508
|
+
// Deterministic, unbuffered Python so partial output is never lost.
|
|
509
|
+
PYTHONUNBUFFERED: '1',
|
|
510
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
511
|
+
DEEPBLEND_JOB_ID: correlationId,
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
} catch (cause) {
|
|
515
|
+
clearTimeout(timer)
|
|
516
|
+
this._cleanup(directory)
|
|
517
|
+
throw new BlenderError(
|
|
518
|
+
BlenderErrorCode.SPAWN_FAILED,
|
|
519
|
+
`Failed to spawn Blender at ${resolvedExecutable.resolved}.`,
|
|
520
|
+
{ cause },
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** @type {import('@deepseek-ai/dsh-subprocess').SubprocessOutcome|null} */
|
|
525
|
+
let outcome = null
|
|
526
|
+
/** @type {unknown} */
|
|
527
|
+
let spawnFailure = null
|
|
528
|
+
try {
|
|
529
|
+
outcome = await handle.done
|
|
530
|
+
} catch (cause) {
|
|
531
|
+
spawnFailure = cause
|
|
532
|
+
} finally {
|
|
533
|
+
clearTimeout(timer)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const stdout = this._readAll(handle.collected?.stdout)
|
|
537
|
+
const stderr = this._readAll(handle.collected?.stderr)
|
|
538
|
+
const durationMs = Date.now() - startedAt
|
|
539
|
+
|
|
540
|
+
const aborted = composedSignal.aborted
|
|
541
|
+
const timedOut = aborted && options.signal?.aborted !== true
|
|
542
|
+
|
|
543
|
+
// Classify BEFORE reading the result: a terminated process may still have
|
|
544
|
+
// written a partial result document, and a timeout must never be reported as
|
|
545
|
+
// a successful probe.
|
|
546
|
+
if (timedOut) {
|
|
547
|
+
this._cleanup(directory)
|
|
548
|
+
throw new BlenderError(
|
|
549
|
+
BlenderErrorCode.TIMEOUT,
|
|
550
|
+
`Blender bootstrap exceeded its ${this.config.timeoutMs} ms deadline.`,
|
|
551
|
+
{ detail: { stderr: stderr.slice(-4000) } },
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
if (aborted) {
|
|
555
|
+
this._cleanup(directory)
|
|
556
|
+
throw new BlenderError(BlenderErrorCode.ABORTED, 'Blender bootstrap was cancelled by the caller.')
|
|
557
|
+
}
|
|
558
|
+
if (spawnFailure !== null) {
|
|
559
|
+
this._cleanup(directory)
|
|
560
|
+
throw new BlenderError(
|
|
561
|
+
BlenderErrorCode.SPAWN_FAILED,
|
|
562
|
+
'Blender bootstrap process failed to start.',
|
|
563
|
+
{ cause: spawnFailure },
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!existsSync(resultPath)) {
|
|
568
|
+
const exitCode = outcome?.exitCode ?? null
|
|
569
|
+
this._cleanup(directory)
|
|
570
|
+
throw new BlenderError(
|
|
571
|
+
exitCode === 0 ? BlenderErrorCode.RESULT_MISSING : BlenderErrorCode.NONZERO_EXIT,
|
|
572
|
+
`Blender exited with code ${exitCode} without writing a result document. ` +
|
|
573
|
+
`stderr tail: ${stderr.slice(-2000) || '<empty>'}`,
|
|
574
|
+
{ detail: { exitCode, stdout: stdout.slice(-2000) } },
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let envelope
|
|
579
|
+
try {
|
|
580
|
+
envelope = JSON.parse(readFileSync(resultPath, 'utf8'))
|
|
581
|
+
} catch (cause) {
|
|
582
|
+
this._cleanup(directory)
|
|
583
|
+
throw new BlenderError(
|
|
584
|
+
BlenderErrorCode.RESULT_UNPARSEABLE,
|
|
585
|
+
`Blender wrote a result document that is not valid JSON at ${resultPath}.`,
|
|
586
|
+
{ cause },
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (envelope?.protocolVersion !== BLENDER_PROTOCOL_VERSION) {
|
|
591
|
+
this._cleanup(directory)
|
|
592
|
+
throw new BlenderError(
|
|
593
|
+
BlenderErrorCode.PROTOCOL_VERSION_MISMATCH,
|
|
594
|
+
`bootstrap.py reported protocolVersion "${envelope?.protocolVersion}", ` +
|
|
595
|
+
`expected "${BLENDER_PROTOCOL_VERSION}".`,
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (envelope.status === 'error') {
|
|
600
|
+
const code = normaliseErrorCode(envelope?.error?.code)
|
|
601
|
+
this._cleanup(directory)
|
|
602
|
+
throw new BlenderError(
|
|
603
|
+
code,
|
|
604
|
+
envelope?.error?.message ?? 'bootstrap.py reported an unspecified error.',
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// A successful probe leaves nothing worth keeping either: bootstrap.py does
|
|
609
|
+
// its own renders in a private temp dir, and the request/result documents
|
|
610
|
+
// are already parsed into memory. Retaining them would make every tool call
|
|
611
|
+
// accumulate residue in the workspace.
|
|
612
|
+
//
|
|
613
|
+
// An action that produced real artifacts — a compiled `.blend`, a rendered
|
|
614
|
+
// frame — needs the opposite: the caller must be able to move those bytes
|
|
615
|
+
// somewhere durable BEFORE the directory is removed. `onWorkingDirectory` is
|
|
616
|
+
// that window, and it is awaited so a slow move cannot race the cleanup.
|
|
617
|
+
if (typeof options.onWorkingDirectory === 'function') {
|
|
618
|
+
await options.onWorkingDirectory({ directory, jobId, envelope, requestPath, resultPath })
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
this._cleanup(directory)
|
|
622
|
+
|
|
623
|
+
return { envelope, stdout, stderr, exitCode: outcome?.exitCode ?? null, durationMs, workingDirectory: directory }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Remove a per-invocation directory unless the operator asked to keep it.
|
|
628
|
+
* @param {string} directory
|
|
629
|
+
*/
|
|
630
|
+
_cleanup(directory) {
|
|
631
|
+
if (this.config.keepWorkingDirectory) return
|
|
632
|
+
try {
|
|
633
|
+
rmSync(directory, { recursive: true, force: true })
|
|
634
|
+
} catch {
|
|
635
|
+
// A retained temp directory is not worth failing a classified error over.
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Probe the Blender runtime and return a behavioral capability report.
|
|
641
|
+
*
|
|
642
|
+
* Never throws for "Blender is not installed": absence is reported as
|
|
643
|
+
* `installed: false` plus a warning, because a probe is exactly where a
|
|
644
|
+
* missing toolchain must be describable (SPEC §9.4).
|
|
645
|
+
*
|
|
646
|
+
* @param {{ refresh?: boolean, signal?: AbortSignal }} [options]
|
|
647
|
+
* @returns {Promise<import('@deepblend/dsh-blender-contracts').BlenderCapabilities>}
|
|
648
|
+
*/
|
|
649
|
+
async getCapabilities(options = {}) {
|
|
650
|
+
const cacheKey = this._requestedBlenderPath()
|
|
651
|
+
const cached = this._capabilitiesCache.get(cacheKey)
|
|
652
|
+
const now = Date.now()
|
|
653
|
+
if (options.refresh !== true && cached && now - cached.probedAt < this.config.capabilitiesCacheMs) {
|
|
654
|
+
return cached
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const resolved = await this.resolveBlenderExecutable({ signal: options.signal })
|
|
658
|
+
if (resolved.resolved === null || resolved.error !== null) {
|
|
659
|
+
const absent = this._absentCapabilities(resolved, resolved.error)
|
|
660
|
+
this._capabilitiesCache.set(cacheKey, absent)
|
|
661
|
+
return absent
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const startedAt = Date.now()
|
|
665
|
+
const run = await this.runBootstrap({ action: 'get_capabilities' }, { signal: options.signal })
|
|
666
|
+
const raw = run.envelope.capabilities ?? {}
|
|
667
|
+
|
|
668
|
+
/** @type {import('@deepblend/dsh-blender-contracts').BlenderCapabilities} */
|
|
669
|
+
const capabilities = {
|
|
670
|
+
protocolVersion: BLENDER_PROTOCOL_VERSION,
|
|
671
|
+
installed: true,
|
|
672
|
+
executable: {
|
|
673
|
+
requested: resolved.requested,
|
|
674
|
+
resolved: resolved.resolved,
|
|
675
|
+
found: true,
|
|
676
|
+
},
|
|
677
|
+
blenderVersion: raw.blenderVersion ?? null,
|
|
678
|
+
blenderVersionTuple: raw.blenderVersionTuple ?? null,
|
|
679
|
+
pythonVersion: raw.pythonVersion ?? null,
|
|
680
|
+
buildHash: raw.buildHash ?? null,
|
|
681
|
+
binaryPath: raw.binaryPath ?? null,
|
|
682
|
+
renderEngines: normaliseEngineProbes(raw.renderEngines),
|
|
683
|
+
bestAvailableEngine: typeof raw.bestAvailableEngine === 'string' ? raw.bestAvailableEngine : null,
|
|
684
|
+
renderEngineEnumItems: normaliseEngineEnumItems(raw.renderEngineDiagnostics),
|
|
685
|
+
gpu: normaliseGpu(raw.gpuDevices),
|
|
686
|
+
exportFormats: Array.isArray(raw.exportFormats) ? raw.exportFormats : [],
|
|
687
|
+
importFormats: Array.isArray(raw.importFormats) ? raw.importFormats : [],
|
|
688
|
+
unavailableFormats: normaliseUnavailableFormats(raw.formatDiagnostics),
|
|
689
|
+
renderSmokeTest: raw.renderSmokeTest ?? null,
|
|
690
|
+
cyclesSmokeTest: raw.cyclesSmokeTest ?? null,
|
|
691
|
+
textBlockApi: raw.textBlockApi === true,
|
|
692
|
+
frameApi: raw.frameApi === true,
|
|
693
|
+
hostPlatform: typeof raw.hostPlatform === 'string' ? raw.hostPlatform : null,
|
|
694
|
+
warnings: collectWarnings(raw),
|
|
695
|
+
probedAt: now,
|
|
696
|
+
durationMs: Date.now() - startedAt,
|
|
697
|
+
commandLine: null,
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Derived convenience flag so every caller agrees on what "has a GPU" means.
|
|
701
|
+
capabilities.gpuAvailable = capabilities.gpu.gpuDeviceNames.length > 0
|
|
702
|
+
|
|
703
|
+
this._capabilitiesCache.set(cacheKey, capabilities)
|
|
704
|
+
return capabilities
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Describe a machine with no resolvable Blender.
|
|
709
|
+
* @param {{ requested: string, resolved: string|null }} resolved
|
|
710
|
+
* @param {BlenderError|null} error
|
|
711
|
+
* @returns {import('@deepblend/dsh-blender-contracts').BlenderCapabilities}
|
|
712
|
+
*/
|
|
713
|
+
_absentCapabilities(resolved, error) {
|
|
714
|
+
return {
|
|
715
|
+
protocolVersion: BLENDER_PROTOCOL_VERSION,
|
|
716
|
+
installed: false,
|
|
717
|
+
executable: { requested: resolved.requested, resolved: null, found: false, advice: resolved.advice ?? null },
|
|
718
|
+
blenderVersion: null,
|
|
719
|
+
blenderVersionTuple: null,
|
|
720
|
+
pythonVersion: null,
|
|
721
|
+
buildHash: null,
|
|
722
|
+
binaryPath: null,
|
|
723
|
+
renderEngines: Object.fromEntries(
|
|
724
|
+
CANDIDATE_RENDER_ENGINES.map(id => [id, { assignable: false, readback: null, error: 'Blender not installed' }]),
|
|
725
|
+
),
|
|
726
|
+
bestAvailableEngine: null,
|
|
727
|
+
renderEngineEnumItems: [],
|
|
728
|
+
gpu: emptyGpuReport(),
|
|
729
|
+
exportFormats: [],
|
|
730
|
+
importFormats: [],
|
|
731
|
+
unavailableFormats: [],
|
|
732
|
+
renderSmokeTest: null,
|
|
733
|
+
cyclesSmokeTest: null,
|
|
734
|
+
textBlockApi: false,
|
|
735
|
+
frameApi: false,
|
|
736
|
+
hostPlatform: null,
|
|
737
|
+
warnings: [
|
|
738
|
+
warning(
|
|
739
|
+
BlenderWarningCode.BLENDER_NOT_INSTALLED,
|
|
740
|
+
error?.message ?? `Blender executable could not be resolved from "${resolved.requested}".`,
|
|
741
|
+
{ configuredPath: resolved.requested },
|
|
742
|
+
),
|
|
743
|
+
],
|
|
744
|
+
probedAt: Date.now(),
|
|
745
|
+
durationMs: 0,
|
|
746
|
+
commandLine: null,
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Drop the capabilities cache. Called on settings change so a corrected
|
|
752
|
+
* `blenderPath` takes effect without a restart (SPEC §17).
|
|
753
|
+
*/
|
|
754
|
+
invalidateCapabilities() {
|
|
755
|
+
this._capabilitiesCache.clear()
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
// M1: batch scene actions
|
|
760
|
+
//
|
|
761
|
+
// Both of these are thin, honest transports. They compose an argv, run one
|
|
762
|
+
// bootstrap action, and return what Bootstrap.py measured — no business logic,
|
|
763
|
+
// no revision state, no retries. Orchestration belongs to `blenderStudio`
|
|
764
|
+
// (SPEC §7.1: "负责具体 Blender 执行,不包含业务状态机").
|
|
765
|
+
// ---------------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Compile a SceneSpec into a `.blend` checkpoint and technically validate it.
|
|
769
|
+
*
|
|
770
|
+
* Nothing is published: the checkpoint is written to the caller's working
|
|
771
|
+
* directory, and `onWorkingDirectory` is the caller's chance to move it into a
|
|
772
|
+
* revision before the directory is cleaned up. That split is what lets a
|
|
773
|
+
* compile failure leave no trace anywhere in the project.
|
|
774
|
+
*
|
|
775
|
+
* @param {object} request
|
|
776
|
+
* @param {string} request.sceneSpecPath - absolute path to the SceneSpec JSON.
|
|
777
|
+
* @param {(info: {directory: string, envelope: object, jobId: string}) => void|Promise<void>} request.onWorkingDirectory
|
|
778
|
+
* @param {string} [request.profile]
|
|
779
|
+
* @param {string} [request.projectRoot]
|
|
780
|
+
* @param {string} [request.jobId]
|
|
781
|
+
* @param {AbortSignal} [request.signal]
|
|
782
|
+
* @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
|
|
783
|
+
*/
|
|
784
|
+
async compileScene(request) {
|
|
785
|
+
const outputBlend = 'result.blend'
|
|
786
|
+
if (typeof request?.sceneSpecPath !== 'string' || request.sceneSpecPath.length === 0) {
|
|
787
|
+
throw new BlenderError(
|
|
788
|
+
BlenderErrorCode.SCENE_SPEC_INVALID,
|
|
789
|
+
'compileScene needs an absolute path to a SceneSpec document.',
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const args = ['--scene-spec', request.sceneSpecPath, '--output-blend', outputBlend]
|
|
794
|
+
if (request.profile !== undefined) args.push('--profile', String(request.profile))
|
|
795
|
+
if (request.projectRoot !== undefined) args.push('--project-root', request.projectRoot)
|
|
796
|
+
|
|
797
|
+
const run = await this.runBootstrap(
|
|
798
|
+
{ action: 'compile_scene', jobId: request.jobId },
|
|
799
|
+
{
|
|
800
|
+
signal: request.signal,
|
|
801
|
+
args,
|
|
802
|
+
projectRoot: request.projectRoot,
|
|
803
|
+
onWorkingDirectory: request.onWorkingDirectory,
|
|
804
|
+
},
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
const report = run.envelope.result ?? {}
|
|
808
|
+
return {
|
|
809
|
+
report,
|
|
810
|
+
envelope: run.envelope,
|
|
811
|
+
durationMs: run.durationMs,
|
|
812
|
+
stdout: run.stdout,
|
|
813
|
+
stderr: run.stderr,
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Render one preview frame from an existing `.blend` checkpoint.
|
|
819
|
+
*
|
|
820
|
+
* @param {object} request
|
|
821
|
+
* @param {string} request.checkpointPath - absolute path to the `.blend`.
|
|
822
|
+
* @param {string} request.outputPath - absolute path for the PNG.
|
|
823
|
+
* @param {string} [request.cameraId]
|
|
824
|
+
* @param {string} [request.engine] - SceneSpec engine key (`cycles`/`eevee`/`workbench`).
|
|
825
|
+
* @param {number} [request.width]
|
|
826
|
+
* @param {number} [request.height]
|
|
827
|
+
* @param {number} [request.samples]
|
|
828
|
+
* @param {number} [request.frame]
|
|
829
|
+
* @param {string} [request.jobId]
|
|
830
|
+
* @param {AbortSignal} [request.signal]
|
|
831
|
+
* @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
|
|
832
|
+
*/
|
|
833
|
+
async renderPreview(request) {
|
|
834
|
+
if (typeof request?.checkpointPath !== 'string' || request.checkpointPath.length === 0) {
|
|
835
|
+
throw new BlenderError(
|
|
836
|
+
BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
|
|
837
|
+
'renderPreview needs an absolute path to a .blend checkpoint.',
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
if (typeof request?.outputPath !== 'string' || request.outputPath.length === 0) {
|
|
841
|
+
throw new BlenderError(BlenderErrorCode.RENDER_NO_OUTPUT, 'renderPreview needs an absolute output path.')
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const args = ['--blend', request.checkpointPath, '--output', request.outputPath]
|
|
845
|
+
if (request.cameraId !== undefined) args.push('--camera', String(request.cameraId))
|
|
846
|
+
if (request.engine !== undefined) args.push('--engine', String(request.engine))
|
|
847
|
+
if (request.width !== undefined) args.push('--width', String(request.width))
|
|
848
|
+
if (request.height !== undefined) args.push('--height', String(request.height))
|
|
849
|
+
if (request.samples !== undefined) args.push('--samples', String(request.samples))
|
|
850
|
+
if (request.frame !== undefined) args.push('--frame', String(request.frame))
|
|
851
|
+
|
|
852
|
+
const run = await this.runBootstrap(
|
|
853
|
+
{ action: 'render_preview', jobId: request.jobId },
|
|
854
|
+
{ signal: request.signal, args, onWorkingDirectory: request.onWorkingDirectory },
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
const report = run.envelope.result ?? {}
|
|
858
|
+
return {
|
|
859
|
+
report,
|
|
860
|
+
envelope: run.envelope,
|
|
861
|
+
durationMs: run.durationMs,
|
|
862
|
+
stdout: run.stdout,
|
|
863
|
+
stderr: run.stderr,
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Render a PLAN of views out of one checkpoint, in one Blender process, and
|
|
869
|
+
* measure each one (SPEC §12.3 "预览默认包含:主相机 / 45 度 / 俯视 / 近景").
|
|
870
|
+
*
|
|
871
|
+
* WHY A PLAN AND NOT A LOOP OVER `renderPreview`
|
|
872
|
+
* ---------------------------------------------
|
|
873
|
+
* Measured: a Blender cold start is about 0.4 s and a preview render 2-3 s. The
|
|
874
|
+
* M2 visual loop renders four views per round for up to five rounds, so a launch
|
|
875
|
+
* per view would spend roughly two minutes on startup alone. One process also
|
|
876
|
+
* guarantees the views describe ONE scene state — four launches could in
|
|
877
|
+
* principle open four different files if the checkpoint changed in between.
|
|
878
|
+
*
|
|
879
|
+
* The per-view measurements come back inside the same envelope because they are
|
|
880
|
+
* computed from pixels that only exist in that process (see `deepblend_views.py`).
|
|
881
|
+
* Re-deriving them later would mean either a second Blender launch or a second
|
|
882
|
+
* PNG decoder in a second language, and a duplicate decoder is the kind of thing
|
|
883
|
+
* that drifts.
|
|
884
|
+
*
|
|
885
|
+
* @param {object} request
|
|
886
|
+
* @param {string} request.checkpointPath - absolute path to the `.blend`.
|
|
887
|
+
* @param {object[]} request.views - `{ id, role?, cameraId?, frame? }`, in reading order.
|
|
888
|
+
* @param {string[]} [request.track] - object ids to measure by isolation.
|
|
889
|
+
* @param {string[]} [request.parts] - object ids that are part of the subject's own
|
|
890
|
+
* body, so a ray reaching them has reached the subject rather than been blocked.
|
|
891
|
+
* @param {string} [request.engine] - SceneSpec engine key.
|
|
892
|
+
* @param {number} [request.width]
|
|
893
|
+
* @param {number} [request.height]
|
|
894
|
+
* @param {number} [request.samples]
|
|
895
|
+
* @param {string} [request.jobId]
|
|
896
|
+
* @param {AbortSignal} [request.signal]
|
|
897
|
+
* @returns {Promise<{report: object, pngs: Record<string, Buffer>, envelope: object, durationMs: number}>}
|
|
898
|
+
*/
|
|
899
|
+
/**
|
|
900
|
+
* Start a frame-sequence render and RETURN THE LIVE HANDLE (M3, SPEC §10).
|
|
901
|
+
*
|
|
902
|
+
* WHY THIS DOES NOT AWAIT, UNLIKE EVERY OTHER METHOD HERE
|
|
903
|
+
* ------------------------------------------------------
|
|
904
|
+
* `renderViews` blocks the caller for the length of a Blender launch, which is
|
|
905
|
+
* correct for a preview measured in seconds. A delivery render is 19.6-41.4 s
|
|
906
|
+
* PER FRAME (measured on this machine for `watch-commercial` at 1920x1080 /
|
|
907
|
+
* Cycles / 256 spp), so 450 frames is ~3.4 hours. Awaiting that inside the tool
|
|
908
|
+
* call that started it would block the Agent for the whole afternoon, which is
|
|
909
|
+
* the first M3 acceptance condition ("长任务不阻塞 Agent").
|
|
910
|
+
*
|
|
911
|
+
* So this method is split in two: `startFrameSequence` spawns and returns, and
|
|
912
|
+
* `awaitFrameSequence` does the awaiting. The host owns the handle in between —
|
|
913
|
+
* and, crucially, owns writing the pid to disk, because a Harness that dies
|
|
914
|
+
* cannot tell anyone which process it left behind.
|
|
915
|
+
*
|
|
916
|
+
* The invocation directory is CALLER-SUPPLIED and PERSISTENT. Everywhere else
|
|
917
|
+
* in this provider the directory is a per-invocation scratch that dies with the
|
|
918
|
+
* call; a resumable render's directory is the thing that survives the crash, so
|
|
919
|
+
* the frames, the plan, the journal and the result all live in one directory the
|
|
920
|
+
* host names (inside the project, never in the shared scratch root).
|
|
921
|
+
*
|
|
922
|
+
* @param {object} request
|
|
923
|
+
* @param {string} request.checkpointPath - absolute path to the `.blend`.
|
|
924
|
+
* @param {number[]} request.frames - the frames to render, in order.
|
|
925
|
+
* @param {string} request.jobDirectory - absolute, persistent, inside the workspace.
|
|
926
|
+
* @param {string} [request.cameraId]
|
|
927
|
+
* @param {string} [request.profileName] - the SceneSpec profile the plan came from.
|
|
928
|
+
* @param {object} [request.profile] - the resolved render profile to apply.
|
|
929
|
+
* @param {[number, number]} [request.frameRange] - the project's own frame range.
|
|
930
|
+
* @param {string} [request.jobId]
|
|
931
|
+
* @returns {Promise<{handle: object, jobDirectory: string, framesDirectory: string,
|
|
932
|
+
* requestPath: string, planPath: string, resultPath: string, eventsPath: string,
|
|
933
|
+
* processPath: string, argv: string[], startedAt: number, executable: string}>}
|
|
934
|
+
*/
|
|
935
|
+
async startFrameSequence(request) {
|
|
936
|
+
if (typeof request?.checkpointPath !== 'string' || request.checkpointPath.length === 0) {
|
|
937
|
+
throw new BlenderError(
|
|
938
|
+
BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
|
|
939
|
+
'startFrameSequence needs an absolute path to a .blend checkpoint.',
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
const frames = Array.isArray(request.frames) ? request.frames : []
|
|
943
|
+
if (frames.length === 0) {
|
|
944
|
+
throw new BlenderError(
|
|
945
|
+
BlenderErrorCode.SCRIPT_ERROR,
|
|
946
|
+
'startFrameSequence needs at least one frame to render; an empty frame list would report ' +
|
|
947
|
+
'success while writing nothing.',
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
if (typeof request.jobDirectory !== 'string' || request.jobDirectory.length === 0) {
|
|
951
|
+
throw new BlenderError(
|
|
952
|
+
BlenderErrorCode.SCRIPT_ERROR,
|
|
953
|
+
'startFrameSequence needs a persistent job directory to render into.',
|
|
954
|
+
)
|
|
955
|
+
}
|
|
956
|
+
if (!existsSync(this.bootstrapPath)) {
|
|
957
|
+
throw new BlenderError(
|
|
958
|
+
BlenderErrorCode.BOOTSTRAP_MISSING,
|
|
959
|
+
`bootstrap.py not found at ${this.bootstrapPath}.`,
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const resolvedExecutable = await this.resolveBlenderExecutable({ signal: request.signal })
|
|
964
|
+
if (resolvedExecutable.error !== null || resolvedExecutable.resolved === null) {
|
|
965
|
+
throw resolvedExecutable.error ?? new BlenderError(
|
|
966
|
+
BlenderErrorCode.NOT_FOUND,
|
|
967
|
+
`Blender executable could not be resolved from "${resolvedExecutable.requested}".`,
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const jobDirectory = resolve(request.jobDirectory)
|
|
972
|
+
const framesDirectory = join(jobDirectory, 'frames')
|
|
973
|
+
mkdirSync(framesDirectory, { recursive: true })
|
|
974
|
+
|
|
975
|
+
const requestPath = join(jobDirectory, 'request.json')
|
|
976
|
+
const planPath = join(jobDirectory, 'plan.json')
|
|
977
|
+
const resultPath = join(jobDirectory, 'result.json')
|
|
978
|
+
const eventsPath = join(jobDirectory, 'events.jsonl')
|
|
979
|
+
const processPath = join(jobDirectory, 'process.json')
|
|
980
|
+
|
|
981
|
+
// A resumed attempt must not inherit ANY of the previous attempt's files, and
|
|
982
|
+
// `process.json` is the one that matters most. MEASURED: without it here, a
|
|
983
|
+
// resumed render read the dead attempt's pid out of the old file and recorded
|
|
984
|
+
// THAT as the process doing the work — so a second restart would go looking for
|
|
985
|
+
// a process that no longer exists and leave the live renderer orphaned, which is
|
|
986
|
+
// precisely the failure the acceptance condition forbids. The journal is listed
|
|
987
|
+
// for the same reason: two attempts interleaved into one stream cannot be told
|
|
988
|
+
// apart. The old `result.json` is listed because reading a stale success envelope
|
|
989
|
+
// for an attempt that has not finished is how a failed render reports success.
|
|
990
|
+
for (const stale of [eventsPath, resultPath, processPath]) {
|
|
991
|
+
if (!existsSync(stale)) continue
|
|
992
|
+
try {
|
|
993
|
+
rmSync(stale, { force: true })
|
|
994
|
+
} catch {
|
|
995
|
+
// A stale file that cannot be cleared is a diagnostic problem, not a
|
|
996
|
+
// reason to refuse to render: the ledger is derived from the frames.
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const plan = {
|
|
1001
|
+
// One spelling for the document type. The contracts package owns the string
|
|
1002
|
+
// and the contracts test pins it; a second literal here is the shape of
|
|
1003
|
+
// defect that has already cost this repository four times (D38, D43) — a
|
|
1004
|
+
// vocabulary written twice rots in the copy nobody exercises.
|
|
1005
|
+
schemaVersion: FRAME_PLAN_VERSION,
|
|
1006
|
+
jobId: request.jobId ?? null,
|
|
1007
|
+
// The token travels with the plan so the child can stamp its identity document
|
|
1008
|
+
// with it. The host refuses an identity whose token is not the one this attempt
|
|
1009
|
+
// was started with, which turns "the pid is from the current attempt" from a
|
|
1010
|
+
// convention about deleting files into a fact that is checked.
|
|
1011
|
+
attemptToken: request.attemptToken ?? null,
|
|
1012
|
+
checkpoint: request.checkpointPath,
|
|
1013
|
+
cameraId: request.cameraId ?? null,
|
|
1014
|
+
profileName: request.profileName ?? null,
|
|
1015
|
+
profile: request.profile ?? {},
|
|
1016
|
+
frameRange: request.frameRange ?? null,
|
|
1017
|
+
frames: frames.map(frame => Number(frame)),
|
|
1018
|
+
outputDirectory: framesDirectory,
|
|
1019
|
+
filePrefix: request.filePrefix ?? 'frame_',
|
|
1020
|
+
padding: request.padding ?? 4,
|
|
1021
|
+
}
|
|
1022
|
+
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf8')
|
|
1023
|
+
writeFileSync(requestPath, JSON.stringify({
|
|
1024
|
+
protocolVersion: BLENDER_PROTOCOL_VERSION,
|
|
1025
|
+
jobId: request.jobId ?? null,
|
|
1026
|
+
action: 'render_frames',
|
|
1027
|
+
}, null, 2), 'utf8')
|
|
1028
|
+
|
|
1029
|
+
const argv = [
|
|
1030
|
+
resolvedExecutable.resolved,
|
|
1031
|
+
'--background',
|
|
1032
|
+
'--factory-startup',
|
|
1033
|
+
'--python',
|
|
1034
|
+
this.bootstrapPath,
|
|
1035
|
+
'--',
|
|
1036
|
+
'--request',
|
|
1037
|
+
requestPath,
|
|
1038
|
+
'--result',
|
|
1039
|
+
resultPath,
|
|
1040
|
+
'--frames',
|
|
1041
|
+
planPath,
|
|
1042
|
+
'--events',
|
|
1043
|
+
eventsPath,
|
|
1044
|
+
'--proc',
|
|
1045
|
+
processPath,
|
|
1046
|
+
]
|
|
1047
|
+
|
|
1048
|
+
const startedAt = Date.now()
|
|
1049
|
+
let handle
|
|
1050
|
+
try {
|
|
1051
|
+
handle = this.ctx.subprocess.spawn({
|
|
1052
|
+
argv,
|
|
1053
|
+
cwd: jobDirectory,
|
|
1054
|
+
stdio: {
|
|
1055
|
+
stdin: 'ignore',
|
|
1056
|
+
stdout: { maxBytes: this.config.maxOutputBytes, spill: { maxBytes: this.config.maxSpillBytes } },
|
|
1057
|
+
stderr: { maxBytes: this.config.maxOutputBytes, spill: { maxBytes: this.config.maxSpillBytes } },
|
|
1058
|
+
},
|
|
1059
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
1060
|
+
// Deliberately NO AbortSignal here. A frame sequence is not bounded by the
|
|
1061
|
+
// preview deadline, and binding a signal would make the provider's own
|
|
1062
|
+
// timeout indistinguishable from a caller's cancellation in the record.
|
|
1063
|
+
// Cancellation goes through `handle.terminate()`, which the host calls
|
|
1064
|
+
// from `cancelJob`.
|
|
1065
|
+
env: {
|
|
1066
|
+
PATH: process.env.PATH ?? '',
|
|
1067
|
+
HOME: process.env.HOME ?? '',
|
|
1068
|
+
TMPDIR: process.env.TMPDIR ?? '',
|
|
1069
|
+
PYTHONUNBUFFERED: '1',
|
|
1070
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
1071
|
+
DEEPBLEND_JOB_ID: String(request.jobId ?? ''),
|
|
1072
|
+
},
|
|
1073
|
+
})
|
|
1074
|
+
} catch (cause) {
|
|
1075
|
+
throw new BlenderError(
|
|
1076
|
+
BlenderErrorCode.SPAWN_FAILED,
|
|
1077
|
+
`Failed to spawn Blender at ${resolvedExecutable.resolved}.`,
|
|
1078
|
+
{ cause },
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return {
|
|
1083
|
+
handle,
|
|
1084
|
+
jobDirectory,
|
|
1085
|
+
framesDirectory,
|
|
1086
|
+
requestPath,
|
|
1087
|
+
planPath,
|
|
1088
|
+
resultPath,
|
|
1089
|
+
eventsPath,
|
|
1090
|
+
processPath,
|
|
1091
|
+
argv,
|
|
1092
|
+
startedAt,
|
|
1093
|
+
attemptToken: request.attemptToken ?? null,
|
|
1094
|
+
executable: resolvedExecutable.resolved,
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Wait for a frame-sequence render started by {@link startFrameSequence}.
|
|
1100
|
+
*
|
|
1101
|
+
* Failure classification mirrors `runBootstrap`, and for the same reason: a
|
|
1102
|
+
* terminated process may still have written a partial result document, so being
|
|
1103
|
+
* killed must never be reported as a successful render. It is returned as data
|
|
1104
|
+
* rather than thrown because a frame sequence legitimately ends two ways — all
|
|
1105
|
+
* frames rendered, or killed mid-sequence with the completed frames intact —
|
|
1106
|
+
* and the caller must record both without losing the evidence.
|
|
1107
|
+
*
|
|
1108
|
+
* @param {Awaited<ReturnType<LocalBlenderRuntime['startFrameSequence']>>} run
|
|
1109
|
+
* @returns {Promise<{envelope: object|null, stdout: string, stderr: string, exitCode: number|null, signal: string|null, durationMs: number, spawnFailure: unknown}>}
|
|
1110
|
+
*/
|
|
1111
|
+
async awaitFrameSequence(run) {
|
|
1112
|
+
let outcome = null
|
|
1113
|
+
let spawnFailure = null
|
|
1114
|
+
try {
|
|
1115
|
+
outcome = await run.handle.done
|
|
1116
|
+
} catch (cause) {
|
|
1117
|
+
spawnFailure = cause
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const stdout = this._readAll(run.handle.collected?.stdout)
|
|
1121
|
+
const stderr = this._readAll(run.handle.collected?.stderr)
|
|
1122
|
+
const durationMs = Date.now() - run.startedAt
|
|
1123
|
+
|
|
1124
|
+
/** @type {object|null} */
|
|
1125
|
+
let envelope = null
|
|
1126
|
+
if (existsSync(run.resultPath)) {
|
|
1127
|
+
try {
|
|
1128
|
+
envelope = JSON.parse(readFileSync(run.resultPath, 'utf8'))
|
|
1129
|
+
} catch {
|
|
1130
|
+
envelope = null
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return {
|
|
1135
|
+
envelope,
|
|
1136
|
+
stdout,
|
|
1137
|
+
stderr,
|
|
1138
|
+
exitCode: outcome?.exitCode ?? null,
|
|
1139
|
+
signal: outcome?.signal ?? null,
|
|
1140
|
+
durationMs,
|
|
1141
|
+
spawnFailure,
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Render a PLAN of views out of one checkpoint, in one Blender process, and
|
|
1147
|
+
* measure each one (SPEC §12.3 "预览默认包含:主相机 / 45 度 / 俯视 / 近景").
|
|
1148
|
+
*
|
|
1149
|
+
* @param {object} request
|
|
1150
|
+
* @param {string} request.checkpointPath - absolute path to the `.blend`.
|
|
1151
|
+
* @param {object[]} request.views - `{ id, role?, cameraId?, frame? }`, in reading order.
|
|
1152
|
+
* @param {string[]} [request.track] - object ids to measure by isolation.
|
|
1153
|
+
* @param {string[]} [request.parts] - object ids that are part of the subject's own
|
|
1154
|
+
* body, so a ray reaching them has reached the subject rather than been blocked.
|
|
1155
|
+
* @param {string} [request.engine] - SceneSpec engine key.
|
|
1156
|
+
* @param {number} [request.width]
|
|
1157
|
+
* @param {number} [request.height]
|
|
1158
|
+
* @param {number} [request.samples]
|
|
1159
|
+
* @param {string} [request.jobId]
|
|
1160
|
+
* @param {AbortSignal} [request.signal]
|
|
1161
|
+
* @returns {Promise<{report: object, pngs: Record<string, Buffer>, envelope: object, durationMs: number}>}
|
|
1162
|
+
*/
|
|
1163
|
+
async renderViews(request) {
|
|
1164
|
+
if (typeof request?.checkpointPath !== 'string' || request.checkpointPath.length === 0) {
|
|
1165
|
+
throw new BlenderError(
|
|
1166
|
+
BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
|
|
1167
|
+
'renderViews needs an absolute path to a .blend checkpoint.',
|
|
1168
|
+
)
|
|
1169
|
+
}
|
|
1170
|
+
const views = Array.isArray(request.views) ? request.views : []
|
|
1171
|
+
if (views.length === 0) {
|
|
1172
|
+
throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'renderViews needs at least one view in its plan.')
|
|
1173
|
+
}
|
|
1174
|
+
for (const view of views) {
|
|
1175
|
+
if (typeof view?.id !== 'string' || view.id.length === 0) {
|
|
1176
|
+
throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'every view in a render plan needs an id.')
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** @type {Record<string, Buffer>} */
|
|
1181
|
+
const pngs = {}
|
|
1182
|
+
|
|
1183
|
+
const run = await this.runBootstrap(
|
|
1184
|
+
{ action: 'render_views', jobId: request.jobId },
|
|
1185
|
+
{
|
|
1186
|
+
signal: request.signal,
|
|
1187
|
+
args: ['--views', 'views.json'],
|
|
1188
|
+
// The plan names outputs by BARE file name: Blender must write somewhere
|
|
1189
|
+
// that dies with the invocation, or a failed render would leave images
|
|
1190
|
+
// where the host could mistake them for artifact bytes.
|
|
1191
|
+
prepareDirectory: ({ directory }) => {
|
|
1192
|
+
const plan = {
|
|
1193
|
+
checkpoint: request.checkpointPath,
|
|
1194
|
+
engine: request.engine,
|
|
1195
|
+
width: request.width,
|
|
1196
|
+
height: request.height,
|
|
1197
|
+
samples: request.samples,
|
|
1198
|
+
track: Array.isArray(request.track) ? request.track : [],
|
|
1199
|
+
// Entities that are part of the subject's own body rather than things in
|
|
1200
|
+
// front of it. The renderer needs them to decide what counts as occlusion.
|
|
1201
|
+
parts: Array.isArray(request.parts) ? request.parts : [],
|
|
1202
|
+
views: views.map(view => ({
|
|
1203
|
+
id: view.id,
|
|
1204
|
+
role: view.role ?? null,
|
|
1205
|
+
cameraId: view.cameraId,
|
|
1206
|
+
frame: view.frame,
|
|
1207
|
+
output: `${view.id}.png`,
|
|
1208
|
+
})),
|
|
1209
|
+
}
|
|
1210
|
+
writeFileSync(join(directory, 'views.json'), JSON.stringify(plan, null, 2), 'utf8')
|
|
1211
|
+
},
|
|
1212
|
+
onWorkingDirectory: async info => {
|
|
1213
|
+
// By the time this runs Blender has finished, so the directory holds the
|
|
1214
|
+
// plan, the renders, the isolation masks and the result document. Read
|
|
1215
|
+
// the PNG bytes out before the cleanup removes all of it: a Buffer is
|
|
1216
|
+
// serializable across this boundary, while a path is not.
|
|
1217
|
+
const result = info.envelope?.result ?? {}
|
|
1218
|
+
for (const entry of Array.isArray(result.views) ? result.views : []) {
|
|
1219
|
+
if (typeof entry?.outputPath !== 'string') continue
|
|
1220
|
+
try {
|
|
1221
|
+
pngs[entry.viewId] = await readFile(entry.outputPath)
|
|
1222
|
+
} catch {
|
|
1223
|
+
// A view whose bytes cannot be read is already reported as missing by
|
|
1224
|
+
// the render report; failing the whole plan here would discard the
|
|
1225
|
+
// views that did render.
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
},
|
|
1229
|
+
},
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
const report = run.envelope.result ?? {}
|
|
1233
|
+
return {
|
|
1234
|
+
report,
|
|
1235
|
+
pngs,
|
|
1236
|
+
envelope: run.envelope,
|
|
1237
|
+
durationMs: run.durationMs,
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* The Blender engine identifier that will actually be used for a SceneSpec
|
|
1243
|
+
* engine key, plus a warning when it is not the requested one.
|
|
1244
|
+
*
|
|
1245
|
+
* Kept on the provider because engine availability is a property of the
|
|
1246
|
+
* RUNTIME, not of the request (finding D1). The host asks this before it
|
|
1247
|
+
* spends a render, so a downgrade is a recorded decision rather than a
|
|
1248
|
+
* surprise in the artifact.
|
|
1249
|
+
*
|
|
1250
|
+
* @param {string} engineKey
|
|
1251
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
1252
|
+
* @returns {Promise<{ blenderEngine: string|null, requested: string|null, downgraded: boolean, warning: object|null }>}
|
|
1253
|
+
*/
|
|
1254
|
+
async resolveEngineKey(engineKey, options = {}) {
|
|
1255
|
+
const requested = BLENDER_ENGINE_BY_KEY[engineKey] ?? null
|
|
1256
|
+
if (requested === null) {
|
|
1257
|
+
throw new BlenderError(
|
|
1258
|
+
BlenderErrorCode.RENDER_PROFILE_MISSING,
|
|
1259
|
+
`"${engineKey}" is not a SceneSpec render engine; expected one of ${Object.keys(BLENDER_ENGINE_BY_KEY).join(', ')}.`,
|
|
1260
|
+
)
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const capabilities = await this.getCapabilities(options)
|
|
1264
|
+
if (capabilities.installed !== true) {
|
|
1265
|
+
return { blenderEngine: null, requested, downgraded: false, warning: null }
|
|
1266
|
+
}
|
|
1267
|
+
if (capabilities.renderEngines[requested]?.assignable === true) {
|
|
1268
|
+
return { blenderEngine: requested, requested, downgraded: false, warning: null }
|
|
1269
|
+
}
|
|
1270
|
+
for (const candidate of CANDIDATE_RENDER_ENGINES) {
|
|
1271
|
+
if (candidate === requested) continue
|
|
1272
|
+
if (capabilities.renderEngines[candidate]?.assignable === true) {
|
|
1273
|
+
return {
|
|
1274
|
+
blenderEngine: candidate,
|
|
1275
|
+
requested,
|
|
1276
|
+
downgraded: true,
|
|
1277
|
+
warning: warning(
|
|
1278
|
+
BlenderWarningCode.ENGINE_DOWNGRADED,
|
|
1279
|
+
`engine "${engineKey}" (${requested}) is not assignable in this Blender build; ${candidate} will be used instead`,
|
|
1280
|
+
{ requested, used: candidate },
|
|
1281
|
+
),
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
throw new BlenderError(
|
|
1286
|
+
BlenderErrorCode.ENGINE_UNAVAILABLE,
|
|
1287
|
+
`none of ${CANDIDATE_RENDER_ENGINES.join(', ')} is assignable in this Blender build.`,
|
|
1288
|
+
)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** Terminate nothing itself — `ctx.subprocess` owns its managed process range. */
|
|
1292
|
+
dispose() {
|
|
1293
|
+
this._capabilitiesCache.clear()
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Coerce the probe map into the declared shape, so a malformed bootstrap result
|
|
1299
|
+
* degrades rather than crashing the host.
|
|
1300
|
+
* @param {unknown} raw
|
|
1301
|
+
* @returns {Record<string, { assignable: boolean, readback: string|null, error: string|null }>}
|
|
1302
|
+
*/
|
|
1303
|
+
function normaliseEngineProbes(raw) {
|
|
1304
|
+
const result = {}
|
|
1305
|
+
for (const id of CANDIDATE_RENDER_ENGINES) {
|
|
1306
|
+
const probe = raw && typeof raw === 'object' ? raw[id] : undefined
|
|
1307
|
+
result[id] = {
|
|
1308
|
+
assignable: probe?.assignable === true,
|
|
1309
|
+
readback: typeof probe?.readback === 'string' ? probe.readback : null,
|
|
1310
|
+
error: typeof probe?.error === 'string' ? probe.error : null,
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return result
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** @returns {import('@deepblend/dsh-blender-contracts').BlenderGpuReport} */
|
|
1317
|
+
function emptyGpuReport() {
|
|
1318
|
+
return {
|
|
1319
|
+
availableBackends: [],
|
|
1320
|
+
preferredBackend: null,
|
|
1321
|
+
gpuDeviceNames: [],
|
|
1322
|
+
cpuDeviceNames: [],
|
|
1323
|
+
backendSupport: {},
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Normalise the bootstrap's GPU report.
|
|
1329
|
+
*
|
|
1330
|
+
* An unsupported backend raises on assignment rather than returning nothing, so
|
|
1331
|
+
* `backendSupport[x].supported === false` is a normal result carrying an error
|
|
1332
|
+
* string — it is not a probe failure and must not be treated as one.
|
|
1333
|
+
*
|
|
1334
|
+
* @param {unknown} raw
|
|
1335
|
+
* @returns {import('@deepblend/dsh-blender-contracts').BlenderGpuReport}
|
|
1336
|
+
*/
|
|
1337
|
+
function normaliseGpu(raw) {
|
|
1338
|
+
if (!raw || typeof raw !== 'object') return emptyGpuReport()
|
|
1339
|
+
const report = raw
|
|
1340
|
+
const backendSupport = {}
|
|
1341
|
+
const support = report.backendSupport && typeof report.backendSupport === 'object' ? report.backendSupport : {}
|
|
1342
|
+
for (const [backend, value] of Object.entries(support)) {
|
|
1343
|
+
backendSupport[backend] = {
|
|
1344
|
+
supported: value?.supported === true,
|
|
1345
|
+
gpuDevices: Array.isArray(value?.gpuDevices) ? value.gpuDevices : [],
|
|
1346
|
+
cpuDevices: Array.isArray(value?.cpuDevices) ? value.cpuDevices : [],
|
|
1347
|
+
error: typeof value?.error === 'string' ? value.error : null,
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const availableBackends = Array.isArray(report.availableBackends)
|
|
1352
|
+
? report.availableBackends.filter(entry => typeof entry === 'string')
|
|
1353
|
+
: []
|
|
1354
|
+
const gpuDeviceNames = Array.isArray(report.gpuDeviceNames)
|
|
1355
|
+
? report.gpuDeviceNames.filter(entry => typeof entry === 'string')
|
|
1356
|
+
: []
|
|
1357
|
+
const cpuDeviceNames = Array.isArray(report.cpuDeviceNames)
|
|
1358
|
+
? report.cpuDeviceNames.filter(entry => typeof entry === 'string')
|
|
1359
|
+
: []
|
|
1360
|
+
|
|
1361
|
+
return {
|
|
1362
|
+
availableBackends,
|
|
1363
|
+
preferredBackend: typeof report.preferredBackend === 'string' ? report.preferredBackend : null,
|
|
1364
|
+
gpuDeviceNames,
|
|
1365
|
+
cpuDeviceNames,
|
|
1366
|
+
backendSupport,
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Extract the "attribute exists but the operator is not registered" list.
|
|
1372
|
+
*
|
|
1373
|
+
* This is the trap behind `hasattr(bpy.ops.export_scene, 'obj')`: it is True on
|
|
1374
|
+
* this build while the operator cannot actually be invoked. Surfacing the names
|
|
1375
|
+
* lets M1 refuse an unsupported export with a precise message.
|
|
1376
|
+
*
|
|
1377
|
+
* @param {unknown} diagnostics
|
|
1378
|
+
* @returns {string[]}
|
|
1379
|
+
*/
|
|
1380
|
+
function normaliseUnavailableFormats(diagnostics) {
|
|
1381
|
+
if (!diagnostics || typeof diagnostics !== 'object') return []
|
|
1382
|
+
const names = new Set()
|
|
1383
|
+
for (const [key, value] of Object.entries(diagnostics)) {
|
|
1384
|
+
// Direction-specific keys: exportUnregisteredButPresent / importUnregisteredButPresent.
|
|
1385
|
+
if (!Array.isArray(value) || !/unregistered/i.test(key)) continue
|
|
1386
|
+
for (const entry of value) if (typeof entry === 'string') names.add(entry)
|
|
1387
|
+
}
|
|
1388
|
+
return [...names].sort()
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Read the informational engine enum out of the bootstrap's diagnostics block.
|
|
1393
|
+
*
|
|
1394
|
+
* This value is NEVER used to decide availability (D1) — it exists so an
|
|
1395
|
+
* operator can see the enum disagree with behaviour, which is the single most
|
|
1396
|
+
* confusing fact about this Blender build.
|
|
1397
|
+
*
|
|
1398
|
+
* @param {unknown} diagnostics
|
|
1399
|
+
* @returns {string[]}
|
|
1400
|
+
*/
|
|
1401
|
+
function normaliseEngineEnumItems(diagnostics) {
|
|
1402
|
+
if (!diagnostics || typeof diagnostics !== 'object') return []
|
|
1403
|
+
const block = diagnostics.engineEnumItemsInformational
|
|
1404
|
+
if (!block || typeof block !== 'object') return []
|
|
1405
|
+
return Array.isArray(block.identifiers)
|
|
1406
|
+
? block.identifiers.filter(entry => typeof entry === 'string')
|
|
1407
|
+
: []
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Map a bootstrap error code onto the stable contract code space.
|
|
1412
|
+
*
|
|
1413
|
+
* bootstrap.py reports BARE codes, and they are of two kinds:
|
|
1414
|
+
*
|
|
1415
|
+
* - the Blender-runtime family, whose contract spelling IS the bare name with a
|
|
1416
|
+
* `BLENDER_` prefix (`UNSUPPORTED_ACTION` -> `BLENDER_UNSUPPORTED_ACTION`);
|
|
1417
|
+
* - the DOMAIN family, whose contract spelling has NO prefix at all
|
|
1418
|
+
* (`SCENE_VALIDATION_FAILED`, `REVISION_CHECKPOINT_MISSING`, `SCENE_CAMERA_MISSING`, ...).
|
|
1419
|
+
*
|
|
1420
|
+
* The version of this function that prefixed EVERYTHING invented codes for the second kind: the
|
|
1421
|
+
* Python side's most common failure (`SCENE_VALIDATION_FAILED`, 27 sites) reached the model as
|
|
1422
|
+
* `BLENDER_SCENE_VALIDATION_FAILED`, which is not in `BlenderErrorCode` at all — so nothing could
|
|
1423
|
+
* branch on it, `docs/recovery.md`'s index by code could not find it, and the SAME failure carried a
|
|
1424
|
+
* different code depending on whether the host's own validation or Blender's reported it.
|
|
1425
|
+
*
|
|
1426
|
+
* So the prefix is applied only when the contract actually exposes that prefixed form, a bare code
|
|
1427
|
+
* that the contract defines is passed through unchanged, and anything else — an unknown code from a
|
|
1428
|
+
* NEWER bootstrap.py than this build knows — becomes `SCRIPT_ERROR`, which is exactly what "this
|
|
1429
|
+
* failure has no code I can branch on" means.
|
|
1430
|
+
*
|
|
1431
|
+
* @param {unknown} code
|
|
1432
|
+
* @returns {string}
|
|
1433
|
+
*/
|
|
1434
|
+
function normaliseErrorCode(code) {
|
|
1435
|
+
if (typeof code !== 'string' || code.length === 0) return BlenderErrorCode.SCRIPT_ERROR
|
|
1436
|
+
const known = CONTRACT_ERROR_CODES
|
|
1437
|
+
if (code.startsWith('BLENDER_')) return known.has(code) ? code : BlenderErrorCode.SCRIPT_ERROR
|
|
1438
|
+
const prefixed = `BLENDER_${code}`
|
|
1439
|
+
if (known.has(prefixed)) return prefixed
|
|
1440
|
+
if (known.has(code)) return code
|
|
1441
|
+
return BlenderErrorCode.SCRIPT_ERROR
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** The contract's whole error space, as a set: the one place this file may map INTO. */
|
|
1445
|
+
const CONTRACT_ERROR_CODES = new Set(Object.values(BlenderErrorCode))
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Derive operator-visible warnings from a probe result (D1, D2, SPEC §2.2 gap).
|
|
1449
|
+
* @param {Record<string, any>} raw
|
|
1450
|
+
* @returns {{ code: string, message: string, detail?: unknown }[]}
|
|
1451
|
+
*/
|
|
1452
|
+
function collectWarnings(raw) {
|
|
1453
|
+
const warnings = []
|
|
1454
|
+
const probes = normaliseEngineProbes(raw.renderEngines)
|
|
1455
|
+
const enumItems = normaliseEngineEnumItems(raw.renderEngineDiagnostics)
|
|
1456
|
+
|
|
1457
|
+
for (const [id, probe] of Object.entries(probes)) {
|
|
1458
|
+
// The Blender 5.2.1 trap: assignable but absent from the static enum.
|
|
1459
|
+
if (probe.assignable && enumItems.length > 0 && !enumItems.includes(id)) {
|
|
1460
|
+
warnings.push(
|
|
1461
|
+
warning(
|
|
1462
|
+
BlenderWarningCode.ENGINE_NOT_IN_STATIC_ENUM,
|
|
1463
|
+
`Render engine ${id} is assignable but absent from the static engine enum ` +
|
|
1464
|
+
`(${enumItems.join(', ')}). Availability must be decided behaviorally, never from the enum.`,
|
|
1465
|
+
{ engine: id, enumItems },
|
|
1466
|
+
),
|
|
1467
|
+
)
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (probes.CYCLES && !probes.CYCLES.assignable) {
|
|
1472
|
+
warnings.push(
|
|
1473
|
+
warning(
|
|
1474
|
+
BlenderWarningCode.ENGINE_UNAVAILABLE,
|
|
1475
|
+
'Cycles is not available in this Blender build. Final render must fall back to EEVEE; ' +
|
|
1476
|
+
'render profiles requesting CYCLES will be downgraded.',
|
|
1477
|
+
{ engine: 'CYCLES' },
|
|
1478
|
+
),
|
|
1479
|
+
)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (normaliseGpu(raw.gpuDevices).gpuDeviceNames.length === 0) {
|
|
1483
|
+
warnings.push(
|
|
1484
|
+
warning(BlenderWarningCode.GPU_UNAVAILABLE, 'No GPU compute device was detected; rendering will use the CPU.'),
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const imports = Array.isArray(raw.importFormats) ? raw.importFormats : []
|
|
1489
|
+
const exports = Array.isArray(raw.exportFormats) ? raw.exportFormats : []
|
|
1490
|
+
for (const format of EXPECTED_IMPORT_FORMATS) {
|
|
1491
|
+
if (!imports.includes(format)) {
|
|
1492
|
+
warnings.push(
|
|
1493
|
+
warning(
|
|
1494
|
+
BlenderWarningCode.FORMAT_UNAVAILABLE,
|
|
1495
|
+
`Import format "${format}" is not provided by this Blender build and cannot be offered yet.`,
|
|
1496
|
+
{ direction: 'import', format },
|
|
1497
|
+
),
|
|
1498
|
+
)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
for (const format of EXPECTED_EXPORT_FORMATS) {
|
|
1502
|
+
if (!exports.includes(format)) {
|
|
1503
|
+
warnings.push(
|
|
1504
|
+
warning(
|
|
1505
|
+
BlenderWarningCode.FORMAT_UNAVAILABLE,
|
|
1506
|
+
`Export format "${format}" is not provided by this Blender build and cannot be offered yet.`,
|
|
1507
|
+
{ direction: 'export', format },
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Bootstrap-reported warnings are already precise; pass them through verbatim
|
|
1514
|
+
// under a generic code rather than re-labelling them as add-on failures.
|
|
1515
|
+
for (const message of Array.isArray(raw.warnings) ? raw.warnings : []) {
|
|
1516
|
+
if (typeof message === 'string' && message.length > 0) {
|
|
1517
|
+
warnings.push(warning(BlenderWarningCode.PROBE_WARNING, message))
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return warnings
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Validate that a configured path is a usable Blender executable, without
|
|
1526
|
+
* launching it. Exposed for the settings card's "test path" affordance and for
|
|
1527
|
+
* unit tests that must not require Blender to be installed.
|
|
1528
|
+
*
|
|
1529
|
+
* @param {string} candidate
|
|
1530
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
1531
|
+
*/
|
|
1532
|
+
export function inspectExecutablePath(candidate) {
|
|
1533
|
+
try {
|
|
1534
|
+
const canonical = realpathSync(candidate)
|
|
1535
|
+
const stats = statSync(canonical)
|
|
1536
|
+
if (stats.isDirectory()) return { ok: false, reason: 'path is a directory' }
|
|
1537
|
+
// Blender ships a macOS .app wrapper; the real binary lives under Contents/MacOS.
|
|
1538
|
+
if (!stats.isFile()) return { ok: false, reason: 'path is not a regular file' }
|
|
1539
|
+
return { ok: true }
|
|
1540
|
+
} catch (cause) {
|
|
1541
|
+
return { ok: false, reason: cause instanceof Error ? cause.message : String(cause) }
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* What to DO about a Blender that could not be resolved, as one sentence.
|
|
1547
|
+
*
|
|
1548
|
+
* This is where the package's two previously-uncalled helpers finally have a caller, and where the
|
|
1549
|
+
* comment above them ("exposed for the settings card's test path affordance") becomes true. MEASURED,
|
|
1550
|
+
* round 35: `inspectExecutablePath` and `discoverBlenderOnPath` were exported — and required by
|
|
1551
|
+
* `contract/imports.test.mjs` — with NO caller anywhere in the product, while the settings card showed
|
|
1552
|
+
* `可执行文件: 未解析到` and nothing else: a dead end for the human, on the one screen whose job is to
|
|
1553
|
+
* say what is wrong with the install. The tool text told the model what to do; the card told the
|
|
1554
|
+
* operator nothing.
|
|
1555
|
+
*
|
|
1556
|
+
* The two lookups arrive as parameters because the branches have to be drivable without arranging the
|
|
1557
|
+
* machine: "a configured path that does not work", "a Blender on PATH that was not configured" and
|
|
1558
|
+
* "nothing anywhere" are three different sentences, and only the first is reachable by configuration.
|
|
1559
|
+
* Same trade as `reconcileRenderJob`'s injectable store.
|
|
1560
|
+
*
|
|
1561
|
+
* Order matters and is deliberate: a path the operator CONFIGURED and that does not work is the most
|
|
1562
|
+
* specific thing to report, so it wins over a suggestion to go looking on PATH.
|
|
1563
|
+
*
|
|
1564
|
+
* @param {{ requested: string, fallbackName?: string }} input
|
|
1565
|
+
* @param {{ inspect?: (candidate: string) => {ok: boolean, reason?: string}, discover?: () => string|null }} [deps]
|
|
1566
|
+
* @returns {string}
|
|
1567
|
+
*/
|
|
1568
|
+
export function blenderPathAdvice(input, deps = {}) {
|
|
1569
|
+
const inspect = deps.inspect ?? inspectExecutablePath
|
|
1570
|
+
const discover = deps.discover ?? discoverBlenderOnPath
|
|
1571
|
+
const requested = typeof input?.requested === 'string' ? input.requested : ''
|
|
1572
|
+
const fallback = input?.fallbackName ?? FALLBACK_BLENDER_NAME
|
|
1573
|
+
const configured = requested.length > 0 && requested !== fallback ? requested : null
|
|
1574
|
+
|
|
1575
|
+
if (configured !== null) {
|
|
1576
|
+
const verdict = inspect(configured)
|
|
1577
|
+
if (verdict.ok !== true) {
|
|
1578
|
+
return `The configured path "${configured}" is not usable: ${verdict.reason}. ` +
|
|
1579
|
+
'Run deepblend/tools/install-blender.mjs, or point deepblend.blenderPath at a real Blender executable.'
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const found = discover()
|
|
1583
|
+
if (found !== null) {
|
|
1584
|
+
return `A Blender was found on PATH at ${found}. Set deepblend.blenderPath to that absolute path ` +
|
|
1585
|
+
'(or add its directory to deepblend.executableAllowlist if a bare name should resolve).'
|
|
1586
|
+
}
|
|
1587
|
+
return 'No Blender was found. Run deepblend/tools/install-blender.mjs to install the managed build, ' +
|
|
1588
|
+
'or set deepblend.blenderPath to an absolute path.'
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Locate a Blender executable on the host PATH synchronously.
|
|
1593
|
+
*
|
|
1594
|
+
* Used only by the settings card's discovery affordance to *suggest* a path.
|
|
1595
|
+
* Never used to bypass `ctx.subprocess.resolveExecutable` on the execution path.
|
|
1596
|
+
*
|
|
1597
|
+
* @returns {string|null}
|
|
1598
|
+
*/
|
|
1599
|
+
export function discoverBlenderOnPath() {
|
|
1600
|
+
const pathValue = process.env.PATH
|
|
1601
|
+
if (!pathValue) return null
|
|
1602
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
1603
|
+
if (directory.length === 0) continue
|
|
1604
|
+
const candidate = join(directory, 'blender')
|
|
1605
|
+
if (!existsSync(candidate)) continue
|
|
1606
|
+
const sync = spawnSync(candidate, ['--version'], { encoding: 'utf8', timeout: 20_000 })
|
|
1607
|
+
if (sync.status === 0) return candidate
|
|
1608
|
+
}
|
|
1609
|
+
return null
|
|
1610
|
+
}
|