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,1727 @@
1
+ // renderer/generate.js — Add-menu (ПКМ), generate (image/video/text/audio), preferences modal, SFX, music, tones
2
+ //
3
+ // Этот модуль был выделен из index.html (раньше всё было в одном <script>
4
+ // блоке на 9123 строки). Все модули загружаются как plain <script>
5
+ // в одном глобальном scope — поэтому функции/переменные между файлами
6
+ // видят друг друга по именам, без import/export. Порядок загрузки
7
+ // важен: см. <script> теги внизу index.html.
8
+
9
+ // =================== Add-menu (dblclick / ПКМ по пустому холсту) ===================
10
+ canvasWrap.addEventListener('dblclick', e => {
11
+ if (!state.currentBoard) return;
12
+ if (e.target.closest('.node, .conn, .resize-handle, .anchor')) return;
13
+ if (e.target.closest('#addMenu, .modal')) return;
14
+ showAddMenu(e.clientX, e.clientY);
15
+ });
16
+ canvasWrap.addEventListener('contextmenu', e => {
17
+ if (!state.currentBoard) return;
18
+ if (e.target.closest('.node, .conn, .resize-handle, .anchor, button, input, textarea, select')) return;
19
+ if (e.target.closest('#addMenu, .modal')) return;
20
+ e.preventDefault();
21
+ showAddMenu(e.clientX, e.clientY);
22
+ });
23
+
24
+ // Rubber-band селектор + сброс выделения по клику в пустое место
25
+ canvasWrap.addEventListener('mousedown', e => {
26
+ if (!state.currentBoard) return;
27
+ if (e.button !== 0) return;
28
+ if (e.target.closest('.node, .conn, .anchor, .resize-handle, button, input, textarea, select, .modal, #addMenu, #nodeMenu')) return;
29
+ // Если без модификатора — сбрасываем старое выделение
30
+ const additive = e.metaKey || e.ctrlKey || e.shiftKey;
31
+ if (!additive && state.selectedNodeIds.size) {
32
+ clearSelection();
33
+ renderSelection();
34
+ }
35
+ // Старт rubber-band
36
+ startRubberBand(e, additive);
37
+ });
38
+
39
+ function startRubberBand(e, additive) {
40
+ const rectStart = canvas.getBoundingClientRect();
41
+ const startC = { x: (e.clientX - rectStart.left) / state.zoom, y: (e.clientY - rectStart.top) / state.zoom };
42
+ const overlay = document.createElement('div');
43
+ overlay.style.cssText = 'position:fixed; pointer-events:none; border:1px dashed #6a8aaa; background:rgba(106,138,170,0.1); z-index:1000;';
44
+ document.body.appendChild(overlay);
45
+ let moved = false;
46
+ const onMove = ev => {
47
+ moved = true;
48
+ const x = Math.min(ev.clientX, e.clientX);
49
+ const y = Math.min(ev.clientY, e.clientY);
50
+ overlay.style.left = x + 'px';
51
+ overlay.style.top = y + 'px';
52
+ overlay.style.width = Math.abs(ev.clientX - e.clientX) + 'px';
53
+ overlay.style.height = Math.abs(ev.clientY - e.clientY) + 'px';
54
+ };
55
+ const onUp = ev => {
56
+ document.removeEventListener('mousemove', onMove);
57
+ document.removeEventListener('mouseup', onUp);
58
+ overlay.remove();
59
+ if (!moved) return;
60
+ const rect = canvas.getBoundingClientRect();
61
+ const endC = { x: (ev.clientX - rect.left) / state.zoom, y: (ev.clientY - rect.top) / state.zoom };
62
+ const x1 = Math.min(startC.x, endC.x), y1 = Math.min(startC.y, endC.y);
63
+ const x2 = Math.max(startC.x, endC.x), y2 = Math.max(startC.y, endC.y);
64
+ if (!additive) state.selectedNodeIds.clear();
65
+ for (const n of state.currentBoard.metadata.nodes) {
66
+ const w = +n.width || 280;
67
+ const el = canvas.querySelector(`.node[data-id="${n.id}"]`);
68
+ const h = el ? el.offsetHeight : (+n.height || 200);
69
+ if (n.x < x2 && n.x + w > x1 && n.y < y2 && n.y + h > y1) {
70
+ state.selectedNodeIds.add(n.id);
71
+ }
72
+ }
73
+ renderSelection();
74
+ };
75
+ document.addEventListener('mousemove', onMove);
76
+ document.addEventListener('mouseup', onUp);
77
+ }
78
+
79
+ function showAddMenu(clientX, clientY) {
80
+ state.addMenuPos = { clientX, clientY };
81
+ const menu = $('addMenu');
82
+ // Удаляем динамические пункты-фразы и пересобираем их
83
+ menu.querySelectorAll('.dyn-phrase').forEach(b => b.remove());
84
+ for (const c of state.charactersInfo) {
85
+ if (!c.voice) continue; // без голоса нечем озвучивать
86
+ const btn = document.createElement('button');
87
+ btn.className = 'dyn-phrase';
88
+ btn.dataset.charName = c.name;
89
+ btn.textContent = `💬 Фраза + ${c.name}`;
90
+ btn.addEventListener('click', () => {
91
+ $('addMenu').classList.add('hidden');
92
+ openPhraseFor(c);
93
+ });
94
+ menu.appendChild(btn);
95
+ }
96
+ positionFloatingMenu(menu, clientX, clientY);
97
+ setTimeout(() => document.addEventListener('mousedown', closeAddMenu, { once: true }), 0);
98
+ }
99
+
100
+ function openPhraseFor(charInfo) {
101
+ state.genKind = 'audio';
102
+ document.querySelectorAll('#genModal [data-kind]').forEach(b =>
103
+ b.classList.toggle('active', b.dataset.kind === 'audio'));
104
+ $('imageModelRow').style.display = 'none';
105
+
106
+ $('imageOptionsRow').style.display = 'none';
107
+ $('videoOptionsRow').style.display = 'none';
108
+
109
+ $('videoModelRow').style.display = 'none';
110
+ $('voiceRow').style.display = '';
111
+
112
+ $('ttsModelRow').style.display = '';
113
+ $('tonesRow').style.display = '';
114
+ loadVoices().then(() => {
115
+ if (charInfo.voice) $('genVoice').value = charInfo.voice;
116
+ });
117
+ // Тоны: подсказки из commonTones персонажа + дефолты, обычный тон уже применён
118
+ state.toneSuggestions = (charInfo.commonTones || []).slice();
119
+ state.activeTones = charInfo.tone ? [charInfo.tone] : [];
120
+ renderTones();
121
+ $('genPrompt').value = '';
122
+ const placeholderHint = `Что должен сказать ${charInfo.name}...`;
123
+ $('genPrompt').setAttribute('placeholder', placeholderHint);
124
+ $('genStatus').textContent = '';
125
+ $('genStatus').className = 'status';
126
+ $('genSubmit').disabled = false;
127
+ resetPicks();
128
+ syncCharLocRows();
129
+ closeMentionPopup();
130
+ $('genModal').classList.remove('hidden');
131
+ setTimeout(() => $('genPrompt').focus(), 50);
132
+ }
133
+
134
+ function closeAddMenu(e) {
135
+ if (e && e.target.closest('#addMenu')) {
136
+ document.addEventListener('mousedown', closeAddMenu, { once: true });
137
+ return;
138
+ }
139
+ $('addMenu').classList.add('hidden');
140
+ }
141
+
142
+ document.querySelectorAll('#addMenu button').forEach(btn => {
143
+ btn.addEventListener('click', async () => {
144
+ const act = btn.dataset.act;
145
+ $('addMenu').classList.add('hidden');
146
+ document.removeEventListener('mousedown', closeAddMenuOnOutside, true);
147
+ const fromNode = state.anchorFromNode; // если меню вызвано из anchor-drag
148
+ state.anchorFromNode = null;
149
+
150
+ if (act === 'text') {
151
+ await addTextAt(state.addMenuPos);
152
+ state.addMenuPos = null;
153
+ return;
154
+ }
155
+ if (act === 'gen-text') {
156
+ // открываем text-gen modal; если есть fromNode — подставляем @ref в промпт
157
+ if (!await ensureApiKey('text')) return;
158
+ $('textGenPrompt').value = fromNode ? `[@${nodeRefKey(fromNode)}] ` : '';
159
+ $('textGenStatus').textContent = ''; $('textGenStatus').className = 'status';
160
+ $('textGenSubmit').disabled = false;
161
+ const savedModel = localStorage.getItem('textGenModel');
162
+ if (savedModel) $('textGenModel').value = savedModel;
163
+ $('textGenModal').classList.remove('hidden');
164
+ setTimeout(() => {
165
+ const ta = $('textGenPrompt');
166
+ ta.focus();
167
+ ta.setSelectionRange(ta.value.length, ta.value.length);
168
+ }, 30);
169
+ return;
170
+ }
171
+ if (act === 'image' || act === 'video' || act === 'audio') {
172
+ if (fromNode) {
173
+ // Из anchor-drag — переиспользуем openGenerateForRef, передавая kind
174
+ await openGenerateForRef(fromNode, state.addMenuPos?.clientX, state.addMenuPos?.clientY, act);
175
+ } else {
176
+ await openGenModal(act);
177
+ }
178
+ }
179
+ });
180
+ });
181
+
182
+ async function addTextAt(pos) {
183
+ if (!state.currentBoard) return;
184
+ const rect = canvas.getBoundingClientRect();
185
+ const x = pos ? (pos.clientX - rect.left) / state.zoom : canvasWrap.scrollLeft / state.zoom + 80;
186
+ const y = pos ? (pos.clientY - rect.top) / state.zoom : canvasWrap.scrollTop / state.zoom + 80;
187
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
188
+ const mdName = await uniqueName(dir, 'text.md');
189
+ await writeFile(dir, mdName, '');
190
+ const node = { id: crypto.randomUUID(), type: 'text', file: `texts/${mdName}`, text: '', x, y };
191
+ state.currentBoard.metadata.nodes.push(node);
192
+ canvas.appendChild(await createNodeEl(node));
193
+ scheduleSave();
194
+ }
195
+
196
+ // =================== Generate (фоновая, не блокирующая) ===================
197
+ // === Preferences modal (API-ключи) ===
198
+ async function openSettings(initialFocus) {
199
+ if (!window.appSettings) {
200
+ alert('Настройки доступны только в Electron-сборке.');
201
+ return;
202
+ }
203
+ const cur = await window.appSettings.get();
204
+ $('prefsKieKey').value = cur.kieKey || '';
205
+ $('prefsElevenKey').value = cur.elevenKey || '';
206
+ $('prefsOpenrouterKey').value = cur.openrouterKey || '';
207
+ $('prefsModal').classList.remove('hidden');
208
+ setTimeout(() => {
209
+ const focusId = initialFocus === 'kie' ? 'prefsKieKey'
210
+ : initialFocus === 'eleven' ? 'prefsElevenKey'
211
+ : initialFocus === 'openrouter' ? 'prefsOpenrouterKey'
212
+ : 'prefsKieKey';
213
+ $(focusId).focus();
214
+ }, 30);
215
+ }
216
+ $('prefsCancel').addEventListener('click', () => $('prefsModal').classList.add('hidden'));
217
+ $('prefsSave').addEventListener('click', async () => {
218
+ await window.appSettings.save({
219
+ kieKey: $('prefsKieKey').value.trim() || undefined,
220
+ elevenKey: $('prefsElevenKey').value.trim() || undefined,
221
+ openrouterKey: $('prefsOpenrouterKey').value.trim() || undefined,
222
+ });
223
+ $('prefsModal').classList.add('hidden');
224
+ });
225
+
226
+ // Универсальное позиционирование floating-меню около курсора с учётом границ
227
+ // окна. Меню должно быть уже видимым (без .hidden) чтобы offsetWidth/Height
228
+ // были реальными — поэтому делаем remove('hidden'), измеряем, корректируем.
229
+ function positionFloatingMenu(menu, clientX, clientY) {
230
+ menu.classList.remove('hidden');
231
+ // Сначала ставим в (0,0), чтобы измерить «честный» размер без overflow.
232
+ menu.style.left = '0px';
233
+ menu.style.top = '0px';
234
+ const w = menu.offsetWidth || 240;
235
+ const h = menu.offsetHeight || 200;
236
+ const pad = 8;
237
+ let x = clientX, y = clientY;
238
+ // Если не помещается справа от курсора — открываем слева от него.
239
+ if (clientX + w + pad > window.innerWidth) x = Math.max(pad, clientX - w);
240
+ // Если не помещается ниже курсора — открываем выше.
241
+ if (clientY + h + pad > window.innerHeight) y = Math.max(pad, clientY - h);
242
+ menu.style.left = x + 'px';
243
+ menu.style.top = y + 'px';
244
+ }
245
+
246
+ // Глобальный Esc: закрывает самую верхнюю видимую модалку.
247
+ document.addEventListener('keydown', e => {
248
+ if (e.key !== 'Escape') return;
249
+ // Reverse DOM order — поздняя в DOM лежит «выше», прячем именно её.
250
+ const modals = [...document.querySelectorAll('.modal:not(.hidden)')].reverse();
251
+ if (!modals.length) return;
252
+ e.preventDefault();
253
+ e.stopPropagation();
254
+ modals[0].classList.add('hidden');
255
+ });
256
+
257
+ // Проверка наличия нужного ключа перед открытием gen-модалки.
258
+ // Возвращает true если ключ есть; иначе показывает settings и false.
259
+ async function ensureApiKey(forKind) {
260
+ if (!window.appSettings) return true; // веб-режим — настройки не доступны
261
+ const s = await window.appSettings.get();
262
+ // Если KingKont подключён и активен — он покроет любой kind, прямые
263
+ // ключи не нужны.
264
+ const hasChatium = !!(s.useChatium === true && s.chatium?.token && s.chatium?.base);
265
+ if (hasChatium) return true;
266
+ // Иначе нужен прямой ключ для соответствующего провайдера.
267
+ if (forKind === 'audio' && (!s.useElevenlabs || !s.elevenKey)) {
268
+ alert('Войдите в KingKont или включите ElevenLabs (нужен API-ключ).');
269
+ openSettings('eleven');
270
+ return false;
271
+ }
272
+ if ((forKind === 'image' || forKind === 'video') && (!s.useKie || !s.kieKey)) {
273
+ alert('Войдите в KingKont или включите KIE (нужен API-ключ).');
274
+ openSettings('kie');
275
+ return false;
276
+ }
277
+ if (forKind === 'text' && (!s.useOpenrouter || !s.openrouterKey)) {
278
+ alert('Войдите в KingKont или включите OpenRouter (нужен API-ключ).');
279
+ openSettings('openrouter');
280
+ return false;
281
+ }
282
+ return true;
283
+ }
284
+
285
+ // Открыть модалку генерации с заранее выбранным kind. UI типа в модалке
286
+ // скрыт — selector переехал в кнопки тулбара.
287
+ async function openGenModal(kind) {
288
+ if (!await ensureApiKey(kind)) return;
289
+ if (!state.currentBoard) return;
290
+ state.genKind = kind;
291
+ document.querySelectorAll('#genModal [data-kind]').forEach(b =>
292
+ b.classList.toggle('active', b.dataset.kind === kind));
293
+ // Видимость рядов под текущий kind
294
+ $('imageModelRow').style.display = kind === 'image' ? '' : 'none';
295
+
296
+ $('imageOptionsRow').style.display = kind === 'image' ? '' : 'none';
297
+ $('videoOptionsRow').style.display = kind === 'video' ? '' : 'none';
298
+
299
+ $('videoModelRow').style.display = kind === 'video' ? '' : 'none';
300
+ $('voiceRow').style.display = kind === 'audio' ? '' : 'none';
301
+
302
+ $('ttsModelRow').style.display = kind === 'audio' ? '' : 'none';
303
+ $('tonesRow').style.display = kind === 'audio' ? '' : 'none';
304
+ // Заголовок модалки = действие
305
+ const title = $('genTitle');
306
+ if (title) {
307
+ title.textContent =
308
+ kind === 'image' ? 'Сгенерировать картинку' :
309
+ kind === 'video' ? 'Сгенерировать видео' :
310
+ kind === 'audio' ? 'Сгенерировать голос' : 'Сгенерировать ноду';
311
+ }
312
+ // Плейсхолдер промпта — соответствующий
313
+ const ph = kind === 'audio'
314
+ ? 'Текст, который надо озвучить...'
315
+ : 'Что должно быть. Печатай @ чтобы вставить ссылку на ноду...';
316
+ $('genPrompt').setAttribute('placeholder', ph);
317
+
318
+ $('genStatus').textContent = '';
319
+ $('genStatus').className = 'status';
320
+ $('genSubmit').disabled = false;
321
+ $('genPrompt').value = '';
322
+ state.activeTones = [];
323
+ state.toneSuggestions = [];
324
+ renderTones();
325
+ resetPicks();
326
+ presetPicksForBoard();
327
+ renderCharsPickChips();
328
+ renderLocPickSelect();
329
+ syncCharLocRows();
330
+ if (kind === 'audio') { loadVoices(); }
331
+ closeMentionPopup();
332
+ syncSourceRefRow();
333
+ $('genModal').classList.remove('hidden');
334
+ setTimeout(() => $('genPrompt').focus(), 50);
335
+ }
336
+
337
+ $('genImage').addEventListener('click', () => openGenModal('image'));
338
+ $('genVideo').addEventListener('click', () => openGenModal('video'));
339
+ $('genAudio').addEventListener('click', () => openGenModal('audio'));
340
+ $('genText').addEventListener('click', async () => {
341
+ if (!state.currentBoard) return;
342
+ if (!await ensureApiKey('text')) return;
343
+ $('textGenPrompt').value = '';
344
+ $('textGenStatus').textContent = '';
345
+ $('textGenStatus').className = 'status';
346
+ $('textGenSubmit').disabled = false;
347
+ // Восстановить выбор модели из localStorage
348
+ const savedModel = localStorage.getItem('textGenModel');
349
+ if (savedModel) $('textGenModel').value = savedModel;
350
+ $('textGenModal').classList.remove('hidden');
351
+ setTimeout(() => $('textGenPrompt').focus(), 30);
352
+ });
353
+
354
+ $('textGenCancel').addEventListener('click', () => $('textGenModal').classList.add('hidden'));
355
+
356
+ $('textGenSubmit').addEventListener('click', async () => {
357
+ const rawPrompt = $('textGenPrompt').value.trim();
358
+ if (!rawPrompt) { $('textGenPrompt').focus(); return; }
359
+ const model = $('textGenModel').value;
360
+ localStorage.setItem('textGenModel', model);
361
+ // Позиция новой ноды: место отпускания (anchor-drag) или свободное.
362
+ let spot;
363
+ if (state.addMenuPos) {
364
+ const rect = canvas.getBoundingClientRect();
365
+ spot = {
366
+ x: (state.addMenuPos.clientX - rect.left) / state.zoom,
367
+ y: (state.addMenuPos.clientY - rect.top) / state.zoom,
368
+ };
369
+ state.addMenuPos = null;
370
+ } else {
371
+ spot = findFreeSpot();
372
+ }
373
+ const pendingFrom = state.pendingConnectionFrom;
374
+ state.pendingConnectionFrom = null;
375
+ // Собираем image-mentions из промпта (видение для модели). Видео/audio в
376
+ // OpenRouter chat не поддерживается — игнорируем.
377
+ const allMediaRefs = (typeof gatherMediaRefs === 'function') ? gatherMediaRefs(rawPrompt) : [];
378
+ const imageRefs = allMediaRefs.filter(r => r.type === 'image' && r.file);
379
+ // Резолвим mentions: text-ноды → инлайн .md, image-ноды → маркеры [image N].
380
+ const resolvedPrompt = (typeof resolveMentions === 'function') ? resolveMentions(rawPrompt, imageRefs) : rawPrompt;
381
+ // Создаём ноду в pending-состоянии сразу — пока модель работает, на холсте
382
+ // виден spinner, юзер может закрыть модалку, ходить по проекту и т.д.
383
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
384
+ const mdName = await uniqueName(dir, 'text.md');
385
+ await writeFile(dir, mdName, ''); // пустой плейсхолдер
386
+ const node = {
387
+ id: crypto.randomUUID(),
388
+ type: 'text', file: `texts/${mdName}`, text: '',
389
+ x: spot.x, y: spot.y,
390
+ status: 'generating',
391
+ generated: { kind: 'text', rawPrompt, prompt: resolvedPrompt, model, state: 'submitting' },
392
+ };
393
+ state.currentBoard.metadata.nodes.push(node);
394
+ canvas.appendChild(await createNodeEl(node));
395
+ if (pendingFrom) addConnection(pendingFrom, node.id);
396
+ scheduleSave();
397
+ $('textGenModal').classList.add('hidden');
398
+ // Фоновая генерация — модалку уже закрыли.
399
+ runTextJob(node, resolvedPrompt, model, state.currentBoard.handle, state.currentBoard.key, imageRefs);
400
+ });
401
+
402
+ async function _imageRefToDataUrl(ref) {
403
+ try {
404
+ const fh = await resolveBoardFile(ref.boardHandle, ref.file);
405
+ const file = await fh.getFile();
406
+ return await new Promise((res, rej) => {
407
+ const r = new FileReader();
408
+ r.onload = () => res(r.result);
409
+ r.onerror = () => rej(r.error);
410
+ r.readAsDataURL(file);
411
+ });
412
+ } catch (e) { return null; }
413
+ }
414
+
415
+ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
416
+ state.jobs.set(node.id, { boardKey: bKey, kind: 'text', nodeId: node.id });
417
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
418
+ logJob(node.id, `text-gen start model=${model} prompt="${(prompt||'').slice(0,80)}" images=${imageRefs?.length||0}`);
419
+ try {
420
+ const images = [];
421
+ for (const ref of (imageRefs || [])) {
422
+ const url = await _imageRefToDataUrl(ref);
423
+ if (url) images.push({ name: ref.name, url });
424
+ }
425
+ const provider = await plannedProvider('text');
426
+ logJob(node.id, `→ POST /api/text → ${provider} (model=${model})`);
427
+ const r = await fetch('/api/text', {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({ prompt, model, images }),
431
+ });
432
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
433
+ const data = await r.json();
434
+ if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);
435
+ const text = (data.text || '').trim();
436
+ if (!text) throw new Error('пустой ответ модели');
437
+ // Записываем в .md и обновляем ноду.
438
+ await writeBoardFile(boardHandle, node.file, text);
439
+ await mutateNode(bKey, boardHandle, node.id, n => {
440
+ n.text = text;
441
+ n.status = undefined;
442
+ n.generated = {
443
+ ...(n.generated || {}),
444
+ prompt, model: data.model || model, state: 'success',
445
+ ...(typeof data.cost === 'number' ? { creditsCharged: data.cost } : {}),
446
+ };
447
+ });
448
+ if (typeof data.cost === 'number') logJob(node.id, `списано ${data.cost} credits`);
449
+ logJob(node.id, `text-gen done (${text.length} chars)`);
450
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
451
+ } catch (e) {
452
+ logJob(node.id, `text-gen failed: ${e?.message || e}`);
453
+ await mutateNode(bKey, boardHandle, node.id, n => {
454
+ n.status = 'error';
455
+ n.error = e?.message || String(e);
456
+ });
457
+ } finally {
458
+ state.jobs.delete(node.id);
459
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
460
+ }
461
+ }
462
+
463
+ // === SFX (ElevenLabs Sound Effects) ===
464
+ $('genSfx').addEventListener('click', async () => {
465
+ if (!state.currentBoard) return;
466
+ if (!await ensureApiKey('audio')) return;
467
+ $('sfxPrompt').value = '';
468
+ $('sfxDuration').value = '';
469
+ $('sfxStatus').textContent = ''; $('sfxStatus').className = 'status';
470
+ $('sfxSubmit').disabled = false;
471
+ $('sfxModal').classList.remove('hidden');
472
+ setTimeout(() => $('sfxPrompt').focus(), 30);
473
+ });
474
+ $('sfxCancel').addEventListener('click', () => $('sfxModal').classList.add('hidden'));
475
+ $('sfxSubmit').addEventListener('click', async () => {
476
+ const text = $('sfxPrompt').value.trim();
477
+ if (!text) { $('sfxPrompt').focus(); return; }
478
+ const durRaw = $('sfxDuration').value.trim();
479
+ const durationSeconds = durRaw ? parseFloat(durRaw) : null;
480
+ $('sfxSubmit').disabled = true;
481
+ const status = $('sfxStatus');
482
+ status.className = 'status'; status.textContent = 'Генерация…';
483
+ $('sfxModal').classList.add('hidden');
484
+ // Regenerate существующей ноды — обновляем её, не создаём новую.
485
+ if (state.sfxRegenerateTarget) {
486
+ const node = state.sfxRegenerateTarget;
487
+ state.sfxRegenerateTarget = null;
488
+ await mutateNode(state.currentBoard.key, state.currentBoard.handle, node.id, n => {
489
+ n.status = 'generating';
490
+ n.error = undefined;
491
+ n.generated = { ...(n.generated || {}), kind: 'audio', subKind: 'sfx', prompt: text, durationSeconds, model: 'eleven-sfx', state: 'submitting' };
492
+ });
493
+ runSfxJob(node, text, durationSeconds, state.currentBoard.handle, state.currentBoard.key);
494
+ return;
495
+ }
496
+ // Новая нода
497
+ const spot = (state.addMenuPos
498
+ ? { x: (state.addMenuPos.clientX - canvas.getBoundingClientRect().left) / state.zoom,
499
+ y: (state.addMenuPos.clientY - canvas.getBoundingClientRect().top) / state.zoom }
500
+ : findFreeSpot());
501
+ state.addMenuPos = null;
502
+ const node = {
503
+ id: crypto.randomUUID(), type: 'audio',
504
+ name: uniqueNodeName(state.currentBoard.metadata.nodes, slugifyPrompt(text) || 'sfx'),
505
+ x: spot.x, y: spot.y, status: 'generating',
506
+ generated: { kind: 'audio', subKind: 'sfx', prompt: text, durationSeconds, model: 'eleven-sfx', state: 'submitting' },
507
+ };
508
+ state.currentBoard.metadata.nodes.push(node);
509
+ canvas.appendChild(await createNodeEl(node));
510
+ scheduleSave();
511
+ runSfxJob(node, text, durationSeconds, state.currentBoard.handle, state.currentBoard.key);
512
+ });
513
+
514
+ // Если в тексте кириллица — переводим в английский через OpenRouter,
515
+ // иначе ElevenLabs SFX endpoint делает TTS-fallback вместо звукового
516
+ // эффекта. Тихо, без блокировки UI; на ошибке оставляем оригинал.
517
+ async function _translateForSfx(text) {
518
+ if (!/[А-Яа-яЁё]/.test(text)) return text; // уже латиница
519
+ if (!window.appSettings) return text;
520
+ const s = await window.appSettings.get();
521
+ if (!s.openrouterKey) return text;
522
+ try {
523
+ const r = await fetch('/api/text', {
524
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
525
+ body: JSON.stringify({
526
+ prompt: text,
527
+ model: 'anthropic/claude-haiku-4',
528
+ system: 'You translate descriptions of sound effects from any language into short, vivid English suitable for a sound-effects generation API. Reply with translation ONLY, no quotes, no explanations. Keep it under 150 characters.',
529
+ }),
530
+ });
531
+ const data = await r.json();
532
+ if (r.ok && data.text) return data.text.trim();
533
+ } catch (e) { /* fallthrough */ }
534
+ return text;
535
+ }
536
+
537
+ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
538
+ state.jobs.set(node.id, { boardKey: bKey, kind: 'audio', nodeId: node.id });
539
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
540
+ // Защита: если boardHandle потерян — пробуем взять текущий, иначе валим
541
+ // с понятным сообщением до запуска генерации.
542
+ if (!boardHandle || typeof boardHandle.getDirectoryHandle !== 'function') {
543
+ boardHandle = state.currentBoard?.handle;
544
+ }
545
+ if (!boardHandle || typeof boardHandle.getDirectoryHandle !== 'function') {
546
+ const err = 'нет доступа к папке проекта (закрой и открой проект заново)';
547
+ logJob(node.id, `sfx aborted: ${err}`);
548
+ await mutateNode(bKey, boardHandle, node.id, n => { n.status = 'error'; n.error = err; }).catch(() => {});
549
+ state.jobs.delete(node.id);
550
+ return;
551
+ }
552
+ logJob(node.id, `sfx start text="${(text||'').slice(0,80)}" dur=${durationSeconds||'auto'} board=${boardHandle.name||'?'}`);
553
+ try {
554
+ const enText = await _translateForSfx(text);
555
+ if (enText !== text) {
556
+ logJob(node.id, `sfx translated → "${enText.slice(0,100)}"`);
557
+ await mutateNode(bKey, boardHandle, node.id, n => {
558
+ n.generated = { ...(n.generated || {}), translatedPrompt: enText };
559
+ });
560
+ }
561
+ const provider = await plannedProvider('sfx');
562
+ logJob(node.id, `→ POST /api/sfx → ${provider} (dur=${durationSeconds || '-'}s)`);
563
+ const r = await fetch('/api/sfx', {
564
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
565
+ body: JSON.stringify({ text: enText, durationSeconds }),
566
+ });
567
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
568
+ if (!r.ok) throw new Error((await r.text()).slice(0, 300) || `HTTP ${r.status}`);
569
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
570
+ const blob = await r.blob();
571
+ const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
572
+ const baseName = await uniqueName(dir, `sfx_${Date.now()}.mp3`);
573
+ await writeFile(dir, baseName, blob);
574
+ const relPath = `audio/${baseName}`;
575
+ await mutateNode(bKey, boardHandle, node.id, n => {
576
+ n.file = relPath;
577
+ n.status = undefined;
578
+ n.generated = {
579
+ ...(n.generated || {}), state: 'success',
580
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
581
+ };
582
+ });
583
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
584
+ logJob(node.id, `sfx saved → ${relPath}`);
585
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
586
+ } catch (e) {
587
+ logJob(node.id, `sfx failed: ${e?.message || e}\n${e?.stack || ''}`);
588
+ await mutateNode(bKey, boardHandle, node.id, n => {
589
+ n.status = 'error'; n.error = e?.message || String(e);
590
+ });
591
+ } finally {
592
+ state.jobs.delete(node.id);
593
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
594
+ }
595
+ }
596
+
597
+ // === Music (ElevenLabs) ===
598
+ $('genMusic').addEventListener('click', async () => {
599
+ if (!state.currentBoard) return;
600
+ if (!await ensureApiKey('audio')) return;
601
+ $('musicPrompt').value = '';
602
+ $('musicDuration').value = '';
603
+ $('musicStatus').textContent = ''; $('musicStatus').className = 'status';
604
+ $('musicSubmit').disabled = false;
605
+ $('musicModal').classList.remove('hidden');
606
+ setTimeout(() => $('musicPrompt').focus(), 30);
607
+ });
608
+ $('musicCancel').addEventListener('click', () => $('musicModal').classList.add('hidden'));
609
+ $('musicSubmit').addEventListener('click', async () => {
610
+ const prompt = $('musicPrompt').value.trim();
611
+ if (!prompt) { $('musicPrompt').focus(); return; }
612
+ const durRaw = $('musicDuration').value.trim();
613
+ const durationMs = durRaw ? Math.round(parseFloat(durRaw) * 1000) : null;
614
+ $('musicSubmit').disabled = true;
615
+ const status = $('musicStatus');
616
+ status.className = 'status'; status.textContent = 'Генерация… (может занять до минуты)';
617
+ $('musicModal').classList.add('hidden');
618
+ if (state.musicRegenerateTarget) {
619
+ const node = state.musicRegenerateTarget;
620
+ state.musicRegenerateTarget = null;
621
+ await mutateNode(state.currentBoard.key, state.currentBoard.handle, node.id, n => {
622
+ n.status = 'generating';
623
+ n.error = undefined;
624
+ n.generated = { ...(n.generated || {}), kind: 'audio', subKind: 'music', prompt, durationMs, model: 'eleven-music', state: 'submitting' };
625
+ });
626
+ runMusicJob(node, prompt, durationMs, state.currentBoard.handle, state.currentBoard.key);
627
+ return;
628
+ }
629
+ const spot = (state.addMenuPos
630
+ ? { x: (state.addMenuPos.clientX - canvas.getBoundingClientRect().left) / state.zoom,
631
+ y: (state.addMenuPos.clientY - canvas.getBoundingClientRect().top) / state.zoom }
632
+ : findFreeSpot());
633
+ state.addMenuPos = null;
634
+ const node = {
635
+ id: crypto.randomUUID(), type: 'audio',
636
+ name: uniqueNodeName(state.currentBoard.metadata.nodes, slugifyPrompt(prompt) || 'music'),
637
+ x: spot.x, y: spot.y, status: 'generating',
638
+ generated: { kind: 'audio', subKind: 'music', prompt, durationMs, model: 'eleven-music', state: 'submitting' },
639
+ };
640
+ state.currentBoard.metadata.nodes.push(node);
641
+ canvas.appendChild(await createNodeEl(node));
642
+ scheduleSave();
643
+ runMusicJob(node, prompt, durationMs, state.currentBoard.handle, state.currentBoard.key);
644
+ });
645
+
646
+ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
647
+ state.jobs.set(node.id, { boardKey: bKey, kind: 'audio', nodeId: node.id });
648
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
649
+ if (!boardHandle || typeof boardHandle.getDirectoryHandle !== 'function') {
650
+ boardHandle = state.currentBoard?.handle;
651
+ }
652
+ if (!boardHandle || typeof boardHandle.getDirectoryHandle !== 'function') {
653
+ const err = 'нет доступа к папке проекта (закрой и открой проект заново)';
654
+ logJob(node.id, `music aborted: ${err}`);
655
+ await mutateNode(bKey, boardHandle, node.id, n => { n.status = 'error'; n.error = err; }).catch(() => {});
656
+ state.jobs.delete(node.id);
657
+ return;
658
+ }
659
+ logJob(node.id, `music start prompt="${(prompt||'').slice(0,80)}" dur=${durationMs||'default'} board=${boardHandle.name||'?'}`);
660
+ try {
661
+ const enPrompt = await _translateForSfx(prompt);
662
+ if (enPrompt !== prompt) {
663
+ logJob(node.id, `music translated → "${enPrompt.slice(0,100)}"`);
664
+ await mutateNode(bKey, boardHandle, node.id, n => {
665
+ n.generated = { ...(n.generated || {}), translatedPrompt: enPrompt };
666
+ });
667
+ }
668
+ const provider = await plannedProvider('music');
669
+ logJob(node.id, `→ POST /api/music → ${provider} (dur=${durationMs ? durationMs/1000 + 's' : '-'})`);
670
+ const r = await fetch('/api/music', {
671
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
672
+ body: JSON.stringify({ prompt: enPrompt, durationMs }),
673
+ });
674
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
675
+ if (!r.ok) throw new Error((await r.text()).slice(0, 300) || `HTTP ${r.status}`);
676
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
677
+ const blob = await r.blob();
678
+ const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
679
+ const baseName = await uniqueName(dir, `music_${Date.now()}.mp3`);
680
+ await writeFile(dir, baseName, blob);
681
+ const relPath = `audio/${baseName}`;
682
+ await mutateNode(bKey, boardHandle, node.id, n => {
683
+ n.file = relPath;
684
+ n.status = undefined;
685
+ n.generated = {
686
+ ...(n.generated || {}), state: 'success',
687
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
688
+ };
689
+ });
690
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
691
+ logJob(node.id, `music saved → ${relPath}`);
692
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
693
+ } catch (e) {
694
+ logJob(node.id, `music failed: ${e?.message || e}`);
695
+ await mutateNode(bKey, boardHandle, node.id, n => {
696
+ n.status = 'error'; n.error = e?.message || String(e);
697
+ });
698
+ } finally {
699
+ state.jobs.delete(node.id);
700
+ if (typeof updateJobsBadge === 'function') updateJobsBadge();
701
+ }
702
+ }
703
+
704
+ $('textGenModal').addEventListener('keydown', e => {
705
+ // Esc обрабатывает глобальный handler. Cmd+Enter — отправить.
706
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
707
+ e.preventDefault();
708
+ $('textGenSubmit').click();
709
+ }
710
+ });
711
+
712
+ // Переключатель типа (картинка/видео)
713
+ document.querySelectorAll('#genModal [data-kind]').forEach(b => {
714
+ b.addEventListener('click', () => {
715
+ document.querySelectorAll('#genModal [data-kind]').forEach(x => x.classList.remove('active'));
716
+ b.classList.add('active');
717
+ state.genKind = b.dataset.kind;
718
+ $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
719
+
720
+ $('imageOptionsRow').style.display = state.genKind === 'image' ? '' : 'none';
721
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
722
+
723
+ $('videoModelRow').style.display = state.genKind === 'video' ? '' : 'none';
724
+ $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
725
+
726
+ $('ttsModelRow').style.display = state.genKind === 'audio' ? '' : 'none';
727
+ $('tonesRow').style.display = state.genKind === 'audio' ? '' : 'none';
728
+ if (state.genKind === 'audio') {
729
+ syncTtsModelActive();
730
+ if (state.ttsModel === 'elevenlabs/v3') loadVoices();
731
+ renderTones();
732
+ }
733
+ const ph = state.genKind === 'audio'
734
+ ? 'Текст, который надо озвучить...'
735
+ : 'Что должно быть. Печатай @ чтобы вставить ссылку на ноду...';
736
+ $('genPrompt').setAttribute('placeholder', ph);
737
+ syncSourceRefRow();
738
+ syncCharLocRows();
739
+ });
740
+ });
741
+
742
+ // === Source frame controls ===
743
+ function syncSourceRefRow() {
744
+ const showable = state.sourceRef && (state.genKind === 'image' || state.genKind === 'video');
745
+ $('sourceRefRow').style.display = showable ? '' : 'none';
746
+ }
747
+
748
+ // === Персонажи + Локация: чипы/дропдаун ===
749
+ function renderCharsPickChips() {
750
+ const wrap = $('charsPickChips');
751
+ wrap.innerHTML = '';
752
+ for (const c of state.charactersInfo) {
753
+ if (!c.characterSheet) continue; // без sheet не из чего брать референс
754
+ const chip = document.createElement('div');
755
+ chip.className = 'picker-chip';
756
+ if (state.pickedCharNames.has(c.name)) chip.classList.add('active');
757
+ const dot = document.createElement('span'); dot.className = 'dot';
758
+ chip.appendChild(dot);
759
+ chip.appendChild(document.createTextNode(c.name));
760
+ chip.addEventListener('click', () => {
761
+ if (state.pickedCharNames.has(c.name)) state.pickedCharNames.delete(c.name);
762
+ else state.pickedCharNames.add(c.name);
763
+ chip.classList.toggle('active');
764
+ });
765
+ wrap.appendChild(chip);
766
+ }
767
+ }
768
+
769
+ function renderLocPickSelect() {
770
+ const sel = $('locPickSelect');
771
+ sel.innerHTML = '<option value="">— не выбрана —</option>';
772
+ for (const l of state.locationsInfo) {
773
+ if (!l.sheet) continue;
774
+ const opt = document.createElement('option');
775
+ opt.value = l.name;
776
+ opt.textContent = l.name;
777
+ sel.appendChild(opt);
778
+ }
779
+ sel.value = state.pickedLocName || '';
780
+ sel.onchange = () => { state.pickedLocName = sel.value || null; };
781
+ }
782
+
783
+ function syncCharLocRows() {
784
+ const showPicker = (state.genKind === 'image' || state.genKind === 'video');
785
+ $('charsPickRow').style.display = showPicker ? '' : 'none';
786
+ $('locPickRow').style.display = showPicker ? '' : 'none';
787
+ }
788
+
789
+ // Авто-предвыбор: если открываем доску персонажа/локации — пометим её сразу
790
+ function presetPicksForBoard() {
791
+ const b = state.currentBoard;
792
+ if (b?.kind === 'character' && b.name) state.pickedCharNames.add(b.name);
793
+ if (b?.kind === 'location' && b.name) state.pickedLocName = b.name;
794
+ }
795
+
796
+ // Собрать рефы из выбранных персонажей и локации (sheet'ы)
797
+ function gatherPickedSheetRefs() {
798
+ const refs = [];
799
+ for (const name of state.pickedCharNames) {
800
+ const c = state.charactersInfo.find(x => x.name === name);
801
+ if (!c || !c.characterSheet) continue;
802
+ refs.push({
803
+ key: `char:${c.name}`,
804
+ name: c.name,
805
+ type: 'image',
806
+ file: c.characterSheet,
807
+ boardHandle: c.handle,
808
+ charName: c.name,
809
+ });
810
+ }
811
+ if (state.pickedLocName) {
812
+ const l = state.locationsInfo.find(x => x.name === state.pickedLocName);
813
+ if (l && l.sheet) {
814
+ refs.push({
815
+ key: `loc:${l.name}`,
816
+ name: l.name,
817
+ type: 'image',
818
+ file: l.sheet,
819
+ boardHandle: l.handle,
820
+ });
821
+ }
822
+ }
823
+ return refs;
824
+ }
825
+
826
+ function resetPicks() {
827
+ state.pickedCharNames.clear();
828
+ state.pickedLocName = null;
829
+ }
830
+ function applySourceRefVisuals() {
831
+ if (!state.sourceRef) return;
832
+ const box = $('sourceRefBox');
833
+ const btn = $('sourceRefToggle');
834
+ box.classList.toggle('disabled', !state.sourceRef.use);
835
+ btn.textContent = state.sourceRef.use ? 'Отключить' : 'Включить';
836
+ btn.title = state.sourceRef.use ? 'Не использовать в этой генерации' : 'Использовать как референс';
837
+ }
838
+ $('sourceRefToggle').addEventListener('click', () => {
839
+ if (!state.sourceRef) return;
840
+ state.sourceRef.use = !state.sourceRef.use;
841
+ applySourceRefVisuals();
842
+ });
843
+
844
+ // =================== Тоны (audio gen) ===================
845
+ function renderTones() {
846
+ const tags = $('tonesTags');
847
+ tags.innerHTML = '';
848
+ for (const t of state.activeTones) {
849
+ const chip = document.createElement('span');
850
+ chip.className = 'chip';
851
+ chip.appendChild(document.createTextNode(t));
852
+ const x = document.createElement('button');
853
+ x.className = 'x'; x.type = 'button'; x.textContent = '×';
854
+ x.addEventListener('click', e => { e.stopPropagation(); removeTone(t); });
855
+ chip.appendChild(x);
856
+ tags.appendChild(chip);
857
+ }
858
+ // Частые тоны персонажа — кликабельные «ghost»-чипы для быстрого выбора (множественный выбор)
859
+ const suggestions = (state.toneSuggestions || []).filter(t => !state.activeTones.includes(t));
860
+ for (const t of suggestions) {
861
+ const chip = document.createElement('button');
862
+ chip.type = 'button';
863
+ chip.className = 'chip ghost';
864
+ chip.title = 'Кликни, чтобы добавить тон';
865
+ chip.textContent = '+ ' + t;
866
+ // mousedown срабатывает раньше любого глобального click-listener'а
867
+ chip.addEventListener('mousedown', e => {
868
+ e.preventDefault();
869
+ e.stopPropagation();
870
+ addTone(t);
871
+ });
872
+ tags.appendChild(chip);
873
+ }
874
+ }
875
+ function addTone(t) {
876
+ t = (t || '').trim();
877
+ if (!t) return;
878
+ if (!Array.isArray(state.activeTones)) state.activeTones = [];
879
+ if (!state.activeTones.includes(t)) state.activeTones.push(t);
880
+ renderTones();
881
+ }
882
+ function removeTone(t) {
883
+ state.activeTones = state.activeTones.filter(x => x !== t);
884
+ renderTones();
885
+ }
886
+ function updateToneSuggest() {
887
+ const inp = $('tonesInput');
888
+ const q = inp.value.trim().toLowerCase();
889
+ const pool = [...new Set([...(state.toneSuggestions || []), ...DEFAULT_TONES])];
890
+ const opts = pool
891
+ .filter(t => !state.activeTones.includes(t))
892
+ .filter(t => !q || t.toLowerCase().includes(q))
893
+ .slice(0, 12);
894
+ const box = $('tonesSuggest');
895
+ box.innerHTML = '';
896
+ if (!opts.length) { box.classList.add('hidden'); return; }
897
+ opts.forEach((t, i) => {
898
+ const o = document.createElement('div');
899
+ o.className = 'opt' + (i === 0 ? ' sel' : '');
900
+ o.textContent = t;
901
+ o.addEventListener('mousedown', e => { e.preventDefault(); addTone(t); inp.value = ''; box.classList.add('hidden'); });
902
+ box.appendChild(o);
903
+ });
904
+ box.classList.remove('hidden');
905
+ }
906
+ $('tonesInput').addEventListener('input', updateToneSuggest);
907
+ $('tonesInput').addEventListener('focus', updateToneSuggest);
908
+ $('tonesInput').addEventListener('keydown', e => {
909
+ const box = $('tonesSuggest');
910
+ const opts = [...box.querySelectorAll('.opt')];
911
+ if (e.key === 'Enter' || e.key === ',') {
912
+ e.preventDefault();
913
+ const sel = box.querySelector('.opt.sel')?.textContent;
914
+ const val = sel || $('tonesInput').value.trim();
915
+ if (val) { addTone(val); $('tonesInput').value = ''; box.classList.add('hidden'); }
916
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
917
+ if (!opts.length) return;
918
+ e.preventDefault();
919
+ const cur = opts.findIndex(o => o.classList.contains('sel'));
920
+ const next = e.key === 'ArrowDown' ? Math.min(cur + 1, opts.length - 1) : Math.max(cur - 1, 0);
921
+ opts.forEach(o => o.classList.remove('sel'));
922
+ opts[next].classList.add('sel');
923
+ opts[next].scrollIntoView({ block: 'nearest' });
924
+ } else if (e.key === 'Escape') {
925
+ box.classList.add('hidden');
926
+ }
927
+ });
928
+ document.addEventListener('mousedown', e => {
929
+ if (!e.target.closest('#tonesRow')) $('tonesSuggest').classList.add('hidden');
930
+ });
931
+
932
+ let voicesLoaded = false;
933
+ const VOICES_CACHE_KEY = 'voicesCache';
934
+ const VOICES_CACHE_TTL = 24 * 60 * 60 * 1000;
935
+
936
+ // ElevenLabs v3 в KingKont/Chatium ждёт voice СТРОГО из этого enum'а.
937
+ // (см. server-side validation: ValidationError: voice: invalid enum value).
938
+ // Это не воссы из ElevenLabs API, а hardcoded набор поддерживаемых @start/sdk.
939
+ const ELEVENLABS_V3_VOICES = [
940
+ 'Rachel', 'Drew', 'Clyde', 'Paul', 'Aria', 'Domi', 'Dave', 'Roger',
941
+ 'Fin', 'Sarah', 'James', 'Jane', 'Juniper', 'Arabella', 'Hope',
942
+ 'Bradford', 'Reginald', 'Gaming', 'Austin', 'Kuon', 'Blondie',
943
+ 'Priyanka', 'Alexandra', 'Monika', 'Mark', 'Grimblewood',
944
+ ];
945
+
946
+ function populateVoicesSelect(voices, useEnum) {
947
+ const select = $('genVoice');
948
+ select.innerHTML = '';
949
+ // Через KingKont elevenlabs/v3 принимает только hardcoded enum 26 имён.
950
+ // Через прямой ElevenLabs — полный список из его API.
951
+ if (useEnum) {
952
+ for (const name of ELEVENLABS_V3_VOICES) {
953
+ const opt = document.createElement('option');
954
+ opt.value = name; // value = name (для KingKont)
955
+ opt.dataset.voiceName = name;
956
+ opt.textContent = name;
957
+ select.appendChild(opt);
958
+ }
959
+ const last = localStorage.getItem('lastElevenV3Voice');
960
+ if (last && ELEVENLABS_V3_VOICES.includes(last)) select.value = last;
961
+ return;
962
+ }
963
+ voices.sort((a, b) => a.name.localeCompare(b.name));
964
+ for (const v of voices) {
965
+ const opt = document.createElement('option');
966
+ opt.value = v.id;
967
+ opt.dataset.voiceName = v.name;
968
+ const lbl = v.labels || {};
969
+ const tags = [lbl.gender, lbl.language || lbl.accent, lbl.age].filter(Boolean).join(' / ');
970
+ opt.textContent = tags ? `${v.name} — ${tags}` : v.name;
971
+ select.appendChild(opt);
972
+ }
973
+ const last = localStorage.getItem('lastVoiceId');
974
+ if (last && voices.some(v => v.id === last)) select.value = last;
975
+ }
976
+
977
+ async function loadVoices() {
978
+ if (voicesLoaded) return;
979
+ // 1) Подхватываем кэш из localStorage — мгновенно, без сети
980
+ let cached = null;
981
+ try {
982
+ const raw = localStorage.getItem(VOICES_CACHE_KEY);
983
+ if (raw) cached = JSON.parse(raw);
984
+ } catch {}
985
+ if (cached?.voices?.length) {
986
+ populateVoicesSelect(cached.voices, !!state._ttsUseEnum);
987
+ voicesLoaded = true;
988
+ }
989
+ // 2) Если кэш свежий — выходим
990
+ if (cached?.ts && Date.now() - cached.ts < VOICES_CACHE_TTL) return;
991
+ // 3) Иначе обновляем (или грузим впервые)
992
+ try {
993
+ const r = await fetch('/api/voices');
994
+ if (!r.ok) return;
995
+ const { voices } = await r.json();
996
+ if (!voices?.length) return;
997
+ localStorage.setItem(VOICES_CACHE_KEY, JSON.stringify({ ts: Date.now(), voices }));
998
+ populateVoicesSelect(voices, !!state._ttsUseEnum);
999
+ voicesLoaded = true;
1000
+ } catch (e) { console.error('voices load failed', e); }
1001
+ }
1002
+ // Переключатель модели картинки
1003
+ document.querySelectorAll('#genModal [data-img-model]').forEach(b => {
1004
+ b.addEventListener('click', () => {
1005
+ document.querySelectorAll('#genModal [data-img-model]').forEach(x => x.classList.remove('active'));
1006
+ b.classList.add('active');
1007
+ state.imageModel = b.dataset.imgModel;
1008
+ });
1009
+ });
1010
+ // Переключатель модели видео
1011
+ document.querySelectorAll('#genModal [data-vid-model]').forEach(b => {
1012
+ b.addEventListener('click', () => {
1013
+ document.querySelectorAll('#genModal [data-vid-model]').forEach(x => x.classList.remove('active'));
1014
+ b.classList.add('active');
1015
+ state.videoModel = b.dataset.vidModel;
1016
+ localStorage.setItem('videoModel', state.videoModel);
1017
+ });
1018
+ });
1019
+ // Переключатель aspect ratio для image
1020
+ document.querySelectorAll('#genModal [data-img-asp]').forEach(b => {
1021
+ b.addEventListener('click', () => {
1022
+ document.querySelectorAll('#genModal [data-img-asp]').forEach(x => x.classList.remove('active'));
1023
+ b.classList.add('active');
1024
+ state.imageAspect = b.dataset.imgAsp;
1025
+ localStorage.setItem('imageAspect', state.imageAspect);
1026
+ });
1027
+ });
1028
+ function syncImageAspectActive() {
1029
+ document.querySelectorAll('#genModal [data-img-asp]').forEach(b =>
1030
+ b.classList.toggle('active', b.dataset.imgAsp === state.imageAspect));
1031
+ }
1032
+ syncImageAspectActive();
1033
+
1034
+ // Переключатель модели TTS
1035
+ document.querySelectorAll('#genModal [data-tts-model]').forEach(b => {
1036
+ b.addEventListener('click', () => {
1037
+ document.querySelectorAll('#genModal [data-tts-model]').forEach(x => x.classList.remove('active'));
1038
+ b.classList.add('active');
1039
+ state.ttsModel = b.dataset.ttsModel;
1040
+ localStorage.setItem('ttsModel', state.ttsModel);
1041
+ syncTtsVoiceList();
1042
+ });
1043
+ });
1044
+ async function syncTtsVoiceList() {
1045
+ const showVoice = state.ttsModel === 'elevenlabs/v3';
1046
+ $('voiceRow').style.display = showVoice ? '' : 'none';
1047
+ if (!showVoice) return;
1048
+ // Через KingKont — hardcoded enum (validation требует одно из 26 имён).
1049
+ // Через прямой ElevenLabs — полный список из ElevenLabs API.
1050
+ const provider = await plannedProvider('tts');
1051
+ const useEnum = provider === 'kingkont';
1052
+ state._ttsUseEnum = useEnum;
1053
+ if (useEnum) {
1054
+ populateVoicesSelect([], true);
1055
+ } else {
1056
+ // Сбрасываем кэш-флаг и подгружаем свежий список (loadVoices
1057
+ // переиспользует populateVoicesSelect внутри).
1058
+ voicesLoaded = false;
1059
+ await loadVoices();
1060
+ }
1061
+ }
1062
+ function syncTtsModelActive() {
1063
+ document.querySelectorAll('#genModal [data-tts-model]').forEach(b =>
1064
+ b.classList.toggle('active', b.dataset.ttsModel === state.ttsModel));
1065
+ syncTtsVoiceList();
1066
+ }
1067
+ // Подсветить активную video-модель при открытии modal'а
1068
+ function syncVideoModelActive() {
1069
+ document.querySelectorAll('#genModal [data-vid-model]').forEach(b =>
1070
+ b.classList.toggle('active', b.dataset.vidModel === state.videoModel));
1071
+ }
1072
+ // Переключатели длительности и разрешения для видео
1073
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(b => {
1074
+ b.addEventListener('click', () => {
1075
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(x => x.classList.remove('active'));
1076
+ b.classList.add('active');
1077
+ state.videoDuration = +b.dataset.vidDur;
1078
+ localStorage.setItem('videoDuration', String(state.videoDuration));
1079
+ });
1080
+ });
1081
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(b => {
1082
+ b.addEventListener('click', () => {
1083
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(x => x.classList.remove('active'));
1084
+ b.classList.add('active');
1085
+ state.videoResolution = b.dataset.vidRes;
1086
+ localStorage.setItem('videoResolution', state.videoResolution);
1087
+ });
1088
+ });
1089
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(b => {
1090
+ b.addEventListener('click', () => {
1091
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(x => x.classList.remove('active'));
1092
+ b.classList.add('active');
1093
+ state.videoAspect = b.dataset.vidAsp;
1094
+ localStorage.setItem('videoAspect', state.videoAspect);
1095
+ });
1096
+ });
1097
+ // Helper: подсветить активные кнопки duration/resolution/aspect согласно state.
1098
+ // Вызывать при открытии любой формы где видны videoOptions.
1099
+ function syncVideoOptionsActive() {
1100
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(b =>
1101
+ b.classList.toggle('active', +b.dataset.vidDur === state.videoDuration));
1102
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(b =>
1103
+ b.classList.toggle('active', b.dataset.vidRes === state.videoResolution));
1104
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(b =>
1105
+ b.classList.toggle('active', b.dataset.vidAsp === state.videoAspect));
1106
+ }
1107
+ syncVideoOptionsActive();
1108
+ syncVideoModelActive();
1109
+
1110
+ $('genCancel').addEventListener('click', () => {
1111
+ state.pendingConnectionFrom = null;
1112
+ state.regenerateTarget = null;
1113
+ state.sourceRef = null;
1114
+ state.timelineGenTarget = null;
1115
+ resetPicks();
1116
+ $('sourceRefRow').style.display = 'none';
1117
+ $('charsPickRow').style.display = 'none';
1118
+ $('locPickRow').style.display = 'none';
1119
+ $('genModal').classList.add('hidden');
1120
+ });
1121
+
1122
+ // «Сохранить» = тот же handler с флагом — нода создаётся как draft, без job.
1123
+ $('genSave').addEventListener('click', () => {
1124
+ state.genSaveOnly = true;
1125
+ $('genSubmit').click();
1126
+ });
1127
+
1128
+ $('genSubmit').addEventListener('click', async () => {
1129
+ const saveOnly = state.genSaveOnly === true;
1130
+ state.genSaveOnly = false;
1131
+ const rawPrompt = $('genPrompt').value.trim();
1132
+ if (!rawPrompt) { alert('Введи описание'); return; }
1133
+ const kind = state.genKind;
1134
+
1135
+ // ===== Regenerate существующей ноды — с возможно изменённым промптом/настройками =====
1136
+ if (state.regenerateTarget) {
1137
+ const target = state.regenerateTarget;
1138
+ const sourceRef = state.sourceRef && state.sourceRef.use ? { ...state.sourceRef } : null;
1139
+ const pickedSheets = gatherPickedSheetRefs();
1140
+ state.regenerateTarget = null;
1141
+ state.sourceRef = null;
1142
+ resetPicks();
1143
+ $('sourceRefRow').style.display = 'none';
1144
+ $('charsPickRow').style.display = 'none';
1145
+ $('locPickRow').style.display = 'none';
1146
+ $('genModal').classList.add('hidden');
1147
+ if (saveOnly) {
1148
+ // Сохраняем правки промпта без запуска генерации.
1149
+ target.generated = { ...(target.generated || {}), rawPrompt, kind };
1150
+ scheduleSave();
1151
+ return;
1152
+ }
1153
+ await regenerateInto(target, kind, rawPrompt, { sourceRef, pickedSheets });
1154
+ return;
1155
+ }
1156
+
1157
+ // ===== Audio (ElevenLabs TTS v3) — отдельный путь, без поллинга =====
1158
+ if (kind === 'audio') {
1159
+ const voiceId = $('genVoice').value || 'JBFqnCBsd6RMkjVDRZzb';
1160
+ const voiceName = $('genVoice').selectedOptions[0]?.textContent || '';
1161
+ if (voiceId) {
1162
+ // Для ElevenLabs v3 (через KingKont) value === name из enum.
1163
+ // Сохраняем в отдельный ключ, иначе при переключении модели
1164
+ // подставится UUID-id и select не выберет его.
1165
+ if (state.ttsModel === 'elevenlabs/v3') {
1166
+ localStorage.setItem('lastElevenV3Voice', voiceId);
1167
+ } else {
1168
+ localStorage.setItem('lastVoiceId', voiceId);
1169
+ }
1170
+ }
1171
+ // Резолвим @-mentions ДО добавления tone-prefix: text-ноды инлайнятся
1172
+ // в их .md-содержимое, иначе ElevenLabs получит «[@name]» → пусто после
1173
+ // strip-spec-tags и упадёт «empty text».
1174
+ const resolvedRaw = (typeof resolveMentions === 'function')
1175
+ ? resolveMentions(rawPrompt, []).trim()
1176
+ : rawPrompt;
1177
+ if (!resolvedRaw) {
1178
+ alert('После раскрытия @-ссылок текст пустой. Проверь содержимое исходных нод.');
1179
+ return;
1180
+ }
1181
+ // Тоны прикладываем как ElevenLabs v3 audio-tags перед текстом
1182
+ const tonePrefix = state.activeTones.map(t => `[${t}]`).join(' ');
1183
+ const finalText = tonePrefix ? `${tonePrefix} ${resolvedRaw}` : resolvedRaw;
1184
+ // Запоминаем имя персонажа ДО сброса timelineGenTarget — fallback на voiceId
1185
+ const charKey = state.timelineGenTarget?.charName || voiceId;
1186
+ const usedTones = [...state.activeTones];
1187
+ let spot;
1188
+ if (state.addMenuPos) {
1189
+ const rect = canvas.getBoundingClientRect();
1190
+ spot = {
1191
+ x: (state.addMenuPos.clientX - rect.left) / state.zoom,
1192
+ y: (state.addMenuPos.clientY - rect.top) / state.zoom,
1193
+ };
1194
+ state.addMenuPos = null;
1195
+ } else spot = findFreeSpot();
1196
+ const autoName = slugifyPrompt(rawPrompt);
1197
+ const node = {
1198
+ id: crypto.randomUUID(),
1199
+ type: 'audio',
1200
+ ...(autoName ? { name: uniqueNodeName(state.currentBoard.metadata.nodes, autoName) } : {}),
1201
+ x: spot.x, y: spot.y,
1202
+ status: 'generating',
1203
+ generated: {
1204
+ kind: 'audio',
1205
+ prompt: finalText, rawPrompt,
1206
+ model: 'eleven_v3', voiceId, voiceName,
1207
+ ttsModel: state.ttsModel || 'qwen/qwen3-tts',
1208
+ tones: [...state.activeTones],
1209
+ },
1210
+ };
1211
+ if (saveOnly) node.status = 'draft';
1212
+ state.currentBoard.metadata.nodes.push(node);
1213
+ canvas.appendChild(await createNodeEl(node));
1214
+ if (state.pendingConnectionFrom) {
1215
+ addConnection(state.pendingConnectionFrom, node.id);
1216
+ state.pendingConnectionFrom = null;
1217
+ }
1218
+ if (saveOnly) {
1219
+ scheduleSave();
1220
+ $('genModal').classList.add('hidden');
1221
+ state.timelineGenTarget = null;
1222
+ return;
1223
+ }
1224
+ // Если аудио генерится для конкретной точки таймлайна — добавляем placeholder-клип
1225
+ if (state.timelineGenTarget) {
1226
+ const tl = getTimeline();
1227
+ const target = tl?.tracks.find(t => t.id === state.timelineGenTarget.trackId && t.kind === 'audio');
1228
+ if (target) {
1229
+ target.clips.push({
1230
+ id: crypto.randomUUID(),
1231
+ nodeId: node.id,
1232
+ type: 'audio',
1233
+ file: '', // подхватится из node.file после TTS через syncTimelineClipsForNode
1234
+ name: node.name || 'replica',
1235
+ duration: 5,
1236
+ start: state.timelineGenTarget.time,
1237
+ });
1238
+ }
1239
+ state.timelineGenTarget = null;
1240
+ }
1241
+ // Запомним использованные тоны для этого персонажа (дополним commonTones, сохраним lastTones)
1242
+ rememberCharTones(charKey, usedTones).catch(() => {});
1243
+ scheduleSave();
1244
+ $('genModal').classList.add('hidden');
1245
+ if (!$('timelinePanel').classList.contains('hidden')) renderTimeline();
1246
+ runTTSJob(node, finalText, state.currentBoard.handle, state.currentBoard.key, voiceId);
1247
+ return;
1248
+ }
1249
+
1250
+ const mediaRefs = gatherMediaRefs(rawPrompt);
1251
+ // "Исходный кадр" — добавляем как первый референс, если включён.
1252
+ // Заодно запоминаем savedSourceRef для записи в node.generated, чтобы
1253
+ // связь с родительской нодой пережила regenerate (см. regenerateNode —
1254
+ // там при следующем открытии modal sourceRef восстанавливается из
1255
+ // node.generated.sourceRef).
1256
+ let sourceMarker = '';
1257
+ let savedSourceRef = null;
1258
+ if (state.sourceRef && state.sourceRef.use && state.sourceRef.file) {
1259
+ const sr = state.sourceRef;
1260
+ savedSourceRef = { file: sr.file, type: sr.type };
1261
+ const dup = mediaRefs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
1262
+ if (!dup) {
1263
+ const refType = sr.type === 'video' ? 'video' : 'image';
1264
+ mediaRefs.unshift({
1265
+ key: '__source__',
1266
+ name: 'исходный кадр',
1267
+ type: refType,
1268
+ file: sr.file,
1269
+ boardHandle: sr.boardHandle,
1270
+ });
1271
+ sourceMarker = refType === 'video' ? '[video 1] ' : '[image 1] ';
1272
+ }
1273
+ }
1274
+ // Sheet'ы выбранных персонажей и локации
1275
+ for (const pr of gatherPickedSheetRefs()) {
1276
+ if (mediaRefs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
1277
+ mediaRefs.push(pr);
1278
+ }
1279
+ const missing = mediaRefs.filter(r => !r.file || r.status === 'generating');
1280
+ if (missing.length) {
1281
+ alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + m.name).join(', '));
1282
+ return;
1283
+ }
1284
+ let resolvedPrompt = resolveMentions(rawPrompt, mediaRefs);
1285
+ if (sourceMarker) resolvedPrompt = sourceMarker + resolvedPrompt;
1286
+ // Сбрасываем sourceRef и пикеры после использования
1287
+ state.sourceRef = null;
1288
+ resetPicks();
1289
+ $('sourceRefRow').style.display = 'none';
1290
+ $('charsPickRow').style.display = 'none';
1291
+ $('locPickRow').style.display = 'none';
1292
+
1293
+ // Создать pending-ноду сразу. Если был dblclick / drag-line — там, иначе в свободном месте
1294
+ let spot;
1295
+ if (state.addMenuPos) {
1296
+ const rect = canvas.getBoundingClientRect();
1297
+ spot = {
1298
+ x: (state.addMenuPos.clientX - rect.left) / state.zoom,
1299
+ y: (state.addMenuPos.clientY - rect.top) / state.zoom,
1300
+ };
1301
+ state.addMenuPos = null;
1302
+ } else {
1303
+ spot = findFreeSpot();
1304
+ }
1305
+ const node = {
1306
+ id: crypto.randomUUID(),
1307
+ type: kind === 'image' ? 'image' : 'video',
1308
+ x: spot.x,
1309
+ y: spot.y,
1310
+ status: 'generating',
1311
+ generated: {
1312
+ kind,
1313
+ prompt: resolvedPrompt,
1314
+ rawPrompt,
1315
+ modelKey: kind === 'image' ? state.imageModel : (state.videoModel || 'seedance-2'),
1316
+ model: kind === 'image'
1317
+ ? ({
1318
+ 'grok': 'grok-imagine/text-to-image',
1319
+ 'seedream': 'seedream/4.5-text-to-image',
1320
+ 'seedream-5-lite': 'seedream/5-lite-text-to-image',
1321
+ 'nano-banana-2': 'nano-banana-2',
1322
+ }[state.imageModel] || 'nano-banana-2')
1323
+ : ({
1324
+ 'seedance-2': 'bytedance/seedance-2',
1325
+ 'seedance-2-fast': 'bytedance/seedance-2-fast',
1326
+ 'kling-o1': 'kwaivgi/kling-o1',
1327
+ 'kling-3.0': 'kling-3.0/video',
1328
+ }[state.videoModel || 'seedance-2'] || 'bytedance/seedance-2'),
1329
+ refs: mediaRefs.map(r => ({ name: r.name, type: r.type, file: r.file })),
1330
+ // Связь с родительской нодой (выведена через "вытягивание"). Хранится
1331
+ // даже если sourceRef.use=false в state — сохраняем структурную связь.
1332
+ ...(savedSourceRef ? { sourceRef: savedSourceRef } : {}),
1333
+ // Параметры видео/картинки сохраняем — при regenerate используем те же.
1334
+ ...(kind === 'video' ? {
1335
+ duration: state.videoDuration,
1336
+ resolution: state.videoResolution,
1337
+ aspectRatio: state.videoAspect,
1338
+ } : {}),
1339
+ ...(kind === 'image' ? {
1340
+ aspectRatio: state.imageAspect,
1341
+ } : {}),
1342
+ },
1343
+ };
1344
+ if (saveOnly) node.status = 'draft';
1345
+ state.currentBoard.metadata.nodes.push(node);
1346
+ canvas.appendChild(await createNodeEl(node));
1347
+
1348
+ // Если открывали через drag-line — фиксируем связь
1349
+ if (state.pendingConnectionFrom) {
1350
+ addConnection(state.pendingConnectionFrom, node.id);
1351
+ state.pendingConnectionFrom = null;
1352
+ }
1353
+
1354
+ scheduleSave();
1355
+
1356
+ $('genModal').classList.add('hidden');
1357
+ if (!saveOnly) {
1358
+ startGenerationJob(node, kind, resolvedPrompt, mediaRefs, state.currentBoard.handle, state.currentBoard.key, node.generated.modelKey);
1359
+ }
1360
+ });
1361
+
1362
+ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
1363
+ state.jobs.set(node.id, { boardKey: bKey, kind: 'audio', nodeId: node.id });
1364
+ updateJobsBadge();
1365
+ logJob(node.id, `tts start voice=${voiceId} text="${(text||'').slice(0,80)}"`);
1366
+ try {
1367
+ await mutateNode(bKey, boardHandle, node.id, n => {
1368
+ n.generated = { ...(n.generated || {}), state: 'submitting' };
1369
+ });
1370
+ const provider = await plannedProvider('tts');
1371
+ // ttsModel может быть сохранён в node.generated.ttsModel (при regenerate)
1372
+ // или в текущем глобальном state.ttsModel (новая генерация).
1373
+ const ttsModel = node.generated?.ttsModel || state.ttsModel || 'qwen/qwen3-tts';
1374
+ // ElevenLabs v3 в KingKont/Chatium ждёт voice как ИМЯ ('Rachel', 'Lara
1375
+ // Croft'), не voice_id. Резолвим имя через select option (data-voice-name).
1376
+ let voiceName = node.generated?.voiceName || null;
1377
+ if (!voiceName && voiceId) {
1378
+ const opt = document.querySelector(`#genVoice option[value="${voiceId}"]`);
1379
+ voiceName = opt?.dataset.voiceName || null;
1380
+ }
1381
+ const ttsBody = { text, voiceId, ttsModel };
1382
+ if (ttsModel === 'elevenlabs/v3' && voiceName) {
1383
+ ttsBody.voice = voiceName; // отправляем ИМЯ
1384
+ } else if (voiceId) {
1385
+ ttsBody.voice = voiceId;
1386
+ }
1387
+ logJob(node.id, `→ POST /api/tts → ${provider} (model=${ttsModel} voice=${ttsBody.voice || '—'})`);
1388
+ const r = await fetch('/api/tts', {
1389
+ method: 'POST',
1390
+ headers: { 'Content-Type': 'application/json' },
1391
+ body: JSON.stringify(ttsBody),
1392
+ });
1393
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
1394
+ if (!r.ok) {
1395
+ const t = await r.text().catch(() => '');
1396
+ logJob(node.id, `tts HTTP ${r.status}: ${t.slice(0,200)}`);
1397
+ throw new Error(t || `HTTP ${r.status}`);
1398
+ }
1399
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
1400
+ const blob = await r.blob();
1401
+ logJob(node.id, `tts response blob ${blob.size} bytes`);
1402
+ const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
1403
+ const baseName = await uniqueName(dir, `tts_${Date.now()}.mp3`);
1404
+ await writeFile(dir, baseName, blob);
1405
+ const relPath = `audio/${baseName}`;
1406
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
1407
+ logJob(node.id, `tts saved → ${relPath}`);
1408
+ await mutateNode(bKey, boardHandle, node.id, n => {
1409
+ n.status = undefined; n.error = undefined; n.file = relPath;
1410
+ if (Number.isFinite(cost)) {
1411
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
1412
+ }
1413
+ });
1414
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
1415
+ } catch (e) {
1416
+ logJob(node.id, `tts ERROR: ${e.message}`);
1417
+ await mutateNode(bKey, boardHandle, node.id, n => {
1418
+ n.status = 'error'; n.error = e.message;
1419
+ });
1420
+ } finally {
1421
+ state.jobs.delete(node.id);
1422
+ updateJobsBadge();
1423
+ }
1424
+ }
1425
+
1426
+ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bKey, modelKey) {
1427
+ const job = { boardKey: bKey, boardHandle, kind, taskId: null, nodeId: node.id };
1428
+ state.jobs.set(node.id, job);
1429
+ updateJobsBadge();
1430
+ logJob(node.id, `gen start kind=${kind} model=${modelKey || '—'} refs=${mediaRefs?.length || 0} prompt="${(prompt||'').slice(0,200)}"`);
1431
+ // Подробный дамп всех refs со всеми полями
1432
+ logJob(node.id, `refs dump: ${logSafe((mediaRefs || []).map(r => ({
1433
+ key: r.key, name: r.name, type: r.type, file: r.file,
1434
+ char: r.charName || null, board: r.boardHandle?.name || null,
1435
+ })))}`);
1436
+ try {
1437
+ // 1) Загрузить референсы в KIE
1438
+ const imageInputs = [];
1439
+ const videoInputs = [];
1440
+ const uploadDetails = [];
1441
+ if (mediaRefs?.length) {
1442
+ await mutateNode(bKey, boardHandle, node.id, n => {
1443
+ n.generated = { ...(n.generated || {}), state: 'uploading-refs' };
1444
+ });
1445
+ let i = 0;
1446
+ for (const ref of mediaRefs) {
1447
+ i++;
1448
+ const refBoard = ref.boardHandle || boardHandle;
1449
+ const refKey = ref.charName ? `char/${ref.charName}` : bKey;
1450
+ const upStart = Date.now();
1451
+ // Получим размер/MIME до выгрузки — для лога
1452
+ let size = null, mime = null;
1453
+ try {
1454
+ const fh = await resolveBoardFile(refBoard, ref.file);
1455
+ const file = await fh.getFile();
1456
+ size = file.size; mime = file.type || null;
1457
+ } catch (e) {
1458
+ logJob(node.id, ` ✗ resolve FAILED for ${ref.file}: ${e.message}`);
1459
+ }
1460
+ logJob(node.id, `upload ref ${i}/${mediaRefs.length}: key=@${ref.key || ref.name} type=${ref.type} char=${ref.charName||'—'} board=${refBoard?.name||'—'} file=${ref.file} size=${size} mime=${mime}`);
1461
+ const upTimer = setInterval(() => {
1462
+ if (state.jobs.get(node.id) !== job) return;
1463
+ const sec = Math.round((Date.now() - upStart) / 1000);
1464
+ logJob(node.id, ` upload ${i}/${mediaRefs.length} still pending... ${sec}s`);
1465
+ }, 5000);
1466
+ let url;
1467
+ try { url = await uploadBoardFile(refBoard, refKey, ref.file); }
1468
+ catch (e) {
1469
+ clearInterval(upTimer);
1470
+ logJob(node.id, ` ✗ upload FAILED in ${Date.now() - upStart}ms: ${e.message}`);
1471
+ throw e;
1472
+ }
1473
+ clearInterval(upTimer);
1474
+ logJob(node.id, ` ↳ ok in ${Date.now() - upStart}ms — ${url}`);
1475
+ uploadDetails.push({ idx: i, refKey: ref.key, type: ref.type, char: ref.charName||null, file: ref.file, size, mime, url });
1476
+ if (ref.type === 'image') imageInputs.push(url);
1477
+ else if (ref.type === 'video') videoInputs.push(url);
1478
+ }
1479
+ }
1480
+ logJob(node.id, `uploads summary: ${logSafe(uploadDetails)}`);
1481
+
1482
+ // 2) Создать таск
1483
+ await mutateNode(bKey, boardHandle, node.id, n => {
1484
+ n.generated = { ...(n.generated || {}), state: 'submitting' };
1485
+ });
1486
+ const submitBody = { kind, prompt, imageInputs, videoInputs, model: modelKey };
1487
+ if (kind === 'video') {
1488
+ submitBody.duration = node.generated?.duration ?? state.videoDuration;
1489
+ submitBody.resolution = node.generated?.resolution ?? state.videoResolution;
1490
+ submitBody.aspectRatio = node.generated?.aspectRatio ?? state.videoAspect;
1491
+ } else if (kind === 'image') {
1492
+ // Grok-imagine требует aspect_ratio из {2:3, 3:2, 1:1, 9:16, 16:9}.
1493
+ // Остальные модели (nano-banana-2, seedream и др.) тоже принимают.
1494
+ submitBody.aspectRatio = node.generated?.aspectRatio ?? state.imageAspect;
1495
+ }
1496
+ logJob(node.id, `POST /api/generate body: ${logSafe(submitBody)}`);
1497
+ logJob(node.id, `POST /api/generate (image_input=${imageInputs.length}, video_input=${videoInputs.length}, model=${modelKey})`);
1498
+ const submitStart = Date.now();
1499
+ const submitTimer = setInterval(() => {
1500
+ if (state.jobs.get(node.id) !== job) return;
1501
+ const sec = Math.round((Date.now() - submitStart) / 1000);
1502
+ logJob(node.id, `submit still pending... ${sec}s (KIE может тормозить)`);
1503
+ mutateNode(bKey, boardHandle, node.id, n => {
1504
+ n.generated = { ...(n.generated || {}), state: `submitting (${sec}s)` };
1505
+ }).catch(() => {});
1506
+ }, 5000);
1507
+ let r, rawText, data;
1508
+ const provider = await plannedProvider(kind);
1509
+ logJob(node.id, `→ POST /api/generate → ${provider} (kind=${kind} model=${modelKey || '—'})`);
1510
+ try {
1511
+ r = await fetch('/api/generate', {
1512
+ method: 'POST',
1513
+ headers: { 'Content-Type': 'application/json' },
1514
+ body: JSON.stringify(submitBody),
1515
+ });
1516
+ rawText = await r.text();
1517
+ try { data = JSON.parse(rawText); }
1518
+ catch { logJob(node.id, `/api/generate non-JSON response: ${rawText.slice(0,200)}`); throw new Error('Bad JSON from server'); }
1519
+ } catch (e) {
1520
+ clearInterval(submitTimer);
1521
+ logJob(node.id, `/api/generate FETCH FAILED in ${Date.now() - submitStart}ms: ${e.message}`);
1522
+ throw e;
1523
+ }
1524
+ clearInterval(submitTimer);
1525
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status} in ${Date.now() - submitStart}ms`);
1526
+ if (!r.ok || data.error) {
1527
+ logJob(node.id, `generate ERROR: ${data.error || rawText.slice(0,200)}`);
1528
+ throw new Error(data.error || `HTTP ${r.status}: ${rawText.slice(0,200)}`);
1529
+ }
1530
+ job.taskId = data.taskId;
1531
+ logJob(node.id, `taskId=${data.taskId}`);
1532
+ await mutateNode(bKey, boardHandle, node.id, n => {
1533
+ n.generated = { ...(n.generated || {}), taskId: data.taskId, state: 'queued' };
1534
+ });
1535
+ await pollJob(job, node.id, bKey, boardHandle, kind);
1536
+ } catch (e) {
1537
+ logJob(node.id, `gen ERROR: ${e.message}`);
1538
+ await mutateNode(bKey, boardHandle, node.id, n => {
1539
+ n.status = 'error'; n.error = e.message;
1540
+ });
1541
+ state.jobs.delete(node.id);
1542
+ updateJobsBadge();
1543
+ }
1544
+ }
1545
+
1546
+ async function resumeJob(node, bKey, boardHandle) {
1547
+ if (state.jobs.has(node.id)) return;
1548
+ if (!node.generated || node.status !== 'generating') return;
1549
+
1550
+ if (node.generated.taskId) {
1551
+ // Уже зарегистрировано в KIE — просто опрашиваем
1552
+ const job = { boardKey: bKey, boardHandle, kind: node.generated.kind, taskId: node.generated.taskId, nodeId: node.id };
1553
+ state.jobs.set(node.id, job);
1554
+ updateJobsBadge();
1555
+ try {
1556
+ await pollJob(job, node.id, bKey, boardHandle, node.generated.kind);
1557
+ } catch (e) {
1558
+ await mutateNode(bKey, boardHandle, node.id, n => { n.status = 'error'; n.error = e.message; });
1559
+ state.jobs.delete(node.id);
1560
+ updateJobsBadge();
1561
+ }
1562
+ } else {
1563
+ // Перезагрузка случилась до сабмита: рестарт с фазы upload+submit.
1564
+ // Маршрутизируем по kind — audio/text не идут через KIE.
1565
+ const kind = node.generated.kind;
1566
+ if (kind === 'audio') {
1567
+ await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
1568
+ } else if (kind === 'text') {
1569
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
1570
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
1571
+ await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
1572
+ } else {
1573
+ const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
1574
+ await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
1575
+ }
1576
+ }
1577
+ }
1578
+
1579
+ async function scanAllBoardsForPendingJobs(filmHandle) {
1580
+ const [eps, chars] = await Promise.all([listEpisodes(filmHandle), listCharacters(filmHandle)]);
1581
+ const all = [
1582
+ ...eps.map(b => ({ kind: 'episode', ...b })),
1583
+ ...chars.map(b => ({ kind: 'character', ...b })),
1584
+ ];
1585
+ for (const b of all) {
1586
+ try {
1587
+ const meta = await loadBoardMetadata(b.handle);
1588
+ const bKey = boardKey(b.kind, b.name);
1589
+ for (const n of meta.nodes) {
1590
+ if (n.status === 'generating' && n.generated?.taskId && !state.jobs.has(n.id)) {
1591
+ resumeJob(n, bKey, b.handle);
1592
+ }
1593
+ }
1594
+ } catch (e) { console.warn('scan board failed', b.name, e); }
1595
+ }
1596
+ }
1597
+
1598
+ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
1599
+ let pollCount = 0;
1600
+ let lastState = null;
1601
+ while (state.jobs.get(nodeId) === job) {
1602
+ await new Promise(r => setTimeout(r, 4000));
1603
+ if (state.jobs.get(nodeId) !== job) return;
1604
+ pollCount++;
1605
+ let pr, pd;
1606
+ try {
1607
+ pr = await fetch(`/api/poll?taskId=${encodeURIComponent(job.taskId)}`);
1608
+ pd = await pr.json();
1609
+ } catch (e) {
1610
+ logJob(nodeId, `poll #${pollCount} network ERROR: ${e.message}`);
1611
+ continue;
1612
+ }
1613
+ // Логируем провайдера только когда меняется state (раз в несколько polls'ов).
1614
+ // Иначе таблица логов забьётся повторами «via kie» каждые 4 секунды.
1615
+ const provider = pr.headers.get('x-provider') || '?';
1616
+ if (pd.state && pd.state !== lastState) {
1617
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state}`);
1618
+ lastState = pd.state;
1619
+ } else if (pollCount % 6 === 0) {
1620
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state || pd.status}`);
1621
+ }
1622
+ if (pd.status === 'done') {
1623
+ logJob(nodeId, `done, downloading ${pd.url?.slice(0,80)}`);
1624
+ const blob = await (await fetch(`/api/proxy?url=${encodeURIComponent(pd.url)}`)).blob();
1625
+ const ext = kind === 'image' ? 'jpg' : 'mp4';
1626
+ const sub = kind === 'image' ? 'frames' : 'clips';
1627
+ const dir = await getOrCreateBoardSubdir(boardHandle, sub);
1628
+ const baseName = await uniqueName(dir, `gen_${Date.now()}.${ext}`);
1629
+ await writeFile(dir, baseName, blob);
1630
+ const relPath = `${sub}/${baseName}`;
1631
+ const cost = typeof pd.cost === 'number' ? pd.cost : null;
1632
+ if (cost !== null) logJob(nodeId, `списано ${cost} credits`);
1633
+ logJob(nodeId, `done → file=${relPath} (${blob.size} bytes)`);
1634
+ await mutateNode(bKey, boardHandle, nodeId, n => {
1635
+ n.status = undefined; n.error = undefined; n.file = relPath;
1636
+ if (cost !== null) {
1637
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
1638
+ }
1639
+ });
1640
+ state.jobs.delete(nodeId);
1641
+ updateJobsBadge();
1642
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
1643
+ return;
1644
+ }
1645
+ if (pd.status === 'error') {
1646
+ logJob(nodeId, `KIE error: ${pd.error || 'unknown'}`);
1647
+ throw new Error(pd.error || 'generation failed');
1648
+ }
1649
+ if (pd.state) {
1650
+ await mutateNode(bKey, boardHandle, nodeId, n => {
1651
+ n.generated = { ...(n.generated || {}), state: pd.state };
1652
+ });
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ // Обновляет .state-text внутри node-body не перерисовывая всё тело —
1658
+ // для частых state-only тиков ("submitting (5s)" → "(10s)") DOM-rebuild дорог.
1659
+ function updateNodeStateText(nodeId, stateKey) {
1660
+ const root = canvas.querySelector(`.node[data-id="${nodeId}"] .gen-pending .state-text`);
1661
+ if (!root) return false;
1662
+ const LABELS = {
1663
+ 'uploading-refs': 'Загружаю референсы в KIE...',
1664
+ 'submitting': 'Отправляю задачу...',
1665
+ 'queued': 'В очереди...',
1666
+ 'waiting': 'В очереди...',
1667
+ 'queuing': 'В очереди...',
1668
+ 'generating': 'Модель работает...',
1669
+ };
1670
+ root.textContent = LABELS[stateKey] || (stateKey ? `Статус: ${stateKey}` : 'Генерируется...');
1671
+ return true;
1672
+ }
1673
+
1674
+ // Обновление ноды (на любой доске): меняем in-memory state если эта доска текущая,
1675
+ // иначе грузим metadata, патчим и пишем; всегда сохраняем на диск; если нода видна — обновляем DOM.
1676
+ async function mutateNode(bKey, boardHandle, nodeId, mutator) {
1677
+ // Текущая доска?
1678
+ if (state.currentBoard?.key === bKey) {
1679
+ const node = state.currentBoard.metadata.nodes.find(n => n.id === nodeId);
1680
+ if (node) {
1681
+ const fileBefore = node.file;
1682
+ const statusBefore = node.status;
1683
+ const stateBefore = node.generated?.state;
1684
+ mutator(node);
1685
+ const fileChanged = node.file !== fileBefore;
1686
+ const statusChanged = node.status !== statusBefore;
1687
+ const stateOnly = !statusChanged && !fileChanged && node.generated?.state !== stateBefore;
1688
+ // Если файл сменился (после регена) — подхватить новый файл в клипах таймлайна
1689
+ if (fileChanged && node.file) {
1690
+ syncTimelineClipsForNode(node);
1691
+ }
1692
+ syncHistorySlot(node);
1693
+ scheduleSave();
1694
+ // Частый кейс: меняется только текст статуса генерации. Точечно обновляем .state-text,
1695
+ // не перестраивая всё тело ноды и не дёргая таймлайн.
1696
+ if (stateOnly && updateNodeStateText(nodeId, node.generated?.state)) {
1697
+ return;
1698
+ }
1699
+ await refreshNodeDOM(nodeId);
1700
+ if (fileChanged || statusChanged) refreshTimelineForNode(nodeId);
1701
+ return;
1702
+ }
1703
+ }
1704
+ // Иначе — на диск напрямую
1705
+ const meta = await loadBoardMetadata(boardHandle);
1706
+ const node = meta.nodes.find(n => n.id === nodeId);
1707
+ if (!node) return;
1708
+ mutator(node);
1709
+ syncHistorySlot(node);
1710
+ await saveBoardMetadata(boardHandle, {
1711
+ nodes: meta.nodes,
1712
+ connections: meta.connections,
1713
+ view: meta.view,
1714
+ character: meta.character,
1715
+ location: meta.location,
1716
+ timeline: meta.timeline,
1717
+ });
1718
+ }
1719
+
1720
+ function updateJobsBadge() {
1721
+ const el = $('jobsInfo');
1722
+ const n = state.jobs.size;
1723
+ if (n === 0) { el.style.display = 'none'; return; }
1724
+ el.style.display = '';
1725
+ el.innerHTML = `<span class="spinner"></span>В фоне: ${n}`;
1726
+ }
1727
+