bugfix: correct event handling in grid and sidebar

This commit is contained in:
MajoorWaldi
2025-12-17 11:37:36 +01:00
parent a88a6d7b20
commit f4b0978bf4
5 changed files with 411 additions and 69 deletions
+7
View File
@@ -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) {
+275 -1
View File
@@ -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);
+7 -5
View File
@@ -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));
+8 -6
View File
@@ -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,
+114 -57
View File
@@ -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")