Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/viewer/FloatingViewer.impl.ts
T

1940 lines
70 KiB
TypeScript

/**
* 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<string, any>) } : 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"));
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();
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;
}
}