/** * FloatingViewer (MFV) - Majoor Floating Viewer * * Lightweight floating panel: drag, CSS resize, 3 modes (Simple / A/B / Side-by-Side), * Live Stream toggle, and mouse-wheel zoom + click-drag pan. * Styled via theme-comfy.css (.mjr-mfv scope). */ import { installFollowerVideoSync } from "./videoSync.js"; import { isModel3DInteractionTarget } from "./model3dRenderer.js"; import { MFV_MODES, MFV_ZOOM_FACTOR, MFV_ZOOM_MAX, MFV_ZOOM_MIN, } from "./floatingViewerConstants.js"; import { buildFloatingViewerMediaElement as _buildMediaEl, canFloatingViewerWheelScroll as _canWheelScrollElement, createFloatingViewerEmptyState as _makeEmptyState, createFloatingViewerSlotLabel as _makeLabel, findFloatingViewerScrollableAncestor as _findScrollableAncestor, getFloatingViewerMediaKind as _mediaKind, pauseFloatingViewerMediaIn as _pauseMediaIn, } from "./floatingViewerMedia.js"; import { applyFloatingViewerSidebarPosition, bindFloatingViewerDocumentUiHandlers, buildFloatingViewerGenInfoDOM, buildFloatingViewerHeader, buildFloatingViewerToolbar, getFloatingViewerGenFields, rebindFloatingViewerControlHandlers, renderFloatingViewer, unbindFloatingViewerDocumentUiHandlers, updateFloatingViewerGenButtonUI, updateFloatingViewerSettingsBtnState, } from "./floatingViewerUi.js"; import { cycleFloatingViewerMode, getFloatingViewerPinnedSlots, loadFloatingViewerPreviewBlob, notifyFloatingViewerModeChanged, revokeFloatingViewerPreviewBlob, setFloatingViewerLiveActive, setFloatingViewerMode, setFloatingViewerNodeStreamActive, setFloatingViewerPreviewActive, updateFloatingViewerModeButtonUI, updateFloatingViewerPinUI, } from "./floatingViewerMode.js"; import { activateFloatingViewerDesktopExpandedFallback, clearFloatingViewerPopoutCloseWatch, fallbackPopoutFloatingViewer, installFloatingViewerPopoutStyles, popInFloatingViewer, popOutFloatingViewer, scheduleFloatingViewerPopInFromPopupClose, setFloatingViewerDesktopExpanded, startFloatingViewerPopoutCloseWatch, tryFloatingViewerElectronPopupFallback, updateFloatingViewerPopoutButtonUI, } from "./floatingViewerPopout.js"; import { bindFloatingViewerPanelInteractions, getFloatingViewerResizeCursor, getFloatingViewerResizeDirectionFromPoint, initFloatingViewerDrag, initFloatingViewerEdgeResize, stopFloatingViewerEdgeResize, } from "./floatingViewerPanel.js"; import { captureFloatingViewerView, drawFloatingViewerGenInfoOverlay, drawFloatingViewerMediaFit, estimateFloatingViewerGenInfoOverlayHeight, } from "./floatingViewerCapture.js"; import { loadFloatingViewerMediaA, loadFloatingViewerMediaPair, loadFloatingViewerMediaQuad, } from "./floatingViewerLoader.js"; import { disposeFloatingViewerProgressBar } from "./floatingViewerProgress.js"; import { WorkflowGraphMapPanel } from "./workflowGraphMap/WorkflowGraphMapPanel.js"; import { buildGenerationSectionState } from "../../vue/components/panel/sidebar/generationSectionState.js"; function _hasSimplePlayerControls(mediaEl: any) { try { return ( !!mediaEl?.classList?.contains("mjr-mfv-simple-player") || !!mediaEl?.classList?.contains("mjr-mfv-player-host") || !!mediaEl?.querySelector?.(".mjr-video-controls, .mjr-mfv-simple-player-controls") ); } catch (e: any) { console.debug?.(e); return false; } } export { MFV_MODES } from "./floatingViewerConstants.js"; let _mfvInstanceSeq = 0; export class FloatingViewer { [key: string]: any; constructor({ controller = null } = {}) { this._instanceId = ++_mfvInstanceSeq; this._controller = controller && typeof controller === "object" ? { ...(controller as Record) } : null; this.element = null; this.isVisible = false; this._contentEl = null; this._genSidebarEl = null; this._closeBtn = null; this._modeBtn = null; this._pinGroup = null; this._pinBtns = null; this._liveBtn = null; this._genBtn = null; this._genDropdown = null; this._genSidebarEnabled = true; this._captureBtn = null; this._genInfoSelections = new Set([ "prompt", "seed", "model", "lora", "sampler", "scheduler", "cfg", "step", "genTime", ]); this._mode = MFV_MODES.SIMPLE; this._mediaA = null; this._mediaB = null; this._mediaC = null; this._mediaD = null; this._pinnedSlots = new Set(); this._abDividerX = 0.5; // 0..1 // Persisted media playback state - intentionally NOT reset on navigation so // mute/speed survive moving between assets with the arrow keys. this._mfvMuted = false; this._mfvPlaybackRate = 1; // Pan/zoom state this._zoom = 1; this._panX = 0; this._panY = 0; this._panzoomAC = null; // AbortController for event cleanup this._dragging = false; this._compareSyncAC = null; // AbortController for toolbar/header button click listeners (NM-1). // Aborted in dispose() so listeners are cleaned up without needing named references. this._btnAC = null; // Generation counter: incremented on every loadMediaA/loadMediaPair call so // stale async metadata enrichment results can be discarded (NM-2). this._refreshGen = 0; // Pop-out state: external window reference and button. this._popoutWindow = null; this._popoutBtn = null; this._isPopped = false; this._desktopExpanded = false; this._desktopExpandRestore = null; this._desktopPopoutUnsupported = false; this._popoutCloseHandler = null; this._popoutKeydownHandler = null; this._popoutCloseTimer = null; this._popoutRestoreGuard = false; // Preview stream state: button ref + last blob URL for cleanup. this._previewBtn = null; this._previewBlobUrl = null; this._previewActive = false; // Node stream state: button ref + currently selected node overlay. this._nodeStreamBtn = null; this._nodeStreamActive = false; /** @type {{ nodeId: string, classType: string, title?: string } | null} */ this._nodeStreamSelection = null; this._nodeStreamOverlayEl = null; // Master AbortController for document-level UI handlers (e.g. click-outside). // Aborted in dispose() to guarantee all listeners are removed atomically. this._docAC = new AbortController(); // AbortController for pop-out window listeners (beforeunload, keydown, etc.). this._popoutAC = null; // Panel-level listeners and edge-resize state. this._panelAC = new AbortController(); this._resizeState = null; this._titleId = `mjr-mfv-title-${this._instanceId}`; this._genDropdownId = `mjr-mfv-gen-dropdown-${this._instanceId}`; this._progressEl = null; this._progressNodesEl = null; this._progressStepsEl = null; this._progressTextEl = null; this._mediaProgressEl = null; this._mediaProgressTextEl = null; this._progressUpdateHandler = null; this._progressCurrentNodeId = null; this._docClickHost = null; this._handleDocClick = null; this._mediaControlHandles = []; this._layoutObserver = null; this._channel = "rgb"; this._exposureEV = 0; this._gridMode = 0; this._overlayMaskEnabled = false; this._overlayMaskOpacity = 0.65; this._overlayFormat = "image"; this._graphMapPanel = new WorkflowGraphMapPanel({ large: true }); } _dispatchControllerAction(methodName: any, fallbackEventType: any) { try { const handler = this._controller?.[methodName]; if (typeof handler === "function") { return handler(); } } catch (e: any) { console.debug?.(e); } if (!fallbackEventType) return undefined; try { window.dispatchEvent(new Event(fallbackEventType)); } catch (e: any) { console.debug?.(e); } return undefined; } _forwardKeydownToController(event: any) { let forwardedEvent: KeyboardEvent | null = null; try { const KeyboardEventCtor = (typeof window !== "undefined" && (window as any).KeyboardEvent) || (typeof globalThis !== "undefined" && (globalThis as any).KeyboardEvent); if (typeof KeyboardEventCtor !== "function") { throw new Error("KeyboardEvent unavailable"); } forwardedEvent = new KeyboardEventCtor("keydown", { key: event?.key, code: event?.code, keyCode: event?.keyCode, ctrlKey: event?.ctrlKey, shiftKey: event?.shiftKey, altKey: event?.altKey, metaKey: event?.metaKey, repeat: Boolean(event?.repeat), bubbles: true, cancelable: true, }); const dispatched = window.dispatchEvent(forwardedEvent); if (!dispatched || forwardedEvent.defaultPrevented) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); } return; } catch (e: any) { console.debug?.(e); } try { const handler = this._controller?.handleForwardedKeydown; if (typeof handler === "function") { handler(event); } } catch (e: any) { console.debug?.(e); } } // -- Build DOM ------------------------------------------------------------- render() { return renderFloatingViewer(this); } _buildHeader() { return buildFloatingViewerHeader(this); } _buildToolbar() { return buildFloatingViewerToolbar(this); } _rebindControlHandlers() { return rebindFloatingViewerControlHandlers(this); } _updateSettingsBtnState(active: any) { return updateFloatingViewerSettingsBtnState(this, active); } _applySidebarPosition() { return applyFloatingViewerSidebarPosition(this); } refreshSidebar() { this._sidebar?.refresh(); } _resetGenDropdownForCurrentDocument() { return undefined; } _bindDocumentUiHandlers() { return bindFloatingViewerDocumentUiHandlers(this); } _unbindDocumentUiHandlers() { return unbindFloatingViewerDocumentUiHandlers(this); } _isGenDropdownOpen() { return false; } _openGenDropdown() { return undefined; } _closeGenDropdown() { return undefined; } _updateGenBtnUI() { return updateFloatingViewerGenButtonUI(this); } _buildGenDropdown() { return null; } _getGenFields(fileData: any) { return getFloatingViewerGenFields(this, fileData); } _buildGenInfoDOM(fileData: any) { return buildFloatingViewerGenInfoDOM(this, fileData); } _notifyModeChanged() { return notifyFloatingViewerModeChanged(this); } _cycleMode() { return cycleFloatingViewerMode(this); } setMode(mode: any) { return setFloatingViewerMode(this, mode); } getPinnedSlots() { return getFloatingViewerPinnedSlots(this); } _updatePinUI() { return updateFloatingViewerPinUI(this); } _updateModeBtnUI() { return updateFloatingViewerModeButtonUI(this); } setLiveActive(active: any) { return setFloatingViewerLiveActive(this, active); } setPreviewActive(active: any) { return setFloatingViewerPreviewActive(this, active); } loadPreviewBlob(blob: any, opts = {}) { return loadFloatingViewerPreviewBlob(this, blob, opts); } _revokePreviewBlob() { return revokeFloatingViewerPreviewBlob(this); } setNodeStreamActive(active: any) { return setFloatingViewerNodeStreamActive(this, active); } /** * Update the "currently streamed node" overlay. * Pass `null` to hide the overlay (no node selected). * The overlay is purely informational and independent of media rendering: * if the selected node has no streamable preview, the existing media stays * but the overlay is still shown. * @param {{ nodeId: string|number, classType?: string, title?: string } | null} selection */ setNodeStreamSelection(selection: any) { if (selection && (selection.nodeId != null || selection.classType)) { this._nodeStreamSelection = { nodeId: String(selection.nodeId ?? ""), classType: String(selection.classType || ""), title: selection.title ? String(selection.title) : "", }; } else { this._nodeStreamSelection = null; } this._updateNodeStreamOverlay(); } _updateNodeStreamOverlay() { const host = this._contentEl; if (!host) return; const sel = this._nodeStreamSelection; if (!sel) { if (this._nodeStreamOverlayEl) { this._nodeStreamOverlayEl.remove(); this._nodeStreamOverlayEl = null; } return; } if (!this._nodeStreamOverlayEl || !this._nodeStreamOverlayEl.isConnected) { const el = document.createElement("div"); el.className = "mjr-mfv-node-overlay"; el.setAttribute("aria-live", "polite"); this._nodeStreamOverlayEl = el; } if (this._nodeStreamOverlayEl.parentNode !== host) { host.appendChild(this._nodeStreamOverlayEl); } const idPart = sel.nodeId ? `#${sel.nodeId}` : ""; const classPart = sel.classType || "Node"; const titlePart = sel.title && sel.title !== sel.classType ? ` - ${sel.title}` : ""; this._nodeStreamOverlayEl.textContent = `${idPart} - ${classPart}${titlePart}`.trim(); } loadMediaA(fileData: any, { autoMode = false } = {}) { return loadFloatingViewerMediaA(this, fileData, { autoMode }); } /** * Load two assets for compare modes. * Auto-switches from SIMPLE -> AB on first call. */ loadMediaPair(a: any, b: any) { return loadFloatingViewerMediaPair(this, a, b); } /** * Load up to 4 assets for grid compare mode. * Auto-switches to GRID mode if not already. */ loadMediaQuad(a: any, b: any, c: any, d: any) { return loadFloatingViewerMediaQuad(this, a, b, c, d); } /** Apply current zoom+pan state to all media elements (img/video only - divider/overlays unaffected). */ _applyTransform() { if (!this._contentEl) return; const z = Math.max(MFV_ZOOM_MIN, Math.min(MFV_ZOOM_MAX, this._zoom)); const vw = this._contentEl.clientWidth || 0; const vh = this._contentEl.clientHeight || 0; const maxX = Math.max(0, ((z - 1) * vw) / 2); const maxY = Math.max(0, ((z - 1) * vh) / 2); this._panX = Math.max(-maxX, Math.min(maxX, this._panX)); this._panY = Math.max(-maxY, Math.min(maxY, this._panY)); const t = `translate(${this._panX}px,${this._panY}px) scale(${z})`; for (const el of this._contentEl.querySelectorAll(".mjr-mfv-media")) { if (el?._mjrDisableViewerTransform) continue; el.style.transform = t; el.style.transformOrigin = "center"; } // Cursor feedback - use CSS classes this._contentEl.classList.remove("mjr-mfv-content--grab", "mjr-mfv-content--grabbing"); if (z > 1.01) { this._contentEl.classList.add( this._dragging ? "mjr-mfv-content--grabbing" : "mjr-mfv-content--grab", ); } this._applyMediaToneControls(); this._redrawOverlayGuides(); } _ensureToneFilterDefs() { if (this._toneFilterDefsEl?.isConnected) return this._toneFilterDefsEl; const svgNs = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNs, "svg"); svg.setAttribute("aria-hidden", "true"); svg.style.position = "absolute"; svg.style.width = "0"; svg.style.height = "0"; svg.style.pointerEvents = "none"; const defs = document.createElementNS(svgNs, "defs"); const filters = [ ["mjr-mfv-ch-r", "1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"], ["mjr-mfv-ch-g", "0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 1 0"], ["mjr-mfv-ch-b", "0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 1 0"], ["mjr-mfv-ch-a", "0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0"], [ "mjr-mfv-ch-l", "0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0", ], ]; for (const [id, values] of filters) { const filter = document.createElementNS(svgNs, "filter"); filter.setAttribute("id", id); const matrix = document.createElementNS(svgNs, "feColorMatrix"); matrix.setAttribute("type", "matrix"); matrix.setAttribute("values", values); filter.appendChild(matrix); defs.appendChild(filter); } svg.appendChild(defs); this.element?.appendChild(svg); this._toneFilterDefsEl = svg; return svg; } _applyMediaToneControls() { this._ensureToneFilterDefs(); if (!this._contentEl) return; const channel = String(this._channel || "rgb"); const exposureScale = Math.pow(2, Number(this._exposureEV) || 0); const channelFilter = channel === "rgb" ? "" : `url(#mjr-mfv-ch-${channel})`; const brightnessFilter = Math.abs(exposureScale - 1) < 0.0001 ? "" : `brightness(${exposureScale})`; const filterValue = [channelFilter, brightnessFilter].filter(Boolean).join(" ").trim(); const mediaEls = this._contentEl.querySelectorAll?.(".mjr-mfv-media") || []; for (const el of mediaEls) { try { el.style.filter = filterValue || ""; } catch (e: any) { console.debug?.(e); } } } _getOverlayAspect(format: any, mediaEl: any, panelRect: any) { try { const f = String(format || "image"); if (f === "image") { const nw = Number(mediaEl?.videoWidth) || Number(mediaEl?.naturalWidth) || Number(panelRect?.width) || 1; const nh = Number(mediaEl?.videoHeight) || Number(mediaEl?.naturalHeight) || Number(panelRect?.height) || 1; const a = nw / nh; return Number.isFinite(a) && a > 0 ? a : 1; } if (f === "16:9") return 16 / 9; if (f === "9:16") return 9 / 16; if (f === "1:1") return 1; if (f === "4:3") return 4 / 3; if (f === "2.39") return 2.39; } catch (e: any) { console.debug?.(e); } return 1; } _fitAspectInBox(boxW: any, boxH: any, aspect: any) { try { const bw = Number(boxW) || 0; const bh = Number(boxH) || 0; const a = Number(aspect) || 1; if (!(bw > 0 && bh > 0 && a > 0)) return { x: 0, y: 0, w: bw, h: bh }; const boxA = bw / bh; let w = bw; let h = bh; if (a >= boxA) { h = bw / a; } else { w = bh * a; } return { x: (bw - w) / 2, y: (bh - h) / 2, w, h }; } catch (e: any) { console.debug?.(e); return { x: 0, y: 0, w: Number(boxW) || 0, h: Number(boxH) || 0 }; } } _drawMaskOutside(ctx: any, canvas: any, rects: any, alpha: any) { try { const a = Math.max(0, Math.min(0.92, Number(alpha) || 0)); if (!(a > 0)) return; ctx.save(); ctx.fillStyle = `rgba(0,0,0,${a})`; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation = "destination-out"; for (const r of rects) { if (!r || !(r.w > 1 && r.h > 1)) continue; ctx.fillRect(r.x, r.y, r.w, r.h); } ctx.restore(); } catch (e: any) { console.debug?.(e); } } _redrawOverlayGuides() { const canvas = this._overlayCanvas; const host = this._contentEl; if (!canvas || !host) return; const ctx = canvas.getContext?.("2d"); if (!ctx) return; const dpr = Math.max(1, Math.min(3, Number(window.devicePixelRatio) || 1)); const cssW = host.clientWidth || 0; const cssH = host.clientHeight || 0; canvas.width = Math.max(1, Math.floor(cssW * dpr)); canvas.height = Math.max(1, Math.floor(cssH * dpr)); canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; ctx.clearRect(0, 0, canvas.width, canvas.height); if (!(this._gridMode || this._overlayMaskEnabled)) return; const hostRect = host.getBoundingClientRect?.(); if (!hostRect) return; const panels = Array.from( host.querySelectorAll?.( ".mjr-mfv-simple-container, .mjr-mfv-side-panel, .mjr-mfv-grid-cell, .mjr-mfv-ab-layer", ) || [], ); const panelEls = panels.length ? panels : [host]; const formatRects = []; for (const panelEl of panelEls) { const mediaEl = panelEl.querySelector?.(".mjr-mfv-media"); if (!mediaEl) continue; const panelRect = panelEl.getBoundingClientRect?.(); if (!panelRect?.width || !panelRect?.height) continue; const baseW = Number(panelRect.width) || 0; const baseH = Number(panelRect.height) || 0; const aspect = this._getOverlayAspect(this._overlayFormat, mediaEl, panelRect); const fit = this._fitAspectInBox(baseW, baseH, aspect); const centerX = panelRect.left - hostRect.left + baseW / 2; const centerY = panelRect.top - hostRect.top + baseH / 2; const z = Math.max(0.1, Math.min(16, Number(this._zoom) || 1)); const rectCss = { x: centerX + fit.x * z - (baseW * z) / 2 + (Number(this._panX) || 0), y: centerY + fit.y * z - (baseH * z) / 2 + (Number(this._panY) || 0), w: fit.w * z, h: fit.h * z, }; formatRects.push({ x: rectCss.x * dpr, y: rectCss.y * dpr, w: rectCss.w * dpr, h: rectCss.h * dpr, }); } if (!formatRects.length) return; if (this._overlayMaskEnabled) { this._drawMaskOutside(ctx, canvas, formatRects, this._overlayMaskOpacity); ctx.save(); ctx.setLineDash?.([Math.max(2, 4 * dpr), Math.max(2, 3 * dpr)]); ctx.strokeStyle = "rgba(255,255,255,0.22)"; ctx.lineWidth = Math.max(1, Math.floor(dpr)); for (const r of formatRects) { ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); } ctx.restore(); } if (this._mode !== MFV_MODES.SIMPLE || !this._gridMode) return; const rect = formatRects[0]; if (!rect) return; ctx.save(); ctx.translate(rect.x, rect.y); ctx.strokeStyle = "rgba(255,255,255,0.22)"; ctx.lineWidth = Math.max(2, Math.round(1.25 * dpr)); const drawLine = (x1: any, y1: any, x2: any, y2: any) => { ctx.beginPath(); ctx.moveTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5); ctx.lineTo(Math.round(x2) + 0.5, Math.round(y2) + 0.5); ctx.stroke(); }; if (this._gridMode === 1) { drawLine(rect.w / 3, 0, rect.w / 3, rect.h); drawLine((2 * rect.w) / 3, 0, (2 * rect.w) / 3, rect.h); drawLine(0, rect.h / 3, rect.w, rect.h / 3); drawLine(0, (2 * rect.h) / 3, rect.w, (2 * rect.h) / 3); } else if (this._gridMode === 2) { drawLine(rect.w / 2, 0, rect.w / 2, rect.h); drawLine(0, rect.h / 2, rect.w, rect.h / 2); } else if (this._gridMode === 3) { ctx.strokeRect( rect.w * 0.1 + 0.5, rect.h * 0.1 + 0.5, rect.w * 0.8 - 1, rect.h * 0.8 - 1, ); ctx.strokeRect( rect.w * 0.05 + 0.5, rect.h * 0.05 + 0.5, rect.w * 0.9 - 1, rect.h * 0.9 - 1, ); } ctx.restore(); } /** * Set zoom, optionally centered at (clientX, clientY). * Keeps the image point under the cursor stationary. */ _setMfvZoom(next: any, clientX: any, clientY: any) { const prev = Math.max(MFV_ZOOM_MIN, Math.min(MFV_ZOOM_MAX, this._zoom)); const z = Math.max(MFV_ZOOM_MIN, Math.min(MFV_ZOOM_MAX, Number(next) || 1)); if (clientX != null && clientY != null && this._contentEl) { const r = z / prev; const rect = this._contentEl.getBoundingClientRect(); const ux = clientX - (rect.left + rect.width / 2); const uy = clientY - (rect.top + rect.height / 2); this._panX = this._panX * r + (1 - r) * ux; this._panY = this._panY * r + (1 - r) * uy; } this._zoom = z; // Snap back to exact fit to avoid drift. if (Math.abs(z - 1) < 0.001) { this._zoom = 1; this._panX = 0; this._panY = 0; } this._applyTransform(); } /** Reset zoom and pan to the default 1:1 fit. Called when new media is loaded. */ _resetMfvZoom() { this._zoom = 1; this._panX = 0; this._panY = 0; this._applyTransform(); } _bindLayoutObserver() { this._unbindLayoutObserver(); const target = this._contentEl; if (!target || typeof ResizeObserver === "undefined") return; try { this._layoutObserver = new ResizeObserver(() => { this._applyTransform(); }); this._layoutObserver.observe(target); } catch (e: any) { console.debug?.(e); this._layoutObserver = null; } } _unbindLayoutObserver() { try { this._layoutObserver?.disconnect?.(); } catch (e: any) { console.debug?.(e); } this._layoutObserver = null; } /** Bind wheel + pointer events to the clip viewport element. */ _initPanZoom(contentEl: any) { this._destroyPanZoom(); if (!contentEl) return; this._panzoomAC = new AbortController(); const sig = { signal: this._panzoomAC.signal }; // Wheel -> zoom centered at cursor contentEl.addEventListener( "wheel", (e: any) => { if (e.target?.closest?.("audio")) return; if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")) return; if (isModel3DInteractionTarget(e.target)) return; const scrollableAncestor = _findScrollableAncestor(e.target, contentEl); if ( scrollableAncestor && _canWheelScrollElement( scrollableAncestor, Number(e.deltaX || 0), Number(e.deltaY || 0), ) ) { return; } e.preventDefault(); const delta = e.deltaY || e.deltaX || 0; const factor = 1 - delta * MFV_ZOOM_FACTOR; this._setMfvZoom(this._zoom * factor, e.clientX, e.clientY); }, { ...sig, passive: false }, ); // Pointer drag -> pan (left or middle button, when zoomed in) let panActive = false; let startX = 0, startY = 0, startPanX = 0, startPanY = 0; contentEl.addEventListener( "pointerdown", (e: any) => { if (e.button !== 0 && e.button !== 1) return; if (this._zoom <= 1.01) return; // Let native video controls and the AB divider handle their own events. if (e.target?.closest?.("video")) return; if (e.target?.closest?.("audio")) return; if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")) return; if (e.target?.closest?.(".mjr-mfv-ab-divider")) return; if (isModel3DInteractionTarget(e.target)) return; e.preventDefault(); panActive = true; this._dragging = true; startX = e.clientX; startY = e.clientY; startPanX = this._panX; startPanY = this._panY; try { contentEl.setPointerCapture(e.pointerId); } catch (e: any) { console.debug?.(e); } this._applyTransform(); }, sig, ); contentEl.addEventListener( "pointermove", (e: any) => { if (!panActive) return; this._panX = startPanX + (e.clientX - startX); this._panY = startPanY + (e.clientY - startY); this._applyTransform(); }, sig, ); const endPan = (e: any) => { if (!panActive) return; panActive = false; this._dragging = false; try { contentEl.releasePointerCapture(e.pointerId); } catch (e: any) { console.debug?.(e); } this._applyTransform(); }; contentEl.addEventListener("pointerup", endPan, sig); contentEl.addEventListener("pointercancel", endPan, sig); // Double-click -> zoom to 4x at cursor, or reset to fit contentEl.addEventListener( "dblclick", (e: any) => { if (e.target?.closest?.("video")) return; if (e.target?.closest?.("audio")) return; if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")) return; if (isModel3DInteractionTarget(e.target)) return; const isNearFit = Math.abs(this._zoom - 1) < 0.05; this._setMfvZoom(isNearFit ? Math.min(4, this._zoom * 4) : 1, e.clientX, e.clientY); }, sig, ); } /** Remove all pan/zoom event listeners. */ _destroyPanZoom() { try { this._panzoomAC?.abort(); } catch (e: any) { console.debug?.(e); } this._panzoomAC = null; this._dragging = false; } _destroyCompareSync() { try { this._compareSyncAC?.abort?.(); } catch (e: any) { console.debug?.(e); } this._compareSyncAC = null; } _destroyMediaControls() { const handles = Array.isArray(this._mediaControlHandles) ? this._mediaControlHandles : []; for (const handle of handles) { try { handle?.destroy?.(); } catch (e: any) { console.debug?.(e); } } this._mediaControlHandles = []; } _trackMediaControls(rootEl: any) { try { const handle = rootEl?._mjrMediaControlsHandle || null; if (handle?.destroy) this._mediaControlHandles.push(handle); } catch (e: any) { console.debug?.(e); } return rootEl; } // Keep mute/speed in sync as the user changes them on the primary media // element, so the next navigated-to asset can be built with the same values. _bindMfvPersistence(rootEl: any) { try { const mediaEl = rootEl?.matches?.("video, audio") ? rootEl : rootEl?.querySelector?.("video, audio"); if (!mediaEl) return rootEl; mediaEl.addEventListener("volumechange", () => { try { this._mfvMuted = !!mediaEl.muted; } catch (e: any) { console.debug?.(e); } }); mediaEl.addEventListener("ratechange", () => { try { const rate = Number(mediaEl.playbackRate); if (Number.isFinite(rate) && rate > 0) this._mfvPlaybackRate = rate; } catch (e: any) { console.debug?.(e); } }); } catch (e: any) { console.debug?.(e); } return rootEl; } _initCompareSync() { this._destroyCompareSync(); if (!this._contentEl) return; if (this._mode === MFV_MODES.SIMPLE) return; try { const playables = Array.from(this._contentEl.querySelectorAll("video, audio")); if (playables.length < 2) return; const leader = playables[0] || null; const followers = playables.slice(1); if (!leader || !followers.length) return; this._compareSyncAC = installFollowerVideoSync(leader, followers, { threshold: 0.08 }); } catch (e: any) { console.debug?.(e); } } // -- Render ---------------------------------------------------------------- _refresh() { if (!this._contentEl) return; this._sidebar?.setAsset?.(this._mediaA || null); // Tear down previous panzoom bindings before clearing DOM. this._destroyPanZoom(); this._destroyCompareSync(); this._destroyMediaControls(); _pauseMediaIn(this._contentEl); const overlayCanvas = this._overlayCanvas || null; this._contentEl.replaceChildren(); this._contentEl.style.overflow = "hidden"; switch (this._mode) { case MFV_MODES.SIMPLE: this._renderSimple(); break; case MFV_MODES.AB: this._renderAB(); break; case MFV_MODES.SIDE: this._renderSide(); break; case MFV_MODES.GRID: this._renderGrid(); break; case MFV_MODES.GRAPH: this._renderGraphMap(); break; } if (overlayCanvas) { this._contentEl.appendChild(overlayCanvas); } // The Node Stream overlay lives inside the content area (top-left) // and must be re-attached after each refresh since replaceChildren() // above removes it. if (this._nodeStreamSelection) { this._updateNodeStreamOverlay(); } if (this._mediaProgressEl) { this._contentEl.appendChild(this._mediaProgressEl); } this._applyMediaToneControls(); this._applyTransform(); if (this._mode !== MFV_MODES.GRAPH) { this._initPanZoom(this._contentEl); } this._initCompareSync(); this._renderGenInfoSidebar(); } _renderGenInfoSidebar() { const sidebar = this._genSidebarEl; if (!sidebar) return; sidebar.replaceChildren(); const slots = this._getGenInfoSidebarSlots(); if ( this._mode === MFV_MODES.GRAPH || !this._genSidebarEnabled || slots.length === 0 ) { sidebar.classList.remove("open"); sidebar.setAttribute("hidden", ""); this._updateGenBtnUI?.(); return; } const title = document.createElement("div"); title.className = "mjr-mfv-gen-sidebar-title"; title.textContent = "Gen Info"; sidebar.appendChild(title); let rendered = 0; for (const slot of slots) { if (_mediaKind(slot.media) === "audio") continue; const richContent = this._buildGenInfoSidebarContent(slot.media); if (!richContent) continue; const section = document.createElement("section"); section.className = "mjr-mfv-gen-sidebar-section"; const heading = document.createElement("div"); heading.className = "mjr-mfv-gen-sidebar-heading"; heading.textContent = slots.length > 1 ? `Asset ${slot.label}` : "Current Asset"; const body = document.createElement("div"); body.className = "mjr-mfv-gen-sidebar-body"; body.appendChild(richContent); section.appendChild(heading); section.appendChild(body); sidebar.appendChild(section); rendered += 1; } if (!rendered) { sidebar.classList.remove("open"); sidebar.setAttribute("hidden", ""); return; } sidebar.removeAttribute("hidden"); sidebar.classList.add("open"); this._updateGenBtnUI?.(); } _getGenInfoSidebarSlots() { const slotA = { media: this._mediaA, label: "A" }; const slotB = { media: this._mediaB, label: "B" }; const slotC = { media: this._mediaC, label: "C" }; const slotD = { media: this._mediaD, label: "D" }; const slots = this._mode === MFV_MODES.GRID ? [slotA, slotB, slotC, slotD] : this._mode === MFV_MODES.AB || this._mode === MFV_MODES.SIDE ? [slotA, slotB] : [slotA]; return slots.filter((slot: any) => slot.media); } _buildGenInfoSidebarContent(asset: any) { const genTime = (this._getGenFields(asset) as any)?.genTime || ""; let state: any = null; try { state = buildGenerationSectionState(asset); } catch (e: any) { console.debug?.(e); } if (state && state.kind !== "empty") { const root = document.createDocumentFragment(); if (genTime) { root.appendChild(this._buildGenTimeBadge(genTime)); } if (state.workflowType) { root.appendChild( this._buildGenInfoCard({ title: "Workflow", accent: "#2196F3", value: [state.workflowLabel || state.workflowType, state.workflowBadge] .filter(Boolean) .join(" | "), compact: true, }), ); } if (state.positivePrompt) { root.appendChild( this._buildGenInfoCard({ title: "Positive Prompt", accent: "#4CAF50", value: state.positivePrompt, multiline: true, }), ); } if (state.negativePrompt) { root.appendChild( this._buildGenInfoCard({ title: "Negative Prompt", accent: "#F44336", value: state.negativePrompt, multiline: true, }), ); } for (const tab of state.promptTabs || []) { const value = [tab.positive, tab.negative ? `Negative:\n${tab.negative}` : ""] .filter(Boolean) .join("\n\n"); root.appendChild( this._buildGenInfoCard({ title: tab.label || "Prompt", accent: "#4CAF50", value, multiline: true, }), ); } if (state.ideogram) { this._appendIdeogramGenInfoCards(root, state.ideogram); } const modelBranches = (state.branchCards || []).filter( (item: any) => item?.modelFields?.length || item?.loras?.length, ); const samplingBranches = (state.branchCards || []).filter( (item: any) => item?.samplingFields?.length, ); if (modelBranches.length) { const card = this._buildGenInfoBranchGroupsCard( "Models", "#9C27B0", modelBranches, "model", ); if (card) root.appendChild(card); } else if (state.modelFields?.length) { const card = this._buildGenInfoFieldsCard( "Model & LoRA", "#9C27B0", state.modelFields, ); if (card) root.appendChild(card); } if (!modelBranches.length) for (const group of state.modelGroups || []) { const fields = [ { label: "UNet", value: group.model || "-" }, ...(group.loras || []).map((value: any, index: any) => ({ label: index === 0 ? "LoRA" : `LoRA ${index + 1}`, value, })), ]; const card = this._buildGenInfoFieldsCard( group.label || "Model Branch", "#AB47BC", fields, ); if (card) root.appendChild(card); } if (samplingBranches.length) { const card = this._buildGenInfoBranchGroupsCard( "Sampling", "#FF9800", samplingBranches, "sampling", ); if (card) root.appendChild(card); } else if (state.pipelineTabs?.length) { for (const tab of state.pipelineTabs) { const card = this._buildGenInfoFieldsCard( tab.label || "Generation Pipeline", "#FF9800", tab.fields || [], ); if (card) root.appendChild(card); } } else if (state.samplingFields?.length) { const card = this._buildGenInfoFieldsCard("Sampling", "#FF9800", state.samplingFields); if (card) root.appendChild(card); } for (const module of state.moduleBlocks || []) { const key = String(module?.key || "").toLowerCase(); if (key === "workflow_engine") continue; const card = this._buildGenInfoFieldsCard( module.title || "Generation Module", module.accent || "#2196F3", module.fields || [], ); if (card) root.appendChild(card); } if (state.seed !== null && state.seed !== undefined && state.seed !== "") { root.appendChild( this._buildGenInfoCard({ title: "Seed", accent: "#E91E63", value: String(state.seed), seed: true, }), ); } if (state.mediaOnlyMessage) { root.appendChild( this._buildGenInfoCard({ title: "Generation Data", accent: "#9E9E9E", value: state.mediaOnlyMessage, multiline: true, }), ); } if (root.childNodes.length) return root; } const fallback = this._buildGenInfoDOM(asset); if (!genTime) return fallback; const root = document.createDocumentFragment(); root.appendChild(this._buildGenTimeBadge(genTime)); if (fallback) root.appendChild(fallback); return root; } _buildGenTimeBadge(value: any) { const badge = document.createElement("div"); badge.className = "mjr-mfv-gen-time-badge"; this._bindGenInfoCopy(badge, () => String(value || "")); const label = document.createElement("span"); label.className = "mjr-mfv-gen-time-label"; label.textContent = "Generation Time"; const time = document.createElement("span"); time.className = "mjr-mfv-gen-time-value"; time.textContent = String(value || ""); badge.appendChild(label); badge.appendChild(time); return badge; } _buildGenInfoCard({ title, accent, value, multiline = false, compact = false, seed = false }: { title: any; accent: any; value: any; multiline?: any; compact?: any; seed?: any }) { const card = document.createElement("div"); card.className = `mjr-mfv-gen-card${seed ? " mjr-mfv-gen-card--seed" : ""}${compact ? " mjr-mfv-gen-card--compact" : ""}`; const titleKey = String(title || "") .toLowerCase() .replace(/&/g, "and") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (titleKey) card.classList.add(`mjr-mfv-gen-card--${titleKey}`); card.style.setProperty("--mjr-mfv-gen-accent", accent || "#2196F3"); this._bindGenInfoCopy(card, () => String(value ?? "")); const heading = document.createElement("div"); heading.className = "mjr-mfv-gen-card-title"; heading.textContent = title || ""; const content = document.createElement("div"); content.className = `mjr-mfv-gen-card-value${multiline ? " is-multiline" : ""}${compact ? " is-compact" : ""}`; content.textContent = String(value ?? ""); card.appendChild(heading); card.appendChild(content); return card; } _buildGenInfoFieldsCard(title: any, accent: any, fields: any) { const card = this._buildGenInfoCard({ title, accent, value: "" }); this._bindGenInfoCopy(card, () => (fields || []) .map((field: any) => { const label = String(field?.label || "").trim(); const text = String(field?.value ?? "").trim(); return label && text && text !== "-" ? `${label}: ${text}` : ""; }) .filter(Boolean) .join("\n"), ); const value = card.querySelector(".mjr-mfv-gen-card-value") as any; if (!value) return null; value.replaceChildren(); value.classList.add("is-fields"); for (const field of fields || []) { const label = String(field?.label || "").trim(); const text = String(field?.value ?? "").trim(); if (!label || !text || text === "-") continue; const row = document.createElement("div"); row.className = "mjr-mfv-gen-field"; const key = document.createElement("span"); key.className = "mjr-mfv-gen-field-label"; key.textContent = label; const val = document.createElement("span"); val.className = "mjr-mfv-gen-field-value"; val.textContent = text; row.appendChild(key); row.appendChild(val); value.appendChild(row); } return value.childNodes.length ? card : null; } _appendIdeogramGenInfoCards(root: any, ideogram: any) { if (!root || !ideogram) return; if (ideogram.highLevelDescription) { root.appendChild( this._buildGenInfoCard({ title: "High Level Description", accent: "#4CAF50", value: ideogram.highLevelDescription, multiline: true, }), ); } if (ideogram.background) { root.appendChild( this._buildGenInfoCard({ title: "Background", accent: "#2196F3", value: ideogram.background, multiline: true, }), ); } for (const element of ideogram.elements || []) { const value = [ element?.description, element?.text ? `Text: ${element.text}` : "", element?.bbox ? `bbox ${element.bbox}` : "", ] .filter(Boolean) .join("\n"); if (!value) continue; root.appendChild( this._buildGenInfoCard({ title: element.label || "Element", accent: "#FFB300", value, multiline: true, }), ); } if (ideogram.json) { root.appendChild( this._buildGenInfoCard({ title: "JSON sent to text encoder", accent: "#FFB300", value: ideogram.json, multiline: true, }), ); } } _buildGenInfoBranchGroupsCard(title: any, accent: any, branches: any, kind: any) { const card = this._buildGenInfoCard({ title, accent, value: "" }); this._bindGenInfoCopy(card, () => (branches || []) .map((branch: any) => { const lines = [String(branch?.label || "").trim()]; const fields = kind === "sampling" ? branch?.samplingFields || [] : branch?.modelFields || []; for (const field of fields) { const label = String(field?.label || "").trim(); const text = String(field?.value ?? "").trim(); if (label && text && text !== "-") lines.push(`${label}: ${text}`); } if (kind !== "sampling") { for (const lora of branch?.loras || []) { const text = String(lora ?? "").trim(); if (text) lines.push(`LoRA: ${text}`); } } return lines.filter(Boolean).join("\n"); }) .filter(Boolean) .join("\n\n"), ); const value = card.querySelector(".mjr-mfv-gen-card-value") as any; if (!value) return null; value.replaceChildren(); value.classList.add("is-fields", "is-branch-groups"); for (const branch of branches || []) { const group = document.createElement("div"); group.className = "mjr-mfv-gen-branch-group"; const heading = document.createElement("div"); heading.className = "mjr-mfv-gen-branch-title"; heading.textContent = String(branch?.label || "").trim() || title || ""; group.appendChild(heading); const fields = kind === "sampling" ? branch?.samplingFields || [] : branch?.modelFields || []; for (const field of fields) { const row = this._buildGenInfoFieldRow(field); if (row) group.appendChild(row); } if (kind !== "sampling") { for (const lora of branch?.loras || []) { const row = this._buildGenInfoFieldRow({ label: "LoRA", value: lora }); if (row) group.appendChild(row); } } if (group.childNodes.length > 1) value.appendChild(group); } return value.childNodes.length ? card : null; } _buildGenInfoFieldRow(field: any) { const label = String(field?.label || "").trim(); const text = String(field?.value ?? "").trim(); if (!label || !text || text === "-") return null; const row = document.createElement("div"); row.className = "mjr-mfv-gen-field"; const key = document.createElement("span"); key.className = "mjr-mfv-gen-field-label"; key.textContent = label; const val = document.createElement("span"); val.className = "mjr-mfv-gen-field-value"; val.textContent = text; row.appendChild(key); row.appendChild(val); return row; } _bindGenInfoCopy(el: any, resolveText: any) { if (!el || typeof resolveText !== "function") return; el.title = "Click to copy"; el.addEventListener("click", async (event: any) => { event.stopPropagation(); const text = String(resolveText() || "").trim(); if (!text) return; try { await navigator.clipboard?.writeText?.(text); el.classList.add("mjr-mfv-gen-copy-flash"); setTimeout(() => el.classList.remove("mjr-mfv-gen-copy-flash"), 450); } catch (e: any) { console.debug?.(e); } }); } _renderGraphMap() { this._contentEl.style.overflow = "hidden"; this._graphMapPanel.setAsset(this._mediaA || null); this._contentEl.appendChild(this._graphMapPanel.el); this._graphMapPanel.refresh(); } _renderSimple() { if (!this._mediaA) { this._contentEl.appendChild(_makeEmptyState()); return; } const rawMediaEl = _buildMediaEl(this._mediaA, { initialMuted: this._mfvMuted, initialPlaybackRate: this._mfvPlaybackRate, }); const mediaEl = this._trackMediaControls?.(rawMediaEl) || rawMediaEl; this._bindMfvPersistence?.(mediaEl); if (!mediaEl) { this._contentEl.appendChild(_makeEmptyState("Could not load media")); return; } const wrap = document.createElement("div"); wrap.className = "mjr-mfv-simple-container"; wrap.appendChild(mediaEl); this._contentEl.appendChild(wrap); } _renderAB() { const rawElA = this._mediaA ? _buildMediaEl(this._mediaA, { fill: true, initialMuted: this._mfvMuted, initialPlaybackRate: this._mfvPlaybackRate, }) : null; const rawElB = this._mediaB ? _buildMediaEl(this._mediaB, { fill: true, controls: false, initialMuted: true }) : null; const elA = this._trackMediaControls?.(rawElA) || rawElA; const elB = this._trackMediaControls?.(rawElB) || rawElB; this._bindMfvPersistence?.(elA); const kindA = this._mediaA ? _mediaKind(this._mediaA) : ""; const kindB = this._mediaB ? _mediaKind(this._mediaB) : ""; if (!elA && !elB) { this._contentEl.appendChild(_makeEmptyState("Select 2 assets for A/B compare")); return; } if (!elB) { // Only one asset - render as simple this._renderSimple(); return; } // Audio does not map well to clipped A/B mode; use side-by-side players instead. if (kindA === "audio" || kindB === "audio" || kindA === "model3d" || kindB === "model3d") { this._renderSide(); return; } const container = document.createElement("div"); container.className = "mjr-mfv-ab-container"; // Layer A - full-size backdrop const layerA = document.createElement("div"); layerA.className = "mjr-mfv-ab-layer"; if (elA) layerA.appendChild(elA); // Layer B - clipped from the left edge to the divider const layerB = document.createElement("div"); layerB.className = "mjr-mfv-ab-layer mjr-mfv-ab-layer--b"; const pct = Math.round(this._abDividerX * 100); layerB.style.clipPath = `inset(0 0 0 ${pct}%)`; layerB.appendChild(elB); // Draggable divider bar const divider = document.createElement("div"); divider.className = "mjr-mfv-ab-divider"; divider.style.left = `${pct}%`; // Gen info overlays are placed at the container level (outside the clipped // layers) so they are never truncated by layerB's clip-path. Each overlay // is bounded to its own half, mirroring the canvas capture layout. const fragA = this._buildGenInfoDOM(this._mediaA); let genInfoAEl: any = null; if (fragA) { genInfoAEl = document.createElement("div"); genInfoAEl.className = "mjr-mfv-geninfo-a"; if (_hasSimplePlayerControls(elA)) { genInfoAEl.classList.add("mjr-mfv-geninfo--above-player"); } genInfoAEl.appendChild(fragA); // Limit right edge to divider so it doesn't bleed into B side. genInfoAEl.style.right = `calc(${100 - pct}% + 8px)`; } const fragB = this._buildGenInfoDOM(this._mediaB); let genInfoBEl: any = null; if (fragB) { genInfoBEl = document.createElement("div"); genInfoBEl.className = "mjr-mfv-geninfo-b"; if (_hasSimplePlayerControls(elB)) { genInfoBEl.classList.add("mjr-mfv-geninfo--above-player"); } genInfoBEl.appendChild(fragB); // Start at the divider - overrides CSS left:8px so it is never // clipped by layerB's clip-path. genInfoBEl.style.left = `calc(${pct}% + 8px)`; } let _abDivAC: any = null; divider.addEventListener( "pointerdown", (e) => { e.preventDefault(); divider.setPointerCapture(e.pointerId); // Abort any previous drag listeners to prevent accumulation. try { _abDivAC?.abort(); } catch {} _abDivAC = new AbortController(); const sig = _abDivAC.signal; const rect = container.getBoundingClientRect(); const onMove = (me: any) => { const x = Math.max(0.02, Math.min(0.98, (me.clientX - rect.left) / rect.width)); this._abDividerX = x; const p = Math.round(x * 100); layerB.style.clipPath = `inset(0 0 0 ${p}%)`; divider.style.left = `${p}%`; if (genInfoAEl) genInfoAEl.style.right = `calc(${100 - p}% + 8px)`; if (genInfoBEl) genInfoBEl.style.left = `calc(${p}% + 8px)`; }; const onUp = () => { try { _abDivAC?.abort(); } catch {} }; divider.addEventListener("pointermove", onMove, { signal: sig }); divider.addEventListener("pointerup", onUp, { signal: sig }); }, this._panelAC?.signal ? { signal: this._panelAC.signal } : undefined, ); container.appendChild(layerA); container.appendChild(layerB); container.appendChild(divider); if (genInfoAEl) container.appendChild(genInfoAEl); if (genInfoBEl) container.appendChild(genInfoBEl); container.appendChild(_makeLabel("A", "left")); container.appendChild(_makeLabel("B", "right")); this._contentEl.appendChild(container); } _renderSide() { const rawElA = this._mediaA ? _buildMediaEl(this._mediaA, { initialMuted: this._mfvMuted, initialPlaybackRate: this._mfvPlaybackRate, }) : null; const rawElB = this._mediaB ? _buildMediaEl(this._mediaB, { controls: false, initialMuted: true }) : null; const elA = this._trackMediaControls?.(rawElA) || rawElA; const elB = this._trackMediaControls?.(rawElB) || rawElB; this._bindMfvPersistence?.(elA); const kindA = this._mediaA ? _mediaKind(this._mediaA) : ""; const kindB = this._mediaB ? _mediaKind(this._mediaB) : ""; if (!elA && !elB) { this._contentEl.appendChild(_makeEmptyState("Select 2 assets for Side-by-Side")); return; } const container = document.createElement("div"); container.className = "mjr-mfv-side-container"; const sideA = document.createElement("div"); sideA.className = "mjr-mfv-side-panel"; if (elA) sideA.appendChild(elA); else sideA.appendChild(_makeEmptyState(" - ")); sideA.appendChild(_makeLabel("A", "left")); // Gen info overlay for left const fragSideA = kindA === "audio" ? null : this._buildGenInfoDOM(this._mediaA); if (fragSideA) { const oa = document.createElement("div"); oa.className = "mjr-mfv-geninfo-a"; if (_hasSimplePlayerControls(elA)) { oa.classList.add("mjr-mfv-geninfo--above-player"); } oa.appendChild(fragSideA); sideA.appendChild(oa); } const sideB = document.createElement("div"); sideB.className = "mjr-mfv-side-panel"; if (elB) sideB.appendChild(elB); else sideB.appendChild(_makeEmptyState(" - ")); sideB.appendChild(_makeLabel("B", "right")); // Gen info overlay for right const fragSideB = kindB === "audio" ? null : this._buildGenInfoDOM(this._mediaB); if (fragSideB) { const ob = document.createElement("div"); ob.className = "mjr-mfv-geninfo-b"; if (_hasSimplePlayerControls(elB)) { ob.classList.add("mjr-mfv-geninfo--above-player"); } ob.appendChild(fragSideB); sideB.appendChild(ob); } container.appendChild(sideA); container.appendChild(sideB); this._contentEl.appendChild(container); } _renderGrid() { const slots = [ { media: this._mediaA, label: "A" }, { media: this._mediaB, label: "B" }, { media: this._mediaC, label: "C" }, { media: this._mediaD, label: "D" }, ]; const filled = slots.filter((s: any) => s.media); if (!filled.length) { this._contentEl.appendChild(_makeEmptyState("Select up to 4 assets for Grid Compare")); return; } const container = document.createElement("div"); container.className = "mjr-mfv-grid-container"; for (const { media, label } of slots) { const cell = document.createElement("div"); cell.className = "mjr-mfv-grid-cell"; if (media) { const kind = _mediaKind(media); const isPrimary = label === "A"; const rawEl = _buildMediaEl(media, { controls: isPrimary, initialMuted: isPrimary ? this._mfvMuted : true, initialPlaybackRate: isPrimary ? this._mfvPlaybackRate : 1, }); const el = this._trackMediaControls?.(rawEl) || rawEl; if (isPrimary) this._bindMfvPersistence?.(el); if (el) cell.appendChild(el); else cell.appendChild(_makeEmptyState(" - ")); cell.appendChild( _makeLabel(label, label === "A" || label === "C" ? "left" : "right"), ); if (kind !== "audio") { const frag = this._buildGenInfoDOM(media); if (frag) { const overlay = document.createElement("div"); overlay.className = `mjr-mfv-geninfo-${label.toLowerCase()}`; if (_hasSimplePlayerControls(el)) { overlay.classList.add("mjr-mfv-geninfo--above-player"); } overlay.appendChild(frag); cell.appendChild(overlay); } } } else { cell.appendChild(_makeEmptyState(" - ")); cell.appendChild( _makeLabel(label, label === "A" || label === "C" ? "left" : "right"), ); } container.appendChild(cell); } this._contentEl.appendChild(container); } // -- Visibility ------------------------------------------------------------ show() { if (!this.element) return; this._bindDocumentUiHandlers(); this.element.classList.add("is-visible"); this.element.setAttribute("aria-hidden", "false"); this.isVisible = true; } hide() { if (!this.element) return; // Destroy pan/zoom so _dragging and pointer-capture state are reset cleanly // even if hide() is called mid-drag (NM-5). this._destroyPanZoom(); this._destroyCompareSync(); this._stopEdgeResize(); this._closeGenDropdown(); _pauseMediaIn(this.element); this.element.classList.remove("is-visible"); this.element.setAttribute("aria-hidden", "true"); this.isVisible = false; } // -- Pop-out / Pop-in (separate OS window for second monitor) ------------ _setDesktopExpanded(active: any) { return setFloatingViewerDesktopExpanded(this, active); } _activateDesktopExpandedFallback(error: any) { return activateFloatingViewerDesktopExpandedFallback(this, error); } _tryElectronPopupFallback(el: any, w: any, h: any, reason: any) { return tryFloatingViewerElectronPopupFallback(this, el, w, h, reason); } popOut() { return popOutFloatingViewer(this); } _fallbackPopout(el: any, w: any, h: any) { return fallbackPopoutFloatingViewer(this, el, w, h); } _clearPopoutCloseWatch() { return clearFloatingViewerPopoutCloseWatch(this); } _startPopoutCloseWatch() { return startFloatingViewerPopoutCloseWatch(this); } _schedulePopInFromPopupClose() { return scheduleFloatingViewerPopInFromPopupClose(this); } _installPopoutStyles(doc: any) { return installFloatingViewerPopoutStyles(this, doc); } popIn(options?: any) { return popInFloatingViewer(this, options); } _updatePopoutBtnUI() { return updateFloatingViewerPopoutButtonUI(this); } get isPopped() { return this._isPopped || this._desktopExpanded; } _resizeCursorForDirection(dir: any) { return getFloatingViewerResizeCursor(dir); } _getResizeDirectionFromPoint(clientX: any, clientY: any, rect: any) { return getFloatingViewerResizeDirectionFromPoint(clientX, clientY, rect); } _stopEdgeResize() { return stopFloatingViewerEdgeResize(this); } _bindPanelInteractions() { return bindFloatingViewerPanelInteractions(this); } _initEdgeResize(el: any) { return initFloatingViewerEdgeResize(this, el); } _initDrag(handle: any) { return initFloatingViewerDrag(this, handle); } async _drawMediaFit(ctx: any, fileData: any, ox: any, oy: any, w: any, h: any, preferredVideo: any) { return drawFloatingViewerMediaFit(this, ctx, fileData, ox, oy, w, h, preferredVideo); } _estimateGenInfoOverlayHeight(ctx: any, fileData: any, regionWidth: any) { return estimateFloatingViewerGenInfoOverlayHeight(this, ctx, fileData, regionWidth); } _drawGenInfoOverlay(ctx: any, fileData: any, ox: any, oy: any, w: any, h: any) { return drawFloatingViewerGenInfoOverlay(this, ctx, fileData, ox, oy, w, h); } async _captureView() { return captureFloatingViewerView(this); } dispose() { disposeFloatingViewerProgressBar(this); this._destroyPanZoom(); this._destroyCompareSync(); this._destroyMediaControls(); this._unbindLayoutObserver(); this._stopEdgeResize(); this._clearPopoutCloseWatch(); try { this._panelAC?.abort(); this._panelAC = null; } catch (e: any) { console.debug?.(e); } // Abort all button click listeners in one call (NM-1). try { this._btnAC?.abort(); this._btnAC = null; } catch (e: any) { console.debug?.(e); } // Abort document-level and pop-out window listeners atomically. try { this._docAC?.abort(); this._docAC = null; } catch (e: any) { console.debug?.(e); } try { this._popoutAC?.abort(); this._popoutAC = null; } catch (e: any) { console.debug?.(e); } // Abort panzoom and compare-sync AbortControllers (belt-and-suspenders). try { this._panzoomAC?.abort(); this._panzoomAC = null; } catch (e: any) { console.debug?.(e); } try { this._compareSyncAC?.abort?.(); this._compareSyncAC = null; } catch (e: any) { console.debug?.(e); } // Pop-in before disposing so the element returns to the main document. try { if (this._isPopped) this.popIn(); } catch (e: any) { console.debug?.(e); } this._revokePreviewBlob(); if (this._onSidebarPosChanged) { window.removeEventListener("mjr-settings-changed", this._onSidebarPosChanged); this._onSidebarPosChanged = null; } try { this.element?.remove(); } catch (e: any) { console.debug?.(e); } try { this._graphMapPanel?.dispose?.(); } catch (e: any) { console.debug?.(e); } this._graphMapPanel = null; this.element = null; this._contentEl = null; this._genSidebarEl = null; this._closeBtn = null; this._modeBtn = null; this._pinGroup = null; this._pinBtns = null; this._liveBtn = null; this._nodeStreamBtn = null; this._popoutBtn = null; this._captureBtn = null; this._unbindDocumentUiHandlers(); try { this._genDropdown?.remove(); } catch (e: any) { console.debug?.(e); } this._mediaA = null; this._mediaB = null; this._mediaC = null; this._mediaD = null; this.isVisible = false; } }