kingkont 0.11.5 → 0.12.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/main.js CHANGED
@@ -745,6 +745,20 @@ ipcMain.handle('cloudFs:openInFinder', async (_e, projectId) => {
745
745
  }
746
746
  });
747
747
 
748
+ // Универсальный shell.openPath — для папочных проектов где знаем абс-путь.
749
+ // Renderer выводит путь через webUtils.getPathForFile (см. appPath.pathForFile
750
+ // в preload). Безопасность: shell.openPath сам проверяет что path существует
751
+ // + macOS sandbox предотвращает «поход куда не следует».
752
+ ipcMain.handle('shell:openPath', async (_e, absPath) => {
753
+ if (!absPath || typeof absPath !== 'string') return { ok: false, error: 'no path' };
754
+ try {
755
+ const err = await shell.openPath(absPath);
756
+ return { ok: !err, error: err || null };
757
+ } catch (e) {
758
+ return { ok: false, error: e.message };
759
+ }
760
+ });
761
+
748
762
  ipcMain.handle('window:close', () => {
749
763
  if (win && !win.isDestroyed()) win.close();
750
764
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.11.5",
3
+ "version": "0.12.0",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/preload.js CHANGED
@@ -93,6 +93,16 @@ contextBridge.exposeInMainWorld('claudeMd', {
93
93
  installSkill: () => ipcRenderer.invoke('claudeMd:installSkill'),
94
94
  });
95
95
 
96
+ // Filesystem-path utils. Нужно для «Открыть в Finder» папочных проектов:
97
+ // FSAH directoryHandle не даёт абс-путь, но webUtils.getPathForFile(file)
98
+ // — даёт. Берём любой файл изнутри папки → парсим parent → openPath.
99
+ contextBridge.exposeInMainWorld('appPath', {
100
+ pathForFile: (file) => {
101
+ try { return webUtils.getPathForFile(file) || null; } catch { return null; }
102
+ },
103
+ openPath: (absPath) => ipcRenderer.invoke('shell:openPath', absPath),
104
+ });
105
+
96
106
  // Cloud-projects: тонкая обёртка над userData/cloud-projects/<id>/.
97
107
  // Используется renderer'ом в режиме «облачного проекта» (без showDirectoryPicker).
98
108
  // На веб-версии (без preload) — undefined; web-shell использует in-memory модель
package/renderer/board.js CHANGED
@@ -642,6 +642,7 @@ function makeRecentWelcomeCard(r) {
642
642
 
643
643
  // ПКМ-меню для recent (folder) welcome-карточки. По аналогии с cloud:
644
644
  // «Сохранить как шаблон» (открывает проект → saveCurrentProjectAsTemplate)
645
+ // + «Открыть в Finder» (абс-путь через webUtils.getPathForFile)
645
646
  // + «Убрать из недавних».
646
647
  function showRecentCardContextMenu(r, clientX, clientY) {
647
648
  const menu = $('nodeMenu');
@@ -654,6 +655,21 @@ function showRecentCardContextMenu(r, clientX, clientY) {
654
655
  b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
655
656
  menu.appendChild(b);
656
657
  };
658
+ add('📂 Открыть в Finder', async () => {
659
+ if (!r.handle || !window.appPath?.pathForFile || !window.appPath?.openPath) {
660
+ alert('Доступно только в Electron-приложении');
661
+ return;
662
+ }
663
+ try {
664
+ let g = (await r.handle.queryPermission({ mode: 'read' })) === 'granted';
665
+ if (!g) g = (await r.handle.requestPermission({ mode: 'read' })) === 'granted';
666
+ if (!g) { alert('Доступ к папке не подтверждён.'); return; }
667
+ const folderPath = await deriveFolderAbsPath(r.handle);
668
+ if (!folderPath) { alert('Не удалось определить путь к папке (нет файлов внутри).'); return; }
669
+ const res = await window.appPath.openPath(folderPath);
670
+ if (!res?.ok) alert('Не удалось открыть: ' + (res?.error || 'unknown'));
671
+ } catch (e) { alert('Ошибка: ' + (e?.message || e)); }
672
+ });
657
673
  add('💾 Сохранить как шаблон…', async () => {
658
674
  if (!r.handle) { alert('Handle потерян, сначала открой проект и сохрани заново.'); return; }
659
675
  try {
@@ -676,6 +692,43 @@ function showRecentCardContextMenu(r, clientX, clientY) {
676
692
  setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
677
693
  }
678
694
 
695
+ // Получить абс-путь FSAH-папки. webUtils.getPathForFile работает только
696
+ // для File объектов (не для DirectoryHandle), поэтому ищем любой файл
697
+ // внутри и берём parent. Тестируем: scene.json в любом board → up 2 dirs.
698
+ // Или meta-файл в корне → up 1 dir. Если ничего нет — возвращаем null.
699
+ async function deriveFolderAbsPath(filmHandle) {
700
+ if (!window.appPath?.pathForFile) return null;
701
+ // 1) Любой файл в корне (например .kingkont-meta.json или .cover.*).
702
+ try {
703
+ for await (const [name, h] of filmHandle.entries()) {
704
+ if (h.kind === 'file' && !name.startsWith('.git') && !name.startsWith('node_modules')) {
705
+ const file = await h.getFile();
706
+ const p = window.appPath.pathForFile(file);
707
+ if (p) return p.replace(/[\/\\][^\/\\]+$/, '');
708
+ }
709
+ }
710
+ } catch {}
711
+ // 2) Файл из подпапки (любой board) — up 2 уровня.
712
+ try {
713
+ for await (const [, dirH] of filmHandle.entries()) {
714
+ if (dirH.kind !== 'directory' || dirH.name.startsWith('.')) continue;
715
+ try {
716
+ for await (const [name2, h2] of dirH.entries()) {
717
+ if (h2.kind === 'file') {
718
+ const file = await h2.getFile();
719
+ const p = window.appPath.pathForFile(file);
720
+ if (p) {
721
+ // up 2: removes filename + parent dir name
722
+ return p.replace(/[\/\\][^\/\\]+[\/\\][^\/\\]+$/, '');
723
+ }
724
+ }
725
+ }
726
+ } catch {}
727
+ }
728
+ } catch {}
729
+ return null;
730
+ }
731
+
679
732
  // Карточка облачного проекта. Визуально такая же как у папочного, плюс
680
733
  // ☁ бейдж в углу обложки. Клик → cloudProjects.open() (использует local
681
734
  // cache если синхронизирован, иначе скачивает manifest+файлы).
@@ -1772,7 +1825,11 @@ async function selectBoard(board) {
1772
1825
  scheduleUpdatePreview();
1773
1826
  }
1774
1827
 
1775
- // Восстановить pan/zoom доски
1828
+ // Восстановить pan/zoom доски. Default-scroll выставляем на canvas-pad-x/y
1829
+ // (см. styles.css .canvas-frame), чтобы canvas (0,0) был виден в top-left.
1830
+ // Без этого пользователь видел бы пустой padding слева/сверху.
1831
+ const padX = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-x')) || 2000;
1832
+ const padY = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-y')) || 1500;
1776
1833
  const view = state.currentBoard.metadata.view;
1777
1834
  if (view) {
1778
1835
  if (typeof view.zoom === 'number') {
@@ -1780,14 +1837,21 @@ async function selectBoard(board) {
1780
1837
  applyZoomStyles(state.zoom);
1781
1838
  $('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
1782
1839
  }
1783
- if (typeof view.scrollLeft === 'number') canvasWrap.scrollLeft = view.scrollLeft;
1784
- if (typeof view.scrollTop === 'number') canvasWrap.scrollTop = view.scrollTop;
1840
+ // ВАЖНО: для backward-compat старых view (где scrollLeft/Top сохранены
1841
+ // БЕЗ padding'а — до этой фичи) добавляем padding к сохранённому
1842
+ // значению. Если view свежий и >= padding'а, считаем что padding уже учтён.
1843
+ if (typeof view.scrollLeft === 'number') {
1844
+ canvasWrap.scrollLeft = view.scrollLeft >= padX ? view.scrollLeft : view.scrollLeft + padX;
1845
+ } else canvasWrap.scrollLeft = padX;
1846
+ if (typeof view.scrollTop === 'number') {
1847
+ canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
1848
+ } else canvasWrap.scrollTop = padY;
1785
1849
  } else {
1786
1850
  state.zoom = 1;
1787
1851
  applyZoomStyles(1);
1788
1852
  $('zoomLabel').textContent = '100%';
1789
- canvasWrap.scrollLeft = 0;
1790
- canvasWrap.scrollTop = 0;
1853
+ canvasWrap.scrollLeft = padX;
1854
+ canvasWrap.scrollTop = padY;
1791
1855
  }
1792
1856
 
1793
1857
  // Возобновить незавершённые джобы текущей доски
package/renderer/chat.js CHANGED
@@ -104,10 +104,14 @@
104
104
  async handler({ type, name, prompt, x, y, text, modelKey }) {
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
+ // Авто-координаты — справа от последней ноды. Clamp к >= 50, чтобы
108
+ // нода не ушла в отрицательные координаты (canvas начинается с 0,0,
109
+ // негативные позиции невидимы без скролла).
108
110
  const last = state.currentBoard.metadata.nodes[state.currentBoard.metadata.nodes.length - 1];
109
- const nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
110
- const ny = typeof y === 'number' ? y : (last ? last.y : 100);
111
+ let nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
112
+ let ny = typeof y === 'number' ? y : (last ? last.y : 100);
113
+ if (nx < 50) nx = 50;
114
+ if (ny < 50) ny = 50;
111
115
  const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
112
116
  const node = { id, type, x: nx, y: ny };
113
117
  if (name) node.name = name;
@@ -181,6 +185,119 @@
181
185
  },
182
186
  },
183
187
 
188
+ list_timeline: {
189
+ description: 'Список дорожек и клипов таймлайна текущей доски. Возвращает {tracks: [{id, name, kind, clips: [{id, nodeId, file, start, duration, name}]}], totalDuration}.',
190
+ params: '{}',
191
+ async handler() {
192
+ const b = state.currentBoard;
193
+ if (!b) throw new Error('доска не выбрана');
194
+ if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
195
+ const tl = getTimeline();
196
+ const tracks = (tl?.tracks || []).map(t => ({
197
+ id: t.id, name: t.name, kind: t.kind,
198
+ clips: (t.clips || []).map(c => ({
199
+ id: c.id, nodeId: c.nodeId || null, type: c.type,
200
+ file: c.file || null, name: c.name || null,
201
+ start: c.start || 0, duration: c.duration || 0,
202
+ })),
203
+ }));
204
+ const totalDuration = tracks.reduce((max, t) => Math.max(
205
+ max,
206
+ (t.clips || []).reduce((m, c) => Math.max(m, (c.start || 0) + (c.duration || 0)), 0),
207
+ ), 0);
208
+ return { tracks, totalDuration };
209
+ },
210
+ },
211
+
212
+ add_clip_to_timeline: {
213
+ description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — добавляется в конец дорожки.',
214
+ params: '{"nodeId":"<node-id>","trackKind":"video|audio","start":<seconds (optional)>,"duration":<seconds (optional, default по типу)>}',
215
+ async handler({ nodeId, trackKind, start, duration }) {
216
+ const b = state.currentBoard;
217
+ if (!b) throw new Error('доска не выбрана');
218
+ const node = b.metadata.nodes.find(n => n.id === nodeId);
219
+ if (!node) throw new Error(`нода не найдена: ${nodeId}`);
220
+ if (!node.file) throw new Error('у ноды нет файла (нужно сначала сгенерировать)');
221
+ if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
222
+ const tl = getTimeline();
223
+ const tk = trackKind === 'audio' ? 'audio' : 'video';
224
+ let track = tl.tracks.find(t => t.kind === tk);
225
+ if (!track) {
226
+ track = { id: crypto.randomUUID(), name: tk === 'audio' ? 'Аудио' : 'Видео', kind: tk, clips: [] };
227
+ tl.tracks.push(track);
228
+ }
229
+ const trackEnd = track.clips.reduce((m, c) => Math.max(m, (+c.start || 0) + (+c.duration || 0)), 0);
230
+ const dur = typeof duration === 'number' && duration > 0
231
+ ? duration
232
+ : (typeof defaultClipDuration === 'function' ? defaultClipDuration(node) : (node.type === 'image' ? 3 : 5));
233
+ const clip = {
234
+ id: crypto.randomUUID(),
235
+ nodeId: node.id,
236
+ type: node.type === 'audio' ? 'audio' : (node.type === 'video' ? 'video' : 'image'),
237
+ file: node.file,
238
+ name: node.name || null,
239
+ duration: dur,
240
+ start: typeof start === 'number' ? Math.max(0, start) : trackEnd,
241
+ };
242
+ track.clips.push(clip);
243
+ scheduleSave();
244
+ if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
245
+ if (typeof renderTimeline === 'function') renderTimeline();
246
+ }
247
+ return { ok: true, clipId: clip.id, trackId: track.id, start: clip.start, duration: clip.duration };
248
+ },
249
+ },
250
+
251
+ remove_clip_from_timeline: {
252
+ description: 'Удалить клип из таймлайна по clipId.',
253
+ params: '{"clipId":"<clip-id>"}',
254
+ async handler({ clipId }) {
255
+ const b = state.currentBoard;
256
+ if (!b) throw new Error('доска не выбрана');
257
+ const tl = getTimeline();
258
+ if (!tl) throw new Error('нет таймлайна');
259
+ let removed = false;
260
+ for (const t of tl.tracks) {
261
+ const i = t.clips.findIndex(c => c.id === clipId);
262
+ if (i >= 0) { t.clips.splice(i, 1); removed = true; break; }
263
+ }
264
+ if (!removed) throw new Error(`клип не найден: ${clipId}`);
265
+ scheduleSave();
266
+ if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
267
+ if (typeof renderTimeline === 'function') renderTimeline();
268
+ }
269
+ return { ok: true };
270
+ },
271
+ },
272
+
273
+ clear_timeline: {
274
+ description: 'Очистить таймлайн (все клипы со всех дорожек). Дорожки остаются.',
275
+ params: '{}',
276
+ async handler() {
277
+ const b = state.currentBoard;
278
+ if (!b) throw new Error('доска не выбрана');
279
+ const tl = getTimeline();
280
+ if (!tl) throw new Error('нет таймлайна');
281
+ for (const t of tl.tracks) t.clips = [];
282
+ scheduleSave();
283
+ if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
284
+ if (typeof renderTimeline === 'function') renderTimeline();
285
+ }
286
+ return { ok: true };
287
+ },
288
+ },
289
+
290
+ open_timeline: {
291
+ description: 'Раскрыть панель таймлайна (если скрыта).',
292
+ params: '{}',
293
+ async handler() {
294
+ const panel = document.getElementById('timelinePanel');
295
+ if (!panel) throw new Error('timeline panel не найдена');
296
+ if (panel.classList.contains('hidden')) document.getElementById('timelineBtn')?.click();
297
+ return { ok: true };
298
+ },
299
+ },
300
+
184
301
  generate_node: {
185
302
  description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога — не интерактивная UI-форма как regenerateNode.',
186
303
  params: '{"id":"<node-id>"}',
@@ -303,17 +303,25 @@
303
303
  color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
304
304
  }
305
305
  .chat-tool {
306
- margin-top: 2px; background: #0e0e0e; border: 1px solid #2a2a2a;
307
- border-radius: 3px; padding: 2px 6px; font-size: 11px;
306
+ margin-top: 2px; background: transparent; border: none;
307
+ padding: 0; font-size: 10.5px; color: #666;
308
308
  }
309
309
  .chat-tool summary {
310
- cursor: pointer; color: #aac; outline: none;
310
+ cursor: pointer; color: #666; outline: none; opacity: 0.75;
311
311
  font-family: ui-monospace, 'SF Mono', monospace;
312
+ list-style: none; padding: 1px 4px;
313
+ transition: color 0.1s, opacity 0.1s;
312
314
  }
315
+ .chat-tool summary::-webkit-details-marker { display: none; }
316
+ .chat-tool summary::before {
317
+ content: '▸ '; color: #555; font-size: 9px;
318
+ }
319
+ .chat-tool[open] summary::before { content: '▾ '; }
320
+ .chat-tool summary:hover { color: #aaa; opacity: 1; }
313
321
  .chat-tool pre {
314
- margin: 6px 0 0; color: #888; font-size: 10px;
322
+ margin: 4px 0 4px 14px; color: #777; font-size: 10px;
315
323
  white-space: pre-wrap; word-break: break-all; max-height: 200px;
316
- overflow-y: auto;
324
+ overflow-y: auto; background: #161616; padding: 4px 6px; border-radius: 3px;
317
325
  }
318
326
  .chat-status {
319
327
  color: #888; font-size: 11px; padding: 6px 10px;
@@ -511,12 +519,22 @@
511
519
  background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
512
520
  background-size: 24px 24px;
513
521
  }
522
+ /* canvas-frame даёт virtual coord-system: canvas внутри сдвинут на
523
+ PADDING_LEFT/PADDING_TOP (см. CSS-переменные ниже). Это позволяет
524
+ юзеру скроллить в «отрицательные» координаты (нода с x=-500 видна
525
+ в frame-координатах = PADDING + (-500) = 1500). Без этого ноды с
526
+ отрицательным x/y оставались за левым краем и были недоступны. */
514
527
  .canvas-frame {
515
528
  position: relative;
516
- width: 6000px; height: 4000px;
529
+ --canvas-pad-x: 2000px;
530
+ --canvas-pad-y: 1500px;
531
+ width: calc(6000px + 2 * var(--canvas-pad-x));
532
+ height: calc(4000px + 2 * var(--canvas-pad-y));
517
533
  }
518
534
  .canvas {
519
- position: absolute; left: 0; top: 0; width: 6000px; height: 4000px;
535
+ position: absolute;
536
+ left: var(--canvas-pad-x); top: var(--canvas-pad-y);
537
+ width: 6000px; height: 4000px;
520
538
  transform-origin: 0 0;
521
539
  /* `will-change: transform` — постоянная промоция в композитный слой.
522
540
  Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms