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 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="Чат с Claude (Cmd+J)" onclick="window.kingChat?.toggle?.()">💬 Чат</button>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
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
- const settings = b.metadata.settings || {};
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 promptа можно задать prompt — нода будет в draft-состоянии (юзер запустит generation отдельно или используй generate_node).',
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
- // Авто-координаты справа от последней ноды. Clamp к >= 50, чтобы
108
- // нода не ушла в отрицательные координаты (canvas начинается с 0,0,
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" | "video" | "audio" — куда подаём ссылку (опционально, для @-резолва при генерации).',
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). Стартует напрямую в фоне БЕЗ показа диалога — не интерактивная UI-форма как regenerateNode.',
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
- if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
329
- runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
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
- parts.push({ key: 'sel:' + sel.id, label: `◉ ${sel.type === 'image' ? '🖼' : sel.type === 'video' ? '🎬' : sel.type === 'audio' ? '🎙' : '📝'} ${sel.name || sel.id.slice(0, 6)}`, removable: false });
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
- for (const tc of m.tools) {
1087
- const t = document.createElement('details');
1088
- t.className = 'chat-tool';
1089
- const sum = document.createElement('summary');
1090
- // Компактный summary: имя tool + ключевой arg (если короткий) + статус.
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
- const status = tc._error ? ' ⚠' : tc._ok ? ' ✓' : '';
1093
- sum.textContent = `🔧 ${tc.name}${argHint}${status}`;
1094
- t.appendChild(sum);
1095
- // pre добавляем только если есть что показать (args/result/error не пусты).
1096
- const dump = {};
1097
- if (tc.args && Object.keys(tc.args).length) dump.args = tc.args;
1098
- if (tc.result !== undefined) dump.result = tc.result;
1099
- if (tc._error) dump.error = tc._error;
1100
- if (Object.keys(dump).length) {
1101
- const pre = document.createElement('pre');
1102
- pre.textContent = JSON.stringify(dump, null, 2);
1103
- t.appendChild(pre);
1104
- }
1105
- div.appendChild(t);
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();
@@ -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: node.generated.kind, taskId: node.generated.taskId, nodeId: node.id };
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, node.generated.kind);
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
- } else {
1879
- // Перезагрузка случилась до сабмита: рестарт с фазы upload+submit.
1880
- // Маршрутизируем по kind audio/text не идут через KIE.
1881
- const kind = node.generated.kind;
1882
- if (kind === 'audio') {
1883
- await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
1884
- } else if (kind === 'text') {
1885
- const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
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
- const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
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([listEpisodes(filmHandle), listCharacters(filmHandle)]);
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' && n.generated?.taskId && !state.jobs.has(n.id)) {
1907
- resumeJob(n, bKey, b.handle);
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
- const startX = canvasWrap.scrollLeft / state.zoom + 40;
220
- const startY = canvasWrap.scrollTop / state.zoom + 40;
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);
@@ -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: 80;
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);