kingkont 0.14.0 → 0.14.2
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 +1 -1
- package/package.json +1 -1
- package/renderer/board.js +41 -0
- package/renderer/chat.js +127 -37
- package/renderer/generate.js +48 -18
- package/renderer/media.js +7 -2
- package/renderer/state.js +25 -0
- package/renderer/styles.css +4 -1
package/index.html
CHANGED
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
</div>
|
|
131
131
|
<button id="charSettingsBtn" style="display:none;" title="Настройки персонажа">⚙</button>
|
|
132
132
|
<button id="timelineBtn" title="Показать/скрыть таймлайн">🎬 Таймлайн</button>
|
|
133
|
-
<button id="chatBtn" title="
|
|
133
|
+
<button id="chatBtn" title="Открыть/скрыть чат с KingKont (⌘J / Ctrl+J)" onclick="window.kingChat?.toggle?.()">💬 Чат <span style="opacity:0.6; font-size:10px;">⌘J</span></button>
|
|
134
134
|
<!-- скрытые, но используемые элементы (logic ссылается по id) -->
|
|
135
135
|
<span id="hint" class="path" style="display:none;"></span>
|
|
136
136
|
<span id="boardBadge" class="badge" style="display:none;"></span>
|
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -1748,6 +1748,41 @@ $('newLocation').addEventListener('click', async () => {
|
|
|
1748
1748
|
});
|
|
1749
1749
|
|
|
1750
1750
|
// =================== Board (универсально для серии и персонажа) ===================
|
|
1751
|
+
// Если ни одна нода не попадает в видимую область canvas-wrap, скроллим
|
|
1752
|
+
// view на центр bbox всех нод. Используется после selectBoard / openFilm
|
|
1753
|
+
// — типичный кейс: юзер открывает старый проект, scroll был сохранён в
|
|
1754
|
+
// одном месте, ноды расположены далеко (например после chat-добавления).
|
|
1755
|
+
function _autoScrollToNodesIfHidden(padX, padY) {
|
|
1756
|
+
const board = state.currentBoard;
|
|
1757
|
+
if (!board) return;
|
|
1758
|
+
const nodes = board.metadata.nodes || [];
|
|
1759
|
+
if (!nodes.length) return;
|
|
1760
|
+
// Считаем bbox в canvas-coords.
|
|
1761
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1762
|
+
for (const n of nodes) {
|
|
1763
|
+
const w = n.width || 280, h = n.height || 220;
|
|
1764
|
+
minX = Math.min(minX, n.x);
|
|
1765
|
+
minY = Math.min(minY, n.y);
|
|
1766
|
+
maxX = Math.max(maxX, n.x + w);
|
|
1767
|
+
maxY = Math.max(maxY, n.y + h);
|
|
1768
|
+
}
|
|
1769
|
+
const z = state.zoom || 1;
|
|
1770
|
+
// Видимое окно в canvas-coords (viewport / zoom).
|
|
1771
|
+
const wrap = canvasWrap;
|
|
1772
|
+
const viewLeft = (wrap.scrollLeft - padX) / z;
|
|
1773
|
+
const viewTop = (wrap.scrollTop - padY) / z;
|
|
1774
|
+
const viewRight = viewLeft + wrap.clientWidth / z;
|
|
1775
|
+
const viewBottom = viewTop + wrap.clientHeight / z;
|
|
1776
|
+
// Перекрытие? Если bbox нод хоть как-то пересекается с viewport — не трогаем.
|
|
1777
|
+
const intersects = !(maxX < viewLeft || minX > viewRight || maxY < viewTop || minY > viewBottom);
|
|
1778
|
+
if (intersects) return;
|
|
1779
|
+
// Иначе центрируем на bbox.
|
|
1780
|
+
const cx = (minX + maxX) / 2;
|
|
1781
|
+
const cy = (minY + maxY) / 2;
|
|
1782
|
+
wrap.scrollLeft = cx * z + padX - wrap.clientWidth / 2;
|
|
1783
|
+
wrap.scrollTop = cy * z + padY - wrap.clientHeight / 2;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1751
1786
|
async function selectBoard(board) {
|
|
1752
1787
|
// Останавливаем file-watcher предыдущей доски (если был).
|
|
1753
1788
|
stopExternalWatcher();
|
|
@@ -1855,6 +1890,12 @@ async function selectBoard(board) {
|
|
|
1855
1890
|
canvasWrap.scrollTop = padY;
|
|
1856
1891
|
}
|
|
1857
1892
|
|
|
1893
|
+
// Если в текущем viewport не видно ни одной ноды — авто-скролл на bbox нод.
|
|
1894
|
+
// Срабатывает после render'а (через requestAnimationFrame чтобы иметь
|
|
1895
|
+
// актуальные размеры). Обходит как новый view (без сохранённого scroll'a),
|
|
1896
|
+
// так и случай когда юзер вышел далеко от нод и забыл.
|
|
1897
|
+
requestAnimationFrame(() => _autoScrollToNodesIfHidden(padX, padY));
|
|
1898
|
+
|
|
1858
1899
|
// Возобновить незавершённые джобы текущей доски
|
|
1859
1900
|
for (const n of state.currentBoard.metadata.nodes) {
|
|
1860
1901
|
if (n.status === 'generating' && n.generated?.taskId && !state.jobs.has(n.id)) {
|
package/renderer/chat.js
CHANGED
|
@@ -91,27 +91,57 @@
|
|
|
91
91
|
file: n.file || null,
|
|
92
92
|
status: n.status || (n.file ? 'done' : 'draft'),
|
|
93
93
|
modelKey: n.generated?.modelKey || null,
|
|
94
|
+
aspectRatio: n.generated?.aspectRatio || null,
|
|
94
95
|
}));
|
|
95
96
|
const connections = (b.metadata.connections || []).map(c => ({ from: c.from, to: c.to, toPort: c.toPort || null }));
|
|
96
|
-
|
|
97
|
-
return { kind: b.kind, name: b.name, nodes, connections, settings };
|
|
97
|
+
return { kind: b.kind, name: b.name, nodes, connections, settings: b.metadata.settings || {} };
|
|
98
98
|
},
|
|
99
99
|
},
|
|
100
100
|
|
|
101
101
|
add_node: {
|
|
102
|
-
description: 'Добавить ноду на текущую доску. Для image/video/audio
|
|
103
|
-
params: '{"type":"image|video|audio|text","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>"}',
|
|
104
|
-
async handler({ type, name, prompt, x, y, text, modelKey }) {
|
|
102
|
+
description: 'Добавить ноду на текущую доску. Для image/video/audio с prompt — нода будет draft (юзер запустит generation отдельно или используй generate_node). Для audio: subKind="music"|"sfx"|"voice" (default: voice/TTS). aspectRatio: "16:9"|"9:16"|"1:1"|"3:4"|"4:3"|"21:9" — переопределяет дефолтный для текущей сцены. ВАЖНО: если юзер сказал "горизонтальная" — 16:9, "вертикальная" — 9:16, "квадрат" — 1:1.',
|
|
103
|
+
params: '{"type":"image|video|audio|text","subKind":"music|sfx|voice (only for audio)","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>","durationMs":<for-music-or-sfx>,"aspectRatio":"<optional, for image/video>"}',
|
|
104
|
+
async handler({ type, subKind, name, prompt, x, y, text, modelKey, durationMs, aspectRatio }) {
|
|
105
105
|
if (!state.currentBoard) throw new Error('доска не выбрана');
|
|
106
106
|
if (!['image','video','audio','text','label'].includes(type)) throw new Error(`unknown type: ${type}`);
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
107
|
+
// Поиск незанятого места: пытаемся справа от последней ноды,
|
|
108
|
+
// потом проверяем что не перекрывает существующие. Если
|
|
109
|
+
// перекрывает — сдвигаем вправо/вниз пошагово (по сетке 32px).
|
|
110
110
|
const last = state.currentBoard.metadata.nodes[state.currentBoard.metadata.nodes.length - 1];
|
|
111
111
|
let nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
|
|
112
112
|
let ny = typeof y === 'number' ? y : (last ? last.y : 100);
|
|
113
113
|
if (nx < 50) nx = 50;
|
|
114
114
|
if (ny < 50) ny = 50;
|
|
115
|
+
// Дефолтные размеры для overlap-detection. Реальные node.width/height
|
|
116
|
+
// выставляются renderCanvas'ом по типу — но 280×220 — типичная
|
|
117
|
+
// прикидка для image/video, 200×120 — text/label/audio.
|
|
118
|
+
const w = (type === 'text' || type === 'label' || type === 'audio') ? 200 : 280;
|
|
119
|
+
const h = (type === 'text' || type === 'label' || type === 'audio') ? 120 : 220;
|
|
120
|
+
function overlaps(ax, ay) {
|
|
121
|
+
for (const n of state.currentBoard.metadata.nodes) {
|
|
122
|
+
const nw = n.width || ((n.type === 'text' || n.type === 'label' || n.type === 'audio') ? 200 : 280);
|
|
123
|
+
const nh = n.height || ((n.type === 'text' || n.type === 'label' || n.type === 'audio') ? 120 : 220);
|
|
124
|
+
// Считаем overlap'ом если центр новой ноды попадает В существующую.
|
|
125
|
+
// Это позволяет частично пересекаться (выглядит как «сдвиг»),
|
|
126
|
+
// но юзер увидит обе ноды.
|
|
127
|
+
const cx = ax + w / 2, cy = ay + h / 2;
|
|
128
|
+
if (cx >= n.x && cx <= n.x + nw && cy >= n.y && cy <= n.y + nh) return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// Если первое место занято — двигаем по сетке. Пытаемся вправо до
|
|
133
|
+
// 6 шагов, потом строкой ниже.
|
|
134
|
+
if (overlaps(nx, ny)) {
|
|
135
|
+
const step = 40;
|
|
136
|
+
let found = false;
|
|
137
|
+
for (let row = 0; row < 8 && !found; row++) {
|
|
138
|
+
for (let col = 0; col < 8 && !found; col++) {
|
|
139
|
+
const tx = nx + col * step;
|
|
140
|
+
const ty = ny + row * step;
|
|
141
|
+
if (!overlaps(tx, ty)) { nx = tx; ny = ty; found = true; }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
115
145
|
const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
|
|
116
146
|
const node = { id, type, x: nx, y: ny };
|
|
117
147
|
if (name) node.name = name;
|
|
@@ -122,6 +152,21 @@
|
|
|
122
152
|
if (prompt && type !== 'text' && type !== 'label') {
|
|
123
153
|
node.status = 'draft';
|
|
124
154
|
node.generated = { rawPrompt: prompt, prompt, modelKey: modelKey || undefined };
|
|
155
|
+
// aspectRatio в node.generated — startGenerationJob/submitBody читает
|
|
156
|
+
// node.generated.aspectRatio как первый приоритет (выше чем
|
|
157
|
+
// board-level setting). Без этого новая нода берёт scene aspect,
|
|
158
|
+
// и пользовательское «горизонтальная» теряется.
|
|
159
|
+
if (aspectRatio && (type === 'image' || type === 'video')) {
|
|
160
|
+
node.generated.aspectRatio = aspectRatio;
|
|
161
|
+
}
|
|
162
|
+
// Audio имеет 3 sub-kind'а: voice (TTS — дефолт), music, sfx.
|
|
163
|
+
// generate_node роутит в нужный job по этому полю.
|
|
164
|
+
if (type === 'audio') {
|
|
165
|
+
node.generated.kind = 'audio';
|
|
166
|
+
node.generated.subKind = (subKind === 'music' || subKind === 'sfx') ? subKind : 'voice';
|
|
167
|
+
if (typeof durationMs === 'number') node.generated.durationMs = durationMs;
|
|
168
|
+
if (subKind === 'music') node.generated.model = 'eleven-music';
|
|
169
|
+
}
|
|
125
170
|
}
|
|
126
171
|
state.currentBoard.metadata.nodes.push(node);
|
|
127
172
|
scheduleSave();
|
|
@@ -146,7 +191,7 @@
|
|
|
146
191
|
},
|
|
147
192
|
|
|
148
193
|
connect_nodes: {
|
|
149
|
-
description: 'Соединить две ноды (from → to). toPort: "image"
|
|
194
|
+
description: 'Соединить две ноды (from → to). toPort: "image"|"video"|"audio" — куда подаём ссылку (опционально, для @-резолва при генерации).',
|
|
150
195
|
params: '{"from":"<id>","to":"<id>","toPort":"image|video|audio (optional)"}',
|
|
151
196
|
async handler({ from, to, toPort }) {
|
|
152
197
|
const b = state.currentBoard;
|
|
@@ -175,7 +220,6 @@
|
|
|
175
220
|
const el = document.querySelector(`.node[data-id="${id}"]`);
|
|
176
221
|
if (typeof deleteNode === 'function') await deleteNode(node, el);
|
|
177
222
|
else {
|
|
178
|
-
// Fallback: просто убираем из массива.
|
|
179
223
|
b.metadata.nodes = b.metadata.nodes.filter(n => n.id !== id);
|
|
180
224
|
b.metadata.connections = (b.metadata.connections || []).filter(c => c.from !== id && c.to !== id);
|
|
181
225
|
scheduleSave();
|
|
@@ -210,7 +254,7 @@
|
|
|
210
254
|
},
|
|
211
255
|
|
|
212
256
|
add_clip_to_timeline: {
|
|
213
|
-
description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан —
|
|
257
|
+
description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — в конец дорожки.',
|
|
214
258
|
params: '{"nodeId":"<node-id>","trackKind":"video|audio","start":<seconds (optional)>,"duration":<seconds (optional, default по типу)>}',
|
|
215
259
|
async handler({ nodeId, trackKind, start, duration }) {
|
|
216
260
|
const b = state.currentBoard;
|
|
@@ -299,7 +343,7 @@
|
|
|
299
343
|
},
|
|
300
344
|
|
|
301
345
|
generate_node: {
|
|
302
|
-
description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа
|
|
346
|
+
description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога.',
|
|
303
347
|
params: '{"id":"<node-id>"}',
|
|
304
348
|
async handler({ id }) {
|
|
305
349
|
const b = state.currentBoard;
|
|
@@ -322,11 +366,21 @@
|
|
|
322
366
|
node.generated = { ...(node.generated || {}), kind, prompt, rawPrompt: node.generated?.rawPrompt || prompt };
|
|
323
367
|
scheduleSave();
|
|
324
368
|
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
325
|
-
// Маршрутизация по kind — копия логики из resumeJob/«Повторить»,
|
|
326
|
-
// запустить job напрямую без gen-modal'а.
|
|
369
|
+
// Маршрутизация по kind/subKind — копия логики из resumeJob/«Повторить»,
|
|
370
|
+
// чтобы запустить job напрямую без gen-modal'а.
|
|
327
371
|
if (kind === 'audio') {
|
|
328
|
-
|
|
329
|
-
|
|
372
|
+
const subKind = node.generated?.subKind || 'voice';
|
|
373
|
+
if (subKind === 'music') {
|
|
374
|
+
if (typeof runMusicJob !== 'function') throw new Error('runMusicJob недоступен');
|
|
375
|
+
runMusicJob(node, prompt, node.generated?.durationMs || null, boardHandle, bKey).catch(e => console.error('music job failed:', e));
|
|
376
|
+
} else if (subKind === 'sfx') {
|
|
377
|
+
if (typeof runSfxJob !== 'function') throw new Error('runSfxJob недоступен');
|
|
378
|
+
runSfxJob(node, prompt, node.generated?.durationMs ? node.generated.durationMs / 1000 : null, boardHandle, bKey).catch(e => console.error('sfx job failed:', e));
|
|
379
|
+
} else {
|
|
380
|
+
// voice (TTS) — дефолт.
|
|
381
|
+
if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
|
|
382
|
+
runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
|
|
383
|
+
}
|
|
330
384
|
} else if (kind === 'text') {
|
|
331
385
|
if (typeof runTextJob !== 'function') throw new Error('runTextJob недоступен');
|
|
332
386
|
const imageRefs = refs.filter(r => r.type === 'image' && r.file);
|
|
@@ -362,6 +416,8 @@
|
|
|
362
416
|
lines.push('- Не выдумывай id нод — id это случайные UUID, угадать нельзя.');
|
|
363
417
|
lines.push(' - Если нужен id существующей ноды — сначала read_scene (вернёт массив с реальными id).');
|
|
364
418
|
lines.push(' - После add_node — id новой ноды лежит в result.id ответа этого вызова.');
|
|
419
|
+
lines.push('- НЕ упоминай id в текстовых сообщениях пользователю (это шум). Используй имена нод.');
|
|
420
|
+
lines.push(' Примеры: «Готово, добавил картинку «Закат»», а не «Готово, добавил картинку id=abc-123».');
|
|
365
421
|
lines.push('- Не выдумывай имена сцен — сначала list_scenes.');
|
|
366
422
|
lines.push('- Для генерации сразу после add_node — ВОЗЬМИ id из result.id ПРЕДЫДУЩЕГО вызова и передай в generate_node.');
|
|
367
423
|
lines.push('- Когда нужно создать несколько нод сразу — выдавай add_node + generate_node чередуя, или сначала все add_node, потом все generate_node (используя id из их result-ов).');
|
|
@@ -714,6 +770,21 @@
|
|
|
714
770
|
return ctx;
|
|
715
771
|
}
|
|
716
772
|
|
|
773
|
+
// Декл-форма множественного: «1 картинка», «2 картинки», «5 картинок».
|
|
774
|
+
function _ru_plural(n, forms) {
|
|
775
|
+
const n10 = n % 10, n100 = n % 100;
|
|
776
|
+
if (n100 >= 11 && n100 <= 14) return forms[2];
|
|
777
|
+
if (n10 === 1) return forms[0];
|
|
778
|
+
if (n10 >= 2 && n10 <= 4) return forms[1];
|
|
779
|
+
return forms[2];
|
|
780
|
+
}
|
|
781
|
+
const _typeForms = {
|
|
782
|
+
image: ['картинка','картинки','картинок'],
|
|
783
|
+
video: ['видео','видео','видео'],
|
|
784
|
+
audio: ['аудио','аудио','аудио'],
|
|
785
|
+
text: ['текст','текста','текстов'],
|
|
786
|
+
label: ['подпись','подписи','подписей'],
|
|
787
|
+
};
|
|
717
788
|
function renderContextRow() {
|
|
718
789
|
const row = document.getElementById('chatContextRow');
|
|
719
790
|
if (!row) return;
|
|
@@ -721,8 +792,21 @@
|
|
|
721
792
|
const ctx = buildContextSnapshot();
|
|
722
793
|
const parts = [];
|
|
723
794
|
if (ctx.scene) parts.push({ key: 'scene', label: `🎬 ${ctx.scene.name}`, removable: false });
|
|
795
|
+
// Группируем выделенные ноды по type. Если 1 — показываем имя/id;
|
|
796
|
+
// если 2+ — «N картинок» с локализацией.
|
|
797
|
+
const byType = {};
|
|
724
798
|
for (const sel of ctx.selected) {
|
|
725
|
-
|
|
799
|
+
(byType[sel.type] = byType[sel.type] || []).push(sel);
|
|
800
|
+
}
|
|
801
|
+
for (const [type, items] of Object.entries(byType)) {
|
|
802
|
+
const icon = type === 'image' ? '🖼' : type === 'video' ? '🎬' : type === 'audio' ? '🎙' : type === 'text' ? '📝' : '◉';
|
|
803
|
+
if (items.length === 1) {
|
|
804
|
+
const s = items[0];
|
|
805
|
+
parts.push({ key: 'sel:' + s.id, label: `${icon} ${s.name || s.id.slice(0, 6)}`, removable: false });
|
|
806
|
+
} else {
|
|
807
|
+
const noun = _typeForms[type] || ['нода','ноды','нод'];
|
|
808
|
+
parts.push({ key: 'sel-grp:' + type, label: `${icon} ${items.length} ${_ru_plural(items.length, noun)}`, removable: false });
|
|
809
|
+
}
|
|
726
810
|
}
|
|
727
811
|
for (const a of ctx.attachments) {
|
|
728
812
|
parts.push({ key: 'att:' + a.relPath, label: `📎 ${a.name}`, removable: true, onRemove: () => { manualAttachments = manualAttachments.filter(x => x.relPath !== a.relPath); renderContextRow(); } });
|
|
@@ -1083,27 +1167,30 @@
|
|
|
1083
1167
|
div.appendChild(body);
|
|
1084
1168
|
}
|
|
1085
1169
|
if (hasTools) {
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1170
|
+
// Все tools в ОДНУ строку (separated " · "), без emoji-icons —
|
|
1171
|
+
// компактнее. Раскрываются click'ом одной общей <details>.
|
|
1172
|
+
const t = document.createElement('details');
|
|
1173
|
+
t.className = 'chat-tool';
|
|
1174
|
+
const sum = document.createElement('summary');
|
|
1175
|
+
const parts = m.tools.map(tc => {
|
|
1176
|
+
const status = tc._error ? '⚠' : (tc._ok ? '✓' : '·');
|
|
1091
1177
|
const argHint = _argHint(tc.args);
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
if (tc.
|
|
1100
|
-
if (
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1178
|
+
return `${tc.name}${argHint} ${status}`;
|
|
1179
|
+
});
|
|
1180
|
+
sum.textContent = parts.join(' · ');
|
|
1181
|
+
t.appendChild(sum);
|
|
1182
|
+
// pre с полным JSON всех вызовов (для debug/details).
|
|
1183
|
+
const dumpAll = m.tools.map(tc => {
|
|
1184
|
+
const d = { tool: tc.name };
|
|
1185
|
+
if (tc.args && Object.keys(tc.args).length) d.args = tc.args;
|
|
1186
|
+
if (tc.result !== undefined) d.result = tc.result;
|
|
1187
|
+
if (tc._error) d.error = tc._error;
|
|
1188
|
+
return d;
|
|
1189
|
+
});
|
|
1190
|
+
const pre = document.createElement('pre');
|
|
1191
|
+
pre.textContent = JSON.stringify(dumpAll, null, 2);
|
|
1192
|
+
t.appendChild(pre);
|
|
1193
|
+
div.appendChild(t);
|
|
1107
1194
|
}
|
|
1108
1195
|
list.appendChild(div);
|
|
1109
1196
|
}
|
|
@@ -1215,6 +1302,9 @@
|
|
|
1215
1302
|
const panel = $('chatPanel');
|
|
1216
1303
|
panel.classList.toggle('hidden');
|
|
1217
1304
|
if (!panel.classList.contains('hidden')) {
|
|
1305
|
+
// Сворачиваем preview-панель — иначе она перекрывает чат справа
|
|
1306
|
+
// (preview z-index 40 > chat z-index 30 by design).
|
|
1307
|
+
if (typeof setPreviewCollapsed === 'function') setPreviewCollapsed(true);
|
|
1218
1308
|
// Отрисовываем сохранённую историю при показе (на случай если она
|
|
1219
1309
|
// была подгружена в фоне через loadFromCurrentProject до первого toggle).
|
|
1220
1310
|
renderHistory();
|
package/renderer/generate.js
CHANGED
|
@@ -1863,52 +1863,76 @@ async function resumeJob(node, bKey, boardHandle) {
|
|
|
1863
1863
|
if (state.jobs.has(node.id)) return;
|
|
1864
1864
|
if (!node.generated || node.status !== 'generating') return;
|
|
1865
1865
|
|
|
1866
|
+
// Маршрутизация по kind/subKind. Без этого music/sfx падали на
|
|
1867
|
+
// resume (всё уходило в runTTSJob).
|
|
1868
|
+
const kind = node.generated.kind;
|
|
1869
|
+
const subKind = node.generated.subKind || (kind === 'audio' ? 'voice' : null);
|
|
1870
|
+
|
|
1866
1871
|
if (node.generated.taskId) {
|
|
1867
|
-
// Уже зарегистрировано в KIE — просто
|
|
1868
|
-
const job = { boardKey: bKey, boardHandle, kind
|
|
1872
|
+
// Уже зарегистрировано в KIE — просто опрашиваем.
|
|
1873
|
+
const job = { boardKey: bKey, boardHandle, kind, taskId: node.generated.taskId, nodeId: node.id };
|
|
1869
1874
|
state.jobs.set(node.id, job);
|
|
1870
1875
|
updateJobsBadge();
|
|
1871
1876
|
try {
|
|
1872
|
-
await pollJob(job, node.id, bKey, boardHandle,
|
|
1877
|
+
await pollJob(job, node.id, bKey, boardHandle, kind);
|
|
1873
1878
|
} catch (e) {
|
|
1874
1879
|
await mutateNode(bKey, boardHandle, node.id, n => { n.status = 'error'; n.error = e.message; });
|
|
1875
1880
|
state.jobs.delete(node.id);
|
|
1876
1881
|
updateJobsBadge();
|
|
1877
1882
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
if (
|
|
1883
|
-
await
|
|
1884
|
-
} else if (
|
|
1885
|
-
|
|
1886
|
-
const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
|
|
1887
|
-
await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
// Перезагрузка случилась до сабмита: рестарт с фазы upload+submit.
|
|
1886
|
+
if (kind === 'audio') {
|
|
1887
|
+
if (subKind === 'music' && typeof runMusicJob === 'function') {
|
|
1888
|
+
await runMusicJob(node, node.generated.prompt, node.generated.durationMs || null, boardHandle, bKey);
|
|
1889
|
+
} else if (subKind === 'sfx' && typeof runSfxJob === 'function') {
|
|
1890
|
+
await runSfxJob(node, node.generated.prompt, node.generated.durationMs ? node.generated.durationMs / 1000 : null, boardHandle, bKey);
|
|
1888
1891
|
} else {
|
|
1889
|
-
|
|
1890
|
-
await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
|
|
1892
|
+
await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
|
|
1891
1893
|
}
|
|
1894
|
+
} else if (kind === 'text') {
|
|
1895
|
+
const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
|
|
1896
|
+
const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
|
|
1897
|
+
await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
|
|
1898
|
+
} else {
|
|
1899
|
+
const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
|
|
1900
|
+
await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
|
|
1892
1901
|
}
|
|
1893
1902
|
}
|
|
1894
1903
|
|
|
1904
|
+
// Сканируем ВСЕ доски проекта (episodes + characters + locations) на
|
|
1905
|
+
// зависшие генерации. Раньше только episodes+characters → location-нода
|
|
1906
|
+
// в статусе 'generating' оставалась навсегда.
|
|
1907
|
+
// Resume и для нод БЕЗ taskId (рестарт submit'a) — раньше пропускали.
|
|
1895
1908
|
async function scanAllBoardsForPendingJobs(filmHandle) {
|
|
1896
|
-
const [eps, chars] = await Promise.all([
|
|
1909
|
+
const [eps, chars, locs] = await Promise.all([
|
|
1910
|
+
listEpisodes(filmHandle),
|
|
1911
|
+
listCharacters(filmHandle),
|
|
1912
|
+
listLocations(filmHandle),
|
|
1913
|
+
]);
|
|
1897
1914
|
const all = [
|
|
1898
1915
|
...eps.map(b => ({ kind: 'episode', ...b })),
|
|
1899
1916
|
...chars.map(b => ({ kind: 'character', ...b })),
|
|
1917
|
+
...locs.map(b => ({ kind: 'location', ...b })),
|
|
1900
1918
|
];
|
|
1919
|
+
let resumed = 0;
|
|
1901
1920
|
for (const b of all) {
|
|
1902
1921
|
try {
|
|
1903
1922
|
const meta = await loadBoardMetadata(b.handle);
|
|
1904
1923
|
const bKey = boardKey(b.kind, b.name);
|
|
1905
1924
|
for (const n of meta.nodes) {
|
|
1906
|
-
if (n.status === 'generating' &&
|
|
1907
|
-
|
|
1925
|
+
if (n.status === 'generating' && !state.jobs.has(n.id)) {
|
|
1926
|
+
resumed++;
|
|
1927
|
+
// resumeJob handles BOTH taskId-есть (poll) и taskId-нет (restart submit).
|
|
1928
|
+
resumeJob(n, bKey, b.handle).catch(e => console.warn('resume failed', n.id, e?.message));
|
|
1908
1929
|
}
|
|
1909
1930
|
}
|
|
1910
1931
|
} catch (e) { console.warn('scan board failed', b.name, e); }
|
|
1911
1932
|
}
|
|
1933
|
+
if (resumed) {
|
|
1934
|
+
showToast(`▶ Возобновлено ${resumed} незаконч. генераций`, 'info');
|
|
1935
|
+
}
|
|
1912
1936
|
}
|
|
1913
1937
|
|
|
1914
1938
|
async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
@@ -1947,8 +1971,10 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
1947
1971
|
const cost = typeof pd.cost === 'number' ? pd.cost : null;
|
|
1948
1972
|
if (cost !== null) logJob(nodeId, `списано ${cost} credits`);
|
|
1949
1973
|
logJob(nodeId, `done → file=${relPath} (${blob.size} bytes)`);
|
|
1974
|
+
let nodeName;
|
|
1950
1975
|
await mutateNode(bKey, boardHandle, nodeId, n => {
|
|
1951
1976
|
n.status = undefined; n.error = undefined; n.file = relPath;
|
|
1977
|
+
nodeName = n.name;
|
|
1952
1978
|
if (cost !== null) {
|
|
1953
1979
|
n.generated = { ...(n.generated || {}), creditsCharged: cost };
|
|
1954
1980
|
}
|
|
@@ -1956,6 +1982,10 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
1956
1982
|
state.jobs.delete(nodeId);
|
|
1957
1983
|
updateJobsBadge();
|
|
1958
1984
|
if (typeof window.refreshBalance === 'function') window.refreshBalance();
|
|
1985
|
+
// Toast если завершилось НЕ на текущей доске (юзер мог переключиться).
|
|
1986
|
+
if (typeof showToast === 'function' && state.currentBoard?.key !== bKey) {
|
|
1987
|
+
showToast(`✓ ${kind} «${nodeName || nodeId.slice(0,6)}» готов в ${bKey}`, 'ok');
|
|
1988
|
+
}
|
|
1959
1989
|
return;
|
|
1960
1990
|
}
|
|
1961
1991
|
if (pd.status === 'error') {
|
package/renderer/media.js
CHANGED
|
@@ -216,8 +216,13 @@ function triggerDownload(blob, filename) {
|
|
|
216
216
|
function findFreeSpot(w = 280, h = 320) {
|
|
217
217
|
const nodes = state.currentBoard?.metadata?.nodes || [];
|
|
218
218
|
const margin = 24;
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
// ВАЖНО: учесть canvas-padding (после v0.12.0). scrollLeft теперь
|
|
220
|
+
// включает padX. Без вычитания startX оказывался ~+2000 от content
|
|
221
|
+
// origin'a и нода создавалась за экраном (типичный кейс — музыка
|
|
222
|
+
// не появлялась после клика на 🎵 кнопку).
|
|
223
|
+
const { padX, padY } = _getFramePadding();
|
|
224
|
+
const startX = (canvasWrap.scrollLeft - padX) / state.zoom + 40;
|
|
225
|
+
const startY = (canvasWrap.scrollTop - padY) / state.zoom + 40;
|
|
221
226
|
const step = 60;
|
|
222
227
|
for (let y = startY; y < startY + 3000; y += step) {
|
|
223
228
|
for (let x = startX; x < startX + 3000; x += step) {
|
package/renderer/state.js
CHANGED
|
@@ -70,6 +70,31 @@ async function listCharacters(filmHandle) {
|
|
|
70
70
|
} catch { return []; }
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Глобальный toast для информативных уведомлений (генерация завершилась,
|
|
74
|
+
// resume сработал, ...). Стек справа сверху, auto-dismiss через 5s.
|
|
75
|
+
function showToast(text, kind) {
|
|
76
|
+
let host = document.getElementById('toastHost');
|
|
77
|
+
if (!host) {
|
|
78
|
+
host = document.createElement('div');
|
|
79
|
+
host.id = 'toastHost';
|
|
80
|
+
host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:6px; pointer-events:none;';
|
|
81
|
+
document.body.appendChild(host);
|
|
82
|
+
}
|
|
83
|
+
const t = document.createElement('div');
|
|
84
|
+
const colors = {
|
|
85
|
+
ok: 'background:#1a3a1a; border:1px solid #2a6a3a; color:#9efa9e;',
|
|
86
|
+
error: 'background:#3a1a1a; border:1px solid #8a2a2a; color:#fa9e9e;',
|
|
87
|
+
info: 'background:#1a2a3a; border:1px solid #2a4a6a; color:#9ecdfa;',
|
|
88
|
+
};
|
|
89
|
+
t.style.cssText = `${colors[kind] || colors.info} padding:8px 14px; border-radius:6px; font-size:12px; pointer-events:auto; box-shadow:0 4px 16px rgba(0,0,0,0.4); animation:toastIn 0.18s ease-out;`;
|
|
90
|
+
t.textContent = text;
|
|
91
|
+
t.addEventListener('click', () => t.remove());
|
|
92
|
+
host.appendChild(t);
|
|
93
|
+
setTimeout(() => t.remove(), 5000);
|
|
94
|
+
return t;
|
|
95
|
+
}
|
|
96
|
+
window.showToast = showToast;
|
|
97
|
+
|
|
73
98
|
async function listLocations(filmHandle) {
|
|
74
99
|
try {
|
|
75
100
|
const root = await filmHandle.getDirectoryHandle(LOC_DIR);
|
package/renderer/styles.css
CHANGED
|
@@ -253,7 +253,10 @@
|
|
|
253
253
|
position: fixed; right: 0; top: 0; bottom: 0; width: 420px;
|
|
254
254
|
background: #1a1a1a; border-left: 1px solid #333;
|
|
255
255
|
display: flex; flex-direction: column;
|
|
256
|
-
z-index
|
|
256
|
+
/* z-index 30 — НИЖЕ preview-panel (z-index 40). Preview перекрывает чат
|
|
257
|
+
визуально; при открытии чата preview сворачивается автоматически
|
|
258
|
+
(см. toggle() в chat.js → setPreviewCollapsed(true)). */
|
|
259
|
+
z-index: 30;
|
|
257
260
|
box-shadow: -8px 0 32px rgba(0,0,0,0.4);
|
|
258
261
|
/* --chat-font задаётся inline в chat.js (Cmd+/Cmd-). Дефолт 13px. */
|
|
259
262
|
font-size: var(--chat-font, 13px);
|