543 lines
25 KiB
JavaScript
543 lines
25 KiB
JavaScript
import { chainCallback, addMiddleClickPan, addWheelPassthrough } from './utility.js';
|
||
const { app } = window.comfyAPI.app;
|
||
const { api } = window.comfyAPI.api;
|
||
|
||
const STYLE_ID = 'kj-batch-preview-style';
|
||
function ensureStyles() {
|
||
if (document.getElementById(STYLE_ID)) return;
|
||
const s = document.createElement('style');
|
||
s.id = STYLE_ID;
|
||
s.textContent = `
|
||
.kj-bp-root { width:100%; height:100%; position:relative; background:#1a1a1a; overflow:hidden; user-select:none; outline:none; }
|
||
.kj-bp-grid { width:100%; height:100%; display:block; cursor:pointer; image-rendering:auto; }
|
||
.kj-bp-detail { position:absolute; inset:0; display:none; background:#0d0d0d; }
|
||
.kj-bp-detail.visible { display:flex; flex-direction:column; }
|
||
.kj-bp-detail-canvas { flex:1 1 auto; min-height:0; width:100%; display:block; }
|
||
.kj-bp-bar { display:flex; align-items:center; justify-content:space-between; padding:4px 8px; background:#222; color:#ddd; font:12px sans-serif; }
|
||
.kj-bp-btn { background:#333; color:#ddd; border:1px solid #444; padding:2px 10px; cursor:pointer; font:12px sans-serif; }
|
||
.kj-bp-btn:hover { background:#444; }
|
||
.kj-bp-btn:disabled { opacity:0.4; cursor:default; }
|
||
.kj-bp-scrubber { position:relative; flex:1 1 auto; height:22px; margin:0 8px; background:#2a2a2a; border:1px solid #444; cursor:ew-resize; user-select:none; touch-action:none; }
|
||
.kj-bp-scrubber-fill { position:absolute; top:0; bottom:0; left:0; width:0; background:#3a5a7a; pointer-events:none; }
|
||
.kj-bp-scrubber-text { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#ddd; font:11px sans-serif; pointer-events:none; text-shadow:0 1px 2px rgba(0,0,0,0.6); }
|
||
.kj-bp-status { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#888; font:12px sans-serif; pointer-events:none; }
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function pickGridLayout(count, aspect, containerW, containerH) {
|
||
let best = { cols: 1, rows: count, cellW: 0, cellH: 0, area: 0 };
|
||
for (let cols = 1; cols <= count; cols++) {
|
||
const rows = Math.ceil(count / cols);
|
||
const cellW = containerW / cols;
|
||
const cellH = containerH / rows;
|
||
const fitW = Math.min(cellW, cellH * aspect);
|
||
const fitH = fitW / aspect;
|
||
const area = fitW * fitH;
|
||
if (area > best.area) best = { cols, rows, cellW, cellH, area, fitW, fitH };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: 'KJNodes.FastPreviewBatch',
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData?.name !== 'FastPreviewBatch') return;
|
||
|
||
chainCallback(nodeType.prototype, 'onNodeCreated', function () {
|
||
ensureStyles();
|
||
this.setSize([520, 520]);
|
||
|
||
const root = document.createElement('div');
|
||
root.className = 'kj-bp-root';
|
||
|
||
const status = document.createElement('div');
|
||
status.className = 'kj-bp-status';
|
||
status.textContent = 'No preview yet';
|
||
root.appendChild(status);
|
||
|
||
const gridCanvas = document.createElement('canvas');
|
||
gridCanvas.className = 'kj-bp-grid';
|
||
gridCanvas.style.display = 'none';
|
||
root.appendChild(gridCanvas);
|
||
|
||
const detail = document.createElement('div');
|
||
detail.className = 'kj-bp-detail';
|
||
const detailCanvas = document.createElement('canvas');
|
||
detailCanvas.className = 'kj-bp-detail-canvas';
|
||
const bar = document.createElement('div');
|
||
bar.className = 'kj-bp-bar';
|
||
const prevBtn = document.createElement('button');
|
||
prevBtn.className = 'kj-bp-btn';
|
||
prevBtn.textContent = '❮';
|
||
const nextBtn = document.createElement('button');
|
||
nextBtn.className = 'kj-bp-btn';
|
||
nextBtn.textContent = '❯';
|
||
const scrubber = document.createElement('div');
|
||
scrubber.className = 'kj-bp-scrubber';
|
||
const scrubberFill = document.createElement('div');
|
||
scrubberFill.className = 'kj-bp-scrubber-fill';
|
||
const scrubberText = document.createElement('span');
|
||
scrubberText.className = 'kj-bp-scrubber-text';
|
||
scrubber.appendChild(scrubberFill);
|
||
scrubber.appendChild(scrubberText);
|
||
const playBtn = document.createElement('button');
|
||
playBtn.className = 'kj-bp-btn';
|
||
playBtn.textContent = '▶';
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'kj-bp-btn';
|
||
closeBtn.textContent = '✕';
|
||
bar.appendChild(playBtn);
|
||
bar.appendChild(prevBtn);
|
||
bar.appendChild(scrubber);
|
||
bar.appendChild(nextBtn);
|
||
bar.appendChild(closeBtn);
|
||
detail.appendChild(detailCanvas);
|
||
detail.appendChild(bar);
|
||
root.appendChild(detail);
|
||
|
||
this.addDOMWidget('fast_preview_batch', 'div', root, { serialize: false });
|
||
|
||
const removeMiddleClickPan = addMiddleClickPan(root);
|
||
addWheelPassthrough(root);
|
||
|
||
const state = {
|
||
video: null,
|
||
strip: null,
|
||
stripCols: 1,
|
||
stripCellW: 0,
|
||
stripCellH: 0,
|
||
frameCount: 0,
|
||
fps: 30,
|
||
thumbW: 0,
|
||
thumbH: 0,
|
||
layout: null,
|
||
gridReady: false,
|
||
currentIndex: 0,
|
||
hoverIndex: -1,
|
||
};
|
||
|
||
const setStatus = (msg) => {
|
||
if (msg) {
|
||
status.textContent = msg;
|
||
status.style.display = 'flex';
|
||
} else {
|
||
status.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
const cleanupVideo = () => {
|
||
if (state.video) {
|
||
try { state.video.pause(); } catch (_) { }
|
||
state.video.removeAttribute('src');
|
||
try { state.video.load(); } catch (_) { }
|
||
if (state.video.parentNode) state.video.parentNode.removeChild(state.video);
|
||
state.video = null;
|
||
}
|
||
};
|
||
|
||
const seekToFrame = (i) => new Promise((resolve) => {
|
||
if (!state.video) return resolve();
|
||
const t = (i + 0.5) / state.fps;
|
||
const onSeeked = () => {
|
||
state.video.removeEventListener('seeked', onSeeked);
|
||
// give the decoder a tick to ensure frame is drawable
|
||
requestAnimationFrame(() => resolve());
|
||
};
|
||
state.video.addEventListener('seeked', onSeeked);
|
||
try {
|
||
state.video.currentTime = t;
|
||
} catch (_) {
|
||
state.video.removeEventListener('seeked', onSeeked);
|
||
resolve();
|
||
}
|
||
});
|
||
|
||
let drawGeneration = 0;
|
||
const updateScrubberUI = () => {
|
||
const t = state.frameCount > 1 ? state.currentIndex / (state.frameCount - 1) : 0;
|
||
scrubberFill.style.width = (t * 100) + '%';
|
||
scrubberText.textContent = `frame ${state.currentIndex + 1} / ${state.frameCount}`;
|
||
prevBtn.disabled = state.currentIndex <= 0;
|
||
nextBtn.disabled = state.currentIndex >= state.frameCount - 1;
|
||
};
|
||
const paintFrameToDetail = () => {
|
||
const v = state.video;
|
||
if (!v) return;
|
||
const w = detailCanvas.clientWidth;
|
||
const h = detailCanvas.clientHeight;
|
||
if (w <= 0 || h <= 0) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const targetW = Math.max(1, Math.floor(w * dpr));
|
||
const targetH = Math.max(1, Math.floor(h * dpr));
|
||
if (detailCanvas.width !== targetW) detailCanvas.width = targetW;
|
||
if (detailCanvas.height !== targetH) detailCanvas.height = targetH;
|
||
const ctx = detailCanvas.getContext('2d');
|
||
ctx.fillStyle = '#0d0d0d';
|
||
ctx.fillRect(0, 0, detailCanvas.width, detailCanvas.height);
|
||
const aspect = state.thumbW / state.thumbH;
|
||
let dw = detailCanvas.width;
|
||
let dh = dw / aspect;
|
||
if (dh > detailCanvas.height) {
|
||
dh = detailCanvas.height;
|
||
dw = dh * aspect;
|
||
}
|
||
const dx = (detailCanvas.width - dw) / 2;
|
||
const dy = (detailCanvas.height - dh) / 2;
|
||
ctx.drawImage(v, dx, dy, dw, dh);
|
||
};
|
||
const drawDetail = async () => {
|
||
if (!state.video || !state.frameCount) return;
|
||
updateScrubberUI();
|
||
const gen = ++drawGeneration;
|
||
await seekToFrame(state.currentIndex);
|
||
if (gen !== drawGeneration) return; // superseded by a newer scrub
|
||
paintFrameToDetail();
|
||
};
|
||
|
||
let playing = false;
|
||
let playHandle = 0;
|
||
const updatePlayBtn = () => { playBtn.textContent = playing ? '⏸' : '▶'; };
|
||
const stopPlayback = () => {
|
||
if (!playing) return;
|
||
playing = false;
|
||
playHandle++;
|
||
if (state.video) { try { state.video.pause(); } catch (_) { } }
|
||
updatePlayBtn();
|
||
};
|
||
const startPlayback = async () => {
|
||
if (!state.video || !state.frameCount || playing) return;
|
||
if (typeof state.video.requestVideoFrameCallback !== 'function') return;
|
||
if (state.currentIndex >= state.frameCount - 1) state.currentIndex = 0;
|
||
drawGeneration++; // invalidate any pending drawDetail
|
||
await seekToFrame(state.currentIndex);
|
||
paintFrameToDetail();
|
||
updateScrubberUI();
|
||
playing = true;
|
||
updatePlayBtn();
|
||
state.video.playbackRate = 1;
|
||
const myHandle = ++playHandle;
|
||
try { await state.video.play(); } catch (_) { stopPlayback(); return; }
|
||
if (myHandle !== playHandle) return;
|
||
const onFrame = (_now, metadata) => {
|
||
if (!playing || myHandle !== playHandle) return;
|
||
const idx = Math.min(state.frameCount - 1,
|
||
Math.floor(metadata.mediaTime * state.fps + 1e-6));
|
||
if (idx !== state.currentIndex) {
|
||
state.currentIndex = idx;
|
||
updateScrubberUI();
|
||
}
|
||
paintFrameToDetail();
|
||
if (idx >= state.frameCount - 1) { stopPlayback(); return; }
|
||
state.video.requestVideoFrameCallback(onFrame);
|
||
};
|
||
state.video.requestVideoFrameCallback(onFrame);
|
||
};
|
||
|
||
// Tiles only change with size or content; hover repaints just blit this cached base
|
||
const gridBase = document.createElement('canvas');
|
||
let gridBaseKey = '';
|
||
const renderGrid = () => {
|
||
if (!state.frameCount || !state.strip) return;
|
||
const containerW = root.clientWidth;
|
||
const containerH = root.clientHeight;
|
||
if (containerW <= 0 || containerH <= 0) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const canvasW = Math.floor(containerW * dpr);
|
||
const canvasH = Math.floor(containerH * dpr);
|
||
const total = state.frameCount;
|
||
const sw = state.stripCellW;
|
||
const sh = state.stripCellH;
|
||
const sCols = state.stripCols;
|
||
const key = canvasW + 'x' + canvasH;
|
||
if (gridBaseKey !== key) {
|
||
gridBaseKey = key;
|
||
state.layout = pickGridLayout(total, state.thumbW / state.thumbH, containerW, containerH);
|
||
gridBase.width = gridCanvas.width = canvasW;
|
||
gridBase.height = gridCanvas.height = canvasH;
|
||
gridCanvas.style.width = containerW + 'px';
|
||
gridCanvas.style.height = containerH + 'px';
|
||
const bctx = gridBase.getContext('2d');
|
||
bctx.fillStyle = '#1a1a1a';
|
||
bctx.fillRect(0, 0, canvasW, canvasH);
|
||
const l = state.layout;
|
||
for (let i = 0; i < total; i++) {
|
||
const dx = (i % l.cols) * l.cellW * dpr + (l.cellW - l.fitW) * dpr / 2;
|
||
const dy = Math.floor(i / l.cols) * l.cellH * dpr + (l.cellH - l.fitH) * dpr / 2;
|
||
bctx.drawImage(state.strip, (i % sCols) * sw, Math.floor(i / sCols) * sh, sw, sh, dx, dy, l.fitW * dpr, l.fitH * dpr);
|
||
}
|
||
}
|
||
const layout = state.layout;
|
||
const cellW = layout.cellW * dpr;
|
||
const cellH = layout.cellH * dpr;
|
||
const fitW = layout.fitW * dpr;
|
||
const fitH = layout.fitH * dpr;
|
||
const ctx = gridCanvas.getContext('2d');
|
||
ctx.drawImage(gridBase, 0, 0);
|
||
if (state.hoverIndex >= 0 && state.hoverIndex < total) {
|
||
const i = state.hoverIndex;
|
||
const sx = (i % sCols) * sw;
|
||
const sy = Math.floor(i / sCols) * sh;
|
||
const dCol = i % layout.cols;
|
||
const dRow = Math.floor(i / layout.cols);
|
||
const cx = dCol * cellW + cellW / 2;
|
||
const cy = dRow * cellH + cellH / 2;
|
||
const scale = 1.15;
|
||
const zw = fitW * scale;
|
||
const zh = fitH * scale;
|
||
let dx = cx - zw / 2;
|
||
let dy = cy - zh / 2;
|
||
dx = Math.max(0, Math.min(gridCanvas.width - zw, dx));
|
||
dy = Math.max(0, Math.min(gridCanvas.height - zh, dy));
|
||
ctx.save();
|
||
ctx.shadowColor = 'rgba(0,0,0,0.65)';
|
||
ctx.shadowBlur = 14 * dpr;
|
||
ctx.drawImage(state.strip, sx, sy, sw, sh, dx, dy, zw, zh);
|
||
ctx.restore();
|
||
ctx.strokeStyle = '#5a8ec4';
|
||
ctx.lineWidth = Math.max(2, 2 * dpr);
|
||
ctx.strokeRect(dx + ctx.lineWidth / 2, dy + ctx.lineWidth / 2, zw - ctx.lineWidth, zh - ctx.lineWidth);
|
||
}
|
||
state.gridReady = true;
|
||
setStatus('');
|
||
gridCanvas.style.display = 'block';
|
||
};
|
||
|
||
const enterDetail = (idx) => {
|
||
state.currentIndex = idx;
|
||
state.hoverIndex = -1; // the pointer is elsewhere by the time the detail view closes
|
||
detail.classList.add('visible');
|
||
requestAnimationFrame(drawDetail);
|
||
};
|
||
const exitDetail = () => {
|
||
stopPlayback();
|
||
detail.classList.remove('visible');
|
||
renderGrid();
|
||
};
|
||
|
||
playBtn.addEventListener('click', () => {
|
||
if (playing) stopPlayback();
|
||
else startPlayback();
|
||
});
|
||
|
||
const cellIndexFromEvent = (e) => {
|
||
if (!state.gridReady || !state.layout) return -1;
|
||
const rect = gridCanvas.getBoundingClientRect();
|
||
if (rect.width <= 0 || rect.height <= 0) return -1;
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const cellW = rect.width / state.layout.cols;
|
||
const cellH = rect.height / state.layout.rows;
|
||
const col = Math.floor(x / cellW);
|
||
const row = Math.floor(y / cellH);
|
||
if (col < 0 || col >= state.layout.cols || row < 0 || row >= state.layout.rows) return -1;
|
||
const idx = row * state.layout.cols + col;
|
||
return (idx >= 0 && idx < state.frameCount) ? idx : -1;
|
||
};
|
||
|
||
gridCanvas.addEventListener('click', (e) => {
|
||
const idx = cellIndexFromEvent(e);
|
||
if (idx >= 0) enterDetail(idx);
|
||
});
|
||
|
||
gridCanvas.addEventListener('pointermove', (e) => {
|
||
const idx = cellIndexFromEvent(e);
|
||
if (idx !== state.hoverIndex) {
|
||
state.hoverIndex = idx;
|
||
renderGrid();
|
||
}
|
||
});
|
||
gridCanvas.addEventListener('pointerleave', () => {
|
||
if (state.hoverIndex !== -1) {
|
||
state.hoverIndex = -1;
|
||
renderGrid();
|
||
}
|
||
});
|
||
|
||
prevBtn.addEventListener('click', () => {
|
||
if (state.currentIndex > 0) {
|
||
stopPlayback();
|
||
state.currentIndex--;
|
||
drawDetail();
|
||
}
|
||
});
|
||
nextBtn.addEventListener('click', () => {
|
||
if (state.currentIndex < state.frameCount - 1) {
|
||
stopPlayback();
|
||
state.currentIndex++;
|
||
drawDetail();
|
||
}
|
||
});
|
||
closeBtn.addEventListener('click', exitDetail);
|
||
|
||
let scrubbing = false;
|
||
const scrubFromEvent = (e) => {
|
||
if (!state.frameCount) return;
|
||
const rect = scrubber.getBoundingClientRect();
|
||
const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
const idx = Math.round(t * (state.frameCount - 1));
|
||
if (idx !== state.currentIndex) {
|
||
state.currentIndex = idx;
|
||
drawDetail();
|
||
}
|
||
};
|
||
scrubber.addEventListener('pointerdown', (e) => {
|
||
if (e.button !== 0) return; // let middle/right click bubble (canvas pan, etc.)
|
||
stopPlayback();
|
||
scrubbing = true;
|
||
scrubber.setPointerCapture(e.pointerId);
|
||
scrubFromEvent(e);
|
||
e.preventDefault();
|
||
});
|
||
scrubber.addEventListener('pointermove', (e) => {
|
||
if (scrubbing) scrubFromEvent(e);
|
||
});
|
||
const endScrub = (e) => {
|
||
if (!scrubbing) return;
|
||
scrubbing = false;
|
||
try { scrubber.releasePointerCapture(e.pointerId); } catch (_) { }
|
||
};
|
||
scrubber.addEventListener('pointerup', endScrub);
|
||
scrubber.addEventListener('pointercancel', endScrub);
|
||
|
||
// Keys go to the preview that was last clicked, until focus moves elsewhere. Left button only:
|
||
// focusing a DOM widget selects its node, which must not happen on a middle-click pan.
|
||
root.tabIndex = -1;
|
||
root.addEventListener('pointerdown', (e) => { if (e.button === 0) root.focus({ preventScroll: true }); });
|
||
root.addEventListener('keydown', (e) => {
|
||
if (!detail.classList.contains('visible')) return;
|
||
if (e.key === 'Escape') { exitDetail(); e.stopPropagation(); }
|
||
else if (e.key === ' ') {
|
||
if (playing) stopPlayback(); else startPlayback();
|
||
e.preventDefault(); e.stopPropagation();
|
||
}
|
||
else if (e.key === 'ArrowLeft' && state.currentIndex > 0) {
|
||
stopPlayback(); state.currentIndex--; drawDetail(); e.stopPropagation();
|
||
} else if (e.key === 'ArrowRight' && state.currentIndex < state.frameCount - 1) {
|
||
stopPlayback(); state.currentIndex++; drawDetail(); e.stopPropagation();
|
||
}
|
||
});
|
||
|
||
const ro = new ResizeObserver(() => {
|
||
if (detail.classList.contains('visible')) {
|
||
// Repaint the decoded frame directly; while the video is still loading, wait for the seek
|
||
if (state.video && state.video.readyState >= 2) paintFrameToDetail();
|
||
else drawDetail();
|
||
} else if (state.strip) {
|
||
renderGrid();
|
||
}
|
||
});
|
||
ro.observe(root);
|
||
|
||
const buildViewUrl = (filename, subfolder, type) => {
|
||
const params = new URLSearchParams({
|
||
filename: filename,
|
||
subfolder: subfolder || '',
|
||
type: type || 'temp',
|
||
t: String(Date.now()),
|
||
});
|
||
return api.apiURL(`/view?${params.toString()}`);
|
||
};
|
||
|
||
// Overlapping loads (workflow restore + a fresh run) must not let an older strip land last
|
||
let loadGeneration = 0;
|
||
const loadFromOutput = async (info) => {
|
||
const gen = ++loadGeneration;
|
||
stopPlayback();
|
||
cleanupVideo();
|
||
state.strip = null;
|
||
state.gridReady = false;
|
||
gridBaseKey = '';
|
||
gridCanvas.style.display = 'none';
|
||
setStatus('Loading preview…');
|
||
|
||
state.frameCount = info.frame_count;
|
||
state.fps = info.fps || 30;
|
||
state.thumbW = info.thumb_w;
|
||
state.thumbH = info.thumb_h;
|
||
state.stripCols = info.strip_cols || 1;
|
||
state.stripCellW = info.strip_cell_w || info.thumb_w;
|
||
state.stripCellH = info.strip_cell_h || info.thumb_h;
|
||
if (state.currentIndex >= state.frameCount || state.currentIndex < 0) {
|
||
state.currentIndex = 0;
|
||
}
|
||
|
||
const v = document.createElement('video');
|
||
v.muted = true;
|
||
v.playsInline = true;
|
||
v.preload = 'auto';
|
||
v.style.position = 'absolute';
|
||
v.style.left = '-99999px';
|
||
v.style.width = '1px';
|
||
v.style.height = '1px';
|
||
document.body.appendChild(v);
|
||
state.video = v;
|
||
|
||
// Video loads in parallel for the detail view; failure is non-fatal
|
||
// for the grid (strip handles that). Caught to avoid unhandled-rejection.
|
||
new Promise((resolve, reject) => {
|
||
v.addEventListener('loadeddata', resolve, { once: true });
|
||
v.addEventListener('error', () => {
|
||
if (state.video === v) cleanupVideo();
|
||
reject(new Error('video load failed'));
|
||
}, { once: true });
|
||
v.src = buildViewUrl(info.filename, info.subfolder, info.type);
|
||
}).catch(() => { });
|
||
|
||
let img;
|
||
try {
|
||
img = await new Promise((resolve, reject) => {
|
||
const im = new Image();
|
||
im.onload = () => resolve(im);
|
||
im.onerror = () => reject(new Error('strip load failed'));
|
||
im.src = buildViewUrl(info.strip_filename, info.subfolder, info.type);
|
||
});
|
||
} catch (err) {
|
||
if (gen !== loadGeneration) return;
|
||
throw err;
|
||
}
|
||
if (gen !== loadGeneration) return;
|
||
state.strip = img;
|
||
renderGrid();
|
||
if (detail.classList.contains('visible')) {
|
||
// Refresh detail canvas with the new video; drawDetail awaits seek,
|
||
// which itself queues until the new video element finishes loading.
|
||
drawDetail();
|
||
}
|
||
};
|
||
|
||
const tryLoad = (info, onMissing) => {
|
||
loadFromOutput(info).catch((err) => {
|
||
console.warn('FastPreviewBatch load failed', err);
|
||
if (onMissing) onMissing();
|
||
else setStatus('Preview failed: ' + (err && err.message || err));
|
||
});
|
||
};
|
||
|
||
chainCallback(this, 'onExecuted', function (output) {
|
||
if (!output || !output.kj_batch_preview) return;
|
||
const info = output.kj_batch_preview[0];
|
||
if (!info) return;
|
||
this.properties = this.properties || {};
|
||
this.properties.kj_batch_preview = info;
|
||
tryLoad(info);
|
||
});
|
||
|
||
chainCallback(this, 'onConfigure', function () {
|
||
const info = this.properties && this.properties.kj_batch_preview;
|
||
if (!info) return;
|
||
tryLoad(info, () => {
|
||
setStatus('Preview unavailable (re-run to regenerate)');
|
||
delete this.properties.kj_batch_preview;
|
||
});
|
||
});
|
||
|
||
chainCallback(this, 'onRemoved', function () {
|
||
ro.disconnect();
|
||
removeMiddleClickPan();
|
||
cleanupVideo();
|
||
});
|
||
});
|
||
},
|
||
});
|