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.
- package/README.md +1 -0
- package/index.html +367 -87
- package/package.json +1 -1
package/README.md
CHANGED
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:
|
|
205
|
-
--zoom: 1;
|
|
207
|
+
background-size: 24px 24px;
|
|
206
208
|
}
|
|
207
209
|
.canvas-frame {
|
|
208
210
|
position: relative;
|
|
209
|
-
width:
|
|
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
|
|
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
|
|
236
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.5); user-select: none;
|
|
229
237
|
display: flex; flex-direction: column;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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">
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4086
|
-
|
|
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 (
|
|
6883
|
+
if (fileChanged && node.file) {
|
|
6610
6884
|
syncTimelineClipsForNode(node);
|
|
6611
6885
|
}
|
|
6612
6886
|
syncHistorySlot(node);
|
|
6613
|
-
|
|
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;
|