kingkont 0.6.0 → 0.6.2

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/index.html +367 -87
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,3 +82,4 @@ npm install
82
82
  npm start # обычный dev-запуск Electron
83
83
  npm run dist # собрать .dmg (mac) / .exe (win) / .AppImage (linux)
84
84
  ```
85
+ # kingkont
package/index.html CHANGED
@@ -200,18 +200,26 @@
200
200
 
201
201
  .canvas-wrap {
202
202
  flex: 1; position: relative; overflow: auto; background: #181818;
203
+ /* Точечный паттерн фиксированного размера: при зуме НЕ ререндерим весь фон.
204
+ Раньше background-size = 24px * --zoom — каждый wheel-tick инвалидировал
205
+ весь видимый фон, GPU-коммит занимал сотни мс. */
203
206
  background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
204
- background-size: calc(24px * var(--zoom, 1)) calc(24px * var(--zoom, 1));
205
- --zoom: 1;
207
+ background-size: 24px 24px;
206
208
  }
207
209
  .canvas-frame {
208
210
  position: relative;
209
- width: calc(6000px * var(--zoom, 1));
210
- height: calc(4000px * var(--zoom, 1));
211
+ width: 6000px; height: 4000px;
211
212
  }
212
213
  .canvas {
213
214
  position: absolute; left: 0; top: 0; width: 6000px; height: 4000px;
214
- transform: scale(var(--zoom, 1)); transform-origin: 0 0;
215
+ transform-origin: 0 0;
216
+ /* `will-change: transform` — постоянная промоция в композитный слой.
217
+ Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms
218
+ коммиты), НО зум через transform идёт через GPU без layout/paint
219
+ на main-thread. Без will-change зум форсит full re-raster и layout
220
+ на каждый wheel-tick (видели 751ms Layout + 2900ms commits в трейсе).
221
+ Dynamic will-change (toggle on/off) — ещё хуже из-за promotion churn. */
222
+ will-change: transform;
215
223
  }
216
224
  .canvas-wrap.drag-over::after {
217
225
  content: 'Отпусти, чтобы импортировать';
@@ -225,11 +233,20 @@
225
233
  .node {
226
234
  position: absolute; width: 280px; background: #2a2a2a;
227
235
  border: 1px solid #444; border-radius: 8px;
228
- box-shadow: 0 4px 12px rgba(0,0,0,0.4); user-select: none;
236
+ box-shadow: 0 2px 6px rgba(0,0,0,0.5); user-select: none;
229
237
  display: flex; flex-direction: column;
230
- transition: opacity 0.12s ease, box-shadow 0.12s ease;
231
- /* Stacking context: anchor/name-row остаются внутри ноды и не лезут
232
- поверх соседей. z-order между нодамипо DOM order (drag поднимает). */
238
+ /* `content-visibility: auto` пропускает layout/style/paint для offscreen-нод
239
+ (на холсте 6000×4000 видимы единицы из сотен это огромная экономия).
240
+ С будет работать паре `contain-intrinsic-size`Chrome использует это
241
+ вместо реального измерения для skip-нод.
242
+ НЕ ставим явный `contain: paint` или просто `contain: layout style` —
243
+ первый плодит paint-property-tree (PrePaint >1s на зуме), второй не
244
+ изолирует размер и Chrome всё равно гоняет layout-walk через все ноды
245
+ в каждом frame (482ms layout с dirty=4 из 1446 в трейсе).
246
+ `isolation: isolate` — стекинг-контекст для .anchor (z-index:5),
247
+ чтобы он не пробивал поверх соседних нод. */
248
+ content-visibility: auto;
249
+ contain-intrinsic-size: 280px 200px;
233
250
  isolation: isolate;
234
251
  }
235
252
  .node.dragging-to-timeline {
@@ -480,7 +497,10 @@
480
497
  .content-row { display: flex; flex: 1; min-height: 0; overflow: hidden; }
481
498
  /* === Зафиксированная панель превью на правой стороне === */
482
499
  :root { --preview-w: 420px; --preview-collapsed: 36px; }
483
- body { padding-right: var(--preview-w); transition: padding-right 0.18s ease; }
500
+ /* БЕЗ transition на padding-right/width: оба не композируются,
501
+ анимация прогоняла full-tree layout ~470ms каждый кадр × 11 кадров =
502
+ 1+ секунды затыка при collapse-toggle. Snap-collapse — мгновенно. */
503
+ body { padding-right: var(--preview-w); }
484
504
  body.preview-collapsed { padding-right: var(--preview-collapsed); }
485
505
  .preview-panel {
486
506
  position: fixed; top: 0; right: 0; bottom: 0;
@@ -488,7 +508,6 @@
488
508
  background: #1c1c1c; border-left: 1px solid #333;
489
509
  z-index: 40; overflow: hidden;
490
510
  display: flex; flex-direction: column;
491
- transition: width 0.18s ease;
492
511
  }
493
512
  .preview-panel.collapsed { width: var(--preview-collapsed); }
494
513
  .preview-resize-handle {
@@ -803,6 +822,22 @@
803
822
  }
804
823
  .node.image-node .node-body { padding: 0; overflow: hidden; border-radius: 0 0 8px 8px; }
805
824
  .node.image-node .node-body img { width: 100%; height: auto; display: block; }
825
+ /* Placeholder отображается до lazy-гидрации media — на оффскрин-нодах
826
+ не дёргаем диск/декод. Размером совпадает с типичной картинкой/видео. */
827
+ .media-placeholder {
828
+ display: flex; align-items: center; justify-content: center;
829
+ min-height: 140px; font-size: 32px; opacity: 0.35;
830
+ background: #1e1e1e; border-radius: 4px;
831
+ }
832
+ .node.image-node .media-placeholder,
833
+ .node.video-node .media-placeholder { min-height: 200px; border-radius: 0; }
834
+ /* Thumbnail отображается до загрузки full media — мгновенный визуальный feedback. */
835
+ .media-thumb {
836
+ width: 100%; height: auto; display: block;
837
+ image-rendering: -webkit-optimize-contrast; /* slight crispness for upscaled thumb */
838
+ }
839
+ .node.image-node .media-thumb,
840
+ .node.video-node .media-thumb { display: block; }
806
841
  .node-body textarea {
807
842
  width: 100%; min-height: 100px; background: #1e1e1e; color: #e0e0e0;
808
843
  border: 1px solid #383838; border-radius: 4px; padding: 8px;
@@ -928,7 +963,7 @@
928
963
  <div class="brand">
929
964
  <img src="assets/logo-square.svg" alt="" draggable="false">
930
965
  <div style="min-width:0; flex:1;">
931
- <div class="title">King Kont · Chatium</div>
966
+ <div class="title">KingKont</div>
932
967
  <div class="sub" id="brandSub">Видео-редактор</div>
933
968
  <div class="board" id="brandBoard" style="display:none;"></div>
934
969
  </div>
@@ -965,7 +1000,7 @@
965
1000
  <div class="welcome" id="welcome">
966
1001
  <div class="welcome-inner">
967
1002
  <img class="welcome-logo" src="assets/logo-square.svg" alt="" draggable="false">
968
- <h1 class="welcome-title">King Kont · Chatium</h1>
1003
+ <h1 class="welcome-title">KingKont</h1>
969
1004
  <div class="welcome-sub">Видео-редактор</div>
970
1005
  <button id="welcomeOpen" class="welcome-open primary">Открыть проект</button>
971
1006
  <div class="welcome-recent" id="welcomeRecent" style="display:none;">
@@ -2648,6 +2683,9 @@ async function selectBoard(board) {
2648
2683
  for (const url of _clipURLCache.values()) URL.revokeObjectURL(url);
2649
2684
  _clipURLCache.clear();
2650
2685
  _audioBufferCache.clear();
2686
+ // Thumbnails — тоже revoke предыдущей доски (иначе утечка blob URLs)
2687
+ for (const url of _thumbUrls.values()) URL.revokeObjectURL(url);
2688
+ _thumbUrls.clear();
2651
2689
  // Восстанавливаем playhead из scene.json (если был сохранён)
2652
2690
  const tlMeta = state.currentBoard.metadata.timeline;
2653
2691
  state.playheadTime = (tlMeta && typeof tlMeta.playhead === 'number') ? tlMeta.playhead : 0;
@@ -2662,14 +2700,14 @@ async function selectBoard(board) {
2662
2700
  if (view) {
2663
2701
  if (typeof view.zoom === 'number') {
2664
2702
  state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, view.zoom));
2665
- canvasWrap.style.setProperty('--zoom', state.zoom);
2703
+ applyZoomStyles(state.zoom);
2666
2704
  $('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
2667
2705
  }
2668
2706
  if (typeof view.scrollLeft === 'number') canvasWrap.scrollLeft = view.scrollLeft;
2669
2707
  if (typeof view.scrollTop === 'number') canvasWrap.scrollTop = view.scrollTop;
2670
2708
  } else {
2671
2709
  state.zoom = 1;
2672
- canvasWrap.style.setProperty('--zoom', 1);
2710
+ applyZoomStyles(1);
2673
2711
  $('zoomLabel').textContent = '100%';
2674
2712
  canvasWrap.scrollLeft = 0;
2675
2713
  canvasWrap.scrollTop = 0;
@@ -2729,16 +2767,24 @@ function bezierPath(x1, y1, x2, y2) {
2729
2767
  return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
2730
2768
  }
2731
2769
 
2770
+ // БЕЗ чтения offsetWidth/Height из DOM:
2771
+ // - offsetWidth/Height на content-visibility:auto-элементе ФОРСИТ материализацию
2772
+ // (Chrome обязан посчитать реальные размеры → теряем весь смысл оффскрин-кулинга,
2773
+ // selectBoard 338ms layout с dirty=45 of 784 — все 784 объекта пробуждались)
2774
+ // - Используем кэш в n.width/n.height; обновляется ResizeObserver'ом ниже.
2775
+ // Дефолт по типу — на первый рендер пока размер не измерен.
2776
+ function _defaultH(n) {
2777
+ if (n.type === 'text') return n.height || 120;
2778
+ if (n.type === 'audio') return n.height || 110;
2779
+ if (n.type === 'image') return n.height || 220;
2780
+ if (n.type === 'video') return n.height || 220;
2781
+ return n.height || 80;
2782
+ }
2732
2783
  function nodeAnchorOut(n) {
2733
- const el = canvas.querySelector(`.node[data-id="${n.id}"]`);
2734
- const w = el ? el.offsetWidth : (n.width || 280);
2735
- const h = el ? el.offsetHeight : (n.height || 60);
2736
- return { x: n.x + w, y: n.y + h / 2 };
2784
+ return { x: n.x + (n.width || 280), y: n.y + _defaultH(n) / 2 };
2737
2785
  }
2738
2786
  function nodeAnchorIn(n) {
2739
- const el = canvas.querySelector(`.node[data-id="${n.id}"]`);
2740
- const h = el ? el.offsetHeight : (n.height || 60);
2741
- return { x: n.x, y: n.y + h / 2 };
2787
+ return { x: n.x, y: n.y + _defaultH(n) / 2 };
2742
2788
  }
2743
2789
 
2744
2790
  function renderConnections() {
@@ -3236,6 +3282,9 @@ async function renderNodeBody(node, body) {
3236
3282
  const nodeEl = body.closest('.node');
3237
3283
  nodeEl?.classList.toggle('image-node', node.type === 'image');
3238
3284
  nodeEl?.classList.toggle('video-node', node.type === 'video');
3285
+ // Re-render: снимаем старого hydrate-наблюдателя, иначе он может
3286
+ // повторно стрельнуть на старом placeholder который уже удалён.
3287
+ if (nodeEl) { _mediaHydrationObserver.unobserve(nodeEl); nodeEl.__hydrate = null; }
3239
3288
 
3240
3289
  if (node.type === 'audio') {
3241
3290
  const fname = document.createElement('div');
@@ -3243,70 +3292,138 @@ async function renderNodeBody(node, body) {
3243
3292
  fname.textContent = node.file;
3244
3293
  body.appendChild(fname);
3245
3294
  }
3246
- try {
3247
- let url = state.currentBoard.urls[node.file];
3248
- if (!url) {
3249
- const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
3250
- url = URL.createObjectURL(await fh.getFile());
3251
- state.currentBoard.urls[node.file] = url;
3252
- }
3253
- let media;
3254
- if (node.type === 'image') {
3255
- media = document.createElement('img');
3256
- media.src = url; media.alt = node.file; media.draggable = false;
3257
- } else {
3258
- media = document.createElement(node.type);
3259
- media.src = url;
3260
- if (node.type === 'video') media.controls = true;
3261
- media.preload = 'metadata';
3262
- }
3263
- media.addEventListener('mousedown', e => e.stopPropagation());
3264
- body.appendChild(media);
3265
-
3266
- if (node.type === 'video') {
3267
- const speedRow = document.createElement('div');
3268
- speedRow.className = 'audio-speed';
3269
- const savedRate = parseFloat(localStorage.getItem('mediaPlaybackRate') || '1');
3270
- media.playbackRate = savedRate;
3271
- for (const s of [0.5, 0.75, 1, 1.25, 1.5, 2]) {
3272
- const btn = document.createElement('button');
3273
- btn.textContent = s + '×';
3274
- if (s === savedRate) btn.classList.add('active');
3275
- btn.addEventListener('mousedown', e => e.stopPropagation());
3276
- btn.addEventListener('click', e => {
3277
- e.stopPropagation();
3278
- media.playbackRate = s;
3279
- localStorage.setItem('mediaPlaybackRate', String(s));
3280
- speedRow.querySelectorAll('button').forEach(b => b.classList.remove('active'));
3281
- btn.classList.add('active');
3282
- });
3283
- speedRow.appendChild(btn);
3295
+ // Lazy-инстанциация media через IntersectionObserver.
3296
+ // Раньше при selectBoard для 50 нод синхронно вычитывали 50 файлов с диска
3297
+ // (resolveBoardFile + getFile + createObjectURL) и вставляли <img>/<video>/<audio>
3298
+ // браузер сразу декодировал. Теперь только видимые (+rootMargin для plавного
3299
+ // pre-load) реально материализуются.
3300
+ const placeholder = document.createElement('div');
3301
+ placeholder.className = 'media-placeholder';
3302
+ placeholder.textContent = node.type === 'image' ? '🖼' : node.type === 'video' ? '🎬' : '🎙';
3303
+ body.appendChild(placeholder);
3304
+
3305
+ // Для image/video пробуем показать thumbnail сразу (если он сохранён на диске).
3306
+ // Это асинхронно, но дешёво (5KB jpg vs 2MB исходный) — placeholder ОЧЕНЬ быстро
3307
+ // заменяется на нормальное превью, не дожидаясь IntersectionObserver-а.
3308
+ if ((node.type === 'image' || node.type === 'video') && state.currentBoard) {
3309
+ const bHandle = state.currentBoard.handle;
3310
+ const bKey = state.currentBoard.key;
3311
+ loadThumbnailURL(bHandle, bKey, node.file).then(url => {
3312
+ if (!url) return;
3313
+ if (!placeholder.parentNode) return; // уже заменили на full-media
3314
+ const thumb = document.createElement('img');
3315
+ thumb.src = url;
3316
+ thumb.className = 'media-thumb';
3317
+ thumb.decoding = 'async';
3318
+ thumb.draggable = false;
3319
+ thumb.addEventListener('mousedown', e => e.stopPropagation());
3320
+ placeholder.replaceWith(thumb);
3321
+ // Сохраняем ссылку в nodeEl, чтобы при hydrate заменить на full
3322
+ if (nodeEl) nodeEl.__thumbEl = thumb;
3323
+ });
3324
+ }
3325
+
3326
+ const hydrate = async () => {
3327
+ try {
3328
+ let url = state.currentBoard.urls[node.file];
3329
+ if (!url) {
3330
+ const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
3331
+ url = URL.createObjectURL(await fh.getFile());
3332
+ state.currentBoard.urls[node.file] = url;
3333
+ }
3334
+ let media;
3335
+ if (node.type === 'image') {
3336
+ media = document.createElement('img');
3337
+ media.src = url; media.alt = node.file; media.draggable = false;
3338
+ media.decoding = 'async';
3339
+ } else {
3340
+ media = document.createElement(node.type);
3341
+ media.src = url;
3342
+ if (node.type === 'video') media.controls = true;
3343
+ // preload="none" — заголовки video/audio НЕ читаются пока юзер не запустит play.
3344
+ // Раньше "metadata" грузил chunk для duration/dimensions с каждой ноды на selectBoard.
3345
+ media.preload = 'none';
3346
+ }
3347
+ media.addEventListener('mousedown', e => e.stopPropagation());
3348
+ // Заменяем placeholder ИЛИ ранее показанный thumbnail
3349
+ const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode)
3350
+ ? nodeEl.__thumbEl
3351
+ : placeholder;
3352
+ target.replaceWith(media);
3353
+ if (nodeEl) nodeEl.__thumbEl = null;
3354
+ // В фоне генерируем/обновляем thumbnail для следующего открытия проекта.
3355
+ // Делаем lazy через rAF чтобы не задерживать первое отображение.
3356
+ if (state.currentBoard && (node.type === 'image' || node.type === 'video')) {
3357
+ const bH = state.currentBoard.handle, bK = state.currentBoard.key;
3358
+ requestIdleCallback?.(() => generateThumbnailIfMissing(bH, bK, node, media))
3359
+ ?? setTimeout(() => generateThumbnailIfMissing(bH, bK, node, media), 1000);
3284
3360
  }
3285
- body.appendChild(speedRow);
3361
+ return media;
3362
+ } catch {
3363
+ const m = document.createElement('div');
3364
+ m.className = 'missing';
3365
+ m.textContent = `Файл «${node.file}» не найден`;
3366
+ const target = (nodeEl && nodeEl.__thumbEl && nodeEl.__thumbEl.parentNode) ? nodeEl.__thumbEl : placeholder;
3367
+ target.replaceWith(m);
3368
+ return null;
3286
3369
  }
3287
- if (node.type === 'audio') {
3288
- const playRow = document.createElement('div');
3289
- playRow.style.cssText = 'margin-top: 6px;';
3290
- const playBtn = document.createElement('button');
3291
- playBtn.style.cssText = 'width: 100%; font-size: 14px; padding: 6px;';
3292
- const sync = () => { playBtn.textContent = media.paused ? '▶ Play' : '⏸ Pause'; };
3293
- sync();
3294
- playBtn.addEventListener('mousedown', e => e.stopPropagation());
3295
- playBtn.addEventListener('click', e => {
3370
+ };
3371
+
3372
+ let mediaPromise = null;
3373
+ const ensureMedia = () => mediaPromise || (mediaPromise = hydrate());
3374
+ // Когда .node попадает в (расширенный) viewport гидрируем фоном.
3375
+ // Если юзер сразу нажал play ensureMedia() вызовется через wire-handler.
3376
+ if (nodeEl) {
3377
+ _mediaHydrationObserver.observe(nodeEl);
3378
+ nodeEl.__hydrate = ensureMedia;
3379
+ }
3380
+
3381
+ if (node.type === 'video') {
3382
+ const speedRow = document.createElement('div');
3383
+ speedRow.className = 'audio-speed';
3384
+ const savedRate = parseFloat(localStorage.getItem('mediaPlaybackRate') || '1');
3385
+ for (const s of [0.5, 0.75, 1, 1.25, 1.5, 2]) {
3386
+ const btn = document.createElement('button');
3387
+ btn.textContent = s + '×';
3388
+ if (s === savedRate) btn.classList.add('active');
3389
+ btn.addEventListener('mousedown', e => e.stopPropagation());
3390
+ btn.addEventListener('click', async e => {
3296
3391
  e.stopPropagation();
3297
- if (media.paused) media.play().catch(()=>{}); else media.pause();
3392
+ const media = await ensureMedia();
3393
+ if (!media) return;
3394
+ media.playbackRate = s;
3395
+ localStorage.setItem('mediaPlaybackRate', String(s));
3396
+ speedRow.querySelectorAll('button').forEach(b => b.classList.remove('active'));
3397
+ btn.classList.add('active');
3298
3398
  });
3299
- media.addEventListener('play', sync);
3300
- media.addEventListener('pause', sync);
3301
- media.addEventListener('ended', sync);
3302
- playRow.appendChild(playBtn);
3303
- body.appendChild(playRow);
3399
+ speedRow.appendChild(btn);
3304
3400
  }
3305
- } catch {
3306
- const m = document.createElement('div');
3307
- m.className = 'missing';
3308
- m.textContent = `Файл «${node.file}» не найден`;
3309
- body.appendChild(m);
3401
+ body.appendChild(speedRow);
3402
+ }
3403
+ if (node.type === 'audio') {
3404
+ const playRow = document.createElement('div');
3405
+ playRow.style.cssText = 'margin-top: 6px;';
3406
+ const playBtn = document.createElement('button');
3407
+ playBtn.style.cssText = 'width: 100%; font-size: 14px; padding: 6px;';
3408
+ playBtn.textContent = '▶ Play';
3409
+ playBtn.addEventListener('mousedown', e => e.stopPropagation());
3410
+ playBtn.addEventListener('click', async e => {
3411
+ e.stopPropagation();
3412
+ const media = await ensureMedia();
3413
+ if (!media) return;
3414
+ const sync = () => { playBtn.textContent = media.paused ? '▶ Play' : '⏸ Pause'; };
3415
+ // Wire-up sync только при первом play (lazy).
3416
+ if (!media.__wired) {
3417
+ media.__wired = true;
3418
+ media.addEventListener('play', sync);
3419
+ media.addEventListener('pause', sync);
3420
+ media.addEventListener('ended', sync);
3421
+ }
3422
+ if (media.paused) media.play().catch(()=>{}); else media.pause();
3423
+ sync();
3424
+ });
3425
+ playRow.appendChild(playBtn);
3426
+ body.appendChild(playRow);
3310
3427
  }
3311
3428
  }
3312
3429
  }
@@ -4059,6 +4176,23 @@ async function pasteClipboardNodes() {
4059
4176
  }
4060
4177
 
4061
4178
  // =================== Zoom ===================
4179
+ // Прямые стили вместо CSS-variable: var(--zoom) каскадился через .canvas-frame и .canvas,
4180
+ // заставляя Chrome делать full layout (partialLayout=false) на каждый wheel-tick.
4181
+ const _canvasFrame = $('canvasFrame');
4182
+ // Frame-size меняется ОТЛОЖЕННО (через debounce 200ms): width на canvasFrame —
4183
+ // layout-affecting свойство, его смена на каждом wheel-tick форсила 751ms Layout.
4184
+ // Транформ на canvas идёт через GPU (will-change: transform → composited),
4185
+ // scroll-bounds во время burst могут быть слегка off, на idle подстраиваются.
4186
+ let _frameSizeTimer = null;
4187
+ function applyZoomStyles(z) {
4188
+ canvas.style.transform = `scale(${z})`;
4189
+ clearTimeout(_frameSizeTimer);
4190
+ _frameSizeTimer = setTimeout(() => {
4191
+ _frameSizeTimer = null;
4192
+ _canvasFrame.style.width = (6000 * z) + 'px';
4193
+ _canvasFrame.style.height = (4000 * z) + 'px';
4194
+ }, 200);
4195
+ }
4062
4196
  function applyZoom(nextZoom, anchorClientX, anchorClientY) {
4063
4197
  nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
4064
4198
  if (nextZoom === state.zoom) return;
@@ -4071,19 +4205,30 @@ function applyZoom(nextZoom, anchorClientX, anchorClientY) {
4071
4205
  const contentX = visualX / state.zoom;
4072
4206
  const contentY = visualY / state.zoom;
4073
4207
  state.zoom = nextZoom;
4074
- canvasWrap.style.setProperty('--zoom', nextZoom);
4208
+ applyZoomStyles(nextZoom);
4075
4209
  canvasWrap.scrollLeft = contentX * nextZoom - (ax - rect.left);
4076
4210
  canvasWrap.scrollTop = contentY * nextZoom - (ay - rect.top);
4077
4211
  $('zoomLabel').textContent = Math.round(nextZoom * 100) + '%';
4078
4212
  scheduleViewSave();
4079
4213
  }
4080
4214
 
4215
+ // rAF-троттлинг zoom-через-колесо: трекпад/Magic Mouse шлют wheel @ 60-120Hz,
4216
+ // мы коалесцируем дельты в один applyZoom за кадр — иначе GPU-коммит не успевает.
4217
+ let _pendingZoom = null;
4081
4218
  canvasWrap.addEventListener('wheel', e => {
4082
4219
  // Ctrl/Cmd + wheel ИЛИ тачпад-pinch (Chrome выставляет ctrlKey=true для pinch)
4083
4220
  if (!(e.ctrlKey || e.metaKey)) return;
4084
4221
  e.preventDefault();
4085
- const factor = Math.exp(-e.deltaY * 0.01);
4086
- applyZoom(state.zoom * factor, e.clientX, e.clientY);
4222
+ if (_pendingZoom) {
4223
+ _pendingZoom.factor *= Math.exp(-e.deltaY * 0.01);
4224
+ _pendingZoom.x = e.clientX; _pendingZoom.y = e.clientY;
4225
+ return;
4226
+ }
4227
+ _pendingZoom = { factor: Math.exp(-e.deltaY * 0.01), x: e.clientX, y: e.clientY };
4228
+ requestAnimationFrame(() => {
4229
+ const z = _pendingZoom; _pendingZoom = null;
4230
+ applyZoom(state.zoom * z.factor, z.x, z.y);
4231
+ });
4087
4232
  }, { passive: false });
4088
4233
 
4089
4234
  $('zoomIn').addEventListener('click', () => applyZoom(state.zoom * 1.25));
@@ -4205,6 +4350,113 @@ function findFreeSpot(w = 280, h = 320) {
4205
4350
  return { x: startX, y: startY };
4206
4351
  }
4207
4352
 
4353
+ // =================== Thumbnails ===================
4354
+ // Маленькая jpg-thumbnail для картинки/видео ноды. Хранится в <scene>/thumbs/.
4355
+ // Имя совпадает с исходным (frames/foo.png → thumbs/foo.png.jpg).
4356
+ // При selectBoard сразу показываем thumb как placeholder (мгновенно), full media
4357
+ // догружается lazy. На повторном открытии проекта эффект сильнее всего —
4358
+ // 50 нод × 5KB jpg декодятся миллисекунды против 50 × 2MB исходных.
4359
+
4360
+ const THUMB_W = 320, THUMB_H = 240, THUMB_QUALITY = 0.7;
4361
+
4362
+ // state: пути thumbnails, разрезолвенных в blob URL
4363
+ const _thumbUrls = new Map(); // `${boardKey}::${relPath}` -> objectURL
4364
+
4365
+ function _thumbName(relPath) {
4366
+ // frames/foo.png → foo.png.jpg
4367
+ return relPath.split('/').pop() + '.jpg';
4368
+ }
4369
+
4370
+ async function loadThumbnailURL(boardHandle, boardKey, relPath) {
4371
+ const cacheKey = `${boardKey}::${relPath}`;
4372
+ const cached = _thumbUrls.get(cacheKey);
4373
+ if (cached) return cached;
4374
+ try {
4375
+ const dir = await boardHandle.getDirectoryHandle('thumbs');
4376
+ const fh = await dir.getFileHandle(_thumbName(relPath));
4377
+ const url = URL.createObjectURL(await fh.getFile());
4378
+ _thumbUrls.set(cacheKey, url);
4379
+ return url;
4380
+ } catch { return null; }
4381
+ }
4382
+
4383
+ // Генерирует и сохраняет thumbnail (если ещё нет). Вызывается ПОСЛЕ того как
4384
+ // full-media уже отображается — фоновая работа, не блокирует UI.
4385
+ async function generateThumbnailIfMissing(boardHandle, boardKey, node, mediaEl) {
4386
+ if (!node.file || (node.type !== 'image' && node.type !== 'video')) return;
4387
+ // Уже есть на диске?
4388
+ try {
4389
+ const dir = await boardHandle.getDirectoryHandle('thumbs');
4390
+ await dir.getFileHandle(_thumbName(node.file));
4391
+ return; // уже есть
4392
+ } catch {}
4393
+ try {
4394
+ const c = document.createElement('canvas');
4395
+ c.width = THUMB_W; c.height = THUMB_H;
4396
+ const ctx = c.getContext('2d');
4397
+ ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, THUMB_W, THUMB_H);
4398
+ let srcW, srcH, drawable;
4399
+ if (node.type === 'image') {
4400
+ if (!mediaEl.complete || !mediaEl.naturalWidth) {
4401
+ await new Promise((r, rej) => { mediaEl.addEventListener('load', r, { once: true }); mediaEl.addEventListener('error', rej, { once: true }); });
4402
+ }
4403
+ drawable = mediaEl; srcW = mediaEl.naturalWidth; srcH = mediaEl.naturalHeight;
4404
+ } else {
4405
+ // video: ждём seek на ~0.3s для выбора симпатичного кадра
4406
+ if (mediaEl.readyState < 2) {
4407
+ mediaEl.preload = 'auto';
4408
+ await new Promise((r, rej) => { mediaEl.addEventListener('loadeddata', r, { once: true }); mediaEl.addEventListener('error', rej, { once: true }); setTimeout(rej, 5000); });
4409
+ }
4410
+ try { mediaEl.currentTime = Math.min(0.3, (mediaEl.duration || 1) * 0.1); } catch {}
4411
+ await new Promise(r => mediaEl.addEventListener('seeked', r, { once: true }));
4412
+ drawable = mediaEl; srcW = mediaEl.videoWidth; srcH = mediaEl.videoHeight;
4413
+ }
4414
+ if (!srcW || !srcH) return;
4415
+ // Letterbox fit
4416
+ const scale = Math.min(THUMB_W / srcW, THUMB_H / srcH);
4417
+ const dw = srcW * scale, dh = srcH * scale;
4418
+ ctx.drawImage(drawable, (THUMB_W - dw) / 2, (THUMB_H - dh) / 2, dw, dh);
4419
+ const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', THUMB_QUALITY));
4420
+ if (!blob) return;
4421
+ const dir = await getOrCreateBoardSubdir(boardHandle, 'thumbs');
4422
+ await writeFile(dir, _thumbName(node.file), blob);
4423
+ } catch (e) { /* silent — это best-effort */ }
4424
+ }
4425
+
4426
+ // Lazy media hydration: когда .node заходит в viewport (+ rootMargin запас на
4427
+ // плавный preload), вызывает свой __hydrate() — обычно ставит src на <img>/<video>/<audio>.
4428
+ // Без этого все 50+ медиа-файлов проекта читались с диска синхронно при selectBoard.
4429
+ //
4430
+ // Сериализация через rAF-очередь: если IntersectionObserver сообщит о 12 нодах
4431
+ // одновременно (например, при selectBoard все видимые сразу), мы не запускаем 12
4432
+ // параллельных hydrate'ов — браузер бы выпустил 12× loadedmetadata/canplay events
4433
+ // в один тик, и PrePaint walks paint-tree для всех сразу (видели 454ms в трейсе).
4434
+ // Гидрируем по 2 за rAF-кадр — нагрузка размазывается, паузы заметно меньше.
4435
+ const _hydrationQueue = [];
4436
+ let _hydrationRunning = false;
4437
+ function _drainHydration() {
4438
+ _hydrationRunning = true;
4439
+ // 2 hydrate-задачи за кадр — баланс между скоростью и плавностью.
4440
+ for (let i = 0; i < 2 && _hydrationQueue.length; i++) {
4441
+ const fn = _hydrationQueue.shift();
4442
+ try { fn(); } catch (e) { console.warn("hydrate fail", e); }
4443
+ }
4444
+ if (_hydrationQueue.length) requestAnimationFrame(_drainHydration);
4445
+ else _hydrationRunning = false;
4446
+ }
4447
+ function _enqueueHydration(fn) {
4448
+ _hydrationQueue.push(fn);
4449
+ if (!_hydrationRunning) requestAnimationFrame(_drainHydration);
4450
+ }
4451
+ const _mediaHydrationObserver = new IntersectionObserver((entries) => {
4452
+ for (const e of entries) {
4453
+ if (!e.isIntersecting) continue;
4454
+ _mediaHydrationObserver.unobserve(e.target);
4455
+ const target = e.target;
4456
+ _enqueueHydration(() => target.__hydrate?.());
4457
+ }
4458
+ }, { rootMargin: '400px' });
4459
+
4208
4460
  let saveTimer = null;
4209
4461
  function scheduleSave() {
4210
4462
  if (!state.currentBoard) return;
@@ -6596,6 +6848,23 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
6596
6848
  }
6597
6849
  }
6598
6850
 
6851
+ // Обновляет .state-text внутри node-body не перерисовывая всё тело —
6852
+ // для частых state-only тиков ("submitting (5s)" → "(10s)") DOM-rebuild дорог.
6853
+ function updateNodeStateText(nodeId, stateKey) {
6854
+ const root = canvas.querySelector(`.node[data-id="${nodeId}"] .gen-pending .state-text`);
6855
+ if (!root) return false;
6856
+ const LABELS = {
6857
+ 'uploading-refs': 'Загружаю референсы в KIE...',
6858
+ 'submitting': 'Отправляю задачу...',
6859
+ 'queued': 'В очереди...',
6860
+ 'waiting': 'В очереди...',
6861
+ 'queuing': 'В очереди...',
6862
+ 'generating': 'Модель работает...',
6863
+ };
6864
+ root.textContent = LABELS[stateKey] || (stateKey ? `Статус: ${stateKey}` : 'Генерируется...');
6865
+ return true;
6866
+ }
6867
+
6599
6868
  // Обновление ноды (на любой доске): меняем in-memory state если эта доска текущая,
6600
6869
  // иначе грузим metadata, патчим и пишем; всегда сохраняем на диск; если нода видна — обновляем DOM.
6601
6870
  async function mutateNode(bKey, boardHandle, nodeId, mutator) {
@@ -6604,15 +6873,25 @@ async function mutateNode(bKey, boardHandle, nodeId, mutator) {
6604
6873
  const node = state.currentBoard.metadata.nodes.find(n => n.id === nodeId);
6605
6874
  if (node) {
6606
6875
  const fileBefore = node.file;
6876
+ const statusBefore = node.status;
6877
+ const stateBefore = node.generated?.state;
6607
6878
  mutator(node);
6879
+ const fileChanged = node.file !== fileBefore;
6880
+ const statusChanged = node.status !== statusBefore;
6881
+ const stateOnly = !statusChanged && !fileChanged && node.generated?.state !== stateBefore;
6608
6882
  // Если файл сменился (после регена) — подхватить новый файл в клипах таймлайна
6609
- if (node.file && node.file !== fileBefore) {
6883
+ if (fileChanged && node.file) {
6610
6884
  syncTimelineClipsForNode(node);
6611
6885
  }
6612
6886
  syncHistorySlot(node);
6613
- await saveBoardMetadata(boardHandle, state.currentBoard.metadata);
6887
+ scheduleSave();
6888
+ // Частый кейс: меняется только текст статуса генерации. Точечно обновляем .state-text,
6889
+ // не перестраивая всё тело ноды и не дёргая таймлайн.
6890
+ if (stateOnly && updateNodeStateText(nodeId, node.generated?.state)) {
6891
+ return;
6892
+ }
6614
6893
  await refreshNodeDOM(nodeId);
6615
- refreshTimelineForNode(nodeId);
6894
+ if (fileChanged || statusChanged) refreshTimelineForNode(nodeId);
6616
6895
  return;
6617
6896
  }
6618
6897
  }
@@ -7842,6 +8121,7 @@ function getOrCreateClipMedia(clip, url) {
7842
8121
  if (m) { try { m.src = ''; } catch {} }
7843
8122
  m = clip.type === 'image' ? document.createElement('img') : document.createElement('video');
7844
8123
  m.src = url;
8124
+ if (clip.type === 'image') m.decoding = 'async';
7845
8125
  if (clip.type === 'video') { m.preload = 'metadata'; m.muted = true; m.disablePictureInPicture = true; }
7846
8126
  _clipMediaCache.set(key, m);
7847
8127
  return m;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {