kingkont 0.6.1 → 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,
@@ -200,18 +215,26 @@
200
215
 
201
216
  .canvas-wrap {
202
217
  flex: 1; position: relative; overflow: auto; background: #181818;
218
+ /* Точечный паттерн фиксированного размера: при зуме НЕ ререндерим весь фон.
219
+ Раньше background-size = 24px * --zoom — каждый wheel-tick инвалидировал
220
+ весь видимый фон, GPU-коммит занимал сотни мс. */
203
221
  background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
204
- background-size: calc(24px * var(--zoom, 1)) calc(24px * var(--zoom, 1));
205
- --zoom: 1;
222
+ background-size: 24px 24px;
206
223
  }
207
224
  .canvas-frame {
208
225
  position: relative;
209
- width: calc(6000px * var(--zoom, 1));
210
- height: calc(4000px * var(--zoom, 1));
226
+ width: 6000px; height: 4000px;
211
227
  }
212
228
  .canvas {
213
229
  position: absolute; left: 0; top: 0; width: 6000px; height: 4000px;
214
- transform: scale(var(--zoom, 1)); transform-origin: 0 0;
230
+ transform-origin: 0 0;
231
+ /* `will-change: transform` — постоянная промоция в композитный слой.
232
+ Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms
233
+ коммиты), НО зум через transform идёт через GPU без layout/paint
234
+ на main-thread. Без will-change зум форсит full re-raster и layout
235
+ на каждый wheel-tick (видели 751ms Layout + 2900ms commits в трейсе).
236
+ Dynamic will-change (toggle on/off) — ещё хуже из-за promotion churn. */
237
+ will-change: transform;
215
238
  }
216
239
  .canvas-wrap.drag-over::after {
217
240
  content: 'Отпусти, чтобы импортировать';
@@ -225,11 +248,20 @@
225
248
  .node {
226
249
  position: absolute; width: 280px; background: #2a2a2a;
227
250
  border: 1px solid #444; border-radius: 8px;
228
- box-shadow: 0 4px 12px rgba(0,0,0,0.4); user-select: none;
251
+ box-shadow: 0 2px 6px rgba(0,0,0,0.5); user-select: none;
229
252
  display: flex; flex-direction: column;
230
- transition: opacity 0.12s ease, box-shadow 0.12s ease;
231
- /* Stacking context: anchor/name-row остаются внутри ноды и не лезут
232
- поверх соседей. z-order между нодамипо DOM order (drag поднимает). */
253
+ /* `content-visibility: auto` пропускает layout/style/paint для offscreen-нод
254
+ (на холсте 6000×4000 видимы единицы из сотен это огромная экономия).
255
+ С будет работать паре `contain-intrinsic-size`Chrome использует это
256
+ вместо реального измерения для skip-нод.
257
+ НЕ ставим явный `contain: paint` или просто `contain: layout style` —
258
+ первый плодит paint-property-tree (PrePaint >1s на зуме), второй не
259
+ изолирует размер и Chrome всё равно гоняет layout-walk через все ноды
260
+ в каждом frame (482ms layout с dirty=4 из 1446 в трейсе).
261
+ `isolation: isolate` — стекинг-контекст для .anchor (z-index:5),
262
+ чтобы он не пробивал поверх соседних нод. */
263
+ content-visibility: auto;
264
+ contain-intrinsic-size: 280px 200px;
233
265
  isolation: isolate;
234
266
  }
235
267
  .node.dragging-to-timeline {
@@ -480,7 +512,10 @@
480
512
  .content-row { display: flex; flex: 1; min-height: 0; overflow: hidden; }
481
513
  /* === Зафиксированная панель превью на правой стороне === */
482
514
  :root { --preview-w: 420px; --preview-collapsed: 36px; }
483
- body { padding-right: var(--preview-w); transition: padding-right 0.18s ease; }
515
+ /* БЕЗ transition на padding-right/width: оба не композируются,
516
+ анимация прогоняла full-tree layout ~470ms каждый кадр × 11 кадров =
517
+ 1+ секунды затыка при collapse-toggle. Snap-collapse — мгновенно. */
518
+ body { padding-right: var(--preview-w); }
484
519
  body.preview-collapsed { padding-right: var(--preview-collapsed); }
485
520
  .preview-panel {
486
521
  position: fixed; top: 0; right: 0; bottom: 0;
@@ -488,7 +523,6 @@
488
523
  background: #1c1c1c; border-left: 1px solid #333;
489
524
  z-index: 40; overflow: hidden;
490
525
  display: flex; flex-direction: column;
491
- transition: width 0.18s ease;
492
526
  }
493
527
  .preview-panel.collapsed { width: var(--preview-collapsed); }
494
528
  .preview-resize-handle {
@@ -767,35 +801,8 @@
767
801
  .node-header .delete { cursor: pointer; color: #888; font-size: 16px; line-height: 1; padding: 0 4px; }
768
802
  .node-header .delete:hover { color: #f88; }
769
803
 
770
- .node-name-row {
771
- /* Снаружи ноды под её нижней границей. Контент ноды никогда не перекрывается. */
772
- position: absolute; top: 100%; left: 0; right: 0;
773
- margin-top: 2px;
774
- padding: 4px 8px; background: rgba(28,28,28,0.96);
775
- border: 1px solid #383838; border-radius: 6px;
776
- opacity: 0; pointer-events: none; z-index: 4;
777
- transition: opacity 0.12s;
778
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
779
- }
780
- /* Невидимая зона под нодой для удержания hover пока курсор «летит» к input.
781
- Высота захватывает margin-top + name-row + небольшой запас. */
782
- .node::after {
783
- content: ''; position: absolute; top: 100%; left: 0; right: 0;
784
- height: 44px; pointer-events: none;
785
- }
786
- .node:hover::after { pointer-events: auto; }
787
- .node:hover .node-name-row,
788
- .node-name-row:hover, .node-name-row:focus-within {
789
- opacity: 1; pointer-events: auto;
790
- }
791
- .node-name-row .name {
792
- width: 100%; background: transparent; border: 1px solid transparent;
793
- color: #ddd; font-size: 12px; outline: none;
794
- padding: 2px 6px; border-radius: 3px;
795
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
796
- }
797
- .node-name-row .name:hover { border-color: #383838; }
798
- .node-name-row .name:focus { background: #1e1e1e; border-color: #6a8aaa; }
804
+ /* Имя ноды убрано из header — переименование через ПКМ → ✏ Переименовать.
805
+ Хедер теперь содержит только × delete слева пустое место для drag-grab). */
799
806
  .node-body { padding: 12px; }
800
807
  .node-body video, .node-body audio {
801
808
  width: 100%; max-height: 220px; background: #000;
@@ -803,6 +810,22 @@
803
810
  }
804
811
  .node.image-node .node-body { padding: 0; overflow: hidden; border-radius: 0 0 8px 8px; }
805
812
  .node.image-node .node-body img { width: 100%; height: auto; display: block; }
813
+ /* Placeholder отображается до lazy-гидрации media — на оффскрин-нодах
814
+ не дёргаем диск/декод. Размером совпадает с типичной картинкой/видео. */
815
+ .media-placeholder {
816
+ display: flex; align-items: center; justify-content: center;
817
+ min-height: 140px; font-size: 32px; opacity: 0.35;
818
+ background: #1e1e1e; border-radius: 4px;
819
+ }
820
+ .node.image-node .media-placeholder,
821
+ .node.video-node .media-placeholder { min-height: 200px; border-radius: 0; }
822
+ /* Thumbnail отображается до загрузки full media — мгновенный визуальный feedback. */
823
+ .media-thumb {
824
+ width: 100%; height: auto; display: block;
825
+ image-rendering: -webkit-optimize-contrast; /* slight crispness for upscaled thumb */
826
+ }
827
+ .node.image-node .media-thumb,
828
+ .node.video-node .media-thumb { display: block; }
806
829
  .node-body textarea {
807
830
  width: 100%; min-height: 100px; background: #1e1e1e; color: #e0e0e0;
808
831
  border: 1px solid #383838; border-radius: 4px; padding: 8px;
@@ -956,6 +979,10 @@
956
979
  <div class="sidebar-list" id="episodeList"></div>
957
980
  </div>
958
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>
959
986
  <span id="jobsInfo" class="jobs-info" style="display:none;"></span>
960
987
  <span class="hint">Перетаскивай файлы на холст · @имя для ссылок</span>
961
988
  </div>
@@ -1115,6 +1142,37 @@
1115
1142
  </div>
1116
1143
  </div>
1117
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
+
1118
1176
 
1119
1177
  <!-- ===== Меню «что добавить» (двойной клик на пустом месте холста) ===== -->
1120
1178
  <div class="add-menu hidden" id="addMenu">
@@ -1243,6 +1301,40 @@
1243
1301
  <button class="seg" data-img-model="sdxl-lightning" type="button" title="Очень быстрая (3-5с)">⚡ SDXL Lightning</button>
1244
1302
  </div>
1245
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>
1246
1338
  <label id="voiceRow" style="display: none;">Голос (ElevenLabs v3)
1247
1339
  <select id="genVoice"></select>
1248
1340
  </label>
@@ -1269,7 +1361,7 @@
1269
1361
  <label id="locPickRow" style="display:none;">Локация
1270
1362
  <select id="locPickSelect"><option value="">— не выбрана —</option></select>
1271
1363
  </label>
1272
- <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>
1273
1365
  <textarea id="genPrompt" placeholder="Опиши, что должно быть. Печатай @ чтобы вставить ссылку на ноду..."></textarea>
1274
1366
  <div id="mentionPopup" class="mention-popup hidden"></div>
1275
1367
  </label>
@@ -1628,6 +1720,9 @@ const state = {
1628
1720
  currentBoard: null, // { kind, name, key, handle, metadata, urls }
1629
1721
  genKind: 'image',
1630
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',
1631
1726
  jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
1632
1727
  undoStack: [], // {type, ...payload}
1633
1728
  zoom: 1,
@@ -1658,6 +1753,36 @@ function logJob(nodeId, msg) {
1658
1753
  if (arr.length > 1000) arr.splice(0, arr.length - 1000);
1659
1754
  console.log(`[${nodeId.slice(0,8)}] ${msg}`);
1660
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
+ }
1661
1786
  // Безопасная сериализация для логов: обрезает длинные строки и убирает blob/handle
1662
1787
  function logSafe(obj) {
1663
1788
  try {
@@ -1878,7 +2003,145 @@ window.addEventListener('DOMContentLoaded', async () => {
1878
2003
  vlog('err', 'restore failed: ' + (e?.message || e));
1879
2004
  }
1880
2005
  await renderWelcomeRecents();
2006
+ // Стартуем обновление баланса. Сразу + каждые 60 сек.
2007
+ refreshBalance().catch(() => {});
2008
+ setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
2009
+ });
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');
1881
2143
  });
2144
+ document.getElementById('txLogRefresh')?.addEventListener('click', () => loadTxLog());
1882
2145
 
1883
2146
  // === Recents store ===
1884
2147
  // Метаданные ({ name, thumbDataUrl, ts }) — в JSON-файле через recentsStore
@@ -2648,6 +2911,9 @@ async function selectBoard(board) {
2648
2911
  for (const url of _clipURLCache.values()) URL.revokeObjectURL(url);
2649
2912
  _clipURLCache.clear();
2650
2913
  _audioBufferCache.clear();
2914
+ // Thumbnails — тоже revoke предыдущей доски (иначе утечка blob URLs)
2915
+ for (const url of _thumbUrls.values()) URL.revokeObjectURL(url);
2916
+ _thumbUrls.clear();
2651
2917
  // Восстанавливаем playhead из scene.json (если был сохранён)
2652
2918
  const tlMeta = state.currentBoard.metadata.timeline;
2653
2919
  state.playheadTime = (tlMeta && typeof tlMeta.playhead === 'number') ? tlMeta.playhead : 0;
@@ -2662,14 +2928,14 @@ async function selectBoard(board) {
2662
2928
  if (view) {
2663
2929
  if (typeof view.zoom === 'number') {
2664
2930
  state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, view.zoom));
2665
- canvasWrap.style.setProperty('--zoom', state.zoom);
2931
+ applyZoomStyles(state.zoom);
2666
2932
  $('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
2667
2933
  }
2668
2934
  if (typeof view.scrollLeft === 'number') canvasWrap.scrollLeft = view.scrollLeft;
2669
2935
  if (typeof view.scrollTop === 'number') canvasWrap.scrollTop = view.scrollTop;
2670
2936
  } else {
2671
2937
  state.zoom = 1;
2672
- canvasWrap.style.setProperty('--zoom', 1);
2938
+ applyZoomStyles(1);
2673
2939
  $('zoomLabel').textContent = '100%';
2674
2940
  canvasWrap.scrollLeft = 0;
2675
2941
  canvasWrap.scrollTop = 0;
@@ -2729,16 +2995,24 @@ function bezierPath(x1, y1, x2, y2) {
2729
2995
  return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
2730
2996
  }
2731
2997
 
2998
+ // БЕЗ чтения offsetWidth/Height из DOM:
2999
+ // - offsetWidth/Height на content-visibility:auto-элементе ФОРСИТ материализацию
3000
+ // (Chrome обязан посчитать реальные размеры → теряем весь смысл оффскрин-кулинга,
3001
+ // selectBoard 338ms layout с dirty=45 of 784 — все 784 объекта пробуждались)
3002
+ // - Используем кэш в n.width/n.height; обновляется ResizeObserver'ом ниже.
3003
+ // Дефолт по типу — на первый рендер пока размер не измерен.
3004
+ function _defaultH(n) {
3005
+ if (n.type === 'text') return n.height || 120;
3006
+ if (n.type === 'audio') return n.height || 110;
3007
+ if (n.type === 'image') return n.height || 220;
3008
+ if (n.type === 'video') return n.height || 220;
3009
+ return n.height || 80;
3010
+ }
2732
3011
  function nodeAnchorOut(n) {
2733
- const el = canvas.querySelector(`.node[data-id="${n.id}"]`);
2734
- const w = el ? el.offsetWidth : (n.width || 280);
2735
- const h = el ? el.offsetHeight : (n.height || 60);
2736
- return { x: n.x + w, y: n.y + h / 2 };
3012
+ return { x: n.x + (n.width || 280), y: n.y + _defaultH(n) / 2 };
2737
3013
  }
2738
3014
  function nodeAnchorIn(n) {
2739
- const el = canvas.querySelector(`.node[data-id="${n.id}"]`);
2740
- const h = el ? el.offsetHeight : (n.height || 60);
2741
- return { x: n.x, y: n.y + h / 2 };
3015
+ return { x: n.x, y: n.y + _defaultH(n) / 2 };
2742
3016
  }
2743
3017
 
2744
3018
  function renderConnections() {
@@ -2802,42 +3076,15 @@ async function createNodeEl(node) {
2802
3076
 
2803
3077
  const header = document.createElement('div');
2804
3078
  header.className = 'node-header';
2805
- header.title = 'Тяни, чтобы переместить (или брось на таймлайн — добавится клипом)';
3079
+ header.title = 'Тяни, чтобы переместить. Переименовать ПКМ Переименовать.';
3080
+
3081
+ // Имя ноды НЕ показываем в header — было слишком тесно (overflow-проблемы),
3082
+ // переименование вынесено в контекстное меню (ПКМ → ✏ Переименовать).
2806
3083
  const del = document.createElement('span');
2807
3084
  del.className = 'delete'; del.textContent = '×'; del.title = 'Удалить ноду';
2808
3085
  header.appendChild(del);
2809
3086
  el.appendChild(header);
2810
3087
 
2811
- const nameRow = document.createElement('div');
2812
- nameRow.className = 'node-name-row';
2813
- const nameInput = document.createElement('input');
2814
- nameInput.className = 'name';
2815
- nameInput.value = node.name || '';
2816
- nameInput.placeholder = 'имя для @ссылок';
2817
- nameInput.addEventListener('mousedown', e => e.stopPropagation());
2818
- nameInput.addEventListener('input', () => { node.name = nameInput.value.trim(); scheduleSave(); });
2819
- nameInput.addEventListener('blur', async () => {
2820
- const newName = nameInput.value.trim();
2821
- if (!newName) { nameInput.value = node.name || ''; return; }
2822
- if (node.type === 'text' && node.file) {
2823
- const baseDesired = newName + '.md';
2824
- const currentBase = node.file.split('/').pop();
2825
- if (baseDesired === currentBase) return;
2826
- try {
2827
- const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
2828
- const content = await (await fh.getFile()).text();
2829
- const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
2830
- const newBase = await uniqueName(dir, baseDesired);
2831
- await writeFile(dir, newBase, content);
2832
- await removeBoardFile(state.currentBoard.handle, node.file);
2833
- node.file = `texts/${newBase}`;
2834
- scheduleSave();
2835
- } catch (e) { console.error('rename .md failed', e); }
2836
- }
2837
- });
2838
- nameRow.appendChild(nameInput);
2839
- el.appendChild(nameRow);
2840
-
2841
3088
  const body = document.createElement('div');
2842
3089
  body.className = 'node-body';
2843
3090
  el.appendChild(body);
@@ -2891,6 +3138,31 @@ async function createNodeEl(node) {
2891
3138
  }
2892
3139
 
2893
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
+
2894
3166
  function showNodeContextMenu(node, clientX, clientY) {
2895
3167
  const menu = $('nodeMenu');
2896
3168
  menu.innerHTML = '';
@@ -2902,6 +3174,7 @@ function showNodeContextMenu(node, clientX, clientY) {
2902
3174
  b.addEventListener('click', () => { if (b.disabled) return; menu.classList.add('hidden'); fn(); });
2903
3175
  menu.appendChild(b);
2904
3176
  };
3177
+ add(node.name ? `✏ Переименовать (${node.name})` : '✏ Переименовать', () => renameNode(node));
2905
3178
  add('📋 Логи', () => showNodeLogs(node));
2906
3179
  if (node.generated) add('⚙ Параметры', () => showNodeSettings(node));
2907
3180
  if (node.status === 'generating') {
@@ -2992,6 +3265,9 @@ function showNodeLogs(node) {
2992
3265
  meta.push(`gen.model: ${node.generated.model || node.generated.modelKey || '—'}`);
2993
3266
  meta.push(`gen.taskId: ${node.generated.taskId || '—'}`);
2994
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
+ }
2995
3271
  if (node.generated.voiceId) meta.push(`gen.voice: ${node.generated.voiceName || node.generated.voiceId}`);
2996
3272
  if (node.generated.tones?.length) meta.push(`gen.tones: ${node.generated.tones.join(', ')}`);
2997
3273
  if (node.generated.refs?.length) {
@@ -3217,7 +3493,19 @@ async function renderNodeBody(node, body) {
3217
3493
  node.error = undefined;
3218
3494
  scheduleSave();
3219
3495
  renderNodeBody(node, body);
3220
- 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
+ }
3221
3509
  });
3222
3510
  wrap.appendChild(retry);
3223
3511
  }
@@ -3236,6 +3524,9 @@ async function renderNodeBody(node, body) {
3236
3524
  const nodeEl = body.closest('.node');
3237
3525
  nodeEl?.classList.toggle('image-node', node.type === 'image');
3238
3526
  nodeEl?.classList.toggle('video-node', node.type === 'video');
3527
+ // Re-render: снимаем старого hydrate-наблюдателя, иначе он может
3528
+ // повторно стрельнуть на старом placeholder который уже удалён.
3529
+ if (nodeEl) { _mediaHydrationObserver.unobserve(nodeEl); nodeEl.__hydrate = null; }
3239
3530
 
3240
3531
  if (node.type === 'audio') {
3241
3532
  const fname = document.createElement('div');
@@ -3243,70 +3534,138 @@ async function renderNodeBody(node, body) {
3243
3534
  fname.textContent = node.file;
3244
3535
  body.appendChild(fname);
3245
3536
  }
3246
- try {
3247
- let url = state.currentBoard.urls[node.file];
3248
- if (!url) {
3249
- const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
3250
- url = URL.createObjectURL(await fh.getFile());
3251
- state.currentBoard.urls[node.file] = url;
3252
- }
3253
- let media;
3254
- if (node.type === 'image') {
3255
- media = document.createElement('img');
3256
- media.src = url; media.alt = node.file; media.draggable = false;
3257
- } else {
3258
- media = document.createElement(node.type);
3259
- media.src = url;
3260
- if (node.type === 'video') media.controls = true;
3261
- media.preload = 'metadata';
3262
- }
3263
- media.addEventListener('mousedown', e => e.stopPropagation());
3264
- body.appendChild(media);
3265
-
3266
- if (node.type === 'video') {
3267
- const speedRow = document.createElement('div');
3268
- speedRow.className = 'audio-speed';
3269
- const savedRate = parseFloat(localStorage.getItem('mediaPlaybackRate') || '1');
3270
- media.playbackRate = savedRate;
3271
- for (const s of [0.5, 0.75, 1, 1.25, 1.5, 2]) {
3272
- const btn = document.createElement('button');
3273
- btn.textContent = s + '×';
3274
- if (s === savedRate) btn.classList.add('active');
3275
- btn.addEventListener('mousedown', e => e.stopPropagation());
3276
- btn.addEventListener('click', e => {
3277
- e.stopPropagation();
3278
- media.playbackRate = s;
3279
- localStorage.setItem('mediaPlaybackRate', String(s));
3280
- speedRow.querySelectorAll('button').forEach(b => b.classList.remove('active'));
3281
- btn.classList.add('active');
3282
- });
3283
- speedRow.appendChild(btn);
3537
+ // Lazy-инстанциация media через IntersectionObserver.
3538
+ // Раньше при selectBoard для 50 нод синхронно вычитывали 50 файлов с диска
3539
+ // (resolveBoardFile + getFile + createObjectURL) и вставляли <img>/<video>/<audio>
3540
+ // браузер сразу декодировал. Теперь только видимые (+rootMargin для plавного
3541
+ // pre-load) реально материализуются.
3542
+ const placeholder = document.createElement('div');
3543
+ placeholder.className = 'media-placeholder';
3544
+ placeholder.textContent = node.type === 'image' ? '🖼' : node.type === 'video' ? '🎬' : '🎙';
3545
+ body.appendChild(placeholder);
3546
+
3547
+ // Для image/video пробуем показать thumbnail сразу (если он сохранён на диске).
3548
+ // Это асинхронно, но дешёво (5KB jpg vs 2MB исходный) — placeholder ОЧЕНЬ быстро
3549
+ // заменяется на нормальное превью, не дожидаясь IntersectionObserver-а.
3550
+ if ((node.type === 'image' || node.type === 'video') && state.currentBoard) {
3551
+ const bHandle = state.currentBoard.handle;
3552
+ const bKey = state.currentBoard.key;
3553
+ loadThumbnailURL(bHandle, bKey, node.file).then(url => {
3554
+ if (!url) return;
3555
+ if (!placeholder.parentNode) return; // уже заменили на full-media
3556
+ const thumb = document.createElement('img');
3557
+ thumb.src = url;
3558
+ thumb.className = 'media-thumb';
3559
+ thumb.decoding = 'async';
3560
+ thumb.draggable = false;
3561
+ thumb.addEventListener('mousedown', e => e.stopPropagation());
3562
+ placeholder.replaceWith(thumb);
3563
+ // Сохраняем ссылку в nodeEl, чтобы при hydrate заменить на full
3564
+ if (nodeEl) nodeEl.__thumbEl = thumb;
3565
+ });
3566
+ }
3567
+
3568
+ const hydrate = async () => {
3569
+ try {
3570
+ let url = state.currentBoard.urls[node.file];
3571
+ if (!url) {
3572
+ const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
3573
+ url = URL.createObjectURL(await fh.getFile());
3574
+ state.currentBoard.urls[node.file] = url;
3575
+ }
3576
+ let media;
3577
+ if (node.type === 'image') {
3578
+ media = document.createElement('img');
3579
+ media.src = url; media.alt = node.file; media.draggable = false;
3580
+ media.decoding = 'async';
3581
+ } else {
3582
+ media = document.createElement(node.type);
3583
+ media.src = url;
3584
+ if (node.type === 'video') media.controls = true;
3585
+ // preload="none" — заголовки video/audio НЕ читаются пока юзер не запустит play.
3586
+ // Раньше "metadata" грузил chunk для duration/dimensions с каждой ноды на selectBoard.
3587
+ media.preload = 'none';
3284
3588
  }
3285
- body.appendChild(speedRow);
3589
+ media.addEventListener('mousedown', e => e.stopPropagation());
3590
+ // Заменяем placeholder ИЛИ ранее показанный thumbnail
3591
+ const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode)
3592
+ ? nodeEl.__thumbEl
3593
+ : placeholder;
3594
+ target.replaceWith(media);
3595
+ if (nodeEl) nodeEl.__thumbEl = null;
3596
+ // В фоне генерируем/обновляем thumbnail для следующего открытия проекта.
3597
+ // Делаем lazy через rAF чтобы не задерживать первое отображение.
3598
+ if (state.currentBoard && (node.type === 'image' || node.type === 'video')) {
3599
+ const bH = state.currentBoard.handle, bK = state.currentBoard.key;
3600
+ requestIdleCallback?.(() => generateThumbnailIfMissing(bH, bK, node, media))
3601
+ ?? setTimeout(() => generateThumbnailIfMissing(bH, bK, node, media), 1000);
3602
+ }
3603
+ return media;
3604
+ } catch {
3605
+ const m = document.createElement('div');
3606
+ m.className = 'missing';
3607
+ m.textContent = `Файл «${node.file}» не найден`;
3608
+ const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode) ? nodeEl.__thumbEl : placeholder;
3609
+ target.replaceWith(m);
3610
+ return null;
3286
3611
  }
3287
- if (node.type === 'audio') {
3288
- const playRow = document.createElement('div');
3289
- playRow.style.cssText = 'margin-top: 6px;';
3290
- const playBtn = document.createElement('button');
3291
- playBtn.style.cssText = 'width: 100%; font-size: 14px; padding: 6px;';
3292
- const sync = () => { playBtn.textContent = media.paused ? '▶ Play' : '⏸ Pause'; };
3293
- sync();
3294
- playBtn.addEventListener('mousedown', e => e.stopPropagation());
3295
- playBtn.addEventListener('click', e => {
3612
+ };
3613
+
3614
+ let mediaPromise = null;
3615
+ const ensureMedia = () => mediaPromise || (mediaPromise = hydrate());
3616
+ // Когда .node попадает в (расширенный) viewport гидрируем фоном.
3617
+ // Если юзер сразу нажал play ensureMedia() вызовется через wire-handler.
3618
+ if (nodeEl) {
3619
+ _mediaHydrationObserver.observe(nodeEl);
3620
+ nodeEl.__hydrate = ensureMedia;
3621
+ }
3622
+
3623
+ if (node.type === 'video') {
3624
+ const speedRow = document.createElement('div');
3625
+ speedRow.className = 'audio-speed';
3626
+ const savedRate = parseFloat(localStorage.getItem('mediaPlaybackRate') || '1');
3627
+ for (const s of [0.5, 0.75, 1, 1.25, 1.5, 2]) {
3628
+ const btn = document.createElement('button');
3629
+ btn.textContent = s + '×';
3630
+ if (s === savedRate) btn.classList.add('active');
3631
+ btn.addEventListener('mousedown', e => e.stopPropagation());
3632
+ btn.addEventListener('click', async e => {
3296
3633
  e.stopPropagation();
3297
- if (media.paused) media.play().catch(()=>{}); else media.pause();
3634
+ const media = await ensureMedia();
3635
+ if (!media) return;
3636
+ media.playbackRate = s;
3637
+ localStorage.setItem('mediaPlaybackRate', String(s));
3638
+ speedRow.querySelectorAll('button').forEach(b => b.classList.remove('active'));
3639
+ btn.classList.add('active');
3298
3640
  });
3299
- media.addEventListener('play', sync);
3300
- media.addEventListener('pause', sync);
3301
- media.addEventListener('ended', sync);
3302
- playRow.appendChild(playBtn);
3303
- body.appendChild(playRow);
3641
+ speedRow.appendChild(btn);
3304
3642
  }
3305
- } catch {
3306
- const m = document.createElement('div');
3307
- m.className = 'missing';
3308
- m.textContent = `Файл «${node.file}» не найден`;
3309
- body.appendChild(m);
3643
+ body.appendChild(speedRow);
3644
+ }
3645
+ if (node.type === 'audio') {
3646
+ const playRow = document.createElement('div');
3647
+ playRow.style.cssText = 'margin-top: 6px;';
3648
+ const playBtn = document.createElement('button');
3649
+ playBtn.style.cssText = 'width: 100%; font-size: 14px; padding: 6px;';
3650
+ playBtn.textContent = '▶ Play';
3651
+ playBtn.addEventListener('mousedown', e => e.stopPropagation());
3652
+ playBtn.addEventListener('click', async e => {
3653
+ e.stopPropagation();
3654
+ const media = await ensureMedia();
3655
+ if (!media) return;
3656
+ const sync = () => { playBtn.textContent = media.paused ? '▶ Play' : '⏸ Pause'; };
3657
+ // Wire-up sync только при первом play (lazy).
3658
+ if (!media.__wired) {
3659
+ media.__wired = true;
3660
+ media.addEventListener('play', sync);
3661
+ media.addEventListener('pause', sync);
3662
+ media.addEventListener('ended', sync);
3663
+ }
3664
+ if (media.paused) media.play().catch(()=>{}); else media.pause();
3665
+ sync();
3666
+ });
3667
+ playRow.appendChild(playBtn);
3668
+ body.appendChild(playRow);
3310
3669
  }
3311
3670
  }
3312
3671
  }
@@ -3380,6 +3739,7 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3380
3739
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
3381
3740
  b.classList.toggle('active', b.dataset.kind === forceKind));
3382
3741
  $('imageModelRow').style.display = forceKind === 'image' ? '' : 'none';
3742
+ $('videoOptionsRow').style.display = forceKind === 'video' ? '' : 'none';
3383
3743
  $('voiceRow').style.display = forceKind === 'audio' ? '' : 'none';
3384
3744
  $('tonesRow').style.display = forceKind === 'audio' ? '' : 'none';
3385
3745
  const titleEl = $('genTitle');
@@ -3398,12 +3758,15 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3398
3758
  $('genSubmit').disabled = false;
3399
3759
 
3400
3760
  resetPicks();
3401
- presetPicksForBoard();
3761
+ // НЕ вызываем presetPicksForBoard() — иначе при вытягивании ноды из ноды
3762
+ // на доске персонажа авто-подставится этот персонаж в picked-список,
3763
+ // а юзер ожидает чистую форму (он явно не выбирал персонажа).
3402
3764
  renderCharsPickChips();
3403
3765
  renderLocPickSelect();
3404
3766
  syncCharLocRows();
3405
3767
 
3406
- // Если источник — картинка/видео, кладём его в "Исходный кадр" вместо @-ссылки в промпте
3768
+ // Если источник — картинка/видео, кладём его в "Исходный кадр".
3769
+ // Для VIDEO thumbnail НЕ показываем (юзер просил), только имя.
3407
3770
  if ((fromNode.type === 'image' || fromNode.type === 'video') && fromNode.file) {
3408
3771
  state.sourceRef = {
3409
3772
  file: fromNode.file,
@@ -3411,15 +3774,22 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
3411
3774
  boardHandle: state.currentBoard.handle,
3412
3775
  use: true,
3413
3776
  };
3414
- let thumbUrl = state.currentBoard.urls[fromNode.file];
3415
- if (!thumbUrl) {
3416
- try {
3417
- const fh = await resolveBoardFile(state.currentBoard.handle, fromNode.file);
3418
- thumbUrl = URL.createObjectURL(await fh.getFile());
3419
- state.currentBoard.urls[fromNode.file] = thumbUrl;
3420
- } 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';
3421
3792
  }
3422
- $('sourceRefThumb').src = thumbUrl || '';
3423
3793
  $('sourceRefName').textContent = fromNode.name || fromNode.file.split('/').pop();
3424
3794
  applySourceRefVisuals();
3425
3795
  $('genPrompt').value = '';
@@ -3465,7 +3835,7 @@ function attachResize(el, node, handle) {
3465
3835
  function attachDrag(el, node) {
3466
3836
  const header = el.querySelector('.node-header');
3467
3837
  header.addEventListener('mousedown', e => {
3468
- if (e.target.closest('.delete') || e.target.closest('.name')) return;
3838
+ if (e.target.closest('.delete')) return;
3469
3839
  // Multi-select c модификаторами — без drag
3470
3840
  if (e.metaKey || e.ctrlKey || e.shiftKey) {
3471
3841
  e.preventDefault();
@@ -3491,6 +3861,22 @@ function attachDrag(el, node) {
3491
3861
  arr.splice(idx, 1);
3492
3862
  arr.push(node);
3493
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
+
3494
3880
  let dragInitialized = false; // снято на 1-м move ≥ 4px (избегаем «ложных» переносов)
3495
3881
  let lastTimelineHover = null; // {trackEl, time} — чтобы рисовать индикатор drop
3496
3882
  let nodePosChanged = false; // была ли реальная мутация node.x/y
@@ -3508,8 +3894,8 @@ function attachDrag(el, node) {
3508
3894
  const tlTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
3509
3895
  .find(n => n.classList?.contains('timeline-track'));
3510
3896
  const isMedia = ['image','video','audio'].includes(node.type) && node.file;
3511
- if (tlTrackEl && isMedia) {
3512
- // Drag-out на таймлайн: показываем индикатор drop, не трогаем node.x/y
3897
+ // Drop-on-timeline для multi-drag не имеет смысла — отключаем.
3898
+ if (tlTrackEl && isMedia && !isMulti) {
3513
3899
  const kind = tlTrackEl.dataset.trackKind;
3514
3900
  const compat = (kind === 'video' && (node.type === 'video' || node.type === 'image'))
3515
3901
  || (kind === 'audio' && node.type === 'audio');
@@ -3535,16 +3921,20 @@ function attachDrag(el, node) {
3535
3921
  dropMarker.style.display = 'none';
3536
3922
  lastTimelineHover = null;
3537
3923
  nodePosChanged = true;
3538
- node.x = Math.max(0, origX + (ev.clientX - startX) / state.zoom);
3539
- node.y = Math.max(0, origY + (ev.clientY - startY) / state.zoom);
3540
- el.style.left = node.x + 'px';
3541
- 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
+ }
3542
3932
  renderConnections();
3543
3933
  };
3544
3934
  const onUp = async () => {
3545
3935
  document.removeEventListener('mousemove', onMove);
3546
3936
  document.removeEventListener('mouseup', onUp);
3547
- el.classList.remove('selected');
3937
+ for (const t of dragTargets) t.el.classList.remove('selected');
3548
3938
  el.classList.remove('dragging-to-timeline');
3549
3939
  dropMarker.remove();
3550
3940
  if (lastTimelineHover) {
@@ -4059,6 +4449,23 @@ async function pasteClipboardNodes() {
4059
4449
  }
4060
4450
 
4061
4451
  // =================== Zoom ===================
4452
+ // Прямые стили вместо CSS-variable: var(--zoom) каскадился через .canvas-frame и .canvas,
4453
+ // заставляя Chrome делать full layout (partialLayout=false) на каждый wheel-tick.
4454
+ const _canvasFrame = $('canvasFrame');
4455
+ // Frame-size меняется ОТЛОЖЕННО (через debounce 200ms): width на canvasFrame —
4456
+ // layout-affecting свойство, его смена на каждом wheel-tick форсила 751ms Layout.
4457
+ // Транформ на canvas идёт через GPU (will-change: transform → composited),
4458
+ // scroll-bounds во время burst могут быть слегка off, на idle подстраиваются.
4459
+ let _frameSizeTimer = null;
4460
+ function applyZoomStyles(z) {
4461
+ canvas.style.transform = `scale(${z})`;
4462
+ clearTimeout(_frameSizeTimer);
4463
+ _frameSizeTimer = setTimeout(() => {
4464
+ _frameSizeTimer = null;
4465
+ _canvasFrame.style.width = (6000 * z) + 'px';
4466
+ _canvasFrame.style.height = (4000 * z) + 'px';
4467
+ }, 200);
4468
+ }
4062
4469
  function applyZoom(nextZoom, anchorClientX, anchorClientY) {
4063
4470
  nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
4064
4471
  if (nextZoom === state.zoom) return;
@@ -4071,19 +4478,30 @@ function applyZoom(nextZoom, anchorClientX, anchorClientY) {
4071
4478
  const contentX = visualX / state.zoom;
4072
4479
  const contentY = visualY / state.zoom;
4073
4480
  state.zoom = nextZoom;
4074
- canvasWrap.style.setProperty('--zoom', nextZoom);
4481
+ applyZoomStyles(nextZoom);
4075
4482
  canvasWrap.scrollLeft = contentX * nextZoom - (ax - rect.left);
4076
4483
  canvasWrap.scrollTop = contentY * nextZoom - (ay - rect.top);
4077
4484
  $('zoomLabel').textContent = Math.round(nextZoom * 100) + '%';
4078
4485
  scheduleViewSave();
4079
4486
  }
4080
4487
 
4488
+ // rAF-троттлинг zoom-через-колесо: трекпад/Magic Mouse шлют wheel @ 60-120Hz,
4489
+ // мы коалесцируем дельты в один applyZoom за кадр — иначе GPU-коммит не успевает.
4490
+ let _pendingZoom = null;
4081
4491
  canvasWrap.addEventListener('wheel', e => {
4082
4492
  // Ctrl/Cmd + wheel ИЛИ тачпад-pinch (Chrome выставляет ctrlKey=true для pinch)
4083
4493
  if (!(e.ctrlKey || e.metaKey)) return;
4084
4494
  e.preventDefault();
4085
- const factor = Math.exp(-e.deltaY * 0.01);
4086
- applyZoom(state.zoom * factor, e.clientX, e.clientY);
4495
+ if (_pendingZoom) {
4496
+ _pendingZoom.factor *= Math.exp(-e.deltaY * 0.01);
4497
+ _pendingZoom.x = e.clientX; _pendingZoom.y = e.clientY;
4498
+ return;
4499
+ }
4500
+ _pendingZoom = { factor: Math.exp(-e.deltaY * 0.01), x: e.clientX, y: e.clientY };
4501
+ requestAnimationFrame(() => {
4502
+ const z = _pendingZoom; _pendingZoom = null;
4503
+ applyZoom(state.zoom * z.factor, z.x, z.y);
4504
+ });
4087
4505
  }, { passive: false });
4088
4506
 
4089
4507
  $('zoomIn').addEventListener('click', () => applyZoom(state.zoom * 1.25));
@@ -4205,6 +4623,113 @@ function findFreeSpot(w = 280, h = 320) {
4205
4623
  return { x: startX, y: startY };
4206
4624
  }
4207
4625
 
4626
+ // =================== Thumbnails ===================
4627
+ // Маленькая jpg-thumbnail для картинки/видео ноды. Хранится в <scene>/thumbs/.
4628
+ // Имя совпадает с исходным (frames/foo.png → thumbs/foo.png.jpg).
4629
+ // При selectBoard сразу показываем thumb как placeholder (мгновенно), full media
4630
+ // догружается lazy. На повторном открытии проекта эффект сильнее всего —
4631
+ // 50 нод × 5KB jpg декодятся миллисекунды против 50 × 2MB исходных.
4632
+
4633
+ const THUMB_W = 320, THUMB_H = 240, THUMB_QUALITY = 0.7;
4634
+
4635
+ // state: пути thumbnails, разрезолвенных в blob URL
4636
+ const _thumbUrls = new Map(); // `${boardKey}::${relPath}` -> objectURL
4637
+
4638
+ function _thumbName(relPath) {
4639
+ // frames/foo.png → foo.png.jpg
4640
+ return relPath.split('/').pop() + '.jpg';
4641
+ }
4642
+
4643
+ async function loadThumbnailURL(boardHandle, boardKey, relPath) {
4644
+ const cacheKey = `${boardKey}::${relPath}`;
4645
+ const cached = _thumbUrls.get(cacheKey);
4646
+ if (cached) return cached;
4647
+ try {
4648
+ const dir = await boardHandle.getDirectoryHandle('thumbs');
4649
+ const fh = await dir.getFileHandle(_thumbName(relPath));
4650
+ const url = URL.createObjectURL(await fh.getFile());
4651
+ _thumbUrls.set(cacheKey, url);
4652
+ return url;
4653
+ } catch { return null; }
4654
+ }
4655
+
4656
+ // Генерирует и сохраняет thumbnail (если ещё нет). Вызывается ПОСЛЕ того как
4657
+ // full-media уже отображается — фоновая работа, не блокирует UI.
4658
+ async function generateThumbnailIfMissing(boardHandle, boardKey, node, mediaEl) {
4659
+ if (!node.file || (node.type !== 'image' && node.type !== 'video')) return;
4660
+ // Уже есть на диске?
4661
+ try {
4662
+ const dir = await boardHandle.getDirectoryHandle('thumbs');
4663
+ await dir.getFileHandle(_thumbName(node.file));
4664
+ return; // уже есть
4665
+ } catch {}
4666
+ try {
4667
+ const c = document.createElement('canvas');
4668
+ c.width = THUMB_W; c.height = THUMB_H;
4669
+ const ctx = c.getContext('2d');
4670
+ ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, THUMB_W, THUMB_H);
4671
+ let srcW, srcH, drawable;
4672
+ if (node.type === 'image') {
4673
+ if (!mediaEl.complete || !mediaEl.naturalWidth) {
4674
+ await new Promise((r, rej) => { mediaEl.addEventListener('load', r, { once: true }); mediaEl.addEventListener('error', rej, { once: true }); });
4675
+ }
4676
+ drawable = mediaEl; srcW = mediaEl.naturalWidth; srcH = mediaEl.naturalHeight;
4677
+ } else {
4678
+ // video: ждём seek на ~0.3s для выбора симпатичного кадра
4679
+ if (mediaEl.readyState < 2) {
4680
+ mediaEl.preload = 'auto';
4681
+ await new Promise((r, rej) => { mediaEl.addEventListener('loadeddata', r, { once: true }); mediaEl.addEventListener('error', rej, { once: true }); setTimeout(rej, 5000); });
4682
+ }
4683
+ try { mediaEl.currentTime = Math.min(0.3, (mediaEl.duration || 1) * 0.1); } catch {}
4684
+ await new Promise(r => mediaEl.addEventListener('seeked', r, { once: true }));
4685
+ drawable = mediaEl; srcW = mediaEl.videoWidth; srcH = mediaEl.videoHeight;
4686
+ }
4687
+ if (!srcW || !srcH) return;
4688
+ // Letterbox fit
4689
+ const scale = Math.min(THUMB_W / srcW, THUMB_H / srcH);
4690
+ const dw = srcW * scale, dh = srcH * scale;
4691
+ ctx.drawImage(drawable, (THUMB_W - dw) / 2, (THUMB_H - dh) / 2, dw, dh);
4692
+ const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', THUMB_QUALITY));
4693
+ if (!blob) return;
4694
+ const dir = await getOrCreateBoardSubdir(boardHandle, 'thumbs');
4695
+ await writeFile(dir, _thumbName(node.file), blob);
4696
+ } catch (e) { /* silent — это best-effort */ }
4697
+ }
4698
+
4699
+ // Lazy media hydration: когда .node заходит в viewport (+ rootMargin запас на
4700
+ // плавный preload), вызывает свой __hydrate() — обычно ставит src на <img>/<video>/<audio>.
4701
+ // Без этого все 50+ медиа-файлов проекта читались с диска синхронно при selectBoard.
4702
+ //
4703
+ // Сериализация через rAF-очередь: если IntersectionObserver сообщит о 12 нодах
4704
+ // одновременно (например, при selectBoard все видимые сразу), мы не запускаем 12
4705
+ // параллельных hydrate'ов — браузер бы выпустил 12× loadedmetadata/canplay events
4706
+ // в один тик, и PrePaint walks paint-tree для всех сразу (видели 454ms в трейсе).
4707
+ // Гидрируем по 2 за rAF-кадр — нагрузка размазывается, паузы заметно меньше.
4708
+ const _hydrationQueue = [];
4709
+ let _hydrationRunning = false;
4710
+ function _drainHydration() {
4711
+ _hydrationRunning = true;
4712
+ // 2 hydrate-задачи за кадр — баланс между скоростью и плавностью.
4713
+ for (let i = 0; i < 2 && _hydrationQueue.length; i++) {
4714
+ const fn = _hydrationQueue.shift();
4715
+ try { fn(); } catch (e) { console.warn("hydrate fail", e); }
4716
+ }
4717
+ if (_hydrationQueue.length) requestAnimationFrame(_drainHydration);
4718
+ else _hydrationRunning = false;
4719
+ }
4720
+ function _enqueueHydration(fn) {
4721
+ _hydrationQueue.push(fn);
4722
+ if (!_hydrationRunning) requestAnimationFrame(_drainHydration);
4723
+ }
4724
+ const _mediaHydrationObserver = new IntersectionObserver((entries) => {
4725
+ for (const e of entries) {
4726
+ if (!e.isIntersecting) continue;
4727
+ _mediaHydrationObserver.unobserve(e.target);
4728
+ const target = e.target;
4729
+ _enqueueHydration(() => target.__hydrate?.());
4730
+ }
4731
+ }, { rootMargin: '400px' });
4732
+
4208
4733
  let saveTimer = null;
4209
4734
  function scheduleSave() {
4210
4735
  if (!state.currentBoard) return;
@@ -4368,6 +4893,11 @@ async function restartJob(nodeId) {
4368
4893
  const kind = node.generated.kind;
4369
4894
  if (kind === 'audio') {
4370
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);
4371
4901
  } else {
4372
4902
  const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
4373
4903
  startGenerationJob(node, kind, node.generated.prompt, refs, bHandle, bKey, node.generated.modelKey);
@@ -4409,23 +4939,46 @@ async function regenerateNode(node) {
4409
4939
  renderLocPickSelect();
4410
4940
  syncCharLocRows();
4411
4941
 
4412
- // Исходный кадр — текущий файл ноды (для image/video) станет референсом для модели.
4413
- 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;
4414
4950
  state.sourceRef = {
4415
- file: node.file,
4416
- type: node.type,
4951
+ file: sr.file,
4952
+ type: sr.type,
4417
4953
  boardHandle: state.currentBoard.handle,
4418
4954
  use: true,
4419
4955
  };
4420
- let thumbUrl = state.currentBoard.urls[node.file];
4421
- if (!thumbUrl) {
4422
- try {
4423
- const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
4424
- thumbUrl = URL.createObjectURL(await fh.getFile());
4425
- state.currentBoard.urls[node.file] = thumbUrl;
4426
- } 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';
4427
4970
  }
4428
- $('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';
4429
4982
  $('sourceRefName').textContent = node.name || node.file.split('/').pop();
4430
4983
  applySourceRefVisuals();
4431
4984
  } else {
@@ -4436,6 +4989,7 @@ async function regenerateNode(node) {
4436
4989
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
4437
4990
  b.classList.toggle('active', b.dataset.kind === state.genKind));
4438
4991
  $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
4992
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
4439
4993
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
4440
4994
 
4441
4995
  if (g.modelKey && state.genKind === 'image') {
@@ -4443,6 +4997,13 @@ async function regenerateNode(node) {
4443
4997
  document.querySelectorAll('#genModal [data-img-model]').forEach(b =>
4444
4998
  b.classList.toggle('active', b.dataset.imgModel === g.modelKey));
4445
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
+ }
4446
5007
  if (state.genKind === 'audio') {
4447
5008
  await loadVoices();
4448
5009
  if (g.voiceId) $('genVoice').value = g.voiceId;
@@ -4501,6 +5062,35 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4501
5062
  }
4502
5063
  const tonePrefix = state.activeTones.map(t => `[${t}]`).join(' ');
4503
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);
4504
5094
  } else {
4505
5095
  modelKey = kind === 'image' ? state.imageModel : 'seedance-2';
4506
5096
  refs = gatherMediaRefs(rawPrompt);
@@ -4544,20 +5134,45 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4544
5134
  'sdxl-lightning': 'sdxl/lightning',
4545
5135
  'nano-banana-2': 'nano-banana-2' }[modelKey] || 'nano-banana-2')
4546
5136
  : kind === 'video' ? 'bytedance/seedance-2'
4547
- : '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);
4548
5147
 
4549
5148
  const seedGen = kind === 'audio'
4550
5149
  ? { kind, prompt: resolvedPrompt, rawPrompt, model: modelId, voiceId, voiceName,
4551
5150
  tones: [...state.activeTones], state: 'submitting' }
4552
5151
  : { kind, prompt: resolvedPrompt, rawPrompt, modelKey, model: modelId,
4553
5152
  refs: refs ? refs.map(r => ({ name: r.name, type: r.type, file: r.file })) : [],
5153
+ ...(carryoverSourceRef ? { sourceRef: carryoverSourceRef } : {}),
4554
5154
  state: 'submitting' };
4555
5155
 
4556
- 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 };
4557
5172
  node.history.push(newSlot);
4558
5173
  node.historyIndex = node.history.length - 1;
4559
5174
  node.type = kind;
4560
- node.file = undefined;
5175
+ node.file = preservedFile; // для не-text undefined; для text — путь к .md
4561
5176
  node.generated = newSlot.generated;
4562
5177
  node.status = 'generating';
4563
5178
  node.error = undefined;
@@ -4569,6 +5184,8 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
4569
5184
  const bKey = state.currentBoard.key;
4570
5185
  if (kind === 'audio') {
4571
5186
  runTTSJob(node, resolvedPrompt, bHandle, bKey, voiceId);
5187
+ } else if (kind === 'text') {
5188
+ runTextJob(node, resolvedPrompt, modelId, bHandle, bKey, refs);
4572
5189
  } else {
4573
5190
  startGenerationJob(node, kind, resolvedPrompt, refs, bHandle, bKey, modelKey);
4574
5191
  }
@@ -5312,6 +5929,7 @@ function openPhraseFor(charInfo) {
5312
5929
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
5313
5930
  b.classList.toggle('active', b.dataset.kind === 'audio'));
5314
5931
  $('imageModelRow').style.display = 'none';
5932
+ $('videoOptionsRow').style.display = 'none';
5315
5933
  $('voiceRow').style.display = '';
5316
5934
  $('tonesRow').style.display = '';
5317
5935
  loadVoices().then(() => {
@@ -5490,6 +6108,7 @@ async function openGenModal(kind) {
5490
6108
  b.classList.toggle('active', b.dataset.kind === kind));
5491
6109
  // Видимость рядов под текущий kind
5492
6110
  $('imageModelRow').style.display = kind === 'image' ? '' : 'none';
6111
+ $('videoOptionsRow').style.display = kind === 'video' ? '' : 'none';
5493
6112
  $('voiceRow').style.display = kind === 'audio' ? '' : 'none';
5494
6113
  $('tonesRow').style.display = kind === 'audio' ? '' : 'none';
5495
6114
  // Заголовок модалки = действие
@@ -5613,11 +6232,14 @@ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
5613
6232
  const url = await _imageRefToDataUrl(ref);
5614
6233
  if (url) images.push({ name: ref.name, url });
5615
6234
  }
6235
+ const provider = await plannedProvider('text');
6236
+ logJob(node.id, `→ POST /api/text → ${provider} (model=${model})`);
5616
6237
  const r = await fetch('/api/text', {
5617
6238
  method: 'POST',
5618
6239
  headers: { 'Content-Type': 'application/json' },
5619
6240
  body: JSON.stringify({ prompt, model, images }),
5620
6241
  });
6242
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5621
6243
  const data = await r.json();
5622
6244
  if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);
5623
6245
  const text = (data.text || '').trim();
@@ -5627,9 +6249,15 @@ async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
5627
6249
  await mutateNode(bKey, boardHandle, node.id, n => {
5628
6250
  n.text = text;
5629
6251
  n.status = undefined;
5630
- 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
+ };
5631
6257
  });
6258
+ if (typeof data.cost === 'number') logJob(node.id, `списано ${data.cost} credits`);
5632
6259
  logJob(node.id, `text-gen done (${text.length} chars)`);
6260
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
5633
6261
  } catch (e) {
5634
6262
  logJob(node.id, `text-gen failed: ${e?.message || e}`);
5635
6263
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -5740,11 +6368,15 @@ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
5740
6368
  n.generated = { ...(n.generated || {}), translatedPrompt: enText };
5741
6369
  });
5742
6370
  }
6371
+ const provider = await plannedProvider('sfx');
6372
+ logJob(node.id, `→ POST /api/sfx → ${provider} (dur=${durationSeconds || '-'}s)`);
5743
6373
  const r = await fetch('/api/sfx', {
5744
6374
  method: 'POST', headers: { 'Content-Type': 'application/json' },
5745
6375
  body: JSON.stringify({ text: enText, durationSeconds }),
5746
6376
  });
6377
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5747
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);
5748
6380
  const blob = await r.blob();
5749
6381
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
5750
6382
  const baseName = await uniqueName(dir, `sfx_${Date.now()}.mp3`);
@@ -5753,9 +6385,14 @@ async function runSfxJob(node, text, durationSeconds, boardHandle, bKey) {
5753
6385
  await mutateNode(bKey, boardHandle, node.id, n => {
5754
6386
  n.file = relPath;
5755
6387
  n.status = undefined;
5756
- n.generated = { ...(n.generated || {}), state: 'success' };
6388
+ n.generated = {
6389
+ ...(n.generated || {}), state: 'success',
6390
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
6391
+ };
5757
6392
  });
6393
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
5758
6394
  logJob(node.id, `sfx saved → ${relPath}`);
6395
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
5759
6396
  } catch (e) {
5760
6397
  logJob(node.id, `sfx failed: ${e?.message || e}\n${e?.stack || ''}`);
5761
6398
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -5838,11 +6475,15 @@ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
5838
6475
  n.generated = { ...(n.generated || {}), translatedPrompt: enPrompt };
5839
6476
  });
5840
6477
  }
6478
+ const provider = await plannedProvider('music');
6479
+ logJob(node.id, `→ POST /api/music → ${provider} (dur=${durationMs ? durationMs/1000 + 's' : '-'})`);
5841
6480
  const r = await fetch('/api/music', {
5842
6481
  method: 'POST', headers: { 'Content-Type': 'application/json' },
5843
6482
  body: JSON.stringify({ prompt: enPrompt, durationMs }),
5844
6483
  });
6484
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
5845
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);
5846
6487
  const blob = await r.blob();
5847
6488
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
5848
6489
  const baseName = await uniqueName(dir, `music_${Date.now()}.mp3`);
@@ -5851,9 +6492,14 @@ async function runMusicJob(node, prompt, durationMs, boardHandle, bKey) {
5851
6492
  await mutateNode(bKey, boardHandle, node.id, n => {
5852
6493
  n.file = relPath;
5853
6494
  n.status = undefined;
5854
- n.generated = { ...(n.generated || {}), state: 'success' };
6495
+ n.generated = {
6496
+ ...(n.generated || {}), state: 'success',
6497
+ ...(Number.isFinite(cost) ? { creditsCharged: cost } : {}),
6498
+ };
5855
6499
  });
6500
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
5856
6501
  logJob(node.id, `music saved → ${relPath}`);
6502
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
5857
6503
  } catch (e) {
5858
6504
  logJob(node.id, `music failed: ${e?.message || e}`);
5859
6505
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -5880,6 +6526,7 @@ document.querySelectorAll('#genModal [data-kind]').forEach(b => {
5880
6526
  b.classList.add('active');
5881
6527
  state.genKind = b.dataset.kind;
5882
6528
  $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
6529
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
5883
6530
  $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
5884
6531
  $('tonesRow').style.display = state.genKind === 'audio' ? '' : 'none';
5885
6532
  if (state.genKind === 'audio') { loadVoices(); renderTones(); }
@@ -6135,6 +6782,42 @@ document.querySelectorAll('#genModal [data-img-model]').forEach(b => {
6135
6782
  state.imageModel = b.dataset.imgModel;
6136
6783
  });
6137
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();
6138
6821
 
6139
6822
  $('genCancel').addEventListener('click', () => {
6140
6823
  state.pendingConnectionFrom = null;
@@ -6267,10 +6950,16 @@ $('genSubmit').addEventListener('click', async () => {
6267
6950
  }
6268
6951
 
6269
6952
  const mediaRefs = gatherMediaRefs(rawPrompt);
6270
- // "Исходный кадр" — добавляем как первый референс, если включён
6953
+ // "Исходный кадр" — добавляем как первый референс, если включён.
6954
+ // Заодно запоминаем savedSourceRef для записи в node.generated, чтобы
6955
+ // связь с родительской нодой пережила regenerate (см. regenerateNode —
6956
+ // там при следующем открытии modal sourceRef восстанавливается из
6957
+ // node.generated.sourceRef).
6271
6958
  let sourceMarker = '';
6959
+ let savedSourceRef = null;
6272
6960
  if (state.sourceRef && state.sourceRef.use && state.sourceRef.file) {
6273
6961
  const sr = state.sourceRef;
6962
+ savedSourceRef = { file: sr.file, type: sr.type };
6274
6963
  const dup = mediaRefs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
6275
6964
  if (!dup) {
6276
6965
  const refType = sr.type === 'video' ? 'video' : 'image';
@@ -6335,6 +7024,15 @@ $('genSubmit').addEventListener('click', async () => {
6335
7024
  }[state.imageModel] || 'nano-banana-2')
6336
7025
  : 'bytedance/seedance-2',
6337
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
+ } : {}),
6338
7036
  },
6339
7037
  };
6340
7038
  if (saveOnly) node.status = 'draft';
@@ -6363,27 +7061,35 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
6363
7061
  await mutateNode(bKey, boardHandle, node.id, n => {
6364
7062
  n.generated = { ...(n.generated || {}), state: 'submitting' };
6365
7063
  });
6366
- logJob(node.id, `POST /api/tts`);
7064
+ const provider = await plannedProvider('tts');
7065
+ logJob(node.id, `→ POST /api/tts → ${provider} (voice=${voiceId})`);
6367
7066
  const r = await fetch('/api/tts', {
6368
7067
  method: 'POST',
6369
7068
  headers: { 'Content-Type': 'application/json' },
6370
7069
  body: JSON.stringify({ text, voiceId, modelId: 'eleven_v3' }),
6371
7070
  });
7071
+ logJob(node.id, `← via ${r.headers.get('x-provider') || '?'} HTTP ${r.status}`);
6372
7072
  if (!r.ok) {
6373
7073
  const t = await r.text().catch(() => '');
6374
7074
  logJob(node.id, `tts HTTP ${r.status}: ${t.slice(0,200)}`);
6375
7075
  throw new Error(t || `HTTP ${r.status}`);
6376
7076
  }
7077
+ const cost = parseInt(r.headers.get('x-cost-credits') || '', 10);
6377
7078
  const blob = await r.blob();
6378
7079
  logJob(node.id, `tts response blob ${blob.size} bytes`);
6379
7080
  const dir = await getOrCreateBoardSubdir(boardHandle, 'audio');
6380
7081
  const baseName = await uniqueName(dir, `tts_${Date.now()}.mp3`);
6381
7082
  await writeFile(dir, baseName, blob);
6382
7083
  const relPath = `audio/${baseName}`;
7084
+ if (Number.isFinite(cost)) logJob(node.id, `списано ${cost} credits`);
6383
7085
  logJob(node.id, `tts saved → ${relPath}`);
6384
7086
  await mutateNode(bKey, boardHandle, node.id, n => {
6385
7087
  n.status = undefined; n.error = undefined; n.file = relPath;
7088
+ if (Number.isFinite(cost)) {
7089
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
7090
+ }
6386
7091
  });
7092
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6387
7093
  } catch (e) {
6388
7094
  logJob(node.id, `tts ERROR: ${e.message}`);
6389
7095
  await mutateNode(bKey, boardHandle, node.id, n => {
@@ -6456,6 +7162,11 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6456
7162
  n.generated = { ...(n.generated || {}), state: 'submitting' };
6457
7163
  });
6458
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
+ }
6459
7170
  logJob(node.id, `POST /api/generate body: ${logSafe(submitBody)}`);
6460
7171
  logJob(node.id, `POST /api/generate (image_input=${imageInputs.length}, video_input=${videoInputs.length}, model=${modelKey})`);
6461
7172
  const submitStart = Date.now();
@@ -6468,11 +7179,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6468
7179
  }).catch(() => {});
6469
7180
  }, 5000);
6470
7181
  let r, rawText, data;
7182
+ const provider = await plannedProvider(kind);
7183
+ logJob(node.id, `→ POST /api/generate → ${provider} (kind=${kind} model=${modelKey || '—'})`);
6471
7184
  try {
6472
7185
  r = await fetch('/api/generate', {
6473
7186
  method: 'POST',
6474
7187
  headers: { 'Content-Type': 'application/json' },
6475
- body: JSON.stringify({ kind, prompt, imageInputs, videoInputs, model: modelKey }),
7188
+ body: JSON.stringify(submitBody),
6476
7189
  });
6477
7190
  rawText = await r.text();
6478
7191
  try { data = JSON.parse(rawText); }
@@ -6483,7 +7196,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
6483
7196
  throw e;
6484
7197
  }
6485
7198
  clearInterval(submitTimer);
6486
- 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`);
6487
7200
  if (!r.ok || data.error) {
6488
7201
  logJob(node.id, `generate ERROR: ${data.error || rawText.slice(0,200)}`);
6489
7202
  throw new Error(data.error || `HTTP ${r.status}: ${rawText.slice(0,200)}`);
@@ -6521,9 +7234,19 @@ async function resumeJob(node, bKey, boardHandle) {
6521
7234
  updateJobsBadge();
6522
7235
  }
6523
7236
  } else {
6524
- // Перезагрузка случилась до сабмита: рестарт с фазы upload+submit
6525
- const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
6526
- 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
+ }
6527
7250
  }
6528
7251
  }
6529
7252
 
@@ -6561,14 +7284,17 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6561
7284
  logJob(nodeId, `poll #${pollCount} network ERROR: ${e.message}`);
6562
7285
  continue;
6563
7286
  }
7287
+ // Логируем провайдера только когда меняется state (раз в несколько polls'ов).
7288
+ // Иначе таблица логов забьётся повторами «via kie» каждые 4 секунды.
7289
+ const provider = pr.headers.get('x-provider') || '?';
6564
7290
  if (pd.state && pd.state !== lastState) {
6565
- logJob(nodeId, `poll #${pollCount} state=${pd.state}`);
7291
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state}`);
6566
7292
  lastState = pd.state;
6567
7293
  } else if (pollCount % 6 === 0) {
6568
- logJob(nodeId, `poll #${pollCount} state=${pd.state || pd.status}`);
7294
+ logJob(nodeId, `poll #${pollCount} via ${provider} state=${pd.state || pd.status}`);
6569
7295
  }
6570
7296
  if (pd.status === 'done') {
6571
- logJob(nodeId, `KIE done, downloading ${pd.url?.slice(0,80)}`);
7297
+ logJob(nodeId, `done, downloading ${pd.url?.slice(0,80)}`);
6572
7298
  const blob = await (await fetch(`/api/proxy?url=${encodeURIComponent(pd.url)}`)).blob();
6573
7299
  const ext = kind === 'image' ? 'jpg' : 'mp4';
6574
7300
  const sub = kind === 'image' ? 'frames' : 'clips';
@@ -6576,12 +7302,18 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6576
7302
  const baseName = await uniqueName(dir, `gen_${Date.now()}.${ext}`);
6577
7303
  await writeFile(dir, baseName, blob);
6578
7304
  const relPath = `${sub}/${baseName}`;
7305
+ const cost = typeof pd.cost === 'number' ? pd.cost : null;
7306
+ if (cost !== null) logJob(nodeId, `списано ${cost} credits`);
6579
7307
  logJob(nodeId, `done → file=${relPath} (${blob.size} bytes)`);
6580
7308
  await mutateNode(bKey, boardHandle, nodeId, n => {
6581
7309
  n.status = undefined; n.error = undefined; n.file = relPath;
7310
+ if (cost !== null) {
7311
+ n.generated = { ...(n.generated || {}), creditsCharged: cost };
7312
+ }
6582
7313
  });
6583
7314
  state.jobs.delete(nodeId);
6584
7315
  updateJobsBadge();
7316
+ if (typeof window.refreshBalance === 'function') window.refreshBalance();
6585
7317
  return;
6586
7318
  }
6587
7319
  if (pd.status === 'error') {
@@ -6596,6 +7328,23 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6596
7328
  }
6597
7329
  }
6598
7330
 
7331
+ // Обновляет .state-text внутри node-body не перерисовывая всё тело —
7332
+ // для частых state-only тиков ("submitting (5s)" → "(10s)") DOM-rebuild дорог.
7333
+ function updateNodeStateText(nodeId, stateKey) {
7334
+ const root = canvas.querySelector(`.node[data-id="${nodeId}"] .gen-pending .state-text`);
7335
+ if (!root) return false;
7336
+ const LABELS = {
7337
+ 'uploading-refs': 'Загружаю референсы в KIE...',
7338
+ 'submitting': 'Отправляю задачу...',
7339
+ 'queued': 'В очереди...',
7340
+ 'waiting': 'В очереди...',
7341
+ 'queuing': 'В очереди...',
7342
+ 'generating': 'Модель работает...',
7343
+ };
7344
+ root.textContent = LABELS[stateKey] || (stateKey ? `Статус: ${stateKey}` : 'Генерируется...');
7345
+ return true;
7346
+ }
7347
+
6599
7348
  // Обновление ноды (на любой доске): меняем in-memory state если эта доска текущая,
6600
7349
  // иначе грузим metadata, патчим и пишем; всегда сохраняем на диск; если нода видна — обновляем DOM.
6601
7350
  async function mutateNode(bKey, boardHandle, nodeId, mutator) {
@@ -6604,15 +7353,25 @@ async function mutateNode(bKey, boardHandle, nodeId, mutator) {
6604
7353
  const node = state.currentBoard.metadata.nodes.find(n => n.id === nodeId);
6605
7354
  if (node) {
6606
7355
  const fileBefore = node.file;
7356
+ const statusBefore = node.status;
7357
+ const stateBefore = node.generated?.state;
6607
7358
  mutator(node);
7359
+ const fileChanged = node.file !== fileBefore;
7360
+ const statusChanged = node.status !== statusBefore;
7361
+ const stateOnly = !statusChanged && !fileChanged && node.generated?.state !== stateBefore;
6608
7362
  // Если файл сменился (после регена) — подхватить новый файл в клипах таймлайна
6609
- if (node.file && node.file !== fileBefore) {
7363
+ if (fileChanged && node.file) {
6610
7364
  syncTimelineClipsForNode(node);
6611
7365
  }
6612
7366
  syncHistorySlot(node);
6613
- await saveBoardMetadata(boardHandle, state.currentBoard.metadata);
7367
+ scheduleSave();
7368
+ // Частый кейс: меняется только текст статуса генерации. Точечно обновляем .state-text,
7369
+ // не перестраивая всё тело ноды и не дёргая таймлайн.
7370
+ if (stateOnly && updateNodeStateText(nodeId, node.generated?.state)) {
7371
+ return;
7372
+ }
6614
7373
  await refreshNodeDOM(nodeId);
6615
- refreshTimelineForNode(nodeId);
7374
+ if (fileChanged || statusChanged) refreshTimelineForNode(nodeId);
6616
7375
  return;
6617
7376
  }
6618
7377
  }
@@ -6968,6 +7727,7 @@ async function openGenAudioForTimeline(charInfo, track, time) {
6968
7727
  document.querySelectorAll('#genModal [data-kind]').forEach(b =>
6969
7728
  b.classList.toggle('active', b.dataset.kind === 'audio'));
6970
7729
  $('imageModelRow').style.display = 'none';
7730
+ $('videoOptionsRow').style.display = 'none';
6971
7731
  $('voiceRow').style.display = '';
6972
7732
  $('tonesRow').style.display = '';
6973
7733
  $('sourceRefRow').style.display = 'none';
@@ -7842,6 +8602,7 @@ function getOrCreateClipMedia(clip, url) {
7842
8602
  if (m) { try { m.src = ''; } catch {} }
7843
8603
  m = clip.type === 'image' ? document.createElement('img') : document.createElement('video');
7844
8604
  m.src = url;
8605
+ if (clip.type === 'image') m.decoding = 'async';
7845
8606
  if (clip.type === 'video') { m.preload = 'metadata'; m.muted = true; m.disablePictureInPicture = true; }
7846
8607
  _clipMediaCache.set(key, m);
7847
8608
  return m;
@@ -8229,6 +8990,7 @@ async function generateReplicaCached(charInfo, replica) {
8229
8990
  headers: { 'Content-Type': 'application/json' },
8230
8991
  body: JSON.stringify({ text: finalText, voiceId: charInfo.voice, modelId: 'eleven_v3' }),
8231
8992
  });
8993
+ console.log(`[replica TTS] via ${r.headers.get('x-provider') || '?'}`);
8232
8994
  if (!r.ok) {
8233
8995
  const t = await r.text().catch(() => '');
8234
8996
  throw new Error(t || `HTTP ${r.status}`);