kingkont 0.6.2 → 0.7.1

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;
@@ -961,7 +949,7 @@
961
949
  <aside class="sidebar">
962
950
  <div class="sidebar-header">
963
951
  <div class="brand">
964
- <img src="assets/logo-square.svg" alt="" draggable="false">
952
+ <img src="assets/logo-square.svg" alt="" draggable="false" id="brandLogo" title="Дабл-клик — настройки" style="cursor:pointer;">
965
953
  <div style="min-width:0; flex:1;">
966
954
  <div class="title">KingKont</div>
967
955
  <div class="sub" id="brandSub">Видео-редактор</div>
@@ -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="Баланс KingKont-аккаунта · клик — лог списаний" 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>
@@ -999,7 +991,7 @@
999
991
  <!-- Welcome-экран: виден только когда body.no-project -->
1000
992
  <div class="welcome" id="welcome">
1001
993
  <div class="welcome-inner">
1002
- <img class="welcome-logo" src="assets/logo-square.svg" alt="" draggable="false">
994
+ <img class="welcome-logo" src="assets/logo-square.svg" alt="" draggable="false" id="welcomeLogo" title="Дабл-клик — настройки" style="cursor:pointer;">
1003
995
  <h1 class="welcome-title">KingKont</h1>
1004
996
  <div class="welcome-sub">Видео-редактор</div>
1005
997
  <button id="welcomeOpen" class="welcome-open primary">Открыть проект</button>
@@ -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,48 @@
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="videoModelRow" style="display: none;">Модель для видео
1305
+ <div class="seg-control">
1306
+ <button class="seg active" data-vid-model="seedance-2" type="button" title="ByteDance Seedance 2 — основная">Seedance 2</button>
1307
+ <button class="seg" data-vid-model="seedance-2-fast" type="button" title="Быстрее, чуть проще качество">⚡ Seedance 2 Fast</button>
1308
+ <button class="seg" data-vid-model="kling-o1" type="button" title="Kling O1 — креативный, поддерживает reference-видео">Kling O1</button>
1309
+ <button class="seg" data-vid-model="kling-3.0" type="button" title="Kling 3.0 — multi-shot до 15с">Kling 3.0</button>
1310
+ </div>
1311
+ </label>
1312
+ <label id="videoOptionsRow" style="display: none;">Параметры видео
1313
+ <div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 6px;">
1314
+ <div style="flex: 1; min-width: 200px;">
1315
+ <div style="font-size: 11px; color: #888; margin-bottom: 4px;">Длительность (сек)</div>
1316
+ <div class="seg-control" id="videoDurationCtl">
1317
+ <button class="seg" data-vid-dur="5" type="button">5</button>
1318
+ <button class="seg" data-vid-dur="6" type="button">6</button>
1319
+ <button class="seg" data-vid-dur="8" type="button">8</button>
1320
+ <button class="seg" data-vid-dur="10" type="button">10</button>
1321
+ <button class="seg" data-vid-dur="12" type="button">12</button>
1322
+ <button class="seg" data-vid-dur="15" type="button">15</button>
1323
+ </div>
1324
+ </div>
1325
+ <div style="flex: 1; min-width: 180px;">
1326
+ <div style="font-size: 11px; color: #888; margin-bottom: 4px;">Разрешение</div>
1327
+ <div class="seg-control" id="videoResolutionCtl">
1328
+ <button class="seg" data-vid-res="480p" type="button">480p</button>
1329
+ <button class="seg" data-vid-res="720p" type="button">720p</button>
1330
+ <button class="seg" data-vid-res="1080p" type="button">1080p</button>
1331
+ </div>
1332
+ </div>
1333
+ <div style="flex: 1; min-width: 240px;">
1334
+ <div style="font-size: 11px; color: #888; margin-bottom: 4px;">Соотношение сторон</div>
1335
+ <div class="seg-control" id="videoAspectCtl">
1336
+ <button class="seg" data-vid-asp="16:9" type="button">16:9</button>
1337
+ <button class="seg" data-vid-asp="9:16" type="button">9:16</button>
1338
+ <button class="seg" data-vid-asp="1:1" type="button">1:1</button>
1339
+ <button class="seg" data-vid-asp="4:3" type="button">4:3</button>
1340
+ <button class="seg" data-vid-asp="3:4" type="button">3:4</button>
1341
+ <button class="seg" data-vid-asp="21:9" type="button">21:9</button>
1342
+ </div>
1343
+ </div>
1344
+ </div>
1345
+ </label>
1281
1346
  <label id="voiceRow" style="display: none;">Голос (ElevenLabs v3)
1282
1347
  <select id="genVoice"></select>
1283
1348
  </label>
@@ -1304,7 +1369,7 @@
1304
1369
  <label id="locPickRow" style="display:none;">Локация
1305
1370
  <select id="locPickSelect"><option value="">— не выбрана —</option></select>
1306
1371
  </label>
1307
- <label>Описание (prompt) используй <code style="background:#1e1e1e;padding:1px 4px;border-radius:2px;color:#aae06a;">@имя</code> для ссылок на ноды
1372
+ <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
1373
  <textarea id="genPrompt" placeholder="Опиши, что должно быть. Печатай @ чтобы вставить ссылку на ноду..."></textarea>
1309
1374
  <div id="mentionPopup" class="mention-popup hidden"></div>
1310
1375
  </label>
