kingkont 0.11.4 → 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.4",
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>"}',
@@ -196,8 +313,17 @@
196
313
  const modelKey = node.generated?.modelKey;
197
314
  const boardHandle = b.handle;
198
315
  const bKey = b.key;
199
- // Маршрутизация по kind копия логики из resumeJob, чтобы запустить
200
- // job напрямую без gen-modal'а.
316
+ // КРИТИЧНО: выставить status='generating' ДО запуска job иначе
317
+ // нода не показывает spinner, и наша же ветка состояний может не
318
+ // подхватиться. Также чистим прошлый error и пишем kind в generated
319
+ // (resumeJob и UI зависят от node.generated.kind).
320
+ node.status = 'generating';
321
+ node.error = undefined;
322
+ node.generated = { ...(node.generated || {}), kind, prompt, rawPrompt: node.generated?.rawPrompt || prompt };
323
+ scheduleSave();
324
+ if (typeof renderCanvas === 'function') await renderCanvas();
325
+ // Маршрутизация по kind — копия логики из resumeJob/«Повторить», чтобы
326
+ // запустить job напрямую без gen-modal'а.
201
327
  if (kind === 'audio') {
202
328
  if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
203
329
  runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
@@ -323,6 +449,185 @@
323
449
  renderHistory();
324
450
  }
325
451
 
452
+ // ============== SNAPSHOTS (rollback chat actions) ==============
453
+ // Перед каждым user-сообщением чат снимает snapshot ВСЕГО проекта:
454
+ // для каждой доски — её scene.json и список media-файлов (только пути!).
455
+ // Файлы НЕ копируются. Для отката:
456
+ // 1. Восстанавливаем scene.json для каждой доски.
457
+ // 2. Если файл из snapshot отсутствует — ищем в `<board>/_deleted/`
458
+ // по basename (deleteNode складывает туда с unique-suffix).
459
+ // Хранится в памяти (последние MAX_SNAPSHOTS), теряется при close project.
460
+ const MAX_SNAPSHOTS = 10;
461
+ let snapshots = []; // [{ts, label, boards: [{kind, name, sceneText, files:[relPath]}]}]
462
+
463
+ async function takeSnapshot(label) {
464
+ if (!state.filmHandle) return null;
465
+ const boards = [];
466
+ const lists = [
467
+ { kind: 'episode', getter: listEpisodes },
468
+ { kind: 'character', getter: listCharacters },
469
+ { kind: 'location', getter: listLocations },
470
+ ];
471
+ for (const { kind, getter } of lists) {
472
+ try {
473
+ const items = await getter(state.filmHandle);
474
+ for (const it of items) {
475
+ try {
476
+ const sceneFh = await it.handle.getFileHandle('scene.json');
477
+ const sceneText = await (await sceneFh.getFile()).text();
478
+ // Список media-файлов (пути) — без чтения содержимого.
479
+ let files = [];
480
+ try {
481
+ const all = await walkBoardFiles(it.handle);
482
+ files = all
483
+ .map(f => f.relPath)
484
+ .filter(p => p !== 'scene.json' && !p.startsWith('.'));
485
+ } catch {}
486
+ boards.push({ kind, name: it.name, sceneText, files });
487
+ } catch {}
488
+ }
489
+ } catch {}
490
+ }
491
+ const snap = { ts: Date.now(), label: label || '', boards };
492
+ snapshots.push(snap);
493
+ if (snapshots.length > MAX_SNAPSHOTS) snapshots.shift();
494
+ renderSnapshotBar();
495
+ return snap;
496
+ }
497
+
498
+ // Найти в `<board>/_deleted/` файл по basename (deleteNode даёт unique-suffix
499
+ // типа `<basename>` или `<basename>(1)` если был конфликт). Перемещаем
500
+ // обратно в boardHandle/<relPath>. Возвращает true если успешно.
501
+ async function tryRestoreFromDeleted(boardHandle, relPath) {
502
+ let delDir;
503
+ try { delDir = await boardHandle.getDirectoryHandle('_deleted'); }
504
+ catch { return false; }
505
+ const wanted = relPath.split('/').pop(); // basename
506
+ const baseStem = wanted.replace(/\.[^.]+$/, '');
507
+ const ext = wanted.match(/\.[^.]+$/)?.[0] || '';
508
+ let foundName = null;
509
+ try {
510
+ for await (const [name, h] of delDir.entries()) {
511
+ if (h.kind !== 'file') continue;
512
+ if (name === wanted) { foundName = name; break; }
513
+ // uniqueName может добавить (1), (2)…
514
+ if (name.startsWith(baseStem) && name.endsWith(ext)) { foundName = name; break; }
515
+ }
516
+ } catch {}
517
+ if (!foundName) return false;
518
+ try {
519
+ const srcFh = await delDir.getFileHandle(foundName);
520
+ const blob = await srcFh.getFile();
521
+ // Пишем обратно по relPath (создаём подпапки).
522
+ const parts = relPath.split('/');
523
+ let dh = boardHandle;
524
+ for (let i = 0; i < parts.length - 1; i++) {
525
+ dh = await dh.getDirectoryHandle(parts[i], { create: true });
526
+ }
527
+ const dstFh = await dh.getFileHandle(parts[parts.length - 1], { create: true });
528
+ const w = await dstFh.createWritable();
529
+ await w.write(blob);
530
+ await w.close();
531
+ // И удаляем из _deleted.
532
+ await delDir.removeEntry(foundName);
533
+ return true;
534
+ } catch (e) {
535
+ console.warn('restore from _deleted failed:', relPath, e?.message);
536
+ return false;
537
+ }
538
+ }
539
+
540
+ async function rollbackToSnapshot(idx) {
541
+ const snap = snapshots[idx];
542
+ if (!snap || !state.filmHandle) return;
543
+ const stats = { sceneRestored: 0, filesRestored: 0, filesMissing: 0 };
544
+ for (const b of snap.boards) {
545
+ let parent = state.filmHandle;
546
+ try {
547
+ if (b.kind === 'character') parent = await state.filmHandle.getDirectoryHandle('_characters', { create: true });
548
+ else if (b.kind === 'location') parent = await state.filmHandle.getDirectoryHandle('_locations', { create: true });
549
+ } catch {}
550
+ let boardHandle;
551
+ try { boardHandle = await parent.getDirectoryHandle(b.name, { create: true }); }
552
+ catch (e) { console.warn('rollback: cannot get board', b.name, e); continue; }
553
+ // 1) scene.json — overwrite.
554
+ try {
555
+ const sceneFh = await boardHandle.getFileHandle('scene.json', { create: true });
556
+ const w = await sceneFh.createWritable();
557
+ await w.write(b.sceneText);
558
+ await w.close();
559
+ stats.sceneRestored++;
560
+ } catch (e) { console.warn('rollback: scene write failed', b.name, e); }
561
+ // 2) media-файлы: что отсутствует — пробуем restore из _deleted/.
562
+ for (const relPath of b.files) {
563
+ try {
564
+ await resolveBoardFile(boardHandle, relPath);
565
+ continue; // файл уже на месте
566
+ } catch {}
567
+ if (await tryRestoreFromDeleted(boardHandle, relPath)) stats.filesRestored++;
568
+ else stats.filesMissing++;
569
+ }
570
+ }
571
+ // Удаляем snapshot'ы более новые чем восстановленный (он стал текущим).
572
+ snapshots = snapshots.slice(0, idx);
573
+ renderSnapshotBar();
574
+ // Перечитать текущую доску в UI.
575
+ if (state.currentBoard && typeof selectBoard === 'function') {
576
+ try { await selectBoard({ kind: state.currentBoard.kind, name: state.currentBoard.name, handle: state.currentBoard.handle }); }
577
+ catch {}
578
+ }
579
+ if (typeof refreshSidebar === 'function') await refreshSidebar();
580
+ appendStatus(`↩ Откат: scene=${stats.sceneRestored}, files restored=${stats.filesRestored}, missing=${stats.filesMissing}`);
581
+ }
582
+
583
+ function renderSnapshotBar() {
584
+ const bar = $('chatSnapshotBar');
585
+ if (!bar) return;
586
+ bar.innerHTML = '';
587
+ if (!snapshots.length) { bar.style.display = 'none'; return; }
588
+ bar.style.display = '';
589
+ const lbl = document.createElement('span');
590
+ lbl.textContent = `Снепшоты: ${snapshots.length} `;
591
+ lbl.style.cssText = 'color:#888; font-size:11px;';
592
+ bar.appendChild(lbl);
593
+ // Кнопки: undo до последнего snapshot. Меню по клику для выбора более старого.
594
+ const undoBtn = document.createElement('button');
595
+ undoBtn.textContent = '↩ Откатить';
596
+ undoBtn.title = `Откат до: ${snapshots[snapshots.length - 1]?.label || '(без метки)'}`;
597
+ undoBtn.addEventListener('click', () => {
598
+ if (!confirm(`Откатить до snapshot'а: «${snapshots[snapshots.length - 1].label || '(без метки)'}»?`)) return;
599
+ rollbackToSnapshot(snapshots.length - 1);
600
+ });
601
+ bar.appendChild(undoBtn);
602
+ if (snapshots.length > 1) {
603
+ const moreBtn = document.createElement('button');
604
+ moreBtn.textContent = '…';
605
+ moreBtn.title = 'Выбрать более старый snapshot';
606
+ moreBtn.addEventListener('click', e => {
607
+ e.stopPropagation();
608
+ const menu = $('nodeMenu');
609
+ if (!menu) return;
610
+ menu.innerHTML = '';
611
+ for (let i = snapshots.length - 1; i >= 0; i--) {
612
+ const s = snapshots[i];
613
+ const dt = new Date(s.ts).toLocaleTimeString('ru-RU');
614
+ const b = document.createElement('button');
615
+ b.textContent = `↩ ${dt} · ${s.label || '(без метки)'}`;
616
+ const idx = i;
617
+ b.addEventListener('click', () => {
618
+ menu.classList.add('hidden');
619
+ if (!confirm(`Откатить до «${s.label || '(без метки)'}»?`)) return;
620
+ rollbackToSnapshot(idx);
621
+ });
622
+ menu.appendChild(b);
623
+ }
624
+ positionFloatingMenu(menu, e.clientX, e.clientY);
625
+ setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
626
+ });
627
+ bar.appendChild(moreBtn);
628
+ }
629
+ }
630
+
326
631
  // ============== UI ==============
