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 +955 -193
- package/main.js +219 -4
- package/package.json +3 -1
- package/preload.js +13 -0
- package/server.js +492 -45
- package/settings.html +299 -0
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:
|
|
205
|
-
--zoom: 1;
|
|
222
|
+
background-size: 24px 24px;
|
|
206
223
|
}
|
|
207
224
|
.canvas-frame {
|
|
208
225
|
position: relative;
|
|
209
|
-
width:
|
|
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
|
|
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
|
|
251
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.5); user-select: none;
|
|
229
252
|
display: flex; flex-direction: column;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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> 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
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
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
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')
|
|
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
|
-
|
|
3512
|
-
|
|
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
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4086
|
-
|
|
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
|
-
// Исходный кадр
|
|
4413
|
-
|
|
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:
|
|
4416
|
-
type:
|
|
4951
|
+
file: sr.file,
|
|
4952
|
+
type: sr.type,
|
|
4417
4953
|
boardHandle: state.currentBoard.handle,
|
|
4418
4954
|
use: true,
|
|
4419
4955
|
};
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
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
|
-
$('
|
|
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
|
-
: '
|
|
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
|
-
|
|
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 = {
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
6526
|
-
|
|
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, `
|
|
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 (
|
|
7363
|
+
if (fileChanged && node.file) {
|
|
6610
7364
|
syncTimelineClipsForNode(node);
|
|
6611
7365
|
}
|
|
6612
7366
|
syncHistorySlot(node);
|
|
6613
|
-
|
|
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}`);
|