kingkont 0.7.99 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.7.99",
3
+ "version": "0.8.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -1885,15 +1885,19 @@ function showNodeContextMenu(node, clientX, clientY) {
1885
1885
  add('📥 Вставить (Cmd+V)', () => pasteClipboardNodes(), { disabled: !canPaste });
1886
1886
  const canReplace = !!(state.clipboard?.length);
1887
1887
  add('⇄ Заменить из буфера', () => replaceNodeFromClipboard(node), { disabled: !canReplace });
1888
- if (node.type === 'audio' && node.file) {
1889
- add('⬇ Скачать аудио', async () => {
1888
+ // «Сохранить как…» — для любого медиа-файла (image / audio / video).
1889
+ // Показывает нативный системный диалог через FS-Access-API; если API
1890
+ // недоступен (web-режим) — auto-сохранение в Downloads/.
1891
+ if ((node.type === 'image' || node.type === 'audio' || node.type === 'video') && node.file) {
1892
+ add('💾 Сохранить как…', async () => {
1890
1893
  try {
1891
1894
  const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
1892
1895
  const file = await fh.getFile();
1893
- await downloadAudioAtSpeed(file, node.file, 1);
1896
+ const suggested = node.file.split('/').pop();
1897
+ await saveAsToDisk(file, suggested);
1894
1898
  } catch (err) {
1895
- console.error('Download failed:', err);
1896
- alert('Не удалось скачать: ' + (err?.message || err));
1899
+ console.error('Save failed:', err);
1900
+ alert('Не удалось сохранить: ' + (err?.message || err));
1897
1901
  }
1898
1902
  });
1899
1903
  }
package/renderer/media.js CHANGED
@@ -152,6 +152,29 @@ async function downloadAudioAtSpeed(file, filename, speed, onProgress) {
152
152
  await ff.deleteFile(outName).catch(() => {});
153
153
  }
154
154
 
155
+ // «Сохранить как…» — показывает нативный системный диалог выбора пути
156
+ // (через FS-Access-API), куда сохранить файл. Если API недоступен или
157
+ // юзер ничего не выбрал — fallback на triggerDownload (auto-сохранение
158
+ // в Downloads/). Возвращает true если файл сохранён, false если отменено.
159
+ async function saveAsToDisk(file, suggestedName) {
160
+ if (typeof window.showSaveFilePicker === 'function') {
161
+ try {
162
+ const handle = await window.showSaveFilePicker({ suggestedName });
163
+ const w = await handle.createWritable();
164
+ await w.write(file);
165
+ await w.close();
166
+ return true;
167
+ } catch (err) {
168
+ // AbortError — юзер закрыл диалог. Не fallback'аемся, чтобы не
169
+ // дёргать auto-download против его воли.
170
+ if (err?.name === 'AbortError') return false;
171
+ // Любая другая ошибка — fall through на triggerDownload.
172
+ }
173
+ }
174
+ triggerDownload(file, suggestedName);
175
+ return true;
176
+ }
177
+
155
178
  function triggerDownload(blob, filename) {
156
179
  const url = URL.createObjectURL(blob);
157
180
  const a = document.createElement('a');
@@ -1285,7 +1285,7 @@ async function copySelectedNodes() {
1285
1285
  const n = state.currentBoard.metadata.nodes.find(x => x.id === id);
1286
1286
  if (!n) continue;
1287
1287
  let blob = null, textContent = null;
1288
- if (n.type === 'text') {
1288
+ if (n.type === 'text' || n.type === 'label') {
1289
1289
  textContent = n.text || '';
1290
1290
  } else if (n.file) {
1291
1291
  try {
@@ -1296,13 +1296,16 @@ async function copySelectedNodes() {
1296
1296
  state.clipboard.push({ node: { ...n }, blob, textContent });
1297
1297
  }
1298
1298
  console.log(`Скопировано в буфер: ${state.clipboard.length} нод`);
1299
+ // Дублируем в системный буфер обмена — чтобы Cmd+V в других приложениях
1300
+ // (браузер, Telegram, image-editor) тоже что-то получали.
1301
+ await writeNodesToSystemClipboard(state.clipboard);
1299
1302
  }
1300
1303
 
1301
1304
  // Скопировать одну ноду в буфер (для ПКМ → "Скопировать")
1302
1305
  async function copyNodeToClipboard(node) {
1303
1306
  if (!state.currentBoard) return;
1304
1307
  let blob = null, textContent = null;
1305
- if (node.type === 'text') {
1308
+ if (node.type === 'text' || node.type === 'label') {
1306
1309
  textContent = node.text || '';
1307
1310
  } else if (node.file) {
1308
1311
  try {
@@ -1314,6 +1317,67 @@ async function copyNodeToClipboard(node) {
1314
1317
  state.clipboardPasteCount = 0;
1315
1318
  state.clipboardSourceBoardKey = state.currentBoard.key;
1316
1319
  console.log(`В буфер: ${node.name || node.id}`);
1320
+ await writeNodesToSystemClipboard(state.clipboard);
1321
+ }
1322
+
1323
+ // Кладёт ноды в системный буфер обмена (где это имеет смысл):
1324
+ // • image — image/png (конвертим из jpg/webp через canvas)
1325
+ // • text/label — text/plain (если несколько — соединяем через \n\n)
1326
+ // • video/audio — пропускаем (Clipboard API binary-MIME'ов не держит)
1327
+ // Ошибки глотаем — основной путь это внутренний state.clipboard, system
1328
+ // clipboard это бонус. Если permission denied / focus lost / unsupported
1329
+ // MIME — внутренний работает как раньше.
1330
+ async function writeNodesToSystemClipboard(items) {
1331
+ if (!items?.length || !navigator.clipboard) return;
1332
+ try {
1333
+ // Один image-ноду — пишем как картинку.
1334
+ if (items.length === 1 && items[0].blob && items[0].node?.type === 'image') {
1335
+ const blob = items[0].blob;
1336
+ const pngBlob = blob.type === 'image/png' ? blob : await blobToPng(blob);
1337
+ if (pngBlob && typeof ClipboardItem === 'function') {
1338
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
1339
+ return;
1340
+ }
1341
+ }
1342
+ // Текстовые ноды (text + label) — собираем тексты, пишем plain.
1343
+ const texts = items
1344
+ .filter(it => (it.node?.type === 'text' || it.node?.type === 'label') && typeof it.textContent === 'string')
1345
+ .map(it => it.textContent);
1346
+ if (texts.length) {
1347
+ await navigator.clipboard.writeText(texts.join('\n\n'));
1348
+ return;
1349
+ }
1350
+ // video/audio в одиночку — кладём имя файла как fallback (юзер хотя
1351
+ // бы pasted'ит куда-то осмысленный текст).
1352
+ const single = items[0]?.node;
1353
+ if (single?.file) {
1354
+ await navigator.clipboard.writeText(single.name || single.file.split('/').pop() || '');
1355
+ }
1356
+ } catch (e) {
1357
+ // Permission / async-context потерян — internal clipboard всё равно
1358
+ // отработал, юзер заметит при Cmd+V внутри редактора.
1359
+ console.warn('system clipboard write failed:', e?.message || e);
1360
+ }
1361
+ }
1362
+
1363
+ // Конвертирует image-blob (jpg/webp/heic/etc) в PNG через canvas —
1364
+ // system clipboard принимает только image/png универсально.
1365
+ async function blobToPng(blob) {
1366
+ const url = URL.createObjectURL(blob);
1367
+ try {
1368
+ const img = await new Promise((res, rej) => {
1369
+ const i = new Image();
1370
+ i.onload = () => res(i);
1371
+ i.onerror = rej;
1372
+ i.src = url;
1373
+ });
1374
+ const canvas = document.createElement('canvas');
1375
+ canvas.width = img.naturalWidth;
1376
+ canvas.height = img.naturalHeight;
1377
+ canvas.getContext('2d').drawImage(img, 0, 0);
1378
+ return await new Promise(res => canvas.toBlob(res, 'image/png'));
1379
+ } catch { return null; }
1380
+ finally { URL.revokeObjectURL(url); }
1317
1381
  }
1318
1382
 
1319
1383
  // Заменить контент текущей ноды содержимым из буфера (текущее уходит в history)
package/renderer/state.js CHANGED
@@ -439,6 +439,20 @@ function getFileType(file) {
439
439
  } catch {}
440
440
  })();
441
441
 
442
+ // One-time migration: дефолт video-разрешения раньше был 720p — сбрасываем
443
+ // тех, у кого залипло '720p' (вероятно, они никогда явно не выбирали),
444
+ // на минимальное '480p'. После явного выбора 720p/1080p — он сохранится
445
+ // как и раньше, миграция повторно не отрабатывает.
446
+ (function migrateVideoResolutionDefault() {
447
+ try {
448
+ if (localStorage.getItem('videoResolutionResetMin') === '1') return;
449
+ if (localStorage.getItem('videoResolution') === '720p') {
450
+ localStorage.setItem('videoResolution', '480p');
451
+ }
452
+ localStorage.setItem('videoResolutionResetMin', '1');
453
+ } catch {}
454
+ })();
455
+
442
456
  const state = {
443
457
  filmHandle: null,
444
458
  currentBoard: null, // { kind, name, key, handle, metadata, urls }
@@ -449,7 +463,7 @@ const state = {
449
463
  videoModel: localStorage.getItem('videoModel') || 'seedance-2', // 'seedance-2' | 'kling-o1' | 'kling-3.0' | ...
450
464
  ttsModel: localStorage.getItem('ttsModel') || 'qwen/qwen3-tts', // qwen/elevenlabs/v3/minimax/speech-02-hd/gemini
451
465
  videoDuration: +(localStorage.getItem('videoDuration') || 5),
452
- videoResolution: localStorage.getItem('videoResolution') || '720p',
466
+ videoResolution: localStorage.getItem('videoResolution') || '480p',
453
467
  videoAspect: localStorage.getItem('videoAspect') || '9:16',
454
468
  jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
455
469
  undoStack: [], // {type, ...payload}
@@ -639,8 +639,14 @@
639
639
  transparent 0%, transparent 55%,
640
640
  #777 55%, #777 65%, transparent 65%,
641
641
  transparent 75%, #777 75%, #777 85%, transparent 85%);
642
- opacity: 0.45; border-bottom-right-radius: 8px;
642
+ /* Прячем по умолчанию — handle видно только на hover ноды. Раньше
643
+ был opacity:0.45, и на image-нодах после v0.7.97 (square corners)
644
+ полоски стали хорошо видны против картинки и выглядели как
645
+ посторонний UI-элемент. */
646
+ opacity: 0; transition: opacity 0.15s;
647
+ border-bottom-right-radius: 8px;
643
648
  }
649
+ .node:hover .resize-handle { opacity: 0.55; }
644
650
  .resize-handle:hover { opacity: 1; }
645
651
 
646
652
  .node-footer {
@@ -1282,18 +1288,33 @@
1282
1288
  .seg-control .seg:first-child { border-radius: 4px 0 0 4px; }
1283
1289
  .seg-control .seg:last-child { border-radius: 0 4px 4px 0; border-left: none; }
1284
1290
  .seg-control .seg.active { background: #3a5a8a; border-color: #4a6a9a; color: #fff; }
1291
+ /* Hover ТОЛЬКО на наведённую кнопку — синеватый оттенок, чтобы видно
1292
+ было «вот это сейчас выберется», а не серая «обычный hover» (которая
1293
+ раньше шла от глобального button:hover и выглядела одинаково с
1294
+ disabled-состоянием). */
1295
+ .seg-control .seg:hover:not(.active):not(:disabled) {
1296
+ background: #2a3854; border-color: #3a5a8a; color: #e8e8e8;
1297
+ }
1298
+ .seg-control .seg.active:hover:not(:disabled) {
1299
+ background: #4a6aa0;
1300
+ }
1285
1301
  .status { font-size: 12px; color: #aaa; display: flex; align-items: center; gap: 8px; }
1286
1302
  .status.error { color: #e07a6a; }
1287
1303
  .spinner { width: 14px; height: 14px; border: 2px solid #444; border-top-color: #6a8aaa; border-radius: 50%; animation: spin 0.9s linear infinite; }
1288
1304
  .spinner.lg { width: 32px; height: 32px; border-width: 3px; }
1289
1305
  @keyframes spin { to { transform: rotate(360deg); } }
1290
1306
 
1291
- /* mention popup */
1307
+ /* mention popup — overlay, чтобы появление @-списка не двигало modal-actions
1308
+ вниз (модалка не менялась бы по высоте). Позиционируется absolute
1309
+ относительно <label>-родителя (см. правило ниже). */
1310
+ .modal-card label:has(> .mention-popup) { position: relative; }
1292
1311
  .mention-popup {
1312
+ position: absolute; left: 0; right: 0; top: 100%;
1293
1313
  background: #1e1e1e; border: 1px solid #444; border-radius: 6px;
1294
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
1314
+ box-shadow: 0 8px 24px rgba(0,0,0,0.7);
1295
1315
  max-height: 240px; overflow-y: auto;
1296
- margin-top: 6px;
1316
+ margin-top: 4px;
1317
+ z-index: 100;
1297
1318
  }
1298
1319
  .mention-popup.hidden { display: none; }
1299
1320
  .mention-popup .mit {