327
632
  let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
328
633
  let busy = false;
@@ -454,6 +759,11 @@
454
759
  if (busy) return;
455
760
  if (!userText.trim()) return;
456
761
  busy = true;
762
+ // Snapshot ДО мутации — чтобы юзер мог откатить «всё что чат сделал
763
+ // в ответ на это сообщение». Шортнем label первыми 60 символами user-msg.
764
+ try {
765
+ await takeSnapshot(userText.length > 60 ? userText.slice(0, 60) + '…' : userText);
766
+ } catch (e) { console.warn('snapshot failed:', e?.message); }
457
767
  history.push({ role: 'user', content: userText });
458
768
  renderHistory();
459
769
  persistDebounced();
@@ -621,6 +931,7 @@
621
931
  <button id="chatClear" title="Очистить историю">⌫</button>
622
932
  <button id="chatClose" title="Закрыть">×</button>
623
933
  </div>
934
+ <div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
624
935
  <div id="chatList" class="chat-list"></div>
625
936
  <div class="chat-input-row">
626
937
  <textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
@@ -668,10 +979,11 @@
668
979
  close: () => $('chatPanel')?.classList.add('hidden'),
669
980
  send,
670
981
  // User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
671
- clear: () => { history = []; renderHistory(); persistNow().catch(() => {}); },
982
+ clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
672
983
  // Reset-on-close: только in-memory + UI, на диске НЕ трогает (история
673
984
  // должна остаться чтобы при следующем открытии того же проекта подгрузилась).
674
- resetInMemory: () => { history = []; renderHistory(); },
985
+ // Snapshots сбрасываем тоже они привязаны к текущему filmHandle.
986
+ resetInMemory: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); },
675
987
  // board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
676
988
  loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
677
989
  tools: TOOLS,
@@ -268,6 +268,15 @@
268
268
  border-radius: 4px; padding: 2px 8px; cursor: pointer; font-size: 12px;
269
269
  }
270
270
  .chat-header button:hover { background: #2a2a2a; color: #fff; }
271
+ .chat-snapshot-bar {
272
+ padding: 6px 10px; background: #1f1f1f; border-bottom: 1px solid #2a2a2a;
273
+ display: flex; align-items: center; gap: 6px; flex-shrink: 0;
274
+ }
275
+ .chat-snapshot-bar button {
276
+ background: #2a2a2a; border: 1px solid #444; color: #cde;
277
+ padding: 2px 8px; border-radius: 4px; font-size: 11px; cursor: pointer;
278
+ }
279
+ .chat-snapshot-bar button:hover { background: #3a3a3a; }
271
280
  .chat-list {
272
281
  flex: 1; overflow-y: auto; padding: 10px;
273
282
  display: flex; flex-direction: column; gap: 6px;
@@ -294,17 +303,25 @@
294
303
  color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
295
304
  }
296
305
  .chat-tool {
297
- margin-top: 2px; background: #0e0e0e; border: 1px solid #2a2a2a;
298
- 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;
299
308
  }
300
309
  .chat-tool summary {
301
- cursor: pointer; color: #aac; outline: none;
310
+ cursor: pointer; color: #666; outline: none; opacity: 0.75;
302
311
  font-family: ui-monospace, 'SF Mono', monospace;
312
+ list-style: none; padding: 1px 4px;
313
+ transition: color 0.1s, opacity 0.1s;
314
+ }
315
+ .chat-tool summary::-webkit-details-marker { display: none; }
316
+ .chat-tool summary::before {
317
+ content: '▸ '; color: #555; font-size: 9px;
303
318
  }
319
+ .chat-tool[open] summary::before { content: '▾ '; }
320
+ .chat-tool summary:hover { color: #aaa; opacity: 1; }
304
321
  .chat-tool pre {
305
- margin: 6px 0 0; color: #888; font-size: 10px;
322
+ margin: 4px 0 4px 14px; color: #777; font-size: 10px;
306
323
  white-space: pre-wrap; word-break: break-all; max-height: 200px;
307
- overflow-y: auto;
324
+ overflow-y: auto; background: #161616; padding: 4px 6px; border-radius: 3px;
308
325
  }
309
326
  .chat-status {
310
327
  color: #888; font-size: 11px; padding: 6px 10px;
@@ -502,12 +519,22 @@
502
519
  background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
503
520
  background-size: 24px 24px;
504
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 оставались за левым краем и были недоступны. */
505
527
  .canvas-frame {
506
528
  position: relative;
507
- 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));
508
533
  }
509
534
  .canvas {
510
- 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;
511
538
  transform-origin: 0 0;
512
539
  /* `will-change: transform` — постоянная промоция в композитный слой.
513
540
  Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms