kingkont 0.8.6 → 0.8.8

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.
Binary file
package/index.html CHANGED
@@ -41,7 +41,7 @@
41
41
  <span>Сцены</span>
42
42
  <span style="display:flex; gap:4px;">
43
43
  <button id="newEpisode" disabled title="Создать сцену">+</button>
44
- <button id="openTemplates" title="Открыть библиотеку шаблонов" style="font-size:12px;">📚</button>
44
+ <button id="openTemplates" title="Открыть библиотеку шаблонов" style="font-size:12px;">🎨</button>
45
45
  </span>
46
46
  </h3>
47
47
  <div class="sidebar-list" id="episodeList"></div>
@@ -238,8 +238,6 @@
238
238
  <button data-act="audio">🎙 Сгенерировать голос</button>
239
239
  <button data-act="image">🖼 Сгенерировать картинку</button>
240
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
241
  </div>
244
242
 
245
243
  <!-- ===== Settings modal (двойной клик на сгенерированную ноду) ===== -->
package/lib/providers.js CHANGED
@@ -739,6 +739,21 @@ async function createTemplate(body, s) {
739
739
  return d;
740
740
  }
741
741
 
742
+ async function updateTemplate(id, body, s) {
743
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
744
+ // POST /templates~update?id=...
745
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.templates}~update?id=${encodeURIComponent(id)}`;
746
+ logCall('POST', 'Chatium', url,
747
+ `name=${body?.name} files=${Object.keys(body?.files || {}).length}`);
748
+ const r = await fetch(url, {
749
+ method: 'POST', headers, body: JSON.stringify(body),
750
+ });
751
+ const text = await r.text();
752
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
753
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
754
+ return d;
755
+ }
756
+
742
757
  async function deleteTemplate(id, s) {
743
758
  const headers = chatiumAuthHeaders(s);
744
759
  // POST /templates~delete?id=... (Chatium-роутер не уважает HTTP DELETE
@@ -791,6 +806,7 @@ module.exports = {
791
806
  listTemplates,
792
807
  getTemplate,
793
808
  createTemplate,
809
+ updateTemplate,
794
810
  deleteTemplate,
795
811
  // Constants (для server.js / тестов)
796
812
  KIE_IMAGE_MODELS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -100,6 +100,14 @@ window.addEventListener('DOMContentLoaded', async () => {
100
100
  };
101
101
  document.getElementById('brandLogo')?.addEventListener('dblclick', openSettingsFromLogo);
102
102
  document.getElementById('welcomeLogo')?.addEventListener('dblclick', openSettingsFromLogo);
103
+ // ПКМ на brand-area (sidebar header) — действия проектного уровня
104
+ // (сохранить как шаблон / обновить шаблон).
105
+ document.querySelector('.brand')?.addEventListener('contextmenu', e => {
106
+ if (!state.filmHandle) return;
107
+ e.preventDefault();
108
+ e.stopPropagation();
109
+ showProjectContextMenu(e.clientX, e.clientY);
110
+ });
103
111
 
104
112
  // Версия приложения на welcome-экране и в шапке проекта (после слова
105
113
  // "KingKont"). appInfo.version() — IPC к main → app.getVersion().
@@ -374,6 +382,35 @@ async function renderWelcomeRecents() {
374
382
  openCard.addEventListener('click', () => $('pickRoot').click());
375
383
  grid.appendChild(openCard);
376
384
 
385
+ // Сразу за «Открыть проект» — карточка «Шаблоны» (открывает библиотеку
386
+ // шаблонов с серверa). Стиль такой же как у open-card — иконка + meta.
387
+ const tplCard = document.createElement('div');
388
+ tplCard.className = 'welcome-card open-card';
389
+ const tplThumb = document.createElement('div');
390
+ tplThumb.className = 'welcome-card-thumb';
391
+ // Своя картинка-обложка для карточки «Шаблоны» (assets/templates.jpg).
392
+ // Если не загрузится — fallback на 🎨.
393
+ const tplThumbImg = document.createElement('img');
394
+ tplThumbImg.src = 'assets/templates.jpg';
395
+ tplThumbImg.alt = '';
396
+ tplThumbImg.draggable = false;
397
+ tplThumbImg.onerror = () => { tplThumb.textContent = '🎨'; tplThumbImg.remove(); };
398
+ tplThumb.appendChild(tplThumbImg);
399
+ const tplMeta = document.createElement('div');
400
+ tplMeta.className = 'welcome-card-meta';
401
+ const tplName = document.createElement('div');
402
+ tplName.className = 'welcome-card-name';
403
+ tplName.textContent = 'Шаблоны';
404
+ const tplSub = document.createElement('div');
405
+ tplSub.className = 'welcome-card-ts';
406
+ tplSub.textContent = 'библиотека на сервере';
407
+ tplMeta.append(tplName, tplSub);
408
+ tplCard.append(tplThumb, tplMeta);
409
+ tplCard.addEventListener('click', () => {
410
+ if (typeof openTemplatesWindow === 'function') openTemplatesWindow();
411
+ });
412
+ grid.appendChild(tplCard);
413
+
377
414
  // Title меняем в зависимости от наличия recents.
378
415
  if (titleEl) titleEl.textContent = list.length ? 'Открыть проект · недавние' : 'Открыть проект';
379
416
 
@@ -757,6 +794,21 @@ function showBoardContextMenu(kind, item, clientX, clientY) {
757
794
  selectBoard({ kind, ...item }).then(() => openCharacterSettings()).catch(() => {});
758
795
  });
759
796
  }
797
+ // Сохранение этого board'а как шаблон. saveCurrentBoardAsTemplate
798
+ // работает с state.currentBoard, поэтому сначала открываем нужный
799
+ // board (если он не текущий), а затем запускаем сохранение.
800
+ add('💾 Сохранить как шаблон', async () => {
801
+ try {
802
+ if (!state.currentBoard || state.currentBoard.kind !== kind || state.currentBoard.name !== item.name) {
803
+ await selectBoard({ kind, ...item });
804
+ }
805
+ if (typeof saveCurrentBoardAsTemplate === 'function') {
806
+ await saveCurrentBoardAsTemplate();
807
+ }
808
+ } catch (e) {
809
+ alert('Не удалось сохранить как шаблон: ' + (e?.message || e));
810
+ }
811
+ });
760
812
  add('🗑 Удалить', async () => {
761
813
  if (!confirm(`Удалить «${item.name}»? Папка переедет в _deleted, Cmd+Z восстановит.`)) return;
762
814
  try { await deleteBoard(kind, item.name); }
@@ -1992,6 +2044,29 @@ function showNodeContextMenu(node, clientX, clientY) {
1992
2044
  setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
1993
2045
  }
1994
2046
 
2047
+ // ПКМ на brand-area sidebar — действия с проектом (сохранить как шаблон).
2048
+ // Показывает «Создать новый шаблон» всегда, и «Обновить «X»» если проект
2049
+ // был открыт из шаблона юзера (см. saveCurrentProjectAsTemplate — он сам
2050
+ // определит mode по .kingkont-meta.json).
2051
+ function showProjectContextMenu(clientX, clientY) {
2052
+ const menu = $('nodeMenu');
2053
+ menu.innerHTML = '';
2054
+ const add = (label, fn) => {
2055
+ const b = document.createElement('button');
2056
+ b.textContent = label;
2057
+ b.addEventListener('click', () => {
2058
+ menu.classList.add('hidden');
2059
+ fn();
2060
+ });
2061
+ menu.appendChild(b);
2062
+ };
2063
+ add('💾 Сохранить как шаблон…', () => {
2064
+ if (typeof saveCurrentProjectAsTemplate === 'function') saveCurrentProjectAsTemplate();
2065
+ });
2066
+ positionFloatingMenu(menu, clientX, clientY);
2067
+ setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
2068
+ }
2069
+
1995
2070
  // ПКМ на 📚 Templates-кнопке — действия проектного уровня.
1996
2071
  function showTemplatesButtonContextMenu(clientX, clientY) {
1997
2072
  const menu = $('nodeMenu');
@@ -158,18 +158,11 @@ 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
- // save-project-template вынесен на ПКМ 📚 Templates-кнопки
172
- // (см. board.js: showTemplatesButtonContextMenu).
161
+ // save-template actions перенесены:
162
+ // «Сохранить сцену как шаблон» — ПКМ на board-item в sidebar
163
+ // (см. showBoardContextMenu в board.js)
164
+ // «Сохранить проект как шаблон» ПКМ на 📚 Templates-кнопке
165
+ // (см. showTemplatesButtonContextMenu в board.js)
173
166
  if (act === 'gen-text') {
174
167
  // открываем text-gen modal; если есть fromNode — подставляем @ref в промпт
175
168
  if (!await ensureApiKey('text')) return;
@@ -347,6 +347,15 @@ async function renderNodeBody(node, body) {
347
347
  if (nodeEl) { _mediaHydrationObserver.unobserve(nodeEl); nodeEl.__hydrate = null; }
348
348
 
349
349
  if (node.type === 'audio') {
350
+ // Промпт (текст TTS) виден всегда — даже после генерации. Юзер
351
+ // должен понимать что говорит голос без открытия Settings-modal'я.
352
+ const promptText = node.generated?.rawPrompt || node.generated?.prompt;
353
+ if (promptText) {
354
+ const pp = document.createElement('div');
355
+ pp.className = 'audio-prompt';
356
+ pp.textContent = promptText;
357
+ body.appendChild(pp);
358
+ }
350
359
  const fname = document.createElement('div');
351
360
  fname.className = 'filename';
352
361
  fname.textContent = node.file;
@@ -416,7 +425,20 @@ async function renderNodeBody(node, body) {
416
425
  // Раньше "metadata" грузил chunk для duration/dimensions с каждой ноды на selectBoard.
417
426
  media.preload = 'none';
418
427
  }
419
- media.addEventListener('mousedown', e => e.stopPropagation());
428
+ // Click на media-элементе = выбрать ноду (как клик по header'у).
429
+ // НЕ делаем preventDefault — иначе сломаются нативные video-controls
430
+ // (play/pause/seek). stopPropagation чтобы не запустился rubber-band
431
+ // селектор холста.
432
+ media.addEventListener('mousedown', e => {
433
+ e.stopPropagation();
434
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
435
+ toggleSelection(node.id);
436
+ } else if (!state.selectedNodeIds.has(node.id)) {
437
+ clearSelection();
438
+ state.selectedNodeIds.add(node.id);
439
+ }
440
+ renderSelection();
441
+ });
420
442
  // Заменяем placeholder ИЛИ ранее показанный thumbnail
421
443
  const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode)
422
444
  ? nodeEl.__thumbEl
@@ -497,6 +519,23 @@ async function renderNodeBody(node, body) {
497
519
  playRow.appendChild(playBtn);
498
520
  body.appendChild(playRow);
499
521
  }
522
+ } else if (node.type === 'image' && !node.file && (node.generated?.rawPrompt || node.generated?.prompt)) {
523
+ // Fallback: image-нода с промптом, но без файла (ещё не генерилась
524
+ // и status != 'draft'). Показываем preview-блок чтобы юзер видел
525
+ // что собирается генерироваться.
526
+ const wrap = document.createElement('div');
527
+ wrap.className = 'gen-pending';
528
+ const ic = document.createElement('div');
529
+ ic.style.cssText = 'font-size:36px; opacity:0.7;';
530
+ ic.textContent = '🖼';
531
+ const st = document.createElement('div');
532
+ st.className = 'state-text';
533
+ st.textContent = 'Не сгенерировано — открой ↻ для запуска';
534
+ const pp = document.createElement('div');
535
+ pp.className = 'prompt-preview';
536
+ pp.textContent = node.generated?.rawPrompt || node.generated?.prompt || '';
537
+ wrap.append(ic, st, pp);
538
+ body.appendChild(wrap);
500
539
  }
501
540
  }
502
541
 
@@ -230,15 +230,18 @@
230
230
  margin-bottom: 14px; text-align: center;
231
231
  flex-shrink: 0;
232
232
  }
233
- /* Recents в горизонтальную ленту со скроллом. Карточки фиксированной
234
- ширины чтобы scroll работал предсказуемо. */
233
+ /* Recents плитка (responsive grid). Карточки фиксированной ширины,
234
+ заполняют ряд слева направо, при нехватке места переносятся. Скроллится
235
+ вертикально, если карточек много. */
235
236
  .welcome-recent-grid {
236
- display: flex; flex-direction: row; gap: 16px;
237
- overflow-x: auto; overflow-y: hidden;
238
- padding: 8px 32px 24px;
239
- scroll-snap-type: x proximity;
240
- }
241
- .welcome-recent-grid::-webkit-scrollbar { height: 8px; }
237
+ display: grid;
238
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
239
+ gap: 16px;
240
+ overflow-y: auto; overflow-x: hidden;
241
+ padding: 8px 52px 24px;
242
+ align-content: start;
243
+ }
244
+ .welcome-recent-grid::-webkit-scrollbar { width: 8px; }
242
245
 
243
246
  /* === Глобальные dark-scrollbars === */
244
247
  /* Firefox */
@@ -257,8 +260,9 @@
257
260
  background: #232323; border: 1px solid #333; border-radius: 8px;
258
261
  overflow: hidden; cursor: pointer; transition: border-color 0.12s, transform 0.12s;
259
262
  display: flex; flex-direction: column;
260
- width: 240px; flex-shrink: 0;
261
- scroll-snap-align: start;
263
+ /* Grid-layout: ширина задаётся grid-column'ом (responsive),
264
+ fixed-width убран. position:relative нужен для absolute-расположенного × delete. */
265
+ position: relative;
262
266
  }
263
267
  /* Карточка «Открыть проект» — стилизована под обычную recent-карточку,
264
268
  но контент не превью, а большая иконка + надпись. */
@@ -1237,8 +1241,20 @@
1237
1241
  background: #141414;
1238
1242
  }
1239
1243
  .gen-pending .prompt-preview {
1240
- font-size: 11px; color: #aaa; line-height: 1.4; text-align: center;
1241
- max-height: 60px; overflow-y: auto; word-break: break-word;
1244
+ font-size: 12px; color: #bbb; line-height: 1.4; text-align: center;
1245
+ /* Большой max-height на нодах побольше виден весь промпт; на маленьких
1246
+ — обрезается со скроллом. Раньше было 60px (~3 строки) — мало. */
1247
+ max-height: 240px; overflow-y: auto; word-break: break-word;
1248
+ width: 100%;
1249
+ }
1250
+ /* Промпт под filename аудио-ноды (постоянно — даже после генерации).
1251
+ Для TTS юзеру важно видеть текст без открытия Settings-modal'я. */
1252
+ .node-body .audio-prompt {
1253
+ font-size: 11px; color: #aaa; line-height: 1.4;
1254
+ margin: 4px 0 6px;
1255
+ max-height: 80px; overflow-y: auto; word-break: break-word;
1256
+ background: #1a1a1a; padding: 6px 8px; border-radius: 4px;
1257
+ border: 1px solid #2a2a2a;
1242
1258
  }
1243
1259
  .gen-pending .state-text { font-size: 11px; color: #888; }
1244
1260
  .gen-error {
@@ -21,21 +21,60 @@
21
21
  // 5. Записать scene.json
22
22
  // 6. selectBoard(новый board)
23
23
 
24
+ // Прогресс upload/download шаблонов. Дублирует значение в двух местах:
25
+ // (а) штатный прогресс-бар внутри templates-модалки — виден когда юзер
26
+ // активно работает с библиотекой; (б) фиксированный toast в верху
27
+ // экрана — виден всегда, в т.ч. когда юзер открыл шаблон проекта
28
+ // с welcome-screen и templates-модалка не открыта.
24
29
  const TPL_PROGRESS = {
25
30
  el: () => document.getElementById('tplProgress'),
26
31
  label: () => document.getElementById('tplProgressLabel'),
27
32
  bar: () => document.getElementById('tplProgressBar'),
33
+ toast: () => {
34
+ let t = document.getElementById('tplToast');
35
+ if (t) return t;
36
+ // Лениво создаём toast — div поверх всего, fixed top-center.
37
+ t = document.createElement('div');
38
+ t.id = 'tplToast';
39
+ t.style.cssText =
40
+ 'position:fixed; top:16px; left:50%; transform:translateX(-50%);' +
41
+ 'background:#1a1a1a; border:1px solid #3a3a3a; border-radius:8px;' +
42
+ 'padding:10px 14px; box-shadow:0 8px 28px rgba(0,0,0,0.6);' +
43
+ 'z-index:99999; min-width:340px; max-width:600px; display:none;';
44
+ t.innerHTML =
45
+ '<div id="tplToastLabel" style="font-size:12px; color:#ddd; margin-bottom:6px;"></div>' +
46
+ '<div style="width:100%; height:6px; background:#333; border-radius:3px; overflow:hidden;">' +
47
+ '<div id="tplToastBar" style="width:0; height:100%; background:#3a5a8a; transition:width 0.2s;"></div>' +
48
+ '</div>';
49
+ document.body.appendChild(t);
50
+ return t;
51
+ },
28
52
  show(label) {
29
- TPL_PROGRESS.el().classList.remove('hidden');
30
- TPL_PROGRESS.label().textContent = label;
31
- TPL_PROGRESS.bar().style.width = '0%';
53
+ if (TPL_PROGRESS.el()) {
54
+ TPL_PROGRESS.el().classList.remove('hidden');
55
+ TPL_PROGRESS.label().textContent = label;
56
+ TPL_PROGRESS.bar().style.width = '0%';
57
+ }
58
+ const t = TPL_PROGRESS.toast();
59
+ t.style.display = 'block';
60
+ document.getElementById('tplToastLabel').textContent = label;
61
+ document.getElementById('tplToastBar').style.width = '0%';
32
62
  },
33
63
  update(done, total, label) {
34
- if (label) TPL_PROGRESS.label().textContent = label;
35
64
  const pct = total > 0 ? Math.round((done / total) * 100) : 0;
36
- TPL_PROGRESS.bar().style.width = pct + '%';
65
+ if (TPL_PROGRESS.el()) {
66
+ if (label) TPL_PROGRESS.label().textContent = label;
67
+ TPL_PROGRESS.bar().style.width = pct + '%';
68
+ }
69
+ const t = TPL_PROGRESS.toast();
70
+ if (label) document.getElementById('tplToastLabel').textContent = label;
71
+ document.getElementById('tplToastBar').style.width = pct + '%';
72
+ },
73
+ hide() {
74
+ if (TPL_PROGRESS.el()) TPL_PROGRESS.el().classList.add('hidden');
75
+ const t = document.getElementById('tplToast');
76
+ if (t) t.style.display = 'none';
37
77
  },
38
- hide() { TPL_PROGRESS.el().classList.add('hidden'); },
39
78
  };
40
79
 
41
80
  function tplStatus(msg, isError) {
@@ -77,7 +116,13 @@ async function refreshTemplatesList() {
77
116
  throw new Error(err.error || `HTTP ${r.status}`);
78
117
  }
79
118
  const data = await r.json();
80
- const items = Array.isArray(data) ? data : (data.items || data.templates || []);
119
+ let items = Array.isArray(data) ? data : (data.items || data.templates || []);
120
+ // Когда проект открыт — модалка работает в режиме «добавить сцену»;
121
+ // project-шаблоны не имеют смысла (они создают новый проект, не
122
+ // добавляют board в текущий). Скрываем их из списка.
123
+ if (state.filmHandle) {
124
+ items = items.filter(t => t.kind !== 'project');
125
+ }
81
126
  renderTemplatesList(items);
82
127
  } catch (e) {
83
128
  list.innerHTML = '';
@@ -135,18 +180,27 @@ function renderTemplatesList(items) {
135
180
  openBtn.title = 'Выбрать папку и развернуть в неё проект';
136
181
  openBtn.addEventListener('click', () => openProjectTemplate(t.id, t.name));
137
182
  } else {
138
- openBtn.disabled = !state.filmHandle;
139
- openBtn.title = state.filmHandle ? 'Скачать локально и открыть как новую сцену' : 'Сначала открой проект';
183
+ // Scene-templates: при открытом проекте — добавляются board'ом внутрь;
184
+ // без проекта (welcome-screen) создаётся новый проект с одной этой
185
+ // сценой (см. openTemplate: ветка createdNewProject).
186
+ openBtn.disabled = false;
187
+ openBtn.title = state.filmHandle
188
+ ? 'Скачать локально и открыть как новую сцену'
189
+ : 'Создать новый проект с этой сценой';
140
190
  openBtn.addEventListener('click', () => openTemplate(t.id, t.name));
141
191
  }
142
192
 
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);
193
+ card.append(info, openBtn);
194
+ // Корзина — для своих шаблонов и для админов (canModify приходит с
195
+ // сервера, см. canModifyTemplate в spaces/server/api/templates.ts).
196
+ if (t.canModify || t.mine) {
197
+ const delBtn = document.createElement('button');
198
+ delBtn.className = 'danger';
199
+ delBtn.textContent = '🗑';
200
+ delBtn.title = t.mine ? 'Удалить свой шаблон' : 'Удалить шаблон (admin)';
201
+ delBtn.addEventListener('click', () => deleteTemplateConfirm(t.id, t.name));
202
+ card.appendChild(delBtn);
203
+ }
150
204
  list.appendChild(card);
151
205
  }
152
206
  }
@@ -272,9 +326,42 @@ async function saveCurrentProjectAsTemplate() {
272
326
  alert('Сначала открой проект');
273
327
  return;
274
328
  }
275
- const defaultName = state.filmHandle.name || 'Проект';
276
- const name = await askName('Имя шаблона проекта:', defaultName, defaultName, { okText: 'Сохранить' });
277
- if (!name) return;
329
+
330
+ // Меta может содержать templateId (если проект из шаблона) и fileHashes.
331
+ const meta = (await readProjectMeta(state.filmHandle)) || {};
332
+
333
+ // Если проект из шаблона + юзер автор (или админ) → предлагаем выбор.
334
+ // canModify приходит с сервера: ownerId === userId OR ctx.account.is('Admin').
335
+ let updateExisting = null; // template object или null
336
+ if (meta.templateId) {
337
+ try {
338
+ const r = await fetch(`/api/templates/${encodeURIComponent(meta.templateId)}`);
339
+ if (r.ok) {
340
+ const tpl = await r.json();
341
+ if (tpl.canModify) updateExisting = tpl;
342
+ }
343
+ } catch {}
344
+ }
345
+
346
+ let mode = 'create'; // 'create' | 'update'
347
+ let name;
348
+ if (updateExisting) {
349
+ const choice = await askChoice(
350
+ 'Сохранить шаблон проекта',
351
+ [`Обновить «${updateExisting.name}»`, 'Создать новый шаблон'],
352
+ `Обновить «${updateExisting.name}»`,
353
+ );
354
+ if (!choice) return;
355
+ if (choice.startsWith('Обновить')) {
356
+ mode = 'update';
357
+ name = updateExisting.name;
358
+ }
359
+ }
360
+ if (mode === 'create') {
361
+ const defaultName = state.filmHandle.name || 'Проект';
362
+ name = await askName('Имя шаблона проекта:', defaultName, defaultName, { okText: 'Сохранить' });
363
+ if (!name) return;
364
+ }
278
365
 
279
366
  const modal = document.getElementById('templatesModal');
280
367
  if (modal) modal.classList.remove('hidden');
@@ -284,33 +371,43 @@ async function saveCurrentProjectAsTemplate() {
284
371
  tplStatus('');
285
372
 
286
373
  try {
287
- // 1. Собираем все board'ы (characters / locations / episodes).
374
+ // 1. Собираем все board'ы.
288
375
  const allBoards = await collectProjectBoards(state.filmHandle);
289
376
  if (!allBoards.length) {
290
377
  throw new Error('В проекте нет ни одной сцены/персонажа/локации');
291
378
  }
292
379
 
293
- // 2. Считаем общее число файлов для прогресса.
294
380
  const totalFiles = allBoards.reduce((sum, b) => sum + b.mediaFiles.length, 0);
295
381
  let uploadedTotal = 0;
382
+ let reusedTotal = 0; // dedup'нутые из meta.fileHashes
383
+ const oldHashes = meta.fileHashes || {};
384
+ const newHashes = {}; // обновлённый кэш для записи обратно в meta
296
385
 
297
- // 3. Для каждого board'а — читаем manifest, загружаем файлы, копим
298
- // структуру { kind, name, manifest, files: {relPath: cdnUrl} }.
386
+ // 2. Для каждого board'а — собираем boardsPayload с files map.
299
387
  const boardsPayload = [];
300
388
  for (const b of allBoards) {
301
389
  let manifest = {};
302
390
  try {
303
391
  const sceneFh = await b.handle.getFileHandle('scene.json');
304
392
  manifest = JSON.parse(await (await sceneFh.getFile()).text());
305
- } catch {
306
- // Без scene.json пропускаем — это не board.
307
- continue;
308
- }
393
+ } catch { continue; }
309
394
  const filesMap = {};
310
395
  for (const f of b.mediaFiles) {
396
+ const blob = await f.fileHandle.getFile();
397
+ const metaKey = `${b.kind}/${b.name}/${f.relPath}`;
398
+ const cached = oldHashes[metaKey];
399
+ // Dedup: если файл не менялся (size + mtime совпадают) — переиспользуем CDN-URL.
400
+ if (cached && cached.size === blob.size && cached.mtime === blob.lastModified) {
401
+ filesMap[f.relPath] = cached.url;
402
+ newHashes[metaKey] = cached;
403
+ reusedTotal++;
404
+ uploadedTotal++;
405
+ TPL_PROGRESS.update(uploadedTotal, totalFiles,
406
+ `[${b.kind}/${b.name}] ${f.relPath} (cached, ${uploadedTotal}/${totalFiles})`);
407
+ continue;
408
+ }
311
409
  TPL_PROGRESS.update(uploadedTotal, totalFiles,
312
410
  `[${b.kind}/${b.name}] ${f.relPath} (${uploadedTotal + 1}/${totalFiles})`);
313
- const blob = await f.fileHandle.getFile();
314
411
  const r = await fetch('/api/upload', {
315
412
  method: 'POST',
316
413
  headers: {
@@ -325,42 +422,68 @@ async function saveCurrentProjectAsTemplate() {
325
422
  }
326
423
  const { url } = await r.json();
327
424
  filesMap[f.relPath] = url;
425
+ newHashes[metaKey] = { url, size: blob.size, mtime: blob.lastModified };
328
426
  uploadedTotal++;
329
427
  }
330
- boardsPayload.push({
331
- kind: b.kind,
332
- name: b.name,
333
- manifest,
334
- files: filesMap,
335
- });
428
+ boardsPayload.push({ kind: b.kind, name: b.name, manifest, files: filesMap });
336
429
  }
337
430
 
338
431
  TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Загрузка обложки…');
339
432
  const coverUrl = await uploadProjectCoverIfAny();
340
- TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Регистрация шаблона проекта…');
341
433
 
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}`);
434
+ const body = {
435
+ name,
436
+ kind: 'project',
437
+ manifest: {
438
+ boards: boardsPayload,
439
+ projectName: state.filmHandle.name,
440
+ ...(coverUrl ? { coverUrl } : {}),
441
+ },
442
+ files: {},
443
+ };
444
+
445
+ let resultId;
446
+ if (mode === 'update') {
447
+ TPL_PROGRESS.update(uploadedTotal, totalFiles, `Обновление «${updateExisting.name}»…`);
448
+ const r = await fetch(`/api/templates/${encodeURIComponent(updateExisting.id)}`, {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify(body),
452
+ });
453
+ if (!r.ok) {
454
+ const err = await r.json().catch(() => ({}));
455
+ throw new Error(err.error || `HTTP ${r.status}`);
456
+ }
457
+ resultId = updateExisting.id;
458
+ } else {
459
+ TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Регистрация шаблона проекта…');
460
+ const r = await fetch('/api/templates', {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json' },
463
+ body: JSON.stringify(body),
464
+ });
465
+ if (!r.ok) {
466
+ const err = await r.json().catch(() => ({}));
467
+ throw new Error(err.error || `HTTP ${r.status}`);
468
+ }
469
+ const created = await r.json();
470
+ resultId = created.id;
360
471
  }
361
472
 
473
+ // Обновляем meta-файл — сохраняем новый templateId (или подтверждаем
474
+ // существующий) + актуальный fileHashes для следующего save.
475
+ await writeProjectMeta(state.filmHandle, {
476
+ version: 1,
477
+ templateId: resultId,
478
+ templateName: name,
479
+ savedAt: Date.now(),
480
+ fileHashes: newHashes,
481
+ });
482
+
362
483
  TPL_PROGRESS.hide();
363
- tplStatus(`Шаблон проекта «${name}» сохранён (${allBoards.length} board'ов, ${totalFiles} файл${totalFiles === 1 ? '' : 'ов'})`);
484
+ const reusedNote = reusedTotal ? `, ${reusedTotal} переиспользовано из кэша` : '';
485
+ const action = mode === 'update' ? 'обновлён' : 'сохранён';
486
+ tplStatus(`Шаблон проекта «${name}» ${action} (${allBoards.length} board'ов, ${totalFiles} файл${totalFiles === 1 ? '' : 'ов'}${reusedNote})`);
364
487
  await refreshTemplatesList();
365
488
  } catch (e) {
366
489
  TPL_PROGRESS.hide();
@@ -411,20 +534,58 @@ async function collectProjectBoards(filmHandle) {
411
534
  return validBoards;
412
535
  }
413
536
 
537
+ // Guard от повторного входа: showDirectoryPicker валится с
538
+ // «File picker already active» если юзер быстро кликнул карточку
539
+ // дважды или если предыдущий picker ещё не закрылся.
540
+ let _openProjectTplInFlight = false;
541
+
542
+ // =================== Meta-файл проекта ===================
543
+ // `<project>/.kingkont-meta.json` хранит:
544
+ // { templateId, templateName, fileHashes: { relPath: { url, size, mtime } } }
545
+ // Используется для:
546
+ // • проверки «можно ли обновить шаблон» (templateId известен)
547
+ // • dedup-а при повторной заливке (если файл не менялся — переиспользуем
548
+ // CDN-URL вместо загрузки)
549
+ const META_FILE = '.kingkont-meta.json';
550
+
551
+ async function readProjectMeta(filmHandle) {
552
+ if (!filmHandle) return null;
553
+ try {
554
+ const fh = await filmHandle.getFileHandle(META_FILE);
555
+ const txt = await (await fh.getFile()).text();
556
+ return JSON.parse(txt);
557
+ } catch { return null; }
558
+ }
559
+
560
+ async function writeProjectMeta(filmHandle, meta) {
561
+ if (!filmHandle) return;
562
+ try {
563
+ const fh = await filmHandle.getFileHandle(META_FILE, { create: true });
564
+ const w = await fh.createWritable();
565
+ await w.write(JSON.stringify(meta, null, 2));
566
+ await w.close();
567
+ } catch (e) {
568
+ console.warn('writeProjectMeta failed:', e?.message || e);
569
+ }
570
+ }
571
+
414
572
  // =================== Open project template ===================
415
573
  // Просим юзера выбрать parent-папку. Создаём в ней новую папку с именем
416
574
  // проекта. Скачиваем все boards со всеми files. Открываем filmHandle.
417
575
  async function openProjectTemplate(templateId, suggestedName) {
576
+ if (_openProjectTplInFlight) return;
418
577
  if (typeof window.showDirectoryPicker !== 'function') {
419
578
  alert('Браузер не поддерживает выбор папки. Открой проект через File-System-Access.');
420
579
  return;
421
580
  }
422
581
 
582
+ _openProjectTplInFlight = true;
423
583
  // 1. Парент-папка от юзера.
424
584
  let parentHandle;
425
585
  try {
426
586
  parentHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
427
587
  } catch (e) {
588
+ _openProjectTplInFlight = false;
428
589
  if (e.name === 'AbortError') return; // юзер закрыл диалог
429
590
  alert('Не удалось выбрать папку: ' + e.message);
430
591
  return;
@@ -437,7 +598,7 @@ async function openProjectTemplate(templateId, suggestedName) {
437
598
  suggestedName || '',
438
599
  { okText: 'Создать и открыть' },
439
600
  );
440
- if (!projectName) return;
601
+ if (!projectName) { _openProjectTplInFlight = false; return; }
441
602
 
442
603
  TPL_PROGRESS.show('Получение шаблона…');
443
604
  tplStatus('');
@@ -461,6 +622,9 @@ async function openProjectTemplate(templateId, suggestedName) {
461
622
  let downloadedTotal = 0;
462
623
 
463
624
  // 5. Для каждого board'а — создаём папку, качаем файлы, пишем scene.json.
625
+ // По ходу собираем fileHashes для meta — `<kind>/<board>/<relPath>` → cdnUrl.
626
+ // (size/mtime читаем из созданного файла после write — нужно для dedup.)
627
+ const fileHashes = {};
464
628
  for (const b of boards) {
465
629
  let parent = filmHandle;
466
630
  if (b.kind === 'character') {
@@ -479,12 +643,31 @@ async function openProjectTemplate(templateId, suggestedName) {
479
643
  if (!fr.ok) throw new Error(`download ${b.name}/${relPath}: HTTP ${fr.status}`);
480
644
  const buf = await fr.arrayBuffer();
481
645
  await writeBoardFile(boardHandle, relPath, new Uint8Array(buf));
646
+ // Запомним size+mtime для dedup'а при следующем save.
647
+ try {
648
+ const parts = relPath.split('/');
649
+ let dh = boardHandle;
650
+ for (let i = 0; i < parts.length - 1; i++) dh = await dh.getDirectoryHandle(parts[i]);
651
+ const fh = await dh.getFileHandle(parts[parts.length - 1]);
652
+ const f = await fh.getFile();
653
+ const key = `${b.kind}/${b.name}/${relPath}`;
654
+ fileHashes[key] = { url: cdnUrl, size: f.size, mtime: f.lastModified };
655
+ } catch {}
482
656
  downloadedTotal++;
483
657
  }
484
658
  // scene.json — manifest board'а.
485
659
  await writeBoardFile(boardHandle, 'scene.json', JSON.stringify(b.manifest || {}, null, 2));
486
660
  }
487
661
 
662
+ // Meta: запоминаем шаблон-источник + размеры/мтайм всех файлов для dedup'а.
663
+ await writeProjectMeta(filmHandle, {
664
+ version: 1,
665
+ templateId: tpl.id,
666
+ templateName: tpl.name || '',
667
+ downloadedAt: Date.now(),
668
+ fileHashes,
669
+ });
670
+
488
671
  // 6. Открываем созданный проект.
489
672
  TPL_PROGRESS.update(totalFiles, totalFiles, 'Открытие проекта…');
490
673
  // Чистим stale lastBoard:<name> — иначе если у юзера раньше был
@@ -513,20 +696,59 @@ async function openProjectTemplate(templateId, suggestedName) {
513
696
  TPL_PROGRESS.hide();
514
697
  tplStatus('Ошибка открытия проекта: ' + e.message, true);
515
698
  console.error('open project template failed', e);
699
+ } finally {
700
+ _openProjectTplInFlight = false;
516
701
  }
517
702
  }
518
703
 
519
704
  // =================== Open template (download all files + create board) ===================
705
+ // Если есть открытый проект — board создаётся внутри него.
706
+ // Если проекта нет (welcome-screen) — просим выбрать parent-папку, создаём
707
+ // новый проект с одной сценой/персонажем/локацией из шаблона, открываем.
520
708
  async function openTemplate(templateId, suggestedName) {
521
- if (!state.filmHandle) { alert('Сначала открой проект'); return; }
709
+ // Welcome-flow: создаём новый проект под одну сцену.
710
+ let createdNewProject = false;
711
+ if (!state.filmHandle) {
712
+ if (typeof window.showDirectoryPicker !== 'function') {
713
+ alert('Браузер не поддерживает выбор папки.'); return;
714
+ }
715
+ let parentHandle;
716
+ try {
717
+ parentHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
718
+ } catch (e) {
719
+ if (e.name === 'AbortError') return;
720
+ alert('Не удалось выбрать папку: ' + e.message);
721
+ return;
722
+ }
723
+ const projectName = await askName(
724
+ 'Имя нового проекта:',
725
+ suggestedName || 'Из шаблона',
726
+ suggestedName || '',
727
+ { okText: 'Создать' },
728
+ );
729
+ if (!projectName) return;
730
+ try {
731
+ const filmHandle = await parentHandle.getDirectoryHandle(projectName, { create: true });
732
+ try { localStorage.removeItem(`lastBoard:${projectName}`); } catch {}
733
+ await openFilm(filmHandle);
734
+ createdNewProject = true;
735
+ } catch (e) {
736
+ alert('Не удалось создать папку проекта: ' + e.message);
737
+ return;
738
+ }
739
+ }
522
740
 
523
741
  // 1. Спросить имя нового board'а в текущем проекте.
524
- const name = await askName(
525
- 'Имя для скачанной сцены:',
526
- suggestedName || 'из шаблона',
527
- suggestedName || '',
528
- { okText: 'Скачать и открыть' },
529
- );
742
+ // Если только что создали проект — имя сцены берём из шаблона без вопроса
743
+ // (юзер уже задал имя проекта, лишний диалог раздражает).
744
+ const name = createdNewProject
745
+ ? (suggestedName || 'Сцена 1')
746
+ : await askName(
747
+ 'Имя для скачанной сцены:',
748
+ suggestedName || 'из шаблона',
749
+ suggestedName || '',
750
+ { okText: 'Скачать и открыть' },
751
+ );
530
752
  if (!name) return;
531
753
 
532
754
  TPL_PROGRESS.show('Получение шаблона…');
package/server.js CHANGED
@@ -245,6 +245,13 @@ async function handleTemplateDelete(res, id) {
245
245
  } catch (e) { sendError(res, e, 502); }
246
246
  }
247
247
 
248
+ async function handleTemplateUpdate(req, res, id) {
249
+ try {
250
+ const body = await readJson(req);
251
+ send(res, 200, await providers.updateTemplate(id, body, getSettings()));
252
+ } catch (e) { sendError(res, e, 502); }
253
+ }
254
+
248
255
  // =============================================================================
249
256
  // Static files (renderer assets).
250
257
  // =============================================================================
@@ -290,6 +297,7 @@ const server = createServer(async (req, res) => {
290
297
  const m = url.pathname.match(/^\/api\/templates\/([^/]+)$/);
291
298
  if (m) {
292
299
  if (req.method === 'GET') return handleTemplateGet(res, decodeURIComponent(m[1]));
300
+ if (req.method === 'POST') return handleTemplateUpdate(req, res, decodeURIComponent(m[1]));
293
301
  if (req.method === 'DELETE') return handleTemplateDelete(res, decodeURIComponent(m[1]));
294
302
  }
295
303
  }
package/skill/SKILL.md CHANGED
@@ -132,6 +132,115 @@ Backward-compat: если в scene.json остался старый формат
132
132
  `settings.defaultPrompts: { image: "..." }` — CLI и UI конвертируют
133
133
  его в массив на лету; следующее сохранение перепишет в новый формат.
134
134
 
135
+ #### Когда заполнять defaultPrompts по умолчанию
136
+
137
+ Если юзер описывает сцену **как кадр фильма/сериала/последовательности** —
138
+ сразу при создании сцены пропиши в `defaultPrompts` визуальный стиль кадров.
139
+ Это сэкономит юзеру тонну ручного ввода и сохранит консистентность всех
140
+ генераций в сцене.
141
+
142
+ Что добавлять (зависит от описания):
143
+
144
+ - **Технические характеристики камеры**: «35mm film», «70mm IMAX», «shot
145
+ on Arri Alexa», «handheld camera», «steadicam», «drone shot», «macro
146
+ lens», «anamorphic lens (cinematic widescreen with horizontal lens
147
+ flares)».
148
+ - **Освещение**: «natural daylight», «golden hour», «overcast soft
149
+ lighting», «harsh midday sun», «cinematic three-point lighting»,
150
+ «practical sources only», «moody low-key lighting», «high-key bright»,
151
+ «neon accents».
152
+ - **Композиция и план**: «wide establishing shot», «medium close-up»,
153
+ «extreme close-up», «over-the-shoulder», «Dutch angle», «symmetrical
154
+ Wes-Anderson framing», «rule of thirds».
155
+ - **Цветовая палитра / grading**: «teal and orange», «desaturated muted
156
+ tones», «vintage Kodachrome», «cold blue tint», «warm sepia», «black
157
+ and white high contrast».
158
+ - **Жанровый/режиссёрский референс**: «in the style of Christopher
159
+ Nolan», «Wes Anderson aesthetic», «David Fincher dark moody», «Wong
160
+ Kar-wai dreamy slow-motion», «Roger Deakins cinematography»,
161
+ «Studio Ghibli animation style», «1970s grindhouse film grain».
162
+ - **Период/эпоха**: «1990s film aesthetic», «retro VHS look»,
163
+ «contemporary photoreal», «futuristic cyberpunk neon».
164
+ - **Атмосфера**: «foggy atmosphere», «dust particles in the air»,
165
+ «volumetric god rays», «slight motion blur», «shallow depth of field
166
+ with bokeh».
167
+
168
+ Пример: юзер сказал «давай сцену про детектива в нуар-фильме 50-х в
169
+ дождливом Нью-Йорке».
170
+
171
+ Создай сцену, и сразу после `add-board` добавь в `scene.json →
172
+ settings.defaultPrompts`:
173
+
174
+ ```json
175
+ [
176
+ { "id": "...", "text": "1950s film noir aesthetic, black and white high contrast, dramatic shadows", "kinds": ["image","video"], "enabled": true },
177
+ { "id": "...", "text": "rainy New York street, wet asphalt reflecting neon signs, foggy atmosphere", "kinds": ["image","video"], "enabled": true },
178
+ { "id": "...", "text": "anamorphic lens, shallow depth of field, cinematic composition", "kinds": ["image","video"], "enabled": true }
179
+ ]
180
+ ```
181
+
182
+ Раздели на 2-4 prompt'а (а не один длинный) — юзер потом сможет временно
183
+ выключить какой-то один в gen-modal'е (например прохладную палитру для
184
+ конкретной картинки, оставив остальное).
185
+
186
+ После создания сцены **сообщи юзеру** что defaultPrompts настроены, и
187
+ покажи текстом что именно — чтобы он мог поправить.
188
+
189
+ #### Раскадровка: разноси повторяющееся в @-ссылки или defaultPrompts
190
+
191
+ Когда юзер просит **раскадровку** (storyboard) — серию кадров одной
192
+ сцены/эпизода — **не дублируй общие части в каждый промпт**. Вместо
193
+ повторения извлекай их одним из двух способов:
194
+
195
+ **1. Persistent описания → отдельные ноды (text/image) + @-ссылки.**
196
+
197
+ Если в каждом кадре повторяется один и тот же **персонаж, локация,
198
+ объект, реквизит** — заведи отдельную text/image-ноду и в промптах
199
+ кадров ссылайся на неё через `@имя`. CLI и UI резолвят `[@имя]` в
200
+ полный текст ноды (или прикладывают image как референс) на этапе
201
+ генерации.
202
+
203
+ Пример. Юзер просит раскадровку «детектив идёт по улице, видит труп,
204
+ звонит партнёру». Не пиши в каждый промпт «молодой детектив в потрёпанном
205
+ сером плаще с блокнотом, тёмные волосы, усталое лицо». Вместо:
206
+
207
+ ```bash
208
+ # Сначала — text-нода с описанием персонажа.
209
+ kingkont add-node <project> <board> --kind=text --name="Анна-детектив" \
210
+ --text="Молодая женщина-следователь лет 30, потрёпанный серый плащ, \
211
+ тёмные волосы собраны в хвост, усталое лицо, в руках блокнот."
212
+
213
+ # Потом каждый кадр — короткий промпт с @-ссылкой:
214
+ kingkont gen <project> <board> --kind=image --name="кадр-1" \
215
+ --prompt="[@Анна-детектив] идёт вдоль кирпичной стены, ночь, дождь" \
216
+ --refs="@Анна-детектив"
217
+
218
+ kingkont gen <project> <board> --kind=image --name="кадр-2" \
219
+ --prompt="[@Анна-детектив] склоняется над телом, освещение от уличного фонаря" \
220
+ --refs="@Анна-детектив"
221
+ ```
222
+
223
+ Это даёт **визуальную консистентность** (модель видит то же описание),
224
+ плюс если юзер захочет уточнить персонажа — поправит ОДНУ ноду, не все
225
+ кадры.
226
+
227
+ **2. Universal стиль/съёмка → defaultPrompts сцены.**
228
+
229
+ Если повторяется НЕ конкретный объект, а **как мы снимаем эту сцену**
230
+ (освещение, камера, цветокор, жанр) — кладёшь в `settings.defaultPrompts`
231
+ (см. раздел выше про defaultPrompts). Каждый кадр потом не нуждается в
232
+ этих словах — они автоматически префиксуются ко всем генерациям.
233
+
234
+ Чек-лист при раскадровке:
235
+
236
+ - [ ] Персонажи / локации / реквизит описаны как text/image-ноды → ссылки `@`
237
+ - [ ] Стиль съёмки (камера / свет / палитра / жанр) → `settings.defaultPrompts`
238
+ - [ ] Промпт КАДРА содержит ТОЛЬКО что отличает его от других — действие,
239
+ ракурс, момент в кадре
240
+
241
+ Если соблюсти оба правила — типичный кадр умещается в одну фразу
242
+ («[@Анна-детектив] открывает дверь»), и вся сцена выглядит цельно.
243
+
135
244
  ## ⚠️ Text-ноды — генерируй САМ, не через `kingkont gen --kind=text`
136
245
 
137
246
  Ты — Claude. Когда юзер просит «напиши диалог», «придумай реплику»,