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.
@@ -0,0 +1,689 @@
1
+ // Провайдер-агностичная библиотека генерации.
2
+ // Используется и из HTTP-сервера (server.js), и из CLI (bin/kingkont.js).
3
+ // Не использует req/res — только чистые функции с input → output.
4
+ //
5
+ // Все экспортируемые функции принимают `settings` как параметр (caller сам
6
+ // решает откуда их взять — main.js берёт live, CLI берёт через loadSettings).
7
+ // Прямые API-ключи (kieKey/elevenKey/openrouterKey) либо через
8
+ // applySettingsToEnv() уже в env, либо берём напрямую из settings.
9
+
10
+ 'use strict';
11
+
12
+ const KIE_BASE = 'https://api.kie.ai';
13
+ const ELEVEN_BASE = 'https://api.elevenlabs.io';
14
+
15
+ const CHATIUM_PATHS = {
16
+ text: '/app/spaces/server/api/text',
17
+ audio: '/app/spaces/server/api/audio',
18
+ image: '/app/spaces/server/api/image',
19
+ video: '/app/spaces/server/api/video',
20
+ poll: '/app/spaces/server/api/poll',
21
+ balance: '/app/spaces/server/api/balance',
22
+ transactions: '/app/spaces/server/api/transactions',
23
+ uploadUrl: '/app/spaces/server/api/upload_url',
24
+ };
25
+
26
+ const CHATIUM_CDN = 'https://fs.chatium.ru/get';
27
+
28
+ // =============================================================================
29
+ // Логирование исходящих запросов (общее для server.js и CLI).
30
+ // =============================================================================
31
+
32
+ function logCall(method, provider, target, extra = '') {
33
+ const ts = new Date().toLocaleTimeString();
34
+ console.log(`[${ts}] → ${method.padEnd(4)} ${provider.padEnd(11)} ${target}${extra ? ' ' + extra : ''}`);
35
+ }
36
+
37
+ function summarizeBody(body) {
38
+ if (!body) return '';
39
+ const parts = [];
40
+ if (body.model) parts.push(`model=${body.model}`);
41
+ if (body.kind) parts.push(`kind=${body.kind}`);
42
+ if (typeof body.prompt === 'string') parts.push(`prompt=${body.prompt.length}ch`);
43
+ if (typeof body.text === 'string') parts.push(`text=${body.text.length}ch`);
44
+ if (Array.isArray(body.imageInputs)) parts.push(`imgs=${body.imageInputs.length}`);
45
+ return parts.join(' ');
46
+ }
47
+
48
+ // =============================================================================
49
+ // Маппинги моделей: короткий ключ → полный slug провайдера.
50
+ // Источники: spaces/api/execImageRunners.ts, execVideoRunners.ts.
51
+ // =============================================================================
52
+
53
+ const KIE_IMAGE_MODELS = {
54
+ 'nano-banana-2': 'nano-banana-2',
55
+ 'grok': 'grok-imagine/text-to-image',
56
+ 'seedream': 'seedream/4.5-text-to-image',
57
+ 'seedream-5-lite': 'seedream/5-lite-text-to-image',
58
+ 'flux-schnell': 'flux/schnell',
59
+ 'sdxl-lightning': 'sdxl/lightning',
60
+ };
61
+ const KIE_VIDEO_MODELS = {
62
+ 'seedance-2': 'bytedance/seedance-2',
63
+ };
64
+
65
+ const CHATIUM_IMAGE_MODELS = {
66
+ 'nano-banana-2': 'nano-banana-2',
67
+ 'nano-banana-pro': 'nano-banana-pro',
68
+ 'grok': 'grok-imagine/text-to-image',
69
+ 'grok-i2i': 'grok-imagine/image-to-image',
70
+ 'seedream-5-lite': 'bytedance/seedream-5-lite',
71
+ };
72
+ const CHATIUM_VIDEO_MODELS = {
73
+ 'seedance-2': 'bytedance/seedance-2',
74
+ 'seedance-2-fast': 'bytedance/seedance-2-fast',
75
+ 'veo-3.1': 'google/veo-3.1',
76
+ 'kling-o1': 'kwaivgi/kling-o1',
77
+ 'kling-3.0': 'kling-3.0/video',
78
+ 'kling-v3-omni': 'kwaivgi/kling-v3-omni-video',
79
+ 'wan-2.7-i2v': 'wan/2-7-image-to-video',
80
+ 'runway': 'runway',
81
+ 'grok-i2v': 'grok-imagine/image-to-video',
82
+ 'grok-t2v': 'grok-imagine/text-to-video',
83
+ };
84
+
85
+ function resolveModel(map, key, fallback) {
86
+ if (!key) return fallback;
87
+ return map[key] || (Object.values(map).includes(key) ? key : null);
88
+ }
89
+
90
+ // =============================================================================
91
+ // Chatium HTTP helpers (text/audio/image/video дёргают одни и те же).
92
+ // =============================================================================
93
+
94
+ function chatiumBase(s) {
95
+ if (!s.chatium?.base) throw new Error('Нет Chatium base URL');
96
+ return s.chatium.base.replace(/\/$/, '');
97
+ }
98
+
99
+ async function chatiumStart(s, kind, body) {
100
+ if (!s.chatium?.token) throw new Error('Нет Chatium token (войдите в KingKont в настройках)');
101
+ const url = chatiumBase(s) + CHATIUM_PATHS[kind];
102
+ logCall('POST', 'Chatium', url, summarizeBody(body));
103
+ const r = await fetch(url, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Authorization': `Bearer ${s.chatium.token}`,
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ body: JSON.stringify(body),
110
+ });
111
+ const text = await r.text();
112
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
113
+ if (!r.ok) {
114
+ const reason = data?.reason || data?.error || data?.raw || `HTTP ${r.status}`;
115
+ throw new Error(`Chatium /api/${kind} HTTP ${r.status}: ${String(reason).slice(0, 800)}`);
116
+ }
117
+ if (!data.taskId) throw new Error(`Chatium /api/${kind} вернул без taskId: ${text.slice(0, 200)}`);
118
+ return data.taskId;
119
+ }
120
+
121
+ async function chatiumPoll(s, taskId) {
122
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.poll}?taskId=${encodeURIComponent(taskId)}`;
123
+ const r = await fetch(url, { headers: { 'Authorization': `Bearer ${s.chatium.token}` } });
124
+ const text = await r.text();
125
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
126
+ if (!r.ok) {
127
+ const reason = data?.reason || data?.error || data?.raw || `HTTP ${r.status}`;
128
+ throw new Error(`Chatium /api/poll HTTP ${r.status}: ${String(reason).slice(0, 300)}`);
129
+ }
130
+ return data;
131
+ }
132
+
133
+ /**
134
+ * Поллит Chatium-таск до готовности. Для media (audio/image/video) cost
135
+ * приходит ОТДЕЛЬНЫМ /onMediaBilled callback, иногда позже /onCompleted.
136
+ * Если completed но creditsCharged ещё null — даём grace-период.
137
+ *
138
+ * @returns {Promise<object>} { status, result, creditsCharged, error }
139
+ */
140
+ async function chatiumWait(s, taskId, opts = {}) {
141
+ const timeoutMs = opts.timeoutMs ?? 600000; // 10 минут (для видео)
142
+ const billedGraceMs = opts.billedGraceMs ?? 5000;
143
+ const onProgress = opts.onProgress || (() => {});
144
+ const deadline = Date.now() + timeoutMs;
145
+ let lastStatus = null;
146
+ while (Date.now() < deadline) {
147
+ const d = await chatiumPoll(s, taskId);
148
+ if (d.status !== lastStatus) {
149
+ onProgress(d.status, d);
150
+ lastStatus = d.status;
151
+ }
152
+ if (d.status === 'completed') {
153
+ if (typeof d.creditsCharged === 'number') return d;
154
+ const graceUntil = Date.now() + billedGraceMs;
155
+ while (Date.now() < graceUntil) {
156
+ await new Promise(r => setTimeout(r, 800));
157
+ const d2 = await chatiumPoll(s, taskId);
158
+ if (typeof d2.creditsCharged === 'number') return d2;
159
+ }
160
+ return d;
161
+ }
162
+ if (d.status === 'failed') throw new Error(d.error || 'chatium task failed');
163
+ await new Promise(r => setTimeout(r, 1500));
164
+ }
165
+ throw new Error(`chatium poll timeout after ${timeoutMs / 1000}s`);
166
+ }
167
+
168
+ // =============================================================================
169
+ // KIE HTTP helpers.
170
+ // =============================================================================
171
+
172
+ async function kieFetch(path, options = {}) {
173
+ const key = process.env.KIE_API_KEY;
174
+ if (!key) throw new Error('KIE_API_KEY не задан');
175
+ const r = await fetch(KIE_BASE + path, {
176
+ ...options,
177
+ headers: {
178
+ 'Authorization': `Bearer ${key}`,
179
+ 'Content-Type': 'application/json',
180
+ ...(options.headers || {}),
181
+ },
182
+ });
183
+ const text = await r.text();
184
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
185
+ if (!r.ok) throw new Error(`KIE ${r.status}: ${(data.msg || text).slice(0, 300)}`);
186
+ if (data.code && data.code !== 200) throw new Error(`KIE: ${data.msg || 'unknown error'}`);
187
+ return data;
188
+ }
189
+
190
+ async function kiePoll(taskId) {
191
+ return await kieFetch(`/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
192
+ }
193
+
194
+ // =============================================================================
195
+ // PUBLIC API: image / video generation (async — возвращает taskId).
196
+ // =============================================================================
197
+
198
+ /**
199
+ * @param {object} args
200
+ * @param {'image'|'video'} args.kind
201
+ * @param {string} args.prompt
202
+ * @param {string} [args.modelKey] Короткий ключ (nano-banana-2) или полный slug.
203
+ * @param {string[]} [args.imageInputs] URL'ы reference-картинок.
204
+ * @param {string[]} [args.videoInputs] URL'ы reference-видео.
205
+ * @param {string} [args.firstFrame] URL первого кадра (i2v).
206
+ * @param {string} [args.lastFrame] URL последнего кадра.
207
+ * @param {string} [args.aspectRatio] '16:9' | '9:16' | '1:1' | ...
208
+ * @param {string} [args.resolution] '720p' | '1080p' | '1K' | ...
209
+ * @param {number|string} [args.duration]
210
+ * @param {object} args.settings
211
+ * @returns {Promise<{ taskId: string, provider: 'kingkont'|'kie' }>}
212
+ */
213
+ async function startGeneration(args) {
214
+ const { kind, prompt, modelKey, imageInputs, videoInputs, firstFrame, lastFrame,
215
+ aspectRatio, resolution, duration, settings: s } = args;
216
+ if (kind !== 'image' && kind !== 'video') throw new Error(`unknown kind: ${kind}`);
217
+ if (!prompt && kind !== 'video') throw new Error('prompt обязателен');
218
+
219
+ // Chatium-путь.
220
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
221
+ const map = kind === 'video' ? CHATIUM_VIDEO_MODELS : CHATIUM_IMAGE_MODELS;
222
+ const fullModel = resolveModel(map, modelKey, kind === 'video' ? 'bytedance/seedance-2-fast' : 'nano-banana-2');
223
+ if (!fullModel) {
224
+ const known = Object.keys(map).join(', ');
225
+ throw new Error(`Модель "${modelKey}" не поддерживается KingKont для ${kind}. Доступны: ${known}`);
226
+ }
227
+ let body;
228
+ if (kind === 'video') {
229
+ body = { prompt, model: fullModel, imageInputs, videoInputs, firstFrame, lastFrame, aspectRatio, resolution, duration };
230
+ } else {
231
+ const isSeedream = fullModel.includes('seedream');
232
+ body = {
233
+ prompt, model: fullModel, aspectRatio, resolution,
234
+ outputFormat: isSeedream ? 'jpeg' : 'jpg',
235
+ };
236
+ if (!isSeedream && Array.isArray(imageInputs) && imageInputs.length) {
237
+ body.imageInputs = imageInputs;
238
+ }
239
+ }
240
+ const taskId = await chatiumStart(s, kind, body);
241
+ return { taskId: 'chatium:' + taskId, provider: 'kingkont' };
242
+ }
243
+
244
+ // KIE-путь.
245
+ if (!s.useKie) {
246
+ throw new Error(kind === 'video'
247
+ ? 'Войдите в KingKont или KIE для видео.'
248
+ : 'Войдите в KingKont или KIE для картинок.');
249
+ }
250
+ const map = kind === 'video' ? KIE_VIDEO_MODELS : KIE_IMAGE_MODELS;
251
+ const key = modelKey || (kind === 'video' ? 'seedance-2' : 'nano-banana-2');
252
+ const fullModel = map[key];
253
+ if (!fullModel) throw new Error(`unknown ${kind} model: ${key}`);
254
+
255
+ const input = { prompt };
256
+ if (kind === 'image') {
257
+ if (key === 'nano-banana-2') {
258
+ if (imageInputs?.length) input.image_input = imageInputs;
259
+ if (aspectRatio) input.aspect_ratio = aspectRatio;
260
+ input.resolution = resolution || '1K';
261
+ input.output_format = 'jpg';
262
+ } else if (key === 'grok') {
263
+ if (aspectRatio) input.aspect_ratio = aspectRatio;
264
+ input.nsfw_checker = false;
265
+ input.enable_pro = false;
266
+ } else if (key === 'seedream' || key === 'seedream-5-lite') {
267
+ input.aspect_ratio = aspectRatio || '16:9';
268
+ input.quality = 'high';
269
+ input.nsfw_checker = false;
270
+ } else if (key === 'flux-schnell' || key === 'sdxl-lightning') {
271
+ if (aspectRatio) input.aspect_ratio = aspectRatio;
272
+ input.output_format = 'jpg';
273
+ }
274
+ } else {
275
+ if (imageInputs?.length) input.reference_image_urls = imageInputs;
276
+ if (videoInputs?.length) input.reference_video_urls = videoInputs;
277
+ if (aspectRatio) input.aspect_ratio = aspectRatio;
278
+ if (resolution) input.resolution = resolution;
279
+ if (duration) input.duration = +duration;
280
+ }
281
+
282
+ logCall('POST', 'KIE', `${KIE_BASE}/api/v1/jobs/createTask`, `model=${fullModel} kind=${kind}`);
283
+ const data = await kieFetch('/api/v1/jobs/createTask', {
284
+ method: 'POST',
285
+ body: JSON.stringify({ model: fullModel, input }),
286
+ });
287
+ return { taskId: data.data?.taskId, provider: 'kie' };
288
+ }
289
+
290
+ /**
291
+ * Опросить статус таска. Возвращает унифицированный формат.
292
+ * @returns {Promise<{ status: 'done'|'pending'|'error', url?: string, error?: string, cost?: number, state?: string, provider: string }>}
293
+ */
294
+ async function pollGeneration(taskId, settings) {
295
+ if (taskId.startsWith('chatium:')) {
296
+ const realId = taskId.slice('chatium:'.length);
297
+ if (!settings.chatium?.token) return { status: 'error', error: 'Нет Chatium-сессии', provider: 'kingkont' };
298
+ let d;
299
+ try { d = await chatiumPoll(settings, realId); }
300
+ catch (e) { return { status: 'error', error: 'KingKont: ' + e.message, provider: 'kingkont' }; }
301
+ if (d.status === 'completed') {
302
+ const url = d.result?.urls?.[0];
303
+ if (!url) return { status: 'error', error: 'KingKont вернул без URL', provider: 'kingkont' };
304
+ // Grace для billed.
305
+ let cost = typeof d.creditsCharged === 'number' ? d.creditsCharged : null;
306
+ if (cost == null) {
307
+ const graceUntil = Date.now() + 5000;
308
+ while (Date.now() < graceUntil) {
309
+ await new Promise(r => setTimeout(r, 800));
310
+ try {
311
+ const d2 = await chatiumPoll(settings, realId);
312
+ if (typeof d2.creditsCharged === 'number') { cost = d2.creditsCharged; break; }
313
+ } catch {}
314
+ }
315
+ }
316
+ return { status: 'done', url, cost, provider: 'kingkont' };
317
+ }
318
+ if (d.status === 'failed') return { status: 'error', error: d.error || 'failed', provider: 'kingkont' };
319
+ return { status: 'pending', state: d.status, provider: 'kingkont' };
320
+ }
321
+
322
+ // KIE.
323
+ let data;
324
+ try { data = await kiePoll(taskId); }
325
+ catch (e) { return { status: 'error', error: e.message, provider: 'kie' }; }
326
+ const d = data.data || {};
327
+ if (d.state === 'success') {
328
+ let urls = [];
329
+ try { urls = JSON.parse(d.resultJson || '{}').resultUrls || []; } catch {}
330
+ if (!urls.length) return { status: 'error', error: 'KIE вернул успех без URL', provider: 'kie' };
331
+ return { status: 'done', url: urls[0], provider: 'kie' };
332
+ }
333
+ if (d.state === 'fail') return { status: 'error', error: d.failMsg || d.failCode || 'failed', provider: 'kie' };
334
+ return { status: 'pending', state: d.state, provider: 'kie' };
335
+ }
336
+
337
+ /**
338
+ * Ждать завершения таска (для CLI). Поллит каждые 4 секунды.
339
+ * @returns {Promise<{ url, cost?, provider }>}
340
+ */
341
+ async function waitForGeneration(taskId, settings, opts = {}) {
342
+ const timeoutMs = opts.timeoutMs ?? 600000; // 10 мин
343
+ const intervalMs = opts.intervalMs ?? 4000;
344
+ const onProgress = opts.onProgress || (() => {});
345
+ const deadline = Date.now() + timeoutMs;
346
+ let lastState = null;
347
+ while (Date.now() < deadline) {
348
+ const r = await pollGeneration(taskId, settings);
349
+ if (r.state !== lastState) { onProgress(r); lastState = r.state; }
350
+ if (r.status === 'done') return r;
351
+ if (r.status === 'error') throw new Error(r.error || 'generation failed');
352
+ await new Promise(s => setTimeout(s, intervalMs));
353
+ }
354
+ throw new Error(`Generation timeout after ${timeoutMs / 1000}s`);
355
+ }
356
+
357
+ // =============================================================================
358
+ // PUBLIC API: text generation (sync).
359
+ // =============================================================================
360
+
361
+ /**
362
+ * @returns {Promise<{ text, model, cost?, provider }>}
363
+ */
364
+ async function generateText({ prompt, model = 'anthropic/claude-sonnet-4', system, images, settings: s }) {
365
+ if (!prompt) throw new Error('нужен prompt');
366
+
367
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
368
+ const body = {
369
+ prompt, model, system,
370
+ images: Array.isArray(images) ? images.filter(i => i?.url) : undefined,
371
+ };
372
+ const taskId = await chatiumStart(s, 'text', body);
373
+ const result = await chatiumWait(s, taskId, { timeoutMs: 120000 });
374
+ return {
375
+ text: result.result?.text || '',
376
+ model,
377
+ cost: typeof result.creditsCharged === 'number' ? result.creditsCharged : null,
378
+ provider: 'kingkont',
379
+ };
380
+ }
381
+
382
+ if (!s.useOpenrouter) throw new Error('Войдите в KingKont или OpenRouter для генерации текста.');
383
+ const key = process.env.OPENROUTER_API_KEY;
384
+ if (!key) throw new Error('OPENROUTER_API_KEY не задан');
385
+
386
+ const messages = [];
387
+ if (system) messages.push({ role: 'system', content: system });
388
+ if (Array.isArray(images) && images.length) {
389
+ const content = [{ type: 'text', text: prompt }];
390
+ for (const img of images) {
391
+ if (!img?.url) continue;
392
+ content.push({ type: 'image_url', image_url: { url: img.url } });
393
+ }
394
+ messages.push({ role: 'user', content });
395
+ } else {
396
+ messages.push({ role: 'user', content: prompt });
397
+ }
398
+ logCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions', `model=${model} prompt=${prompt.length}ch`);
399
+ const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
400
+ method: 'POST',
401
+ headers: {
402
+ 'Authorization': `Bearer ${key}`,
403
+ 'Content-Type': 'application/json',
404
+ 'HTTP-Referer': 'http://localhost',
405
+ 'X-Title': 'KingKont',
406
+ },
407
+ body: JSON.stringify({ model, messages }),
408
+ });
409
+ const text = await r.text();
410
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
411
+ if (!r.ok) throw new Error(data?.error?.message || data?.raw || `HTTP ${r.status}`);
412
+ return {
413
+ text: data?.choices?.[0]?.message?.content || '',
414
+ model: data?.model || model,
415
+ cost: null,
416
+ provider: 'openrouter',
417
+ };
418
+ }
419
+
420
+ // =============================================================================
421
+ // PUBLIC API: TTS / SFX / music (sync — возвращает Buffer аудио).
422
+ // =============================================================================
423
+
424
+ const TTS_PASSTHROUGH = new Set([
425
+ 'voice', 'voiceId', 'voice_id', 'speaker',
426
+ 'language', 'language_code', 'language_boost',
427
+ 'speed', 'pitch', 'volume',
428
+ 'stability', 'similarity_boost', 'style', 'style_instruction',
429
+ 'audio_format', 'sample_rate', 'bitrate', 'channel', 'emotion',
430
+ 'subtitle_enable', 'english_normalization',
431
+ 'voice_description', 'reference_audio', 'reference_text', 'mode',
432
+ 'previous_text', 'next_text',
433
+ ]);
434
+
435
+ /**
436
+ * @returns {Promise<{ buffer: Buffer, mime: string, cost?: number, provider: string }>}
437
+ */
438
+ async function generateTts(args) {
439
+ const { text, settings: s } = args;
440
+ if (!text) throw new Error('нужен text');
441
+
442
+ const directElevenAvailable = s.useElevenlabs && process.env.ELEVENLABS_API_KEY;
443
+ if (s.useChatium && s.chatium?.token && s.chatium?.base && !directElevenAvailable) {
444
+ const body = { kind: 'tts', text };
445
+ if (args.ttsModel) body.model = args.ttsModel;
446
+ else if (args.modelId === 'eleven_v3') body.model = 'elevenlabs/v3';
447
+ else if (args.modelId) body.model = args.modelId;
448
+ if (args.voice) body.voice = args.voice;
449
+ else if (args.voiceId) body.voice = args.voiceId;
450
+ for (const k of Object.keys(args)) {
451
+ if (['text', 'voice', 'voiceId', 'ttsModel', 'modelId', 'settings'].includes(k)) continue;
452
+ if (TTS_PASSTHROUGH.has(k)) body[k] = args[k];
453
+ }
454
+ return await audioViaChatium(s, body);
455
+ }
456
+
457
+ if (!s.useElevenlabs) throw new Error('Войдите в KingKont или ElevenLabs для аудио.');
458
+ const key = process.env.ELEVENLABS_API_KEY;
459
+ if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
460
+ const voiceId = args.voiceId || args.voice || 'JBFqnCBsd6RMkjVDRZzb';
461
+ logCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, `model=eleven_v3 text=${text.length}ch`);
462
+ const r = await fetch(`${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, {
463
+ method: 'POST',
464
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
465
+ body: JSON.stringify({ text, model_id: 'eleven_v3' }),
466
+ });
467
+ if (!r.ok) throw new Error((await r.text()).slice(0, 300));
468
+ const buf = Buffer.from(await r.arrayBuffer());
469
+ return { buffer: buf, mime: 'audio/mpeg', cost: null, provider: 'elevenlabs' };
470
+ }
471
+
472
+ async function generateSfx(args) {
473
+ const { text, durationSeconds, promptInfluence = 0.3, settings: s } = args;
474
+ if (!text) throw new Error('нужен text');
475
+
476
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
477
+ return await audioViaChatium(s, { kind: 'sfx', text, durationSeconds });
478
+ }
479
+ if (!s.useElevenlabs) throw new Error('Войдите в KingKont или ElevenLabs для аудио.');
480
+ const key = process.env.ELEVENLABS_API_KEY;
481
+ if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
482
+ const body = { text, prompt_influence: promptInfluence };
483
+ if (durationSeconds) body.duration_seconds = +durationSeconds;
484
+ logCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/sound-generation`, `text=${text.length}ch dur=${durationSeconds || '-'}`);
485
+ const r = await fetch(`${ELEVEN_BASE}/v1/sound-generation`, {
486
+ method: 'POST',
487
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
488
+ body: JSON.stringify(body),
489
+ });
490
+ if (!r.ok) throw new Error((await r.text()).slice(0, 400));
491
+ return { buffer: Buffer.from(await r.arrayBuffer()), mime: 'audio/mpeg', cost: null, provider: 'elevenlabs' };
492
+ }
493
+
494
+ async function generateMusic(args) {
495
+ const { prompt, durationMs, settings: s } = args;
496
+ if (!prompt) throw new Error('нужен prompt');
497
+
498
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
499
+ return await audioViaChatium(s, { kind: 'music', prompt, durationMs });
500
+ }
501
+ if (!s.useElevenlabs) throw new Error('Войдите в KingKont или ElevenLabs для аудио.');
502
+ const key = process.env.ELEVENLABS_API_KEY;
503
+ if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
504
+ const body = { prompt };
505
+ if (durationMs) body.music_length_ms = +durationMs;
506
+ logCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/music`, `prompt=${prompt.length}ch dur_ms=${durationMs || '-'}`);
507
+ const r = await fetch(`${ELEVEN_BASE}/v1/music`, {
508
+ method: 'POST',
509
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
510
+ body: JSON.stringify(body),
511
+ });
512
+ if (!r.ok) throw new Error((await r.text()).slice(0, 400));
513
+ return { buffer: Buffer.from(await r.arrayBuffer()), mime: 'audio/mpeg', cost: null, provider: 'elevenlabs' };
514
+ }
515
+
516
+ /**
517
+ * Внутренний helper: шлём audio-задачу в Chatium, ждём, скачиваем URL → Buffer.
518
+ */
519
+ async function audioViaChatium(s, body) {
520
+ const taskId = await chatiumStart(s, 'audio', body);
521
+ const result = await chatiumWait(s, taskId, { timeoutMs: 180000 });
522
+ const url = result.result?.urls?.[0];
523
+ if (!url) throw new Error('Chatium завершил аудио-задачу без URL');
524
+ logCall('GET ', 'Chatium', url, '(downloading audio)');
525
+ const r = await fetch(url);
526
+ if (!r.ok) throw new Error(`download ${r.status}`);
527
+ const buf = Buffer.from(await r.arrayBuffer());
528
+ return {
529
+ buffer: buf,
530
+ mime: 'audio/mpeg',
531
+ cost: typeof result.creditsCharged === 'number' ? result.creditsCharged : null,
532
+ provider: 'kingkont',
533
+ };
534
+ }
535
+
536
+ // =============================================================================
537
+ // PUBLIC API: file upload (Chatium-storage с fallback на KIE).
538
+ // =============================================================================
539
+
540
+ /**
541
+ * @returns {Promise<{ url, fileName, size, hash?: string, provider }>}
542
+ */
543
+ async function uploadFile({ buffer, filename = 'upload.bin', mime = 'application/octet-stream', settings: s }) {
544
+ if (!buffer || !buffer.length) throw new Error('пустой файл');
545
+ if (buffer.length > 50 * 1024 * 1024) {
546
+ throw new Error(`файл слишком большой (${(buffer.length/1024/1024).toFixed(1)} MB), лимит 50MB`);
547
+ }
548
+
549
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
550
+ const urlReq = chatiumBase(s) + CHATIUM_PATHS.uploadUrl;
551
+ const ru = await fetch(urlReq, { headers: { 'Authorization': `Bearer ${s.chatium.token}` } });
552
+ if (!ru.ok) throw new Error('KingKont upload-url: ' + (await ru.text()).slice(0, 200));
553
+ const { uploadUrl } = await ru.json();
554
+ if (!uploadUrl) throw new Error('KingKont не вернул uploadUrl');
555
+ logCall('POST', 'Chatium', uploadUrl, `file=${filename} size=${buffer.length}`);
556
+ const fd = new FormData();
557
+ fd.append('Filedata', new Blob([buffer], { type: mime }), filename);
558
+ const ru2 = await fetch(uploadUrl, { method: 'POST', body: fd });
559
+ const hash = (await ru2.text()).trim();
560
+ if (!ru2.ok || !hash) throw new Error(`Chatium upload HTTP ${ru2.status}: ${hash.slice(0, 200)}`);
561
+ return { url: `${CHATIUM_CDN}/${hash}`, fileName: filename, size: buffer.length, hash, provider: 'kingkont' };
562
+ }
563
+
564
+ if (!s.useKie) throw new Error('Войдите в KingKont или KIE для загрузки файлов.');
565
+ if (buffer.length > 10 * 1024 * 1024) {
566
+ throw new Error(`файл слишком большой для KIE (${(buffer.length/1024/1024).toFixed(1)} MB), лимит 10MB`);
567
+ }
568
+ const dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
569
+ const key = process.env.KIE_API_KEY;
570
+ if (!key) throw new Error('KIE_API_KEY не задан');
571
+ logCall('POST', 'KIE', 'https://kieai.redpandaai.co/api/file-base64-upload', `file=${filename} size=${buffer.length}`);
572
+ const r = await fetch('https://kieai.redpandaai.co/api/file-base64-upload', {
573
+ method: 'POST',
574
+ headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
575
+ body: JSON.stringify({ base64Data: dataUrl, uploadPath: 'images', fileName: filename }),
576
+ });
577
+ const text = await r.text();
578
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
579
+ if (!r.ok || !data.success) throw new Error(data.msg || `upload failed: ${text.slice(0, 200)}`);
580
+ return { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize, provider: 'kie' };
581
+ }
582
+
583
+ /** Скачать URL → Buffer (для CLI чтобы сохранить файл на диск). */
584
+ async function downloadUrl(url) {
585
+ const r = await fetch(url);
586
+ if (!r.ok) throw new Error(`download ${r.status} for ${url.slice(0, 100)}`);
587
+ return Buffer.from(await r.arrayBuffer());
588
+ }
589
+
590
+ // =============================================================================
591
+ // PUBLIC API: info (balance / transactions / voices).
592
+ // =============================================================================
593
+
594
+ async function fetchBalances(s) {
595
+ const out = {};
596
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
597
+ try {
598
+ const r = await fetch(chatiumBase(s) + CHATIUM_PATHS.balance, {
599
+ headers: { 'Authorization': `Bearer ${s.chatium.token}` },
600
+ });
601
+ const d = await r.json().catch(() => ({}));
602
+ if (r.ok && typeof d.balance === 'number') out.kingkont = { unit: 'credits', amount: d.balance };
603
+ } catch {}
604
+ }
605
+ if (s.useElevenlabs && process.env.ELEVENLABS_API_KEY) {
606
+ try {
607
+ const r = await fetch(`${ELEVEN_BASE}/v1/user/subscription`, {
608
+ headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY },
609
+ });
610
+ const d = await r.json().catch(() => ({}));
611
+ if (r.ok && typeof d.character_count === 'number' && typeof d.character_limit === 'number') {
612
+ out.elevenlabs = {
613
+ unit: 'chars',
614
+ amount: Math.max(0, d.character_limit - d.character_count),
615
+ limit: d.character_limit,
616
+ };
617
+ }
618
+ } catch {}
619
+ }
620
+ if (s.useOpenrouter && process.env.OPENROUTER_API_KEY) {
621
+ try {
622
+ const r = await fetch('https://openrouter.ai/api/v1/credits', {
623
+ headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}` },
624
+ });
625
+ const d = await r.json().catch(() => ({}));
626
+ if (r.ok && d.data && typeof d.data.total_credits === 'number') {
627
+ out.openrouter = {
628
+ unit: 'usd',
629
+ amount: Math.max(0, (d.data.total_credits || 0) - (d.data.total_usage || 0)),
630
+ };
631
+ }
632
+ } catch {}
633
+ }
634
+ return out;
635
+ }
636
+
637
+ async function fetchTransactions(s) {
638
+ if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
639
+ throw new Error('KingKont не подключён');
640
+ }
641
+ const r = await fetch(chatiumBase(s) + CHATIUM_PATHS.transactions, {
642
+ headers: { 'Authorization': `Bearer ${s.chatium.token}` },
643
+ });
644
+ const text = await r.text();
645
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
646
+ if (!r.ok) throw new Error(data?.reason || data?.error || `HTTP ${r.status}`);
647
+ return data;
648
+ }
649
+
650
+ async function listElevenVoices() {
651
+ const key = process.env.ELEVENLABS_API_KEY;
652
+ if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
653
+ logCall('GET ', 'ElevenLabs', `${ELEVEN_BASE}/v1/voices`);
654
+ const r = await fetch(`${ELEVEN_BASE}/v1/voices`, { headers: { 'xi-api-key': key } });
655
+ const text = await r.text();
656
+ let data; try { data = JSON.parse(text); } catch { data = {}; }
657
+ if (!r.ok) throw new Error(data?.detail?.message || `HTTP ${r.status}`);
658
+ return (data.voices || []).map(v => ({
659
+ id: v.voice_id,
660
+ name: v.name,
661
+ category: v.category,
662
+ labels: v.labels || {},
663
+ preview: v.preview_url,
664
+ }));
665
+ }
666
+
667
+ module.exports = {
668
+ // Длительные генерации (image/video)
669
+ startGeneration,
670
+ pollGeneration,
671
+ waitForGeneration,
672
+ // Синхронные
673
+ generateText,
674
+ generateTts,
675
+ generateSfx,
676
+ generateMusic,
677
+ // Storage
678
+ uploadFile,
679
+ downloadUrl,
680
+ // Info
681
+ fetchBalances,
682
+ fetchTransactions,
683
+ listElevenVoices,
684
+ // Constants (для server.js / тестов)
685
+ KIE_IMAGE_MODELS,
686
+ KIE_VIDEO_MODELS,
687
+ CHATIUM_IMAGE_MODELS,
688
+ CHATIUM_VIDEO_MODELS,
689
+ };