kingkont 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html CHANGED
@@ -38,6 +38,21 @@
38
38
  }
39
39
  .sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; }
40
40
  .sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; }
41
+ .sidebar-footer .balance-info {
42
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
43
+ color: #c4c4c4; padding: 4px 8px; background: #2a2a2a;
44
+ border: 1px solid #3a3a3a; border-radius: 6px; cursor: pointer;
45
+ width: fit-content; max-width: 100%;
46
+ transition: background 0.1s;
47
+ }
48
+ .sidebar-footer .balance-info:hover { background: #333; }
49
+ .sidebar-footer .balance-info .dot {
50
+ width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
51
+ background: #16a34a;
52
+ }
53
+ .sidebar-footer .balance-info.low .dot { background: #f59e0b; }
54
+ .sidebar-footer .balance-info.empty .dot { background: #dc2626; }
55
+ .sidebar-footer .balance-info b { color: #fff; font-weight: 500; }
41
56
  /* Когда проект ещё не открыт — секции с персонажами/локациями/сценами
42
57
  не имеют смысла, скрываем их. Класс ставится в openFilm/closeFilm. */
43
58
  body.no-project .sidebar > .sidebar-section,
@@ -786,35 +801,8 @@
786
801
  .node-header .delete { cursor: pointer; color: #888; font-size: 16px; line-height: 1; padding: 0 4px; }
787
802
  .node-header .delete:hover { color: #f88; }
788
803
 
789
- .node-name-row {
790
- /* Снаружи ноды под её нижней границей. Контент ноды никогда не перекрывается. */
791
- position: absolute; top: 100%; left: 0; right: 0;
792
- margin-top: 2px;
793
- padding: 4px 8px; background: rgba(28,28,28,0.96);
794
- border: 1px solid #383838; border-radius: 6px;
795
- opacity: 0; pointer-events: none; z-index: 4;
796
- transition: opacity 0.12s;
797
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
798
- }
799
- /* Невидимая зона под нодой для удержания hover пока курсор «летит» к input.
800
- Высота захватывает margin-top + name-row + небольшой запас. */
801
- .node::after {
802
- content: ''; position: absolute; top: 100%; left: 0; right: 0;
803
- height: 44px; pointer-events: none;
804
- }
805
- .node:hover::after { pointer-events: auto; }
806
- .node:hover .node-name-row,
807
- .node-name-row:hover, .node-name-row:focus-within {
808
- opacity: 1; pointer-events: auto;
809
- }
810
- .node-name-row .name {
811
- width: 100%; background: transparent; border: 1px solid transparent;
812
- color: #ddd; font-size: 12px; outline: none;
813
- padding: 2px 6px; border-radius: 3px;
814
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
815
- }
816
- .node-name-row .name:hover { border-color: #383838; }
817
- .node-name-row .name:focus { background: #1e1e1e; border-color: #6a8aaa; }
804
+ /* Имя ноды убрано из header — переименование через ПКМ → ✏ Переименовать.
805
+ Хедер теперь содержит только × delete слева пустое место для drag-grab). */
818
806
  .node-body { padding: 12px; }
819
807
  .node-body video, .node-body audio {
820
808
  width: 100%; max-height: 220px; background: #000;
@@ -991,6 +979,10 @@
991
979
  <div class="sidebar-list" id="episodeList"></div>
992
980
  </div>
993
981
  <div class="sidebar-footer">
982
+ <span id="balanceInfo" class="balance-info" style="display:none;" title="Баланс Chatium-аккаунта · клик — лог списаний" onclick="window.openTxLog && window.openTxLog()">
983
+ <span class="dot"></span>
984
+ <span id="balanceValue">— credits</span>
985
+ </span>
994
986
  <span id="jobsInfo" class="jobs-info" style="display:none;"></span>
995
987
  <span class="hint">Перетаскивай файлы на холст · @имя для ссылок</span>
996
988
  </div>
@@ -1150,6 +1142,37 @@
1150
1142
  </div>
1151
1143
  </div>
1152
1144
 
1145
+ <!-- ===== Лог списаний кредитов (Chatium) ===== -->
1146
+ <div class="modal hidden" id="txLogModal">
1147
+ <div class="modal-card" style="width: 720px;">
1148
+ <h3 style="display:flex; align-items:center; gap:10px;">
1149
+ Лог списаний
1150
+ <span id="txBalanceLine" style="color:#aaa; font-size:13px; font-weight:400;"></span>
1151
+ </h3>
1152
+ <div id="txLogBody" style="max-height: 60vh; overflow-y: auto; border: 1px solid #383838; border-radius: 4px; background: #1e1e1e;">
1153
+ <div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>
1154
+ </div>
1155
+ <div class="modal-actions">
1156
+ <button id="txLogRefresh">Обновить</button>
1157
+ <span class="spacer"></span>
1158
+ <button id="txLogClose">Закрыть</button>
1159
+ </div>
1160
+ </div>
1161
+ </div>
1162
+ <style>
1163
+ #txLogBody table { width: 100%; border-collapse: collapse; font-size: 12px; }
1164
+ #txLogBody th { text-align: left; padding: 8px 10px; background: #262626;
1165
+ color: #888; font-weight: 500; border-bottom: 1px solid #333;
1166
+ position: sticky; top: 0; }
1167
+ #txLogBody td { padding: 7px 10px; border-bottom: 1px solid #2a2a2a; color: #d4d4d4; vertical-align: top; }
1168
+ #txLogBody tr:hover td { background: #232323; }
1169
+ #txLogBody .tx-amount-neg { color: #ef4444; font-family: ui-monospace, monospace; text-align: right; }
1170
+ #txLogBody .tx-amount-pos { color: #16a34a; font-family: ui-monospace, monospace; text-align: right; }
1171
+ #txLogBody .tx-type { color: #888; font-size: 11px; }
1172
+ #txLogBody .tx-model { color: #aaa; font-size: 11px; font-family: ui-monospace, monospace; }
1173
+ #txLogBody .tx-empty { padding: 32px; text-align: center; color: #888; }
1174
+ </style>
1175
+
1153
1176
 
1154
1177
  <!-- ===== Меню «что добавить» (двойной клик на пустом месте холста) ===== -->
1155
1178
  <div class="add-menu hidden" id="addMenu">
@@ -1278,6 +1301,40 @@
1278
1301
  <button class="seg" data-img-model="sdxl-lightning" type="button" title="Очень быстрая (3-5с)">⚡ SDXL Lightning</button>
1279
1302
  </div>
1280
1303
  </label>
1304
+ <label id="videoOptionsRow" style="display: none;">Параметры видео
1305
+ <div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 6px;">
1306
+ <div style="flex: 1; min-width: 200px;">
1307
+ <div style="font-size: 11px; color: #888; margin-bottom: 4px;">Длительность (сек)</div>
1308
+ <div class="seg-control" id="videoDurationCtl">
1309
+ <button class="seg" data-vid-dur="5" type="button">5</button>
1310
+ <button class="seg" data-vid-dur="6" type="button">6</button>
1311
+ <button class="seg" data-vid-dur="8" type="button">8</button>
1312
+ <button class="seg" data-vid-dur="10" type="button">10</button>
1313
+ <button class="seg" data-vid-dur="12" type="button">12</button>
1314
+ <button class="seg" data-vid-dur="15" type="button">15</button>
1315
+ </div>
1316
+ </div>
1317
+ <div style="flex: 1; min-width: 180px;">
1318
+ <div style="font-size: 11px; color: #888; margin-bottom: 4px;">Разрешение</div>
1319
+ <div class="seg-control" id="videoResolutionCtl">
1320
+ <button class="seg" data-vid-res="480p" type="button">480p</button>
1321
+ <button class="seg" data-vid-res="720p" type="button">720p</button>
1322
+ <button class="seg" data-vid-res="1080p" type="button">1080p</button>
1323
+ </div>
1324
+ </div>
1325
+ <div style="flex: 1; min-width: 240px;">
1326
+ <div style="font-size: 11px; color: #888; margin-bottom: 4px;">Соотношение сторон</div>
1327
+ <div class="seg-control" id="videoAspectCtl">
1328
+ <button class="seg" data-vid-asp="16:9" type="button">16:9</button>
1329
+ <button class="seg" data-vid-asp="9:16" type="button">9:16</button>
1330
+ <button class="seg" data-vid-asp="1:1" type="button">1:1</button>
1331
+ <button class="seg" data-vid-asp="4:3" type="button">4:3</button>
1332
+ <button class="seg" data-vid-asp="3:4" type="button">3:4</button>
1333
+ <button class="seg" data-vid-asp="21:9" type="button">21:9</button>
1334
+ </div>
1335
+ </div>
1336
+ </div>
1337
+ </label>
1281
1338
  <label id="voiceRow" style="display: none;">Голос (ElevenLabs v3)
1282
1339
  <select id="genVoice"></select>
1283
1340
  </label>
@@ -1304,7 +1361,7 @@
1304
1361
  <label id="locPickRow" style="display:none;">Локация
1305
1362
  <select id="locPickSelect"><option value="">— не выбрана —</option></select>
1306
1363
  </label>
1307
- <label>Описание (prompt) используй <code style="background:#1e1e1e;padding:1px 4px;border-radius:2px;color:#aae06a;">@имя</code> для ссылок на ноды
1364
+ <label><span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block;">Описание · <span style="color:#aae06a; font-family:ui-monospace,monospace; text-transform:none;">@имя</span> для ссылки на ноду</span>
1308
1365
  <textarea id="genPrompt" placeholder="Опиши, что должно быть. Печатай @ чтобы вставить ссылку на ноду..."></textarea>
1309
1366
  <div id="mentionPopup" class="mention-popup hidden"></div>
1310
1367
  </label>
@@ -1663,6 +1720,9 @@ const state = {
1663
1720
  currentBoard: null, // { kind, name, key, handle, metadata, urls }
1664
1721
  genKind: 'image',
1665
1722
  imageModel: 'nano-banana-2', // 'nano-banana-2' | 'grok'
1723
+ videoDuration: +(localStorage.getItem('videoDuration') || 5),
1724
+ videoResolution: localStorage.getItem('videoResolution') || '720p',
1725
+ videoAspect: localStorage.getItem('videoAspect') || '9:16',
1666
1726
  jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
1667
1727
  undoStack: [], // {type, ...payload}
1668
1728
  zoom: 1,
@@ -1693,6 +1753,36 @@ function logJob(nodeId, msg) {
1693
1753
  if (arr.length > 1000) arr.splice(0, arr.length - 1000);
1694
1754
  console.log(`[${nodeId.slice(0,8)}] ${msg}`);
1695
1755
  }
1756
+
1757
+ // Зеркалит логику маршрутизации в server.js — для timeline-лога ноды,
1758
+ // чтобы юзер ВИДЕЛ ДО запроса, на какой провайдер уйдут данные. Если
1759
+ // настройки не позволяют ни один путь — возвращает 'none' (запрос либо
1760
+ // упадёт 503, либо отработает дефолтом — UI всё равно покажет факт).
1761
+ async function plannedProvider(kind) {
1762
+ let s;
1763
+ try { s = await window.appSettings.get(); } catch { s = {}; }
1764
+ const hasChatium = !!(s.useChatium && s.chatium?.token && s.chatium?.base);
1765
+ switch (kind) {
1766
+ case 'text':
1767
+ if (hasChatium) return 'chatium';
1768
+ if (s.useOpenrouter !== false) return 'openrouter';
1769
+ return 'none';
1770
+ case 'audio': case 'tts': case 'sfx': case 'music':
1771
+ if (hasChatium) return 'chatium';
1772
+ if (s.useElevenlabs !== false) return 'elevenlabs';
1773
+ return 'none';
1774
+ case 'video':
1775
+ if (hasChatium) return 'chatium';
1776
+ if (s.useKie !== false) return 'kie';
1777
+ return 'none';
1778
+ case 'image':
1779
+ if (hasChatium) return 'chatium';
1780
+ if (s.useKie !== false) return 'kie';
1781
+ return 'none';
1782
+ default:
1783
+ return '?';
1784
+ }
1785
+ }
1696
1786
  // Безопасная сериализация для логов: обрезает длинные строки и убирает blob/handle
1697
1787
  function logSafe(obj) {
1698
1788
  try {
@@ -1913,8 +2003,146 @@ window.addEventListener('DOMContentLoaded', async () => {
1913
2003
  vlog('err', 'restore failed: ' + (e?.message || e));
1914
2004
  }
1915
2005
  await renderWelcomeRecents();
2006
+ // Стартуем обновление баланса. Сразу + каждые 60 сек.
2007
+ refreshBalance().catch(() => {});
2008
+ setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
1916
2009
  });
1917
2010
 
2011
+ // === Баланс Chatium ===
2012
+ // Раз в минуту дёргает GET /api/balance (server.js → kingkont.ru). Если
2013
+ // chatium отключён или ключа нет — pill в sidebar-footer'е скрывается.
2014
+ // Цвет dot'а меняется в зависимости от баланса: green / yellow (<100) /
2015
+ // red (≤0). Также экспортирована глобально как window.refreshBalance —
2016
+ // чтобы settings-окно могло триггерить обновление после login/logout.
2017
+ async function refreshBalance() {
2018
+ const pill = document.getElementById('balanceInfo');
2019
+ const valueEl = document.getElementById('balanceValue');
2020
+ if (!pill || !valueEl) return;
2021
+ try {
2022
+ const s = await window.appSettings.get();
2023
+ if (!s.useChatium || !s.chatium?.token) {
2024
+ pill.style.display = 'none';
2025
+ return;
2026
+ }
2027
+ const r = await fetch('/api/balance');
2028
+ if (!r.ok) {
2029
+ pill.style.display = '';
2030
+ pill.classList.remove('low'); pill.classList.add('empty');
2031
+ valueEl.textContent = `— credits (HTTP ${r.status})`;
2032
+ return;
2033
+ }
2034
+ const d = await r.json();
2035
+ const balance = Number(d.balance) || 0;
2036
+ pill.style.display = '';
2037
+ pill.classList.toggle('low', balance > 0 && balance < 100);
2038
+ pill.classList.toggle('empty', balance <= 0);
2039
+ valueEl.innerHTML = `<b>${balance.toLocaleString('ru-RU')}</b>&nbsp;credits`;
2040
+ } catch (e) {
2041
+ pill.style.display = '';
2042
+ pill.classList.remove('low'); pill.classList.add('empty');
2043
+ valueEl.textContent = `— credits (offline)`;
2044
+ }
2045
+ }
2046
+ window.refreshBalance = refreshBalance;
2047
+
2048
+ // === Лог списаний (модал с историей кредитов) ===
2049
+ async function openTxLog() {
2050
+ const modal = document.getElementById('txLogModal');
2051
+ if (!modal) return;
2052
+ modal.classList.remove('hidden');
2053
+ await loadTxLog();
2054
+ }
2055
+ window.openTxLog = openTxLog;
2056
+
2057
+ async function loadTxLog() {
2058
+ const body = document.getElementById('txLogBody');
2059
+ const balanceLine = document.getElementById('txBalanceLine');
2060
+ if (!body) return;
2061
+ body.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>';
2062
+ if (balanceLine) balanceLine.textContent = '';
2063
+ try {
2064
+ const [txR, balR] = await Promise.all([
2065
+ fetch('/api/transactions'),
2066
+ fetch('/api/balance').catch(() => null),
2067
+ ]);
2068
+ if (!txR.ok) {
2069
+ const t = await txR.text().catch(() => '');
2070
+ body.innerHTML = `<div class="tx-empty">Ошибка: HTTP ${txR.status} ${escapeHtmlSafe(t.slice(0, 200))}</div>`;
2071
+ return;
2072
+ }
2073
+ const txs = await txR.json();
2074
+ if (balR && balR.ok) {
2075
+ const b = await balR.json();
2076
+ const n = Number(b.balance) || 0;
2077
+ balanceLine.textContent = `· баланс ${n.toLocaleString('ru-RU')} credits`;
2078
+ }
2079
+ if (!Array.isArray(txs) || txs.length === 0) {
2080
+ body.innerHTML = '<div class="tx-empty">Списаний нет</div>';
2081
+ return;
2082
+ }
2083
+ const rows = txs.map(t => {
2084
+ const amount = Number(t.amount) || 0;
2085
+ const isNeg = amount < 0;
2086
+ const amountStr = (isNeg ? '' : '+') + amount.toLocaleString('ru-RU');
2087
+ const dateStr = formatTxDate(t.createdAt);
2088
+ const typeLabel = formatTxType(t.type);
2089
+ return `
2090
+ <tr>
2091
+ <td style="white-space:nowrap; color:#aaa; font-family:ui-monospace,monospace;">${escapeHtmlSafe(dateStr)}</td>
2092
+ <td>
2093
+ <div>${escapeHtmlSafe(t.description || '—')}</div>
2094
+ <div class="tx-type">${escapeHtmlSafe(typeLabel)}${t.model ? ' · <span class="tx-model">' + escapeHtmlSafe(t.model) + '</span>' : ''}</div>
2095
+ </td>
2096
+ <td class="${isNeg ? 'tx-amount-neg' : 'tx-amount-pos'}">${escapeHtmlSafe(amountStr)}</td>
2097
+ </tr>
2098
+ `;
2099
+ }).join('');
2100
+ body.innerHTML = `
2101
+ <table>
2102
+ <thead>
2103
+ <tr>
2104
+ <th style="width:140px;">Время</th>
2105
+ <th>Описание</th>
2106
+ <th style="width:110px; text-align:right;">Кредиты</th>
2107
+ </tr>
2108
+ </thead>
2109
+ <tbody>${rows}</tbody>
2110
+ </table>
2111
+ `;
2112
+ } catch (e) {
2113
+ body.innerHTML = `<div class="tx-empty">Ошибка: ${escapeHtmlSafe(e?.message || String(e))}</div>`;
2114
+ }
2115
+ }
2116
+
2117
+ function formatTxDate(ts) {
2118
+ if (!ts) return '—';
2119
+ const d = new Date(ts);
2120
+ return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
2121
+ }
2122
+
2123
+ function formatTxType(t) {
2124
+ return ({
2125
+ deduct: 'списание',
2126
+ reserve: 'резерв',
2127
+ reserve_cancelled: 'резерв отменён',
2128
+ refund: 'возврат',
2129
+ topup: 'пополнение',
2130
+ bonus: 'бонус',
2131
+ subscription: 'подписка',
2132
+ renewal: 'продление',
2133
+ correction: 'корректировка',
2134
+ })[t] || t || '—';
2135
+ }
2136
+
2137
+ function escapeHtmlSafe(s) {
2138
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
2139
+ }
2140
+
2141
+ document.getElementById('txLogClose')?.addEventListener('click', () => {
2142
+ document.getElementById('txLogModal').classList.add('hidden');
2143
+ });
2144
+ document.getElementById('txLogRefresh')?.addEventListener('click', () => loadTxLog());
2145
+
1918
2146
  // === Recents store ===
1919
2147
  // Метаданные ({ name, thumbDataUrl, ts }) — в JSON-файле через recentsStore
1920
2148
  // (переживают quota-reset IDB). FSAH-handle — параллельно в IDB под ключом
@@ -2848,42 +3076,15 @@ async function createNodeEl(node) {
2848
3076
 
2849
3077
  const header = document.createElement('div');
2850
3078
  header.className = 'node-header';
2851
- header.title = 'Тяни, чтобы переместить (или брось на таймлайн — добавится клипом)';
3079
+ header.title = 'Тяни, чтобы переместить. Переименовать ПКМ Переименовать.';
3080
+
3081
+ // Имя ноды НЕ показываем в header — было слишком тесно (overflow-проблемы),
3082
+ // переименование вынесено в контекстное меню (ПКМ → ✏ Переименовать).
2852
3083
  const del = document.createElement('span');
2853
3084
  del.className = 'delete'; del.textContent = '×'; del.title = 'Удалить ноду';
2854
3085
  header.appendChild(del);
2855
3086
  el.appendChild(header);
2856
3087
 
2857
- const nameRow = document.createElement('div');
2858
- nameRow.className = 'node-name-row';
2859
- const nameInput = document.createElement('input');
2860
- nameInput.className = 'name';
2861
- nameInput.value = node.name || '';
2862
- nameInput.placeholder = 'имя для @ссылок';
2863
- nameInput.addEventListener('mousedown', e => e.stopPropagation());
2864
- nameInput.addEventListener('input', () => { node.name = nameInput.value.trim(); scheduleSave(); });
2865
- nameInput.addEventListener('blur', async () => {
2866
- const newName = nameInput.value.trim();
2867
- if (!newName) { nameInput.value = node.name || ''; return; }
2868
- if (node.type === 'text' && node.file) {
2869
- const baseDesired = newName + '.md';
2870
- const currentBase = node.file.split('/').pop();
2871
- if (baseDesired === currentBase) return;
2872
- try {
2873
- const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
2874
- const content = await (await fh.getFile()).text();
2875
- const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
2876
- const newBase = await uniqueName(dir, baseDesired);
2877
- await writeFile(dir, newBase, content);
2878
- await removeBoardFile(state.currentBoard.handle, node.file);
2879
- node.file = `texts/${newBase}`;
2880
- scheduleSave();
2881
- } catch (e) { console.error('rename .md failed', e); }
2882
- }
2883
- });
2884
- nameRow.appendChild(nameInput);
2885
- el.appendChild(nameRow);
2886
-
2887
3088
  const body = document.createElement('div');
2888
3089
  body.className = 'node-body';
2889
3090
  el.appendChild(body);
@@ -2937,6 +3138,31 @@ async function createNodeEl(node) {
2937
3138
  }
2938
3139
 
2939
3140
  // =================== Контекстное меню ноды (ПКМ) ===================
3141
+ async function renameNode(node) {
3142
+ const current = node.name || '';
3143
+ const newName = await askName('Имя ноды (для @-ссылок):', '', current);
3144
+ if (newName == null) return;
3145
+ const trimmed = newName.trim();
3146
+ // Текст-нода: переименовать соответствующий .md файл
3147
+ if (trimmed && node.type === 'text' && node.file) {
3148
+ const baseDesired = trimmed + '.md';
3149
+ const currentBase = node.file.split('/').pop();
3150
+ if (baseDesired !== currentBase) {
3151
+ try {
3152
+ const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
3153
+ const content = await (await fh.getFile()).text();
3154
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
3155
+ const newBase = await uniqueName(dir, baseDesired);
3156
+ await writeFile(dir, newBase, content);
3157
+ await removeBoardFile(state.currentBoard.handle, node.file);
3158
+ node.file = `texts/${newBase}`;
3159
+ } catch (e) { console.error('rename .md failed', e); }
3160
+ }
3161
+ }
3162
+ node.name = trimmed;
3163
+ scheduleSave();
3164
+ }
3165
+
2940
3166
  function showNodeContextMenu(node, clientX, clientY) {
2941
3167
  const menu = $('nodeMenu');
2942
3168
  menu.innerHTML = '';
@@ -2948,6 +3174,7 @@ function showNodeContextMenu(node, clientX, clientY) {
2948
3174
  b.addEventListener('click', () => { if (b.disabled) return; menu.classList.add('hidden'); fn(); });
2949
3175
  menu.appendChild(b);
2950
3176
  };
3177
+ add(node.name ? `✏ Переименовать (${node.name})` : '✏ Переименовать', () => renameNode(node));
2951
3178
  add('📋 Логи', () => showNodeLogs(node));
2952
3179
  if (node.generated) add('⚙ Параметры', () => showNodeSettings(node));
2953
3180
  if (node.status === 'generating') {
@@ -3038,6 +3265,9 @@ function showNodeLogs(node) {
3038
3265
  meta.push(`gen.model: ${node.generated.model || node.generated.modelKey || '—'}`);
3039
3266
  meta.push(`gen.taskId: ${node.generated.taskId || '—'}`);
3040
3267
  meta.push(`gen.state: ${node.generated.state || '—'}`);
3268
+ if (typeof node.generated.creditsCharged === 'number') {
3269
+ meta.push(`gen.cost: ${node.generated.creditsCharged} credits`);
3270
+ }
3041
3271
  if (node.generated.voiceId) meta.push(`gen.voice: ${node.generated.voiceName || node.generated.voiceId}`);
3042
3272
  if (node.generated.tones?.length) meta.push(`gen.tones: ${node.generated.tones.join(', ')}`);
3043
3273
  if (node.generated.refs?.length) {
@@ -3263,7 +3493,19 @@ async function renderNodeBody(node, body) {
3263
3493
  node.error = undefined;
3264
3494
  scheduleSave();
3265
3495
  renderNodeBody(node, body);
3266
- startGenerationJob(node, node.generated.kind, node.generated.prompt, state.currentBoard.handle, state.currentBoard.key);
3496
+ const kind = node.generated.kind;
3497
+ const bH = state.currentBoard.handle;
3498
+ const bK = state.currentBoard.key;
3499
+ if (kind === 'audio') {
3500
+ runTTSJob(node, node.generated.prompt, bH, bK, node.generated.voiceId);
3501
+ } else if (kind === 'text') {
3502
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
3503
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
3504
+ runTextJob(node, node.generated.prompt, model, bH, bK, imageRefs);
3505
+ } else {
3506
+ const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
3507
+ startGenerationJob(node, kind, node.generated.prompt, refs, bH, bK, node.generated.modelKey);
3508
+ }
3267
3509
  });
3268
3510
  wrap.appendChild(retry);
3269
3511
  }
@@ -3497,6 +3739,7 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3497
3739
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
3498
3740
  b.classList.toggle('active', b.dataset.kind === forceKind));
3499
3741
  $('imageModelRow').style.display = forceKind === 'image' ? '' : 'none';
3742
+ $('videoOptionsRow').style.display = forceKind === 'video' ? '' : 'none';
3500
3743
  $('voiceRow').style.display = forceKind === 'audio' ? '' : 'none';
3501
3744
  $('tonesRow').style.display = forceKind === 'audio' ? '' : 'none';
3502
3745
  const titleEl = $('genTitle');
@@ -3515,12 +3758,15 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3515
3758
  $('genSubmit').disabled = false;
3516
3759
 
3517
3760
  resetPicks();
3518
- presetPicksForBoard();
3761
+ // НЕ вызываем presetPicksForBoard() — иначе при вытягивании ноды из ноды
3762
+ // на доске персонажа авто-подставится этот персонаж в picked-список,
3763
+ // а юзер ожидает чистую форму (он явно не выбирал персонажа).
3519
3764
  renderCharsPickChips();
3520
3765
  renderLocPickSelect();
3521
3766
  syncCharLocRows();
3522
3767
 
3523
- // Если источник — картинка/видео, кладём его в "Исходный кадр" вместо @-ссылки в промпте
3768
+ // Если источник — картинка/видео, кладём его в "Исходный кадр".
3769
+ // Для VIDEO thumbnail НЕ показываем (юзер просил), только имя.
3524
3770
  if ((fromNode.type === 'image' || fromNode.type === 'video') && fromNode.file) {
3525
3771
  state.sourceRef = {
3526
3772
  file: fromNode.file,
@@ -3528,15 +3774,22 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3528
3774
  boardHandle: state.currentBoard.handle,
3529
3775
  use: true,
3530
3776
  };
3531
- let thumbUrl = state.currentBoard.urls[fromNode.file];
3532
- if (!thumbUrl) {
3533
- try {
3534
- const fh = await resolveBoardFile(state.currentBoard.handle, fromNode.file);
3535
- thumbUrl = URL.createObjectURL(await fh.getFile());
3536
- state.currentBoard.urls[fromNode.file] = thumbUrl;
3537
- } catch {}
3777
+ if (fromNode.type === 'image') {
3778
+ let thumbUrl = state.currentBoard.urls[fromNode.file];
3779
+ if (!thumbUrl) {
3780
+ try {
3781
+ const fh = await resolveBoardFile(state.currentBoard.handle, fromNode.file);
3782
+ thumbUrl = URL.createObjectURL(await fh.getFile());
3783
+ state.currentBoard.urls[fromNode.file] = thumbUrl;
3784
+ } catch {}
3785
+ }
3786
+ $('sourceRefThumb').src = thumbUrl || '';
3787
+ $('sourceRefThumb').style.display = '';
3788
+ } else {
3789
+ // video — без thumbnail, чтобы не дёргать декодер видео ради превьюшки.
3790
+ $('sourceRefThumb').src = '';
3791
+ $('sourceRefThumb').style.display = 'none';
3538
3792
  }
3539
- $('sourceRefThumb').src = thumbUrl || '';
3540
3793
  $('sourceRefName').textContent = fromNode.name || fromNode.file.split('/').pop();
3541
3794
  applySourceRefVisuals();
3542
3795
  $('genPrompt').value = '';
@@ -3582,7 +3835,7 @@ function attachResize(el, node, handle) {
3582
3835
  function attachDrag(el, node) {
3583
3836
  const header = el.querySelector('.node-header');
3584
3837
  header.addEventListener('mousedown', e => {
3585
- if (e.target.closest('.delete') || e.target.closest('.name')) return;
3838
+ if (e.target.closest('.delete')) return;
3586
3839
  // Multi-select c модификаторами — без drag
3587
3840
  if (e.metaKey || e.ctrlKey || e.shiftKey) {
3588
3841
  e.preventDefault();
@@ -3608,6 +3861,22 @@ function attachDrag(el, node) {
3608
3861
  arr.splice(idx, 1);
3609
3862
  arr.push(node);
3610
3863
  }
3864
+
3865
+ // Multi-drag: если выделено несколько нод и кликнули по одной из них —
3866
+ // двигаем всю группу с одинаковым delta. dragTargets: { node, el, origX, origY }.
3867
+ const isMulti = state.selectedNodeIds.size > 1 && state.selectedNodeIds.has(node.id);
3868
+ const dragTargets = isMulti
3869
+ ? arr.filter(n => state.selectedNodeIds.has(n.id)).map(n => ({
3870
+ node: n,
3871
+ el: canvas.querySelector(`.node[data-id="${n.id}"]`),
3872
+ origX: n.x,
3873
+ origY: n.y,
3874
+ })).filter(t => t.el)
3875
+ : [{ node, el, origX, origY }];
3876
+ if (isMulti) {
3877
+ for (const t of dragTargets) t.el.classList.add('selected');
3878
+ }
3879
+
3611
3880
  let dragInitialized = false; // снято на 1-м move ≥ 4px (избегаем «ложных» переносов)
3612
3881
  let lastTimelineHover = null; // {trackEl, time} — чтобы рисовать индикатор drop
3613
3882
  let nodePosChanged = false; // была ли реальная мутация node.x/y
@@ -3625,8 +3894,8 @@ function attachDrag(el, node) {
3625
3894
  const tlTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
3626
3895
  .find(n => n.classList?.contains('timeline-track'));
3627
3896
  const isMedia = ['image','video','audio'].includes(node.type) && node.file;
3628
- if (tlTrackEl && isMedia) {
3629
- // Drag-out на таймлайн: показываем индикатор drop, не трогаем node.x/y
3897
+ // Drop-on-timeline для multi-drag не имеет смысла — отключаем.
3898
+ if (tlTrackEl && isMedia && !isMulti) {
3630
3899
  const kind = tlTrackEl.dataset.trackKind;
3631
3900
  const compat = (kind === 'video' && (node.type === 'video' || node.type === 'image'))
3632
3901
  || (kind === 'audio' && node.type === 'audio');
@@ -3652,16 +3921,20 @@ function attachDrag(el, node) {
3652
3921
  dropMarker.style.display = 'none';
3653
3922
  lastTimelineHover = null;
3654
3923
  nodePosChanged = true;
3655
- node.x = Math.max(0, origX + (ev.clientX - startX) / state.zoom);
3656
- node.y = Math.max(0, origY + (ev.clientY - startY) / state.zoom);
3657
- el.style.left = node.x + 'px';
3658
- el.style.top = node.y + 'px';
3924
+ const dxNode = (ev.clientX - startX) / state.zoom;
3925
+ const dyNode = (ev.clientY - startY) / state.zoom;
3926
+ for (const t of dragTargets) {
3927
+ t.node.x = Math.max(0, t.origX + dxNode);
3928
+ t.node.y = Math.max(0, t.origY + dyNode);
3929
+ t.el.style.left = t.node.x + 'px';
3930
+ t.el.style.top = t.node.y + 'px';
3931
+ }
3659
3932
  renderConnections();
3660
3933
  };
3661
3934
  const onUp = async () => {
3662
3935
  document.removeEventListener('mousemove', onMove);
3663
3936
  document.removeEventListener('mouseup', onUp);
3664
- el.classList.remove('selected');
3937
+ for (const t of dragTargets) t.el.classList.remove('selected');
3665
3938
  el.classList.remove('dragging-to-timeline');
3666
3939
  dropMarker.remove();
3667
3940
  if (lastTimelineHover) {
@@ -4620,6 +4893,11 @@ async function restartJob(nodeId) {
4620
4893
  const kind = node.generated.kind;
4621
4894
  if (kind === 'audio') {
4622
4895
  runTTSJob(node, node.generated.prompt, bHandle, bKey, node.generated.voiceId);
4896
+ } else if (kind === 'text') {
4897
+ // text → /api/text (OpenRouter / Chatium), а не /api/generate (KIE).
4898
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
4899
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
4900
+ runTextJob(node, node.generated.prompt, model, bHandle, bKey, imageRefs);
4623
4901
  } else {
4624
4902
  const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
4625
4903
  startGenerationJob(node, kind, node.generated.prompt, refs, bHandle, bKey, node.generated.modelKey);
@@ -4661,23 +4939,46 @@ async function regenerateNode(node) {
4661
4939
  renderLocPickSelect();
4662
4940
  syncCharLocRows();
4663
4941
 
4664
- // Исходный кадр — текущий файл ноды (для image/video) станет референсом для модели.
4665
- if (node.file && (node.type === 'image' || node.type === 'video')) {
4942
+ // Исходный кадр для regenerate:
4943
+ // 1. Если нода была ВЫВЕДЕНА из другой ноды (node.generated.sourceRef есть)
4944
+ // — это связь с родительской нодой, её надо сохранить через regenerate
4945
+ // иначе модель сгенерирует не «вариацию из X», а просто новый по промпту.
4946
+ // 2. Иначе video: текущий файл как референс (вариация первого кадра).
4947
+ // 3. Иначе image: НЕ берём свою же картинку — модель просто скопирует.
4948
+ if (node.generated?.sourceRef && node.generated.sourceRef.file) {
4949
+ const sr = node.generated.sourceRef;
4666
4950
  state.sourceRef = {
4667
- file: node.file,
4668
- type: node.type,
4951
+ file: sr.file,
4952
+ type: sr.type,
4669
4953
  boardHandle: state.currentBoard.handle,
4670
4954
  use: true,
4671
4955
  };
4672
- let thumbUrl = state.currentBoard.urls[node.file];
4673
- if (!thumbUrl) {
4674
- try {
4675
- const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
4676
- thumbUrl = URL.createObjectURL(await fh.getFile());
4677
- state.currentBoard.urls[node.file] = thumbUrl;
4678
- } catch {}
4956
+ if (sr.type === 'image') {
4957
+ let thumbUrl = state.currentBoard.urls[sr.file];
4958
+ if (!thumbUrl) {
4959
+ try {
4960
+ const fh = await resolveBoardFile(state.currentBoard.handle, sr.file);
4961
+ thumbUrl = URL.createObjectURL(await fh.getFile());
4962
+ state.currentBoard.urls[sr.file] = thumbUrl;
4963
+ } catch {}
4964
+ }
4965
+ $('sourceRefThumb').src = thumbUrl || '';
4966
+ $('sourceRefThumb').style.display = '';
4967
+ } else {
4968
+ $('sourceRefThumb').src = '';
4969
+ $('sourceRefThumb').style.display = 'none';
4679
4970
  }
4680
- $('sourceRefThumb').src = thumbUrl || '';
4971
+ $('sourceRefName').textContent = sr.file.split('/').pop();
4972
+ applySourceRefVisuals();
4973
+ } else if (node.file && node.type === 'video') {
4974
+ state.sourceRef = {
4975
+ file: node.file,
4976
+ type: 'video',
4977
+ boardHandle: state.currentBoard.handle,
4978
+ use: true,
4979
+ };
4980
+ $('sourceRefThumb').src = '';
4981
+ $('sourceRefThumb').style.display = 'none';
4681
4982
  $('sourceRefName').textContent = node.name || node.file.split('/').pop();
4682
4983
  applySourceRefVisuals();
4683
4984
  } else {
@@ -4688,6 +4989,7 @@ async function regenerateNode(node) {
4688
4989
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
4689
4990
  b.classList.toggle('active', b.dataset.kind === state.genKind));
4690
4991
  $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
4992
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
4691
4993
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
4692
4994
 
4693
4995
  if (g.modelKey && state.genKind === 'image') {
@@ -4695,6 +4997,13 @@ async function regenerateNode(node) {
4695
4997
  document.querySelectorAll('#genModal [data-img-model]').forEach(b =>
4696
4998
  b.classList.toggle('active', b.dataset.imgModel === g.modelKey));
4697
4999
  }
5000
+ // Видео: подставляем сохранённые duration/resolution/aspect для regenerate
5001
+ if (state.genKind === 'video') {
5002
+ if (g.duration) state.videoDuration = g.duration;
5003
+ if (g.resolution) state.videoResolution = g.resolution;
5004
+ if (g.aspectRatio) state.videoAspect = g.aspectRatio;
5005
+ syncVideoOptionsActive();
5006
+ }
4698
5007
  if (state.genKind === 'audio') {
4699
5008
  await loadVoices();
4700
5009
  if (g.voiceId) $('genVoice').value = g.voiceId;
@@ -4753,6 +5062,35 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4753
5062
  }
4754
5063
  const tonePrefix = state.activeTones.map(t => `[${t}]`).join(' ');
4755
5064
  resolvedPrompt = tonePrefix ? `${tonePrefix} ${resolvedRaw}` : resolvedRaw;
5065
+ } else if (kind === 'text') {
5066
+ // Текстовая модель: имя берём как есть (полный slug OpenRouter,
5067
+ // напр. anthropic/claude-sonnet-4). Нет state.textModel — fallback
5068
+ // на сохранённое в ноде или дефолт.
5069
+ modelKey = node.generated?.modelKey || node.generated?.model || 'anthropic/claude-sonnet-4';
5070
+ refs = gatherMediaRefs(rawPrompt);
5071
+ if (opts.sourceRef && opts.sourceRef.file && opts.sourceRef.type === 'image') {
5072
+ const sr = opts.sourceRef;
5073
+ const dup = refs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
5074
+ if (!dup) {
5075
+ refs.unshift({
5076
+ key: '__source__',
5077
+ name: 'исходный кадр',
5078
+ type: 'image',
5079
+ file: sr.file,
5080
+ boardHandle: sr.boardHandle,
5081
+ });
5082
+ }
5083
+ }
5084
+ for (const pr of (opts.pickedSheets || [])) {
5085
+ if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
5086
+ refs.push(pr);
5087
+ }
5088
+ const missing = refs.filter(r => !r.file || r.status === 'generating');
5089
+ if (missing.length) {
5090
+ alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
5091
+ return;
5092
+ }
5093
+ resolvedPrompt = resolveMentions(rawPrompt, refs);
4756
5094
  } else {
4757
5095
  modelKey = kind === 'image' ? state.imageModel : 'seedance-2';
4758
5096
  refs = gatherMediaRefs(rawPrompt);
@@ -4796,20 +5134,45 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4796
5134
  'sdxl-lightning': 'sdxl/lightning',
4797
5135
  'nano-banana-2': 'nano-banana-2' }[modelKey] || 'nano-banana-2')
4798
5136
  : kind === 'video' ? 'bytedance/seedance-2'
4799
- : 'eleven_v3';
5137
+ : kind === 'text' ? modelKey // полный slug OpenRouter
5138
+ : 'eleven_v3'; // audio
5139
+
5140
+ // sourceRef нужно сохранить через regenerate, иначе при следующем
5141
+ // открытии modal'а связь с родительской нодой потеряется.
5142
+ // Приоритет: то что юзер активно выбрал в UI (opts.sourceRef);
5143
+ // если не выбирал — оставляем то что было раньше в node.generated.sourceRef.
5144
+ const carryoverSourceRef = opts.sourceRef && opts.sourceRef.file
5145
+ ? { file: opts.sourceRef.file, type: opts.sourceRef.type }
5146
+ : (node.generated?.sourceRef || null);
4800
5147
 
4801
5148
  const seedGen = kind === 'audio'
4802
5149
  ? { kind, prompt: resolvedPrompt, rawPrompt, model: modelId, voiceId, voiceName,
4803
5150
  tones: [...state.activeTones], state: 'submitting' }
4804
5151
  : { kind, prompt: resolvedPrompt, rawPrompt, modelKey, model: modelId,
4805
5152
  refs: refs ? refs.map(r => ({ name: r.name, type: r.type, file: r.file })) : [],
5153
+ ...(carryoverSourceRef ? { sourceRef: carryoverSourceRef } : {}),
4806
5154
  state: 'submitting' };
4807
5155
 
4808
- const newSlot = { file: undefined, generated: seedGen, status: 'generating', error: undefined };
5156
+ // Для text-ноды сохраняем существующий .md-файл (runTextJob перезапишет
5157
+ // его содержимое). Если файла ещё нет — создаём пустой texts/text_X.md
5158
+ // здесь, иначе writeBoardFile упадёт на split('/') от undefined.
5159
+ let preservedFile;
5160
+ if (kind === 'text') {
5161
+ if (node.file) {
5162
+ preservedFile = node.file;
5163
+ } else {
5164
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
5165
+ const mdName = await uniqueName(dir, 'text.md');
5166
+ await writeFile(dir, mdName, '');
5167
+ preservedFile = `texts/${mdName}`;
5168
+ }
5169
+ }
5170
+
5171
+ const newSlot = { file: preservedFile, generated: seedGen, status: 'generating', error: undefined };
4809
5172
  node.history.push(newSlot);
4810
5173
  node.historyIndex = node.history.length - 1;
4811
5174
  node.type = kind;
4812
- node.file = undefined;
5175
+ node.file = preservedFile; // для не-text undefined; для text — путь к .md
4813
5176
  node.generated = newSlot.generated;
4814
5177
  node.status = 'generating';
4815
5178
  node.error = undefined;
@@ -4821,6 +5184,8 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4821
5184
  const bKey = state.currentBoard.key;
4822
5185
  if (kind === 'audio') {
4823
5186
  runTTSJob(node, resolvedPrompt, bHandle, bKey, voiceId);
5187
+ } else if (kind === 'text') {
5188
+ runTextJob(node, resolvedPrompt, modelId, bHandle, bKey, refs);
4824
5189
  } else {
4825
5190
  startGenerationJob(node, kind, resolvedPrompt, refs, bHandle, bKey, modelKey);
4826
5191
  }
@@ -5564,6 +5929,7 @@ function openPhraseFor(charInfo) {
5564
5929
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
5565
5930
  b.classList.toggle('active', b.dataset.kind === 'audio'));
5566
5931
  $('imageModelRow').style.display = 'none';
5932
+ $('videoOptionsRow').style.display = 'none';
5567
5933
  $('voiceRow').style.display = '';
5568
5934
  $('tonesRow').style.display = '';
5569
5935
  loadVoices().then(() => {
@@ -5742,6 +6108,7 @@ async function openGenModal(kind) {
5742
6108
  b.classList.toggle('active', b.dataset.kind === kind));
5743
6109
  // Видимость рядов под текущий kind
5744
6110
  $('imageModelRow').style.display = kind === 'image' ? '' : 'none';
6111
+ $('videoOptionsRow').style.display = kind === 'video' ? '' : 'none';
5745
6112
  $('voiceRow').style.display = kind === 'audio' ? '' : 'none';
5746
6113
  $('tonesRow').style.display = kind === 'audio' ? '' : 'none';
5747
6114
  // Заголовок модалки = действие
@@ -5865,11 +6232,14 @@ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
5865
6232
  const url = await _imageRefToDataUrl(ref);
5866
6233
  if (url) images.push({ name: ref.name, url });
5867
6234
  }
6235
+ const provider = await plannedProvider('text');
6236
+ logJob(node.id, `→ POST /api/text → ${provider} (model=${model})`);
5868
6237
  const r = await fetch('/api/text', {
5869
6238
  method: 'POST',
5870
6239
  headers: { 'Content-Type': 'application/json' },
5871
6240
  body: JSON.stringify({ prompt, model, images }),
5872
6241
  });
6242
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5873
6243
  const data = await r.json();
5874
6244
  if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);
5875
6245
  const text = (data.text || '').trim();
@@ -5879,9 +6249,15 @@ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
5879
6249
  await mutateNode(bKey, boardHandle, node.id, n => {
5880
6250
  n.text = text;
5881
6251
  n.status = undefined;
5882
- n.generated = { ...(n.generated || {}), prompt, model: data.model || model, state: 'success' };
6252
+ n.generated = {
6253
+ ...(n.generated || {}),
6254
+ prompt, model: data.model || model, state: 'success',
6255
+ ...(typeof data.cost === 'number' ? { creditsCharged: data.cost } : {}),
6256
+ };
5883
6257
  });
6258
+ if (typeof data.cost === 'number') logJob(node.id, `списано ${data.cost} credits`);
5884
6259
  logJob(node.id, `text-gen done (${text.length} chars)`);
6260
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
5885
6261
  } catch (e) {
5886
6262
  logJob(node.id, `text-gen failed: ${e?.message || e}`);
5887
6263
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -5992,11 +6368,15 @@ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
5992
6368
  n.generated = { ...(n.generated || {}), translatedPrompt: enText };
5993
6369
  });
5994
6370
  }
6371
+ const provider = await plannedProvider('sfx');
6372
+ logJob(node.id, `→ POST /api/sfx → ${provider} (dur=${durationSeconds || '-'}s)`);
5995
6373
  const r = await fetch('/api/sfx', {
5996
6374
  method: 'POST', headers: { 'Content-Type': 'application/json' },
5997
6375
  body: JSON.stringify({ text: enText, durationSeconds }),
5998
6376
  });
6377
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5999
6378
  if (!r.ok) throw new Error((await r.text()).slice(0, 300) || `HTTP ${r.status}`);
6379
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6000
6380
  const blob = await r.blob();
6001
6381
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6002
6382
  const baseName = await uniqueName(dir, `sfx_${Date.now()}.mp3`);
@@ -6005,9 +6385,14 @@ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
6005
6385
  await mutateNode(bKey, boardHandle, node.id, n => {
6006
6386
  n.file = relPath;
6007
6387
  n.status = undefined;
6008
- n.generated = { ...(n.generated || {}), state: 'success' };
6388
+ n.generated = {
6389
+ ...(n.generated || {}), state: 'success',
6390
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
6391
+ };
6009
6392
  });
6393
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6010
6394
  logJob(node.id, `sfx saved → ${relPath}`);
6395
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6011
6396
  } catch (e) {
6012
6397
  logJob(node.id, `sfx failed: ${e?.message || e}\n${e?.stack || ''}`);
6013
6398
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6090,11 +6475,15 @@ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
6090
6475
  n.generated = { ...(n.generated || {}), translatedPrompt: enPrompt };
6091
6476
  });
6092
6477
  }
6478
+ const provider = await plannedProvider('music');
6479
+ logJob(node.id, `→ POST /api/music → ${provider} (dur=${durationMs ? durationMs/1000 + 's' : '-'})`);
6093
6480
  const r = await fetch('/api/music', {
6094
6481
  method: 'POST', headers: { 'Content-Type': 'application/json' },
6095
6482
  body: JSON.stringify({ prompt: enPrompt, durationMs }),
6096
6483
  });
6484
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
6097
6485
  if (!r.ok) throw new Error((await r.text()).slice(0, 300) || `HTTP ${r.status}`);
6486
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6098
6487
  const blob = await r.blob();
6099
6488
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6100
6489
  const baseName = await uniqueName(dir, `music_${Date.now()}.mp3`);
@@ -6103,9 +6492,14 @@ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
6103
6492
  await mutateNode(bKey, boardHandle, node.id, n => {
6104
6493
  n.file = relPath;
6105
6494
  n.status = undefined;
6106
- n.generated = { ...(n.generated || {}), state: 'success' };
6495
+ n.generated = {
6496
+ ...(n.generated || {}), state: 'success',
6497
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
6498
+ };
6107
6499
  });
6500
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6108
6501
  logJob(node.id, `music saved → ${relPath}`);
6502
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6109
6503
  } catch (e) {
6110
6504
  logJob(node.id, `music failed: ${e?.message || e}`);
6111
6505
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6132,6 +6526,7 @@ document.querySelectorAll('#genModal [data-kind]').forEach(b => {
6132
6526
  b.classList.add('active');
6133
6527
  state.genKind = b.dataset.kind;
6134
6528
  $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
6529
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
6135
6530
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
6136
6531
  $('tonesRow').style.display = state.genKind === 'audio' ? '' : 'none';
6137
6532
  if (state.genKind === 'audio') { loadVoices(); renderTones(); }
@@ -6387,6 +6782,42 @@ document.querySelectorAll('#genModal [data-img-model]').forEach(b => {
6387
6782
  state.imageModel = b.dataset.imgModel;
6388
6783
  });
6389
6784
  });
6785
+ // Переключатели длительности и разрешения для видео
6786
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(b => {
6787
+ b.addEventListener('click', () => {
6788
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(x => x.classList.remove('active'));
6789
+ b.classList.add('active');
6790
+ state.videoDuration = +b.dataset.vidDur;
6791
+ localStorage.setItem('videoDuration', String(state.videoDuration));
6792
+ });
6793
+ });
6794
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(b => {
6795
+ b.addEventListener('click', () => {
6796
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(x => x.classList.remove('active'));
6797
+ b.classList.add('active');
6798
+ state.videoResolution = b.dataset.vidRes;
6799
+ localStorage.setItem('videoResolution', state.videoResolution);
6800
+ });
6801
+ });
6802
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(b => {
6803
+ b.addEventListener('click', () => {
6804
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(x => x.classList.remove('active'));
6805
+ b.classList.add('active');
6806
+ state.videoAspect = b.dataset.vidAsp;
6807
+ localStorage.setItem('videoAspect', state.videoAspect);
6808
+ });
6809
+ });
6810
+ // Helper: подсветить активные кнопки duration/resolution/aspect согласно state.
6811
+ // Вызывать при открытии любой формы где видны videoOptions.
6812
+ function syncVideoOptionsActive() {
6813
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(b =>
6814
+ b.classList.toggle('active', +b.dataset.vidDur === state.videoDuration));
6815
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(b =>
6816
+ b.classList.toggle('active', b.dataset.vidRes === state.videoResolution));
6817
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(b =>
6818
+ b.classList.toggle('active', b.dataset.vidAsp === state.videoAspect));
6819
+ }
6820
+ syncVideoOptionsActive();
6390
6821
 
6391
6822
  $('genCancel').addEventListener('click', () => {
6392
6823
  state.pendingConnectionFrom = null;
@@ -6519,10 +6950,16 @@ $('genSubmit').addEventListener('click', async () => {
6519
6950
  }
6520
6951
 
6521
6952
  const mediaRefs = gatherMediaRefs(rawPrompt);
6522
- // "Исходный кадр" — добавляем как первый референс, если включён
6953
+ // "Исходный кадр" — добавляем как первый референс, если включён.
6954
+ // Заодно запоминаем savedSourceRef для записи в node.generated, чтобы
6955
+ // связь с родительской нодой пережила regenerate (см. regenerateNode —
6956
+ // там при следующем открытии modal sourceRef восстанавливается из
6957
+ // node.generated.sourceRef).
6523
6958
  let sourceMarker = '';
6959
+ let savedSourceRef = null;
6524
6960
  if (state.sourceRef && state.sourceRef.use && state.sourceRef.file) {
6525
6961
  const sr = state.sourceRef;
6962
+ savedSourceRef = { file: sr.file, type: sr.type };
6526
6963
  const dup = mediaRefs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
6527
6964
  if (!dup) {
6528
6965
  const refType = sr.type === 'video' ? 'video' : 'image';
@@ -6587,6 +7024,15 @@ $('genSubmit').addEventListener('click', async () => {
6587
7024
  }[state.imageModel] || 'nano-banana-2')
6588
7025
  : 'bytedance/seedance-2',
6589
7026
  refs: mediaRefs.map(r => ({ name: r.name, type: r.type, file: r.file })),
7027
+ // Связь с родительской нодой (выведена через "вытягивание"). Хранится
7028
+ // даже если sourceRef.use=false в state — сохраняем структурную связь.
7029
+ ...(savedSourceRef ? { sourceRef: savedSourceRef } : {}),
7030
+ // Параметры видео сохраняем — при regenerate используем те же (предсказуемость).
7031
+ ...(kind === 'video' ? {
7032
+ duration: state.videoDuration,
7033
+ resolution: state.videoResolution,
7034
+ aspectRatio: state.videoAspect,
7035
+ } : {}),
6590
7036
  },
6591
7037
  };
6592
7038
  if (saveOnly) node.status = 'draft';
@@ -6615,27 +7061,35 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
6615
7061
  await mutateNode(bKey, boardHandle, node.id, n => {
6616
7062
  n.generated = { ...(n.generated || {}), state: 'submitting' };
6617
7063
  });
6618
- logJob(node.id, `POST /api/tts`);
7064
+ const provider = await plannedProvider('tts');
7065
+ logJob(node.id, `→ POST /api/tts → ${provider} (voice=${voiceId})`);
6619
7066
  const r = await fetch('/api/tts', {
6620
7067
  method: 'POST',
6621
7068
  headers: { 'Content-Type': 'application/json' },
6622
7069
  body: JSON.stringify({ text, voiceId, modelId: 'eleven_v3' }),
6623
7070
  });
7071
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
6624
7072
  if (!r.ok) {
6625
7073
  const t = await r.text().catch(() => '');
6626
7074
  logJob(node.id, `tts HTTP ${r.status}: ${t.slice(0,200)}`);
6627
7075
  throw new Error(t || `HTTP ${r.status}`);
6628
7076
  }
7077
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6629
7078
  const blob = await r.blob();
6630
7079
  logJob(node.id, `tts response blob ${blob.size} bytes`);
6631
7080
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6632
7081
  const baseName = await uniqueName(dir, `tts_${Date.now()}.mp3`);
6633
7082
  await writeFile(dir, baseName, blob);
6634
7083
  const relPath = `audio/${baseName}`;
7084
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6635
7085
  logJob(node.id, `tts saved → ${relPath}`);
6636
7086
  await mutateNode(bKey, boardHandle, node.id, n => {
6637
7087
  n.status = undefined; n.error = undefined; n.file = relPath;
7088
+ if (Number.isFinite(cost)) {
7089
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
7090
+ }
6638
7091
  });
7092
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6639
7093
  } catch (e) {
6640
7094
  logJob(node.id, `tts ERROR: ${e.message}`);
6641
7095
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6708,6 +7162,11 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6708
7162
  n.generated = { ...(n.generated || {}), state: 'submitting' };
6709
7163
  });
6710
7164
  const submitBody = { kind, prompt, imageInputs, videoInputs, model: modelKey };
7165
+ if (kind === 'video') {
7166
+ submitBody.duration = node.generated?.duration ?? state.videoDuration;
7167
+ submitBody.resolution = node.generated?.resolution ?? state.videoResolution;
7168
+ submitBody.aspectRatio = node.generated?.aspectRatio ?? state.videoAspect;
7169
+ }
6711
7170
  logJob(node.id, `POST /api/generate body: ${logSafe(submitBody)}`);
6712
7171
  logJob(node.id, `POST /api/generate (image_input=${imageInputs.length}, video_input=${videoInputs.length}, model=${modelKey})`);
6713
7172
  const submitStart = Date.now();
@@ -6720,11 +7179,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6720
7179
  }).catch(() => {});
6721
7180
  }, 5000);
6722
7181
  let r, rawText, data;
7182
+ const provider = await plannedProvider(kind);
7183
+ logJob(node.id, `→ POST /api/generate → ${provider} (kind=${kind} model=${modelKey || '—'})`);
6723
7184
  try {
6724
7185
  r = await fetch('/api/generate', {
6725
7186
  method: 'POST',
6726
7187
  headers: { 'Content-Type': 'application/json' },
6727
- body: JSON.stringify({ kind, prompt, imageInputs, videoInputs, model: modelKey }),
7188
+ body: JSON.stringify(submitBody),
6728
7189
  });
6729
7190
  rawText = await r.text();
6730
7191
  try { data = JSON.parse(rawText); }
@@ -6735,7 +7196,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6735
7196
  throw e;
6736
7197
  }
6737
7198
  clearInterval(submitTimer);
6738
- logJob(node.id, `/api/generate HTTP ${r.status} in ${Date.now() - submitStart}ms`);
7199
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status} in ${Date.now() - submitStart}ms`);
6739
7200
  if (!r.ok || data.error) {
6740
7201
  logJob(node.id, `generate ERROR: ${data.error || rawText.slice(0,200)}`);
6741
7202
  throw new Error(data.error || `HTTP ${r.status}: ${rawText.slice(0,200)}`);
@@ -6773,9 +7234,19 @@ async function resumeJob(node, bKey, boardHandle) {
6773
7234
  updateJobsBadge();
6774
7235
  }
6775
7236
  } else {
6776
- // Перезагрузка случилась до сабмита: рестарт с фазы upload+submit
6777
- const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
6778
- await startGenerationJob(node, node.generated.kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
7237
+ // Перезагрузка случилась до сабмита: рестарт с фазы upload+submit.
7238
+ // Маршрутизируем по kind audio/text не идут через KIE.
7239
+ const kind = node.generated.kind;
7240
+ if (kind === 'audio') {
7241
+ await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
7242
+ } else if (kind === 'text') {
7243
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
7244
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
7245
+ await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
7246
+ } else {
7247
+ const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
7248
+ await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
7249
+ }
6779
7250
  }
6780
7251
  }
6781
7252
 
@@ -6813,14 +7284,17 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6813
7284
  logJob(nodeId, `poll #${pollCount} network ERROR: ${e.message}`);
6814
7285
  continue;
6815
7286
  }
7287
+ // Логируем провайдера только когда меняется state (раз в несколько polls'ов).
7288
+ // Иначе таблица логов забьётся повторами «via kie» каждые 4 секунды.
7289
+ const provider = pr.headers.get('x-provider') || '?';
6816
7290
  if (pd.state && pd.state !== lastState) {
6817
- logJob(nodeId, `poll #${pollCount} state=${pd.state}`);
7291
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state}`);
6818
7292
  lastState = pd.state;
6819
7293
  } else if (pollCount % 6 === 0) {
6820
- logJob(nodeId, `poll #${pollCount} state=${pd.state || pd.status}`);
7294
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state || pd.status}`);
6821
7295
  }
6822
7296
  if (pd.status === 'done') {
6823
- logJob(nodeId, `KIE done, downloading ${pd.url?.slice(0,80)}`);
7297
+ logJob(nodeId, `done, downloading ${pd.url?.slice(0,80)}`);
6824
7298
  const blob = await (await fetch(`/api/proxy?url=${encodeURIComponent(pd.url)}`)).blob();
6825
7299
  const ext = kind === 'image' ? 'jpg' : 'mp4';
6826
7300
  const sub = kind === 'image' ? 'frames' : 'clips';
@@ -6828,12 +7302,18 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6828
7302
  const baseName = await uniqueName(dir, `gen_${Date.now()}.${ext}`);
6829
7303
  await writeFile(dir, baseName, blob);
6830
7304
  const relPath = `${sub}/${baseName}`;
7305
+ const cost = typeof pd.cost === 'number' ? pd.cost : null;
7306
+ if (cost !== null) logJob(nodeId, `списано ${cost} credits`);
6831
7307
  logJob(nodeId, `done → file=${relPath} (${blob.size} bytes)`);
6832
7308
  await mutateNode(bKey, boardHandle, nodeId, n => {
6833
7309
  n.status = undefined; n.error = undefined; n.file = relPath;
7310
+ if (cost !== null) {
7311
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
7312
+ }
6834
7313
  });
6835
7314
  state.jobs.delete(nodeId);
6836
7315
  updateJobsBadge();
7316
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6837
7317
  return;
6838
7318
  }
6839
7319
  if (pd.status === 'error') {
@@ -7247,6 +7727,7 @@ async function openGenAudioForTimeline(charInfo, track, time) {
7247
7727
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
7248
7728
  b.classList.toggle('active', b.dataset.kind === 'audio'));
7249
7729
  $('imageModelRow').style.display = 'none';
7730
+ $('videoOptionsRow').style.display = 'none';
7250
7731
  $('voiceRow').style.display = '';
7251
7732
  $('tonesRow').style.display = '';
7252
7733
  $('sourceRefRow').style.display = 'none';
@@ -8509,6 +8990,7 @@ async function generateReplicaCached(charInfo, replica) {
8509
8990
  headers: { 'Content-Type': 'application/json' },
8510
8991
  body: JSON.stringify({ text: finalText, voiceId: charInfo.voice, modelId: 'eleven_v3' }),
8511
8992
  });
8993
+ console.log(`[replica TTS] via ${r.headers.get('x-provider') || '?'}`);
8512
8994
  if (!r.ok) {
8513
8995
  const t = await r.text().catch(() => '');
8514
8996
  throw new Error(t || `HTTP ${r.status}`);