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.
- package/assets/templates.jpg +0 -0
- package/index.html +1 -3
- package/lib/providers.js +16 -0
- package/package.json +1 -1
- package/renderer/board.js +75 -0
- package/renderer/generate.js +5 -12
- package/renderer/settings.js +40 -1
- package/renderer/styles.css +28 -12
- package/renderer/templates.js +284 -62
- package/server.js +8 -0
- package/skill/SKILL.md +109 -0
|
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;"
|
|
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
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');
|
package/renderer/generate.js
CHANGED
|
@@ -158,18 +158,11 @@ document.querySelectorAll('#addMenu button').forEach(btn => {
|
|
|
158
158
|
state.addMenuPos = null;
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
package/renderer/settings.js
CHANGED
|
@@ -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
|
|
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
|
|
package/renderer/styles.css
CHANGED
|
@@ -230,15 +230,18 @@
|
|
|
230
230
|
margin-bottom: 14px; text-align: center;
|
|
231
231
|
flex-shrink: 0;
|
|
232
232
|
}
|
|
233
|
-
/* Recents
|
|
234
|
-
|
|
233
|
+
/* Recents — плитка (responsive grid). Карточки фиксированной ширины,
|
|
234
|
+
заполняют ряд слева направо, при нехватке места переносятся. Скроллится
|
|
235
|
+
вертикально, если карточек много. */
|
|
235
236
|
.welcome-recent-grid {
|
|
236
|
-
display:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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:
|
|
1241
|
-
max-height
|
|
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 {
|
package/renderer/templates.js
CHANGED
|
@@ -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()
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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'
|
|
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
|
-
//
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
name,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
suggestedName || ''
|
|
528
|
-
|
|
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. Когда юзер просит «напиши диалог», «придумай реплику»,
|