dsh-multimodal-node 0.0.0-stage → 0.2.2

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/blender.js ADDED
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Blender 板块:一个实例 = 一个可提交渲染的地方。
3
+ *
4
+ * 两种托管,判据不同,因此不能共用一套健康检查:
5
+ *
6
+ * bridge —— 对方机器上跑着一个小 HTTP 端点(Blender addon 或包装服务)。
7
+ * 探 `GET /health`(退一步 `GET /`),提交 `POST /render`。
8
+ * spawn —— 本机 `blender.exe -b file.blend -o 前缀 -f 帧`。
9
+ * 不探网,只判「可执行文件在不在 + 进程还活着」,提交即起进程。
10
+ *
11
+ * 为什么不做成「whatever 一个 URL」:命令行渲染没有 URL,而桥接渲染没有进程。
12
+ * 把两者塞进同一个 data 形状,面板就只能给出一个既能又能的假象(用户点了「启动」
13
+ * 发现是别人的进程),这正是 03 号插件在 ComfyUI 实例上踩过的坑,不再重犯。
14
+ */
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { existsSync, readdirSync, statSync } from 'node:fs';
17
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
18
+ import { randomUUID } from 'node:crypto';
19
+ import { errorMessage } from './http.js';
20
+
21
+ const ID_PATTERN = /^[a-z0-9_-]{1,32}$/;
22
+ const BLEND_EXT = /\.(blend|blend1)$/i;
23
+ const VIDEO_EXT = /\.(mp4|mov|avi|mkv|webm)$/i;
24
+ const IMAGE_EXT = /\.(png|jpg|jpeg|exr|tif|tiff|webp)$/i;
25
+
26
+ export function normalizeBaseUrl(raw) {
27
+ let text = String(raw ?? '').trim();
28
+ if (text === '') return '';
29
+ if (!/^https?:\/\//i.test(text)) text = `http://${text}`;
30
+ try {
31
+ const url = new URL(text);
32
+ return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`;
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ function clampInt(value, min, max, fallback) {
39
+ const number = Number(value);
40
+ if (!Number.isFinite(number)) return fallback;
41
+ return Math.min(max, Math.max(min, Math.floor(number)));
42
+ }
43
+
44
+ export function normalizeBlenderInstance(input, index = 0) {
45
+ const kind = input.kind === 'spawn' ? 'spawn' : 'bridge';
46
+ const baseUrl = normalizeBaseUrl(input.baseUrl ?? input.url);
47
+ const derived = baseUrl === '' ? `b${index}` : `b${new URL(baseUrl).port || '80'}`;
48
+ const id = typeof input.id === 'string' && ID_PATTERN.test(input.id) ? input.id : derived;
49
+ const control = input.control ?? {};
50
+ const isBat = (value) => /\.(bat|cmd)$/i.test(String(value ?? '').trim());
51
+ const declared = typeof control.batPath === 'string' ? control.batPath.trim() : '';
52
+ const type = isBat(declared) ? 'script' : 'none';
53
+ return {
54
+ id,
55
+ label: typeof input.label === 'string' && input.label !== '' ? input.label : id,
56
+ kind,
57
+ baseUrl: kind === 'bridge' ? baseUrl : '',
58
+ token: typeof input.token === 'string' ? input.token : '',
59
+ weight: clampInt(input.weight, 1, 10, 1),
60
+ maxConcurrent: clampInt(input.maxConcurrent, 1, 16, 1),
61
+ enabled: input.enabled !== false,
62
+ autoStart: input.autoStart === true,
63
+ /** spawn 专用:这台机器的工程文件与渲染参数由提交时给,这里只放可执行文件覆盖。 */
64
+ executable: typeof input.executable === 'string' ? input.executable.trim() : '',
65
+ control: {
66
+ type,
67
+ batPath: declared,
68
+ cwd: typeof control.cwd === 'string' ? control.cwd : '',
69
+ stopTimeoutMs: clampInt(control.stopTimeoutMs, 500, 60000, 15000),
70
+ readyTimeoutMs: clampInt(control.readyTimeoutMs, 2000, 900000, 120000),
71
+ stopOnExit: control.stopOnExit === true,
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * 把一个可执行文件 + 参数列表变成一次真正能跑的 spawn 调用形状。
78
+ *
79
+ * 为什么需要它:Node 18 起在 Windows 上直接 spawn 一个 .cmd / .bat 会 EINVAL
80
+ * (CVE-2024-27980 的修法),而「用户填了个包装脚本」是最常见的部署形态——
81
+ * 一个 .cmd 里 cd 到工程目录再调 blender.exe。同时真实 blender.exe 又不该被套一层
82
+ * cmd.exe,那样 Ctrl-C 与退出码都会失真。
83
+ *
84
+ * 所以:exe 自己跑,.cmd/.bat 交给 cmd.exe 跑,两条路径各自用正确的引号形式。
85
+ */
86
+ export function buildLaunch(exe, args) {
87
+ const file = String(exe ?? '');
88
+ const argv = args.map((item) => String(item));
89
+ if (!/\.(bat|cmd)$/i.test(file)) {
90
+ return { file, argv, windowsVerbatimArguments: false };
91
+ }
92
+ // cmd /s /c 要求整条命令再包一层引号,否则带空格的路径会被拆开。
93
+ // /d 跳过 AutoRun,避免注册表里一条 AutoRun 往我们的命令行前面插东西。
94
+ const quoted = argv.map((item) => (item.includes(' ') ? `"${item}"` : item)).join(' ');
95
+ return {
96
+ file: process.env.ComSpec ?? 'cmd.exe',
97
+ argv: ['/d', '/s', '/c', `""${file}" ${quoted}"`],
98
+ windowsVerbatimArguments: true,
99
+ };
100
+ }
101
+
102
+ /** 在常见安装位置找 blender.exe;找到第一个就用,找不到返回空串。 */
103
+ export function findBlenderExecutable(configured = '') {
104
+ const explicit = String(configured ?? '').trim();
105
+ if (explicit !== '' && existsSync(explicit)) return explicit;
106
+ const candidates = [];
107
+ const roots = [
108
+ 'C:\\Program Files\\Blender Foundation',
109
+ 'D:\\Program Files\\Blender Foundation',
110
+ 'C:\\Program Files\\Blender',
111
+ 'D:\\Blender',
112
+ ];
113
+ for (const root of roots) {
114
+ if (!existsSync(root)) continue;
115
+ let entries = [];
116
+ try {
117
+ entries = readdirSync(root, { withFileTypes: true });
118
+ } catch {
119
+ continue;
120
+ }
121
+ for (const entry of entries) {
122
+ if (!entry.isDirectory()) continue;
123
+ // 「Blender 4.2」这样的版本目录,倒序取新版本
124
+ candidates.push(join(root, entry.name, 'blender.exe'));
125
+ }
126
+ }
127
+ candidates.sort().reverse();
128
+ for (const file of candidates) if (existsSync(file)) return file;
129
+ // PATH 兜底
130
+ const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['blender'], { encoding: 'utf8', windowsHide: true });
131
+ if (which.status === 0) {
132
+ const first = String(which.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '')[0];
133
+ if (first !== undefined && existsSync(first)) return first;
134
+ }
135
+ return '';
136
+ }
137
+
138
+ /** 一帧一行:接受 `1`、`1-10`、`1,3,5`、`1-10:2`。 */
139
+ export function parseFrames(text) {
140
+ const raw = String(text ?? '').trim();
141
+ if (raw === '') return [];
142
+ const frames = [];
143
+ for (const part of raw.split(/[,\s]+/)) {
144
+ if (part === '') continue;
145
+ const stepped = part.match(/^(\d+)-(\d+)(?::(\d+))?$/);
146
+ if (stepped !== null) {
147
+ const start = Number(stepped[1]);
148
+ const end = Number(stepped[2]);
149
+ // 步长必须 >= 1:0 会让 for 循环永不推进(直接挂死宿主)。
150
+ const step = Math.max(1, Number(stepped[3] ?? 1));
151
+ // 倒序范围(9-1)故意产出空:它的字面意思是一段不存在的区间,
152
+ // 猜成 1-9 会让「1-10」这类手误静默渲染十倍帧数。
153
+ // 注意 for 的条件本身已经挡住了它,所以这里不再写一个多余的 continue——
154
+ // 之前的版本有一句 end<start 的守卫,那是不可达代码,
155
+ // 而一条永远为真的断言会把「倒序被忽略」伪装成被测过的行为。
156
+ for (let frame = start; frame <= end && frames.length < 2000; frame += step) frames.push(frame);
157
+ continue;
158
+ }
159
+ const single = Number(part);
160
+ if (Number.isFinite(single) && single >= 0) frames.push(Math.floor(single));
161
+ }
162
+ return frames.slice(0, 2000);
163
+ }
164
+
165
+ /** 一个 Blender 实例的运行态。 */
166
+ export class BlenderRuntime {
167
+ constructor(instance) {
168
+ this.instance = instance;
169
+ this.health = { ok: false, checkedAt: 0, version: null, latencyMs: 0, error: '未探测', engine: null };
170
+ this.child = null;
171
+ this.pid = null;
172
+ this.starting = false;
173
+ this.stopping = false;
174
+ this.lastError = null;
175
+ this.consoleTail = '';
176
+ /** 进行中的执行:jobId -> { id, state, startedAt, frames, outputs, error, command } */
177
+ this.jobs = new Map();
178
+ this.running = 0;
179
+ }
180
+
181
+ get id() {
182
+ return this.instance.id;
183
+ }
184
+ }
185
+
186
+ export class BlenderManager {
187
+ /**
188
+ * @param {{getConfig:()=>any, onChange:()=>void, log:(level:string,message:string)=>void}} options
189
+ */
190
+ constructor(options) {
191
+ this.options = options;
192
+ /** @type {Map<string, BlenderRuntime>} */
193
+ this.runtimes = new Map();
194
+ this.sync();
195
+ }
196
+
197
+ get config() {
198
+ return this.options.getConfig() ?? {};
199
+ }
200
+
201
+ sync() {
202
+ const rows = Array.isArray(this.config.instances) ? this.config.instances : [];
203
+ const wanted = new Map();
204
+ for (const raw of rows) {
205
+ const instance = normalizeBlenderInstance(raw, wanted.size);
206
+ if (instance.enabled === false) {
207
+ // 停用的实例仍然保留在运行时表里:面板要显示它、要能重新启用,
208
+ // 只是不派发。删掉会让「停用」表现得像「删除」。
209
+ wanted.set(instance.id, instance);
210
+ continue;
211
+ }
212
+ wanted.set(instance.id, instance);
213
+ }
214
+ for (const [id, runtime] of this.runtimes) {
215
+ if (!wanted.has(id)) {
216
+ this.killJob(runtime, '实例被移除');
217
+ this.runtimes.delete(id);
218
+ }
219
+ }
220
+ for (const [id, instance] of wanted) {
221
+ const existing = this.runtimes.get(id);
222
+ if (existing === undefined) this.runtimes.set(id, new BlenderRuntime(instance));
223
+ else existing.instance = instance;
224
+ }
225
+ }
226
+
227
+ list() {
228
+ return [...this.runtimes.values()];
229
+ }
230
+
231
+ get(id) {
232
+ return this.runtimes.get(id);
233
+ }
234
+
235
+ require(id) {
236
+ const runtime = this.runtimes.get(id);
237
+ if (runtime === undefined) throw new Error(`没有 Blender 实例 ${id}`);
238
+ return runtime;
239
+ }
240
+
241
+ enabled() {
242
+ return this.list().filter((runtime) => runtime.instance.enabled !== false);
243
+ }
244
+
245
+ executableFor(runtime) {
246
+ const own = String(runtime.instance.executable ?? '').trim();
247
+ const shared = String(this.config.executable ?? '').trim();
248
+ return findBlenderExecutable(own !== '' ? own : shared);
249
+ }
250
+
251
+ async probeAll(force = false) {
252
+ const now = Date.now();
253
+ await Promise.all(
254
+ this.list().map(async (runtime) => {
255
+ if (!force && now - runtime.health.checkedAt < 4000) return;
256
+ const started = Date.now();
257
+ if (runtime.instance.kind === 'spawn') {
258
+ const exe = this.executableFor(runtime);
259
+ runtime.health = {
260
+ ok: exe !== '',
261
+ checkedAt: Date.now(),
262
+ version: exe !== '' ? basename(dirname(exe)) : null,
263
+ latencyMs: Date.now() - started,
264
+ error: exe === '' ? '找不到 blender.exe:在板块里填可执行文件路径' : null,
265
+ engine: 'spawn',
266
+ };
267
+ return;
268
+ }
269
+ const base = runtime.instance.baseUrl;
270
+ if (base === '') {
271
+ runtime.health = { ok: false, checkedAt: Date.now(), version: null, latencyMs: 0, error: '没有填地址', engine: 'bridge' };
272
+ return;
273
+ }
274
+ try {
275
+ const reply = await fetch(`${base}/health`, {
276
+ headers: this.headers(runtime),
277
+ signal: AbortSignal.timeout(4000),
278
+ }).catch(() => fetch(`${base}/`, { headers: this.headers(runtime), signal: AbortSignal.timeout(4000) }));
279
+ const text = await reply.text();
280
+ let version = null;
281
+ let engine = null;
282
+ try {
283
+ const parsed = JSON.parse(text);
284
+ version = typeof parsed.version === 'string' ? parsed.version : null;
285
+ engine = typeof parsed.engine === 'string' ? parsed.engine : null;
286
+ } catch {
287
+ // 非 JSON 也当在线:一个回 HTML 的桥接至少是活的
288
+ }
289
+ runtime.health = {
290
+ ok: reply.ok,
291
+ checkedAt: Date.now(),
292
+ version,
293
+ latencyMs: Date.now() - started,
294
+ error: reply.ok ? null : `HTTP ${reply.status}`,
295
+ engine: engine ?? 'bridge',
296
+ };
297
+ } catch (error) {
298
+ runtime.health = {
299
+ ok: false,
300
+ checkedAt: Date.now(),
301
+ version: null,
302
+ latencyMs: Date.now() - started,
303
+ error: errorMessage(error),
304
+ engine: 'bridge',
305
+ };
306
+ }
307
+ }),
308
+ );
309
+ }
310
+
311
+ headers(runtime) {
312
+ const headers = { 'content-type': 'application/json' };
313
+ if (runtime.instance.token !== '') headers.authorization = `Bearer ${runtime.instance.token}`;
314
+ return headers;
315
+ }
316
+
317
+ /**
318
+ * 提交一次渲染。
319
+ *
320
+ * spawn:立刻起进程,返回 jobId;调用方轮询 status。
321
+ * bridge:POST 过去,用对方的 id(没有就用本地的),再由本端轮询对方。
322
+ */
323
+ async submit(instanceId, body = {}) {
324
+ const runtime = this.require(instanceId);
325
+ if (runtime.instance.enabled === false) throw new Error(`${instanceId} 已停用`);
326
+ if (runtime.instance.kind === 'bridge') return this.submitBridge(runtime, body);
327
+ return this.submitSpawn(runtime, body);
328
+ }
329
+
330
+ async submitBridge(runtime, body) {
331
+ const base = runtime.instance.baseUrl;
332
+ if (base === '') throw new Error('这个实例没有填桥接地址');
333
+ const reply = await fetch(`${base}/render`, {
334
+ method: 'POST',
335
+ headers: this.headers(runtime),
336
+ body: JSON.stringify(body),
337
+ signal: AbortSignal.timeout(30000),
338
+ });
339
+ const text = await reply.text();
340
+ let parsed = {};
341
+ try {
342
+ parsed = JSON.parse(text);
343
+ } catch {
344
+ // 桥接回的不是 JSON:把原文带回去,比一个空洞的 parse error 有用
345
+ }
346
+ if (!reply.ok) throw new Error(`桥接拒绝:HTTP ${reply.status} ${String(parsed.error ?? text).slice(0, 200)}`);
347
+ const jobId = String(parsed.id ?? parsed.jobId ?? parsed.job_id ?? '');
348
+ const id = jobId !== '' ? jobId : randomUUID();
349
+ runtime.jobs.set(id, {
350
+ id,
351
+ state: 'running',
352
+ mode: 'bridge',
353
+ startedAt: Date.now(),
354
+ frames: body.frames ?? null,
355
+ blend: body.blend ?? null,
356
+ outputs: [],
357
+ error: null,
358
+ });
359
+ runtime.running += 1;
360
+ return { ok: true, instanceId: runtime.id, jobId: id, mode: 'bridge', upstream: parsed };
361
+ }
362
+
363
+ async submitSpawn(runtime, body) {
364
+ const exe = this.executableFor(runtime);
365
+ if (exe === '') throw new Error('找不到 blender.exe:在板块里填可执行文件路径');
366
+ const blend = String(body.blend ?? '').trim();
367
+ if (blend === '' || !isAbsolute(blend)) throw new Error('spawn 渲染需要工程文件的绝对路径(blend)');
368
+ if (!existsSync(blend)) throw new Error(`工程文件不存在:${blend}`);
369
+ if (!BLEND_EXT.test(blend)) throw new Error(`不是 .blend 文件:${blend}`);
370
+ const frames = parseFrames(body.frames ?? '1');
371
+ if (frames.length === 0) throw new Error('没有可渲染的帧(frames 例:1 / 1-10 / 1,3,5)');
372
+
373
+ const outDir = isAbsolute(String(body.outputDir ?? '').trim())
374
+ ? resolve(String(body.outputDir).trim())
375
+ : join(this.config.outputDir && isAbsolute(this.config.outputDir) ? this.config.outputDir : dirname(blend), 'render');
376
+ const prefix = String(body.prefix ?? basename(blend, extname(blend))).replace(/[^\w.-]/g, '_');
377
+ const args = ['-b', blend];
378
+ if (typeof body.engine === 'string' && body.engine !== '') args.push('-E', body.engine);
379
+ args.push('-o', join(outDir, `${prefix}_####`), '-F', String(body.format ?? 'PNG'));
380
+ for (const frame of frames) args.push('-f', String(frame));
381
+
382
+ if (runtime.running >= runtime.instance.maxConcurrent) {
383
+ throw new Error(`${runtime.id} 已到并发上限 ${runtime.instance.maxConcurrent}`);
384
+ }
385
+
386
+ const id = randomUUID();
387
+ const launch = buildLaunch(exe, args);
388
+ const child = spawn(launch.file, launch.argv, {
389
+ cwd: typeof body.cwd === 'string' && body.cwd !== '' && existsSync(body.cwd) ? body.cwd : dirname(exe),
390
+ windowsHide: true,
391
+ windowsVerbatimArguments: launch.windowsVerbatimArguments,
392
+ stdio: ['ignore', 'pipe', 'pipe'],
393
+ });
394
+ runtime.child = child;
395
+ runtime.pid = child.pid ?? null;
396
+ runtime.running += 1;
397
+ const job = {
398
+ id,
399
+ state: 'running',
400
+ mode: 'spawn',
401
+ startedAt: Date.now(),
402
+ frames,
403
+ blend,
404
+ outputDir: outDir,
405
+ prefix,
406
+ outputs: [],
407
+ error: null,
408
+ command: `${basename(exe)} ${args.join(' ')}`,
409
+ };
410
+ runtime.jobs.set(id, job);
411
+
412
+ const tail = [];
413
+ const keep = (chunk) => {
414
+ tail.push(String(chunk));
415
+ if (tail.length > 60) tail.shift();
416
+ runtime.consoleTail = tail.join('').slice(-8000);
417
+ };
418
+ child.stdout?.on('data', keep);
419
+ child.stderr?.on('data', keep);
420
+ child.on('error', (error) => {
421
+ job.state = 'failed';
422
+ job.error = errorMessage(error);
423
+ job.finishedAt = Date.now();
424
+ runtime.running = Math.max(0, runtime.running - 1);
425
+ runtime.child = null;
426
+ });
427
+ child.on('exit', (code) => {
428
+ runtime.child = null;
429
+ runtime.pid = null;
430
+ runtime.running = Math.max(0, runtime.running - 1);
431
+ job.finishedAt = Date.now();
432
+ job.outputs = this.scanOutputs(outDir, prefix);
433
+ if (code === 0) {
434
+ job.state = job.outputs.length > 0 ? 'done' : 'empty';
435
+ job.error = job.outputs.length > 0 ? null : '进程正常退出但没有产物:检查输出目录与帧范围';
436
+ } else {
437
+ job.state = 'failed';
438
+ job.error = `blender 退出码 ${code}:${(runtime.consoleTail || '').split(/\r?\n/).filter(Boolean).slice(-3).join(' | ').slice(0, 300)}`;
439
+ }
440
+ this.options.onChange?.();
441
+ });
442
+ return { ok: true, instanceId: runtime.id, jobId: id, mode: 'spawn', frames: frames.length, outputDir: outDir, command: job.command };
443
+ }
444
+
445
+ /** 渲染产物按前缀扫目录;blender 自己往 `前缀_0001.png` 这种名字写。 */
446
+ scanOutputs(dir, prefix) {
447
+ const found = [];
448
+ let entries;
449
+ try {
450
+ entries = readdirSync(dir, { withFileTypes: true });
451
+ } catch {
452
+ return found;
453
+ }
454
+ for (const entry of entries) {
455
+ if (!entry.isFile()) continue;
456
+ if (prefix !== '' && !entry.name.startsWith(prefix)) continue;
457
+ if (!IMAGE_EXT.test(entry.name) && !VIDEO_EXT.test(entry.name)) continue;
458
+ let size = null;
459
+ try {
460
+ size = statSync(join(dir, entry.name)).size;
461
+ } catch {
462
+ size = null;
463
+ }
464
+ found.push({ file: entry.name, dir, size });
465
+ }
466
+ return found.slice(0, 500);
467
+ }
468
+
469
+ async cancel(instanceId, jobId) {
470
+ const runtime = this.require(instanceId);
471
+ if (runtime.instance.kind === 'bridge') {
472
+ const base = runtime.instance.baseUrl;
473
+ const reply = await fetch(`${base}/cancel`, {
474
+ method: 'POST',
475
+ headers: this.headers(runtime),
476
+ body: JSON.stringify({ id: jobId }),
477
+ signal: AbortSignal.timeout(10000),
478
+ }).catch((error) => ({ ok: false, status: 0, statusText: errorMessage(error) }));
479
+ const job = runtime.jobs.get(jobId);
480
+ if (job !== undefined) {
481
+ job.state = 'cancelled';
482
+ job.finishedAt = Date.now();
483
+ }
484
+ return { ok: reply.ok === true, status: reply.status ?? 0 };
485
+ }
486
+ const job = runtime.jobs.get(jobId);
487
+ if (job === undefined) throw new Error(`没有任务 ${jobId}`);
488
+ this.killJob(runtime, '用户取消');
489
+ job.state = 'cancelled';
490
+ job.finishedAt = Date.now();
491
+ return { ok: true };
492
+ }
493
+
494
+ killJob(runtime, reason) {
495
+ const child = runtime.child;
496
+ runtime.child = null;
497
+ if (child === null || child === undefined) return;
498
+ const pid = child.pid;
499
+ try {
500
+ if (process.platform === 'win32' && Number.isFinite(pid)) {
501
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
502
+ } else {
503
+ child.kill('SIGKILL');
504
+ }
505
+ } catch {
506
+ // 进程已经没了:这不是错误
507
+ }
508
+ runtime.pid = null;
509
+ runtime.running = Math.max(0, runtime.running - 1);
510
+ runtime.lastError = reason ?? null;
511
+ }
512
+
513
+ /** 板块状态:给面板与设置页同一个形状。 */
514
+ statusPayload() {
515
+ return {
516
+ enabled: this.config.enabled !== false,
517
+ executable: findBlenderExecutable(String(this.config.executable ?? '')),
518
+ outputDir: typeof this.config.outputDir === 'string' ? this.config.outputDir : '',
519
+ instances: this.list().map((runtime) => ({
520
+ id: runtime.id,
521
+ label: runtime.instance.label,
522
+ kind: runtime.instance.kind,
523
+ baseUrl: runtime.instance.baseUrl,
524
+ enabled: runtime.instance.enabled !== false,
525
+ healthy: runtime.health.ok,
526
+ version: runtime.health.version,
527
+ engine: runtime.health.engine,
528
+ latencyMs: runtime.health.latencyMs,
529
+ error: runtime.health.error,
530
+ maxConcurrent: runtime.instance.maxConcurrent,
531
+ weight: runtime.instance.weight,
532
+ control: runtime.instance.control.type,
533
+ controlDetail: runtime.instance.control,
534
+ executable: this.executableFor(runtime),
535
+ pid: runtime.pid,
536
+ running: runtime.running,
537
+ starting: runtime.starting,
538
+ stopping: runtime.stopping,
539
+ jobs: [...runtime.jobs.values()].slice(-20).map((job) => ({
540
+ id: job.id,
541
+ state: job.state,
542
+ mode: job.mode,
543
+ startedAt: job.startedAt,
544
+ finishedAt: job.finishedAt ?? null,
545
+ frames: Array.isArray(job.frames) ? job.frames.length : null,
546
+ blend: job.blend ?? null,
547
+ error: job.error ?? null,
548
+ outputs: (job.outputs ?? []).slice(0, 8),
549
+ })),
550
+ })),
551
+ };
552
+ }
553
+
554
+ /** 板块里能选的工程文件:扫某个目录下的 .blend。 */
555
+ browseBlends(dir) {
556
+ const target = String(dir ?? '').trim();
557
+ if (target === '' || !isAbsolute(target) || !existsSync(target)) return [];
558
+ const rows = [];
559
+ const walk = (current, depth) => {
560
+ if (depth > 3 || rows.length > 400) return;
561
+ let entries;
562
+ try {
563
+ entries = readdirSync(current, { withFileTypes: true });
564
+ } catch {
565
+ return;
566
+ }
567
+ for (const entry of entries) {
568
+ if (entry.isDirectory()) {
569
+ if (/^(\.git|__pycache__|node_modules)$/i.test(entry.name)) continue;
570
+ walk(join(current, entry.name), depth + 1);
571
+ continue;
572
+ }
573
+ if (!entry.isFile()) continue;
574
+ if (!BLEND_EXT.test(entry.name)) continue;
575
+ let size = null;
576
+ try {
577
+ size = statSync(join(current, entry.name)).size;
578
+ } catch {
579
+ size = null;
580
+ }
581
+ rows.push({ path: join(current, entry.name), name: entry.name, size, dir: current });
582
+ }
583
+ };
584
+ walk(target, 0);
585
+ return rows;
586
+ }
587
+
588
+ dispose() {
589
+ const stopped = [];
590
+ for (const runtime of this.list()) {
591
+ if (runtime.instance.control.stopOnExit === false) continue;
592
+ if (runtime.child !== null) {
593
+ this.killJob(runtime, 'shutdown');
594
+ stopped.push(runtime.id);
595
+ }
596
+ }
597
+ return stopped;
598
+ }
599
+ }
600
+