diff --git a/js/am_cards.js b/js/am_cards.js index bbd4b43b..d4dd2741 100644 --- a/js/am_cards.js +++ b/js/am_cards.js @@ -52,6 +52,13 @@ export function handleDragStart(file, ev) { ev.dataTransfer.effectAllowed = "copy"; ev.dataTransfer.setData("text/uri-list", url); ev.dataTransfer.setData("DownloadURL", `${mime}:${filename}:${url}`); + + if (kind === "video") { + ev.dataTransfer.setData( + "application/x-mjr-asset", + JSON.stringify({ filename, subfolder: file.subfolder || "", kind: "video" }) + ); + } } export function updateWorkflowDot(card, workflowState) { diff --git a/js/grid/events.js b/js/grid/events.js index 374f376e..fd765c92 100644 --- a/js/grid/events.js +++ b/js/grid/events.js @@ -1,3 +1,4 @@ +import { app } from "../../../../scripts/app.js"; import { api } from "../../../../scripts/api.js"; import { buildViewUrl, detectKindFromExt, getExt, mjrSettings, mjrShowToast } from "../ui_settings.js"; import { mjrGlobalState } from "../mjr_global.js"; @@ -5,12 +6,18 @@ import { sortFilesDeterministically } from "../am_data.js"; let mjrGlobalRefreshTimer = null; let mjrFocusListenerAttached = false; +let mjrVideoNodeDropBridgeBound = false; let mjrQueueListenerBound = false; let mjrNewFilesListenerBound = false; let mjrCapabilitiesCache = null; +try { + if (typeof window !== "undefined" && window.MJR_DND_DEBUG === undefined) { + window.MJR_DND_DEBUG = true; + } +} catch (_) {} const mjrDbg = (...args) => { try { - if (mjrSettings && mjrSettings.debugDnD) { + if ((mjrSettings && mjrSettings.debugDnD) || (typeof window !== "undefined" && window.MJR_DND_DEBUG)) { console.debug("[Majoor.DnD]", ...args); } } catch (_) {} @@ -98,6 +105,273 @@ export function mjrDispatchSyntheticDrop(target, file) { } } +let mjrHighlightedNode = null; +let mjrHighlightedNodePrev = null; + +function getNodeUnderClientXY(clientX, clientY) { + const canvasEl = app?.canvas?.canvas || document.querySelector("canvas"); + const graph = app?.canvas?.graph; + const ds = app?.canvas?.ds; + if (!canvasEl || !graph || !ds) return null; + + const rect = canvasEl.getBoundingClientRect(); + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + + const scale = Number(ds.scale) || 1; + const off = ds.offset || [0, 0]; + const offX = Array.isArray(off) ? Number(off[0]) || 0 : Number(off?.x) || 0; + const offY = Array.isArray(off) ? Number(off[1]) || 0 : Number(off?.y) || 0; + const x = (clientX - rect.left) / scale - offX; + const y = (clientY - rect.top) / scale - offY; + + if (typeof graph.getNodeOnPos === "function") return graph.getNodeOnPos(x, y); + + const nodes = graph._nodes || []; + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i]; + if (!n?.pos || !n?.size) continue; + if ( + x >= n.pos[0] && + y >= n.pos[1] && + x <= n.pos[0] + n.size[0] && + y <= n.pos[1] + n.size[1] + ) { + return n; + } + } + return null; +} + +function isVhsVideoNode(node) { + if (!node) return false; + const title = String(node.title || ""); + const type = String(node.type || ""); + if (/video/i.test(title)) return true; + if (type.includes("VHS_LoadVideo")) return true; + + const widgets = node.widgets; + if (Array.isArray(widgets)) { + return widgets.some((w) => String(w?.name || "").toLowerCase() === "video"); + } + return false; +} + +function markCanvasDirty() { + try { + app?.graph?.setDirtyCanvas?.(true, true); + } catch (_) {} + try { + app?.canvas?.setDirty?.(true, true); + } catch (_) {} +} + +function applyHighlight(node) { + if (!node) return; + if (mjrHighlightedNode === node) return; + + clearHighlight(); + mjrHighlightedNode = node; + mjrHighlightedNodePrev = { color: node.color, bgcolor: node.bgcolor }; + node.bgcolor = "#3355ff"; + node.color = "#a9c4ff"; + markCanvasDirty(); +} + +function clearHighlight() { + if (!mjrHighlightedNode) return; + try { + if (mjrHighlightedNodePrev) { + mjrHighlightedNode.color = mjrHighlightedNodePrev.color; + mjrHighlightedNode.bgcolor = mjrHighlightedNodePrev.bgcolor; + } + } catch (_) {} + mjrHighlightedNode = null; + mjrHighlightedNodePrev = null; + markCanvasDirty(); +} + +function ensureComboHasValue(widget, value) { + if (!widget || widget.type !== "combo" || !widget.options) return; + + let vals = null; + let target = null; + if (Array.isArray(widget.options.values)) { + vals = widget.options.values; + target = widget.options; + } else if (widget.options.values && Array.isArray(widget.options.values.values)) { + vals = widget.options.values.values; + target = widget.options.values; + } + if (!Array.isArray(vals) || !target) return; + + const has = vals.some((v) => { + if (typeof v === "string") return v === value; + return (v?.content ?? v?.value ?? v?.text) === value; + }); + if (has) return; + + if (vals.length === 0 || typeof vals[0] === "string") vals.unshift(value); + else vals.unshift({ content: value, text: value, value }); + target.values = vals; +} + +function pickVideoWidget(node) { + const widgets = node?.widgets; + if (!Array.isArray(widgets) || !widgets.length) return null; + const exact = widgets.find((w) => String(w?.name || "").toLowerCase() === "video"); + if (exact) return exact; + return ( + widgets.find((w) => /video|file|path/i.test(String(w?.name || w?.label || ""))) || null + ); +} + +function comboHasValue(widget, value) { + if (!widget || widget.type !== "combo" || !widget.options) return false; + const vals = + (Array.isArray(widget.options.values) && widget.options.values) || + (widget.options.values && Array.isArray(widget.options.values.values) && widget.options.values.values) || + null; + if (!Array.isArray(vals)) return false; + return vals.some((v) => { + if (typeof v === "string") return v === value; + return (v?.content ?? v?.value ?? v?.text) === value; + }); +} + +export function ensureVideoNodeDropBridge() { + if (mjrVideoNodeDropBridgeBound) return () => {}; + + const onDragOver = (e) => { + const types = Array.from(e?.dataTransfer?.types || []); + if (!types.includes("application/x-mjr-asset")) return; + + let payload = null; + try { + payload = JSON.parse(e.dataTransfer.getData("application/x-mjr-asset")); + } catch (_) { + payload = null; + } + if (!payload || payload.kind !== "video") return; + + const node = getNodeUnderClientXY(e.clientX, e.clientY); + if (node && isVhsVideoNode(node)) { + e.preventDefault(); + e.stopImmediatePropagation?.(); + e.stopPropagation(); + applyHighlight(node); + e.dataTransfer.dropEffect = "copy"; + mjrDbg("DnD video dragover", { payload, nodeTitle: node?.title, nodeType: node?.type }); + return; + } + + clearHighlight(); + // Pass-through: let ComfyUI handle canvas-friendly video drop (workflow embed). + }; + + const onDrop = async (e) => { + const types = Array.from(e?.dataTransfer?.types || []); + if (!types.includes("application/x-mjr-asset")) return; + + let payload = null; + try { + payload = JSON.parse(e.dataTransfer.getData("application/x-mjr-asset")); + } catch (_) { + payload = null; + } + if (!payload || payload.kind !== "video") return; + + const node = getNodeUnderClientXY(e.clientX, e.clientY); + if (!node || !isVhsVideoNode(node)) { + clearHighlight(); + mjrDbg("DnD video drop pass-through", { payload, nodeTitle: node?.title, nodeType: node?.type }); + return; // leave ComfyUI to load workflow from video metadata + } + + e.preventDefault(); + e.stopImmediatePropagation?.(); + e.stopPropagation(); + clearHighlight(); + + const filename = payload.filename; + const subfolder = payload.subfolder || ""; + if (!filename) return; + + let stageResp = null; + try { + const res = await api.fetchApi("/mjr/filemanager/stage_to_input", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename, subfolder, from_type: "output" }), + }); + stageResp = res.ok ? await res.json().catch(() => null) : null; + } catch (_) { + stageResp = null; + } + + const relativePath = stageResp?.relative_path; + const stagedName = stageResp?.filename || stageResp?.name || filename; + mjrDbg("DnD stage_to_input response", stageResp); + + if (!relativePath && !stagedName) { + mjrShowToast("error", "Failed to stage video to input", "Drag & Drop"); + return; + } + + const w = pickVideoWidget(node); + if (!w) { + mjrShowToast("warn", "No compatible 'video' widget found", "Drag & Drop"); + return; + } + + const prefer = w.type === "combo" && comboHasValue(w, relativePath) ? relativePath : null; + const value = + prefer || + (w.type === "combo" && comboHasValue(w, stagedName) ? stagedName : null) || + relativePath || + stagedName; + + if (w.type === "combo") ensureComboHasValue(w, value); + w.value = value; + try { + w.callback?.(w.value); + } catch (_) {} + + markCanvasDirty(); + + mjrDbg("DnD video injected", { + payload, + nodeTitle: node?.title, + nodeType: node?.type, + widget: { name: w?.name, type: w?.type }, + value, + }); + }; + + const onDragLeave = (_e) => { + clearHighlight(); + }; + + window.addEventListener("dragover", onDragOver, true); + window.addEventListener("drop", onDrop, true); + window.addEventListener("dragleave", onDragLeave, true); + mjrVideoNodeDropBridgeBound = true; + + return () => { + window.removeEventListener("dragover", onDragOver, true); + window.removeEventListener("drop", onDrop, true); + window.removeEventListener("dragleave", onDragLeave, true); + mjrVideoNodeDropBridgeBound = false; + clearHighlight(); + }; +} + export function mjrStartAutoRefreshTimer(refreshAllInstances) { if (mjrGlobalRefreshTimer) { clearInterval(mjrGlobalRefreshTimer); diff --git a/js/ui_grid.js b/js/ui_grid.js index 9d6bd761..37dd3172 100644 --- a/js/ui_grid.js +++ b/js/ui_grid.js @@ -11,10 +11,11 @@ import { mjrStepVideos, mjrUpdateNavVisibility, } from "./ui_viewer.js"; -import { createMetadataSidebar } from "./ui_sidebar.js"; -import { createGridView } from "./am_gridview.js"; +import { createMetadataSidebar } from "./ui_sidebar.js"; +import { createGridView } from "./am_gridview.js"; import { ensureGlobalAutoRefresh, + ensureVideoNodeDropBridge, ensureNewFilesListener, ensureQueueListener, mjrStartAutoRefreshTimer, @@ -177,10 +178,11 @@ function renderAssetsManager(root) { loadFiles, lastSignatureRef, } = createRefreshController(state, grid, applyFilterAndRender, fetchMetadataForVisible); - refreshAllInstances = refreshAllInstancesFn; - const refreshCollections = () => populateCollectionsOptions(state, collectionFilterSelect); - + refreshAllInstances = refreshAllInstancesFn; + const refreshCollections = () => populateCollectionsOptions(state, collectionFilterSelect); + // Media drops on the ComfyUI canvas are handled natively by ComfyUI (this plugin does not intercept drop events). + cleanups.push(ensureVideoNodeDropBridge()); cleanups.push(ensureQueueListener(refreshAllInstances)); cleanups.push(ensureNewFilesListener()); cleanups.push(ensureGlobalAutoRefresh(refreshAllInstances)); diff --git a/js/ui_sidebar.js b/js/ui_sidebar.js index 9688484f..8b6c9c00 100644 --- a/js/ui_sidebar.js +++ b/js/ui_sidebar.js @@ -565,7 +565,7 @@ export function createMetadataSidebar(options) { params.set("filename", filename); if (file.subfolder) params.set("subfolder", file.subfolder); - showPanel(file, fallbackMeta, cachedEntry ? null : "Loading metadata…"); + showPanel(file, fallbackMeta, cachedEntry ? null : "Loading metadata..."); try { const res = await api.fetchApi(`/mjr/filemanager/metadata?${params.toString()}`); @@ -593,11 +593,13 @@ export function createMetadataSidebar(options) { } else { showPanel(file, null, data.error || "No metadata found for this file."); } - } catch (err) { - console.error("[Majoor.AssetsManager] metadata error", err); - showPanel(file, null, "Error while loading metadata (check console)."); - } - } + } catch (err) { + if (seq !== metaRequestSeq) return; // ignore stale request failures + if (err?.name === "AbortError") return; + console.error("[Majoor.AssetsManager] metadata error", err); + showPanel(file, null, "Error while loading metadata (check console)."); + } + } return { loadMetadataForFile, diff --git a/server/routes.py b/server/routes.py index f16c9c70..ed1881bb 100644 --- a/server/routes.py +++ b/server/routes.py @@ -1,4 +1,5 @@ import os +import sys import subprocess import asyncio import time @@ -65,6 +66,49 @@ except Exception: _CACHE_LOCK = threading.Lock() +_ASYNCIO_SILENCER_INSTALLED = False + + +def _install_windows_asyncio_connection_reset_silencer() -> None: + """ + On Windows, aborted HTTP requests can produce noisy asyncio logs like: + "Exception in callback _ProactorBasePipeTransport._call_connection_lost()" + with ConnectionResetError([WinError 10054]). + + This does not affect correctness, but it can confuse users and pollute logs. + """ + global _ASYNCIO_SILENCER_INSTALLED + if _ASYNCIO_SILENCER_INSTALLED: + return + if sys.platform != "win32": + _ASYNCIO_SILENCER_INSTALLED = True + return + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + previous = loop.get_exception_handler() + + def _handler(loop: asyncio.AbstractEventLoop, context: Dict[str, Any]) -> None: + exc = context.get("exception") + msg = context.get("message") or "" + if ( + isinstance(exc, ConnectionResetError) + and getattr(exc, "winerror", None) == 10054 + and "_call_connection_lost" in str(msg) + ): + return + + if previous: + previous(loop, context) + else: + loop.default_exception_handler(context) + + loop.set_exception_handler(_handler) + _ASYNCIO_SILENCER_INSTALLED = True + def _folder_changed(root_path: Path) -> bool: """Quick check if folder changed (file added/modified).""" @@ -422,6 +466,7 @@ async def _scan_outputs_async(force: bool = False) -> List[Dict[str, Any]]: @PromptServer.instance.routes.get("/mjr/filemanager/files") async def list_files(request: web.Request) -> web.Response: + _install_windows_asyncio_connection_reset_silencer() force_param = (request.query.get("force") or "").lower() force = force_param in ("1", "true", "yes", "force") @@ -471,9 +516,12 @@ async def list_files(request: web.Request) -> web.Response: @PromptServer.instance.routes.post("/mjr/filemanager/metadata/batch") async def batch_metadata(request: web.Request) -> web.Response: + _install_windows_asyncio_connection_reset_silencer() root = _get_output_root() try: payload = await request.json() + except ConnectionResetError: + return web.Response(status=499) except Exception: return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400) @@ -541,7 +589,10 @@ async def batch_metadata(request: web.Request) -> web.Response: except Exception: continue - return web.json_response({"ok": True, "metadatas": results, "errors": errors}) + try: + return web.json_response({"ok": True, "metadatas": results, "errors": errors}) + except ConnectionResetError: + return web.Response(status=499) @PromptServer.instance.routes.post("/mjr/filemanager/delete") @@ -640,6 +691,7 @@ async def open_folder(request: web.Request) -> web.Response: @PromptServer.instance.routes.get("/mjr/filemanager/metadata") async def get_metadata(request: web.Request) -> web.Response: + _install_windows_asyncio_connection_reset_silencer() root = _get_output_root() filename = request.query.get("filename") subfolder = request.query.get("subfolder", "") @@ -661,68 +713,73 @@ async def get_metadata(request: web.Request) -> web.Response: {"ok": False, "error": "File not found"}, status=404 ) - def _extract(path: Path) -> Dict[str, Any]: - try: - return extract_generation_params_from_png(path) or {} - except Exception as e: - print(f"[Majoor.AssetsManager] metadata parsing error for {path.name}: {e}") - return {} + def _extract_generation(path: Path) -> Dict[str, Any]: + def _extract_one(p: Path) -> Dict[str, Any]: + try: + return extract_generation_params_from_png(p) or {} + except Exception as e: + print(f"[Majoor.AssetsManager] metadata parsing error for {p.name}: {e}") + return {} - ext_lower = target.suffix.lower() - params: Dict[str, Any] = _extract(target) - - def _has_wf(p: Dict[str, Any]) -> bool: - return bool( - p.get("has_workflow") - or p.get("workflow") - or p.get("prompt") - or p.get("positive_prompt") - or p.get("negative_prompt") - or p.get("sampler_name") - or p.get("model") - or p.get("loras") - ) - - if ext_lower in {".mp4", ".mov", ".m4v", ".webm", ".mkv"} and not _has_wf(params): - stem = target.stem - candidates = [] - try: - for entry in target.parent.iterdir(): - if not entry.is_file(): - continue - if entry.suffix.lower() != ".png": - continue - if entry.stem == stem or entry.stem.startswith(f"{stem}_") or entry.stem.startswith(f"{stem}-"): - candidates.append(entry) - except Exception: - candidates = [] - - if candidates: - candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) - for png in candidates: - sib_params = _extract(png) - if _has_wf(sib_params): - params = sib_params - params["generation_source"] = "sibling_png" - break - - if isinstance(params, dict): - if "has_workflow" not in params: - params["has_workflow"] = bool( - params.get("has_workflow") - or params.get("workflow") - or params.get("prompt") - or params.get("positive_prompt") - or params.get("negative_prompt") - or params.get("sampler_name") - or params.get("model") - or params.get("loras") + def _has_wf(p: Dict[str, Any]) -> bool: + return bool( + p.get("has_workflow") + or p.get("workflow") + or p.get("prompt") + or p.get("positive_prompt") + or p.get("negative_prompt") + or p.get("sampler_name") + or p.get("model") + or p.get("loras") ) + ext_lower = path.suffix.lower() + params: Dict[str, Any] = _extract_one(path) + + if ext_lower in {".mp4", ".mov", ".m4v", ".webm", ".mkv"} and not _has_wf(params): + stem = path.stem + candidates = [] + try: + for entry in path.parent.iterdir(): + if not entry.is_file(): + continue + if entry.suffix.lower() != ".png": + continue + if ( + entry.stem == stem + or entry.stem.startswith(f"{stem}_") + or entry.stem.startswith(f"{stem}-") + ): + candidates.append(entry) + except Exception: + candidates = [] + + if candidates: + candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) + for png in candidates: + sib_params = _extract_one(png) + if _has_wf(sib_params): + params = sib_params + params["generation_source"] = "sibling_png" + break + + if isinstance(params, dict) and "has_workflow" not in params: + params["has_workflow"] = _has_wf(params) + + return params + + loop = asyncio.get_running_loop() + params = await loop.run_in_executor(None, _extract_generation, target) + kind = classify_ext(filename.lower()) rating, tags = _rating_tags_with_fallback(target, kind) - return web.json_response({"ok": True, "generation": params, "rating": rating, "tags": tags}) + try: + return web.json_response( + {"ok": True, "generation": params, "rating": rating, "tags": tags} + ) + except ConnectionResetError: + return web.Response(status=499) @PromptServer.instance.routes.get("/mjr/filemanager/capabilities")