feat(viewer): expand floating viewer to full-screen pop-out with advanced behaviors

- Implement Document Picture-in-Picture for always-on-top secondary monitor support
- Enhance pop-out functionality with fallback to window.open() for compatibility
- Refine pop-in/out transitions and user interface interactions
- Optimize media loading and error handling for various scenarios
- Improve keyboard shortcut integration and behavior
- Update CSS animations and z-index hierarchy for better visual presentation
- Enhance logging for request handling in the backend
This commit is contained in:
MajoorWaldi
2026-03-11 17:32:07 +01:00
parent 571e5d3f15
commit 5fc58d6c12
8 changed files with 348 additions and 156 deletions
+93 -31
View File
@@ -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);
};
+12 -1
View File
@@ -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?.();
+164 -98
View File
@@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#1e1e1e">
<title>Majoor Viewer</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #ddd;
font-family: system-ui, sans-serif;
}
body {
display: flex;
min-height: 100vh;
}
#mjr-mfv-popout-root {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
align-items: stretch;
justify-content: stretch;
background:
radial-gradient(circle at top, rgba(95, 179, 255, 0.12), transparent 35%),
linear-gradient(180deg, #1a1a1a 0%, #101010 100%);
}
.mjr-mfv-popout-loading {
margin: auto;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(20,20,20,0.82);
font-size: 12px;
letter-spacing: 0.02em;
opacity: 0.84;
}
</style>
</head>
<body>
<div id="mjr-mfv-popout-root">
<div class="mjr-mfv-popout-loading">Preparing viewer...</div>
</div>
</body>
</html>`;
}
/**
* 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); }
+19 -9
View File
@@ -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 */ }
});
+3 -2
View File
@@ -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();
+19 -1
View File
@@ -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); }
};
+30 -11
View File
@@ -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;
+8 -3
View File
@@ -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: