kingkont 0.7.63 → 0.7.65

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/index.html CHANGED
@@ -440,16 +440,8 @@
440
440
  <label id="locPickRow" style="display:none;">Локация
441
441
  <select id="locPickSelect"><option value="">— не выбрана —</option></select>
442
442
  </label>
443
- <!-- Дефолтный промпт сцены (read-only превью + чекбокс «применить») -->
444
- <div id="genDefaultPromptRow" style="display:none; margin-bottom:8px;">
445
- <label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer; padding:8px 10px; background:#1a1a1a; border:1px solid #333; border-radius:6px;">
446
- <input type="checkbox" id="genDefaultPromptToggle" checked style="margin-top:2px; flex-shrink:0; accent-color:#7c3aed;">
447
- <div style="flex:1; min-width:0;">
448
- <div style="font-size:11px; color:#888; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:3px;">Дефолт сцены — добавится к промпту при генерации</div>
449
- <div id="genDefaultPromptText" style="font-size:12px; color:#aaccdd; font-family:ui-monospace,monospace; white-space:pre-wrap; word-break:break-word;"></div>
450
- </div>
451
- </label>
452
- </div>
443
+ <!-- Дефолтные промпты сцены (стек чекбоксов; рендерится динамически) -->
444
+ <div id="genDefaultPromptsRow" style="display:none; margin-bottom:8px; flex-direction:column; gap:6px;"></div>
453
445
  <label><span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block;">Описание · <span style="color:#aae06a; font-family:ui-monospace,monospace; text-transform:none;">@имя</span> для ссылки на ноду</span>
454
446
  <textarea id="genPrompt" placeholder="Опиши, что должно быть. Печатай @ чтобы вставить ссылку на ноду..."></textarea>
455
447
  <div id="mentionPopup" class="mention-popup hidden"></div>
@@ -465,27 +457,19 @@
465
457
  </div>
466
458
 
467
459
  <!-- ===== Полноэкранный просмотр (image/video) ===== -->
468
- <!-- ===== Дефолтные промпты сцены (per-kind) ===== -->
460
+ <!-- ===== Дефолтные промпты сцены (multi, с тегами kind) ===== -->
469
461
  <div class="modal hidden" id="defaultPromptsModal">
470
- <div class="modal-card" style="min-width:520px;">
462
+ <div class="modal-card" style="min-width:600px; max-width:760px;">
471
463
  <h3 id="defaultPromptsTitle">Дефолтные промпты сцены</h3>
472
464
  <div class="hint" style="margin-bottom:14px;">
473
- Текст добавляется к каждому новому промпту в этой сцене (НЕ редактируется
474
- в поле промпта — применяется в момент генерации). В gen-модалке можно
475
- отключить для конкретной генерации.
465
+ Промпты добавляются к каждой генерации соответствующего типа в этой
466
+ сцене (НЕ редактируются в поле промпта — применяются в момент генерации).
467
+ В gen-модалке каждый можно отдельно включать/отключать.
468
+ </div>
469
+ <div id="dpList" style="display:flex; flex-direction:column; gap:12px;"></div>
470
+ <div style="margin-top:10px;">
471
+ <button id="dpAdd" type="button">+ Добавить промпт</button>
476
472
  </div>
477
- <label>Картинка
478
- <textarea id="dpImage" rows="2" placeholder="например: cinematic lighting, soft shadows" style="width:100%;font-family:inherit;"></textarea>
479
- </label>
480
- <label>Видео
481
- <textarea id="dpVideo" rows="2" placeholder="например: smooth camera movement, 24fps cinematic" style="width:100%;font-family:inherit;"></textarea>
482
- </label>
483
- <label>Аудио (TTS / SFX / музыка)
484
- <textarea id="dpAudio" rows="2" placeholder="например: тёплый, мягкий тон" style="width:100%;font-family:inherit;"></textarea>
485
- </label>
486
- <label>Текст
487
- <textarea id="dpText" rows="2" placeholder="например: пиши кратко, в стиле сценария" style="width:100%;font-family:inherit;"></textarea>
488
- </label>
489
473
  <div class="modal-actions">
490
474
  <button id="dpCancel">Отмена</button>
491
475
  <button id="dpSave" class="primary">Сохранить</button>