@@ -1662,7 +1727,11 @@ const state = {
1662
1727
  filmHandle: null,
1663
1728
  currentBoard: null, // { kind, name, key, handle, metadata, urls }
1664
1729
  genKind: 'image',
1665
- imageModel: 'nano-banana-2', // 'nano-banana-2' | 'grok'
1730
+ imageModel: 'nano-banana-2', // 'nano-banana-2' | 'grok' | ...
1731
+ videoModel: localStorage.getItem('videoModel') || 'seedance-2', // 'seedance-2' | 'kling-o1' | 'kling-3.0' | ...
1732
+ videoDuration: +(localStorage.getItem('videoDuration') || 5),
1733
+ videoResolution: localStorage.getItem('videoResolution') || '720p',
1734
+ videoAspect: localStorage.getItem('videoAspect') || '9:16',
1666
1735
  jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
1667
1736
  undoStack: [], // {type, ...payload}
1668
1737
  zoom: 1,
@@ -1693,6 +1762,36 @@ function logJob(nodeId, msg) {
1693
1762
  if (arr.length > 1000) arr.splice(0, arr.length - 1000);
1694
1763
  console.log(`[${nodeId.slice(0,8)}] ${msg}`);
1695
1764
  }
1765
+
1766
+ // Зеркалит логику маршрутизации в server.js — для timeline-лога ноды,
1767
+ // чтобы юзер ВИДЕЛ ДО запроса, на какой провайдер уйдут данные. Если
1768
+ // настройки не позволяют ни один путь — возвращает 'none' (запрос либо
1769
+ // упадёт 503, либо отработает дефолтом — UI всё равно покажет факт).
1770
+ async function plannedProvider(kind) {
1771
+ let s;
1772
+ try { s = await window.appSettings.get(); } catch { s = {}; }
1773
+ const hasChatium = !!(s.useChatium && s.chatium?.token && s.chatium?.base);
1774
+ switch (kind) {
1775
+ case 'text':
1776
+ if (hasChatium) return 'chatium';
1777
+ if (s.useOpenrouter !== false) return 'openrouter';
1778
+ return 'none';
1779
+ case 'audio': case 'tts': case 'sfx': case 'music':
1780
+ if (hasChatium) return 'chatium';
1781
+ if (s.useElevenlabs !== false) return 'elevenlabs';
1782
+ return 'none';
1783
+ case 'video':
1784
+ if (hasChatium) return 'chatium';
1785
+ if (s.useKie !== false) return 'kie';
1786
+ return 'none';
1787
+ case 'image':
1788
+ if (hasChatium) return 'chatium';
1789
+ if (s.useKie !== false) return 'kie';
1790
+ return 'none';
1791
+ default:
1792
+ return '?';
1793
+ }
1794
+ }
1696
1795
  // Безопасная сериализация для логов: обрезает длинные строки и убирает blob/handle
1697
1796
  function logSafe(obj) {
1698
1797
  try {
@@ -1878,7 +1977,7 @@ window.addEventListener('DOMContentLoaded', async () => {
1878
1977
  // Восстановить состояние панелей таймлайна/превью/реплик
1879
1978
  const tlOpen = localStorage.getItem('timelineOpen') === '1';
1880
1979
  const pvOpen = localStorage.getItem('previewOpen') === '1';
1881
- const rqOpen = localStorage.getItem('repliquesOpen') === '1';
1980
+ const rqOpen = false; // localStorage.getItem('repliquesOpen') === '1'; — панель реплик скрыта
1882
1981
  if (tlOpen) $('timelinePanel').classList.remove('hidden');
1883
1982
  // Превью больше не скрывается — состояние управляется previewCollapsed
1884
1983
  if (rqOpen) $('repliquesPanel').classList.remove('hidden');
@@ -1913,8 +2012,153 @@ window.addEventListener('DOMContentLoaded', async () => {
1913
2012
  vlog('err', 'restore failed: ' + (e?.message || e));
1914
2013
  }
1915
2014
  await renderWelcomeRecents();
2015
+ // Стартуем обновление баланса. Сразу + каждые 60 сек.
2016
+ refreshBalance().catch(() => {});
2017
+ setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
2018
+
2019
+ // Дабл-клик на логотипе (sidebar или welcome) → открытие окна настроек.
2020
+ const openSettingsFromLogo = () => {
2021
+ if (window.appSettings?.openSettingsWindow) window.appSettings.openSettingsWindow();
2022
+ };
2023
+ document.getElementById('brandLogo')?.addEventListener('dblclick', openSettingsFromLogo);
2024
+ document.getElementById('welcomeLogo')?.addEventListener('dblclick', openSettingsFromLogo);
1916
2025
  });
1917
2026
 
2027
+ // === Баланс Chatium ===
2028
+ // Раз в минуту дёргает GET /api/balance (server.js → kingkont.ru). Если
2029
+ // chatium отключён или ключа нет — pill в sidebar-footer'е скрывается.
2030
+ // Цвет dot'а меняется в зависимости от баланса: green / yellow (<100) /
2031
+ // red (≤0). Также экспортирована глобально как window.refreshBalance —
2032
+ // чтобы settings-окно могло триггерить обновление после login/logout.
2033
+ async function refreshBalance() {
2034
+ const pill = document.getElementById('balanceInfo');
2035
+ const valueEl = document.getElementById('balanceValue');
2036
+ if (!pill || !valueEl) return;
2037
+ try {
2038
+ const s = await window.appSettings.get();
2039
+ if (!s.useChatium || !s.chatium?.token) {
2040
+ pill.style.display = 'none';
2041
+ return;
2042
+ }
2043
+ const r = await fetch('/api/balance');
2044
+ if (!r.ok) {
2045
+ pill.style.display = '';
2046
+ pill.classList.remove('low'); pill.classList.add('empty');
2047
+ valueEl.textContent = `— credits (HTTP ${r.status})`;
2048
+ return;
2049
+ }
2050
+ const d = await r.json();
2051
+ const balance = Number(d.balance) || 0;
2052
+ pill.style.display = '';
2053
+ pill.classList.toggle('low', balance > 0 && balance < 100);
2054
+ pill.classList.toggle('empty', balance <= 0);
2055
+ valueEl.innerHTML = `<b>${balance.toLocaleString('ru-RU')}</b>&nbsp;credits`;
2056
+ } catch (e) {
2057
+ pill.style.display = '';
2058
+ pill.classList.remove('low'); pill.classList.add('empty');
2059
+ valueEl.textContent = `— credits (offline)`;
2060
+ }
2061
+ }
2062
+ window.refreshBalance = refreshBalance;
2063
+
2064
+ // === Лог списаний (модал с историей кредитов) ===
2065
+ async function openTxLog() {
2066
+ const modal = document.getElementById('txLogModal');
2067
+ if (!modal) return;
2068
+ modal.classList.remove('hidden');
2069
+ await loadTxLog();
2070
+ }
2071
+ window.openTxLog = openTxLog;
2072
+
2073
+ async function loadTxLog() {
2074
+ const body = document.getElementById('txLogBody');
2075
+ const balanceLine = document.getElementById('txBalanceLine');
2076
+ if (!body) return;
2077
+ body.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>';
2078
+ if (balanceLine) balanceLine.textContent = '';
2079
+ try {
2080
+ const [txR, balR] = await Promise.all([
2081
+ fetch('/api/transactions'),
2082
+ fetch('/api/balance').catch(() => null),
2083
+ ]);
2084
+ if (!txR.ok) {
2085
+ const t = await txR.text().catch(() => '');
2086
+ body.innerHTML = `<div class="tx-empty">Ошибка: HTTP ${txR.status} ${escapeHtmlSafe(t.slice(0, 200))}</div>`;
2087
+ return;
2088
+ }
2089
+ const txs = await txR.json();
2090
+ if (balR && balR.ok) {
2091
+ const b = await balR.json();
2092
+ const n = Number(b.balance) || 0;
2093
+ balanceLine.textContent = `· баланс ${n.toLocaleString('ru-RU')} credits`;
2094
+ }
2095
+ if (!Array.isArray(txs) || txs.length === 0) {
2096
+ body.innerHTML = '<div class="tx-empty">Списаний нет</div>';
2097
+ return;
2098
+ }
2099
+ const rows = txs.map(t => {
2100
+ const amount = Number(t.amount) || 0;
2101
+ const isNeg = amount < 0;
2102
+ const amountStr = (isNeg ? '' : '+') + amount.toLocaleString('ru-RU');
2103
+ const dateStr = formatTxDate(t.createdAt);
2104
+ const typeLabel = formatTxType(t.type);
2105
+ return `
2106
+ <tr>
2107
+ <td style="white-space:nowrap; color:#aaa; font-family:ui-monospace,monospace;">${escapeHtmlSafe(dateStr)}</td>
2108
+ <td>
2109
+ <div>${escapeHtmlSafe(t.description || '—')}</div>
2110
+ <div class="tx-type">${escapeHtmlSafe(typeLabel)}${t.model ? ' · <span class="tx-model">' + escapeHtmlSafe(t.model) + '</span>' : ''}</div>
2111
+ </td>
2112
+ <td class="${isNeg ? 'tx-amount-neg' : 'tx-amount-pos'}">${escapeHtmlSafe(amountStr)}</td>
2113
+ </tr>
2114
+ `;
2115
+ }).join('');
2116
+ body.innerHTML = `
2117
+ <table>
2118
+ <thead>
2119
+ <tr>
2120
+ <th style="width:140px;">Время</th>
2121
+ <th>Описание</th>
2122
+ <th style="width:110px; text-align:right;">Кредиты</th>
2123
+ </tr>
2124
+ </thead>
2125
+ <tbody>${rows}</tbody>
2126
+ </table>
2127
+ `;
2128
+ } catch (e) {
2129
+ body.innerHTML = `<div class="tx-empty">Ошибка: ${escapeHtmlSafe(e?.message || String(e))}</div>`;
2130
+ }
2131
+ }
2132
+
2133
+ function formatTxDate(ts) {
2134
+ if (!ts) return '—';
2135
+ const d = new Date(ts);
2136
+ return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
2137
+ }
2138
+
2139
+ function formatTxType(t) {
2140
+ return ({
2141
+ deduct: 'списание',
2142
+ reserve: 'резерв',
2143
+ reserve_cancelled: 'резерв отменён',
2144
+ refund: 'возврат',
2145
+ topup: 'пополнение',
2146
+ bonus: 'бонус',
2147
+ subscription: 'подписка',
2148
+ renewal: 'продление',
2149
+ correction: 'корректировка',
2150
+ })[t] || t || '—';
2151
+ }
2152
+
2153
+ function escapeHtmlSafe(s) {
2154
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
2155
+ }
2156
+
2157
+ document.getElementById('txLogClose')?.addEventListener('click', () => {
2158
+ document.getElementById('txLogModal').classList.add('hidden');
2159
+ });
2160
+ document.getElementById('txLogRefresh')?.addEventListener('click', () => loadTxLog());
2161
+
1918
2162
  // === Recents store ===
1919
2163
  // Метаданные ({ name, thumbDataUrl, ts }) — в JSON-файле через recentsStore
1920
2164
  // (переживают quota-reset IDB). FSAH-handle — параллельно в IDB под ключом
@@ -2668,13 +2912,14 @@ async function selectBoard(board) {
2668
2912
  }
2669
2913
  emptyState.classList.add('hidden');
2670
2914
  $('charSettingsBtn').style.display = board.kind === 'character' ? '' : 'none';
2671
- // Авто-открытие/закрытие панели реплик в зависимости от типа board.
2672
- if (board.kind === 'character') {
2673
- openRepliquesFor(board.name).catch(e => console.warn('replicas open failed', e));
2674
- } else {
2675
- $('repliquesPanel').classList.add('hidden');
2676
- localStorage.setItem('repliquesOpen', '0');
2677
- }
2915
+ // Панель реплик скрыта (юзер попросил убрать из карточки персонажа).
2916
+ // Сами данные реплик в scene.json не трогаем — таймлайн и character-озвучка
2917
+ // продолжают работать. Если понадобится вернуть — расскомментируй ниже.
2918
+ $('repliquesPanel').classList.add('hidden');
2919
+ localStorage.setItem('repliquesOpen', '0');
2920
+ // if (board.kind === 'character') {
2921
+ // openRepliquesFor(board.name).catch(e => console.warn('replicas open failed', e));
2922
+ // }
2678
2923
  localStorage.setItem(`lastBoard:${state.filmHandle.name}`,
2679
2924
  JSON.stringify({ kind: board.kind, name: board.name }));
2680
2925
  await refreshSidebar();
@@ -2848,42 +3093,15 @@ async function createNodeEl(node) {
2848
3093
 
2849
3094
  const header = document.createElement('div');
2850
3095
  header.className = 'node-header';
2851
- header.title = 'Тяни, чтобы переместить (или брось на таймлайн — добавится клипом)';
3096
+ header.title = 'Тяни, чтобы переместить. Переименовать ПКМ Переименовать.';
3097
+
3098
+ // Имя ноды НЕ показываем в header — было слишком тесно (overflow-проблемы),
3099
+ // переименование вынесено в контекстное меню (ПКМ → ✏ Переименовать).
2852
3100
  const del = document.createElement('span');
2853
3101
  del.className = 'delete'; del.textContent = '×'; del.title = 'Удалить ноду';
2854
3102
  header.appendChild(del);
2855
3103
  el.appendChild(header);
2856
3104
 
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
3105
  const body = document.createElement('div');
2888
3106
  body.className = 'node-body';
2889
3107
  el.appendChild(body);
@@ -2937,6 +3155,31 @@ async function createNodeEl(node) {
2937
3155
  }
2938
3156
 
2939
3157
  // =================== Контекстное меню ноды (ПКМ) ===================
3158
+ async function renameNode(node) {
3159
+ const current = node.name || '';
3160
+ const newName = await askName('Имя ноды (для @-ссылок):', '', current);
3161
+ if (newName == null) return;
3162
+ const trimmed = newName.trim();
3163
+ // Текст-нода: переименовать соответствующий .md файл
3164
+ if (trimmed && node.type === 'text' && node.file) {
3165
+ const baseDesired = trimmed + '.md';
3166
+ const currentBase = node.file.split('/').pop();
3167
+ if (baseDesired !== currentBase) {
3168
+ try {
3169
+ const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
3170
+ const content = await (await fh.getFile()).text();
3171
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
3172
+ const newBase = await uniqueName(dir, baseDesired);
3173
+ await writeFile(dir, newBase, content);
3174
+ await removeBoardFile(state.currentBoard.handle, node.file);
3175
+ node.file = `texts/${newBase}`;
3176
+ } catch (e) { console.error('rename .md failed', e); }
3177
+ }
3178
+ }
3179
+ node.name = trimmed;
3180
+ scheduleSave();
3181
+ }
3182
+
2940
3183
  function showNodeContextMenu(node, clientX, clientY) {
2941
3184
  const menu = $('nodeMenu');
2942
3185
  menu.innerHTML = '';
@@ -2948,6 +3191,7 @@ function showNodeContextMenu(node, clientX, clientY) {
2948
3191
  b.addEventListener('click', () => { if (b.disabled) return; menu.classList.add('hidden'); fn(); });
2949
3192
  menu.appendChild(b);
2950
3193
  };
3194
+ add(node.name ? `✏ Переименовать (${node.name})` : '✏ Переименовать', () => renameNode(node));
2951
3195
  add('📋 Логи', () => showNodeLogs(node));
2952
3196
  if (node.generated) add('⚙ Параметры', () => showNodeSettings(node));
2953
3197
  if (node.status === 'generating') {
@@ -3038,6 +3282,9 @@ function showNodeLogs(node) {
3038
3282
  meta.push(`gen.model: ${node.generated.model || node.generated.modelKey || '—'}`);
3039
3283
  meta.push(`gen.taskId: ${node.generated.taskId || '—'}`);
3040
3284
  meta.push(`gen.state: ${node.generated.state || '—'}`);
3285
+ if (typeof node.generated.creditsCharged === 'number') {
3286
+ meta.push(`gen.cost: ${node.generated.creditsCharged} credits`);
3287
+ }
3041
3288
  if (node.generated.voiceId) meta.push(`gen.voice: ${node.generated.voiceName || node.generated.voiceId}`);
3042
3289
  if (node.generated.tones?.length) meta.push(`gen.tones: ${node.generated.tones.join(', ')}`);
3043
3290
  if (node.generated.refs?.length) {
@@ -3263,7 +3510,19 @@ async function renderNodeBody(node, body) {
3263
3510
  node.error = undefined;
3264
3511
  scheduleSave();
3265
3512
  renderNodeBody(node, body);
3266
- startGenerationJob(node, node.generated.kind, node.generated.prompt, state.currentBoard.handle, state.currentBoard.key);
3513
+ const kind = node.generated.kind;
3514
+ const bH = state.currentBoard.handle;
3515
+ const bK = state.currentBoard.key;
3516
+ if (kind === 'audio') {
3517
+ runTTSJob(node, node.generated.prompt, bH, bK, node.generated.voiceId);
3518
+ } else if (kind === 'text') {
3519
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
3520
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
3521
+ runTextJob(node, node.generated.prompt, model, bH, bK, imageRefs);
3522
+ } else {
3523
+ const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
3524
+ startGenerationJob(node, kind, node.generated.prompt, refs, bH, bK, node.generated.modelKey);
3525
+ }
3267
3526
  });
3268
3527
  wrap.appendChild(retry);
3269
3528
  }
@@ -3497,6 +3756,9 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3497
3756
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
3498
3757
  b.classList.toggle('active', b.dataset.kind === forceKind));
3499
3758
  $('imageModelRow').style.display = forceKind === 'image' ? '' : 'none';
3759
+ $('videoOptionsRow').style.display = forceKind === 'video' ? '' : 'none';
3760
+
3761
+ $('videoModelRow').style.display = forceKind === 'video' ? '' : 'none';
3500
3762
  $('voiceRow').style.display = forceKind === 'audio' ? '' : 'none';
3501
3763
  $('tonesRow').style.display = forceKind === 'audio' ? '' : 'none';
3502
3764
  const titleEl = $('genTitle');
@@ -3515,12 +3777,15 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3515
3777
  $('genSubmit').disabled = false;
3516
3778
 
3517
3779
  resetPicks();
3518
- presetPicksForBoard();
3780
+ // НЕ вызываем presetPicksForBoard() — иначе при вытягивании ноды из ноды
3781
+ // на доске персонажа авто-подставится этот персонаж в picked-список,
3782
+ // а юзер ожидает чистую форму (он явно не выбирал персонажа).
3519
3783
  renderCharsPickChips();
3520
3784
  renderLocPickSelect();
3521
3785
  syncCharLocRows();
3522
3786
 
3523
- // Если источник — картинка/видео, кладём его в "Исходный кадр" вместо @-ссылки в промпте
3787
+ // Если источник — картинка/видео, кладём его в "Исходный кадр".
3788
+ // Для VIDEO thumbnail НЕ показываем (юзер просил), только имя.
3524
3789
  if ((fromNode.type === 'image' || fromNode.type === 'video') && fromNode.file) {
3525
3790
  state.sourceRef = {
3526
3791
  file: fromNode.file,
@@ -3528,15 +3793,22 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3528
3793
  boardHandle: state.currentBoard.handle,
3529
3794
  use: true,
3530
3795
  };
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 {}
3796
+ if (fromNode.type === 'image') {
3797
+ let thumbUrl = state.currentBoard.urls[fromNode.file];
3798
+ if (!thumbUrl) {
3799
+ try {
3800
+ const fh = await resolveBoardFile(state.currentBoard.handle, fromNode.file);
3801
+ thumbUrl = URL.createObjectURL(await fh.getFile());
3802
+ state.currentBoard.urls[fromNode.file] = thumbUrl;
3803
+ } catch {}
3804
+ }
3805
+ $('sourceRefThumb').src = thumbUrl || '';
3806
+ $('sourceRefThumb').style.display = '';
3807
+ } else {
3808
+ // video — без thumbnail, чтобы не дёргать декодер видео ради превьюшки.
3809
+ $('sourceRefThumb').src = '';
3810
+ $('sourceRefThumb').style.display = 'none';
3538
3811
  }
3539
- $('sourceRefThumb').src = thumbUrl || '';
3540
3812
  $('sourceRefName').textContent = fromNode.name || fromNode.file.split('/').pop();
3541
3813
  applySourceRefVisuals();
3542
3814
  $('genPrompt').value = '';
@@ -3582,7 +3854,7 @@ function attachResize(el, node, handle) {
3582
3854
  function attachDrag(el, node) {
3583
3855
  const header = el.querySelector('.node-header');
3584
3856
  header.addEventListener('mousedown', e => {
3585
- if (e.target.closest('.delete') || e.target.closest('.name')) return;
3857
+ if (e.target.closest('.delete')) return;
3586
3858
  // Multi-select c модификаторами — без drag
3587
3859
  if (e.metaKey || e.ctrlKey || e.shiftKey) {
3588
3860
  e.preventDefault();
@@ -3608,6 +3880,22 @@ function attachDrag(el, node) {
3608
3880
  arr.splice(idx, 1);
3609
3881
  arr.push(node);
3610
3882
  }
3883
+
3884
+ // Multi-drag: если выделено несколько нод и кликнули по одной из них —
3885
+ // двигаем всю группу с одинаковым delta. dragTargets: { node, el, origX, origY }.
3886
+ const isMulti = state.selectedNodeIds.size > 1 && state.selectedNodeIds.has(node.id);
3887
+ const dragTargets = isMulti
3888
+ ? arr.filter(n => state.selectedNodeIds.has(n.id)).map(n => ({
3889
+ node: n,
3890
+ el: canvas.querySelector(`.node[data-id="${n.id}"]`),
3891
+ origX: n.x,
3892
+ origY: n.y,
3893
+ })).filter(t => t.el)
3894
+ : [{ node, el, origX, origY }];
3895
+ if (isMulti) {
3896
+ for (const t of dragTargets) t.el.classList.add('selected');
3897
+ }
3898
+
3611
3899
  let dragInitialized = false; // снято на 1-м move ≥ 4px (избегаем «ложных» переносов)
3612
3900
  let lastTimelineHover = null; // {trackEl, time} — чтобы рисовать индикатор drop
3613
3901
  let nodePosChanged = false; // была ли реальная мутация node.x/y
@@ -3625,8 +3913,8 @@ function attachDrag(el, node) {
3625
3913
  const tlTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
3626
3914
  .find(n => n.classList?.contains('timeline-track'));
3627
3915
  const isMedia = ['image','video','audio'].includes(node.type) && node.file;
3628
- if (tlTrackEl && isMedia) {
3629
- // Drag-out на таймлайн: показываем индикатор drop, не трогаем node.x/y
3916
+ // Drop-on-timeline для multi-drag не имеет смысла — отключаем.
3917
+ if (tlTrackEl && isMedia && !isMulti) {
3630
3918
  const kind = tlTrackEl.dataset.trackKind;
3631
3919
  const compat = (kind === 'video' && (node.type === 'video' || node.type === 'image'))
3632
3920
  || (kind === 'audio' && node.type === 'audio');
@@ -3652,16 +3940,20 @@ function attachDrag(el, node) {
3652
3940
  dropMarker.style.display = 'none';
3653
3941
  lastTimelineHover = null;
3654
3942
  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';
3943
+ const dxNode = (ev.clientX - startX) / state.zoom;
3944
+ const dyNode = (ev.clientY - startY) / state.zoom;
3945
+ for (const t of dragTargets) {
3946
+ t.node.x = Math.max(0, t.origX + dxNode);
3947
+ t.node.y = Math.max(0, t.origY + dyNode);
3948
+ t.el.style.left = t.node.x + 'px';
3949
+ t.el.style.top = t.node.y + 'px';
3950
+ }
3659
3951
  renderConnections();
3660
3952
  };
3661
3953
  const onUp = async () => {
3662
3954
  document.removeEventListener('mousemove', onMove);
3663
3955
  document.removeEventListener('mouseup', onUp);
3664
- el.classList.remove('selected');
3956
+ for (const t of dragTargets) t.el.classList.remove('selected');
3665
3957
  el.classList.remove('dragging-to-timeline');
3666
3958
  dropMarker.remove();
3667
3959
  if (lastTimelineHover) {
@@ -4620,6 +4912,11 @@ async function restartJob(nodeId) {
4620
4912
  const kind = node.generated.kind;
4621
4913
  if (kind === 'audio') {
4622
4914
  runTTSJob(node, node.generated.prompt, bHandle, bKey, node.generated.voiceId);
4915
+ } else if (kind === 'text') {
4916
+ // text → /api/text (OpenRouter / Chatium), а не /api/generate (KIE).
4917
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
4918
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
4919
+ runTextJob(node, node.generated.prompt, model, bHandle, bKey, imageRefs);
4623
4920
  } else {
4624
4921
  const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
4625
4922
  startGenerationJob(node, kind, node.generated.prompt, refs, bHandle, bKey, node.generated.modelKey);
@@ -4661,23 +4958,46 @@ async function regenerateNode(node) {
4661
4958
  renderLocPickSelect();
4662
4959
  syncCharLocRows();
4663
4960
 
4664
- // Исходный кадр — текущий файл ноды (для image/video) станет референсом для модели.
4665
- if (node.file && (node.type === 'image' || node.type === 'video')) {
4961
+ // Исходный кадр для regenerate:
4962
+ // 1. Если нода была ВЫВЕДЕНА из другой ноды (node.generated.sourceRef есть)
4963
+ // — это связь с родительской нодой, её надо сохранить через regenerate
4964
+ // иначе модель сгенерирует не «вариацию из X», а просто новый по промпту.
4965
+ // 2. Иначе video: текущий файл как референс (вариация первого кадра).
4966
+ // 3. Иначе image: НЕ берём свою же картинку — модель просто скопирует.
4967
+ if (node.generated?.sourceRef && node.generated.sourceRef.file) {
4968
+ const sr = node.generated.sourceRef;
4666
4969
  state.sourceRef = {
4667
- file: node.file,
4668
- type: node.type,
4970
+ file: sr.file,
4971
+ type: sr.type,
4669
4972
  boardHandle: state.currentBoard.handle,
4670
4973
  use: true,
4671
4974
  };
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 {}
4975
+ if (sr.type === 'image') {
4976
+ let thumbUrl = state.currentBoard.urls[sr.file];
4977
+ if (!thumbUrl) {
4978
+ try {
4979
+ const fh = await resolveBoardFile(state.currentBoard.handle, sr.file);
4980
+ thumbUrl = URL.createObjectURL(await fh.getFile());
4981
+ state.currentBoard.urls[sr.file] = thumbUrl;
4982
+ } catch {}
4983
+ }
4984
+ $('sourceRefThumb').src = thumbUrl || '';
4985
+ $('sourceRefThumb').style.display = '';
4986
+ } else {
4987
+ $('sourceRefThumb').src = '';
4988
+ $('sourceRefThumb').style.display = 'none';
4679
4989
  }
4680
- $('sourceRefThumb').src = thumbUrl || '';
4990
+ $('sourceRefName').textContent = sr.file.split('/').pop();
4991
+ applySourceRefVisuals();
4992
+ } else if (node.file && node.type === 'video') {
4993
+ state.sourceRef = {
4994
+ file: node.file,
4995
+ type: 'video',
4996
+ boardHandle: state.currentBoard.handle,
4997
+ use: true,
4998
+ };
4999
+ $('sourceRefThumb').src = '';
5000
+ $('sourceRefThumb').style.display = 'none';
4681
5001
  $('sourceRefName').textContent = node.name || node.file.split('/').pop();
4682
5002
  applySourceRefVisuals();
4683
5003
  } else {
@@ -4688,6 +5008,9 @@ async function regenerateNode(node) {
4688
5008
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
4689
5009
  b.classList.toggle('active', b.dataset.kind === state.genKind));
4690
5010
  $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
5011
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
5012
+
5013
+ $('videoModelRow').style.display = state.genKind === 'video' ? '' : 'none';
4691
5014
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
4692
5015
 
4693
5016
  if (g.modelKey && state.genKind === 'image') {
@@ -4695,6 +5018,15 @@ async function regenerateNode(node) {
4695
5018
  document.querySelectorAll('#genModal [data-img-model]').forEach(b =>
4696
5019
  b.classList.toggle('active', b.dataset.imgModel === g.modelKey));
4697
5020
  }
5021
+ // Видео: подставляем сохранённые duration/resolution/aspect для regenerate
5022
+ if (state.genKind === 'video') {
5023
+ if (g.duration) state.videoDuration = g.duration;
5024
+ if (g.resolution) state.videoResolution = g.resolution;
5025
+ if (g.aspectRatio) state.videoAspect = g.aspectRatio;
5026
+ if (g.modelKey) state.videoModel = g.modelKey;
5027
+ syncVideoOptionsActive();
5028
+ syncVideoModelActive();
5029
+ }
4698
5030
  if (state.genKind === 'audio') {
4699
5031
  await loadVoices();
4700
5032
  if (g.voiceId) $('genVoice').value = g.voiceId;
@@ -4753,8 +5085,37 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4753
5085
  }
4754
5086
  const tonePrefix = state.activeTones.map(t => `[${t}]`).join(' ');
4755
5087
  resolvedPrompt = tonePrefix ? `${tonePrefix} ${resolvedRaw}` : resolvedRaw;
5088
+ } else if (kind === 'text') {
5089
+ // Текстовая модель: имя берём как есть (полный slug OpenRouter,
5090
+ // напр. anthropic/claude-sonnet-4). Нет state.textModel — fallback
5091
+ // на сохранённое в ноде или дефолт.
5092
+ modelKey = node.generated?.modelKey || node.generated?.model || 'anthropic/claude-sonnet-4';
5093
+ refs = gatherMediaRefs(rawPrompt);
5094
+ if (opts.sourceRef && opts.sourceRef.file && opts.sourceRef.type === 'image') {
5095
+ const sr = opts.sourceRef;
5096
+ const dup = refs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
5097
+ if (!dup) {
5098
+ refs.unshift({
5099
+ key: '__source__',
5100
+ name: 'исходный кадр',
5101
+ type: 'image',
5102
+ file: sr.file,
5103
+ boardHandle: sr.boardHandle,
5104
+ });
5105
+ }
5106
+ }
5107
+ for (const pr of (opts.pickedSheets || [])) {
5108
+ if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
5109
+ refs.push(pr);
5110
+ }
5111
+ const missing = refs.filter(r => !r.file || r.status === 'generating');
5112
+ if (missing.length) {
5113
+ alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
5114
+ return;
5115
+ }
5116
+ resolvedPrompt = resolveMentions(rawPrompt, refs);
4756
5117
  } else {
4757
- modelKey = kind === 'image' ? state.imageModel : 'seedance-2';
5118
+ modelKey = kind === 'image' ? state.imageModel : (state.videoModel || 'seedance-2');
4758
5119
  refs = gatherMediaRefs(rawPrompt);
4759
5120
  // Подмешиваем "исходный кадр" — текущий файл ноды как референс при редактировании
4760
5121
  let sourceMarker = '';
@@ -4795,21 +5156,51 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4795
5156
  'flux-schnell': 'flux/schnell',
4796
5157
  'sdxl-lightning': 'sdxl/lightning',
4797
5158
  'nano-banana-2': 'nano-banana-2' }[modelKey] || 'nano-banana-2')
4798
- : kind === 'video' ? 'bytedance/seedance-2'
4799
- : 'eleven_v3';
5159
+ : kind === 'video' ? ({
5160
+ 'seedance-2': 'bytedance/seedance-2',
5161
+ 'seedance-2-fast': 'bytedance/seedance-2-fast',
5162
+ 'kling-o1': 'kwaivgi/kling-o1',
5163
+ 'kling-3.0': 'kling-3.0/video',
5164
+ }[modelKey] || 'bytedance/seedance-2')
5165
+ : kind === 'text' ? modelKey // полный slug OpenRouter
5166
+ : 'eleven_v3'; // audio
5167
+
5168
+ // sourceRef нужно сохранить через regenerate, иначе при следующем
5169
+ // открытии modal'а связь с родительской нодой потеряется.
5170
+ // Приоритет: то что юзер активно выбрал в UI (opts.sourceRef);
5171
+ // если не выбирал — оставляем то что было раньше в node.generated.sourceRef.
5172
+ const carryoverSourceRef = opts.sourceRef && opts.sourceRef.file
5173
+ ? { file: opts.sourceRef.file, type: opts.sourceRef.type }
5174
+ : (node.generated?.sourceRef || null);
4800
5175
 
4801
5176
  const seedGen = kind === 'audio'
4802
5177
  ? { kind, prompt: resolvedPrompt, rawPrompt, model: modelId, voiceId, voiceName,
4803
5178
  tones: [...state.activeTones], state: 'submitting' }
4804
5179
  : { kind, prompt: resolvedPrompt, rawPrompt, modelKey, model: modelId,
4805
5180
  refs: refs ? refs.map(r => ({ name: r.name, type: r.type, file: r.file })) : [],
5181
+ ...(carryoverSourceRef ? { sourceRef: carryoverSourceRef } : {}),
4806
5182
  state: 'submitting' };
4807
5183
 
4808
- const newSlot = { file: undefined, generated: seedGen, status: 'generating', error: undefined };
5184
+ // Для text-ноды сохраняем существующий .md-файл (runTextJob перезапишет
5185
+ // его содержимое). Если файла ещё нет — создаём пустой texts/text_X.md
5186
+ // здесь, иначе writeBoardFile упадёт на split('/') от undefined.
5187
+ let preservedFile;
5188
+ if (kind === 'text') {
5189
+ if (node.file) {
5190
+ preservedFile = node.file;
5191
+ } else {
5192
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
5193
+ const mdName = await uniqueName(dir, 'text.md');
5194
+ await writeFile(dir, mdName, '');
5195
+ preservedFile = `texts/${mdName}`;
5196
+ }
5197
+ }
5198
+
5199
+ const newSlot = { file: preservedFile, generated: seedGen, status: 'generating', error: undefined };
4809
5200
  node.history.push(newSlot);
4810
5201
  node.historyIndex = node.history.length - 1;
4811
5202
  node.type = kind;
4812
- node.file = undefined;
5203
+ node.file = preservedFile; // для не-text undefined; для text — путь к .md
4813
5204
  node.generated = newSlot.generated;
4814
5205
  node.status = 'generating';
4815
5206
  node.error = undefined;
@@ -4821,6 +5212,8 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4821
5212
  const bKey = state.currentBoard.key;
4822
5213
  if (kind === 'audio') {
4823
5214
  runTTSJob(node, resolvedPrompt, bHandle, bKey, voiceId);
5215
+ } else if (kind === 'text') {
5216
+ runTextJob(node, resolvedPrompt, modelId, bHandle, bKey, refs);
4824
5217
  } else {
4825
5218
  startGenerationJob(node, kind, resolvedPrompt, refs, bHandle, bKey, modelKey);
4826
5219
  }
@@ -5564,6 +5957,9 @@ function openPhraseFor(charInfo) {
5564
5957
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
5565
5958
  b.classList.toggle('active', b.dataset.kind === 'audio'));
5566
5959
  $('imageModelRow').style.display = 'none';
5960
+ $('videoOptionsRow').style.display = 'none';
5961
+
5962
+ $('videoModelRow').style.display = 'none';
5567
5963
  $('voiceRow').style.display = '';
5568
5964
  $('tonesRow').style.display = '';
5569
5965
  loadVoices().then(() => {
@@ -5742,6 +6138,9 @@ async function openGenModal(kind) {
5742
6138
  b.classList.toggle('active', b.dataset.kind === kind));
5743
6139
  // Видимость рядов под текущий kind
5744
6140
  $('imageModelRow').style.display = kind === 'image' ? '' : 'none';
6141
+ $('videoOptionsRow').style.display = kind === 'video' ? '' : 'none';
6142
+
6143
+ $('videoModelRow').style.display = kind === 'video' ? '' : 'none';
5745
6144
  $('voiceRow').style.display = kind === 'audio' ? '' : 'none';
5746
6145
  $('tonesRow').style.display = kind === 'audio' ? '' : 'none';
5747
6146
  // Заголовок модалки = действие
@@ -5865,11 +6264,14 @@ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
5865
6264
  const url = await _imageRefToDataUrl(ref);
5866
6265
  if (url) images.push({ name: ref.name, url });
5867
6266
  }
6267
+ const provider = await plannedProvider('text');
6268
+ logJob(node.id, `→ POST /api/text → ${provider} (model=${model})`);
5868
6269
  const r = await fetch('/api/text', {
5869
6270
  method: 'POST',
5870
6271
  headers: { 'Content-Type': 'application/json' },
5871
6272
  body: JSON.stringify({ prompt, model, images }),
5872
6273
  });
6274
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5873
6275
  const data = await r.json();
5874
6276
  if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);
5875
6277
  const text = (data.text || '').trim();
@@ -5879,9 +6281,15 @@ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
5879
6281
  await mutateNode(bKey, boardHandle, node.id, n => {
5880
6282
  n.text = text;
5881
6283
  n.status = undefined;
5882
- n.generated = { ...(n.generated || {}), prompt, model: data.model || model, state: 'success' };
6284
+ n.generated = {
6285
+ ...(n.generated || {}),
6286
+ prompt, model: data.model || model, state: 'success',
6287
+ ...(typeof data.cost === 'number' ? { creditsCharged: data.cost } : {}),
6288
+ };
5883
6289
  });
6290
+ if (typeof data.cost === 'number') logJob(node.id, `списано ${data.cost} credits`);
5884
6291
  logJob(node.id, `text-gen done (${text.length} chars)`);
6292
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
5885
6293
  } catch (e) {
5886
6294
  logJob(node.id, `text-gen failed: ${e?.message || e}`);
5887
6295
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -5992,11 +6400,15 @@ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
5992
6400
  n.generated = { ...(n.generated || {}), translatedPrompt: enText };
5993
6401
  });
5994
6402
  }
6403
+ const provider = await plannedProvider('sfx');
6404
+ logJob(node.id, `→ POST /api/sfx → ${provider} (dur=${durationSeconds || '-'}s)`);
5995
6405
  const r = await fetch('/api/sfx', {
5996
6406
  method: 'POST', headers: { 'Content-Type': 'application/json' },
5997
6407
  body: JSON.stringify({ text: enText, durationSeconds }),
5998
6408
  });
6409
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5999
6410
  if (!r.ok) throw new Error((await r.text()).slice(0, 300) || `HTTP ${r.status}`);
6411
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6000
6412
  const blob = await r.blob();
6001
6413
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6002
6414
  const baseName = await uniqueName(dir, `sfx_${Date.now()}.mp3`);
@@ -6005,9 +6417,14 @@ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
6005
6417
  await mutateNode(bKey, boardHandle, node.id, n => {
6006
6418
  n.file = relPath;
6007
6419
  n.status = undefined;
6008
- n.generated = { ...(n.generated || {}), state: 'success' };
6420
+ n.generated = {
6421
+ ...(n.generated || {}), state: 'success',
6422
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
6423
+ };
6009
6424
  });
6425
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6010
6426
  logJob(node.id, `sfx saved → ${relPath}`);
6427
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6011
6428
  } catch (e) {
6012
6429
  logJob(node.id, `sfx failed: ${e?.message || e}\n${e?.stack || ''}`);
6013
6430
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6090,11 +6507,15 @@ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
6090
6507
  n.generated = { ...(n.generated || {}), translatedPrompt: enPrompt };
6091
6508
  });
6092
6509
  }
6510
+ const provider = await plannedProvider('music');
6511
+ logJob(node.id, `→ POST /api/music → ${provider} (dur=${durationMs ? durationMs/1000 + 's' : '-'})`);
6093
6512
  const r = await fetch('/api/music', {
6094
6513
  method: 'POST', headers: { 'Content-Type': 'application/json' },
6095
6514
  body: JSON.stringify({ prompt: enPrompt, durationMs }),
6096
6515
  });
6516
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
6097
6517
  if (!r.ok) throw new Error((await r.text()).slice(0, 300) || `HTTP ${r.status}`);
6518
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6098
6519
  const blob = await r.blob();
6099
6520
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6100
6521
  const baseName = await uniqueName(dir, `music_${Date.now()}.mp3`);
@@ -6103,9 +6524,14 @@ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
6103
6524
  await mutateNode(bKey, boardHandle, node.id, n => {
6104
6525
  n.file = relPath;
6105
6526
  n.status = undefined;
6106
- n.generated = { ...(n.generated || {}), state: 'success' };
6527
+ n.generated = {
6528
+ ...(n.generated || {}), state: 'success',
6529
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
6530
+ };
6107
6531
  });
6532
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6108
6533
  logJob(node.id, `music saved → ${relPath}`);
6534
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6109
6535
  } catch (e) {
6110
6536
  logJob(node.id, `music failed: ${e?.message || e}`);
6111
6537
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6132,6 +6558,9 @@ document.querySelectorAll('#genModal [data-kind]').forEach(b => {
6132
6558
  b.classList.add('active');
6133
6559
  state.genKind = b.dataset.kind;
6134
6560
  $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
6561
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
6562
+
6563
+ $('videoModelRow').style.display = state.genKind === 'video' ? '' : 'none';
6135
6564
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
6136
6565
  $('tonesRow').style.display = state.genKind === 'audio' ? '' : 'none';
6137
6566
  if (state.genKind === 'audio') { loadVoices(); renderTones(); }
@@ -6387,6 +6816,57 @@ document.querySelectorAll('#genModal [data-img-model]').forEach(b => {
6387
6816
  state.imageModel = b.dataset.imgModel;
6388
6817
  });
6389
6818
  });
6819
+ // Переключатель модели видео
6820
+ document.querySelectorAll('#genModal [data-vid-model]').forEach(b => {
6821
+ b.addEventListener('click', () => {
6822
+ document.querySelectorAll('#genModal [data-vid-model]').forEach(x => x.classList.remove('active'));
6823
+ b.classList.add('active');
6824
+ state.videoModel = b.dataset.vidModel;
6825
+ localStorage.setItem('videoModel', state.videoModel);
6826
+ });
6827
+ });
6828
+ // Подсветить активную video-модель при открытии modal'а
6829
+ function syncVideoModelActive() {
6830
+ document.querySelectorAll('#genModal [data-vid-model]').forEach(b =>
6831
+ b.classList.toggle('active', b.dataset.vidModel === state.videoModel));
6832
+ }
6833
+ // Переключатели длительности и разрешения для видео
6834
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(b => {
6835
+ b.addEventListener('click', () => {
6836
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(x => x.classList.remove('active'));
6837
+ b.classList.add('active');
6838
+ state.videoDuration = +b.dataset.vidDur;
6839
+ localStorage.setItem('videoDuration', String(state.videoDuration));
6840
+ });
6841
+ });
6842
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(b => {
6843
+ b.addEventListener('click', () => {
6844
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(x => x.classList.remove('active'));
6845
+ b.classList.add('active');
6846
+ state.videoResolution = b.dataset.vidRes;
6847
+ localStorage.setItem('videoResolution', state.videoResolution);
6848
+ });
6849
+ });
6850
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(b => {
6851
+ b.addEventListener('click', () => {
6852
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(x => x.classList.remove('active'));
6853
+ b.classList.add('active');
6854
+ state.videoAspect = b.dataset.vidAsp;
6855
+ localStorage.setItem('videoAspect', state.videoAspect);
6856
+ });
6857
+ });
6858
+ // Helper: подсветить активные кнопки duration/resolution/aspect согласно state.
6859
+ // Вызывать при открытии любой формы где видны videoOptions.
6860
+ function syncVideoOptionsActive() {
6861
+ document.querySelectorAll('#genModal [data-vid-dur]').forEach(b =>
6862
+ b.classList.toggle('active', +b.dataset.vidDur === state.videoDuration));
6863
+ document.querySelectorAll('#genModal [data-vid-res]').forEach(b =>
6864
+ b.classList.toggle('active', b.dataset.vidRes === state.videoResolution));
6865
+ document.querySelectorAll('#genModal [data-vid-asp]').forEach(b =>
6866
+ b.classList.toggle('active', b.dataset.vidAsp === state.videoAspect));
6867
+ }
6868
+ syncVideoOptionsActive();
6869
+ syncVideoModelActive();
6390
6870
 
6391
6871
  $('genCancel').addEventListener('click', () => {
6392
6872
  state.pendingConnectionFrom = null;
@@ -6519,10 +6999,16 @@ $('genSubmit').addEventListener('click', async () => {
6519
6999
  }
6520
7000
 
6521
7001
  const mediaRefs = gatherMediaRefs(rawPrompt);
6522
- // "Исходный кадр" — добавляем как первый референс, если включён
7002
+ // "Исходный кадр" — добавляем как первый референс, если включён.
7003
+ // Заодно запоминаем savedSourceRef для записи в node.generated, чтобы
7004
+ // связь с родительской нодой пережила regenerate (см. regenerateNode —
7005
+ // там при следующем открытии modal sourceRef восстанавливается из
7006
+ // node.generated.sourceRef).
6523
7007
  let sourceMarker = '';
7008
+ let savedSourceRef = null;
6524
7009
  if (state.sourceRef && state.sourceRef.use && state.sourceRef.file) {
6525
7010
  const sr = state.sourceRef;
7011
+ savedSourceRef = { file: sr.file, type: sr.type };
6526
7012
  const dup = mediaRefs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
6527
7013
  if (!dup) {
6528
7014
  const refType = sr.type === 'video' ? 'video' : 'image';
@@ -6577,7 +7063,7 @@ $('genSubmit').addEventListener('click', async () => {
6577
7063
  kind,
6578
7064
  prompt: resolvedPrompt,
6579
7065
  rawPrompt,
6580
- modelKey: kind === 'image' ? state.imageModel : 'seedance-2',
7066
+ modelKey: kind === 'image' ? state.imageModel : (state.videoModel || 'seedance-2'),
6581
7067
  model: kind === 'image'
6582
7068
  ? ({
6583
7069
  'grok': 'grok-imagine/text-to-image',
@@ -6585,8 +7071,22 @@ $('genSubmit').addEventListener('click', async () => {
6585
7071
  'seedream-5-lite': 'seedream/5-lite-text-to-image',
6586
7072
  'nano-banana-2': 'nano-banana-2',
6587
7073
  }[state.imageModel] || 'nano-banana-2')
6588
- : 'bytedance/seedance-2',
7074
+ : ({
7075
+ 'seedance-2': 'bytedance/seedance-2',
7076
+ 'seedance-2-fast': 'bytedance/seedance-2-fast',
7077
+ 'kling-o1': 'kwaivgi/kling-o1',
7078
+ 'kling-3.0': 'kling-3.0/video',
7079
+ }[state.videoModel || 'seedance-2'] || 'bytedance/seedance-2'),
6589
7080
  refs: mediaRefs.map(r => ({ name: r.name, type: r.type, file: r.file })),
7081
+ // Связь с родительской нодой (выведена через "вытягивание"). Хранится
7082
+ // даже если sourceRef.use=false в state — сохраняем структурную связь.
7083
+ ...(savedSourceRef ? { sourceRef: savedSourceRef } : {}),
7084
+ // Параметры видео сохраняем — при regenerate используем те же (предсказуемость).
7085
+ ...(kind === 'video' ? {
7086
+ duration: state.videoDuration,
7087
+ resolution: state.videoResolution,
7088
+ aspectRatio: state.videoAspect,
7089
+ } : {}),
6590
7090
  },
6591
7091
  };
6592
7092
  if (saveOnly) node.status = 'draft';
@@ -6615,27 +7115,35 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
6615
7115
  await mutateNode(bKey, boardHandle, node.id, n => {
6616
7116
  n.generated = { ...(n.generated || {}), state: 'submitting' };
6617
7117
  });
6618
- logJob(node.id, `POST /api/tts`);
7118
+ const provider = await plannedProvider('tts');
7119
+ logJob(node.id, `→ POST /api/tts → ${provider} (voice=${voiceId})`);
6619
7120
  const r = await fetch('/api/tts', {
6620
7121
  method: 'POST',
6621
7122
  headers: { 'Content-Type': 'application/json' },
6622
7123
  body: JSON.stringify({ text, voiceId, modelId: 'eleven_v3' }),
6623
7124
  });
7125
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
6624
7126
  if (!r.ok) {
6625
7127
  const t = await r.text().catch(() => '');
6626
7128
  logJob(node.id, `tts HTTP ${r.status}: ${t.slice(0,200)}`);
6627
7129
  throw new Error(t || `HTTP ${r.status}`);
6628
7130
  }
7131
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6629
7132
  const blob = await r.blob();
6630
7133
  logJob(node.id, `tts response blob ${blob.size} bytes`);
6631
7134
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6632
7135
  const baseName = await uniqueName(dir, `tts_${Date.now()}.mp3`);
6633
7136
  await writeFile(dir, baseName, blob);
6634
7137
  const relPath = `audio/${baseName}`;
7138
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6635
7139
  logJob(node.id, `tts saved → ${relPath}`);
6636
7140
  await mutateNode(bKey, boardHandle, node.id, n => {
6637
7141
  n.status = undefined; n.error = undefined; n.file = relPath;
7142
+ if (Number.isFinite(cost)) {
7143
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
7144
+ }
6638
7145
  });
7146
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6639
7147
  } catch (e) {
6640
7148
  logJob(node.id, `tts ERROR: ${e.message}`);
6641
7149
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6708,6 +7216,11 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6708
7216
  n.generated = { ...(n.generated || {}), state: 'submitting' };
6709
7217
  });
6710
7218
  const submitBody = { kind, prompt, imageInputs, videoInputs, model: modelKey };
7219
+ if (kind === 'video') {
7220
+ submitBody.duration = node.generated?.duration ?? state.videoDuration;
7221
+ submitBody.resolution = node.generated?.resolution ?? state.videoResolution;
7222
+ submitBody.aspectRatio = node.generated?.aspectRatio ?? state.videoAspect;
7223
+ }
6711
7224
  logJob(node.id, `POST /api/generate body: ${logSafe(submitBody)}`);
6712
7225
  logJob(node.id, `POST /api/generate (image_input=${imageInputs.length}, video_input=${videoInputs.length}, model=${modelKey})`);
6713
7226
  const submitStart = Date.now();
@@ -6720,11 +7233,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6720
7233
  }).catch(() => {});
6721
7234
  }, 5000);
6722
7235
  let r, rawText, data;
7236
+ const provider = await plannedProvider(kind);
7237
+ logJob(node.id, `→ POST /api/generate → ${provider} (kind=${kind} model=${modelKey || '—'})`);
6723
7238
  try {
6724
7239
  r = await fetch('/api/generate', {
6725
7240
  method: 'POST',
6726
7241
  headers: { 'Content-Type': 'application/json' },
6727
- body: JSON.stringify({ kind, prompt, imageInputs, videoInputs, model: modelKey }),
7242
+ body: JSON.stringify(submitBody),
6728
7243
  });
6729
7244
  rawText = await r.text();
6730
7245
  try { data = JSON.parse(rawText); }
@@ -6735,7 +7250,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6735
7250
  throw e;
6736
7251
  }
6737
7252
  clearInterval(submitTimer);
6738
- logJob(node.id, `/api/generate HTTP ${r.status} in ${Date.now() - submitStart}ms`);
7253
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status} in ${Date.now() - submitStart}ms`);
6739
7254
  if (!r.ok || data.error) {
6740
7255
  logJob(node.id, `generate ERROR: ${data.error || rawText.slice(0,200)}`);
6741
7256
  throw new Error(data.error || `HTTP ${r.status}: ${rawText.slice(0,200)}`);
@@ -6773,9 +7288,19 @@ async function resumeJob(node, bKey, boardHandle) {
6773
7288
  updateJobsBadge();
6774
7289
  }
6775
7290
  } 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);
7291
+ // Перезагрузка случилась до сабмита: рестарт с фазы upload+submit.
7292
+ // Маршрутизируем по kind audio/text не идут через KIE.
7293
+ const kind = node.generated.kind;
7294
+ if (kind === 'audio') {
7295
+ await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
7296
+ } else if (kind === 'text') {
7297
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
7298
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
7299
+ await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
7300
+ } else {
7301
+ const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
7302
+ await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
7303
+ }
6779
7304
  }
6780
7305
  }
6781
7306
 
@@ -6813,14 +7338,17 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6813
7338
  logJob(nodeId, `poll #${pollCount} network ERROR: ${e.message}`);
6814
7339
  continue;
6815
7340
  }
7341
+ // Логируем провайдера только когда меняется state (раз в несколько polls'ов).
7342
+ // Иначе таблица логов забьётся повторами «via kie» каждые 4 секунды.
7343
+ const provider = pr.headers.get('x-provider') || '?';
6816
7344
  if (pd.state && pd.state !== lastState) {
6817
- logJob(nodeId, `poll #${pollCount} state=${pd.state}`);
7345
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state}`);
6818
7346
  lastState = pd.state;
6819
7347
  } else if (pollCount % 6 === 0) {
6820
- logJob(nodeId, `poll #${pollCount} state=${pd.state || pd.status}`);
7348
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state || pd.status}`);
6821
7349
  }
6822
7350
  if (pd.status === 'done') {
6823
- logJob(nodeId, `KIE done, downloading ${pd.url?.slice(0,80)}`);
7351
+ logJob(nodeId, `done, downloading ${pd.url?.slice(0,80)}`);
6824
7352
  const blob = await (await fetch(`/api/proxy?url=${encodeURIComponent(pd.url)}`)).blob();
6825
7353
  const ext = kind === 'image' ? 'jpg' : 'mp4';
6826
7354
  const sub = kind === 'image' ? 'frames' : 'clips';
@@ -6828,12 +7356,18 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6828
7356
  const baseName = await uniqueName(dir, `gen_${Date.now()}.${ext}`);
6829
7357
  await writeFile(dir, baseName, blob);
6830
7358
  const relPath = `${sub}/${baseName}`;
7359
+ const cost = typeof pd.cost === 'number' ? pd.cost : null;
7360
+ if (cost !== null) logJob(nodeId, `списано ${cost} credits`);
6831
7361
  logJob(nodeId, `done → file=${relPath} (${blob.size} bytes)`);
6832
7362
  await mutateNode(bKey, boardHandle, nodeId, n => {
6833
7363
  n.status = undefined; n.error = undefined; n.file = relPath;
7364
+ if (cost !== null) {
7365
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
7366
+ }
6834
7367
  });
6835
7368
  state.jobs.delete(nodeId);
6836
7369
  updateJobsBadge();
7370
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6837
7371
  return;
6838
7372
  }
6839
7373
  if (pd.status === 'error') {
@@ -7247,6 +7781,9 @@ async function openGenAudioForTimeline(charInfo, track, time) {
7247
7781
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
7248
7782
  b.classList.toggle('active', b.dataset.kind === 'audio'));
7249
7783
  $('imageModelRow').style.display = 'none';
7784
+ $('videoOptionsRow').style.display = 'none';
7785
+
7786
+ $('videoModelRow').style.display = 'none';
7250
7787
  $('voiceRow').style.display = '';
7251
7788
  $('tonesRow').style.display = '';
7252
7789
  $('sourceRefRow').style.display = 'none';
@@ -8509,6 +9046,7 @@ async function generateReplicaCached(charInfo, replica) {
8509
9046
  headers: { 'Content-Type': 'application/json' },
8510
9047
  body: JSON.stringify({ text: finalText, voiceId: charInfo.voice, modelId: 'eleven_v3' }),
8511
9048
  });
9049
+ console.log(`[replica TTS] via ${r.headers.get('x-provider') || '?'}`);
8512
9050
  if (!r.ok) {
8513
9051
  const t = await r.text().catch(() => '');
8514
9052
  throw new Error(t || `HTTP ${r.status}`);