kingkont 0.20.16 → 0.20.18

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.
@@ -516,6 +516,13 @@ $('textGenSubmit').addEventListener('click', async () => {
516
516
  // Собираем image-mentions из промпта (видение для модели). Видео/audio в
517
517
  // OpenRouter chat не поддерживается — игнорируем.
518
518
  const allMediaRefs = (typeof gatherMediaRefs === 'function') ? gatherMediaRefs(rawPrompt) : [];
519
+ // + источник pendingConnectionFrom (anchor-drag → новая text-нода): если
520
+ // юзер протянул из image-ноды в gen-text, картинка автоматически попадает
521
+ // в контекст. Видео-источники для text-gen бесполезны (OpenRouter chat
522
+ // их не съест), но мы оставляем в общем списке — фильтр ниже их уберёт.
523
+ for (const pf of _pendingConnectionFromRef()) {
524
+ if (!allMediaRefs.some(r => r.file === pf.file)) allMediaRefs.push(pf);
525
+ }
519
526
  const imageRefs = allMediaRefs.filter(r => r.type === 'image' && r.file);
520
527
  // Резолвим mentions: text-ноды → инлайн .md, image-ноды → маркеры [image N].
521
528
  const resolvedPrompt = (typeof resolveMentions === 'function') ? resolveMentions(rawPrompt, imageRefs) : rawPrompt;
@@ -1202,6 +1209,63 @@ function presetPicksForBoard() {
1202
1209
  if (b?.kind === 'location' && b.name) state.pickedLocName = b.name;
1203
1210
  }
1204
1211
 
1212
+ // Собирает image/video ноды, ИЗ которых есть connection В targetNodeId.
1213
+ // Юзер: «если в ноду 'входит' несколько других нод-картинок или 'нод-видео'
1214
+ // — подавай их тоже как референсы». Когда граф уже выражает связь
1215
+ // (юзер протянул стрелку), не заставляем юзера ещё и писать @name в
1216
+ // промпте — автоматически подмешиваем источники к refs.
1217
+ // Используется в:
1218
+ // • genSubmit (новая нода) — для state.pendingConnectionFrom (anchor-drag)
1219
+ // это уже учитывается отдельно, т.к. там ещё нет node.id; здесь же
1220
+ // полезно когда юзер ставит несколько связей через drag-line ДО
1221
+ // запуска gen-modal'а (редкий кейс).
1222
+ // • regenerateInto (существующая нода) — все incoming-edges с
1223
+ // image/video-источниками. Главный кейс: юзер связал draft-ноду с
1224
+ // несколькими картинками и нажал «↻ Перегенерировать».
1225
+ function gatherIncomingMediaRefs(targetNodeId) {
1226
+ const refs = [];
1227
+ const board = state.currentBoard;
1228
+ if (!board || !targetNodeId) return refs;
1229
+ const conns = board.metadata.connections || [];
1230
+ const nodes = board.metadata.nodes || [];
1231
+ for (const c of conns) {
1232
+ if (c.to !== targetNodeId) continue;
1233
+ const src = nodes.find(n => n.id === c.from);
1234
+ if (!src) continue;
1235
+ if (src.type !== 'image' && src.type !== 'video') continue;
1236
+ if (!src.file) continue; // источник ещё не готов — не блокируем gen, просто пропускаем
1237
+ refs.push({
1238
+ key: `incoming:${src.id}`,
1239
+ name: src.name || src.file.split('/').pop(),
1240
+ type: src.type,
1241
+ file: src.file,
1242
+ boardHandle: board.handle,
1243
+ });
1244
+ }
1245
+ return refs;
1246
+ }
1247
+
1248
+ // Источник pendingConnectionFrom (для NEW ноды, когда node.id ещё не
1249
+ // существует). Возвращает массив с одним рефом или пустой.
1250
+ function _pendingConnectionFromRef() {
1251
+ const refs = [];
1252
+ if (!state.pendingConnectionFrom) return refs;
1253
+ const board = state.currentBoard;
1254
+ if (!board) return refs;
1255
+ const src = (board.metadata.nodes || []).find(n => n.id === state.pendingConnectionFrom);
1256
+ if (!src) return refs;
1257
+ if (src.type !== 'image' && src.type !== 'video') return refs;
1258
+ if (!src.file) return refs;
1259
+ refs.push({
1260
+ key: `incoming:${src.id}`,
1261
+ name: src.name || src.file.split('/').pop(),
1262
+ type: src.type,
1263
+ file: src.file,
1264
+ boardHandle: board.handle,
1265
+ });
1266
+ return refs;
1267
+ }
1268
+
1205
1269
  // Собрать рефы из выбранных персонажей и локации (sheet'ы)
1206
1270
  function gatherPickedSheetRefs() {
1207
1271
  const refs = [];
@@ -1715,6 +1779,14 @@ $('genSubmit').addEventListener('click', async () => {
1715
1779
  if (mediaRefs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
1716
1780
  mediaRefs.push(pr);
1717
1781
  }
1782
+ // Incoming-edge источник для НОВОЙ ноды (anchor-drag из image/video ноды).
1783
+ // Юзер: «если в ноду 'входит' нода-картинка/видео — подавай её тоже как
1784
+ // референс». Для NEW ноды есть только pendingConnectionFrom (один источник);
1785
+ // существующая нода с множественными incoming обрабатывается в regenerateInto.
1786
+ for (const pf of _pendingConnectionFromRef()) {
1787
+ if (mediaRefs.some(r => r.file === pf.file && r.boardHandle === pf.boardHandle)) continue;
1788
+ mediaRefs.push(pf);
1789
+ }
1718
1790
  const missing = mediaRefs.filter(r => !r.file || r.status === 'generating');
1719
1791
  if (missing.length) {
1720
1792
  alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + m.name).join(', '));
@@ -1974,9 +2046,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1974
2046
  // Так per-board настройка перебивает глобальный фоллбэк.
1975
2047
  const boardAspect = state.currentBoard?.metadata?.settings?.aspectRatio || null;
1976
2048
  if (kind === 'video') {
1977
- submitBody.duration = node.generated?.duration ?? state.videoDuration;
2049
+ // Явное приведение к Number — иначе если state.videoDuration хранится
2050
+ // строкой ("8") chatium-side ALLOWED_PARAMS пропустит, но Replicate
2051
+ // может молча проигнорировать и взять дефолт.
2052
+ submitBody.duration = +(node.generated?.duration ?? state.videoDuration) || 5;
1978
2053
  submitBody.resolution = node.generated?.resolution ?? state.videoResolution;
1979
2054
  submitBody.aspectRatio = node.generated?.aspectRatio ?? boardAspect ?? state.videoAspect;
2055
+ logJob(node.id, `video params: duration=${submitBody.duration}s resolution=${submitBody.resolution} aspect=${submitBody.aspectRatio} model=${modelKey}`);
1980
2056
  } else if (kind === 'image') {
1981
2057
  // Grok-imagine требует aspect_ratio из {2:3, 3:2, 1:1, 9:16, 16:9}.
1982
2058
  // Остальные модели (nano-banana-2, seedream и др.) тоже принимают.
package/renderer/media.js CHANGED
@@ -94,22 +94,117 @@ $('zoomIn').addEventListener('click', () => applyZoom(state.zoom * 1.25));
94
94
  $('zoomOut').addEventListener('click', () => applyZoom(state.zoom / 1.25));
95
95
  $('zoomLabel').addEventListener('click', () => applyZoom(1));
96
96
 
97
- // Сохранение pan/zoom для текущей доски
97
+ // =================== Pinch-zoom (touch) ===================
98
+ // Мобильный/таблет: pinch двумя пальцами → zoom вокруг midpoint двух тачей.
99
+ // Браузерный native pinch-zoom (visualViewport) масштабирует ВЕСЬ layout
100
+ // и ломает координаты — поэтому touch-action: pan-x pan-y отключает его в
101
+ // view-only-mode (см. styles.css), а мы делаем canvas-zoom вручную через
102
+ // applyZoom() с координатным якорем посередине pinch'а.
103
+ // Якорь = midpoint двух touch'ей в client-coords (как у wheel-ctrl-zoom).
104
+ // Ratio = currentDist / startDist → applyZoom(startZoom * ratio, midX, midY).
105
+ let _pinchStartDist = 0;
106
+ let _pinchStartZoom = 1;
107
+ let _pinchActive = false;
108
+ let _pinchRaf = null;
109
+ let _pinchPendingMid = null;
110
+ function _touchDist(t1, t2) {
111
+ const dx = t2.clientX - t1.clientX;
112
+ const dy = t2.clientY - t1.clientY;
113
+ return Math.hypot(dx, dy);
114
+ }
115
+ function _touchMid(t1, t2) {
116
+ return { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
117
+ }
118
+ canvasWrap.addEventListener('touchstart', e => {
119
+ if (e.touches.length === 2) {
120
+ _pinchActive = true;
121
+ _pinchStartDist = _touchDist(e.touches[0], e.touches[1]);
122
+ _pinchStartZoom = state.zoom;
123
+ // Предотвращаем native pinch только если у нас 2-touch жест.
124
+ // 1-touch (pan) пропускаем — native scroll работает.
125
+ e.preventDefault();
126
+ }
127
+ }, { passive: false });
128
+ canvasWrap.addEventListener('touchmove', e => {
129
+ if (!_pinchActive || e.touches.length !== 2) return;
130
+ e.preventDefault();
131
+ const dist = _touchDist(e.touches[0], e.touches[1]);
132
+ const mid = _touchMid(e.touches[0], e.touches[1]);
133
+ if (_pinchStartDist <= 0) return;
134
+ const ratio = dist / _pinchStartDist;
135
+ _pinchPendingMid = { mid, target: _pinchStartZoom * ratio };
136
+ if (_pinchRaf) return;
137
+ _pinchRaf = requestAnimationFrame(() => {
138
+ _pinchRaf = null;
139
+ const p = _pinchPendingMid;
140
+ if (!p) return;
141
+ applyZoom(p.target, p.mid.x, p.mid.y);
142
+ });
143
+ }, { passive: false });
144
+ function _endPinch() {
145
+ _pinchActive = false;
146
+ _pinchStartDist = 0;
147
+ if (_pinchRaf) { cancelAnimationFrame(_pinchRaf); _pinchRaf = null; }
148
+ _pinchPendingMid = null;
149
+ }
150
+ canvasWrap.addEventListener('touchend', e => {
151
+ if (e.touches.length < 2) _endPinch();
152
+ });
153
+ canvasWrap.addEventListener('touchcancel', _endPinch);
154
+
155
+ // Сохранение pan/zoom для текущей доски.
156
+ // Раньше 400ms debounce: scroll → 400ms wait → set view → scheduleSave 300ms
157
+ // → write. Юзер закрывал таб быстрее → save не успевал → позиция терялась.
158
+ // Теперь 100ms debounce + явный flush на pagehide/beforeunload.
98
159
  let viewSaveTimer = null;
160
+ function _updateViewFromScroll() {
161
+ if (!state.currentBoard) return;
162
+ state.currentBoard.metadata.view = {
163
+ scrollLeft: canvasWrap.scrollLeft,
164
+ scrollTop: canvasWrap.scrollTop,
165
+ zoom: state.zoom,
166
+ };
167
+ }
99
168
  function scheduleViewSave() {
100
169
  if (!state.currentBoard) return;
101
170
  clearTimeout(viewSaveTimer);
102
171
  viewSaveTimer = setTimeout(() => {
103
- if (!state.currentBoard) return;
104
- state.currentBoard.metadata.view = {
105
- scrollLeft: canvasWrap.scrollLeft,
106
- scrollTop: canvasWrap.scrollTop,
107
- zoom: state.zoom,
108
- };
172
+ _updateViewFromScroll();
109
173
  scheduleSave();
110
- }, 400);
174
+ }, 100);
111
175
  }
112
176
  canvasWrap.addEventListener('scroll', scheduleViewSave);
177
+ // На выгрузке страницы: немедленно перенесём scroll в metadata + flush
178
+ // save СРАЗУ (минуя debounce). Без этого юзер скроллил, закрывал таб через
179
+ // 200мс — viewSaveTimer не успевал отстреляться. Используем 'pagehide'
180
+ // (срабатывает на close-tab/navigate AND back-forward-cache), плюс
181
+ // 'visibilitychange' с 'hidden' (более надёжно на mobile/system-sleep).
182
+ function _flushViewSave() {
183
+ if (!state.currentBoard) return;
184
+ clearTimeout(viewSaveTimer);
185
+ _updateViewFromScroll();
186
+ // Прямой синхронный вызов сохранения вместо scheduleSave (которое
187
+ // ставит ещё один 300ms timer и тоже может не успеть на unload).
188
+ clearTimeout(saveTimer); saveTimer = null;
189
+ _runSaveNow().catch(() => {});
190
+ }
191
+ window.addEventListener('pagehide', _flushViewSave);
192
+ window.addEventListener('beforeunload', _flushViewSave);
193
+ document.addEventListener('visibilitychange', () => {
194
+ if (document.visibilityState === 'hidden') _flushViewSave();
195
+ });
196
+ // Глобальный flush — зовётся из closeProject ПЕРЕД обнулением state.currentBoard.
197
+ // Без этого pending viewSaveTimer/saveTimer срабатывали уже после того как
198
+ // state.currentBoard стал null → write скипался → позиция терялась.
199
+ // (closeProject уже вызывал `window.flushScheduledSave` через typeof-guard,
200
+ // но функция нигде не была определена — guard скрывал баг.)
201
+ window.flushScheduledSave = async function () {
202
+ if (!state.currentBoard) return;
203
+ clearTimeout(viewSaveTimer);
204
+ clearTimeout(saveTimer); saveTimer = null;
205
+ _updateViewFromScroll();
206
+ await _runSaveNow();
207
+ };
113
208
 
114
209
  // =================== Скачивание аудио на выбранной скорости (ffmpeg.wasm atempo) ===================
115
210
  let ffmpegInstance = null;
@@ -748,6 +843,17 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
748
843
  if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
749
844
  refs.push(pr);
750
845
  }
846
+ // Incoming-edge refs: для text-нод OpenRouter chat принимает image-входы.
847
+ // Юзер: «если в ноду 'входит' нода-картинка/видео — подавай её как реф».
848
+ // Видео для text-gen бесполезно (модели не глотают video frames в chat
849
+ // API), поэтому фильтруем по image.
850
+ if (typeof gatherIncomingMediaRefs === 'function') {
851
+ for (const ir of gatherIncomingMediaRefs(node.id)) {
852
+ if (ir.type !== 'image') continue;
853
+ if (refs.some(r => r.file === ir.file && r.boardHandle === ir.boardHandle)) continue;
854
+ refs.push(ir);
855
+ }
856
+ }
751
857
  const missing = refs.filter(r => !r.file || r.status === 'generating');
752
858
  if (missing.length) {
753
859
  alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
@@ -780,6 +886,17 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
780
886
  if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
781
887
  refs.push(pr);
782
888
  }
889
+ // Incoming-edge refs (image/video → image/video нода). Юзер: «если в
890
+ // ноду 'входит' несколько других нод-картинок или 'нод-видео' —
891
+ // подавай их тоже как референсы». Граф уже выражает связь, не нужно
892
+ // дублировать через @name в промпте. Берём ВСЕ incoming media-источники
893
+ // — не только pending-anchor, как для NEW ноды.
894
+ if (typeof gatherIncomingMediaRefs === 'function') {
895
+ for (const ir of gatherIncomingMediaRefs(node.id)) {
896
+ if (refs.some(r => r.file === ir.file && r.boardHandle === ir.boardHandle)) continue;
897
+ refs.push(ir);
898
+ }
899
+ }
783
900
  const missing = refs.filter(r => !r.file || r.status === 'generating');
784
901
  if (missing.length) {
785
902
  alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
@@ -552,23 +552,15 @@ async function renderNodeBody(node, body) {
552
552
  playRow.appendChild(playBtn);
553
553
  body.appendChild(playRow);
554
554
  }
555
- // Описание (опц.) — для image/video нод. Юзер: «под каждой нодой
556
- // картинки или видео должно быть возможно добавить описание».
557
- // Хранится в node.description (scene.json). Показываем всегда (даже
558
- // пустым) клик в textarea = начать писать.
559
- if (node.type === 'image' || node.type === 'video') {
560
- const desc = document.createElement('textarea');
561
- desc.className = 'node-description';
562
- desc.placeholder = 'Описание (опц.)';
563
- desc.value = node.description || '';
564
- desc.rows = 1;
565
- desc.addEventListener('mousedown', e => e.stopPropagation());
566
- desc.addEventListener('input', () => {
567
- node.description = desc.value;
568
- scheduleSave();
569
- });
570
- body.appendChild(desc);
571
- }
555
+ // Описание (опц.) — read-only текст под медиа. Редактирование через
556
+ // ПКМ «📝 Изменить описание…» (открывает большую textarea-модалку).
557
+ // ВАЖНО: рендерится НЕ внутри .node (image/video задают фиксированный
558
+ // aspect-ratio высоты, всё ниже обрезается content-visibility:auto
559
+ // и paint-containment). Юзер: «давай попробуем показать описание
560
+ // ВНЕ этой ноды, но так чтобы оно с ней перемещалось». Описание —
561
+ // отдельный sibling-элемент `.node-description-outside` на canvas'е,
562
+ // позиционируется в (node.x, node.y+node.height+4), width=node.width.
563
+ // Перемещается через ensureNodeDescriptionEl (вызывается из drag/resize).
572
564
  } else if (node.type === 'image' && !node.file && (node.generated?.rawPrompt || node.generated?.prompt)) {
573
565
  // Image-нода с промптом, но без файла (ещё не генерилась).
574
566
  // Кнопка ▶ Запустить — сверху (без декоративной 🖼-иконки и
@@ -589,6 +581,48 @@ async function renderNodeBody(node, body) {
589
581
  }
590
582
  }
591
583
 
584
+ // Описание image/video-ноды отрисовывается ВНЕ ноды (sibling на canvas'е).
585
+ // Раньше пробовали инлайн внутри .node — но image/video задают высоту через
586
+ // aspect-ratio + paint-containment у .node клипит всё что вне bbox. Юзер:
587
+ // «попробуем показать описание ВНЕ этой ноды, но так чтобы оно с ней
588
+ // перемещалось… совпадало с ней по ширине, а высота — сколько займет текст».
589
+ // Идемпотентно: пересоздаёт DOM, если описание изменилось/добавилось/удалилось.
590
+ function ensureNodeDescriptionEl(node) {
591
+ if (!node) return null;
592
+ const canvasEl = document.getElementById('canvas');
593
+ if (!canvasEl) return null;
594
+ const text = (node.type === 'image' || node.type === 'video') ? (node.description || '').trim() : '';
595
+ const sel = `.node-description-outside[data-desc-for="${node.id}"]`;
596
+ let el = canvasEl.querySelector(sel);
597
+ if (!text) {
598
+ // Описания нет — удаляем sidecar.
599
+ if (el) el.remove();
600
+ return null;
601
+ }
602
+ if (!el) {
603
+ el = document.createElement('div');
604
+ el.className = 'node-description-outside';
605
+ el.dataset.descFor = node.id;
606
+ canvasEl.appendChild(el);
607
+ }
608
+ if (el.textContent !== text) el.textContent = text;
609
+ positionNodeDescriptionEl(node, el);
610
+ return el;
611
+ }
612
+ function positionNodeDescriptionEl(node, el) {
613
+ if (!el) el = document.querySelector(`.node-description-outside[data-desc-for="${node.id}"]`);
614
+ if (!el) return;
615
+ const w = node.width || 280;
616
+ const h = node.height || 200;
617
+ const GAP = 4;
618
+ el.style.left = node.x + 'px';
619
+ el.style.top = (node.y + h + GAP) + 'px';
620
+ el.style.width = w + 'px';
621
+ }
622
+ function removeNodeDescriptionEl(nodeId) {
623
+ const el = document.querySelector(`.node-description-outside[data-desc-for="${nodeId}"]`);
624
+ if (el) el.remove();
625
+ }
592
626
  // Подгоняет node.height под natural-aspect медиа (image/video). Сохраняет
593
627
  // текущую node.width, пересчитывает height = width * (natH/natW) + chrome.
594
628
  // Без этого после генерации (или history-undo на ноду с другим aspect'ом)
@@ -612,6 +646,7 @@ function fitNodeHeightToMedia(node, nodeEl, mediaEl) {
612
646
  node.height = newH;
613
647
  nodeEl.style.height = newH + 'px';
614
648
  if (typeof renderConnections === 'function') renderConnections();
649
+ positionNodeDescriptionEl(node);
615
650
  scheduleSave();
616
651
  }
617
652
 
@@ -916,6 +951,8 @@ function attachResize(el, node, handle) {
916
951
  // out, левая для in) считаются от node.x/y/width/height, поэтому при
917
952
  // resize bezier должен следовать за новым размером.
918
953
  renderConnections();
954
+ // Sidecar-описание подстраивается под новую ширину/высоту.
955
+ positionNodeDescriptionEl(node);
919
956
  };
920
957
  const onUp = () => {
921
958
  document.removeEventListener('mousemove', onMove);
@@ -1055,10 +1092,15 @@ function makeDragHandler(el, node) {
1055
1092
  const dxNode = (ev.clientX - startX) / state.zoom;
1056
1093
  const dyNode = (ev.clientY - startY) / state.zoom;
1057
1094
  for (const t of dragTargets) {
1058
- t.node.x = Math.max(0, t.origX + dxNode);
1059
- t.node.y = Math.max(0, t.origY + dyNode);
1095
+ // Раньше клампали в Math.max(0,...) но canvas-frame отрисовывает
1096
+ // отрицательную зону вокруг canvas (padX=2000), и юзер видит её.
1097
+ // Юзер: «нужно позволить сдвигать выше и левее нуля».
1098
+ t.node.x = t.origX + dxNode;
1099
+ t.node.y = t.origY + dyNode;
1060
1100
  t.el.style.left = t.node.x + 'px';
1061
1101
  t.el.style.top = t.node.y + 'px';
1102
+ // Sidecar-описание движется вместе с нодой (тот же x, top чуть ниже).
1103
+ positionNodeDescriptionEl(t.node);
1062
1104
  }
1063
1105
  renderConnections();
1064
1106
  };
@@ -1198,6 +1240,8 @@ async function deleteNode(node, el) {
1198
1240
  renderConnections();
1199
1241
  }
1200
1242
  if (el) el.remove();
1243
+ // Sidecar-описание (если было) — уезжает вместе с нодой.
1244
+ removeNodeDescriptionEl(node.id);
1201
1245
  // Записываем history ПОСЛЕ — но snapshot был снят ДО мутации
1202
1246
  const h = _getHistory();
1203
1247
  if (h) {
@@ -1286,6 +1330,7 @@ async function deleteSelectedNodes() {
1286
1330
  for (const id of ids) {
1287
1331
  const el = canvas.querySelector(`.node[data-id="${id}"]`);
1288
1332
  if (el) el.remove();
1333
+ removeNodeDescriptionEl(id);
1289
1334
  }
1290
1335
  state.selectedNodeIds.clear();
1291
1336
  renderConnections();
package/renderer/state.js CHANGED
@@ -6,6 +6,32 @@
6
6
  // видят друг друга по именам, без import/export. Порядок загрузки
7
7
  // важен: см. <script> теги внизу index.html.
8
8
 
9
+ // === File-логгер (Electron only): дублируем console.* в userData/app.log,
10
+ // чтобы агент-помощник (Claude) мог анализировать историю. На web (без
11
+ // window.appLog) — noop. ====================================================
12
+ (function _hookConsoleToAppLog() {
13
+ if (!window.appLog?.write) return;
14
+ const fmt = a => {
15
+ if (a instanceof Error) return a.stack || a.message || String(a);
16
+ if (typeof a === 'string') return a;
17
+ try { return JSON.stringify(a); } catch { return String(a); }
18
+ };
19
+ const wrap = (orig, level) => (...args) => {
20
+ try { window.appLog.write(level, args.map(fmt).join(' ')); } catch {}
21
+ orig.apply(console, args);
22
+ };
23
+ console.log = wrap(console.log, 'log');
24
+ console.warn = wrap(console.warn, 'warn');
25
+ console.error = wrap(console.error, 'error');
26
+ // Подхватываем uncaught/unhandled — стек туда же.
27
+ window.addEventListener('error', e => {
28
+ try { window.appLog.write('uncaught', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}\n${e.error?.stack || ''}`); } catch {}
29
+ });
30
+ window.addEventListener('unhandledrejection', e => {
31
+ try { window.appLog.write('unhandled', `${e.reason?.message || e.reason}\n${e.reason?.stack || ''}`); } catch {}
32
+ });
33
+ })();
34
+
9
35
  // =================== IndexedDB (хэндл папки фильма) ===================
10
36
  const DB_NAME = 'video-editor';
11
37
  const STORE = 'handles';
@@ -110,18 +110,47 @@
110
110
  width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
111
111
  background: #1a1a1a; border-radius: 8px; padding: 4px;
112
112
  }
113
- /* Описание под image/video нодой. Auto-grow по содержимому. */
114
- .node .node-description {
115
- width: 100%; min-height: 28px; max-height: 120px;
116
- margin-top: 6px; padding: 4px 6px;
113
+ /* Описание под image/video нодой. Раньше пробовали рендерить ВНУТРИ .node,
114
+ но image/video задают фиксированный aspect-ratio высоту, а .node клипит
115
+ overflow (content-visibility:auto + paint-containment) — текст ниже не
116
+ был виден. Теперь описание — отдельный sibling-элемент на canvas'е
117
+ (`.node-description-outside`), позиционируется в (node.x, node.y+h+gap),
118
+ ширина = node.width, высота = auto по контенту. См. ensureNodeDescriptionEl
119
+ в settings.js (вызывается из renderCanvas / drag / resize / ПКМ-меню). */
120
+ .node-description-outside {
121
+ position: absolute;
122
+ padding: 6px 8px;
117
123
  background: #1e1e1e; color: #ccc;
118
- border: 1px solid #333; border-radius: 3px;
119
- font-family: inherit; font-size: 12px; line-height: 1.4;
120
- resize: vertical; box-sizing: border-box;
121
- outline: none;
124
+ border-left: 2px solid #4a6a9a; border-radius: 2px;
125
+ font-size: 12px; line-height: 1.4;
126
+ white-space: pre-wrap; word-wrap: break-word;
127
+ box-sizing: border-box;
128
+ user-select: text; -webkit-user-select: text; cursor: text;
129
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
130
+ z-index: 2;
131
+ pointer-events: auto;
132
+ }
133
+ /* Legacy: оставляем класс для совместимости (вдруг где-то ещё ссылается). */
134
+ .node .node-description { display: none; }
135
+
136
+ /* Большая явная кнопка «Перейти → <имя сцены>» снизу ноды. Видна когда
137
+ node.linkedBoard.name задан (см. refreshNodeLinkBadge). Position:
138
+ absolute, чуть выше нижней границы ноды; перекрывает контент только
139
+ визуально (pointer-events:auto только на кнопке). */
140
+ .node .link-go-btn {
141
+ position: absolute; left: 8px; right: 8px; bottom: 6px;
142
+ padding: 5px 10px; font-size: 11px; font-weight: 500;
143
+ background: #2a3854; color: #aac8e6;
144
+ border: 1px solid #4a6a9a; border-radius: 4px;
145
+ cursor: pointer; text-align: center;
146
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
147
+ transition: background 0.12s;
148
+ z-index: 4;
149
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
122
150
  }
123
- .node .node-description:focus { border-color: #4a6a9a; }
124
- .node .node-description::placeholder { color: #555; }
151
+ .node .link-go-btn:hover { background: #3a5a8a; color: #fff; }
152
+ /* В drawing-нодах (тонкая стрелка) кнопка визуально странная — не показываем. */
153
+ .node.drawing-node .link-go-btn { display: none !important; }
125
154
 
126
155
  /* Link-badge: значок 🔗 на ноде когда у неё есть linkedBoard. Лежит в
127
156
  правом-верхнем углу, кликабельный (= dblclick: открыть target-сцену).
@@ -141,6 +170,44 @@
141
170
  к левому-верху самой bbox'ы. */
142
171
  .node.drawing-node .link-badge { top: 4px; right: 4px; }
143
172
 
173
+ /* Мобильный бургер для template/view-only. По умолчанию скрыт — показывается
174
+ только на узких экранах в view-only-mode (template-просмотр). В edit-режиме
175
+ юзер обычно на десктопе, sidebar нужен постоянно. */
176
+ .kk-mobile-burger {
177
+ display: none; padding: 6px 10px; font-size: 18px; line-height: 1;
178
+ background: transparent; color: #ccc; border: 1px solid #444;
179
+ border-radius: 4px; cursor: pointer;
180
+ }
181
+ .kk-mobile-burger:hover { background: #2a2a2a; color: #fff; }
182
+ @media (max-width: 768px) {
183
+ body.view-only-mode .kk-mobile-burger { display: inline-flex; }
184
+ /* Sidebar в шторку (slide-in). Position:fixed чтобы перекрывать canvas. */
185
+ body.view-only-mode .sidebar {
186
+ position: fixed; left: 0; top: 0; height: 100vh; z-index: 90;
187
+ transform: translateX(-100%); transition: transform 0.2s ease;
188
+ box-shadow: 4px 0 16px rgba(0,0,0,0.5);
189
+ }
190
+ body.view-only-mode.sidebar-open .sidebar { transform: translateX(0); }
191
+ /* Backdrop (затемнение) когда sidebar открыт. */
192
+ body.view-only-mode.sidebar-open::before {
193
+ content: ''; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
194
+ z-index: 89; cursor: pointer;
195
+ }
196
+ /* В template/mobile прячем ВСЁ из toolbar'а кроме бургера —
197
+ у юзера маленький экран, кнопки управления (zoom, banner) занимают
198
+ половину viewport'а. Юзер: «оставь только бургер». */
199
+ body.view-only-mode .toolbar > *:not(.kk-mobile-burger) { display: none !important; }
200
+ /* Сам toolbar тоже минимизируем — без растягивания и фона, чтобы canvas
201
+ занял максимум места. */
202
+ body.view-only-mode .toolbar {
203
+ padding: 4px; min-height: auto; border-bottom: none;
204
+ background: transparent;
205
+ }
206
+ /* Канвас должен принимать pinch-zoom через JS, native scroll и pan
207
+ по 1 пальцу остаются. */
208
+ body.view-only-mode .canvas-wrap { touch-action: pan-x pan-y; }
209
+ }
210
+
144
211
  /* Brand-logo и welcome-logo всегда кликабельны (на главной → ничего,
145
212
  в проекте → возврат на welcome, dblclick → настройки). cursor:pointer
146
213
  ставится и в HTML inline, но в web chatium-минифайер иногда срывает
@@ -528,8 +528,17 @@ async function collectProjectBoards(filmHandle) {
528
528
  await b.handle.getFileHandle('scene.json');
529
529
  // Исключаем scene.json и .thumbnails/ из mediaFiles — они либо
530
530
  // manifest, либо генерятся клиентом.
531
+ // scene.json — manifest, отдельный канал.
532
+ // .thumbnails/ — старая папка миниатюр (legacy).
533
+ // thumbs/ — текущая папка миниатюр (генерятся клиентом
534
+ // через generateThumbnailIfMissing; их НЕ нужно
535
+ // заливать на сервер — получатель сам сгенерит).
536
+ // Без этого фильтра save отправлял 4 frames + 4 thumbs
537
+ // = 8 файлов вместо 4. См. логи.
531
538
  b.mediaFiles = b.mediaFiles.filter(f =>
532
- f.relPath !== 'scene.json' && !f.relPath.startsWith('.thumbnails/'));
539
+ f.relPath !== 'scene.json' &&
540
+ !f.relPath.startsWith('.thumbnails/') &&
541
+ !f.relPath.startsWith('thumbs/'));
533
542
  validBoards.push(b);
534
543
  } catch {}
535
544
  }
package/server.js CHANGED
@@ -139,7 +139,14 @@ async function handleProxy(res, url) {
139
139
  try { u = new URL(target); } catch { return send(res, 400, { error: 'битый url' }); }
140
140
  if (!/^https?:$/.test(u.protocol)) return send(res, 400, { error: 'разрешены только http/https' });
141
141
 
142
- const r = await fetch(target);
142
+ // Auth-aware прокси: некоторые upstream-URL'ы (OpenRouter video content)
143
+ // требуют Bearer-токен. Добавляем здесь чтобы клиентский <video src="...">
144
+ // мог их подгрузить через /api/proxy без обхода CORS+auth в renderer'е.
145
+ const fetchHeaders = {};
146
+ if (u.hostname === 'openrouter.ai' && process.env.OPENROUTER_API_KEY) {
147
+ fetchHeaders.Authorization = 'Bearer ' + process.env.OPENROUTER_API_KEY;
148
+ }
149
+ const r = await fetch(target, { headers: fetchHeaders });
143
150
  if (!r.ok) return send(res, r.status, { error: `upstream ${r.status}` });
144
151
  const headers = {
145
152
  'Content-Type': r.headers.get('content-type') || 'application/octet-stream',