kingkont 0.8.1 → 0.8.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # KingKont · Chatium
1
+ # KingKont - редактор AI-видео
2
2
 
3
3
  Локальный нод-редактор сцен с AI-генерацией (картинки, видео, голос, SFX,
4
4
  музыка, текст). Каждая «сцена» — папка на диске с `scene.json`, файлы
package/index.html CHANGED
@@ -37,7 +37,13 @@
37
37
  </div>
38
38
 
39
39
  <div class="sidebar-section flex">
40
- <h3>Сцены <button id="newEpisode" disabled title="Создать сцену">+</button></h3>
40
+ <h3>
41
+ <span>Сцены</span>
42
+ <span style="display:flex; gap:4px;">
43
+ <button id="newEpisode" disabled title="Создать сцену">+</button>
44
+ <button id="openTemplates" title="Открыть библиотеку шаблонов" style="font-size:12px;">📚</button>
45
+ </span>
46
+ </h3>
41
47
  <div class="sidebar-list" id="episodeList"></div>
42
48
  </div>
43
49
  <div class="sidebar-footer">
@@ -232,6 +238,9 @@
232
238
  <button data-act="audio">🎙 Сгенерировать голос</button>
233
239
  <button data-act="image">🖼 Сгенерировать картинку</button>
234
240
  <button data-act="video">🎬 Сгенерировать видео</button>
241
+ <hr style="margin:4px 0; border:0; border-top:1px solid #333;">
242
+ <button data-act="save-template" title="Загрузить эту сцену как шаблон на сервер">💾 Сохранить сцену как шаблон</button>
243
+ <button data-act="save-project-template" title="Загрузить весь проект (все сцены/персонажи/локации) как шаблон">💾 Сохранить проект как шаблон</button>
235
244
  </div>
236
245
 
237
246
  <!-- ===== Settings modal (двойной клик на сгенерированную ноду) ===== -->
@@ -526,12 +535,37 @@
526
535
  <button id="fsClose" title="Закрыть (Esc)">×</button>
527
536
  </div>
528
537
 
538
+ <!-- ===== Templates modal — список шаблонов с сервера Chatium ===== -->
539
+ <div class="modal hidden" id="templatesModal">
540
+ <div class="modal-card" style="min-width: 600px; max-width: 90vw; max-height: 90vh;">
541
+ <div style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
542
+ <h3 style="margin:0; flex:1;">Шаблоны</h3>
543
+ <button id="tplSaveCurrent" class="primary" title="Сохранить текущую сцену как шаблон">+ Сцена</button>
544
+ <button id="tplSaveProject" class="primary" title="Сохранить весь проект (все сцены, персонажи, локации) как шаблон">+ Проект</button>
545
+ <button id="tplRefresh" title="Обновить список">↻</button>
546
+ <button id="tplClose">×</button>
547
+ </div>
548
+ <div id="tplStatus" class="status" style="margin-bottom:8px;"></div>
549
+ <div id="tplList" style="display:flex; flex-direction:column; gap:8px; max-height:60vh; overflow-y:auto;">
550
+ <div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>
551
+ </div>
552
+ <!-- Прогресс при upload/download (показывается во время операций). -->
553
+ <div id="tplProgress" class="hidden" style="margin-top:12px; padding:10px; background:#1a1a1a; border:1px solid #333; border-radius:6px;">
554
+ <div id="tplProgressLabel" style="font-size:12px; color:#bbb; margin-bottom:6px;"></div>
555
+ <div style="width:100%; height:6px; background:#333; border-radius:3px; overflow:hidden;">
556
+ <div id="tplProgressBar" style="width:0; height:100%; background:#3a5a8a; transition:width 0.2s;"></div>
557
+ </div>
558
+ </div>
559
+ </div>
560
+ </div>
561
+
529
562
  <script src="renderer/state.js"></script>
530
563
  <script src="renderer/board.js"></script>
531
564
  <script src="renderer/settings.js"></script>
532
565
  <script src="renderer/media.js"></script>
533
566
  <script src="renderer/generate.js"></script>
534
567
  <script src="renderer/timeline.js"></script>
568
+ <script src="renderer/templates.js"></script>
535
569
 
536
570
  </body>
537
571
  </html>
package/lib/providers.js CHANGED
@@ -21,6 +21,11 @@ const CHATIUM_PATHS = {
21
21
  balance: '/app/spaces/server/api/balance',
22
22
  transactions: '/app/spaces/server/api/transactions',
23
23
  uploadUrl: '/app/spaces/server/api/upload_url',
24
+ // Шаблоны сцен/персонажей/локаций. Хранят manifest (scene.json) и
25
+ // ссылки на CDN-файлы (загруженные через uploadUrl).
26
+ // Формат шаблона:
27
+ // { id, name, kind, manifest, files: { 'frames/x.jpg': cdnUrl, ... }, createdAt }
28
+ templates: '/app/spaces/server/api/templates',
24
29
  };
25
30
 
26
31
  const CHATIUM_CDN = 'https://fs.chatium.ru/get';
@@ -685,6 +690,69 @@ async function fetchTransactions(s) {
685
690
  return data;
686
691
  }
687
692
 
693
+ // =============================================================================
694
+ // PUBLIC API: templates (CRUD на Chatium-сервере).
695
+ // =============================================================================
696
+ // Шаблон — это сериализованная сцена/персонаж/локация: scene.json + ссылки на
697
+ // все медиа-файлы (загружены в Chatium через uploadUrl, доступны по CDN).
698
+ // При открытии шаблона client скачивает все файлы локально через handleProxy.
699
+
700
+ function chatiumAuthHeaders(s) {
701
+ if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
702
+ throw new Error('Войдите в KingKont для работы с шаблонами');
703
+ }
704
+ return { 'Authorization': `Bearer ${s.chatium.token}` };
705
+ }
706
+
707
+ async function listTemplates(s) {
708
+ const headers = chatiumAuthHeaders(s);
709
+ logCall('GET ', 'Chatium', chatiumBase(s) + CHATIUM_PATHS.templates);
710
+ const r = await fetch(chatiumBase(s) + CHATIUM_PATHS.templates, { headers });
711
+ const text = await r.text();
712
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
713
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
714
+ return d;
715
+ }
716
+
717
+ async function getTemplate(id, s) {
718
+ const headers = chatiumAuthHeaders(s);
719
+ // tilde-suffix: /templates~get?id=... (см. spaces/server/api/templates.ts)
720
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.templates}~get?id=${encodeURIComponent(id)}`;
721
+ logCall('GET ', 'Chatium', url);
722
+ const r = await fetch(url, { headers });
723
+ const text = await r.text();
724
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
725
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
726
+ return d;
727
+ }
728
+
729
+ async function createTemplate(body, s) {
730
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
731
+ logCall('POST', 'Chatium', chatiumBase(s) + CHATIUM_PATHS.templates,
732
+ `name=${body?.name} files=${Object.keys(body?.files || {}).length}`);
733
+ const r = await fetch(chatiumBase(s) + CHATIUM_PATHS.templates, {
734
+ method: 'POST', headers, body: JSON.stringify(body),
735
+ });
736
+ const text = await r.text();
737
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
738
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
739
+ return d;
740
+ }
741
+
742
+ async function deleteTemplate(id, s) {
743
+ const headers = chatiumAuthHeaders(s);
744
+ // POST /templates~delete?id=... (Chatium-роутер не уважает HTTP DELETE
745
+ // в file-based routing, поэтому везде POST для side-effects).
746
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.templates}~delete?id=${encodeURIComponent(id)}`;
747
+ logCall('POST', 'Chatium', url);
748
+ const r = await fetch(url, { method: 'POST', headers });
749
+ if (!r.ok) {
750
+ const text = await r.text().catch(() => '');
751
+ throw new Error(text || `HTTP ${r.status}`);
752
+ }
753
+ return true;
754
+ }
755
+
688
756
  async function listElevenVoices() {
689
757
  const key = process.env.ELEVENLABS_API_KEY;
690
758
  if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
@@ -719,6 +787,11 @@ module.exports = {
719
787
  fetchBalances,
720
788
  fetchTransactions,
721
789
  listElevenVoices,
790
+ // Templates (CRUD на Chatium-сервере)
791
+ listTemplates,
792
+ getTemplate,
793
+ createTemplate,
794
+ deleteTemplate,
722
795
  // Constants (для server.js / тестов)
723
796
  KIE_IMAGE_MODELS,
724
797
  KIE_VIDEO_MODELS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -47,6 +47,11 @@ window.addEventListener('DOMContentLoaded', async () => {
47
47
  if (rqOpen) $('repliquesPanel').classList.remove('hidden');
48
48
  // Welcome-кнопка дублирует pickRoot click (с user-gesture от пользователя).
49
49
  $('welcomeOpen').addEventListener('click', () => $('pickRoot').click());
50
+ // Открытие окна шаблонов — доступно даже без проекта (юзер может посмотреть
51
+ // список, открытие конкретного шаблона потребует filmHandle).
52
+ $('openTemplates')?.addEventListener('click', () => {
53
+ if (typeof openTemplatesWindow === 'function') openTemplatesWindow();
54
+ });
50
55
  // Sidebar search — фильтрует персонажей/локации/сцены.
51
56
  $('sidebarSearch').addEventListener('input', e => {
52
57
  const q = e.target.value.trim().toLowerCase();
@@ -429,9 +434,54 @@ async function renderWelcomeRecents() {
429
434
  }
430
435
  }
431
436
 
432
- // Сгенерировать превью: первый image-нод любого board'а проекта,
437
+ // Скопировать файл image-ноды в корень проекта как `.cover.<ext>`.
438
+ // Используется как обложка проекта (welcome-recents, templates list).
439
+ // Старые .cover.* удаляются — расширение может смениться.
440
+ async function setProjectCoverFromNode(node) {
441
+ if (!state.filmHandle || !node?.file) throw new Error('нет проекта или файла');
442
+ const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
443
+ const file = await fh.getFile();
444
+ // Удаляем существующие .cover.* (любое расширение).
445
+ for await (const [name] of state.filmHandle.entries()) {
446
+ if (name.startsWith('.cover.')) {
447
+ try { await state.filmHandle.removeEntry(name); } catch {}
448
+ }
449
+ }
450
+ const ext = (node.file.split('.').pop() || 'jpg').toLowerCase();
451
+ const coverName = `.cover.${ext}`;
452
+ const out = await state.filmHandle.getFileHandle(coverName, { create: true });
453
+ const w = await out.createWritable();
454
+ await w.write(file);
455
+ await w.close();
456
+ // Обновим recents-thumb асинхронно.
457
+ try {
458
+ const thumb = await generateProjectThumb(state.filmHandle);
459
+ if (thumb) await touchRecent(state.filmHandle, thumb);
460
+ } catch {}
461
+ }
462
+
463
+ // Возвращает .cover.<ext> File, если есть в корне проекта. Иначе null.
464
+ async function readProjectCover(filmHandle) {
465
+ if (!filmHandle) return null;
466
+ try {
467
+ for await (const [name, h] of filmHandle.entries()) {
468
+ if (h.kind === 'file' && name.startsWith('.cover.')) {
469
+ return await h.getFile();
470
+ }
471
+ }
472
+ } catch {}
473
+ return null;
474
+ }
475
+
476
+ // Сгенерировать превью: сначала пытаемся .cover.* (явно выбранная юзером
477
+ // обложка), иначе fallback на первый image-нод любого board'а.
433
478
  // resize в 320×180, JPEG. Возвращает Blob или null.
434
479
  async function generateProjectThumb(filmHandle) {
480
+ // Manual cover wins over auto-pick.
481
+ const cover = await readProjectCover(filmHandle);
482
+ if (cover) {
483
+ try { return await blobToThumbJpeg(cover, 320, 180); } catch {}
484
+ }
435
485
  try {
436
486
  // Собрать кандидатов: characters + locations + episodes (в этом приоритете).
437
487
  const candidates = [];
@@ -1546,6 +1596,12 @@ function clearCanvasKeepSvg() {
1546
1596
  if (child !== svg) child.remove();
1547
1597
  }
1548
1598
  if (canvas.firstChild !== svg) canvas.insertBefore(svg, canvas.firstChild);
1599
+ // Чистим коннекторы внутри SVG (кроме временной линии drag'а). Иначе при
1600
+ // закрытии проекта или открытии пустого холста на нём оставались bezier'ы
1601
+ // от предыдущей сцены — нод нет, а линии висят.
1602
+ for (const ch of [...svg.children]) {
1603
+ if (!ch.classList.contains('temp-line')) ch.remove();
1604
+ }
1549
1605
  }
1550
1606
 
1551
1607
  function showEmpty() {
@@ -1855,6 +1911,18 @@ function showNodeContextMenu(node, clientX, clientY) {
1855
1911
  if (node.type === 'image' || node.type === 'video' || node.type === 'audio') {
1856
1912
  add('➕ В таймлайн', () => addToTimeline(node));
1857
1913
  }
1914
+ // Image-нода как обложка проекта — копируется в `<project>/.cover.<ext>`,
1915
+ // оттуда подхватывается generateProjectThumb (для recents и шаблонов).
1916
+ if (node.type === 'image' && node.file && state.filmHandle) {
1917
+ add('📌 Сделать обложкой проекта', async () => {
1918
+ try {
1919
+ await setProjectCoverFromNode(node);
1920
+ alert('Обложка проекта обновлена');
1921
+ } catch (e) {
1922
+ alert('Не удалось установить обложку: ' + (e?.message || e));
1923
+ }
1924
+ });
1925
+ }
1858
1926
  // Локация: пометить картинку как location sheet
1859
1927
  if (node.type === 'image' && node.file && state.currentBoard?.kind === 'location') {
1860
1928
  const isSheet = state.currentBoard.metadata.location?.sheet === node.file;
@@ -158,6 +158,24 @@ document.querySelectorAll('#addMenu button').forEach(btn => {
158
158
  state.addMenuPos = null;
159
159
  return;
160
160
  }
161
+ if (act === 'save-template') {
162
+ // Сохранение всей сцены как шаблона на Chatium-сервер.
163
+ // saveCurrentBoardAsTemplate сам откроет templates-модалку (для
164
+ // прогресс-бара) и обновит список после успешного upload'а.
165
+ state.addMenuPos = null;
166
+ if (typeof saveCurrentBoardAsTemplate === 'function') {
167
+ await saveCurrentBoardAsTemplate();
168
+ }
169
+ return;
170
+ }
171
+ if (act === 'save-project-template') {
172
+ // Сохранение ВСЕГО проекта (все boards) как одного шаблона.
173
+ state.addMenuPos = null;
174
+ if (typeof saveCurrentProjectAsTemplate === 'function') {
175
+ await saveCurrentProjectAsTemplate();
176
+ }
177
+ return;
178
+ }
161
179
  if (act === 'gen-text') {
162
180
  // открываем text-gen modal; если есть fromNode — подставляем @ref в промпт
163
181
  if (!await ensureApiKey('text')) return;
@@ -0,0 +1,614 @@
1
+ // renderer/templates.js — список / сохранение / открытие шаблонов сцен.
2
+ //
3
+ // Шаблон — это сериализованный board (scene + media), хранящийся на
4
+ // Chatium-сервере. Структура шаблона на сервере:
5
+ // { id, name, kind, manifest, files: { 'frames/x.jpg': cdnUrl, ... }, createdAt }
6
+ //
7
+ // Где manifest = содержимое scene.json (ноды, connections, settings, …),
8
+ // files = маппинг relPath → CDN-URL загруженного файла.
9
+ //
10
+ // Save flow (saveCurrentBoardAsTemplate):
11
+ // 1. Собрать все файлы текущего board'а (рекурсивно через walkBoardFiles)
12
+ // 2. POST /api/upload каждый файл → получить CDN-URL
13
+ // 3. Прочитать scene.json
14
+ // 4. POST /api/templates с {name, kind, manifest, files}
15
+ //
16
+ // Open flow (openTemplate):
17
+ // 1. GET /api/templates/<id>
18
+ // 2. Спросить юзера имя нового board'а
19
+ // 3. Создать пустую папку под текущим filmHandle
20
+ // 4. Скачать каждый файл из manifest.files (через /api/proxy?url=…)
21
+ // 5. Записать scene.json
22
+ // 6. selectBoard(новый board)
23
+
24
+ const TPL_PROGRESS = {
25
+ el: () => document.getElementById('tplProgress'),
26
+ label: () => document.getElementById('tplProgressLabel'),
27
+ bar: () => document.getElementById('tplProgressBar'),
28
+ show(label) {
29
+ TPL_PROGRESS.el().classList.remove('hidden');
30
+ TPL_PROGRESS.label().textContent = label;
31
+ TPL_PROGRESS.bar().style.width = '0%';
32
+ },
33
+ update(done, total, label) {
34
+ if (label) TPL_PROGRESS.label().textContent = label;
35
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
36
+ TPL_PROGRESS.bar().style.width = pct + '%';
37
+ },
38
+ hide() { TPL_PROGRESS.el().classList.add('hidden'); },
39
+ };
40
+
41
+ function tplStatus(msg, isError) {
42
+ const el = document.getElementById('tplStatus');
43
+ if (!el) return;
44
+ el.textContent = msg || '';
45
+ el.className = 'status' + (isError ? ' error' : '');
46
+ }
47
+
48
+ // =================== Open templates window ===================
49
+ async function openTemplatesWindow() {
50
+ const modal = document.getElementById('templatesModal');
51
+ if (!modal) return;
52
+ modal.classList.remove('hidden');
53
+ // Кнопка «+ Сцена» доступна только если открыт board (currentBoard).
54
+ // Кнопка «+ Проект» доступна если открыт film (filmHandle, без необходимости board'а).
55
+ const saveBtn = document.getElementById('tplSaveCurrent');
56
+ if (saveBtn) saveBtn.disabled = !state.currentBoard;
57
+ const saveProjBtn = document.getElementById('tplSaveProject');
58
+ if (saveProjBtn) saveProjBtn.disabled = !state.filmHandle;
59
+ await refreshTemplatesList();
60
+ }
61
+
62
+ document.getElementById('tplClose')?.addEventListener('click', () => {
63
+ document.getElementById('templatesModal')?.classList.add('hidden');
64
+ });
65
+ document.getElementById('tplRefresh')?.addEventListener('click', () => refreshTemplatesList());
66
+
67
+ // =================== List templates ===================
68
+ async function refreshTemplatesList() {
69
+ const list = document.getElementById('tplList');
70
+ if (!list) return;
71
+ list.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>';
72
+ tplStatus('');
73
+ try {
74
+ const r = await fetch('/api/templates');
75
+ if (!r.ok) {
76
+ const err = await r.json().catch(() => ({}));
77
+ throw new Error(err.error || `HTTP ${r.status}`);
78
+ }
79
+ const data = await r.json();
80
+ const items = Array.isArray(data) ? data : (data.items || data.templates || []);
81
+ renderTemplatesList(items);
82
+ } catch (e) {
83
+ list.innerHTML = '';
84
+ tplStatus(`Ошибка: ${e.message}`, true);
85
+ }
86
+ }
87
+
88
+ function renderTemplatesList(items) {
89
+ const list = document.getElementById('tplList');
90
+ list.innerHTML = '';
91
+ if (!items.length) {
92
+ list.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Шаблонов пока нет. Сохрани текущую сцену как шаблон, чтобы появилась в списке.</div>';
93
+ return;
94
+ }
95
+ for (const t of items) {
96
+ const card = document.createElement('div');
97
+ card.style.cssText = 'display:flex; gap:12px; align-items:center; padding:10px 12px; background:#1a1a1a; border:1px solid #333; border-radius:6px;';
98
+
99
+ // Обложка — сразу слева. Если coverUrl нет, показываем placeholder.
100
+ const cover = document.createElement('div');
101
+ cover.style.cssText = 'width:80px; height:60px; flex-shrink:0; border-radius:4px; background:#0e0e0e; border:1px solid #2a2a2a; overflow:hidden; display:flex; align-items:center; justify-content:center; color:#444; font-size:24px;';
102
+ if (t.coverUrl) {
103
+ const img = document.createElement('img');
104
+ img.style.cssText = 'width:100%; height:100%; object-fit:cover;';
105
+ img.src = '/api/proxy?url=' + encodeURIComponent(t.coverUrl);
106
+ img.draggable = false;
107
+ img.onerror = () => { cover.textContent = '🖼'; };
108
+ cover.appendChild(img);
109
+ } else {
110
+ cover.textContent = t.kind === 'project' ? '🎬' : '🎞';
111
+ }
112
+ card.appendChild(cover);
113
+
114
+ const info = document.createElement('div');
115
+ info.style.cssText = 'flex:1; min-width:0;';
116
+ const title = document.createElement('div');
117
+ title.style.cssText = 'font-size:14px; color:#e0e0e0; font-weight:500; word-break:break-word;';
118
+ title.textContent = t.name || 'без названия';
119
+ const meta = document.createElement('div');
120
+ meta.style.cssText = 'font-size:11px; color:#888; margin-top:4px;';
121
+ const kindLabel = ({ episode: 'сцена', character: 'персонаж', location: 'локация', project: '🎬 проект' }[t.kind] || t.kind || '—');
122
+ const filesCount = t.fileCount ?? Object.keys(t.files || {}).length;
123
+ const dateStr = t.createdAt ? new Date(t.createdAt).toLocaleString('ru-RU') : '';
124
+ meta.textContent = [kindLabel, filesCount ? `${filesCount} файл${filesCount === 1 ? '' : 'ов'}` : null, dateStr].filter(Boolean).join(' · ');
125
+ info.append(title, meta);
126
+
127
+ const isProject = t.kind === 'project';
128
+ const openBtn = document.createElement('button');
129
+ openBtn.className = 'primary';
130
+ openBtn.textContent = 'Открыть';
131
+ if (isProject) {
132
+ // Project-templates требуют выбор parent-папки через showDirectoryPicker
133
+ // — открываются всегда, независимо от того, есть ли filmHandle.
134
+ openBtn.disabled = false;
135
+ openBtn.title = 'Выбрать папку и развернуть в неё проект';
136
+ openBtn.addEventListener('click', () => openProjectTemplate(t.id, t.name));
137
+ } else {
138
+ openBtn.disabled = !state.filmHandle;
139
+ openBtn.title = state.filmHandle ? 'Скачать локально и открыть как новую сцену' : 'Сначала открой проект';
140
+ openBtn.addEventListener('click', () => openTemplate(t.id, t.name));
141
+ }
142
+
143
+ const delBtn = document.createElement('button');
144
+ delBtn.className = 'danger';
145
+ delBtn.textContent = '🗑';
146
+ delBtn.title = 'Удалить шаблон';
147
+ delBtn.addEventListener('click', () => deleteTemplateConfirm(t.id, t.name));
148
+
149
+ card.append(info, openBtn, delBtn);
150
+ list.appendChild(card);
151
+ }
152
+ }
153
+
154
+ // =================== Save current board as template ===================
155
+ // Загружает .cover из проекта (если есть) на Chatium → возвращает CDN URL
156
+ // или null. Используется для обоих типов template'ов (board и project).
157
+ async function uploadProjectCoverIfAny() {
158
+ if (!state.filmHandle || typeof readProjectCover !== 'function') return null;
159
+ const cover = await readProjectCover(state.filmHandle);
160
+ if (!cover) return null;
161
+ const r = await fetch('/api/upload', {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': cover.type || 'application/octet-stream',
165
+ 'X-File-Name': encodeURIComponent('cover.' + (cover.type?.split('/')[1] || 'jpg')),
166
+ },
167
+ body: await cover.arrayBuffer(),
168
+ });
169
+ if (!r.ok) return null;
170
+ const { url } = await r.json();
171
+ return url;
172
+ }
173
+
174
+ async function saveCurrentBoardAsTemplate() {
175
+ if (!state.currentBoard) {
176
+ alert('Сначала открой сцену');
177
+ return;
178
+ }
179
+ const defaultName = state.currentBoard.name || 'Шаблон';
180
+ const name = await askName('Имя шаблона:', defaultName, defaultName, { okText: 'Сохранить' });
181
+ if (!name) return;
182
+
183
+ // Открываем модалку шаблонов — там лежат progress-bar и список,
184
+ // в который добавится новая запись после успешного upload'а.
185
+ // Если уже открыта — просто будет видно прогресс там же.
186
+ const modal = document.getElementById('templatesModal');
187
+ if (modal) modal.classList.remove('hidden');
188
+ // Загружаем актуальный список (пока юзер ждёт upload — видно что было).
189
+ refreshTemplatesList().catch(() => {});
190
+
191
+ const board = state.currentBoard;
192
+ TPL_PROGRESS.show('Сбор файлов…');
193
+ tplStatus('');
194
+
195
+ try {
196
+ // 1. Собрать все файлы board'а (рекурсивно).
197
+ const allFiles = await walkBoardFiles(board.handle);
198
+ // Исключаем scene.json — он уйдёт как manifest, не как файл.
199
+ const mediaFiles = allFiles.filter(f => f.relPath !== 'scene.json' && !f.relPath.startsWith('.thumbnails/'));
200
+
201
+ // 2. Прочитать manifest (scene.json).
202
+ let manifest = null;
203
+ try {
204
+ const sceneFh = await board.handle.getFileHandle('scene.json');
205
+ const sceneFile = await sceneFh.getFile();
206
+ manifest = JSON.parse(await sceneFile.text());
207
+ } catch (e) {
208
+ throw new Error('Не удалось прочитать scene.json: ' + e.message);
209
+ }
210
+
211
+ // 3. Загрузить все файлы на Chatium через /api/upload.
212
+ const fileMap = {}; // relPath → cdnUrl
213
+ let done = 0;
214
+ for (const f of mediaFiles) {
215
+ TPL_PROGRESS.update(done, mediaFiles.length, `Загрузка: ${f.relPath} (${done + 1}/${mediaFiles.length})`);
216
+ const blob = await f.fileHandle.getFile();
217
+ const r = await fetch('/api/upload', {
218
+ method: 'POST',
219
+ headers: {
220
+ 'Content-Type': blob.type || 'application/octet-stream',
221
+ 'X-File-Name': encodeURIComponent(f.relPath.split('/').pop()),
222
+ },
223
+ body: await blob.arrayBuffer(),
224
+ });
225
+ if (!r.ok) {
226
+ const err = await r.json().catch(() => ({}));
227
+ throw new Error(`upload ${f.relPath}: ${err.error || r.status}`);
228
+ }
229
+ const { url } = await r.json();
230
+ fileMap[f.relPath] = url;
231
+ done++;
232
+ }
233
+ TPL_PROGRESS.update(done, mediaFiles.length, 'Загрузка обложки…');
234
+ const coverUrl = await uploadProjectCoverIfAny();
235
+ TPL_PROGRESS.update(done, mediaFiles.length, 'Регистрация шаблона…');
236
+
237
+ // 4. Создать шаблон на сервере.
238
+ const r = await fetch('/api/templates', {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({
242
+ name,
243
+ kind: board.kind,
244
+ manifest: { ...manifest, ...(coverUrl ? { coverUrl } : {}) },
245
+ files: fileMap,
246
+ }),
247
+ });
248
+ if (!r.ok) {
249
+ const err = await r.json().catch(() => ({}));
250
+ throw new Error(err.error || `HTTP ${r.status}`);
251
+ }
252
+
253
+ TPL_PROGRESS.hide();
254
+ tplStatus(`Шаблон «${name}» сохранён (${mediaFiles.length} файл${mediaFiles.length === 1 ? '' : 'ов'})`);
255
+ await refreshTemplatesList();
256
+ } catch (e) {
257
+ TPL_PROGRESS.hide();
258
+ tplStatus('Ошибка сохранения: ' + e.message, true);
259
+ console.error('save template failed', e);
260
+ }
261
+ }
262
+
263
+ document.getElementById('tplSaveCurrent')?.addEventListener('click', saveCurrentBoardAsTemplate);
264
+ document.getElementById('tplSaveProject')?.addEventListener('click', saveCurrentProjectAsTemplate);
265
+
266
+ // =================== Save WHOLE PROJECT as template ===================
267
+ // Шаблон проекта = массив board'ов { kind, name, manifest, files }, лежит
268
+ // в поле `manifest.boards` записи на сервере (kind='project'). Поле `files`
269
+ // в верхней структуре пустое — все файлы хранятся внутри boards[i].files.
270
+ async function saveCurrentProjectAsTemplate() {
271
+ if (!state.filmHandle) {
272
+ alert('Сначала открой проект');
273
+ return;
274
+ }
275
+ const defaultName = state.filmHandle.name || 'Проект';
276
+ const name = await askName('Имя шаблона проекта:', defaultName, defaultName, { okText: 'Сохранить' });
277
+ if (!name) return;
278
+
279
+ const modal = document.getElementById('templatesModal');
280
+ if (modal) modal.classList.remove('hidden');
281
+ refreshTemplatesList().catch(() => {});
282
+
283
+ TPL_PROGRESS.show('Сбор board\'ов проекта…');
284
+ tplStatus('');
285
+
286
+ try {
287
+ // 1. Собираем все board'ы (characters / locations / episodes).
288
+ const allBoards = await collectProjectBoards(state.filmHandle);
289
+ if (!allBoards.length) {
290
+ throw new Error('В проекте нет ни одной сцены/персонажа/локации');
291
+ }
292
+
293
+ // 2. Считаем общее число файлов для прогресса.
294
+ const totalFiles = allBoards.reduce((sum, b) => sum + b.mediaFiles.length, 0);
295
+ let uploadedTotal = 0;
296
+
297
+ // 3. Для каждого board'а — читаем manifest, загружаем файлы, копим
298
+ // структуру { kind, name, manifest, files: {relPath: cdnUrl} }.
299
+ const boardsPayload = [];
300
+ for (const b of allBoards) {
301
+ let manifest = {};
302
+ try {
303
+ const sceneFh = await b.handle.getFileHandle('scene.json');
304
+ manifest = JSON.parse(await (await sceneFh.getFile()).text());
305
+ } catch {
306
+ // Без scene.json пропускаем — это не board.
307
+ continue;
308
+ }
309
+ const filesMap = {};
310
+ for (const f of b.mediaFiles) {
311
+ TPL_PROGRESS.update(uploadedTotal, totalFiles,
312
+ `[${b.kind}/${b.name}] ${f.relPath} (${uploadedTotal + 1}/${totalFiles})`);
313
+ const blob = await f.fileHandle.getFile();
314
+ const r = await fetch('/api/upload', {
315
+ method: 'POST',
316
+ headers: {
317
+ 'Content-Type': blob.type || 'application/octet-stream',
318
+ 'X-File-Name': encodeURIComponent(f.relPath.split('/').pop()),
319
+ },
320
+ body: await blob.arrayBuffer(),
321
+ });
322
+ if (!r.ok) {
323
+ const err = await r.json().catch(() => ({}));
324
+ throw new Error(`upload ${b.name}/${f.relPath}: ${err.error || r.status}`);
325
+ }
326
+ const { url } = await r.json();
327
+ filesMap[f.relPath] = url;
328
+ uploadedTotal++;
329
+ }
330
+ boardsPayload.push({
331
+ kind: b.kind,
332
+ name: b.name,
333
+ manifest,
334
+ files: filesMap,
335
+ });
336
+ }
337
+
338
+ TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Загрузка обложки…');
339
+ const coverUrl = await uploadProjectCoverIfAny();
340
+ TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Регистрация шаблона проекта…');
341
+
342
+ // 4. POST /api/templates с kind='project' и manifest.boards = [...].
343
+ const r = await fetch('/api/templates', {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify({
347
+ name,
348
+ kind: 'project',
349
+ manifest: {
350
+ boards: boardsPayload,
351
+ projectName: state.filmHandle.name,
352
+ ...(coverUrl ? { coverUrl } : {}),
353
+ },
354
+ files: {}, // всё внутри manifest.boards[i].files
355
+ }),
356
+ });
357
+ if (!r.ok) {
358
+ const err = await r.json().catch(() => ({}));
359
+ throw new Error(err.error || `HTTP ${r.status}`);
360
+ }
361
+
362
+ TPL_PROGRESS.hide();
363
+ tplStatus(`Шаблон проекта «${name}» сохранён (${allBoards.length} board'ов, ${totalFiles} файл${totalFiles === 1 ? '' : 'ов'})`);
364
+ await refreshTemplatesList();
365
+ } catch (e) {
366
+ TPL_PROGRESS.hide();
367
+ tplStatus('Ошибка сохранения проекта: ' + e.message, true);
368
+ console.error('save project template failed', e);
369
+ }
370
+ }
371
+
372
+ // Собирает все board-папки из проекта: { kind, name, handle, mediaFiles[] }.
373
+ async function collectProjectBoards(filmHandle) {
374
+ const out = [];
375
+
376
+ // _characters/* и _locations/*.
377
+ const subdirs = [
378
+ { dir: CHAR_DIR, kind: 'character' },
379
+ { dir: LOC_DIR, kind: 'location' },
380
+ ];
381
+ for (const { dir, kind } of subdirs) {
382
+ try {
383
+ const root = await filmHandle.getDirectoryHandle(dir);
384
+ for await (const [name, h] of root.entries()) {
385
+ if (h.kind !== 'directory') continue;
386
+ if (name.startsWith('.') || name === '_deleted') continue;
387
+ out.push({ kind, name, handle: h, mediaFiles: await walkBoardFiles(h) });
388
+ }
389
+ } catch {}
390
+ }
391
+
392
+ // Эпизоды лежат в корне (любая папка кроме _* и .*).
393
+ for await (const [name, h] of filmHandle.entries()) {
394
+ if (h.kind !== 'directory') continue;
395
+ if (name.startsWith('_') || name.startsWith('.')) continue;
396
+ out.push({ kind: 'episode', name, handle: h, mediaFiles: await walkBoardFiles(h) });
397
+ }
398
+
399
+ // Фильтруем те, у которых нет scene.json (т.е. не наши board'ы).
400
+ const validBoards = [];
401
+ for (const b of out) {
402
+ try {
403
+ await b.handle.getFileHandle('scene.json');
404
+ // Исключаем scene.json и .thumbnails/ из mediaFiles — они либо
405
+ // manifest, либо генерятся клиентом.
406
+ b.mediaFiles = b.mediaFiles.filter(f =>
407
+ f.relPath !== 'scene.json' && !f.relPath.startsWith('.thumbnails/'));
408
+ validBoards.push(b);
409
+ } catch {}
410
+ }
411
+ return validBoards;
412
+ }
413
+
414
+ // =================== Open project template ===================
415
+ // Просим юзера выбрать parent-папку. Создаём в ней новую папку с именем
416
+ // проекта. Скачиваем все boards со всеми files. Открываем filmHandle.
417
+ async function openProjectTemplate(templateId, suggestedName) {
418
+ if (typeof window.showDirectoryPicker !== 'function') {
419
+ alert('Браузер не поддерживает выбор папки. Открой проект через File-System-Access.');
420
+ return;
421
+ }
422
+
423
+ // 1. Парент-папка от юзера.
424
+ let parentHandle;
425
+ try {
426
+ parentHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
427
+ } catch (e) {
428
+ if (e.name === 'AbortError') return; // юзер закрыл диалог
429
+ alert('Не удалось выбрать папку: ' + e.message);
430
+ return;
431
+ }
432
+
433
+ // 2. Имя нового проекта.
434
+ const projectName = await askName(
435
+ 'Имя нового проекта:',
436
+ suggestedName || 'Из шаблона',
437
+ suggestedName || '',
438
+ { okText: 'Создать и открыть' },
439
+ );
440
+ if (!projectName) return;
441
+
442
+ TPL_PROGRESS.show('Получение шаблона…');
443
+ tplStatus('');
444
+
445
+ try {
446
+ const r = await fetch(`/api/templates/${encodeURIComponent(templateId)}`);
447
+ if (!r.ok) {
448
+ const err = await r.json().catch(() => ({}));
449
+ throw new Error(err.error || `HTTP ${r.status}`);
450
+ }
451
+ const tpl = await r.json();
452
+ const boards = tpl.manifest?.boards || [];
453
+ if (!boards.length) throw new Error('В шаблоне нет board\'ов');
454
+
455
+ // 3. Создаём корень проекта.
456
+ const filmHandle = await parentHandle.getDirectoryHandle(projectName, { create: true });
457
+
458
+ // 4. Считаем общее число файлов для прогресса.
459
+ let totalFiles = 0;
460
+ for (const b of boards) totalFiles += Object.keys(b.files || {}).length;
461
+ let downloadedTotal = 0;
462
+
463
+ // 5. Для каждого board'а — создаём папку, качаем файлы, пишем scene.json.
464
+ for (const b of boards) {
465
+ let parent = filmHandle;
466
+ if (b.kind === 'character') {
467
+ parent = await filmHandle.getDirectoryHandle(CHAR_DIR, { create: true });
468
+ } else if (b.kind === 'location') {
469
+ parent = await filmHandle.getDirectoryHandle(LOC_DIR, { create: true });
470
+ }
471
+ const boardHandle = await parent.getDirectoryHandle(b.name, { create: true });
472
+
473
+ const fileEntries = Object.entries(b.files || {});
474
+ for (const [relPath, cdnUrl] of fileEntries) {
475
+ TPL_PROGRESS.update(downloadedTotal, totalFiles,
476
+ `[${b.kind}/${b.name}] ${relPath} (${downloadedTotal + 1}/${totalFiles})`);
477
+ const proxyUrl = '/api/proxy?url=' + encodeURIComponent(cdnUrl);
478
+ const fr = await fetch(proxyUrl);
479
+ if (!fr.ok) throw new Error(`download ${b.name}/${relPath}: HTTP ${fr.status}`);
480
+ const buf = await fr.arrayBuffer();
481
+ await writeBoardFile(boardHandle, relPath, new Uint8Array(buf));
482
+ downloadedTotal++;
483
+ }
484
+ // scene.json — manifest board'а.
485
+ await writeBoardFile(boardHandle, 'scene.json', JSON.stringify(b.manifest || {}, null, 2));
486
+ }
487
+
488
+ // 6. Открываем созданный проект.
489
+ TPL_PROGRESS.update(totalFiles, totalFiles, 'Открытие проекта…');
490
+ await openFilm(filmHandle);
491
+
492
+ TPL_PROGRESS.hide();
493
+ document.getElementById('templatesModal').classList.add('hidden');
494
+ tplStatus('');
495
+ } catch (e) {
496
+ TPL_PROGRESS.hide();
497
+ tplStatus('Ошибка открытия проекта: ' + e.message, true);
498
+ console.error('open project template failed', e);
499
+ }
500
+ }
501
+
502
+ // =================== Open template (download all files + create board) ===================
503
+ async function openTemplate(templateId, suggestedName) {
504
+ if (!state.filmHandle) { alert('Сначала открой проект'); return; }
505
+
506
+ // 1. Спросить имя нового board'а в текущем проекте.
507
+ const name = await askName(
508
+ 'Имя для скачанной сцены:',
509
+ suggestedName || 'из шаблона',
510
+ suggestedName || '',
511
+ { okText: 'Скачать и открыть' },
512
+ );
513
+ if (!name) return;
514
+
515
+ TPL_PROGRESS.show('Получение шаблона…');
516
+ tplStatus('');
517
+
518
+ try {
519
+ // 2. Получить шаблон с сервера.
520
+ const r = await fetch(`/api/templates/${encodeURIComponent(templateId)}`);
521
+ if (!r.ok) {
522
+ const err = await r.json().catch(() => ({}));
523
+ throw new Error(err.error || `HTTP ${r.status}`);
524
+ }
525
+ const tpl = await r.json();
526
+ const manifest = tpl.manifest || {};
527
+ const files = tpl.files || {};
528
+ const kind = tpl.kind || 'episode';
529
+
530
+ // 3. Создать пустую папку под нужный kind.
531
+ let parentHandle;
532
+ if (kind === 'character') {
533
+ parentHandle = await state.filmHandle.getDirectoryHandle(CHAR_DIR, { create: true });
534
+ } else if (kind === 'location') {
535
+ parentHandle = await state.filmHandle.getDirectoryHandle(LOC_DIR, { create: true });
536
+ } else {
537
+ parentHandle = state.filmHandle;
538
+ }
539
+ const boardHandle = await parentHandle.getDirectoryHandle(name, { create: true });
540
+
541
+ // 4. Скачать каждый файл из manifest.files.
542
+ const entries = Object.entries(files);
543
+ let done = 0;
544
+ for (const [relPath, cdnUrl] of entries) {
545
+ TPL_PROGRESS.update(done, entries.length, `Скачивание: ${relPath} (${done + 1}/${entries.length})`);
546
+ // Через /api/proxy чтобы обойти возможные CORS/auth-вопросы.
547
+ const proxyUrl = '/api/proxy?url=' + encodeURIComponent(cdnUrl);
548
+ const fr = await fetch(proxyUrl);
549
+ if (!fr.ok) throw new Error(`download ${relPath}: HTTP ${fr.status}`);
550
+ const buf = await fr.arrayBuffer();
551
+ await writeBoardFile(boardHandle, relPath, new Uint8Array(buf));
552
+ done++;
553
+ }
554
+
555
+ // 5. Записать scene.json (manifest).
556
+ TPL_PROGRESS.update(entries.length, entries.length, 'Создание сцены…');
557
+ await writeBoardFile(boardHandle, 'scene.json', JSON.stringify(manifest, null, 2));
558
+
559
+ // 6. Обновить sidebar и открыть board.
560
+ if (kind === 'character') await refreshCharacters();
561
+ else if (kind === 'location') await refreshLocations();
562
+ else await refreshEpisodes();
563
+ await selectBoard({ kind, name, handle: boardHandle });
564
+
565
+ TPL_PROGRESS.hide();
566
+ document.getElementById('templatesModal').classList.add('hidden');
567
+ tplStatus('');
568
+ } catch (e) {
569
+ TPL_PROGRESS.hide();
570
+ tplStatus('Ошибка открытия: ' + e.message, true);
571
+ console.error('open template failed', e);
572
+ }
573
+ }
574
+
575
+ // =================== Delete template ===================
576
+ async function deleteTemplateConfirm(id, name) {
577
+ if (!confirm(`Удалить шаблон «${name}» с сервера? Действие необратимо.`)) return;
578
+ try {
579
+ const r = await fetch(`/api/templates/${encodeURIComponent(id)}`, { method: 'DELETE' });
580
+ if (!r.ok) {
581
+ const err = await r.json().catch(() => ({}));
582
+ throw new Error(err.error || `HTTP ${r.status}`);
583
+ }
584
+ await refreshTemplatesList();
585
+ tplStatus(`Шаблон «${name}» удалён`);
586
+ } catch (e) {
587
+ tplStatus('Не удалось удалить: ' + e.message, true);
588
+ }
589
+ }
590
+
591
+ // =================== Helpers ===================
592
+ // Рекурсивно собирает все файлы из board-папки. Возвращает массив
593
+ // { relPath: 'frames/x.jpg', fileHandle: FileSystemFileHandle }.
594
+ // Игнорирует _deleted/, .DS_Store и hidden-папки.
595
+ async function walkBoardFiles(boardHandle) {
596
+ const out = [];
597
+ async function walk(dirHandle, prefix) {
598
+ for await (const [name, h] of dirHandle.entries()) {
599
+ // Скипаем системное и удалённое.
600
+ if (name.startsWith('.') || name === '_deleted') continue;
601
+ const relPath = prefix ? `${prefix}/${name}` : name;
602
+ if (h.kind === 'file') {
603
+ out.push({ relPath, fileHandle: h });
604
+ } else if (h.kind === 'directory') {
605
+ await walk(h, relPath);
606
+ }
607
+ }
608
+ }
609
+ await walk(boardHandle, '');
610
+ return out;
611
+ }
612
+
613
+ // Экспортируем глобально, чтобы board.js мог вызывать.
614
+ window.openTemplatesWindow = openTemplatesWindow;
package/server.js CHANGED
@@ -218,6 +218,33 @@ async function handleTransactions(res) {
218
218
  catch (e) { sendError(res, e, 502); }
219
219
  }
220
220
 
221
+ // =============================================================================
222
+ // Templates: тонкие proxy-роуты на Chatium-сервер.
223
+ // =============================================================================
224
+ async function handleTemplatesList(res) {
225
+ try { send(res, 200, await providers.listTemplates(getSettings())); }
226
+ catch (e) { sendError(res, e, 502); }
227
+ }
228
+
229
+ async function handleTemplateGet(res, id) {
230
+ try { send(res, 200, await providers.getTemplate(id, getSettings())); }
231
+ catch (e) { sendError(res, e, 502); }
232
+ }
233
+
234
+ async function handleTemplateCreate(req, res) {
235
+ try {
236
+ const body = await readJson(req);
237
+ send(res, 200, await providers.createTemplate(body, getSettings()));
238
+ } catch (e) { sendError(res, e, 502); }
239
+ }
240
+
241
+ async function handleTemplateDelete(res, id) {
242
+ try {
243
+ await providers.deleteTemplate(id, getSettings());
244
+ send(res, 200, { ok: true });
245
+ } catch (e) { sendError(res, e, 502); }
246
+ }
247
+
221
248
  // =============================================================================
222
249
  // Static files (renderer assets).
223
250
  // =============================================================================
@@ -256,6 +283,16 @@ const server = createServer(async (req, res) => {
256
283
  if (req.method === 'GET' && url.pathname === '/api/balance') return handleBalance(res);
257
284
  if (req.method === 'GET' && url.pathname === '/api/balance/all') return handleBalanceAll(res);
258
285
  if (req.method === 'GET' && url.pathname === '/api/transactions') return handleTransactions(res);
286
+ // Templates routes — proxy на Chatium server (см. providers.listTemplates etc).
287
+ if (req.method === 'GET' && url.pathname === '/api/templates') return handleTemplatesList(res);
288
+ if (req.method === 'POST' && url.pathname === '/api/templates') return handleTemplateCreate(req, res);
289
+ {
290
+ const m = url.pathname.match(/^\/api\/templates\/([^/]+)$/);
291
+ if (m) {
292
+ if (req.method === 'GET') return handleTemplateGet(res, decodeURIComponent(m[1]));
293
+ if (req.method === 'DELETE') return handleTemplateDelete(res, decodeURIComponent(m[1]));
294
+ }
295
+ }
259
296
  if (req.method === 'GET') return serveStatic(res, url);
260
297
  send(res, 404, 'not found');
261
298
  } catch (e) {