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