package/lib/cli.js CHANGED
@@ -202,17 +202,28 @@ async function cmdGen({ positional, flags }) {
202
202
  await fsLib.saveScene(root, ref, scene);
203
203
  console.error(`[node] создана ${node.id} (status=generating)`);
204
204
 
205
- // Применяем scene.settings.defaultPrompts[kind] (если задан и не отключён
206
- // флагом --no-default-prompt). Префиксует «<default>, <user prompt>»
207
- // та же логика что в renderer/generate.js applyDefaultPrompt().
205
+ // Применяем дефолтные промпты сцены — массив scene.settings.defaultPrompts
206
+ // [{id, text, kinds, enabled}]. Берём только те, у которых enabled!==false
207
+ // и в kinds есть текущий kind. Backward-compat: если settings.defaultPrompts
208
+ // — старый object-формат {image: "..."}, конвертируем на лету.
208
209
  let effectivePrompt = prompt;
209
- const dp = scene.settings?.defaultPrompts || {};
210
- const defForKind = (dp[kind] || '').trim();
211
- const useDefault = defForKind && !flags['no-default-prompt'] && !flags.noDefaultPrompt;
212
- if (useDefault) {
210
+ const rawDp = scene.settings?.defaultPrompts;
211
+ const dpArr = Array.isArray(rawDp)
212
+ ? rawDp
213
+ : (rawDp && typeof rawDp === 'object'
214
+ ? Object.entries(rawDp).filter(([_, v]) => v && String(v).trim()).map(([k, v]) => ({
215
+ text: String(v).trim(), kinds: [k], enabled: true,
216
+ }))
217
+ : []);
218
+ const noDefault = flags['no-default-prompt'] || flags.noDefaultPrompt;
219
+ const applicable = dpArr.filter(p =>
220
+ p && p.enabled !== false && (p.kinds || []).includes(kind) && p.text
221
+ );
222
+ if (applicable.length && !noDefault) {
223
+ const prefix = applicable.map(p => String(p.text).trim()).join(', ');
213
224
  const u = (prompt || '').trim();
214
- effectivePrompt = u ? (defForKind + ', ' + u) : defForKind;
215
- console.error(`[gen] applied scene default prompt for ${kind}: "${defForKind.slice(0, 80)}${defForKind.length > 80 ? '…' : ''}"`);
225
+ effectivePrompt = u ? (prefix + ', ' + u) : prefix;
226
+ console.error(`[gen] applied ${applicable.length} default prompt(s) for ${kind}: "${prefix.slice(0, 100)}${prefix.length > 100 ? '…' : ''}"`);
216
227
  }
217
228
 
218
229
  // 4) Запускаем генерацию через провайдер.
package/lib/projectFs.js CHANGED
@@ -111,6 +111,24 @@ async function loadScene(root, ref) {
111
111
  throw e;
112
112
  }
113
113
  const data = JSON.parse(raw);
114
+ // Backward-compat: settings.defaultPrompts может быть старым object-форматом
115
+ // ({image: "...", video: "..."}) или новым array ([{id, text, kinds, enabled}]).
116
+ // Нормализуем к array — CLI работает только с array.
117
+ let settings = data.settings || null;
118
+ if (settings && settings.defaultPrompts && !Array.isArray(settings.defaultPrompts)) {
119
+ const dp = settings.defaultPrompts;
120
+ const arr = [];
121
+ for (const [kindKey, text] of Object.entries(dp || {})) {
122
+ if (typeof text !== 'string' || !text.trim()) continue;
123
+ arr.push({
124
+ id: crypto.randomUUID(),
125
+ text: text.trim(),
126
+ kinds: [kindKey],
127
+ enabled: true,
128
+ });
129
+ }
130
+ settings = { ...settings, defaultPrompts: arr };
131
+ }
114
132
  const scene = {
115
133
  nodes: Array.isArray(data.nodes) ? data.nodes : [],
116
134
  connections: Array.isArray(data.connections) ? data.connections : [],
@@ -119,7 +137,7 @@ async function loadScene(root, ref) {
119
137
  location: data.location || null,
120
138
  timeline: data.timeline || null,
121
139
  history: data.history || null,
122
- settings: data.settings || null,
140
+ settings,
123
141
  };
124
142
  // Подгрузить .md для text-нод (как делает renderer в loadBoardMetadata).
125
143
  for (const n of scene.nodes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.7.63",
3
+ "version": "0.7.65",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -603,6 +603,9 @@ async function closeProject() {
603
603
  if (state.currentBoard?.urls) {
604
604
  for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
605
605
  }
606
+ // Освобождаем blob URL'ы для миниатюр @-popup'а — они грузились лазиво
607
+ // при открытии mention-popup, кэш накапливался.
608
+ if (typeof revokeMentionThumbCache === 'function') revokeMentionThumbCache();
606
609
  state.filmHandle = null;
607
610
  state.currentBoard = null;
608
611
  window.appProject?.notifyState(false);
@@ -724,11 +727,77 @@ async function promptBoardAspectRatio(kind, item) {
724
727
  console.log(`[board] ${kind}/${item.name} aspectRatio → ${chosen}`);
725
728
  }
726
729
 
727
- // Открыть модалку «Дефолтные промпты сцены» — 4 textarea (image/video/audio/
728
- // text). Сохраняется в scene.json → settings.defaultPrompts.
729
- // При генерации каждой ноды соответствующий промпт добавляется к user-input
730
- // (см. handler в renderer/generate.js); юзер может отключить чекбоксом в
731
- // gen-modal'e для конкретной генерации.
730
+ // Open «Дефолтные промпты сцены» modal multi-prompt list, each with
731
+ // kind-tags (image/video/audio/text) и default-enabled-чекбоксом.
732
+ // Сохраняется в scene.json settings.defaultPrompts как массив.
733
+ const DP_KINDS = [
734
+ { id: 'image', label: 'Картинка' },
735
+ { id: 'video', label: 'Видео' },
736
+ { id: 'audio', label: 'Аудио' },
737
+ { id: 'text', label: 'Текст' },
738
+ ];
739
+
740
+ function _dpRenderItem(prompt) {
741
+ const row = document.createElement('div');
742
+ row.className = 'dp-item';
743
+ row.dataset.id = prompt.id;
744
+ // Textarea для самого текста промпта.
745
+ const ta = document.createElement('textarea');
746
+ ta.value = prompt.text || '';
747
+ ta.placeholder = 'например: cinematic lighting, soft shadows';
748
+ ta.rows = 2;
749
+ row.appendChild(ta);
750
+ // Контролы: kind-чипы + дефолт-чекбокс + удалить.
751
+ const controls = document.createElement('div');
752
+ controls.className = 'dp-item-controls';
753
+ const kindsLabel = document.createElement('span');
754
+ kindsLabel.textContent = 'для:';
755
+ controls.appendChild(kindsLabel);
756
+ const kindsBox = document.createElement('div');
757
+ kindsBox.className = 'dp-item-kinds';
758
+ for (const k of DP_KINDS) {
759
+ const chip = document.createElement('span');
760
+ chip.className = 'dp-kind-chip';
761
+ chip.dataset.kind = k.id;
762
+ chip.textContent = k.label;
763
+ if ((prompt.kinds || []).includes(k.id)) chip.classList.add('active');
764
+ chip.addEventListener('click', () => chip.classList.toggle('active'));
765
+ kindsBox.appendChild(chip);
766
+ }
767
+ controls.appendChild(kindsBox);
768
+ const defLabel = document.createElement('label');
769
+ defLabel.className = 'dp-item-default';
770
+ const defCb = document.createElement('input');
771
+ defCb.type = 'checkbox';
772
+ defCb.checked = prompt.enabled !== false;
773
+ defLabel.appendChild(defCb);
774
+ defLabel.append(document.createTextNode('включён по умолчанию'));
775
+ controls.appendChild(defLabel);
776
+ const delBtn = document.createElement('button');
777
+ delBtn.type = 'button';
778
+ delBtn.className = 'dp-item-delete';
779
+ delBtn.title = 'Удалить';
780
+ delBtn.textContent = '×';
781
+ delBtn.addEventListener('click', () => row.remove());
782
+ controls.appendChild(delBtn);
783
+ row.appendChild(controls);
784
+ return row;
785
+ }
786
+
787
+ function _dpReadList() {
788
+ const list = document.getElementById('dpList');
789
+ const out = [];
790
+ for (const row of list.querySelectorAll('.dp-item')) {
791
+ const text = row.querySelector('textarea').value.trim();
792
+ if (!text) continue; // пустые отбрасываем
793
+ const kinds = [...row.querySelectorAll('.dp-kind-chip.active')].map(c => c.dataset.kind);
794
+ if (!kinds.length) continue; // без kind тоже не пишем — не виден нигде
795
+ const enabled = row.querySelector('.dp-item-default input').checked;
796
+ out.push({ id: row.dataset.id, text, kinds, enabled });
797
+ }
798
+ return out;
799
+ }
800
+
732
801
  async function openDefaultPromptsDialog(kind, item) {
733
802
  const isActive = state.currentBoard?.kind === kind && state.currentBoard.name === item.name;
734
803
  let metaSettings;
@@ -738,56 +807,56 @@ async function openDefaultPromptsDialog(kind, item) {
738
807
  const meta = await loadBoardMetadata(item.handle);
739
808
  metaSettings = meta.settings || {};
740
809
  }
741
- const dp = metaSettings.defaultPrompts || {};
742
- // Заголовок и заполнение полей.
810
+ const prompts = (metaSettings.defaultPrompts || []);
743
811
  const titleEl = document.getElementById('defaultPromptsTitle');
744
812
  if (titleEl) titleEl.textContent = `Дефолтные промпты для «${item.name}»`;
745
- document.getElementById('dpImage').value = dp.image || '';
746
- document.getElementById('dpVideo').value = dp.video || '';
747
- document.getElementById('dpAudio').value = dp.audio || '';
748
- document.getElementById('dpText').value = dp.text || '';
813
+ // Рендерим существующие.
814
+ const list = document.getElementById('dpList');
815
+ list.innerHTML = '';
816
+ for (const p of prompts) list.appendChild(_dpRenderItem(p));
817
+ // Если ничего нет — сразу добавляем пустую заготовку, чтобы юзер не
818
+ // ломал голову куда печатать.
819
+ if (!prompts.length) {
820
+ list.appendChild(_dpRenderItem({ id: crypto.randomUUID(), text: '', kinds: ['image'], enabled: true }));
821
+ }
749
822
  document.getElementById('defaultPromptsModal').classList.remove('hidden');
750
- setTimeout(() => document.getElementById('dpImage').focus(), 30);
823
+ setTimeout(() => list.querySelector('textarea')?.focus(), 30);
751
824
 
752
- // Save handler переустанавливаем каждый раз с замыканием на текущий item.
825
+ // Save / Cancel / Add навешиваем фрешевые handler'ы removeEventListener
826
+ // на close, чтобы при следующем openDefaultPromptsDialog не накапливались).
753
827
  const saveBtn = document.getElementById('dpSave');
754
828
  const cancelBtn = document.getElementById('dpCancel');
829
+ const addBtn = document.getElementById('dpAdd');
755
830
  const close = () => document.getElementById('defaultPromptsModal').classList.add('hidden');
831
+ const cleanup = () => {
832
+ saveBtn.removeEventListener('click', onSave);
833
+ cancelBtn.removeEventListener('click', onCancel);
834
+ addBtn.removeEventListener('click', onAdd);
835
+ };
836
+ const onAdd = () => {
837
+ list.appendChild(_dpRenderItem({ id: crypto.randomUUID(), text: '', kinds: ['image'], enabled: true }));
838
+ list.lastElementChild.querySelector('textarea').focus();
839
+ };
756
840
  const onSave = async () => {
757
- const next = {
758
- image: document.getElementById('dpImage').value.trim(),
759
- video: document.getElementById('dpVideo').value.trim(),
760
- audio: document.getElementById('dpAudio').value.trim(),
761
- text: document.getElementById('dpText').value.trim(),
762
- };
763
- // Удаляем пустые ключи чтобы не плодить мусор в JSON.
764
- for (const k of Object.keys(next)) if (!next[k]) delete next[k];
841
+ const next = _dpReadList();
765
842
  if (isActive) {
766
843
  if (!state.currentBoard.metadata.settings) state.currentBoard.metadata.settings = {};
767
- if (Object.keys(next).length) {
768
- state.currentBoard.metadata.settings.defaultPrompts = next;
769
- } else {
770
- delete state.currentBoard.metadata.settings.defaultPrompts;
771
- }
844
+ if (next.length) state.currentBoard.metadata.settings.defaultPrompts = next;
845
+ else delete state.currentBoard.metadata.settings.defaultPrompts;
772
846
  scheduleSave();
773
847
  } else {
774
848
  const meta = await loadBoardMetadata(item.handle);
775
849
  meta.settings = meta.settings || {};
776
- if (Object.keys(next).length) meta.settings.defaultPrompts = next;
850
+ if (next.length) meta.settings.defaultPrompts = next;
777
851
  else delete meta.settings.defaultPrompts;
778
852
  await saveBoardMetadata(item.handle, meta);
779
853
  }
780
- close();
781
- saveBtn.removeEventListener('click', onSave);
782
- cancelBtn.removeEventListener('click', onCancel);
783
- };
784
- const onCancel = () => {
785
- close();
786
- saveBtn.removeEventListener('click', onSave);
787
- cancelBtn.removeEventListener('click', onCancel);
854
+ close(); cleanup();
788
855
  };
856
+ const onCancel = () => { close(); cleanup(); };
789
857
  saveBtn.addEventListener('click', onSave);
790
858
  cancelBtn.addEventListener('click', onCancel);
859
+ addBtn.addEventListener('click', onAdd);
791
860
  }
792
861
 
793
862
  // Переименовать board (папку): создать новую папку с тем же именем, перенести
@@ -809,45 +809,65 @@ function nearestSupportedAspect(ratio) {
809
809
  return best.name;
810
810
  }
811
811
 
812
- // Показать «Дефолт сцены» в gen-modal'е если задан scene.settings
813
- // .defaultPrompts[currentKind]. Чекбокс «применить» включён по дефолту,
814
- // его state хранится в state.useDefaultPrompt и сбрасывается на каждое
815
- // открытие модалки.
812
+ // Рендерим стек дефолтных промптов в gen-modal'е. Каждый промпт,
813
+ // у которого в `kinds` есть текущий kind, показывается отдельной строкой
814
+ // с чекбоксом. Initial state prompt.enabled (заданный в scene-settings).
815
+ // Состояние «включён сейчас» (per-open, не sticky) хранится в Set
816
+ // state.activeDefaultPrompts (id'ы включённых).
816
817
  function syncDefaultPromptRow() {
817
- const row = document.getElementById('genDefaultPromptRow');
818
- const text = document.getElementById('genDefaultPromptText');
819
- const toggle = document.getElementById('genDefaultPromptToggle');
820
- if (!row || !text || !toggle) return;
821
- const dp = state.currentBoard?.metadata?.settings?.defaultPrompts || {};
822
- const value = dp[state.genKind] || '';
823
- if (value) {
824
- text.textContent = value;
825
- toggle.checked = true;
826
- state.useDefaultPrompt = true;
827
- row.style.display = '';
828
- } else {
818
+ const row = document.getElementById('genDefaultPromptsRow');
819
+ if (!row) return;
820
+ row.innerHTML = '';
821
+ state.activeDefaultPrompts = new Set();
822
+ const all = state.currentBoard?.metadata?.settings?.defaultPrompts || [];
823
+ const prompts = all.filter(p => (p.kinds || []).includes(state.genKind) && p.text);
824
+ if (!prompts.length) {
829
825
  row.style.display = 'none';
830
- state.useDefaultPrompt = false;
826
+ return;
827
+ }
828
+ row.style.display = 'flex';
829
+ for (const p of prompts) {
830
+ if (p.enabled !== false) state.activeDefaultPrompts.add(p.id);
831
+ const lab = document.createElement('label');
832
+ lab.style.cssText = 'display:flex; align-items:flex-start; gap:8px; cursor:pointer; padding:8px 10px; background:#1a1a1a; border:1px solid #333; border-radius:6px;';
833
+ const cb = document.createElement('input');
834
+ cb.type = 'checkbox';
835
+ cb.checked = p.enabled !== false;
836
+ cb.style.cssText = 'margin-top:2px; flex-shrink:0; accent-color:#7c3aed;';
837
+ cb.addEventListener('change', () => {
838
+ if (cb.checked) state.activeDefaultPrompts.add(p.id);
839
+ else state.activeDefaultPrompts.delete(p.id);
840
+ });
841
+ lab.appendChild(cb);
842
+ const wrap = document.createElement('div');
843
+ wrap.style.cssText = 'flex:1; min-width:0;';
844
+ const cap = document.createElement('div');
845
+ cap.style.cssText = 'font-size:11px; color:#888; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:3px;';
846
+ cap.textContent = 'дефолт сцены — добавится к промпту';
847
+ wrap.appendChild(cap);
848
+ const txt = document.createElement('div');
849
+ txt.style.cssText = 'font-size:12px; color:#aaccdd; font-family:ui-monospace,monospace; white-space:pre-wrap; word-break:break-word;';
850
+ txt.textContent = p.text;
851
+ wrap.appendChild(txt);
852
+ lab.appendChild(wrap);
853
+ row.appendChild(lab);
831
854
  }
832
855
  }
833
856
 
834
- // Слушатель чекбокса (один раз навешиваем).
835
- const _dpToggleEl = document.getElementById('genDefaultPromptToggle');
836
- if (_dpToggleEl) _dpToggleEl.addEventListener('change', () => {
837
- state.useDefaultPrompt = _dpToggleEl.checked;
838
- });
839
-
840
- // Применяет дефолтный промпт сцены к user-prompt'у. Префиксует с разделителем
841
- // ", " (типичная конвенция для image/video моделей). Если default отключён
842
- // или пуст — возвращает prompt как есть.
857
+ // Применяет включённые дефолтные промпты сцены к user-prompt'у.
858
+ // Префиксует с разделителем ", ". Промпты идут в порядке как в settings.
843
859
  function applyDefaultPrompt(userPrompt, kind) {
844
- if (!state.useDefaultPrompt) return userPrompt;
845
- const dp = state.currentBoard?.metadata?.settings?.defaultPrompts || {};
846
- const def = (dp[kind] || '').trim();
847
- if (!def) return userPrompt;
860
+ const all = state.currentBoard?.metadata?.settings?.defaultPrompts || [];
861
+ const active = state.activeDefaultPrompts || new Set();
862
+ const apply = all
863
+ .filter(p => active.has(p.id) && (p.kinds || []).includes(kind) && p.text)
864
+ .map(p => p.text.trim())
865
+ .filter(Boolean);
866
+ if (!apply.length) return userPrompt;
867
+ const prefix = apply.join(', ');
848
868
  const u = (userPrompt || '').trim();
849
- if (!u) return def;
850
- return def + ', ' + u;
869
+ if (!u) return prefix;
870
+ return prefix + ', ' + u;
851
871
  }
852
872
 
853
873
  // === Source frame controls ===
package/renderer/media.js CHANGED
@@ -1132,6 +1132,8 @@ function updateMentionPopup() {
1132
1132
  type: 'image',
1133
1133
  scope: 'char-image',
1134
1134
  charName: c.name,
1135
+ file: img.file,
1136
+ boardHandle: c.handle,
1135
1137
  });
1136
1138
  }
1137
1139
  }
@@ -1146,6 +1148,8 @@ function updateMentionPopup() {
1146
1148
  type: 'image',
1147
1149
  scope: 'loc-image',
1148
1150
  locName: l.name,
1151
+ file: img.file,
1152
+ boardHandle: l.handle,
1149
1153
  });
1150
1154
  }
1151
1155
  }
@@ -1162,6 +1166,9 @@ function updateMentionPopup() {
1162
1166
  type: 'image',
1163
1167
  scope: 'char',
1164
1168
  hasImages, hasSheet: !!c.characterSheet,
1169
+ // Превью: characterSheet если есть, иначе первая imageNodes.
1170
+ file: c.characterSheet || c.imageNodes?.[0]?.file || null,
1171
+ boardHandle: c.handle,
1165
1172
  });
1166
1173
  }
1167
1174
  items = items.filter(s => s.key.toLowerCase().includes(query)).slice(0, 16);
@@ -1174,7 +1181,11 @@ function updateMentionPopup() {
1174
1181
  for (const n of state.currentBoard.metadata.nodes) {
1175
1182
  if (!n.name || !n.id) continue;
1176
1183
  if (!allowed.includes(n.type)) continue;
1177
- items.push({ key: n.name, label: n.name, type: n.type, scope: 'board' });
1184
+ items.push({
1185
+ key: n.name, label: n.name, type: n.type, scope: 'board',
1186
+ file: n.file || null,
1187
+ boardHandle: state.currentBoard.handle,
1188
+ });
1178
1189
  }
1179
1190
  if (state.genKind === 'image' || state.genKind === 'video') {
1180
1191
  for (const c of state.charactersInfo) {
@@ -1187,6 +1198,8 @@ function updateMentionPopup() {
1187
1198
  type: 'image',
1188
1199
  scope: 'char',
1189
1200
  hasImages, hasSheet: !!c.characterSheet,
1201
+ file: c.characterSheet || c.imageNodes?.[0]?.file || null,
1202
+ boardHandle: c.handle,
1190
1203
  });
1191
1204
  }
1192
1205
  }
