@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 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
+ }