/** * Asset Viewer Component * Supports Single, A/B Compare, and Side-by-Side modes */ import { buildAssetViewURL } from "../api/endpoints.js"; import { updateAssetRating, getAssetMetadata, getAssetsBatch, getViewerInfo, getFileMetadataScoped, } from "../api/client.js"; import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT, VIEWER_ACTIVE_ASSET_CHANGED_EVENT, } from "../app/events.js"; import { t } from "../app/i18n.js"; import { bindViewerContextMenu, unbindViewerContextMenu, } from "../features/viewer/ViewerContextMenu.js"; import { createFileBadge, createRatingBadge, createTagsBadge } from "./Badges.js"; import { APP_CONFIG } from "../app/config.js"; import { safeDispatchCustomEvent } from "../utils/events.js"; import { isPlayableViewerKind, collectPlayableMediaElements, pickPrimaryPlayableMedia, mountUnifiedMediaControls, } from "../features/viewer/mediaPlayer.js"; import { createDefaultViewerState, loadViewerPrefs, saveViewerPrefs, } from "../features/viewer/ViewerState.js"; import { createViewerLifecycle, destroyMediaProcessorsIn, safeAddListener, safeCall, } from "../features/viewer/lifecycle.js"; import { createViewerToolbar } from "../features/viewer/ViewerToolbar.js"; import { installViewerKeyboard } from "../features/viewer/ViewerKeyboard.js"; import { installFollowerVideoSync } from "../features/viewer/videoSync.js"; import { createViewerGrid } from "../features/viewer/grid.js"; import { installViewerProbe } from "../features/viewer/probe.js"; import { createViewerLoupe } from "../features/viewer/loupe.js"; import { createViewerMetadataHydrator } from "../features/viewer/metadata.js"; import { createViewerPanZoom, createViewerMediaFactory } from "../features/viewer/ViewerCanvas.js"; import { createImagePreloader } from "../features/viewer/imagePreloader.js"; import { buildMetadataComparePanel } from "../features/viewer/metadataCompare.js"; // --- Lazy-loaded viewer sub-modules (separate bundle chunks) ----------------- // Pre-warmed when the first viewer instance is created so the browser fetches // them in parallel, reducing the initial viewer chunk size by ~140 KB. let _mod_abCompare: any = null; let _mod_sideBySide: any = null; let _mod_model3d: any = null; let _mod_scopes: any = null; let _mod_genInfo: any = null; let _mod_frameExport: any = null; function _prewarmViewerModules() { if (!_mod_abCompare) import("../features/viewer/abCompare.js").then((m: any) => { _mod_abCompare = m; }); if (!_mod_sideBySide) import("../features/viewer/sideBySide.js").then((m: any) => { _mod_sideBySide = m; }); if (!_mod_model3d) import("../features/viewer/model3dRenderer.js").then((m: any) => { _mod_model3d = m; }); if (!_mod_scopes) import("../features/viewer/scopes.js").then((m: any) => { _mod_scopes = m; }); if (!_mod_genInfo) import("../features/viewer/genInfo.js").then((m: any) => { _mod_genInfo = m; }); if (!_mod_frameExport) import("../features/viewer/frameExport.js").then((m: any) => { _mod_frameExport = m; }); } // ----------------------------------------------------------------------------- import { getViewerInstance as _getViewerInstance } from "../features/viewer/viewerInstanceManager.js"; import { createPlayerBarManager } from "../features/viewer/playerBarManager.js"; import { getHotkeysState, setHotkeysScope } from "../features/panel/controllers/hotkeysState.js"; import { VIEWER_INFO_PANEL_RESERVE, ensureViewerThemeStyles, } from "../features/viewer/viewerThemeStyles.js"; import { createViewerOverlayRoot, createViewerStageShell } from "../features/viewer/viewerShell.js"; import { installViewerLightDismiss } from "../features/viewer/viewerOverlayDismiss.js"; /** * Viewer modes */ const VIEWER_MODES = { SINGLE: "single", AB_COMPARE: "ab", SIDE_BY_SIDE: "sidebyside", }; /** * Create the main viewer overlay */ function createViewer() { _prewarmViewerModules(); ensureViewerThemeStyles(); const overlay = createViewerOverlayRoot(); const lifecycle = createViewerLifecycle(overlay); const lifecycleUnsubs = lifecycle.unsubs || []; const state = createDefaultViewerState(); state.mode = VIEWER_MODES.SINGLE; try { const prefs = loadViewerPrefs(); if (prefs && typeof prefs === "object") { if (typeof prefs.analysisMode === "string") state.analysisMode = prefs.analysisMode || "none"; if (typeof prefs.loupeEnabled === "boolean") state.loupeEnabled = prefs.loupeEnabled; if (typeof prefs.probeEnabled === "boolean") state.probeEnabled = prefs.probeEnabled; if (typeof prefs.hudEnabled === "boolean") state.hudEnabled = prefs.hudEnabled; if (typeof prefs.genInfoOpen === "boolean") state.genInfoOpen = prefs.genInfoOpen; if (typeof prefs.audioVisualizerMode === "string") state.audioVisualizerMode = prefs.audioVisualizerMode || "artistic"; if ( typeof prefs.abWipePercent === "number" && Number.isFinite(prefs.abWipePercent) && prefs.abWipePercent >= 0 && prefs.abWipePercent <= 100 ) state._abWipePercent = prefs.abWipePercent; } } catch (e: any) { console.debug?.(e); } const IMAGE_PRELOAD_EXTENSIONS = new Set([ "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "avif", "jxl", "heic", "hdr", "svg", "apng", ]); let panzoom: any = null; let mediaFactory: any = null; function mediaTransform() { try { return panzoom?.mediaTransform?.() || ""; } catch { return ""; } } function clampPanToBounds() { try { panzoom?.clampPanToBounds?.(); } catch (e: any) { console.debug?.(e); } } function applyTransform() { try { panzoom?.applyTransform?.(); } catch (e: any) { console.debug?.(e); } } function setZoom(next: any, opts?: any) { try { panzoom?.setZoom?.(next, opts); } catch (e: any) { console.debug?.(e); } } function updatePanCursor() { try { panzoom?.updatePanCursor?.(); } catch (e: any) { console.debug?.(e); } } function getPrimaryMedia() { try { return panzoom?.getPrimaryMedia?.() || null; } catch { return null; } } function getMediaNaturalSize(mediaEl: any) { try { return panzoom?.getMediaNaturalSize?.(mediaEl) || { w: 0, h: 0 }; } catch { return { w: 0, h: 0 }; } } function getViewportRect() { try { return panzoom?.getViewportRect?.() || null; } catch { return null; } } function computeOneToOneZoom() { try { return panzoom?.computeOneToOneZoom?.() ?? null; } catch { return null; } } function updateMediaNaturalSize() { try { panzoom?.updateMediaNaturalSize?.(); } catch (e: any) { console.debug?.(e); } } function createMediaElement(asset: any, url: any) { try { return mediaFactory?.createMediaElement?.(asset, url) || document.createElement("div"); } catch { return document.createElement("div"); } } function createCompareMediaElement(asset: any, url: any) { try { return ( mediaFactory?.createCompareMediaElement?.(asset, url) || document.createElement("div") ); } catch { return document.createElement("div"); } } function updateVisibleAudioVisualizerMode() { let updated = false; try { const activeRoot = state.mode === VIEWER_MODES.AB_COMPARE ? abView : state.mode === VIEWER_MODES.SIDE_BY_SIDE ? sideView : singleView; const canvases = Array.from( activeRoot?.querySelectorAll?.(".mjr-viewer-audio-viz") || [], ); for (const canvas of canvases) { try { const proc = (canvas as any)?._mjrProc || null; if (!proc?.setMode) continue; proc.setMode(state.audioVisualizerMode); updated = true; } catch (e: any) { console.debug?.(e); } } } catch (e: any) { console.debug?.(e); } return updated; } let toolbar: any = null; // Late-bound close handler: default to "soft close" until API exists, then upgrade close button to full dispose. let _requestCloseFromButton = () => closeViewer(); // Header (toolbar) let header = document.createElement("div"); header.className = "mjr-viewer-header"; header.style.cssText = ` display: flex; flex-direction: column; gap: 8px; padding: 12px 20px; background: var(--mjr-surface-0, rgba(0, 0, 0, 0.8)); border-bottom: 1px solid rgba(255, 255, 255, 0.1); color: white; box-sizing: border-box; `; let filename = document.createElement("span"); filename.className = "mjr-viewer-filename"; filename.style.cssText = "font-size: 14px; font-weight: 500; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"; let badgesBar = document.createElement("div"); badgesBar.className = "mjr-viewer-badges"; badgesBar.style.cssText = "display:flex; gap:8px; align-items:center; flex-wrap:wrap;"; let filenameRight: any = null; let badgesBarRight: any = null; let rightMeta: any = null; let rightArea: any = null; let leftArea: any = null; let leftMeta: any = null; let centerArea: any = null; let titleLine: any = null; let titleWrap: any = null; let modeButtonsEl: any = null; let headerTop: any = null; try { header.appendChild(filename); header.appendChild(badgesBar); } catch (e: any) { console.debug?.(e); } try { toolbar = createViewerToolbar({ VIEWER_MODES, state, lifecycle, getCanAB: () => canAB(), onToggleFullscreen: () => { try { if (!document.fullscreenElement) { try { overlay.requestFullscreen(); } catch (e: any) { console.debug?.(e); } } else { try { document.exitFullscreen(); } catch (e: any) { console.debug?.(e); } } } catch (e: any) { console.debug?.(e); } }, // Close button should fully tear down the viewer (remove overlay + listeners) to avoid leaks/ghost UI. onClose: () => _requestCloseFromButton?.(), onMode: (mode: any) => { try { if (mode === VIEWER_MODES.AB_COMPARE && !canAB()) return; if (mode === VIEWER_MODES.SIDE_BY_SIDE && !canSide()) return; state.mode = mode; updateUI(); try { toolbar?.syncToolsUIFromState?.(); } catch (e: any) { console.debug?.(e); } } catch (e: any) { console.debug?.(e); } }, onZoomIn: () => { try { setZoom((Number(state.zoom) || 1) + 0.25, { clientX: state._lastPointerX, clientY: state._lastPointerY, }); } catch (e: any) { console.debug?.(e); } }, onZoomOut: () => { try { setZoom((Number(state.zoom) || 1) - 0.25, { clientX: state._lastPointerX, clientY: state._lastPointerY, }); } catch (e: any) { console.debug?.(e); } }, onZoomReset: () => { try { setZoom(1); } catch (e: any) { console.debug?.(e); } }, onZoomOneToOne: () => { try { const tryApply = () => { const z = computeOneToOneZoom(); if (z == null) return false; const near = Math.abs((Number(state.zoom) || 1) - z) < 0.01; setZoom(near ? 1 : z, { clientX: state._lastPointerX, clientY: state._lastPointerY, }); return true; }; // Sometimes processors haven't set canvas natural size yet; retry once after a frame. if (tryApply()) return; try { requestAnimationFrame(() => { try { updateMediaNaturalSize(); } catch (e: any) { console.debug?.(e); } try { tryApply(); } catch (e: any) { console.debug?.(e); } }); } catch (e: any) { console.debug?.(e); } } catch (e: any) { console.debug?.(e); } }, onCompareModeChanged: () => { try { if (state.mode === VIEWER_MODES.AB_COMPARE) { renderAsset(); // Rebind the player bar to the newly created video elements (wipe/difference re-renders AB view). syncPlayerBar(); } } catch (e: any) { console.debug?.(e); } }, onExportFrame: () => { try { void exportCurrentFrame({ toClipboard: false }); } catch (e: any) { console.debug?.(e); } }, onCopyFrame: () => { try { void exportCurrentFrame({ toClipboard: true }); } catch (e: any) { console.debug?.(e); } }, onAudioVizModeChanged: () => { try { const current = state.assets[state.currentIndex]; if (String(current?.kind || "") !== "audio") return; if (!updateVisibleAudioVisualizerMode()) { renderAsset(); syncPlayerBar(); } } catch (e: any) { console.debug?.(e); } }, onToolsChanged: () => { try { toolbar?.syncToolsUIFromState?.(); } catch (e: any) { console.debug?.(e); } try { saveViewerPrefs(state); } catch (e: any) { console.debug?.(e); } try { applyDistractionFreeUI(); } catch (e: any) { console.debug?.(e); } try { if (state.mode === VIEWER_MODES.AB_COMPARE) { const m = String(state.abCompareMode || "wipe"); if (m !== "wipe" && m !== "wipeV") { abView?._mjrDiffRequest?.(); } } } catch (e: any) { console.debug?.(e); } try { if (!state.probeEnabled) probeTooltip.style.display = "none"; } catch (e: any) { console.debug?.(e); } try { if (!state.loupeEnabled) loupeWrap.style.display = "none"; } catch (e: any) { console.debug?.(e); } try { scheduleOverlayRedraw(); } catch (e: any) { console.debug?.(e); } try { scheduleApplyGrade?.(); } catch (e: any) { console.debug?.(e); } try { void renderGenInfoPanel(); } catch (e: any) { console.debug?.(e); } }, }); if (toolbar?.headerEl) header = toolbar.headerEl; if (toolbar?.headerTopEl) headerTop = toolbar.headerTopEl; if (toolbar?.filenameEl) filename = toolbar.filenameEl; if (toolbar?.badgesBarEl) badgesBar = toolbar.badgesBarEl; if (toolbar?.filenameRightEl) filenameRight = toolbar.filenameRightEl; if (toolbar?.badgesBarRightEl) badgesBarRight = toolbar.badgesBarRightEl; if (toolbar?.leftAreaEl) leftArea = toolbar.leftAreaEl; if (toolbar?.leftMetaEl) leftMeta = toolbar.leftMetaEl; if (toolbar?.centerAreaEl) centerArea = toolbar.centerAreaEl; if (toolbar?.rightMetaEl) rightMeta = toolbar.rightMetaEl; if (toolbar?.rightAreaEl) rightArea = toolbar.rightAreaEl; if (toolbar?.titleLineEl) titleLine = toolbar.titleLineEl; if (toolbar?.titleWrapEl) titleWrap = toolbar.titleWrapEl; if (toolbar?.modeButtonsEl) modeButtonsEl = toolbar.modeButtonsEl; } catch (e: any) { console.debug?.(e); } const { contentRow, content, singleView, abView, sideView, overlayLayer, gridCanvas, probeTooltip, loupeWrap, loupeCanvas, genInfoOverlay, genInfoTitle, genInfoBody, genInfoOverlayLeft, genInfoTitleLeft, genInfoBodyLeft, footer, prevBtn, indexInfo, nextBtn, navBar, playerBarHost, filmstrip, } = createViewerStageShell({ state, buildAssetViewURL, onNavigate: (idx: any) => { try { if (state.compareAsset != null) { state.compareAsset = null; state.mode = VIEWER_MODES.SINGLE; } state.currentIndex = idx; updateUI(); } catch (e: any) { console.debug?.(e); } }, onCompare: (idx: any) => { try { const assets = Array.isArray(state.assets) ? state.assets : []; const chosen = assets[idx]; if (!chosen) return; if (chosen === assets[state.currentIndex]) return; if (chosen === state.compareAsset) { state.compareAsset = null; state.mode = VIEWER_MODES.SINGLE; updateUI(); return; } if (assets.length === 2) { state.compareAsset = assets[1 - state.currentIndex]; state.mode = compareIncludes3D() ? VIEWER_MODES.SIDE_BY_SIDE : VIEWER_MODES.AB_COMPARE; } else { state.compareAsset = chosen; state.mode = canSide() ? VIEWER_MODES.SIDE_BY_SIDE : VIEWER_MODES.AB_COMPARE; } updateUI(); } catch (e: any) { console.debug?.(e); } }, }); overlay.appendChild(header); overlay.appendChild(contentRow); function getOtherComparedAsset() { try { if (state.compareAsset) return state.compareAsset; const assets = Array.isArray(state.assets) ? state.assets : []; if (assets.length === 2) { return assets[1 - (state.currentIndex || 0)] || null; } } catch (e: any) { console.debug?.(e); } return null; } function compareIncludes3D() { try { const current = state.assets?.[state.currentIndex] || null; return ( (_mod_model3d?.isModel3DAsset?.(current) ?? false) || (_mod_model3d?.isModel3DAsset?.(getOtherComparedAsset()) ?? false) ); } catch (e: any) { console.debug?.(e); } return false; } overlay.appendChild(filmstrip.el); overlay.appendChild(footer); overlay.appendChild(genInfoOverlay); overlay.appendChild(genInfoOverlayLeft); installViewerLightDismiss({ overlay, requestClose: () => _requestCloseFromButton(), }); const metadataHydrator = createViewerMetadataHydrator({ state, VIEWER_MODES, APP_CONFIG, getAssetMetadata, getAssetsBatch, }); const VIEWER_INFO_CACHE_TTL_MS = 5 * 60 * 1000; const VIEWER_INFO_CACHE_MAX = 256; const _viewerInfoCache = new Map(); const _viewerInfoCachePrune = () => { try { const now = Date.now(); for (const [k, entry] of _viewerInfoCache.entries()) { const ts = Number(entry?.at) || 0; if (!ts || now - ts > VIEWER_INFO_CACHE_TTL_MS) _viewerInfoCache.delete(k); } if (_viewerInfoCache.size <= VIEWER_INFO_CACHE_MAX) return; const ordered = Array.from(_viewerInfoCache.entries()).sort( (a, b) => (Number(a?.[1]?.at) || 0) - (Number(b?.[1]?.at) || 0), ); const overflow = _viewerInfoCache.size - VIEWER_INFO_CACHE_MAX; for (let i = 0; i < overflow; i += 1) { const key = ordered?.[i]?.[0]; if (key != null) _viewerInfoCache.delete(key); } } catch (e: any) { console.debug?.(e); } }; const _viewerInfoCacheGet = (id: any) => { try { const key = String(id ?? ""); if (!key) return null; const entry = _viewerInfoCache.get(key); if (!entry || typeof entry !== "object") return null; const ts = Number(entry?.at) || 0; if (!ts || Date.now() - ts > VIEWER_INFO_CACHE_TTL_MS) { _viewerInfoCache.delete(key); return null; } return entry?.data || null; } catch { return null; } }; const _viewerInfoCacheSet = (id: any, data: any) => { try { const key = String(id ?? ""); if (!key || !data) return; _viewerInfoCache.set(key, { data, at: Date.now() }); _viewerInfoCachePrune(); } catch (e: any) { console.debug?.(e); } }; const hydrateVisibleMetadata = async () => { try { await metadataHydrator?.hydrateVisibleMetadata?.(); } catch (e: any) { console.debug?.(e); } }; try { panzoom = createViewerPanZoom({ overlay, content, singleView, abView, sideView, state, VIEWER_MODES, scheduleOverlayRedraw, lifecycle, }); } catch { panzoom = null; } const clearCanvas = (ctx: any, w: any, h: any) => { try { ctx.clearRect(0, 0, w, h); } catch (e: any) { console.debug?.(e); } }; // Overlay drawing (grid + probe + loupe) let _overlayRAF: any = null; function scheduleOverlayRedraw(immediate?: any) { try { if (overlay.style.display === "none") return; if (immediate === true) { if (_overlayRAF != null) { cancelAnimationFrame(_overlayRAF); _overlayRAF = null; } try { redrawOverlays(); } catch (e: any) { console.debug?.(e); } return; } if (_overlayRAF != null) return; _overlayRAF = requestAnimationFrame(() => { _overlayRAF = null; try { redrawOverlays(); } catch (e: any) { console.debug?.(e); } }); } catch (e: any) { console.debug?.(e); } } const getOverlayMedia = () => { try { if (state?.mode === VIEWER_MODES.SINGLE) { return singleView?.querySelector?.(".mjr-viewer-media") || null; } if (state?.mode === VIEWER_MODES.AB_COMPARE) { return abView?.querySelector?.(".mjr-viewer-media") || null; } if (state?.mode === VIEWER_MODES.SIDE_BY_SIDE) { return sideView?.querySelector?.(".mjr-viewer-media") || null; } } catch (e: any) { console.debug?.(e); } return null; }; const grid = createViewerGrid({ gridCanvas, content, state, VIEWER_MODES, getPrimaryMedia: getOverlayMedia, getViewportRect, clearCanvas, }); const redrawOverlays = () => { const hasPanHint = (() => { try { const at = Number(state?._panHintAt) || 0; return at > 0 && Date.now() - at < 900; } catch { return false; } })(); try { const showHud = state?.mode === VIEWER_MODES.SINGLE && Boolean(state?.hudEnabled); const showScopes = String(state?.scopesMode || "off") !== "off"; const showMask = Boolean(state?.overlayMaskEnabled); gridCanvas.style.display = state.gridMode === 0 && !showMask && !hasPanHint && !showHud && !showScopes ? "none" : ""; } catch (e: any) { console.debug?.(e); } const size = grid.ensureCanvasSize(); if (!(size.w > 0 && size.h > 0)) return; const wantGridOrMask = (() => { const showHud = state?.mode === VIEWER_MODES.SINGLE && Boolean(state?.hudEnabled); return ( (Number(state.gridMode) || 0) !== 0 || Boolean(state?.overlayMaskEnabled) || showHud ); })(); if (wantGridOrMask) { grid.redrawGrid(size); } else { try { const ctx = gridCanvas.getContext("2d"); if (ctx) clearCanvas(ctx, size.w, size.h); } catch (e: any) { console.debug?.(e); } } if (hasPanHint) { try { const ctx = gridCanvas.getContext("2d"); if (ctx) { const rect = content?.getBoundingClientRect?.(); const x0 = Number(state?._panHintX); const y0 = Number(state?._panHintY); const x = rect && Number.isFinite(x0) ? x0 - rect.left : size.w / 2; const y = rect && Number.isFinite(y0) ? y0 - rect.top : size.h * 0.78; const pad = 10; const tx = Math.max(pad, Math.min(size.w - pad, x)); const ty = Math.max(pad, Math.min(size.h - pad, y)); ctx.save(); ctx.font = "12px var(--comfy-font, ui-sans-serif, system-ui)"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; const msg = "Zoom in to pan"; const metrics = ctx.measureText(msg); const w = Math.min(size.w - 2 * pad, Math.max(140, metrics.width + 26)); const h = 26; ctx.fillStyle = "rgba(0,0,0,0.65)"; ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 1; ctx.beginPath(); const rx = tx - w / 2; const ry = ty - h / 2; const r = 10; ctx.moveTo(rx + r, ry); ctx.arcTo(rx + w, ry, rx + w, ry + h, r); ctx.arcTo(rx + w, ry + h, rx, ry + h, r); ctx.arcTo(rx, ry + h, rx, ry, r); ctx.arcTo(rx, ry, rx + w, ry, r); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.92)"; ctx.fillText(msg, tx, ty); ctx.restore(); } } catch (e: any) { console.debug?.(e); } } // HUD: bbox + image WxH label (drawn in `grid.js`). No filename overlay here. // Scopes (optional): RGB histogram + luma waveform (downscaled, best-effort). try { const scopesMode = String(state?.scopesMode || "off"); if (scopesMode !== "off") { const ctx = gridCanvas.getContext("2d"); if (ctx) { let root = singleView; if (state?.mode === VIEWER_MODES.AB_COMPARE) root = abView; else if (state?.mode === VIEWER_MODES.SIDE_BY_SIDE) root = sideView; const any = root?.querySelector?.("canvas.mjr-viewer-media") || overlay?.querySelector?.("canvas.mjr-viewer-media"); if (any && any instanceof HTMLCanvasElement) { _mod_scopes?.drawScopesLight?.(ctx, { w: size.w, h: size.h }, any, { mode: scopesMode, channel: state?.channel, }); } } } } catch (e: any) { console.debug?.(e); } if (state.mode !== VIEWER_MODES.SINGLE) { try { probeTooltip.style.display = "none"; } catch (e: any) { console.debug?.(e); } try { loupeWrap.style.display = "none"; } catch (e: any) { console.debug?.(e); } } }; // ---------------------------------------------------------------------------- // Generation Info side panel (prompt/model/etc) // ---------------------------------------------------------------------------- const clearGenInfoBody = (target: any) => { if (!target) return; try { for (const child of Array.from(target.childNodes || [])) { try { (child as any)?._mjrDispose?.(); } catch (e: any) { console.debug?.(e); } } } catch (e: any) { console.debug?.(e); } try { target.replaceChildren(); } catch (e: any) { console.debug?.(e); } }; const stopGenInfoFetch = () => { try { state._genInfoAbort?.abort?.(); } catch (e: any) { console.debug?.(e); } state._genInfoAbort = null; try { state._genInfoReqId = (Number(state._genInfoReqId) || 0) + 1; } catch (e: any) { console.debug?.(e); } }; const ensureGenInfoAsset = async (asset: any, { signal }: Record = {}) => { try { return await _mod_genInfo?.ensureViewerMetadataAsset?.(asset, { getAssetMetadata, getFileMetadataScoped, metadataCache: metadataHydrator, signal, }); } catch { return asset; } }; const shouldShowGenInfoLoading = (asset: any) => { try { if (!asset || typeof asset !== "object") return false; if (asset?.geninfo || asset?.prompt || asset?.workflow || asset?.metadata) return false; const mime = String(asset?.mime || asset?.mimetype || asset?.type || "").toLowerCase(); if (mime.startsWith("video/")) return true; const p = String( asset?.filepath || asset?.path || asset?.filename || asset?.name || "", ).toLowerCase(); const ext = p.split(".").pop() || ""; if (["mp4", "webm", "mov", "mkv", "avi", "m4v", "gif"].includes(ext)) return true; // If we have nothing at all, show a loading hint while hydration runs. return true; } catch { return false; } }; const getGenInfoAssetKey = (asset: any) => { try { if (!asset || typeof asset !== "object") return ""; if (asset.id != null) return `id:${asset.id}`; const filepath = String(asset.filepath || asset.path || asset?.file_info?.filepath || "").trim(); if (filepath) return `fp:${filepath}`; const type = String(asset.source || asset.type || "output").trim().toLowerCase(); const subfolder = String(asset.subfolder || asset?.file_info?.subfolder || "").trim(); const filename = String(asset.filename || asset.name || asset?.file_info?.filename || "").trim(); return filename ? `file:${type}:${subfolder}:${filename}` : ""; } catch { return ""; } }; const hasGenInfoBodyContent = () => { try { return Boolean( genInfoBody?.childNodes?.length || genInfoBodyLeft?.childNodes?.length, ); } catch { return false; } }; const renderGenInfoPanel = async () => { const canABMode = canAB(); const canSideMode = canSide(); const mode = state.mode; const open = Boolean(state?.genInfoOpen) && !state?.distractionFree; const current = state?.assets?.[state?.currentIndex] || null; // Determine if we should show split panels const isDual = open && ((mode === VIEWER_MODES.AB_COMPARE && canABMode) || (mode === VIEWER_MODES.SIDE_BY_SIDE && canSideMode)); // Grid mode: 3-4 assets in 2x2 grid (no filmstrip compare) const isGrid = isDual && mode === VIEWER_MODES.SIDE_BY_SIDE && !state?.compareAsset && (state?.assets?.length ?? 0) > 2; try { genInfoOverlay.style.display = open ? "flex" : "none"; genInfoOverlayLeft.style.display = isDual ? "flex" : "none"; // Reserve space so the viewer shrinks. overlay.style.paddingRight = open ? VIEWER_INFO_PANEL_RESERVE : "0px"; overlay.style.paddingLeft = isDual ? VIEWER_INFO_PANEL_RESERVE : "0px"; if (!open) { stopGenInfoFetch(); try { state._genInfoRenderSignature = ""; } catch (e: any) { console.debug?.(e); } try { clearGenInfoBody(genInfoBody); } catch (e: any) { console.debug?.(e); } try { clearGenInfoBody(genInfoBodyLeft); } catch (e: any) { console.debug?.(e); } return; } } catch { return; } let signature = ""; try { const assets = Array.isArray(state?.assets) ? state.assets : []; const compared = state?.compareAsset ? getGenInfoAssetKey(state.compareAsset) : ""; const visibleKeys = assets.slice(0, 4).map(getGenInfoAssetKey).join("|"); signature = [ "open", mode, Number(state?.currentIndex) || 0, getGenInfoAssetKey(current), compared, visibleKeys, isDual ? "dual" : "single", isGrid ? "grid" : "", ].join("::"); const activeSignal = state?._genInfoAbort?.signal; if ( signature && state?._genInfoRenderSignature === signature && activeSignal && !activeSignal.aborted && hasGenInfoBodyContent() ) { return; } state._genInfoRenderSignature = signature; } catch (e: any) { console.debug?.(e); } stopGenInfoFetch(); const reqId = (Number(state?._genInfoReqId) || 0) + 1; try { state._genInfoReqId = reqId; } catch (e: any) { console.debug?.(e); } const ac = new AbortController(); state._genInfoAbort = ac; const renderNow = ({ left = null, leftExtra = null, right = null, rightExtra = null, single = null }: Record = {}) => { try { if (state._genInfoReqId !== reqId) return; clearGenInfoBody(genInfoBody); } catch (e: any) { console.debug?.(e); } try { if (state._genInfoReqId !== reqId) return; clearGenInfoBody(genInfoBodyLeft); } catch (e: any) { console.debug?.(e); } if (state._genInfoReqId !== reqId) return; const onRetry = () => { try { if (!state?.genInfoOpen) state.genInfoOpen = true; state._genInfoRenderSignature = ""; void renderGenInfoPanel(); } catch (e: any) { console.debug?.(e); } }; const addBlockTo = (targetBody: any, title: any, assetObj: any, loading: any) => { if (!targetBody) return; try { try { const _blocks = _mod_genInfo?.buildViewerMetadataBlocks?.({ title, asset: assetObj, ui: { loading: Boolean(loading), onRetry }, }); if (_blocks) { targetBody.appendChild(_blocks); return; } } catch (e: any) { console.debug?.(e); } // Fallback manual rendering const block = document.createElement("div"); block.style.cssText = "display:flex; flex-direction:column; gap:10px; margin-bottom: 14px;"; if (title) { const h = document.createElement("div"); h.textContent = title; h.style.cssText = "font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: rgba(255,255,255,0.86);"; block.appendChild(h); } const empty = document.createElement("div"); empty.style.cssText = "padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.72);"; empty.textContent = t("viewer.noGenerationDataFile", "No generation data found for this file."); block.appendChild(empty); try { const raw = assetObj?.metadata_raw; if (raw != null) { const details = document.createElement("details"); details.style.cssText = "border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(255,255,255,0.04); overflow: hidden;"; const summary = document.createElement("summary"); summary.textContent = t("msg.rawMetadata", "Raw metadata"); summary.style.cssText = "cursor: pointer; padding: 10px 12px; color: rgba(255,255,255,0.78); user-select: none;"; const pre = document.createElement("pre"); pre.style.cssText = "margin:0; padding: 10px 12px; max-height: 280px; overflow:auto; font-size: 11px; line-height: 1.35; color: rgba(255,255,255,0.86);"; let txt = ""; try { if (typeof raw === "string") txt = raw; else txt = JSON.stringify(raw, null, 2); } catch { txt = String(raw); } if (txt.length > 40_000) txt = `${txt.slice(0, 40_000)}\n...(truncated)...`; pre.textContent = txt; details.appendChild(summary); details.appendChild(pre); block.appendChild(details); } } catch (e: any) { console.debug?.(e); } targetBody.appendChild(block); } catch (e: any) { console.debug?.(e); } }; if (isDual) { if (left) { genInfoTitleLeft.textContent = left.title || "Asset A"; addBlockTo( genInfoBodyLeft, leftExtra ? "Asset A" : "", left.asset, left.loading, ); } if (leftExtra) { addBlockTo(genInfoBodyLeft, "Asset C", leftExtra.asset, leftExtra.loading); } if (right) { genInfoTitle.textContent = right.title || "Asset B"; addBlockTo( genInfoBody, rightExtra ? "Asset B" : "", right.asset, right.loading, ); } if (rightExtra) { addBlockTo(genInfoBody, "Asset D", rightExtra.asset, rightExtra.loading); } if (!isGrid && left?.asset && right?.asset && !left.loading && !right.loading) { try { const comparePanel = buildMetadataComparePanel(left.asset, right.asset); if (comparePanel) { genInfoBody.insertBefore(comparePanel, genInfoBody.firstChild || null); } } catch (e: any) { console.debug?.(e); } } } else { // Single mode if (single) { genInfoTitle.textContent = single.title || "Generation Info"; addBlockTo(genInfoBody, "", single.asset, single.loading); } } }; // Resolve assets and perform initial render try { if (!current) { renderNow({}); return; } let assetLeft: any = null; let assetRight: any = null; let assetC: any = null; let assetD: any = null; let assetSingle: any = null; if (isDual) { // Determine A and B if (mode === VIEWER_MODES.SIDE_BY_SIDE) { // Side-by-side can be fixed-order (2-up set) or filmstrip compare (A=current, B=chosen). if (state?.compareAsset) { assetLeft = current; assetRight = state.compareAsset; } else { assetLeft = state.assets[0] || null; assetRight = state.assets[1] || null; if (isGrid) { assetC = state.assets[2] || null; assetD = state.assets[3] || null; } } } else { // AB_COMPARE: Swappable roles (Current vs Other) assetLeft = current; const other = state?.compareAsset || (state.assets.length === 2 ? state.assets[1 - state.currentIndex] : null); assetRight = other; } } else { assetSingle = current; } // Initial render (cache) const getCached = (a: any) => (a ? metadataHydrator?.getCached?.(a.id)?.data || a : null); renderNow({ left: isDual ? { title: isGrid ? "Assets A & C" : "Asset A", asset: getCached(assetLeft), loading: shouldShowGenInfoLoading(getCached(assetLeft)), } : null, leftExtra: isGrid && assetC ? { asset: getCached(assetC), loading: shouldShowGenInfoLoading(getCached(assetC)), } : null, right: isDual ? { title: isGrid ? "Assets B & D" : "Asset B", asset: getCached(assetRight), loading: shouldShowGenInfoLoading(getCached(assetRight)), } : null, rightExtra: isGrid && assetD ? { asset: getCached(assetD), loading: shouldShowGenInfoLoading(getCached(assetD)), } : null, single: !isDual ? { title: "Generation Info", asset: getCached(assetSingle), loading: shouldShowGenInfoLoading(getCached(assetSingle)), } : null, }); // Hydrate logic if (state._genInfoReqId !== reqId) return; if (isDual) { const lFull = assetLeft ? await ensureGenInfoAsset(assetLeft, { signal: ac.signal }) : null; const rFull = assetRight ? await ensureGenInfoAsset(assetRight, { signal: ac.signal }) : null; const cFull = assetC ? await ensureGenInfoAsset(assetC, { signal: ac.signal }) : null; const dFull = assetD ? await ensureGenInfoAsset(assetD, { signal: ac.signal }) : null; if (state._genInfoReqId !== reqId) return; renderNow({ left: { title: isGrid ? "Assets A & C" : "Asset A", asset: lFull, loading: false, }, leftExtra: isGrid && cFull ? { asset: cFull, loading: false } : null, right: { title: isGrid ? "Assets B & D" : "Asset B", asset: rFull, loading: false, }, rightExtra: isGrid && dFull ? { asset: dFull, loading: false } : null, }); } else { const sFull = assetSingle ? await ensureGenInfoAsset(assetSingle, { signal: ac.signal }) : null; if (state._genInfoReqId !== reqId) return; renderNow({ single: { title: "Generation Info", asset: sFull, loading: false, }, }); } } catch (e: any) { console.debug?.(e); } }; // No close button in panel (toggle via toolbar / shortcut). // ---------------------------------------------------------------------------- // Export frame (PNG download + clipboard copy) // ---------------------------------------------------------------------------- // Frame exporter: lazily instantiated on first use since the module is // dynamically loaded. By the time the user clicks "Export frame" the module // will almost always have finished loading. let _frameExporterInst: any = null; function exportCurrentFrame(opts: any) { try { if (!_frameExporterInst && _mod_frameExport) { _frameExporterInst = _mod_frameExport.createFrameExporter({ state, VIEWER_MODES, singleView, abView, sideView, }); } return _frameExporterInst?.exportCurrentFrame?.(opts); } catch (e: any) { console.debug?.(e); } } // Smooth animation loop using requestAnimationFrame (only when needed) // REMOVED: Animation loop (requestAnimationFrame) for performance // Direct transform updates only const renderBadges = () => { const clear = (el: any) => { try { if (el) el.replaceChildren(); } catch (e: any) { console.debug?.(e); } }; clear(badgesBar); clear(badgesBarRight); const makePill = (asset: any, { showName }: Record = {}) => { if (!asset) return null; const pill = document.createElement("div"); pill.className = "mjr-viewer-asset-pill"; pill.style.cssText = ` display: inline-flex; align-items: center; gap: 8px; padding: 2px 8px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.08); font-size: 12px; max-width: 360px; overflow: hidden; `; const name = document.createElement("span"); name.textContent = String(asset.filename || ""); name.style.cssText = "max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0.95;"; const extBadge = createFileBadge( asset.filename, asset.kind, !!asset?._mjrNameCollision, ); try { extBadge.style.position = "static"; extBadge.style.top = ""; extBadge.style.left = ""; extBadge.style.padding = "2px 6px"; extBadge.style.fontSize = "10px"; extBadge.style.borderRadius = "6px"; extBadge.style.pointerEvents = "none"; } catch (e: any) { console.debug?.(e); } const stars = createRatingBadge(asset.rating || 0); if (stars) { try { stars.style.position = "static"; stars.style.top = ""; stars.style.right = ""; stars.style.padding = "2px 6px"; stars.style.fontSize = "12px"; } catch (e: any) { console.debug?.(e); } } const tags = createTagsBadge(Array.isArray(asset.tags) ? asset.tags : []); if (tags) { try { tags.style.position = "static"; tags.style.bottom = ""; tags.style.left = ""; tags.style.maxWidth = "220px"; tags.style.pointerEvents = "none"; } catch (e: any) { console.debug?.(e); } } pill.appendChild(extBadge); if (showName) pill.appendChild(name); if (stars) pill.appendChild(stars); if (tags && tags.style.display !== "none") pill.appendChild(tags); try { if (asset.filepath) pill.title = String(asset.filepath); } catch (e: any) { console.debug?.(e); } return pill; }; const isSingle = state.mode === VIEWER_MODES.SINGLE; const isAB = state.mode === VIEWER_MODES.AB_COMPARE && canAB(); const isSide = state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide(); // A/B and Side-by-side: when we have a right badge bar, split per side to keep the header readable. if ((isAB || isSide) && badgesBarRight) { const current = state.assets?.[state.currentIndex] || null; const sideUsesCompare = isSide && state.compareAsset != null; const leftAsset = isAB ? state.compareAsset != null ? current : state.assets?.[0] || null : sideUsesCompare ? current : state.assets?.[0] || null; const rightAsset = isAB ? state.compareAsset != null ? state.compareAsset : state.assets?.[1] || null : sideUsesCompare ? state.compareAsset : state.assets?.[Math.max(0, (state.assets?.length || 1) - 1)] || null; const leftPill = makePill(leftAsset, { showName: false }); const rightPill = makePill(rightAsset, { showName: false }); try { if (leftPill) badgesBar.appendChild(leftPill); } catch (e: any) { console.debug?.(e); } try { if (rightPill) badgesBarRight.appendChild(rightPill); } catch (e: any) { console.debug?.(e); } return; } const items = isSingle ? [state.assets[state.currentIndex]].filter(Boolean) : Array.isArray(state.assets) ? state.assets.slice(0, 4) : []; for (const a of items) { const pill = makePill(a, { showName: !isSingle }); if (!pill) continue; try { badgesBar.appendChild(pill); } catch (e: any) { console.debug?.(e); } } }; function canAB() { return (state.assets.length === 2 || state.compareAsset != null) && !compareIncludes3D(); } function canSide() { const n = state.assets.length; return (n >= 2 && n <= 4) || (n >= 1 && state.compareAsset != null); } function applyDistractionFreeUI() { const on = Boolean(state?.distractionFree); try { header.style.display = on ? "none" : ""; } catch (e: any) { console.debug?.(e); } try { footer.style.display = on ? "none" : ""; } catch (e: any) { console.debug?.(e); } try { overlay.classList.toggle("mjr-viewer-focus", on); } catch (e: any) { console.debug?.(e); } try { if (on) { overlay.style.paddingRight = "0px"; overlay.style.paddingLeft = "0px"; genInfoOverlay.style.display = "none"; genInfoOverlayLeft.style.display = "none"; } } catch (e: any) { console.debug?.(e); } } // Update UI based on state function updateUI() { // FORCE RESET: Always reset zoom/pan when changing images state.zoom = 1; state.panX = 0; state.panY = 0; state.targetZoom = 1; // Clear filmstrip compare asset when leaving compare modes try { if ( state.mode !== VIEWER_MODES.AB_COMPARE && state.mode !== VIEWER_MODES.SIDE_BY_SIDE && state.compareAsset != null ) { state.compareAsset = null; } } catch (e: any) { console.debug?.(e); } // Update filename const current = state.assets[state.currentIndex]; const isAB = state.mode === VIEWER_MODES.AB_COMPARE && canAB(); const isSide = state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide(); // In filmstrip-compare AB mode, A = current navigated asset, B = compareAsset. // In traditional AB mode (exactly 2 assets), A = assets[0], B = assets[1]. const isFilmCompareAB = isAB && state.compareAsset != null; const isFilmCompareSide = isSide && state.compareAsset != null; const leftAsset = isAB ? (isFilmCompareAB ? current : state.assets?.[0]) || null : isSide ? (isFilmCompareSide ? current : state.assets?.[0]) || null : current || null; const rightAsset = isAB ? (isFilmCompareAB ? state.compareAsset : state.assets?.[1]) || null : isSide ? isFilmCompareSide ? state.compareAsset : Array.isArray(state.assets) && state.assets.length >= 2 ? state.assets[state.assets.length - 1] : null : null; try { filename.textContent = leftAsset?.filename || ""; } catch (e: any) { console.debug?.(e); } try { if (rightMeta && filenameRight && rightAsset && rightAsset !== leftAsset) { rightMeta.style.display = "flex"; if (rightArea) rightArea.style.display = "flex"; filenameRight.textContent = rightAsset?.filename || ""; // Compare mode: move titleLine to leftArea for proper 3-column layout if (leftArea && leftMeta && titleLine) { leftArea.style.display = "flex"; leftMeta.appendChild(titleLine); titleLine.style.justifyContent = "flex-start"; } if (headerTop) { headerTop.style.justifyContent = "center"; headerTop.style.paddingLeft = "84px"; } if (centerArea) centerArea.style.flex = "0 0 auto"; if (titleWrap) titleWrap.style.flex = "0 0 auto"; if (filename) filename.style.textAlign = "left"; } else if (rightMeta && filenameRight) { rightMeta.style.display = "none"; if (rightArea) rightArea.style.display = "none"; filenameRight.textContent = ""; // Single mode: restore titleLine into titleWrap (before modeButtons) if (leftArea) leftArea.style.display = "none"; if (titleWrap && titleLine && modeButtonsEl) { titleWrap.insertBefore(titleLine, modeButtonsEl); titleLine.style.justifyContent = "center"; } if (headerTop) { headerTop.style.justifyContent = "center"; headerTop.style.paddingLeft = "12px"; } if (centerArea) centerArea.style.flex = "1 1 auto"; if (titleWrap) titleWrap.style.flex = ""; if (filename) filename.style.textAlign = "center"; } } catch (e: any) { console.debug?.(e); } // Update index if (state.mode === VIEWER_MODES.AB_COMPARE && canAB()) { indexInfo.textContent = "2 selected"; } else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide()) { indexInfo.textContent = state.compareAsset != null ? "2 selected" : `${state.assets.length} selected`; } else { indexInfo.textContent = `${state.currentIndex + 1} / ${state.assets.length}`; } if (state.mode === VIEWER_MODES.AB_COMPARE && !canAB()) { state.mode = canSide() ? VIEWER_MODES.SIDE_BY_SIDE : VIEWER_MODES.SINGLE; } if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && !canSide()) state.mode = VIEWER_MODES.SINGLE; try { toolbar?.syncModeButtons?.({ canAB, canSide }); } catch (e: any) { console.debug?.(e); } // Show/hide views singleView.style.display = state.mode === VIEWER_MODES.SINGLE ? "flex" : "none"; abView.style.display = state.mode === VIEWER_MODES.AB_COMPARE ? "block" : "none"; sideView.style.display = state.mode === VIEWER_MODES.SIDE_BY_SIDE ? "flex" : "none"; // Cleanup hidden views to prevent processor loops and listener buildup. try { if (state.mode !== VIEWER_MODES.SINGLE) { destroyMediaProcessorsIn(singleView); singleView.replaceChildren(); } } catch (e: any) { console.debug?.(e); } try { if (state.mode !== VIEWER_MODES.AB_COMPARE) { destroyMediaProcessorsIn(abView); abView.replaceChildren(); } } catch (e: any) { console.debug?.(e); } try { if (state.mode !== VIEWER_MODES.SIDE_BY_SIDE) { destroyMediaProcessorsIn(sideView); sideView.replaceChildren(); } } catch (e: any) { console.debug?.(e); } renderBadges(); // Hide navigation for compare modes (selected-set compare). const hideNav = (state.mode === VIEWER_MODES.AB_COMPARE && canAB()) || (state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide()); try { prevBtn.style.display = hideNav ? "none" : ""; nextBtn.style.display = hideNav ? "none" : ""; } catch (e: any) { console.debug?.(e); } // Render current asset renderAsset(); preloadAdjacentAssets(state.assets, state.currentIndex); // Mount/unmount the video player bar depending on current media. syncPlayerBar(); try { toolbar?.syncToolsUIFromState?.(); } catch (e: any) { console.debug?.(e); } try { applyDistractionFreeUI(); } catch (e: any) { console.debug?.(e); } try { scheduleApplyGrade?.(); } catch (e: any) { console.debug?.(e); } scheduleOverlayRedraw(); try { void renderGenInfoPanel(); } catch (e: any) { console.debug?.(e); } // Hydrate rating/tags for visible assets and refresh the badge bar. try { void hydrateVisibleMetadata().then(() => { try { renderBadges(); } catch (e: any) { console.debug?.(e); } }); } catch (e: any) { console.debug?.(e); } // Sync filmstrip highlight and scroll position. try { const isSingle = state.mode === VIEWER_MODES.SINGLE; filmstrip.sync({ isSingle }); } catch (e: any) { console.debug?.(e); } } // Render the current asset based on mode function renderAsset() { const current = state.assets[state.currentIndex]; if (!current) return; const viewUrl = buildAssetViewURL(current); if (!viewUrl) { try { destroyMediaProcessorsIn(singleView); } catch (e: any) { console.debug?.(e); } try { singleView.replaceChildren(); const err = document.createElement("div"); err.className = "mjr-viewer-media"; err.style.cssText = "color:#ff9a9a; font-size:13px; padding:16px; text-align:center;"; err.textContent = "Cannot open asset: missing or invalid filename/path."; singleView.appendChild(err); } catch (e: any) { console.debug?.(e); } return; } if (state.mode === VIEWER_MODES.SINGLE) { try { destroyMediaProcessorsIn(singleView); } catch (e: any) { console.debug?.(e); } singleView.innerHTML = ""; state._mediaW = 0; state._mediaH = 0; const media = createMediaElement(current, viewUrl); singleView.appendChild(media); } else if (state.mode === VIEWER_MODES.AB_COMPARE) { if (canAB()) { _mod_abCompare?.renderABCompareView?.({ abView, state, currentAsset: current, viewUrl, buildAssetViewURL, createCompareMediaElement, destroyMediaProcessorsIn, }); } } else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE) { if (canSide()) { _mod_sideBySide?.renderSideBySideView?.({ sideView, state, currentAsset: current, viewUrl, buildAssetViewURL, createMediaElement, destroyMediaProcessorsIn, }); } } applyTransform(); updatePanCursor(); } const { preloadAdjacentAssets, preloadImageForAsset: _preloadImageForAsset, trackPreloadRef: _trackPreloadRef, } = createImagePreloader({ buildAssetViewURL, IMAGE_PRELOAD_EXTENSIONS, state, }); const { destroyPlayerBar, syncPlayerBar: _syncPlayerBarImpl } = createPlayerBarManager({ state, APP_CONFIG, VIEWER_MODES, overlay, navBar, playerBarHost, singleView, abView, sideView, metadataHydrator, isPlayableViewerKind, collectPlayableMediaElements, pickPrimaryPlayableMedia, mountUnifiedMediaControls, installFollowerVideoSync, getViewerInfo, scheduleOverlayRedraw, viewerInfoCacheGet: _viewerInfoCacheGet, viewerInfoCacheSet: _viewerInfoCacheSet, }); const syncPlayerBar = () => _syncPlayerBarImpl(); // ---------------------------------------------------------------------------- // Image processing (Nuke-like): exposure, gamma, channels, false color, zebra // ---------------------------------------------------------------------------- const MAX_PROC_PIXELS = APP_CONFIG.VIEWER_MAX_PROC_PIXELS ?? 12_000_000; const getGradeParams = () => ({ exposureEV: Number(state.exposureEV) || 0, gamma: Math.max(0.1, Math.min(3, Number(state.gamma) || 1)), channel: state.channel || "rgb", analysisMode: state.analysisMode || "none", zebraThreshold: Math.max(0, Math.min(1, Number(state.zebraThreshold) || 0.95)), }); const applyGradeToVisibleMedia = () => { const params = getGradeParams(); try { const els = overlay.querySelectorAll(".mjr-viewer-media"); for (const el of els) { try { const proc = (el as any)?._mjrProc; if (proc?.setParams) proc.setParams(params); } catch (e: any) { console.debug?.(e); } } } catch (e: any) { console.debug?.(e); } // If A/B compare is showing a computed diff canvas, force a refresh after grade changes. try { if (state?.mode === VIEWER_MODES.AB_COMPARE) { abView?._mjrDiffRequest?.(); } } catch (e: any) { console.debug?.(e); } }; const isDefaultGrade = (params: any) => { try { if (!params) return true; const exposureEV = Number(params.exposureEV) || 0; const gamma = Number(params.gamma) || 1; const channel = String(params.channel || "rgb"); const analysisMode = String(params.analysisMode || "none"); return ( Math.abs(exposureEV) < 0.0001 && Math.abs(gamma - 1) < 0.0001 && channel === "rgb" && analysisMode === "none" ); } catch { return true; } }; const MAX_PROC_PIXELS_VIDEO = APP_CONFIG.VIEWER_MAX_PROC_PIXELS_VIDEO ?? 3_000_000; const VIDEO_GRADE_THROTTLE_FPS = APP_CONFIG.VIEWER_VIDEO_GRADE_THROTTLE_FPS ?? 15; try { mediaFactory = createViewerMediaFactory({ overlay, state, mediaTransform, updateMediaNaturalSize, clampPanToBounds, applyTransform, scheduleOverlayRedraw, getGradeParams, isDefaultGrade, tonemap: null, maxProcPixels: MAX_PROC_PIXELS, maxProcPixelsVideo: MAX_PROC_PIXELS_VIDEO, disableWebGL: !!APP_CONFIG.VIEWER_DISABLE_WEBGL_VIDEO, videoGradeThrottleFps: VIDEO_GRADE_THROTTLE_FPS, safeAddListener, safeCall, }); } catch { mediaFactory = null; } // Navigation lifecycleUnsubs.push( safeAddListener(prevBtn, "click", () => { if (state.currentIndex > 0) { state.currentIndex--; updateUI(); } }), ); lifecycleUnsubs.push( safeAddListener(nextBtn, "click", () => { if (state.currentIndex < state.assets.length - 1) { state.currentIndex++; updateUI(); } }), ); // Nuke-like processing controls (images only): channel/exposure/gamma/analysis. let _gradeRAF: any = null; const scheduleApplyGrade = () => { try { if (_gradeRAF != null) return; _gradeRAF = requestAnimationFrame(() => { _gradeRAF = null; try { applyGradeToVisibleMedia(); } catch (e: any) { console.debug?.(e); } }); } catch (e: any) { console.debug?.(e); } }; const syncToolsUIFromState = () => { try { toolbar?.syncToolsUIFromState?.(); } catch (e: any) { console.debug?.(e); } }; // Mouse wheel zoom (trackpad-friendly). Capture + preventDefault so ComfyUI canvas doesn't zoom underneath. const navigateViewerAssets = (direction: any) => { if (!Array.isArray(state.assets) || state.assets.length === 0) { return false; } const nextIndex = state.currentIndex + direction; if (nextIndex < 0 || nextIndex >= state.assets.length) { return false; } state.currentIndex = nextIndex; updateUI(); return true; }; const onWheelZoom = (e: any) => { if (overlay.style.display === "none") return; try { const t = e.target; if ( t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable) ) { return; } } catch (e: any) { console.debug?.(e); } // Only zoom when hovering the viewer content area. try { if (!content.contains(e.target)) return; } catch (e: any) { console.debug?.(e); } try { if (_mod_model3d?.isModel3DInteractionTarget?.(e?.target)) return; } catch (e: any) { console.debug?.(e); } try { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); } catch (e: any) { console.debug?.(e); } // Shift+wheel or horizontal scroll (trackpad) = navigate assets const dx = Number(e.deltaX) || 0; const dy = Number(e.deltaY) || 0; if (e.shiftKey && dy) { const direction = dy > 0 ? 1 : -1; if (navigateViewerAssets(direction)) return; } // Horizontal scroll (trackpad swipe) navigates between assets if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 30) { const direction = dx > 0 ? 1 : -1; if (navigateViewerAssets(direction)) return; } if (!dy) return; // Smooth scaling: wheel down -> zoom out, wheel up -> zoom in. const factor = Math.exp(-dy * 0.0015); const next = (Number(state.zoom) || 1) * factor; setZoom(next, { clientX: e.clientX, clientY: e.clientY }); // setZoom now handles transform application directly }; // Pixel probe + loupe sampling (Radiance-inspired). const positionOverlayBox = (el: any, clientX: any, clientY: any, { offsetX = 16, offsetY = 16 } = {}) => { try { const v = getViewportRect(); if (!v) return; const vp = content.getBoundingClientRect(); const x0 = (Number(clientX) || 0) - vp.left; const y0 = (Number(clientY) || 0) - vp.top; const w = Number(el.offsetWidth) || 0; const h = Number(el.offsetHeight) || 0; let x = x0 + offsetX; let y = y0 + offsetY; const pad = 10; x = Math.max(pad, Math.min(x, v.width - w - pad)); y = Math.max(pad, Math.min(y, v.height - h - pad)); el.style.left = `${Math.round(x)}px`; el.style.top = `${Math.round(y)}px`; } catch (e: any) { console.debug?.(e); } }; const loupe = createViewerLoupe({ state, loupeCanvas, loupeWrap, getMediaNaturalSize, positionOverlayBox, }); const probe = installViewerProbe({ overlay, content, state, VIEWER_MODES, getPrimaryMedia, getMediaNaturalSize, getViewportRect, positionOverlayBox, probeTooltip, loupeWrap, onLoupeRedraw: loupe.redraw, lifecycle, }); // Resize observer so grid canvas stays crisp and overlays remain aligned. try { if (!content._mjrOverlayResizeBound && "ResizeObserver" in window) { try { (overlay as any)._mjrResizeObserver?.disconnect?.(); } catch (e: any) { console.debug?.(e); } const ro = new ResizeObserver(() => { try { state._viewportCache = null; } catch (e: any) { console.debug?.(e); } scheduleOverlayRedraw(); }); try { ro.observe(content); } catch (e: any) { console.debug?.(e); } (overlay as any)._mjrResizeObserver = ro; lifecycleUnsubs.push(() => { try { ro.disconnect(); } catch (e: any) { console.debug?.(e); } }); content._mjrOverlayResizeBound = true; } } catch (e: any) { console.debug?.(e); } // Keyboard shortcuts (installed on capture phase to avoid ComfyUI/global handlers eating events). const keyboard = installViewerKeyboard({ overlay, content, singleView, state, VIEWER_MODES, computeOneToOneZoom, setZoom, scheduleOverlayRedraw, scheduleApplyGrade, syncToolsUIFromState, applyDistractionFreeUI, navigateViewerAssets, closeViewer, renderBadges, updateAssetRating, safeDispatchCustomEvent, ASSET_RATING_CHANGED_EVENT, probeTooltip, loupeWrap, renderGenInfoPanel, getVideoControls: () => { try { return state?._videoControlsMounted || null; } catch { return null; } }, lifecycle, }); // Bind/unbind global listeners only while the viewer is visible. // Prevents hotkeys/scroll from leaking to ComfyUI/browser when the viewer is closed. let _openUnsubs: any[] = []; const disposeOpenListeners = () => { try { for (const u of _openUnsubs) safeCall(u); } catch (e: any) { console.debug?.(e); } _openUnsubs = []; try { keyboard?.unbind?.(); } catch (e: any) { console.debug?.(e); } }; const bindOpenListeners = () => { disposeOpenListeners(); try { _openUnsubs.push( safeAddListener(overlay, "click", (e: any) => { try { if (e.target !== overlay) return; } catch (e: any) { console.debug?.(e); } closeViewer(); }), ); } catch (e: any) { console.debug?.(e); } try { _openUnsubs.push( safeAddListener(content, "wheel", onWheelZoom, { passive: false, capture: true }), ); } catch (e: any) { console.debug?.(e); } // Touch swipe navigation (swipe left/right to navigate assets) try { let _touchStart: any = null; const SWIPE_THRESHOLD = 60; // minimum px to trigger swipe const SWIPE_MAX_VERTICAL = 80; // ignore if too much vertical movement _openUnsubs.push( safeAddListener( content, "touchstart", (e) => { try { if ((e as any).touches?.length !== 1) return; const t = (e as any).touches[0]; _touchStart = { x: t.clientX, y: t.clientY, t: Date.now() }; } catch (ex: any) { console.debug?.(ex); } }, { passive: true }, ), ); _openUnsubs.push( safeAddListener( content, "touchend", (e) => { try { if (!_touchStart) return; if ((e as any).changedTouches?.length !== 1) { _touchStart = null; return; } const t = (e as any).changedTouches[0]; const dx = t.clientX - _touchStart.x; const dy = t.clientY - _touchStart.y; const elapsed = Date.now() - _touchStart.t; _touchStart = null; // Ignore if too slow (>600ms) or too much vertical movement if (elapsed > 600 || Math.abs(dy) > SWIPE_MAX_VERTICAL) return; // Check swipe direction if (Math.abs(dx) >= SWIPE_THRESHOLD) { const direction = dx < 0 ? 1 : -1; // swipe left = next navigateViewerAssets(direction); } } catch (ex: any) { console.debug?.(ex); } }, { passive: true }, ), ); _openUnsubs.push( safeAddListener( content, "touchcancel", () => { _touchStart = null; }, { passive: true }, ), ); } catch (e: any) { console.debug?.(e); } try { _openUnsubs.push( safeAddListener( content, "mousemove", (e) => { try { state._lastPointerX = (e as any).clientX; state._lastPointerY = (e as any).clientY; } catch (e: any) { console.debug?.(e); } }, { passive: true, capture: true }, ), ); } catch (e: any) { console.debug?.(e); } try { keyboard?.bind?.(); } catch (e: any) { console.debug?.(e); } }; // Keep viewer badges in sync when tags/ratings change elsewhere (sidebar, panel hotkeys, etc.). try { if (!(overlay as any)._mjrBadgeSyncBound) { const onRatingSync = (e: any) => { try { const id = e?.detail?.assetId; const rating = e?.detail?.rating; if (id == null) return; for (const a of state.assets || []) { if (a?.id != null && String(a.id) === String(id)) { a.rating = rating; } } try { metadataHydrator?.deleteCached?.(id); } catch (e: any) { console.debug?.(e); } renderBadges(); } catch (e: any) { console.debug?.(e); } }; const onTagsSync = (e: any) => { try { const id = e?.detail?.assetId; const tags = e?.detail?.tags; if (id == null) return; for (const a of state.assets || []) { if (a?.id != null && String(a.id) === String(id)) { a.tags = tags; } } try { metadataHydrator?.deleteCached?.(id); } catch (e: any) { console.debug?.(e); } renderBadges(); } catch (e: any) { console.debug?.(e); } }; lifecycleUnsubs.push( safeAddListener(window, ASSET_RATING_CHANGED_EVENT, onRatingSync, { passive: true, }), ); lifecycleUnsubs.push( safeAddListener(window, ASSET_TAGS_CHANGED_EVENT, onTagsSync, { passive: true }), ); (overlay as any)._mjrBadgeSyncBound = true; } } catch (e: any) { console.debug?.(e); } function closeViewer() { try { const current = state.assets?.[state.currentIndex]; if (current?.id) { safeDispatchCustomEvent( VIEWER_ACTIVE_ASSET_CHANGED_EVENT, { assetId: String(current.id) }, { warnPrefix: "[ViewerRuntime]" }, ); } } catch (e: any) { console.debug?.(e); } try { state.distractionFree = false; applyDistractionFreeUI(); } catch (e: any) { console.debug?.(e); } try { metadataHydrator?.abort?.(); } catch (e: any) { console.debug?.(e); } try { destroyPlayerBar(); } catch (e: any) { console.debug?.(e); } try { state._scopesVideoAbort?.abort?.(); } catch (e: any) { console.debug?.(e); } state._scopesVideoAbort = null; // Clear any pending hint timers (avoid callbacks after close/dispose). try { if (state._panHintTimer) clearTimeout(state._panHintTimer); } catch (e: any) { console.debug?.(e); } state._panHintTimer = null; try { state._panHintAt = 0; } catch (e: any) { console.debug?.(e); } // Abort compare-mode background work (sync listeners, diff loops). try { abView?._mjrSyncAbort?.abort?.(); } catch (e: any) { console.debug?.(e); } try { abView?._mjrDiffAbort?.abort?.(); } catch (e: any) { console.debug?.(e); } try { abView._mjrSyncAbort = null; } catch (e: any) { console.debug?.(e); } try { abView._mjrDiffAbort = null; } catch (e: any) { console.debug?.(e); } try { sideView?._mjrSyncAbort?.abort?.(); } catch (e: any) { console.debug?.(e); } try { sideView._mjrSyncAbort = null; } catch (e: any) { console.debug?.(e); } try { abView?._mjrSliderAbort?.abort?.(); } catch (e: any) { console.debug?.(e); } try { abView._mjrSliderAbort = null; } catch (e: any) { console.debug?.(e); } // Ensure media playback is fully stopped when closing the overlay. // Hiding the overlay alone does not stop HTMLMediaElement audio in all browsers. try { const mediaEls = overlay.querySelectorAll?.("video, audio"); if (mediaEls && mediaEls.length) { for (const el of mediaEls) { try { (el as HTMLVideoElement).muted = true; } catch (e: any) { console.debug?.(e); } try { (el as any).pause?.(); } catch (e: any) { console.debug?.(e); } try { (el as HTMLVideoElement).currentTime = 0; } catch (e: any) { console.debug?.(e); } try { // Remove all children (if any) and unload the element. const sources = el.querySelectorAll?.("source"); if (sources && sources.length) { for (const s of sources) { try { s.remove(); } catch (e: any) { console.debug?.(e); } } } } catch (e: any) { console.debug?.(e); } try { el.removeAttribute?.("src"); } catch (e: any) { console.debug?.(e); } try { // Some browsers keep playing unless we force a reload after removing src. (el as any).load?.(); } catch (e: any) { console.debug?.(e); } } } } catch (e: any) { console.debug?.(e); } // Free DOM resources (and ensure playback stops even if `pause()` was ignored). try { destroyMediaProcessorsIn(singleView); singleView.replaceChildren(); } catch (e: any) { console.debug?.(e); } try { destroyMediaProcessorsIn(abView); abView.replaceChildren(); } catch (e: any) { console.debug?.(e); } try { destroyMediaProcessorsIn(sideView); sideView.replaceChildren(); } catch (e: any) { console.debug?.(e); } try { state.genInfoOpen = false; } catch (e: any) { console.debug?.(e); } try { stopGenInfoFetch(); } catch (e: any) { console.debug?.(e); } try { genInfoOverlay.style.display = "none"; clearGenInfoBody(genInfoBody); } catch (e: any) { console.debug?.(e); } try { genInfoOverlayLeft.style.display = "none"; clearGenInfoBody(genInfoBodyLeft); } catch (e: any) { console.debug?.(e); } overlay.style.display = "none"; overlay.style.pointerEvents = "none"; disposeOpenListeners(); try { document.body.style.overflow = state._prevBodyOverflow ?? ""; } catch { document.body.style.overflow = ""; } // FIX: Restore focus to previously focused element for accessibility try { if ( state._prevFocusedElement && typeof state._prevFocusedElement.focus === "function" ) { state._prevFocusedElement.focus(); } state._prevFocusedElement = null; } catch (e: any) { console.debug?.(e); } // Restore previous hotkeys scope if available. const prevScope = state?._prevHotkeyScope; setHotkeysScope(prevScope || "panel"); state._prevHotkeyScope = null; } // Public API const api = { open(assets: any, startIndex = 0, compareAsset = null) { bindOpenListeners(); state.assets = Array.isArray(assets) ? assets : [assets]; state.currentIndex = Math.max(0, Math.min(startIndex, state.assets.length - 1)); state.distractionFree = false; // Rebuild the filmstrip thumbnail strip for the new asset list. try { filmstrip.rebuild(); } catch (e: any) { console.debug?.(e); } state.zoom = 1; state.panX = 0; state.panY = 0; state.targetZoom = 1; state._userInteracted = false; state._panHintAt = 0; try { if (state._panHintTimer) clearTimeout(state._panHintTimer); } catch (e: any) { console.debug?.(e); } state._panHintTimer = null; state._lastPointerX = null; state._lastPointerY = null; state._mediaW = 0; state._mediaH = 0; state.compareAsset = compareAsset; state.gridMode = 0; stopGenInfoFetch(); state._probe = null; try { probeTooltip.style.display = "none"; } catch (e: any) { console.debug?.(e); } try { loupeWrap.style.display = "none"; } catch (e: any) { console.debug?.(e); } overlay.style.display = "flex"; overlay.style.pointerEvents = "auto"; // FIX: Store previously focused element and set focus to overlay for accessibility try { state._prevFocusedElement = document.activeElement; } catch { state._prevFocusedElement = null; } // Focus the overlay so screen readers announce it as a dialog overlay.focus(); try { state._prevBodyOverflow = document.body.style.overflow; } catch { state._prevBodyOverflow = ""; } document.body.style.overflow = "hidden"; // Set hotkeys scope to viewer (takes priority over panel) state._prevHotkeyScope = getHotkeysState().scope || null; setHotkeysScope("viewer"); updateUI(); try { syncToolsUIFromState(); } catch (e: any) { console.debug?.(e); } try { scheduleApplyGrade(); } catch (e: any) { console.debug?.(e); } scheduleOverlayRedraw(); // No animation loop needed - transforms are applied directly }, close() { closeViewer(); // No animation loop to stop }, setMode(mode: any) { if (Object.values(VIEWER_MODES).includes(mode)) { state.mode = mode; updateUI(); } }, setCompareAsset(asset: any) { state.compareAsset = asset; updateUI(); }, dispose() { // Best-effort: never throw to UI. try { closeViewer(); } catch (e: any) { console.debug?.(e); } try { _viewerInfoCache.clear(); } catch (e: any) { console.debug?.(e); } try { destroyMediaProcessorsIn(singleView); } catch (e: any) { console.debug?.(e); } try { destroyMediaProcessorsIn(abView); } catch (e: any) { console.debug?.(e); } try { destroyMediaProcessorsIn(sideView); } catch (e: any) { console.debug?.(e); } try { if (_overlayRAF != null) cancelAnimationFrame(_overlayRAF); } catch (e: any) { console.debug?.(e); } try { if (_gradeRAF != null) cancelAnimationFrame(_gradeRAF); } catch (e: any) { console.debug?.(e); } try { probe?.dispose?.(); } catch (e: any) { console.debug?.(e); } try { keyboard?.dispose?.(); } catch (e: any) { console.debug?.(e); } try { (overlay as any)._mjrResizeObserver?.disconnect?.(); } catch (e: any) { console.debug?.(e); } try { (overlay as any)._mjrResizeObserver = null; } catch (e: any) { console.debug?.(e); } try { metadataHydrator?.dispose?.(); } catch (e: any) { console.debug?.(e); } try { unbindViewerContextMenu(overlay); } catch (e: any) { console.debug?.(e); } try { for (const u of (overlay as any)._mjrViewerUnsubs || []) safeCall(u); } catch (e: any) { console.debug?.(e); } try { (overlay as any)._mjrViewerUnsubs = []; } catch (e: any) { console.debug?.(e); } try { state._preloadRefs?.clear?.(); } catch (e: any) { console.debug?.(e); } try { state._preloadedAssetKeys?.clear?.(); } catch (e: any) { console.debug?.(e); } try { overlay.remove?.(); } catch (e: any) { console.debug?.(e); } }, }; // Keep toolbar close non-destructive so the singleton viewer can reopen reliably. try { _requestCloseFromButton = () => api.close(); } catch (e: any) { console.debug?.(e); } (overlay as any)._mjrViewerAPI = api; // Right-click context menu (viewer): tags/rating/open-in-folder/copy path. try { bindViewerContextMenu({ overlayEl: overlay, getCurrentAsset: () => state.assets[state.currentIndex], getCurrentViewUrl: (asset: any) => buildAssetViewURL(asset), onAssetChanged: () => { try { renderBadges(); } catch (e: any) { console.debug?.(e); } }, }); } catch (e: any) { console.debug?.(e); } return overlay; } /** * Helper to create icon button */ /** * Get or create global viewer instance */ export function getViewerInstance(): any { return _getViewerInstance(createViewer); }