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/README.md +504 -2
- package/client.js +2817 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +960 -0
- package/lib/apikeys.js +250 -0
- package/lib/assets.js +234 -0
- package/lib/auth.js +315 -0
- package/lib/blender.js +600 -0
- package/lib/chain.js +161 -0
- package/lib/comfy.js +204 -0
- package/lib/config.js +377 -0
- package/lib/gateway.js +974 -0
- package/lib/http.js +119 -0
- package/lib/imagesize.js +92 -0
- package/lib/instances.js +554 -0
- package/lib/library.js +1209 -0
- package/lib/models.js +322 -0
- package/lib/nodeapi.js +491 -0
- package/lib/nodeclient.js +143 -0
- package/lib/pages.js +5788 -0
- package/lib/pool.js +591 -0
- package/lib/promptassist.js +1077 -0
- package/lib/promptlibrary.js +92 -0
- package/lib/remote.js +3348 -0
- package/lib/runindex.js +126 -0
- package/lib/seed.js +118 -0
- package/lib/tagdata.json +1 -0
- package/lib/tiers.js +283 -0
- package/lib/tools.js +230 -0
- package/lib/viewport.js +134 -0
- package/lib/workflows.js +472 -0
- package/lib/ws.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/lib/config.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多模态节点 —— 配置与持久化。
|
|
3
|
+
*
|
|
4
|
+
* 一个插件,两种身份:
|
|
5
|
+
*
|
|
6
|
+
* service(服务节点)—— 这台机器有显卡。它开门:API 接入端口给脚本/别的 DSH 用,
|
|
7
|
+
* 网页端口给人用,另加 ComfyUI 板块与 Blender 板块的实际端口托管。
|
|
8
|
+
* user(用户节点) —— 这台机器只是客户端。它不出门:填服务节点的公网地址 + apikey,
|
|
9
|
+
* 侧边栏里拿模板、看队列、看模型,产物落本机目录。
|
|
10
|
+
*
|
|
11
|
+
* 模式是唯一的顶层开关,两套 UI 与两套监听行为都由它派生。端口沿用 03 号插件已有的两个
|
|
12
|
+
* 存储键(`listen` = API 接入端口,`remote` = 网页端口),因为 03 的网关、控制台、页面
|
|
13
|
+
* 全读这两个键——换成新键名只会让 18k 行底座跟着改,收益为零。
|
|
14
|
+
*/
|
|
15
|
+
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const PLUGIN_ID = 'dsh-multimodal-node';
|
|
20
|
+
export const MODES = ['service', 'user'];
|
|
21
|
+
|
|
22
|
+
export function defaultDataDir() {
|
|
23
|
+
const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
24
|
+
return join(base, 'data', PLUGIN_ID);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 用户节点默认的产物目录:本机一个明确可见的地方,而不是散在 ComfyUI 的 output 里。 */
|
|
28
|
+
export function defaultOutputDir() {
|
|
29
|
+
const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
30
|
+
return join(base, 'media', PLUGIN_ID);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultSettings() {
|
|
34
|
+
return {
|
|
35
|
+
/**
|
|
36
|
+
* 工作模式:service | user。
|
|
37
|
+
*
|
|
38
|
+
* 默认 service:新装的机器更可能是「有卡的那台」,而且用户节点必须填公网地址才有意义,
|
|
39
|
+
* 缺地址的用户节点是一个什么都点不动的页面。
|
|
40
|
+
*/
|
|
41
|
+
mode: 'service',
|
|
42
|
+
|
|
43
|
+
/** API 接入端口(其他机器 / 脚本 / 别的 DSH 把 JSON POST 到这里)。 */
|
|
44
|
+
listen: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
host: '0.0.0.0',
|
|
47
|
+
port: 8287,
|
|
48
|
+
},
|
|
49
|
+
/** fifo | dynamic —— 只影响排序,两种都经过任务池。 */
|
|
50
|
+
dynamic: true,
|
|
51
|
+
/** 任务排队超过这个秒数没有空闲实例就提示,不静默挂着。 */
|
|
52
|
+
stallWarnSec: 120,
|
|
53
|
+
/** 实例健康探测间隔,ms。 */
|
|
54
|
+
probeMs: 4000,
|
|
55
|
+
/** 合并历史保留多少条完成记录。 */
|
|
56
|
+
historyItems: 120,
|
|
57
|
+
/**
|
|
58
|
+
* 固定提示词表头。控制台把「表头」和「人写的那句」分成两个框,送出前用 ", " 拼。
|
|
59
|
+
* 真正的生效值是每个工作流自己的 heads;这里只是新建工作流时的默认值。
|
|
60
|
+
*/
|
|
61
|
+
promptHeads: {
|
|
62
|
+
positive: 'masterpiece, best quality, very awa, absurdres, highres',
|
|
63
|
+
negative:
|
|
64
|
+
'worst quality, low quality, normal quality, lowres, bad anatomy, bad hands, ' +
|
|
65
|
+
'signature, watermarks, ugly, imperfect eyes, extra limb, missing limbs',
|
|
66
|
+
},
|
|
67
|
+
/** 自然语言助手读参考资料的来源。 */
|
|
68
|
+
promptKb: {
|
|
69
|
+
kbName: '提示词',
|
|
70
|
+
root: '',
|
|
71
|
+
dir: '',
|
|
72
|
+
excerptBytes: 6000,
|
|
73
|
+
},
|
|
74
|
+
/** 助手按哪个提示词技能选词。 */
|
|
75
|
+
promptSkill: {
|
|
76
|
+
name: 'pony-prompt-engineering',
|
|
77
|
+
dir: '',
|
|
78
|
+
maxBytes: 12000,
|
|
79
|
+
includeReferences: true,
|
|
80
|
+
},
|
|
81
|
+
/** 网页端口 + 网页账号 + 会话(03 号插件的控制台原样沿用)。 */
|
|
82
|
+
remote: {
|
|
83
|
+
enabled: true,
|
|
84
|
+
host: '0.0.0.0',
|
|
85
|
+
port: 8286,
|
|
86
|
+
requireCode: true,
|
|
87
|
+
assistant: true,
|
|
88
|
+
secret: '',
|
|
89
|
+
accounts: [],
|
|
90
|
+
sessions: [],
|
|
91
|
+
failures: [],
|
|
92
|
+
},
|
|
93
|
+
/**
|
|
94
|
+
* 服务节点的 API 侧凭据。
|
|
95
|
+
*
|
|
96
|
+
* apikey 与网页账号是**两套凭据**,故意不合并:网页账号是人用的(用户名+密码+配对码),
|
|
97
|
+
* apikey 是程序用的(一个 Bearer 串,可轮换、可停用、可分级)。把它们塞进同一张表,
|
|
98
|
+
* 结局一定是「脚本拿到了一个能登进网页的密码」。
|
|
99
|
+
*/
|
|
100
|
+
service: {
|
|
101
|
+
/** 关掉时 API 端口不校验凭据(家庭内网、纯本机脚本用)。开着才认 apikey。 */
|
|
102
|
+
requireKey: false,
|
|
103
|
+
/**
|
|
104
|
+
* 每个 key: { key, name, tier: 'sfw'|'nsfw', enabled, createdAt, lastUsedAt, note }
|
|
105
|
+
* tier 决定这个 key 能看到/提交什么:SFW 的 key 提交成人工作流会被拒。
|
|
106
|
+
*/
|
|
107
|
+
apiKeys: [],
|
|
108
|
+
/** 公网暴露时的提示语,纯展示。 */
|
|
109
|
+
publicHint: '',
|
|
110
|
+
},
|
|
111
|
+
/** 用户节点:服务节点在哪、用哪个 key、产物落哪。 */
|
|
112
|
+
node: {
|
|
113
|
+
/** 服务节点的 API 接入地址,如 https://node.example.com:8287 或 http://1.2.3.4:8287 */
|
|
114
|
+
publicUrl: '',
|
|
115
|
+
/** 服务节点签发的 apikey。 */
|
|
116
|
+
apiKey: '',
|
|
117
|
+
/** 本机产物文件夹(绝对路径)。空 = defaultOutputDir()。 */
|
|
118
|
+
outputDir: '',
|
|
119
|
+
/** 连接时带上的显示名,方便服务端审计里认出是谁。 */
|
|
120
|
+
label: '',
|
|
121
|
+
/** 拉取模板/模型/队列的超时,ms。 */
|
|
122
|
+
timeoutMs: 15000,
|
|
123
|
+
},
|
|
124
|
+
/**
|
|
125
|
+
* Blender 板块。
|
|
126
|
+
*
|
|
127
|
+
* 一个实例 = 一个可提交渲染的地方,两种托管:
|
|
128
|
+
* bridge —— 那台机器上跑着一个 HTTP 端点(自带 addon / 小服务),POST 提交渲染。
|
|
129
|
+
* spawn —— 本机 blender.exe 命令行渲染(-b file.blend -o 前缀 -f 帧)。
|
|
130
|
+
* 健康判据因此不同:bridge 探 HTTP,spawn 看可执行文件与进程。
|
|
131
|
+
*/
|
|
132
|
+
blender: {
|
|
133
|
+
enabled: true,
|
|
134
|
+
/** 本机 blender.exe 路径;不给就按 PATH 与常见安装位置找。 */
|
|
135
|
+
executable: '',
|
|
136
|
+
/** 渲染产物默认目录;空 = 用户节点产物目录/blender */
|
|
137
|
+
outputDir: '',
|
|
138
|
+
instances: [
|
|
139
|
+
{
|
|
140
|
+
id: 'b9876',
|
|
141
|
+
label: 'Blender · 9876',
|
|
142
|
+
kind: 'bridge',
|
|
143
|
+
baseUrl: 'http://127.0.0.1:9876',
|
|
144
|
+
token: '',
|
|
145
|
+
weight: 1,
|
|
146
|
+
maxConcurrent: 1,
|
|
147
|
+
enabled: true,
|
|
148
|
+
autoStart: false,
|
|
149
|
+
control: { type: 'none', batPath: '', cwd: '', stopTimeoutMs: 15000, readyTimeoutMs: 120000, stopOnExit: false },
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
/**
|
|
154
|
+
* 模型扫描:在 ComfyUI 板块里设定「模型文件夹地址」。
|
|
155
|
+
*
|
|
156
|
+
* 主来源是每个实例自己的 `modelsRoot`(一个实例一行,跟着实例走);
|
|
157
|
+
* 这里是**额外**要扫的目录,给那些不在任何 ComfyUI 安装里的模型用
|
|
158
|
+
* (另一块盘、NAS 映射盘、整理到一半的下载目录)。
|
|
159
|
+
*
|
|
160
|
+
* 为什么不合并成一个列表:实例的 models 目录决定「这张卡能加载什么」,
|
|
161
|
+
* 而额外目录只决定「面板上能看见什么」。混在一起,人就分不清
|
|
162
|
+
* 「我 GPU1 上到底有没有这个 LoRA」。
|
|
163
|
+
*/
|
|
164
|
+
modelsScan: {
|
|
165
|
+
roots: [],
|
|
166
|
+
/** 扫描时最多递归几层(模型树通常 2 层,深了容易撞符号链接环)。 */
|
|
167
|
+
maxDepth: 4,
|
|
168
|
+
/** 单个目录最多返回多少个文件,防止一个巨量目录把面板拖死。 */
|
|
169
|
+
maxFilesPerFolder: 4000,
|
|
170
|
+
},
|
|
171
|
+
/** ComfyUI 实例:一个实例 = 一张卡 = 一个端口。 */
|
|
172
|
+
instances: [
|
|
173
|
+
{
|
|
174
|
+
id: 'p8188',
|
|
175
|
+
label: 'GPU0 · 8188',
|
|
176
|
+
baseUrl: 'http://127.0.0.1:8188',
|
|
177
|
+
apiKey: '',
|
|
178
|
+
weight: 1,
|
|
179
|
+
maxConcurrent: 1,
|
|
180
|
+
enabled: true,
|
|
181
|
+
autoStart: false,
|
|
182
|
+
modelsRoot: '',
|
|
183
|
+
control: { type: 'none', batPath: '', cwd: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000, stopOnExit: true },
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'p8189',
|
|
187
|
+
label: 'GPU1 · 8189',
|
|
188
|
+
baseUrl: 'http://127.0.0.1:8189',
|
|
189
|
+
apiKey: '',
|
|
190
|
+
weight: 1,
|
|
191
|
+
maxConcurrent: 1,
|
|
192
|
+
enabled: true,
|
|
193
|
+
autoStart: false,
|
|
194
|
+
modelsRoot: '',
|
|
195
|
+
control: { type: 'none', batPath: '', cwd: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000, stopOnExit: true },
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export class SettingsStore {
|
|
202
|
+
constructor(dataDir) {
|
|
203
|
+
this.dataDir = dataDir;
|
|
204
|
+
this.file = join(dataDir, 'settings.json');
|
|
205
|
+
this.value = defaultSettings();
|
|
206
|
+
/**
|
|
207
|
+
* 写盘串行链。
|
|
208
|
+
*
|
|
209
|
+
* 全插件只有一个 settings.json,而写它的主体有五个:面板设置、网页账号(auth)、
|
|
210
|
+
* API key、实例托管(onChange)、会话心跳(touch)。其中三个会在同一次请求里
|
|
211
|
+
* 各写一次——例如一次带 apikey 的登录同时更新 failures 与 lastUsedAt。
|
|
212
|
+
*
|
|
213
|
+
* 两次 rename 到同一个目标在 Windows 上是 EPERM operation not permitted,
|
|
214
|
+
* 在 Linux 上是抢同一个临时文件名;两种都表现为「设置偶发没保存」。
|
|
215
|
+
* 串行化是唯一可靠的修法——不靠调用方守规矩。
|
|
216
|
+
*/
|
|
217
|
+
this.writing = Promise.resolve();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 排进写链。
|
|
222
|
+
*
|
|
223
|
+
* 链本身不能被一次失败卡死,但调用方要能看到自己的那次失败——所以链与返回值
|
|
224
|
+
* 是两个 promise,不是一个。
|
|
225
|
+
*/
|
|
226
|
+
enqueue(work) {
|
|
227
|
+
const attempt = this.writing.then(work);
|
|
228
|
+
this.writing = attempt.catch(() => {});
|
|
229
|
+
return attempt;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async init() {
|
|
233
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
234
|
+
try {
|
|
235
|
+
const raw = await readFile(this.file, 'utf8');
|
|
236
|
+
const parsed = JSON.parse(raw);
|
|
237
|
+
this.value = mergeSettings(defaultSettings(), parsed);
|
|
238
|
+
} catch {
|
|
239
|
+
this.value = defaultSettings();
|
|
240
|
+
await this.save();
|
|
241
|
+
}
|
|
242
|
+
return this.value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get() {
|
|
246
|
+
return this.value;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 落盘。
|
|
251
|
+
*
|
|
252
|
+
* 快照在**入队时**取:如果等到执行时才 JSON.stringify(this.value),
|
|
253
|
+
* 两次相邻 patch 都会序列化出「最后一次」的内容,前一次改动静默丢失。
|
|
254
|
+
* 临时文件名带序号,避免一个卡住的写入把下一次的临时文件占住。
|
|
255
|
+
*/
|
|
256
|
+
async save() {
|
|
257
|
+
const snapshot = JSON.stringify(this.value, null, 2);
|
|
258
|
+
this.counter = (this.counter ?? 0) + 1;
|
|
259
|
+
const serial = this.counter;
|
|
260
|
+
return this.enqueue(async () => {
|
|
261
|
+
const temp = `${this.file}.${serial}.tmp`;
|
|
262
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
263
|
+
await writeFile(temp, `${snapshot}\n`, 'utf8');
|
|
264
|
+
await rename(temp, this.file);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async patch(next) {
|
|
269
|
+
const merged = mergeSettings(this.value, next ?? {});
|
|
270
|
+
this.value = merged;
|
|
271
|
+
await this.save();
|
|
272
|
+
return this.value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 合并而不让一次 patch 抹掉没提到的字段(实例的未知字段、账号、配对 secret 都在此列)。 */
|
|
277
|
+
export function mergeSettings(current, patch) {
|
|
278
|
+
const next = {
|
|
279
|
+
...current,
|
|
280
|
+
...(patch.listen !== undefined ? { listen: { ...current.listen, ...patch.listen } } : {}),
|
|
281
|
+
...(typeof patch.dynamic === 'boolean' ? { dynamic: patch.dynamic } : {}),
|
|
282
|
+
...(Number.isFinite(Number(patch.probeMs)) ? { probeMs: Math.min(60000, Math.max(500, Math.floor(Number(patch.probeMs)))) } : {}),
|
|
283
|
+
...(Number.isFinite(Number(patch.historyItems)) ? { historyItems: Math.min(1000, Math.max(10, Math.floor(Number(patch.historyItems)))) } : {}),
|
|
284
|
+
...(Number.isFinite(Number(patch.stallWarnSec)) ? { stallWarnSec: Math.max(10, Math.floor(Number(patch.stallWarnSec))) } : {}),
|
|
285
|
+
};
|
|
286
|
+
if (patch.mode === 'service' || patch.mode === 'user') next.mode = patch.mode;
|
|
287
|
+
if (Array.isArray(patch.instances)) {
|
|
288
|
+
next.instances = patch.instances.map((entry) => ({
|
|
289
|
+
...entry,
|
|
290
|
+
control: { ...(entry.control ?? {}) },
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
if (patch.promptHeads !== undefined && patch.promptHeads !== null && typeof patch.promptHeads === 'object') {
|
|
294
|
+
next.promptHeads = { ...(current.promptHeads ?? {}), ...patch.promptHeads };
|
|
295
|
+
}
|
|
296
|
+
if (patch.promptKb !== undefined && patch.promptKb !== null && typeof patch.promptKb === 'object') {
|
|
297
|
+
next.promptKb = { ...(current.promptKb ?? {}), ...patch.promptKb };
|
|
298
|
+
}
|
|
299
|
+
if (patch.promptSkill !== undefined && patch.promptSkill !== null && typeof patch.promptSkill === 'object') {
|
|
300
|
+
next.promptSkill = { ...(current.promptSkill ?? {}), ...patch.promptSkill };
|
|
301
|
+
}
|
|
302
|
+
if (patch.remote !== undefined && patch.remote !== null && typeof patch.remote === 'object') {
|
|
303
|
+
next.remote = { ...(current.remote ?? {}), ...patch.remote };
|
|
304
|
+
}
|
|
305
|
+
if (patch.service !== undefined && patch.service !== null && typeof patch.service === 'object') {
|
|
306
|
+
// 分层而非替换:一次只改 requireKey 的 patch 不能把整串 apikey 抹掉。
|
|
307
|
+
next.service = { ...(current.service ?? {}), ...patch.service };
|
|
308
|
+
}
|
|
309
|
+
if (patch.modelsScan !== undefined && patch.modelsScan !== null && typeof patch.modelsScan === 'object') {
|
|
310
|
+
// 分层:只改 maxDepth 的 patch 不能把整串扫描目录抹掉。
|
|
311
|
+
const nextScan = { ...(current.modelsScan ?? {}), ...patch.modelsScan };
|
|
312
|
+
if (Array.isArray(nextScan.roots)) {
|
|
313
|
+
// 目录去重 + 只留绝对路径:相对路径在「当前工作目录」下没有意义。
|
|
314
|
+
const seen = new Set();
|
|
315
|
+
nextScan.roots = nextScan.roots
|
|
316
|
+
.map((row) => String(row ?? '').trim())
|
|
317
|
+
.filter((row) => {
|
|
318
|
+
if (row === '' || !isAbsolute(row) || seen.has(row)) return false;
|
|
319
|
+
seen.add(row);
|
|
320
|
+
return true;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
next.modelsScan = nextScan;
|
|
324
|
+
}
|
|
325
|
+
if (patch.node !== undefined && patch.node !== null && typeof patch.node === 'object') {
|
|
326
|
+
next.node = normalizeNodeBlock({ ...(current.node ?? {}), ...patch.node });
|
|
327
|
+
}
|
|
328
|
+
if (patch.blender !== undefined && patch.blender !== null && typeof patch.blender === 'object') {
|
|
329
|
+
const merged = { ...(current.blender ?? {}), ...patch.blender };
|
|
330
|
+
if (Array.isArray(patch.blender.instances)) {
|
|
331
|
+
merged.instances = patch.blender.instances.map((entry) => ({
|
|
332
|
+
...entry,
|
|
333
|
+
control: { ...(entry.control ?? {}) },
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
next.blender = merged;
|
|
337
|
+
}
|
|
338
|
+
return next;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 用户节点的地址与 key 都要清洗:尾斜杠会让每条拼接路径变成双斜杠。 */
|
|
342
|
+
export function normalizeNodeBlock(block) {
|
|
343
|
+
const next = { ...block };
|
|
344
|
+
if (typeof next.publicUrl === 'string') {
|
|
345
|
+
let text = next.publicUrl.trim();
|
|
346
|
+
while (text.endsWith('/')) text = text.slice(0, -1);
|
|
347
|
+
if (text !== '' && !/^https?:\/\//i.test(text)) text = `http://${text}`;
|
|
348
|
+
next.publicUrl = text;
|
|
349
|
+
}
|
|
350
|
+
if (typeof next.apiKey === 'string') next.apiKey = next.apiKey.trim();
|
|
351
|
+
if (typeof next.outputDir === 'string') next.outputDir = next.outputDir.trim();
|
|
352
|
+
if (typeof next.label === 'string') next.label = next.label.trim();
|
|
353
|
+
const timeout = Number(next.timeoutMs);
|
|
354
|
+
next.timeoutMs = Number.isFinite(timeout) ? Math.min(120000, Math.max(2000, Math.floor(timeout))) : 15000;
|
|
355
|
+
return next;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 从行配置里解析数据目录,绝对路径优先。 */
|
|
359
|
+
export function resolveDataDir(configValue) {
|
|
360
|
+
const configured = typeof configValue?.dataDir === 'string' ? configValue.dataDir.trim() : '';
|
|
361
|
+
if (configured !== '' && isAbsolute(configured)) return resolve(configured);
|
|
362
|
+
return defaultDataDir();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 用户节点产物目录的最终值:填了用填的,没填用默认。 */
|
|
366
|
+
export function resolveOutputDir(settingsValue) {
|
|
367
|
+
const configured = typeof settingsValue?.node?.outputDir === 'string' ? settingsValue.node.outputDir.trim() : '';
|
|
368
|
+
if (configured !== '' && isAbsolute(configured)) return resolve(configured);
|
|
369
|
+
return resolve(defaultOutputDir());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Blender 产物目录。 */
|
|
373
|
+
export function resolveBlenderOutputDir(settingsValue) {
|
|
374
|
+
const configured = typeof settingsValue?.blender?.outputDir === 'string' ? settingsValue.blender.outputDir.trim() : '';
|
|
375
|
+
if (configured !== '' && isAbsolute(configured)) return resolve(configured);
|
|
376
|
+
return join(resolveOutputDir(settingsValue), 'blender');
|
|
377
|
+
}
|