ImageDirPreviewの左クリックメニューとプレビュー状態のソースパスを修正し、プレビューセッションIDを追加

This commit is contained in:
るみなー先生
2026-06-16 23:27:25 +09:00
parent c0c7cc9774
commit 808ecb34ff
2 changed files with 118 additions and 60 deletions
+11 -5
View File
@@ -22,6 +22,7 @@ from server import PromptServer
logger = logging.getLogger(__name__)
# v9i: ImageDirPreview left-click menu, hover frame, and preview-state source path fix.
# v9h: ImageDirPreview right-click event separation and blank-area suppression.
# v9g: ImageDirPreview right-click capture fix for ComfyUI/LiteGraph context menu.
# v9f: ImageDirPreview context menu foundation and Set as checkpoint thumbnail.
@@ -838,10 +839,12 @@ def _preferred_checkpoint_thumbnail_path(safetensors_path: Path) -> Path:
return Path(safetensors_path).resolve().with_suffix(".jpg")
def _get_preview_state_item(tab_id, node_id, item_index):
def _get_preview_state_item(tab_id, node_id, item_index, preview_session_id=None):
key = _state_key(tab_id, node_id)
with _STATE_LOCK:
state = dict(_PREVIEW_STATES.get(key, {}))
if preview_session_id and state.get("preview_session_id") != str(preview_session_id):
return state, None, None, None
source_paths = state.get("source_paths")
if not isinstance(source_paths, list):
return None, None, None, None
@@ -1451,6 +1454,7 @@ def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="",
"search_directory": str(_safe_search_root(search_directory) or ""),
"max_preview_images": max_preview_images,
"source_paths": [str(p) for p in loaded_paths],
"preview_session_id": str(uuid.uuid4()),
})
return sheet, extra
@@ -2020,7 +2024,8 @@ class ImageDirPreview:
relpath = _normalize_relpath(ckpt_name_str)
sheet, extra = _load_image_dir_preview(unique_id, relpath, search_directory, tab_id=hps_tab_id, send_progress=True, max_preview_images=max_preview_images)
status = _get_status(relpath)
_store_preview_state(hps_tab_id, unique_id, relpath, status, node_class="ImageDirPreview")
state_extra = {k: v for k, v in extra.items() if k not in ("ckpt_name_str", "status")}
_store_preview_state(hps_tab_id, unique_id, relpath, status, **state_extra)
_send_preview(unique_id, _image_dir_title(relpath), sheet, extra, tab_id=hps_tab_id)
return ()
@@ -2085,7 +2090,7 @@ async def image_dir_preview_context_menu(request):
data = await request.json()
node_id = str(data.get("node_id", ""))
tab_id = _clean_tab_id(data.get("tab_id", ""))
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"))
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id"))
if not state or index is None or source_path is None or not relpath:
return web.json_response({"ok": False, "error": "Preview item not found."}, status=404)
exists = _preview_item_exists_for_state(state, source_path)
@@ -2115,7 +2120,7 @@ async def image_dir_preview_set_thumbnail(request):
data = await request.json()
node_id = str(data.get("node_id", ""))
tab_id = _clean_tab_id(data.get("tab_id", ""))
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"))
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id"))
if not state or index is None or source_path is None or not relpath:
return web.json_response({"ok": False, "error": "Preview item not found."}, status=404)
if not _preview_item_exists_for_state(state, source_path):
@@ -2401,7 +2406,8 @@ async def review_sync_checkpoint(request):
True,
max_preview_images,
)
_store_preview_state(tab_id, node_id, relpath, status, node_class="ImageDirPreview")
state_extra = {k: v for k, v in extra.items() if k not in ("ckpt_name_str", "status")}
_store_preview_state(tab_id, node_id, relpath, status, **state_extra)
_send_preview(node_id, _image_dir_title(relpath), sheet, extra, tab_id=tab_id)
preview_count += 1
except Exception:
+107 -55
View File
@@ -774,9 +774,11 @@ async function restoreNodeStateFromBackend(node, nodeClass) {
}
// ---------- Preview ----------
let hpsContextMenuEl = null;
let hpsContextMenuHideInstalled = false;
let imageDirHoverCaptureInstalled = false;
function ensureHpsContextMenu() {
if (hpsContextMenuEl) return hpsContextMenuEl;
@@ -806,7 +808,10 @@ function hideHpsContextMenu() {
function installHpsContextMenuHide() {
if (hpsContextMenuHideInstalled) return;
hpsContextMenuHideInstalled = true;
document.addEventListener("mousedown", () => hideHpsContextMenu(), true);
document.addEventListener("mousedown", (event) => {
if (hpsContextMenuEl?.contains?.(event.target)) return;
hideHpsContextMenu();
}, true);
document.addEventListener("scroll", () => hideHpsContextMenu(), true);
window.addEventListener("blur", () => hideHpsContextMenu());
}
@@ -907,20 +912,91 @@ function imageDirPreviewItemAtLocal(node, local) {
return { index, sourcePath: String(sourcePaths[index] || ""), ckptName: String(st.ckpt_name_str || "") };
}
function imageDirPreviewTileRect(node, index) {
const st = node.__hpsPreviewState || {};
const cols = Number(st.columns || 0);
const tileW = Number(st.tile_width || 0);
const tileH = Number(st.tile_height || 0);
const gap = Number(st.gap || 0);
if (!cols || !tileW || !tileH || index == null || index < 0) return null;
const box = previewDrawBox(node);
if (!box) return null;
const row = Math.floor(index / cols);
const col = index % cols;
const sx = col * (tileW + gap);
const sy = row * (tileH + gap);
const scaleX = box.w / box.imageW;
const scaleY = box.h / box.imageH;
return { x: box.x + sx * scaleX, y: box.y + sy * scaleY, w: tileW * scaleX, h: tileH * scaleY };
}
function clearImageDirHover(node) {
if (!node) return;
if (node.__hpsHoveredPreviewIndex != null) {
node.__hpsHoveredPreviewIndex = null;
app.graph?.setDirtyCanvas?.(true, true);
}
}
function setImageDirHover(node, item) {
const index = item?.index ?? null;
if (node.__hpsHoveredPreviewIndex !== index) {
node.__hpsHoveredPreviewIndex = index;
app.graph?.setDirtyCanvas?.(true, true);
}
}
function updateImageDirHoverFromEvent(event) {
let handled = false;
const nodes = [...(app.graph?._nodes || [])].reverse();
for (const node of nodes) {
if (!node || node.flags?.collapsed || !isNodeClass(node, "ImageDirPreview")) continue;
const local = graphEventToLocal(node, event);
if (!hpsLocalInsideNode(node, local)) {
clearImageDirHover(node);
continue;
}
const item = imageDirPreviewItemAtLocal(node, local);
setImageDirHover(node, item);
handled = true;
break;
}
if (!handled) {
for (const node of app.graph?._nodes || []) {
if (isNodeClass(node, "ImageDirPreview")) clearImageDirHover(node);
}
}
}
function installImageDirHoverCapture() {
if (imageDirHoverCaptureInstalled) return;
const canvasEl = app.canvas?.canvas;
if (!canvasEl) return;
imageDirHoverCaptureInstalled = true;
canvasEl.addEventListener("mousemove", (event) => updateImageDirHoverFromEvent(event), true);
canvasEl.addEventListener("mouseleave", () => {
for (const node of app.graph?._nodes || []) {
if (isNodeClass(node, "ImageDirPreview")) clearImageDirHover(node);
}
});
}
async function fetchImageDirContextMenu(node, itemIndex) {
const st = node.__hpsPreviewState || {};
const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/context_menu`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: node.id, item_index: itemIndex, tab_id: HPS_TAB_ID }),
body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }),
});
return await response.json();
}
async function setImageDirCheckpointThumbnail(node, itemIndex) {
const st = node.__hpsPreviewState || {};
const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/set_thumbnail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: node.id, item_index: itemIndex, tab_id: HPS_TAB_ID }),
body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }),
});
const result = await response.json();
if (result?.ok) {
@@ -954,54 +1030,11 @@ async function openImageDirContextMenu(node, item, event) {
}
}
let imageDirContextCaptureInstalled = false;
function findImageDirContextTarget(event) {
const nodes = [...(app.graph?._nodes || [])].reverse();
for (const node of nodes) {
if (!node || node.flags?.collapsed || !isNodeClass(node, "ImageDirPreview")) continue;
const local = graphEventToLocal(node, event);
if (!hpsLocalInsideNode(node, local)) continue;
const item = imageDirPreviewItemAtLocal(node, local);
// Return the node even when the click is on ImageDirPreview blank space.
// Blank-space right-clicks should suppress the standard ComfyUI menu but
// must not open an action menu.
return { node, item };
}
return null;
}
function installImageDirContextMenuCapture() {
if (imageDirContextCaptureInstalled) return;
const canvasEl = app.canvas?.canvas;
if (!canvasEl) return;
imageDirContextCaptureInstalled = true;
canvasEl.addEventListener("contextmenu", (event) => {
const target = findImageDirContextTarget(event);
if (!target) return;
// Any right-click inside ImageDirPreview is owned by HPS. This prevents
// browser/LiteGraph menus from leaking through blank areas.
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
if (target.item) {
openImageDirContextMenu(target.node, target.item, event);
} else {
hideHpsContextMenu();
}
return false;
}, true);
}
function setupPreviewNode(nodeType) {
installMinSize(nodeType, 340, 300);
installTabIdSupport(nodeType);
installCursorCapture();
installImageDirContextMenuCapture();
installImageDirHoverCapture();
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origCreated ? origCreated.apply(this, arguments) : undefined;
@@ -1054,6 +1087,19 @@ function setupPreviewNode(nodeType) {
const drawBox = previewDrawBox(this);
if (img && drawBox) {
ctx.drawImage(img, drawBox.x, drawBox.y, drawBox.w, drawBox.h);
if (g.isImageDir && this.__hpsHoveredPreviewIndex != null) {
const rect = imageDirPreviewTileRect(this, this.__hpsHoveredPreviewIndex);
if (rect) {
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.lineWidth = 2;
ctx.strokeRect(rect.x + 1, rect.y + 1, Math.max(1, rect.w - 2), Math.max(1, rect.h - 2));
ctx.strokeStyle = "rgba(0,0,0,0.75)";
ctx.lineWidth = 1;
ctx.strokeRect(rect.x + 3, rect.y + 3, Math.max(1, rect.w - 6), Math.max(1, rect.h - 6));
ctx.restore();
}
}
} else {
const st = this.__hpsPreviewState || {};
const isLoading = st.status === "loading" || !!st.progress;
@@ -1080,17 +1126,22 @@ function setupPreviewNode(nodeType) {
ctx.restore();
};
const origMouseDown = nodeType.prototype.onRightClick;
nodeType.prototype.onRightClick = function (event, local_pos) {
// LiteGraphの onRightClick は第2引数にすでにローカル座標 (local_pos) が渡ってくる
if (isNodeClass(this, "ImageDirPreview")) {
if (hpsLocalInsideNode(this, local_pos)) {
// true を返すことで、LiteGraphの標準ノードメニュー展開を完全にブロックする!
// (実際の独自メニュー表示は、既存の contextmenu イベントキャプチャ側が担当する)
const origMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (event, pos, canvas) {
if (isNodeClass(this, "ImageDirPreview") && event?.button === 0) {
const local = previewLocalFromEventOrPos(this, event, pos);
const item = imageDirPreviewItemAtLocal(this, local);
if (item) {
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
setImageDirHover(this, item);
openImageDirContextMenu(this, item, event);
return true;
}
hideHpsContextMenu();
}
return origRightClick ? origRightClick.apply(this, arguments) : false;
return origMouseDown ? origMouseDown.apply(this, arguments) : false;
};
}
@@ -1118,6 +1169,7 @@ api.addEventListener(PREVIEW_EVENT, ({ detail }) => {
}
node.__hpsPreviewState = { ...(node.__hpsPreviewState || {}), ...detail };
if (isNodeClass(node, "ImageDirPreview")) node.__hpsHoveredPreviewIndex = null;
const caption = detail.progress_message || detail.message || `${detail.count ?? 0} img · ${detail.columns ?? 0}×${detail.rows ?? 0} · ${detail.width ?? 0}×${detail.height ?? 0}`;
node.__hpsPreviewCaption = caption;
if (!detail.image) {