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