diff --git a/js/components/Card.js b/js/components/Card.js index 1c29407f..8ffacead 100644 --- a/js/components/Card.js +++ b/js/components/Card.js @@ -92,6 +92,7 @@ const getVideoThumbManager = () => { const playing = new Set(); const prepared = new Set(); const observed = new Set(); + const pausedForHide = new Set(); // videos paused on tab-hide, resumed when tab returns let cleanupTimer = null; let removeVisibilityListener = null; let removeSettingsListener = null; @@ -113,15 +114,18 @@ const getVideoThumbManager = () => { } catch (e) { console.debug?.(e); } try { while (prepared.size > VIDEO_THUMB_MAX_PREPARED) { - const oldest = prepared.values().next().value; - prepared.delete(oldest); + // First entry in Set = least recently used (re-insertion keeps LRU order). + const lru = prepared.values().next().value; + prepared.delete(lru); try { - oldest?.pause?.(); + lru?.pause?.(); + // Clear stale flags so the evicted element doesn't auto-resume. + if (lru) { lru._mjrVisible = false; lru._mjrHovered = false; } } catch (e) { console.debug?.(e); } try { - if (oldest?.getAttribute?.("src")) { - oldest.removeAttribute("src"); - oldest.load?.(); + if (lru?.getAttribute?.("src")) { + lru.removeAttribute("src"); + lru.load?.(); } } catch (e) { console.debug?.(e); } } @@ -199,19 +203,21 @@ const getVideoThumbManager = () => { } try { - // Prefer a later frame to avoid black/fade-in thumbnails when frame 0 is empty. - video.currentTime = VIDEO_THUMB_FIRST_FRAME_SEEK_S; + // For clips longer than VIDEO_THUMB_FIRST_FRAME_SEEK_S, seek to that position + // to skip black/fade-in frames. For shorter clips, seek to 10 % of the + // duration (min 0.05 s) so we still avoid frame-0 black frames. + const dur = Number.isFinite(video.duration) && video.duration > 0 + ? video.duration : null; + const preferred = (dur !== null && dur <= VIDEO_THUMB_FIRST_FRAME_SEEK_S) + ? Math.max(0.05, dur * 0.1) + : VIDEO_THUMB_FIRST_FRAME_SEEK_S; + video.currentTime = dur !== null + ? Math.min(preferred, dur - 0.01) + : preferred; } catch { clearTimeout(fallbackTimer); return resolve(); } - - try { - // Fallback for very short clips where 1.0s may be out of range. - if (!Number.isFinite(video.currentTime) || video.currentTime <= 0) { - video.currentTime = 0.05; - } - } catch (e) { console.debug?.(e); } }); const stopVideo = (video, { releaseSrc = true } = {}) => { @@ -245,8 +251,11 @@ const getVideoThumbManager = () => { const src = video.dataset.src || ""; if (!src) return false; - if (!prepared.has(video)) { - prepared.add(video); + const isNew = !prepared.has(video); + // Re-insert to refresh LRU position (most recently used = last in Set). + prepared.delete(video); + prepared.add(video); + if (isNew) { pruneSets(); try { if (!video.getAttribute("src")) { @@ -267,24 +276,26 @@ const getVideoThumbManager = () => { const src = video.dataset.src || ""; if (!src) return; - if (!playing.has(video)) { - playing.add(video); - } else { - playing.delete(video); - playing.add(video); - } + // Refresh insertion order so playing Set acts as LRU. + playing.delete(video); + playing.add(video); await ensurePrepared(video); + const overlay = (() => { try { return video._mjrPlayOverlay || null; } catch { return null; } })(); try { const p = video.play(); if (p && typeof p.then === "function") { p.catch(async () => { await ensurePrepared(video); + // play() rejected (autoplay policy, missing codec…) — no pause event fires, + // so restore the overlay manually. + try { if (overlay) overlay.style.opacity = "1"; } catch (e) { console.debug?.(e); } }); } } catch { await ensurePrepared(video); + try { if (overlay) overlay.style.opacity = "1"; } catch (e) { console.debug?.(e); } } }; @@ -299,14 +310,27 @@ const getVideoThumbManager = () => { }; const onVisibility = () => { - if (!document.hidden) return; - for (const v of Array.from(playing)) { - playing.delete(v); - stopVideo(v); - try { - const overlay = v._mjrPlayOverlay || null; - if (overlay) overlay.style.opacity = "1"; - } catch (e) { console.debug?.(e); } + if (document.hidden) { + // Pause all playing videos; remember them so we can resume on return. + for (const v of Array.from(playing)) { + pausedForHide.add(v); + playing.delete(v); + try { v.pause(); } catch (e) { console.debug?.(e); } + try { + const overlay = v._mjrPlayOverlay || null; + if (overlay) overlay.style.opacity = "1"; + } catch (e) { console.debug?.(e); } + } + } else { + // Tab became visible — resume videos that were paused due to hide. + const mode = getVideoAutoplayMode(); + for (const v of Array.from(pausedForHide)) { + pausedForHide.delete(v); + if (!v.isConnected || !observed.has(v) || !v._mjrVisible) continue; + const shouldResume = mode === "always" || (mode === "hover" && !!v._mjrHovered); + if (shouldResume) playVideo(v); + } + pausedForHide.clear(); } }; @@ -434,6 +458,30 @@ const getVideoThumbManager = () => { video._mjrThumbBound = true; } catch (e) { console.debug?.(e); } + // Authoritative overlay sync: driven by native playback events. + // This resolves any race between async play() and subsequent stop/pause calls. + try { + video.addEventListener("playing", () => { + try { + const ol = video._mjrPlayOverlay; + if (ol) ol.style.opacity = "0"; + } catch (e) { console.debug?.(e); } + }); + video.addEventListener("pause", () => { + try { + const ol = video._mjrPlayOverlay; + if (ol) ol.style.opacity = "1"; + } catch (e) { console.debug?.(e); } + }); + video.addEventListener("ended", () => { + // loop=true means this rarely fires, but guard it anyway. + try { + const ol = video._mjrPlayOverlay; + if (ol) ol.style.opacity = "1"; + } catch (e) { console.debug?.(e); } + }); + } catch (e) { console.debug?.(e); } + try { video.addEventListener("error", () => { playing.delete(video); @@ -447,12 +495,20 @@ const getVideoThumbManager = () => { }, observe(video) { if (!observer || !video) return; + let hadTimer = false; try { + hadTimer = !!video._mjrReleaseTimer; if (video._mjrReleaseTimer) { clearTimeout(video._mjrReleaseTimer); video._mjrReleaseTimer = null; } } catch (e) { console.debug?.(e); } + // No pending release timer → virtual grid recycled this DOM node for a new asset. + // Clear stale state so the previous card's playback intent doesn't leak to the new asset. + if (!hadTimer) { + try { video._mjrResumeOnVisible = false; } catch (e) { console.debug?.(e); } + try { video._mjrHovered = false; } catch (e) { console.debug?.(e); } + } try { api._bindVideo(video); observed.add(video); @@ -565,6 +621,7 @@ const getVideoThumbManager = () => { try { removeSettingsListener?.(); } catch (e) { console.debug?.(e); } + try { pausedForHide.clear(); } catch (e) { console.debug?.(e); } // Disconnect the IntersectionObserver so it stops firing after dispose. // observed.clear() alone does not stop the observer from holding // references to previously-observed elements. @@ -852,6 +909,11 @@ function createThumbnail(asset, viewUrl) { video.onerror = () => { video.style.display = "none"; + // Hide the play overlay so it doesn't float over the error placeholder. + try { + const ol = video._mjrPlayOverlay; + if (ol) ol.style.display = "none"; + } catch (e) { console.debug?.(e); } const err = createMediaErrorPlaceholder("pi pi-video"); thumb.appendChild(err); }; diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js index 1a3be0e0..c112bdc8 100644 --- a/js/features/panel/AssetsManagerPanel.js +++ b/js/features/panel/AssetsManagerPanel.js @@ -447,7 +447,18 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true } // 2. Grid visuals (badges, details, sizes) // Refresh only when a relevant setting changed to avoid reload storms. if (shouldRefreshGrid) { - refreshGrid(gridContainer); + // Siblings toggle requires a full data reload: PNG siblings are filtered + // during appendAssets (state.assets), so refreshGrid alone can't add them + // back or remove them — a re-fetch is needed. + if (!isInitialSync && changedKey.startsWith("siblings.")) { + try { + gridContainer.dispatchEvent(new CustomEvent("mjr:reload-grid")); + } catch (e) { + refreshGrid(gridContainer); + } + } else { + refreshGrid(gridContainer); + } } if (shouldRefreshSidebar && state.sidebarOpen && state.activeAssetId) { sidebarController?.refreshActiveAsset?.(); diff --git a/js/features/viewer/FloatingViewer.js b/js/features/viewer/FloatingViewer.js index 572fdcbb..2421081c 100644 --- a/js/features/viewer/FloatingViewer.js +++ b/js/features/viewer/FloatingViewer.js @@ -222,14 +222,14 @@ export class FloatingViewer { // stale async metadata enrichment results can be discarded (NM-2). this._refreshGen = 0; - // Pop-out state: the external window reference and pop-out button. + // Pop-out state: external window reference and button. this._popoutWindow = null; this._popoutBtn = null; this._isPopped = false; - this._popoutCloseHandler = null; + this._popoutCloseHandler = null; this._popoutKeydownHandler = null; - this._popoutCloseTimer = null; - this._popoutRestoreGuard = false; + this._popoutCloseTimer = null; + this._popoutRestoreGuard = false; // Preview stream state: button ref + last blob URL for cleanup. this._previewBtn = null; @@ -377,7 +377,7 @@ export class FloatingViewer { this._popoutBtn = document.createElement("button"); this._popoutBtn.type = "button"; this._popoutBtn.className = "mjr-icon-btn"; - const popoutLabel = t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)"); + const popoutLabel = t("tooltip.popOutViewer", "Expand to full screen (Esc or button to return)"); this._popoutBtn.title = popoutLabel; this._popoutBtn.setAttribute("aria-label", popoutLabel); this._popoutBtn.setAttribute("aria-pressed", "false"); @@ -788,12 +788,26 @@ export class FloatingViewer { this._previewBlobUrl = url; // Build a minimal fileData that _resolveUrl can handle const fileData = { url, filename: "preview.jpg", kind: "image", _isPreview: true }; - this._mediaA = fileData; - this._resetMfvZoom(); - // Force simple mode for preview — don't trigger metadata enrichment - if (this._mode !== MFV_MODES.SIMPLE) { - this._mode = MFV_MODES.SIMPLE; - this._updateModeBtnUI(); + + const inCompare = this._mode === MFV_MODES.AB || this._mode === MFV_MODES.SIDE; + if (inCompare) { + // In compare mode: route the preview steps to the non-pinned slot so the + // user's reference image stays intact. Default: preview → B, selection → A. + if (this.getPinnedSlot() === "B") { + this._mediaA = fileData; // B pinned as reference — stream to A + } else { + this._mediaB = fileData; // A pinned (or no pin) — stream to B + } + // Do NOT reset zoom (preview frames arrive rapidly; resetting each time + // would make the reference slot unusable) and do NOT switch mode. + } else { + // Simple mode: preview takes slot A, keep it simple. + this._mediaA = fileData; + this._resetMfvZoom(); + if (this._mode !== MFV_MODES.SIMPLE) { + this._mode = MFV_MODES.SIMPLE; + this._updateModeBtnUI(); + } } ++this._refreshGen; this._refresh(); @@ -1207,7 +1221,7 @@ export class FloatingViewer { this.isVisible = false; } - // ── Pop-out / Pop-in (external window) ────────────────────────────────── + // ── Pop-out / Pop-in (separate OS window for second monitor) ──────────── /** * Move the viewer into a separate browser window so it can be @@ -1219,11 +1233,86 @@ export class FloatingViewer { const el = this.element; this._stopEdgeResize(); - // Remember size for the popup const w = Math.max(el.offsetWidth || 520, 400); const h = Math.max(el.offsetHeight || 420, 300); - // Open a blank popup centred on the current screen + // 1. Document Picture-in-Picture (Chrome 116+) ───────────────────────── + // Creates a same-origin always-on-top window that shares the full JS + // context of the main page. Bypasses the about:blank isolation that + // breaks window.open() in Chrome App mode and Electron-based hosts. + if ("documentPictureInPicture" in window) { + window.documentPictureInPicture.requestWindow({ width: w, height: h }) + .then((pipWindow) => { + this._popoutWindow = pipWindow; + this._isPopped = true; + this._popoutRestoreGuard = false; + try { this._popoutAC?.abort(); } catch (e) { console.debug?.(e); } + this._popoutAC = new AbortController(); + const popoutSignal = this._popoutAC.signal; + + const handlePopupClosing = () => this._schedulePopInFromPopupClose(); + this._popoutCloseHandler = handlePopupClosing; + + const doc = pipWindow.document; + doc.title = "Majoor Viewer"; + this._installPopoutStyles(doc); + + // Build a minimal shell directly — no server round-trip needed. + doc.body.style.cssText = "margin:0;display:flex;min-height:100vh;background:#111;overflow:hidden;"; + const root = doc.createElement("div"); + root.id = "mjr-mfv-popout-root"; + root.style.cssText = "flex:1;min-width:0;min-height:0;display:flex;"; + doc.body.appendChild(root); + + try { + root.appendChild(doc.adoptNode(el)); + } catch (e) { + console.warn("[MFV] PiP adoptNode failed, falling back to window.open", e); + this._isPopped = false; + this._popoutWindow = null; + try { pipWindow.close(); } catch (_) { /* noop */ } + this._fallbackPopout(el, w, h); + return; + } + el.classList.add("is-visible"); + this.isVisible = true; + + this._resetGenDropdownForCurrentDocument(); + this._rebindControlHandlers(); + this._bindDocumentUiHandlers(); + this._updatePopoutBtnUI(); + + pipWindow.addEventListener("pagehide", handlePopupClosing, { signal: popoutSignal }); + this._startPopoutCloseWatch(); + + this._popoutKeydownHandler = (e) => { + const tag = String(e?.target?.tagName || "").toLowerCase(); + if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return; + const lower = String(e?.key || "").toLowerCase(); + window.dispatchEvent(new KeyboardEvent("keydown", { + key: e.key, code: e.code, keyCode: e.keyCode, + ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey, + })); + }; + pipWindow.addEventListener("keydown", this._popoutKeydownHandler, { signal: popoutSignal }); + }) + .catch((err) => { + console.warn("[MFV] Document PiP failed, falling back to window.open", err); + this._fallbackPopout(el, w, h); + }); + return; + } + + // 2. Fallback: classic window.open() to the backend-served shell page. + this._fallbackPopout(el, w, h); + } + + /** + * Classic popup fallback used when Document PiP is unavailable. + * Opens /mjr/viewer/popout (served by the backend) and mounts the viewer + * once the shell page finishes loading. + */ + _fallbackPopout(el, w, h) { const left = (window.screenX || window.screenLeft) + Math.round((window.outerWidth - w) / 2); const top = (window.screenY || window.screenTop) + Math.round((window.outerHeight - h) / 2); const features = `width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=no,toolbar=no,menubar=no,location=no,status=no`; @@ -1235,8 +1324,6 @@ export class FloatingViewer { this._popoutWindow = popup; this._isPopped = true; this._popoutRestoreGuard = false; - // Create an AbortController for all popup-window listeners so they can - // be removed atomically in popIn() without manual per-event cleanup. try { this._popoutAC?.abort(); } catch (e) { console.debug?.(e); } this._popoutAC = new AbortController(); const popoutSignal = this._popoutAC.signal; @@ -1246,7 +1333,8 @@ export class FloatingViewer { const mountViewer = () => { if (mounted) return; - const doc = popup.document; + let doc; + try { doc = popup.document; } catch { return; } // cross-origin during navigation if (!doc) return; if (!this._ensurePopoutShell(doc)) return; doc.title = "Majoor Viewer"; @@ -1254,7 +1342,12 @@ export class FloatingViewer { const root = doc.getElementById("mjr-mfv-popout-root") || doc.body; if (!root) return; try { root.replaceChildren(); } catch (e) { console.debug?.(e); } - root.appendChild(doc.adoptNode(el)); + try { + root.appendChild(doc.adoptNode(el)); + } catch (e) { + console.warn("[MFV] adoptNode failed", e); + return; + } el.classList.add("is-visible"); this.isVisible = true; mounted = true; @@ -1264,27 +1357,48 @@ export class FloatingViewer { this._updatePopoutBtnUI(); }; + // Do NOT use { once: true } — Chrome fires load for the initial blank + // document state, which consumes the listener before the real page load. try { - popup.addEventListener("load", mountViewer, { once: true, signal: popoutSignal }); + popup.addEventListener("load", mountViewer, { signal: popoutSignal }); } catch (e) { - console.debug?.("[MFV] pop-out page mount failed", e); - } - if (popup.document?.readyState === "interactive" || popup.document?.readyState === "complete") { - setTimeout(mountViewer, 0); + console.debug?.("[MFV] pop-out page load listener failed", e); } - // When the user closes the popup window, automatically pop the viewer back in. - popup.addEventListener("beforeunload", handlePopupClosing, { signal: popoutSignal }); - popup.addEventListener("pagehide", handlePopupClosing, { signal: popoutSignal }); - popup.addEventListener("unload", handlePopupClosing, { signal: popoutSignal }); - this._startPopoutCloseWatch(); + // Fire immediately if the popup already shows the shell (rare but possible). + try { + const doc = popup.document; + if (doc?.readyState === "complete" && doc.getElementById("mjr-mfv-popout-root")) { + setTimeout(mountViewer, 0); + } + } catch { /* cross-origin during navigation — load event will handle it */ } - // Relay keyboard shortcuts from popup back to main window - this._popoutKeydownHandler = (e) => { - const tag = String(e?.target?.tagName || "").toLowerCase(); - if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") { + // Polling safety net: Chrome App mode may suppress popup load events. + // Skip ticks where popup.document throws (normal during navigation). + let _mountPollCount = 0; + const _mountPollId = window.setInterval(() => { + if (mounted || ++_mountPollCount > 50 || !this._isPopped || popup.closed) { + window.clearInterval(_mountPollId); return; } + try { + const doc = popup.document; + if (doc?.readyState === "complete" && doc.getElementById("mjr-mfv-popout-root")) { + window.clearInterval(_mountPollId); + mountViewer(); + } + } catch { /* skip this tick */ } + }, 100); + popoutSignal.addEventListener("abort", () => window.clearInterval(_mountPollId), { once: true }); + + popup.addEventListener("beforeunload", handlePopupClosing, { signal: popoutSignal }); + popup.addEventListener("pagehide", handlePopupClosing, { signal: popoutSignal }); + popup.addEventListener("unload", handlePopupClosing, { signal: popoutSignal }); + this._startPopoutCloseWatch(); + + this._popoutKeydownHandler = (e) => { + const tag = String(e?.target?.tagName || "").toLowerCase(); + if (e?.defaultPrevented || e?.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return; const lower = String(e?.key || "").toLowerCase(); if (lower === "v" && (e?.ctrlKey || e?.metaKey) && !e?.altKey && !e?.shiftKey) { e.preventDefault(); @@ -1342,69 +1456,18 @@ export class FloatingViewer { } } - _buildPopoutShellHtml() { - return ` - - - - - - Majoor Viewer - - - -
-
Preparing viewer...
-
- -`; - } - + /** + * Verify the popup window's document contains the shell element served by + * the backend at /mjr/viewer/popout. We no longer fall back to doc.write() + * because that is blocked in Chrome App mode and is unnecessary now that the + * backend serves a real HTML page. + */ _ensurePopoutShell(doc) { if (!doc) return false; try { - if (doc.getElementById("mjr-mfv-popout-root")) return true; - doc.open(); - doc.write(this._buildPopoutShellHtml()); - doc.close(); return !!doc.getElementById("mjr-mfv-popout-root"); } catch (e) { - console.debug?.("[MFV] pop-out shell init failed", e); + console.debug?.("[MFV] pop-out shell check failed", e); return false; } } @@ -1446,20 +1509,26 @@ export class FloatingViewer { border: none !important; border-radius: 0 !important; box-shadow: none !important; + /* Override animated hidden state so it shows immediately in popup */ display: flex !important; + opacity: 1 !important; + visibility: visible !important; + pointer-events: auto !important; + transform: none !important; + transition: none !important; } `; doc.head.appendChild(overrideStyle); } /** - * Move the viewer back into the main ComfyUI page from the popup window. + * Move the viewer back from the popup window into the main ComfyUI page. */ popIn({ closePopupWindow = true } = {}) { if (!this._isPopped || !this.element) return; const popup = this._popoutWindow; this._clearPopoutCloseWatch(); - // Abort all popup-window listeners atomically instead of manual per-event removal. + // Abort all popup-window listeners atomically instead of manual removeEventListener. try { this._popoutAC?.abort(); } catch (e) { console.debug?.(e); } this._popoutAC = null; this._popoutCloseHandler = null; @@ -1482,8 +1551,6 @@ export class FloatingViewer { adopted.setAttribute("aria-hidden", "false"); this.isVisible = true; - // Restore inline position styles (they were cleared by the popup override) - // The viewer will revert to its normal fixed-position CSS. this._updatePopoutBtnUI(); // Close the popup window if it's still open @@ -1492,7 +1559,6 @@ export class FloatingViewer { } this._popoutWindow = null; } - /** Toggle pop-out button icon between external-link (pop out) and sign-in (pop in). */ _updatePopoutBtnUI() { if (!this._popoutBtn) return; @@ -1502,8 +1568,8 @@ export class FloatingViewer { this._popoutBtn.classList.toggle("mjr-popin-active", this._isPopped); const icon = this._popoutBtn.querySelector("i") || document.createElement("i"); const label = this._isPopped - ? t("tooltip.popInViewer", "Return viewer to ComfyUI window") - : t("tooltip.popOutViewer", "Pop out to separate window (can move to 2nd monitor)"); + ? t("tooltip.popInViewer", "Return to floating panel") + : t("tooltip.popOutViewer", "Expand to full screen (Esc or button to return)"); if (this._isPopped) { icon.className = "pi pi-sign-in"; } else { @@ -1993,7 +2059,7 @@ export class FloatingViewer { // Abort document-level and pop-out window listeners atomically. try { this._docAC?.abort(); this._docAC = null; } catch (e) { console.debug?.(e); } try { this._popoutAC?.abort(); this._popoutAC = null; } catch (e) { console.debug?.(e); } - // Pop-in before disposing so the element returns to the main document + // Pop-in before disposing so the element returns to the main document. try { if (this._isPopped) this.popIn(); } catch (e) { console.debug?.(e); } this._revokePreviewBlob(); try { this.element?.remove(); } catch (e) { console.debug?.(e); } diff --git a/js/features/viewer/floatingViewerManager.js b/js/features/viewer/floatingViewerManager.js index bc6f98c2..672ca921 100644 --- a/js/features/viewer/floatingViewerManager.js +++ b/js/features/viewer/floatingViewerManager.js @@ -263,7 +263,22 @@ export const floatingViewerManager = { inst.show(); _syncViewerControls(inst); _bindSelectionListener(); - inst.loadMediaA(fileData, { autoMode: true }); + + const mode = inst._mode; + const inCompare = mode === MFV_MODES.AB || mode === MFV_MODES.SIDE; + if (inCompare) { + // In compare mode: route the live stream to the non-pinned slot so the + // user's selection or pin stays intact. Default: stream → B, selection → A. + const pin = inst.getPinnedSlot(); + if (pin === "B") { + inst.loadMediaPair(fileData, inst._mediaB); // B pinned — stream to A + } else { + inst.loadMediaPair(inst._mediaA, fileData); // A pinned (or no pin) — stream to B + } + } else { + inst.loadMediaA(fileData, { autoMode: true }); + } + if (!wasVisible) _emitVisibilityChanged(true); }, @@ -277,9 +292,8 @@ export const floatingViewerManager = { }, /** - * Toggle the viewer between popped-out (separate window) and inline. - * When popping out, the viewer is first opened (if not visible) so the - * user always sees content in the new window. + * Toggle the viewer between the expanded dialog overlay and the floating panel. + * If the viewer isn't open yet, it is opened first so there's something to see. */ popOut() { const inst = _getInstance(); @@ -376,10 +390,6 @@ const _onGlobalKeydown = (event) => { return; } - if (lower === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey && !event?.shiftKey) { - consume(); - floatingViewerManager.toggle(); - } }; function _installGlobalHandlers() { @@ -416,7 +426,7 @@ export function teardownFloatingViewerManager() { _installGlobalHandlers(); -// Close the pop-out window when the main ComfyUI page unloads +// Close the pop-out window when the main ComfyUI page unloads. window.addEventListener("beforeunload", () => { try { if (_instance?.isPopped) _instance.popIn(); } catch (e) { /* noop */ } }); diff --git a/js/features/viewer/imageProcessor.js b/js/features/viewer/imageProcessor.js index 6e6df542..5d21bdb3 100644 --- a/js/features/viewer/imageProcessor.js +++ b/js/features/viewer/imageProcessor.js @@ -1,7 +1,7 @@ import { clamp01 } from "./state.js"; import { computeProcessorScale } from "./processorUtils.js"; -export function drawMediaError(canvas, message) { +export function drawMediaError(canvas, message, hint) { if (!canvas) return; try { if (!(canvas.width > 1 && canvas.height > 1)) { @@ -31,7 +31,8 @@ export function drawMediaError(canvas, message) { const text = message ? String(message) : "Failed to load media"; ctx.fillText(text, 14, 14); ctx.fillStyle = "rgba(255,255,255,0.7)"; - ctx.fillText("Check file permissions / path, or try re-indexing.", 14, 34); + const hintText = hint != null ? String(hint) : "Check file permissions / path, or try re-indexing."; + ctx.fillText(hintText, 14, 34); } catch (e) { console.debug?.(e); } try { ctx.restore(); diff --git a/js/features/viewer/videoProcessor.js b/js/features/viewer/videoProcessor.js index 512cc6de..94a1eadd 100644 --- a/js/features/viewer/videoProcessor.js +++ b/js/features/viewer/videoProcessor.js @@ -481,7 +481,25 @@ export function createVideoProcessor({ const onError = () => { proc.ready = false; try { - drawMediaError(canvas, "Failed to load video (unsupported codec/format?)"); + const errCode = videoEl?.error?.code; + const errMsg = videoEl?.error?.message || ""; + // MediaError codes: 1=aborted, 2=network, 3=decode, 4=src_not_supported + let msg, hint; + if (errCode === 2) { + msg = "Failed to load video (network / path error)"; + hint = "Check file permissions / path, or try re-indexing."; + } else if (errCode === 3) { + msg = "Failed to load video (decode error — unsupported codec?)"; + hint = "Browser may not support this codec (e.g. H.265/HEVC). Try converting to H.264/MP4."; + } else if (errCode === 4) { + msg = "Failed to load video (unsupported format or codec)"; + hint = "Browser cannot decode this file (e.g. H.265/HEVC). Try converting to H.264/MP4."; + } else { + msg = "Failed to load video"; + hint = errMsg || "Check file permissions / path, or try re-indexing."; + } + console.warn("[MJR] Video load error", { code: errCode, message: errMsg, src: videoEl?.src }); + drawMediaError(canvas, msg, hint); } catch (e) { console.debug?.(e); } }; diff --git a/js/theme/theme-comfy.css b/js/theme/theme-comfy.css index 85b981e0..bf896347 100644 --- a/js/theme/theme-comfy.css +++ b/js/theme/theme-comfy.css @@ -1711,19 +1711,19 @@ right: 0; bottom: 0; background: rgba(0, 0, 0, 0.95); - z-index: 10000; + /* Sit above MFV (10000) so the full viewer always covers the floating panel */ + z-index: 10001; display: none; flex-direction: column; - animation: mjr-fade-in 0.2s ease-out; +} +/* Fade-in: animation re-triggers each time JS switches display back to flex */ +.mjr-viewer-overlay[style*="flex"] { + animation: mjr-fade-in 0.18s ease-out; } @keyframes mjr-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { opacity: 0; transform: scale(0.995); } + to { opacity: 1; transform: scale(1); } } .mjr-viewer-header { @@ -2603,7 +2603,8 @@ border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); min-width: 200px; - z-index: 9999; + /* Above viewer overlay (10001) and MFV dialog (10000) */ + z-index: 10002; display: none; padding: 4px 0; animation: mjr-context-menu-in 0.15s ease-out; @@ -3037,11 +3038,22 @@ height: 380px; min-width: 220px; min-height: 160px; - display: none; /* shown via .mjr-mfv.is-visible or JS show() */ + /* Always flex; visibility + opacity control show/hide so CSS transitions work. + pointer-events:none while hidden prevents accidental interaction. */ + display: flex; flex-direction: column; resize: both; overflow: hidden; z-index: 10000; + /* Hidden state */ + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(-6px) scale(0.98); + transform-origin: top right; + transition: opacity 0.18s ease-out, + transform 0.18s ease-out, + visibility 0s linear 0.18s; } .mjr-mfv.mjr-mfv--resizing { @@ -3049,9 +3061,16 @@ } .mjr-mfv.is-visible { - display: flex; + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(0) scale(1); + transition: opacity 0.18s ease-out, + transform 0.18s ease-out, + visibility 0s linear 0s; } + /* Media elements (img/video) sizing variants */ .mjr-mfv-media { display: block; diff --git a/mjr_am_backend/observability.py b/mjr_am_backend/observability.py index 9ec02286..3aef5de8 100644 --- a/mjr_am_backend/observability.py +++ b/mjr_am_backend/observability.py @@ -348,12 +348,17 @@ def _request_log_key(request: web.Request, status: int | None) -> str: def _emit_request_log_by_status(status: int | None, fields: dict[str, Any]) -> None: + method = fields.get("method") or "?" + path = fields.get("path") or "?" + dur = fields.get("duration_ms") + dur_part = f" ({dur:.0f}ms)" if isinstance(dur, (int, float)) else "" + msg = f"Request handled | {method} {path} → {status}{dur_part}" if status is not None and status >= 500: - logger.error("Request handled", extra=fields) + logger.error(msg, extra=fields) elif status is not None and status >= 400: - logger.warning("Request handled", extra=fields) + logger.warning(msg, extra=fields) else: - logger.info("Request handled", extra=fields) + logger.info(msg, extra=fields) def ensure_observability(app: web.Application) -> None: