kingkont 0.7.39 → 0.7.41

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/cli.js ADDED
@@ -0,0 +1,504 @@
1
+ // CLI команды для KingKont. Используется из bin/kingkont.js.
2
+ // Все команды работают с выключенным GUI — правят scene.json напрямую через
3
+ // FS, генерации запускают через lib/providers (тот же роутинг что и server.js).
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const fsp = require('node:fs/promises');
9
+ const path = require('node:path');
10
+
11
+ const settingsLib = require('./settings');
12
+ const providers = require('./providers');
13
+ const fsLib = require('./projectFs');
14
+
15
+ // =============================================================================
16
+ // CLI-arg парсер (минимальный, без зависимостей).
17
+ // =============================================================================
18
+
19
+ /**
20
+ * Парсит хвост argv в { positional: [...], flags: {...} }.
21
+ * --kind=image → flags.kind = 'image'
22
+ * --kind image → flags.kind = 'image'
23
+ * --json → flags.json = true
24
+ * --refs=a,b,c → flags.refs = ['a', 'b', 'c']
25
+ * foo bar → positional = ['foo', 'bar']
26
+ */
27
+ function parseArgs(argv) {
28
+ const positional = [];
29
+ const flags = {};
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a.startsWith('--')) {
33
+ const eq = a.indexOf('=');
34
+ let key, val;
35
+ if (eq >= 0) {
36
+ key = a.slice(2, eq);
37
+ val = a.slice(eq + 1);
38
+ } else {
39
+ key = a.slice(2);
40
+ const next = argv[i + 1];
41
+ if (next !== undefined && !next.startsWith('--')) { val = next; i++; }
42
+ else val = true;
43
+ }
44
+ // Авто-распак списков по запятой для известных полей.
45
+ if (typeof val === 'string' && (key === 'refs' || key === 'imageInputs' || key === 'videoInputs')) {
46
+ flags[key] = val.split(',').map(s => s.trim()).filter(Boolean);
47
+ } else {
48
+ flags[key] = val;
49
+ }
50
+ } else {
51
+ positional.push(a);
52
+ }
53
+ }
54
+ return { positional, flags };
55
+ }
56
+
57
+ function parseProjectArg(positional) {
58
+ if (!positional.length) throw new Error('Не указан путь к проекту');
59
+ const p = path.resolve(positional[0]);
60
+ if (!fs.existsSync(p)) throw new Error(`Не существует: ${p}`);
61
+ if (!fs.statSync(p).isDirectory()) throw new Error(`Не папка: ${p}`);
62
+ return p;
63
+ }
64
+
65
+ function asJson(obj) { return JSON.stringify(obj, null, 2); }
66
+
67
+ // Определяем формат вывода: --json → строгий JSON; иначе человеко-читаемый.
68
+ function out(flags, machine, human) {
69
+ if (flags.json) console.log(asJson(machine));
70
+ else console.log(human);
71
+ }
72
+
73
+ // =============================================================================
74
+ // Команды.
75
+ // =============================================================================
76
+
77
+ /** kingkont open <project> — печатает JSON со структурой проекта. */
78
+ async function cmdOpen({ positional, flags }) {
79
+ const root = parseProjectArg(positional);
80
+ const boards = await fsLib.listBoards(root);
81
+ const all = [];
82
+ for (const name of boards.scenes) all.push({ ref: name, kind: 'scene', name });
83
+ for (const name of boards.characters) all.push({ ref: `_characters/${name}`, kind: 'character', name });
84
+ for (const name of boards.locations) all.push({ ref: `_locations/${name}`, kind: 'location', name });
85
+ // Для каждой — короткая сводка.
86
+ const summary = [];
87
+ for (const b of all) {
88
+ const scene = await fsLib.loadScene(root, b);
89
+ summary.push({
90
+ ref: b.ref, kind: b.kind, name: b.name,
91
+ nodesCount: scene.nodes.length,
92
+ connectionsCount: (scene.connections || []).length,
93
+ });
94
+ }
95
+ const result = { root, boards: summary };
96
+ if (flags.json) console.log(asJson(result));
97
+ else {
98
+ console.log(`KingKont проект: ${root}`);
99
+ console.log(`Досок: ${summary.length}`);
100
+ for (const b of summary) {
101
+ console.log(` ${b.kind.padEnd(9)} ${b.ref.padEnd(40)} (${b.nodesCount} нод, ${b.connectionsCount} связей)`);
102
+ }
103
+ }
104
+ }
105
+
106
+ /** kingkont list <project> — перечисление досок. */
107
+ async function cmdList({ positional, flags }) {
108
+ const root = parseProjectArg(positional);
109
+ const boards = await fsLib.listBoards(root);
110
+ if (flags.json) return console.log(asJson(boards));
111
+ console.log('Сцены:');
112
+ for (const n of boards.scenes) console.log(` ${n}`);
113
+ console.log('Персонажи:');
114
+ for (const n of boards.characters) console.log(` ${n}`);
115
+ console.log('Локации:');
116
+ for (const n of boards.locations) console.log(` ${n}`);
117
+ }
118
+
119
+ /** kingkont board <project> <board> — JSON одной доски. */
120
+ async function cmdBoard({ positional, flags }) {
121
+ const root = parseProjectArg(positional);
122
+ if (positional.length < 2) throw new Error('Нужно: kingkont board <project> <board>');
123
+ const ref = fsLib.parseBoardRef(positional[1]);
124
+ const scene = await fsLib.loadScene(root, ref);
125
+ if (flags.json) return console.log(asJson(scene));
126
+ console.log(`Доска ${fsLib.boardKey(ref)}`);
127
+ console.log(` ${scene.nodes.length} нод, ${(scene.connections || []).length} связей`);
128
+ for (const n of scene.nodes) {
129
+ const tag = n.name ? `@${n.name}` : '';
130
+ console.log(` - ${n.id.slice(0, 8)} ${n.type.padEnd(6)} ${tag.padEnd(20)} ${n.file || ''}`);
131
+ }
132
+ }
133
+
134
+ /** kingkont add-node <project> <board> --kind=... [--name=...] [--file=...] [--text="..."] [--x=...] [--y=...] */
135
+ async function cmdAddNode({ positional, flags }) {
136
+ const root = parseProjectArg(positional);
137
+ if (positional.length < 2) throw new Error('Нужно: kingkont add-node <project> <board> --kind=...');
138
+ const ref = fsLib.parseBoardRef(positional[1]);
139
+ const partial = {
140
+ type: flags.kind,
141
+ name: flags.name,
142
+ file: flags.file,
143
+ text: flags.text,
144
+ x: flags.x != null ? Number(flags.x) : undefined,
145
+ y: flags.y != null ? Number(flags.y) : undefined,
146
+ };
147
+ const scene = await fsLib.loadScene(root, ref);
148
+ const node = fsLib.addNode(scene, partial);
149
+ await fsLib.saveScene(root, ref, scene);
150
+ if (flags.json) console.log(asJson(node));
151
+ else console.log(`✓ создана нода ${node.id} (${node.type})`);
152
+ }
153
+
154
+ /** kingkont gen <project> <board> --kind=... --prompt=... [--model=...] [--refs=@a,@b]
155
+ * Совмещает add-node + generate. Самая частая команда. */
156
+ async function cmdGen({ positional, flags }) {
157
+ const root = parseProjectArg(positional);
158
+ if (positional.length < 2) throw new Error('Нужно: kingkont gen <project> <board> --kind=... --prompt="..."');
159
+ const ref = fsLib.parseBoardRef(positional[1]);
160
+ const kind = flags.kind || 'image';
161
+ const prompt = flags.prompt;
162
+ if (!prompt && kind !== 'video') throw new Error('--prompt обязателен');
163
+
164
+ // 1) Грузим сетки.
165
+ const settings = await loadSettingsForCli();
166
+
167
+ // 2) Резолвим refs → загружаем их в storage.
168
+ const refNames = Array.isArray(flags.refs) ? flags.refs : [];
169
+ const resolved = await fsLib.resolveRefs(root, ref, refNames);
170
+ const imageInputs = [];
171
+ const videoInputs = [];
172
+ for (const r of resolved) {
173
+ const buf = await fsp.readFile(r.abs);
174
+ const up = await providers.uploadFile({
175
+ buffer: buf,
176
+ filename: path.basename(r.file),
177
+ mime: guessMime(r.file),
178
+ settings,
179
+ });
180
+ if (r.type === 'image') imageInputs.push(up.url);
181
+ else if (r.type === 'video') videoInputs.push(up.url);
182
+ console.error(`[ref] @${r.name} → ${up.url}`);
183
+ }
184
+
185
+ // 3) Создаём ноду в статусе 'generating'.
186
+ const scene = await fsLib.loadScene(root, ref);
187
+ const node = fsLib.addNode(scene, {
188
+ type: kind,
189
+ name: flags.name,
190
+ x: flags.x != null ? Number(flags.x) : undefined,
191
+ y: flags.y != null ? Number(flags.y) : undefined,
192
+ status: 'generating',
193
+ generated: {
194
+ kind,
195
+ prompt,
196
+ model: flags.model,
197
+ modelKey: flags.model,
198
+ refs: resolved.map(r => ({ name: r.name, type: r.type, file: r.file })),
199
+ state: 'submitting',
200
+ },
201
+ });
202
+ await fsLib.saveScene(root, ref, scene);
203
+ console.error(`[node] создана ${node.id} (status=generating)`);
204
+
205
+ // 4) Запускаем генерацию через провайдер.
206
+ if (kind === 'text') return await runTextGeneration(root, ref, node, { prompt, model: flags.model, settings, flags });
207
+ if (kind === 'audio') {
208
+ const subKind = flags['sub-kind'] || flags.subKind || (flags.tts ? 'tts' : null);
209
+ if (subKind === 'sfx') return await runSfxGeneration(root, ref, node, { text: prompt, durationSeconds: flags.duration, settings, flags });
210
+ if (subKind === 'music') return await runMusicGeneration(root, ref, node, { prompt, durationMs: flags['duration-ms'] || flags.durationMs, settings, flags });
211
+ return await runTtsGeneration(root, ref, node, { text: prompt, voice: flags.voice, ttsModel: flags['tts-model'] || flags.ttsModel, settings, flags });
212
+ }
213
+ // image | video
214
+ return await runMediaGeneration(root, ref, node, {
215
+ kind, prompt, modelKey: flags.model, imageInputs, videoInputs,
216
+ aspectRatio: flags['aspect-ratio'] || flags.aspectRatio,
217
+ resolution: flags.resolution,
218
+ duration: flags.duration,
219
+ firstFrame: flags['first-frame'] || flags.firstFrame,
220
+ lastFrame: flags['last-frame'] || flags.lastFrame,
221
+ settings, flags,
222
+ });
223
+ }
224
+
225
+ /** kingkont generate <project> <board> <nodeId> — для существующей draft-ноды.
226
+ * Берёт generated.prompt/model и перезапускает генерацию. */
227
+ async function cmdGenerate({ positional, flags }) {
228
+ const root = parseProjectArg(positional);
229
+ if (positional.length < 3) throw new Error('Нужно: kingkont generate <project> <board> <nodeId>');
230
+ const ref = fsLib.parseBoardRef(positional[1]);
231
+ const scene = await fsLib.loadScene(root, ref);
232
+ const node = fsLib.findNode(scene, positional[2]);
233
+ if (!node) throw new Error(`Нода ${positional[2]} не найдена`);
234
+ if (!node.generated?.prompt) throw new Error(`У ноды нет generated.prompt — добавь сначала через add-node или сразу gen`);
235
+
236
+ const settings = await loadSettingsForCli();
237
+ const args = {
238
+ kind: node.type, prompt: node.generated.prompt, modelKey: node.generated.model || node.generated.modelKey,
239
+ imageInputs: [], videoInputs: [], settings, flags,
240
+ };
241
+ // Если у ноды есть refs — резолвим их сейчас.
242
+ if (Array.isArray(node.generated.refs)) {
243
+ for (const r of node.generated.refs) {
244
+ const refRes = await fsLib.resolveRefs(root, ref, [r.name]);
245
+ if (!refRes[0]) continue;
246
+ const buf = await fsp.readFile(refRes[0].abs);
247
+ const up = await providers.uploadFile({ buffer: buf, filename: path.basename(refRes[0].file), mime: guessMime(refRes[0].file), settings });
248
+ if (r.type === 'image') args.imageInputs.push(up.url);
249
+ else if (r.type === 'video') args.videoInputs.push(up.url);
250
+ }
251
+ }
252
+ if (node.type === 'image' || node.type === 'video') return await runMediaGeneration(root, ref, node, args);
253
+ if (node.type === 'text') return await runTextGeneration(root, ref, node, { prompt: node.generated.prompt, model: node.generated.model, settings, flags });
254
+ if (node.type === 'audio') return await runTtsGeneration(root, ref, node, { text: node.generated.prompt, voice: node.generated.voiceId, ttsModel: node.generated.model, settings, flags });
255
+ throw new Error(`unknown node type: ${node.type}`);
256
+ }
257
+
258
+ /** kingkont status <project> <board> <nodeId> — для in-flight задачи. */
259
+ async function cmdStatus({ positional, flags }) {
260
+ const root = parseProjectArg(positional);
261
+ if (positional.length < 3) throw new Error('Нужно: kingkont status <project> <board> <nodeId>');
262
+ const ref = fsLib.parseBoardRef(positional[1]);
263
+ const scene = await fsLib.loadScene(root, ref);
264
+ const node = fsLib.findNode(scene, positional[2]);
265
+ if (!node) throw new Error(`Нода не найдена`);
266
+ if (!node.generated?.taskId) {
267
+ if (flags.json) return console.log(asJson({ status: node.status, file: node.file, error: node.error }));
268
+ console.log(`status=${node.status || 'ready'} file=${node.file || '—'} ${node.error ? 'error: ' + node.error : ''}`);
269
+ return;
270
+ }
271
+ const settings = await loadSettingsForCli();
272
+ const r = await providers.pollGeneration(node.generated.taskId, settings);
273
+ if (flags.json) return console.log(asJson(r));
274
+ console.log(`status=${r.status} provider=${r.provider} ${r.url ? 'url=' + r.url : ''} ${r.error || ''}`);
275
+ }
276
+
277
+ /** kingkont connect <project> <board> <fromId> <toId> [--port=...] */
278
+ async function cmdConnect({ positional, flags }) {
279
+ const root = parseProjectArg(positional);
280
+ if (positional.length < 4) throw new Error('Нужно: kingkont connect <project> <board> <fromId> <toId>');
281
+ const ref = fsLib.parseBoardRef(positional[1]);
282
+ const scene = await fsLib.loadScene(root, ref);
283
+ const fromId = (fsLib.findNode(scene, positional[2]) || {}).id;
284
+ const toId = (fsLib.findNode(scene, positional[3]) || {}).id;
285
+ if (!fromId || !toId) throw new Error('Не найдена одна из нод');
286
+ const added = fsLib.addConnection(scene, fromId, toId, { toPort: flags.port });
287
+ await fsLib.saveScene(root, ref, scene);
288
+ console.log(added ? `✓ связь ${fromId.slice(0,8)} → ${toId.slice(0,8)}` : 'связь уже была');
289
+ }
290
+
291
+ /** kingkont rm-node <project> <board> <nodeId> */
292
+ async function cmdRmNode({ positional }) {
293
+ const root = parseProjectArg(positional);
294
+ if (positional.length < 3) throw new Error('Нужно: kingkont rm-node <project> <board> <nodeId>');
295
+ const ref = fsLib.parseBoardRef(positional[1]);
296
+ const scene = await fsLib.loadScene(root, ref);
297
+ const node = fsLib.findNode(scene, positional[2]);
298
+ if (!node) throw new Error(`Нода не найдена`);
299
+ await fsLib.removeNode(root, ref, node.id);
300
+ console.log(`✓ удалена ${node.id}`);
301
+ }
302
+
303
+ /** kingkont voices — список голосов ElevenLabs. */
304
+ async function cmdVoices({ flags }) {
305
+ const settings = await loadSettingsForCli();
306
+ if (!process.env.ELEVENLABS_API_KEY) throw new Error('Нет ELEVENLABS_API_KEY (включи ElevenLabs в настройках приложения)');
307
+ const voices = await providers.listElevenVoices();
308
+ if (flags.json) return console.log(asJson(voices));
309
+ for (const v of voices) console.log(`${v.id} ${v.name} (${v.category})`);
310
+ }
311
+
312
+ /** kingkont balance — балансы всех включённых провайдеров. */
313
+ async function cmdBalance({ flags }) {
314
+ const settings = await loadSettingsForCli();
315
+ const all = await providers.fetchBalances(settings);
316
+ if (flags.json) return console.log(asJson(all));
317
+ for (const [k, v] of Object.entries(all)) {
318
+ console.log(`${k.padEnd(12)} ${v.amount} ${v.unit}${v.limit ? ` / ${v.limit}` : ''}`);
319
+ }
320
+ if (!Object.keys(all).length) console.log('Нет подключённых провайдеров (см. настройки приложения)');
321
+ }
322
+
323
+ /** kingkont upload <file> — загрузить файл в storage, печатает URL. */
324
+ async function cmdUpload({ positional, flags }) {
325
+ if (!positional.length) throw new Error('Нужно: kingkont upload <file>');
326
+ const file = path.resolve(positional[0]);
327
+ const settings = await loadSettingsForCli();
328
+ const buf = await fsp.readFile(file);
329
+ const r = await providers.uploadFile({
330
+ buffer: buf, filename: path.basename(file), mime: guessMime(file), settings,
331
+ });
332
+ if (flags.json) return console.log(asJson(r));
333
+ console.log(r.url);
334
+ }
335
+
336
+ // =============================================================================
337
+ // Generation runners (детали).
338
+ // =============================================================================
339
+
340
+ async function runMediaGeneration(root, ref, node, args) {
341
+ const { kind, prompt, modelKey, imageInputs, videoInputs, aspectRatio, resolution, duration, firstFrame, lastFrame, settings, flags } = args;
342
+ const start = await providers.startGeneration({
343
+ kind, prompt, modelKey, imageInputs, videoInputs, aspectRatio, resolution, duration, firstFrame, lastFrame, settings,
344
+ });
345
+ console.error(`[task] ${start.taskId} provider=${start.provider}`);
346
+ await updateNode(root, ref, node.id, n => {
347
+ n.generated = { ...(n.generated || {}), taskId: start.taskId, state: 'queued' };
348
+ });
349
+
350
+ const result = await providers.waitForGeneration(start.taskId, settings, {
351
+ onProgress: r => console.error(`[poll] state=${r.state} status=${r.status}`),
352
+ });
353
+
354
+ // Скачиваем и сохраняем.
355
+ const buf = await providers.downloadUrl(result.url);
356
+ const subdir = fsLib.defaultSubdir(kind);
357
+ const ext = fsLib.defaultExt(kind, null, guessMimeFromUrl(result.url));
358
+ const fileRel = await fsLib.uniqueFilename(root, ref, subdir, `${kind}_${node.id.slice(0,8)}${ext}`);
359
+ await fsLib.saveBoardFile(root, ref, fileRel, buf);
360
+
361
+ await updateNode(root, ref, node.id, n => {
362
+ n.status = undefined;
363
+ n.error = undefined;
364
+ n.file = fileRel;
365
+ n.generated = {
366
+ ...(n.generated || {}), state: 'success',
367
+ ...(typeof result.cost === 'number' ? { creditsCharged: result.cost } : {}),
368
+ };
369
+ });
370
+ if (flags.json) console.log(asJson({ id: node.id, file: fileRel, url: result.url, cost: result.cost ?? null }));
371
+ else console.log(`✓ ${node.id.slice(0,8)} → ${fileRel}${result.cost != null ? ` (cost=${result.cost})` : ''}`);
372
+ }
373
+
374
+ async function runTextGeneration(root, ref, node, { prompt, model, settings, flags }) {
375
+ const r = await providers.generateText({ prompt, model, settings });
376
+ // Текст сохраняем в .md (создаём имя если нет).
377
+ let fileRel = node.file;
378
+ if (!fileRel) {
379
+ const stem = (node.name || 'text').replace(/[^\wЀ-ӿ.-]+/g, '_').slice(0, 60) || 'text';
380
+ fileRel = await fsLib.uniqueFilename(root, ref, 'texts', `${stem}_${node.id.slice(0,8)}.md`);
381
+ }
382
+ await fsLib.saveBoardFile(root, ref, fileRel, r.text);
383
+ await updateNode(root, ref, node.id, n => {
384
+ n.status = undefined;
385
+ n.error = undefined;
386
+ n.file = fileRel;
387
+ n.text = r.text;
388
+ n.generated = { ...(n.generated || {}), state: 'success', model: r.model, ...(typeof r.cost === 'number' ? { creditsCharged: r.cost } : {}) };
389
+ });
390
+ if (flags.json) console.log(asJson({ id: node.id, file: fileRel, text: r.text, cost: r.cost ?? null }));
391
+ else console.log(`✓ ${node.id.slice(0,8)} → ${fileRel} (${r.text.length}ch)`);
392
+ }
393
+
394
+ async function runTtsGeneration(root, ref, node, { text, voice, ttsModel, settings, flags }) {
395
+ const r = await providers.generateTts({ text, voice, ttsModel, settings });
396
+ const fileRel = await fsLib.uniqueFilename(root, ref, 'audio', `tts_${node.id.slice(0,8)}.mp3`);
397
+ await fsLib.saveBoardFile(root, ref, fileRel, r.buffer);
398
+ await updateNode(root, ref, node.id, n => {
399
+ n.status = undefined; n.error = undefined; n.file = fileRel;
400
+ n.generated = { ...(n.generated || {}), state: 'success', ...(typeof r.cost === 'number' ? { creditsCharged: r.cost } : {}) };
401
+ });
402
+ if (flags.json) console.log(asJson({ id: node.id, file: fileRel, cost: r.cost ?? null }));
403
+ else console.log(`✓ ${node.id.slice(0,8)} → ${fileRel}`);
404
+ }
405
+
406
+ async function runSfxGeneration(root, ref, node, { text, durationSeconds, settings, flags }) {
407
+ const r = await providers.generateSfx({ text, durationSeconds, settings });
408
+ const fileRel = await fsLib.uniqueFilename(root, ref, 'audio', `sfx_${node.id.slice(0,8)}.mp3`);
409
+ await fsLib.saveBoardFile(root, ref, fileRel, r.buffer);
410
+ await updateNode(root, ref, node.id, n => {
411
+ n.status = undefined; n.error = undefined; n.file = fileRel;
412
+ n.generated = { ...(n.generated || {}), state: 'success', subKind: 'sfx', ...(typeof r.cost === 'number' ? { creditsCharged: r.cost } : {}) };
413
+ });
414
+ if (flags.json) console.log(asJson({ id: node.id, file: fileRel }));
415
+ else console.log(`✓ ${node.id.slice(0,8)} → ${fileRel}`);
416
+ }
417
+
418
+ async function runMusicGeneration(root, ref, node, { prompt, durationMs, settings, flags }) {
419
+ const r = await providers.generateMusic({ prompt, durationMs, settings });
420
+ const fileRel = await fsLib.uniqueFilename(root, ref, 'audio', `music_${node.id.slice(0,8)}.mp3`);
421
+ await fsLib.saveBoardFile(root, ref, fileRel, r.buffer);
422
+ await updateNode(root, ref, node.id, n => {
423
+ n.status = undefined; n.error = undefined; n.file = fileRel;
424
+ n.generated = { ...(n.generated || {}), state: 'success', subKind: 'music', ...(typeof r.cost === 'number' ? { creditsCharged: r.cost } : {}) };
425
+ });
426
+ if (flags.json) console.log(asJson({ id: node.id, file: fileRel }));
427
+ else console.log(`✓ ${node.id.slice(0,8)} → ${fileRel}`);
428
+ }
429
+
430
+ // =============================================================================
431
+ // Helpers.
432
+ // =============================================================================
433
+
434
+ async function loadSettingsForCli() {
435
+ const settings = settingsLib.loadSettings();
436
+ settingsLib.applySettingsToEnv(settings);
437
+ // Подгружаем .env из cwd как fallback (для разработчиков).
438
+ const envPath = path.join(process.cwd(), '.env');
439
+ if (fs.existsSync(envPath)) {
440
+ for (const raw of fs.readFileSync(envPath, 'utf-8').split('\n')) {
441
+ const line = raw.trim();
442
+ if (!line || line.startsWith('#')) continue;
443
+ const eq = line.indexOf('=');
444
+ if (eq < 0) continue;
445
+ const key = line.slice(0, eq).trim();
446
+ let val = line.slice(eq + 1).trim();
447
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
448
+ if (!(key in process.env)) process.env[key] = val;
449
+ }
450
+ }
451
+ return settings;
452
+ }
453
+
454
+ /** Перечитать scene, мутировать ноду через mutator, сохранить. Атомарно (для CLI). */
455
+ async function updateNode(root, ref, nodeId, mutator) {
456
+ const scene = await fsLib.loadScene(root, ref);
457
+ const n = scene.nodes.find(x => x.id === nodeId);
458
+ if (!n) throw new Error(`нода ${nodeId} пропала между шагами генерации`);
459
+ mutator(n);
460
+ await fsLib.saveScene(root, ref, scene);
461
+ }
462
+
463
+ function guessMime(filename) {
464
+ const ext = path.extname(filename).toLowerCase();
465
+ return {
466
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
467
+ '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
468
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
469
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4',
470
+ '.md': 'text/markdown', '.txt': 'text/plain', '.json': 'application/json',
471
+ }[ext] || 'application/octet-stream';
472
+ }
473
+
474
+ function guessMimeFromUrl(url) {
475
+ try { return guessMime(new URL(url).pathname); } catch { return 'application/octet-stream'; }
476
+ }
477
+
478
+ // =============================================================================
479
+ // Dispatch.
480
+ // =============================================================================
481
+
482
+ const COMMANDS = {
483
+ open: cmdOpen,
484
+ list: cmdList,
485
+ board: cmdBoard,
486
+ 'add-node': cmdAddNode,
487
+ gen: cmdGen,
488
+ generate: cmdGenerate,
489
+ status: cmdStatus,
490
+ connect: cmdConnect,
491
+ 'rm-node': cmdRmNode,
492
+ voices: cmdVoices,
493
+ balance: cmdBalance,
494
+ upload: cmdUpload,
495
+ };
496
+
497
+ async function run(argv) {
498
+ const cmd = argv[0];
499
+ const fn = COMMANDS[cmd];
500
+ if (!fn) throw new Error(`unknown command: ${cmd}`);
501
+ return await fn(parseArgs(argv.slice(1)));
502
+ }
503
+
504
+ module.exports = { run, COMMANDS, parseArgs };