kingkont 0.14.1 → 0.14.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
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,15 @@ async function selectBoard(board) {
1855
1890
  canvasWrap.scrollTop = padY;
1856
1891
  }
1857
1892
 
1893
+ // Auto-scroll к bbox нод — ТОЛЬКО если view не был сохранён ранее
1894
+ // (т.е. это первый раз открываем доску, или юзер ни разу не скроллил).
1895
+ // Раньше срабатывало всегда — из-за гонки с applyZoomStyles (resize
1896
+ // через setTimeout 200ms) scrollLeft при zoom<1 клампился, bbox казался
1897
+ // невидимым, view перебивался → юзер терял сохранённую позицию.
1898
+ if (!view || (typeof view.scrollLeft !== 'number' && typeof view.scrollTop !== 'number')) {
1899
+ requestAnimationFrame(() => _autoScrollToNodesIfHidden(padX, padY));
1900
+ }
1901
+
1858
1902
  // Возобновить незавершённые джобы текущей доски
1859
1903
  for (const n of state.currentBoard.metadata.nodes) {
1860
1904
  if (n.status === 'generating' && n.generated?.taskId && !state.jobs.has(n.id)) {
package/renderer/chat.js CHANGED
@@ -91,17 +91,17 @@
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). Для audio укажи subKind="music"|"sfx"|"voice" — иначе по умолчанию voice (TTS).',
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>}',
104
- async handler({ type, subKind, name, prompt, x, y, text, modelKey, durationMs }) {
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
107
  // Поиск незанятого места: пытаемся справа от последней ноды,
@@ -152,6 +152,13 @@
152
152
  if (prompt && type !== 'text' && type !== 'label') {
153
153
  node.status = 'draft';
154
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
+ }
155
162
  // Audio имеет 3 sub-kind'а: voice (TTS — дефолт), music, sfx.
156
163
  // generate_node роутит в нужный job по этому полю.
157
164
  if (type === 'audio') {
@@ -184,7 +191,7 @@
184
191
  },
185
192
 
186
193
  connect_nodes: {
187
- description: 'Соединить две ноды (from → to). toPort: "image" | "video" | "audio" — куда подаём ссылку (опционально, для @-резолва при генерации).',
194
+ description: 'Соединить две ноды (from → to). toPort: "image"|"video"|"audio" — куда подаём ссылку (опционально, для @-резолва при генерации).',
188
195
  params: '{"from":"<id>","to":"<id>","toPort":"image|video|audio (optional)"}',
189
196
  async handler({ from, to, toPort }) {
190
197
  const b = state.currentBoard;
@@ -213,7 +220,6 @@
213
220
  const el = document.querySelector(`.node[data-id="${id}"]`);
214
221
  if (typeof deleteNode === 'function') await deleteNode(node, el);
215
222
  else {
216
- // Fallback: просто убираем из массива.
217
223
  b.metadata.nodes = b.metadata.nodes.filter(n => n.id !== id);
218
224
  b.metadata.connections = (b.metadata.connections || []).filter(c => c.from !== id && c.to !== id);
219
225
  scheduleSave();
@@ -248,7 +254,7 @@
248
254
  },
249
255
 
250
256
  add_clip_to_timeline: {
251
- description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — добавляется в конец дорожки.',
257
+ description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — в конец дорожки.',
252
258
  params: '{"nodeId":"<node-id>","trackKind":"video|audio","start":<seconds (optional)>,"duration":<seconds (optional, default по типу)>}',
253
259
  async handler({ nodeId, trackKind, start, duration }) {
254
260
  const b = state.currentBoard;
@@ -337,7 +343,7 @@
337
343
  },
338
344
 
339
345
  generate_node: {
340
- description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога — не интерактивная UI-форма как regenerateNode.',
346
+ description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога.',
341
347
  params: '{"id":"<node-id>"}',
342
348
  async handler({ id }) {
343
349
  const b = state.currentBoard;
@@ -410,6 +416,8 @@
410
416
  lines.push('- Не выдумывай id нод — id это случайные UUID, угадать нельзя.');
411
417
  lines.push(' - Если нужен id существующей ноды — сначала read_scene (вернёт массив с реальными id).');
412
418
  lines.push(' - После add_node — id новой ноды лежит в result.id ответа этого вызова.');
419
+ lines.push('- НЕ упоминай id в текстовых сообщениях пользователю (это шум). Используй имена нод.');
420
+ lines.push(' Примеры: «Готово, добавил картинку «Закат»», а не «Готово, добавил картинку id=abc-123».');
413
421
  lines.push('- Не выдумывай имена сцен — сначала list_scenes.');
414
422
  lines.push('- Для генерации сразу после add_node — ВОЗЬМИ id из result.id ПРЕДЫДУЩЕГО вызова и передай в generate_node.');
415
423
  lines.push('- Когда нужно создать несколько нод сразу — выдавай add_node + generate_node чередуя, или сначала все add_node, потом все generate_node (используя id из их result-ов).');
@@ -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);