@@ -1200,6 +1213,8 @@ function updateMentionPopup() {
1200
1213
  type: 'image',
1201
1214
  scope: 'loc',
1202
1215
  hasImages, hasSheet: !!l.sheet,
1216
+ file: l.sheet || l.imageNodes?.[0]?.file || null,
1217
+ boardHandle: l.handle,
1203
1218
  });
1204
1219
  }
1205
1220
  }
@@ -1222,6 +1237,14 @@ function updateMentionPopup() {
1222
1237
  const it = document.createElement('div');
1223
1238
  it.className = 'mit' + (i === 0 ? ' selected' : '');
1224
1239
  it.dataset.idx = i;
1240
+ // Превью миниатюра — только для image-items с file. Лазиво грузим
1241
+ // через blob URL (с кешем mentionThumbCache).
1242
+ if (s.type === 'image' && s.file && s.boardHandle) {
1243
+ const img = document.createElement('img');
1244
+ img.className = 'mit-thumb';
1245
+ loadMentionThumb(s.boardHandle, s.file).then(url => { if (url) img.src = url; }).catch(() => {});
1246
+ it.appendChild(img);
1247
+ }
1225
1248
  const t = document.createElement('span');
1226
1249
  t.className = `mtype ${s.type}`;
1227
1250
  t.textContent = s.scope === 'char' ? 'персонаж'
@@ -1239,6 +1262,30 @@ function updateMentionPopup() {
1239
1262
  popup.classList.remove('hidden');
1240
1263
  }
1241
1264
 
1265
+ // Кеш blob URL'ов для миниатюр @-popup'а. Ключ = boardHandle.name + ':' + file.
1266
+ // blobURL'ы НЕ revoke'аются автоматически — копят память пока юзер открыт
1267
+ // в проекте; при закрытии проекта чистятся (revokeMentionThumbCache).
1268
+ // Это OK потому что миниатюры маленькие, и одна и та же картинка часто
1269
+ // показывается в нескольких popup'ах без релоада.
1270
+ const _mentionThumbCache = new Map();
1271
+ async function loadMentionThumb(boardHandle, file) {
1272
+ if (!boardHandle || !file) return null;
1273
+ const key = (boardHandle.name || '?') + ':' + file;
1274
+ if (_mentionThumbCache.has(key)) return _mentionThumbCache.get(key);
1275
+ try {
1276
+ const fh = await resolveBoardFile(boardHandle, file);
1277
+ const f = await fh.getFile();
1278
+ if (!f.type?.startsWith('image/') && !/\.(jpe?g|png|gif|webp|avif)$/i.test(file)) return null;
1279
+ const url = URL.createObjectURL(f);
1280
+ _mentionThumbCache.set(key, url);
1281
+ return url;
1282
+ } catch { return null; }
1283
+ }
1284
+ function revokeMentionThumbCache() {
1285
+ for (const url of _mentionThumbCache.values()) { try { URL.revokeObjectURL(url); } catch {} }
1286
+ _mentionThumbCache.clear();
1287
+ }
1288
+
1242
1289
  function closeMentionPopup() {
1243
1290
  mentionState.open = false;
1244
1291
  $('mentionPopup').classList.add('hidden');
@@ -1297,20 +1344,59 @@ $('genPrompt').addEventListener('keydown', e => {
1297
1344
  });
1298
1345
 
1299
1346
  // =================== Character settings ===================
1347
+ // Хелпер: пути к именованным image-нодам доски. Если эта доска сейчас
1348
+ // открыта в state.currentBoard — берём свежие in-memory нодии (юзер мог
1349
+ // только что добавить ноду, scheduleSave ещё не flush'нул на диск).
1350
+ // Иначе грузим с диска через loadBoardMetadata.
1351
+ // Включаем nameless-ноды по basename файла — без этого только что
1352
+ // созданные ноды не появляются в @-popup'е.
1353
+ async function _scanBoardForRefs(itemKind, item) {
1354
+ let nodes, character, location;
1355
+ const isActive = state.currentBoard?.kind === itemKind && state.currentBoard.name === item.name;
1356
+ if (isActive) {
1357
+ nodes = state.currentBoard.metadata.nodes || [];
1358
+ character = state.currentBoard.metadata.character;
1359
+ location = state.currentBoard.metadata.location;
1360
+ } else {
1361
+ const meta = await loadBoardMetadata(item.handle);
1362
+ nodes = meta.nodes || [];
1363
+ character = meta.character;
1364
+ location = meta.location;
1365
+ }
1366
+ // Image-ноды с file. Имя берём из n.name если есть, иначе basename файла
1367
+ // без расширения. Дедупим по итоговому labelKey.
1368
+ const seen = new Set();
1369
+ const imageNodes = [];
1370
+ for (const n of nodes) {
1371
+ if (n.type !== 'image' || !n.file) continue;
1372
+ let label = (n.name || '').trim();
1373
+ if (!label) {
1374
+ // basename без расширения
1375
+ const base = n.file.split('/').pop() || '';
1376
+ label = base.replace(/\.[^.]+$/, '') || base;
1377
+ }
1378
+ // Дедуп: если 2 ноды с одинаковым label → к второй припишем -<short id>
1379
+ let key = label;
1380
+ if (seen.has(key)) {
1381
+ key = `${label}-${(n.id || '').slice(0, 4)}`;
1382
+ }
1383
+ seen.add(key);
1384
+ imageNodes.push({ name: key, file: n.file, id: n.id });
1385
+ }
1386
+ return { imageNodes, character, location };
1387
+ }
1388
+
1300
1389
  async function loadAllCharactersInfo() {
1301
1390
  if (!state.filmHandle) return;
1302
1391
  const chars = await listCharacters(state.filmHandle);
1303
1392
  const info = [];
1304
1393
  for (const c of chars) {
1305
1394
  try {
1306
- const meta = await loadBoardMetadata(c.handle);
1307
- const imageNodes = meta.nodes
1308
- .filter(n => n.type === 'image' && n.file && n.name)
1309
- .map(n => ({ name: n.name, file: n.file, id: n.id }));
1395
+ const { imageNodes, character } = await _scanBoardForRefs('character', c);
1310
1396
  info.push({
1311
1397
  name: c.name,
1312
1398
  handle: c.handle,
1313
- ...(meta.character || {}),
1399
+ ...(character || {}),
1314
1400
  imageNodes,
1315
1401
  });
1316
1402
  } catch {}
@@ -1354,14 +1440,11 @@ async function loadAllLocationsInfo() {
1354
1440
  const info = [];
1355
1441
  for (const l of locs) {
1356
1442
  try {
1357
- const meta = await loadBoardMetadata(l.handle);
1358
- const imageNodes = meta.nodes
1359
- .filter(n => n.type === 'image' && n.file && n.name)
1360
- .map(n => ({ name: n.name, file: n.file, id: n.id }));
1443
+ const { imageNodes, location } = await _scanBoardForRefs('location', l);
1361
1444
  info.push({
1362
1445
  name: l.name,
1363
1446
  handle: l.handle,
1364
- sheet: meta.location?.sheet || null,
1447
+ sheet: location?.sheet || null,
1365
1448
  imageNodes,
1366
1449
  });
1367
1450
  } catch {}
package/renderer/state.js CHANGED
@@ -301,9 +301,50 @@ async function loadBoardMetadata(boardHandle) {
301
301
  try { await boardHandle.removeEntry('.editor.json'); } catch {}
302
302
  }
303
303
 
304
+ // Нормализуем settings.defaultPrompts к новому array-формату
305
+ // (старый object-формат миграется на лету; saveBoardMetadata всегда
306
+ // пишет в новом).
307
+ if (settings && settings.defaultPrompts !== undefined) {
308
+ const normalized = normalizeDefaultPrompts(settings.defaultPrompts);
309
+ if (JSON.stringify(normalized) !== JSON.stringify(settings.defaultPrompts)) {
310
+ migrated = true; // запишется на следующем save в новом формате
311
+ }
312
+ settings = { ...settings, defaultPrompts: normalized };
313
+ }
304
314
  return { nodes, connections, view, character, location, timeline, history, settings, _migrated: migrated };
305
315
  }
306
316
 
317
+ // Backward-compat: старый формат settings.defaultPrompts был
318
+ // `{ image: "...", video: "...", ... }` — по одному промпту на kind.
319
+ // Новый формат — массив `[{ id, text, kinds: [...], enabled: true }, ...]`.
320
+ // Нормализуем при чтении: если object — конвертируем в array, каждый
321
+ // non-empty key становится одним prompt'ом со своим единственным kind.
322
+ function normalizeDefaultPrompts(dp) {
323
+ if (Array.isArray(dp)) {
324
+ // Гарантируем что у каждого есть id и enabled.
325
+ return dp.map(p => ({
326
+ id: p.id || crypto.randomUUID(),
327
+ text: p.text || '',
328
+ kinds: Array.isArray(p.kinds) && p.kinds.length ? p.kinds : ['image'],
329
+ enabled: p.enabled !== false, // default true
330
+ ...(p.name ? { name: p.name } : {}),
331
+ }));
332
+ }
333
+ if (!dp || typeof dp !== 'object') return [];
334
+ // Старый object-формат → array.
335
+ const out = [];
336
+ for (const [kind, text] of Object.entries(dp)) {
337
+ if (typeof text !== 'string' || !text.trim()) continue;
338
+ out.push({
339
+ id: crypto.randomUUID(),
340
+ text: text.trim(),
341
+ kinds: [kind],
342
+ enabled: true,
343
+ });
344
+ }
345
+ return out;
346
+ }
347
+
307
348
  async function saveBoardMetadata(boardHandle, meta) {
308
349
  for (const n of meta.nodes) {
309
350
  if (n.type === 'text' && n.file) {
@@ -463,6 +463,42 @@
463
463
  }
464
464
  .fs-modal #fsClose:hover { background: rgba(255,68,68,0.7); }
465
465
 
466
+ /* Default-prompts editor: каждый промпт — карточка с textarea, kind-чипами,
467
+ дефолт-чекбоксом и кнопкой удаления. */
468
+ .dp-item {
469
+ border: 1px solid #333; border-radius: 8px; background: #1a1a1a;
470
+ padding: 10px 12px; display: flex; flex-direction: column; gap: 8px;
471
+ }
472
+ .dp-item textarea {
473
+ width: 100%; font-family: ui-monospace, monospace; font-size: 12px;
474
+ background: #0e0e0e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px;
475
+ padding: 6px 8px; resize: vertical; min-height: 40px;
476
+ }
477
+ .dp-item-controls {
478
+ display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
479
+ font-size: 12px; color: #aaa;
480
+ }
481
+ .dp-item-kinds {
482
+ display: flex; gap: 4px;
483
+ }
484
+ .dp-kind-chip {
485
+ padding: 3px 9px; border: 1px solid #444; border-radius: 12px;
486
+ background: #1a1a1a; color: #888; font-size: 11px; cursor: pointer;
487
+ user-select: none;
488
+ }
489
+ .dp-kind-chip.active {
490
+ background: rgba(124,58,237,0.18); border-color: #7c3aed; color: #c8a8ff;
491
+ }
492
+ .dp-item-default {
493
+ display: flex; align-items: center; gap: 6px; cursor: pointer;
494
+ }
495
+ .dp-item-default input { accent-color: #7c3aed; }
496
+ .dp-item-delete {
497
+ margin-left: auto; background: transparent; border: 0; color: #888;
498
+ font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 6px;
499
+ }
500
+ .dp-item-delete:hover { color: #f88; }
501
+
466
502
  /* Кнопки навигации prev/next в полноэкранном просмотре */
467
503
  .fs-modal .fs-nav {
468
504
  position: absolute; top: 50%; transform: translateY(-50%);
@@ -1000,6 +1036,10 @@
1000
1036
  .mention-popup .mit .mtype.audio { background: #6a4a2a; color: #e6c8aa; }
1001
1037
  .mention-popup .mit .mtype.text { background: #2a6a2a; color: #c8e6aa; }
1002
1038
  .mention-popup .mit .mtype.image { background: #5a2a6a; color: #e6aac8; }
1039
+ .mention-popup .mit .mit-thumb {
1040
+ width: 28px; height: 28px; border-radius: 3px; object-fit: cover;
1041
+ flex-shrink: 0; background: #1a1a1a; border: 1px solid #333;
1042
+ }
1003
1043
  .mention-popup .empty-msg { padding: 8px 12px; color: #666; font-size: 12px; }
1004
1044
 
1005
1045
  .settings-row { display: flex; flex-direction: column; gap: 4px; }
package/skill/SKILL.md CHANGED
@@ -105,19 +105,32 @@ kingkont gen <project> 'Серия 1' --kind=image --prompt="..." --aspect-ratio
105
105
 
106
106
  ### Дефолтные промпты сцены
107
107
 
108
- `scene.json → settings.defaultPrompts` — объект с ключами `image / video /
109
- audio / text`. Если задан, CLI `kingkont gen` автоматически префиксует его
110
- к user-prompt'у при генерации (`<default>, <user prompt>`). Это позволяет
111
- юзеру задать общий стиль/тон для всей сцены один раз.
108
+ `scene.json → settings.defaultPrompts` — массив:
109
+ ```json
110
+ [
111
+ { "id": "uuid", "text": "cinematic lighting", "kinds": ["image","video"], "enabled": true },
112
+ { "id": "uuid", "text": "тёплый тон", "kinds": ["audio"], "enabled": false }
113
+ ]
114
+ ```
112
115
 
113
- Поведение CLI:
114
- - Default подхватывается из scene.json автоматически флага не нужно.
115
- - Чтобы отключить для одной генерации: `--no-default-prompt`.
116
- - В stderr пишется `[gen] applied scene default prompt for <kind>: "..."`.
116
+ Каждый prompt:
117
+ - `text`собственно строка
118
+ - `kinds` список kind'ов где он применим (`image / video / audio / text`)
119
+ - `enabled` true/false: применяется ли по умолчанию (юзер может в gen-modal'е переключить для конкретной генерации)
120
+
121
+ CLI `kingkont gen <kind>` берёт ВСЕ промпты с `enabled !== false` и
122
+ текущим kind в их `kinds`, конкатенирует через `, ` и префиксует к
123
+ user-prompt'у. Чтобы отключить целиком для одной генерации — флаг
124
+ `--no-default-prompt`. В stderr пишется `[gen] applied N default
125
+ prompt(s) for <kind>: "..."`.
117
126
 
118
127
  Юзер обычно настраивает defaults через UI (правый клик на сцену →
119
- «📝 Дефолтные промпты…»), но может попросить установить через CLI —
120
- правь `scene.json` напрямую: `settings.defaultPrompts.<kind> = "..."`.
128
+ «📝 Дефолтные промпты…»). Через CLI правь `scene.json` напрямую,
129
+ добавляя/удаляя элементы массива.
130
+
131
+ Backward-compat: если в scene.json остался старый формат
132
+ `settings.defaultPrompts: { image: "..." }` — CLI и UI конвертируют
133
+ его в массив на лету; следующее сохранение перепишет в новый формат.
121
134
 
122
135
  ## ⚠️ Text-ноды — генерируй САМ, не через `kingkont gen --kind=text`
123
136