From 58d598be302bdbb91279aef17c22690a7b9855c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Wed, 17 Jun 2026 00:03:47 +0900 Subject: [PATCH] =?UTF-8?q?ImageDirPreview=E3=81=AB=E3=81=8A=E3=81=91?= =?UTF-8?q?=E3=82=8B=E5=89=8A=E9=99=A4=E3=81=95=E3=82=8C=E3=81=9F=E7=94=BB?= =?UTF-8?q?=E5=83=8F=E3=81=AE=E7=AE=A1=E7=90=86=E6=A9=9F=E8=83=BD=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=97=E3=80=81=E5=89=8A=E9=99=A4=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=87=E3=83=83=E3=82=AF=E3=82=B9=E3=81=AE=E8=BF=BD?= =?UTF-8?q?=E8=B7=A1=E3=81=A8=E3=83=97=E3=83=AC=E3=83=BC=E3=82=B9=E3=83=9B?= =?UTF-8?q?=E3=83=AB=E3=83=80=E3=83=BC=E8=A1=A8=E7=A4=BA=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 120 ++++++++++++++++++++++++++++- web/assets/no_image.svg | 6 ++ web/checkpoint_handpicker_suite.js | 106 ++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 web/assets/no_image.svg diff --git a/suite_nodes.py b/suite_nodes.py index 22a9430..ab0bf15 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -880,6 +880,54 @@ def _preview_item_exists_for_state(state: dict | None, source_path: Path | None) return True +def _preview_deleted_indices(state: dict | None) -> list[int]: + if not state: + return [] + values = state.get("deleted_indices") + if not isinstance(values, list): + return [] + out = [] + for value in values: + try: + idx = int(value) + except Exception: + continue + if idx >= 0: + out.append(idx) + return sorted(set(out)) + + +def _preview_item_deleted_for_state(state: dict | None, index: int | None) -> bool: + if index is None: + return False + return index in set(_preview_deleted_indices(state)) + + +def _mark_preview_item_deleted(tab_id, node_id, index: int) -> list[int]: + key = _state_key(tab_id, node_id) + with _STATE_LOCK: + state = _PREVIEW_STATES.get(key) + if not state: + return [] + deleted = set(_preview_deleted_indices(state)) + deleted.add(int(index)) + merged = sorted(deleted) + state["deleted_indices"] = merged + _PREVIEW_STATES[key] = state + return list(merged) + + +def _delete_preview_source_image(state: dict, source_path: Path) -> None: + search_root = _safe_search_root(state.get("search_directory")) + if search_root is not None and not _is_path_under_root(source_path, search_root): + raise ValueError("Preview image is outside the allowed search directory.") + if source_path.suffix.lower() not in ALLOWED_IMAGE_SUFFIXES: + raise ValueError("Unsupported preview image format.") + if not source_path.exists() or not source_path.is_file(): + raise FileNotFoundError(str(source_path)) + source_path.unlink() + + def _set_checkpoint_thumbnail_from_preview_item(relpath: str, source_path: Path) -> dict: relpath = _normalize_relpath(relpath) if not _is_valid_checkpoint_relpath(relpath): @@ -1455,6 +1503,7 @@ def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", "max_preview_images": max_preview_images, "source_paths": [str(p) for p in loaded_paths], "preview_session_id": str(uuid.uuid4()), + "deleted_indices": [], }) return sheet, extra @@ -2093,6 +2142,17 @@ async def image_dir_preview_context_menu(request): 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 _preview_item_deleted_for_state(state, index): + return web.json_response({ + "ok": True, + "node_id": node_id, + "item_index": index, + "exists": False, + "deleted": True, + "ckpt_name_str": relpath, + "source_path": str(source_path), + "items": [], + }) exists = _preview_item_exists_for_state(state, source_path) if not exists: return web.json_response({ @@ -2100,18 +2160,72 @@ async def image_dir_preview_context_menu(request): "node_id": node_id, "item_index": index, "exists": False, + "deleted": False, "ckpt_name_str": relpath, "source_path": str(source_path), - "items": [{"id": "deleted", "label": "Image deleted", "enabled": False}], + "items": [], }) return web.json_response({ "ok": True, "node_id": node_id, "item_index": index, "exists": True, + "deleted": False, + "ckpt_name_str": relpath, + "source_path": str(source_path), + "items": [ + {"id": "delete_image", "label": "Delete image", "enabled": True}, + {"id": "set_thumbnail", "label": "Set as checkpoint thumbnail", "enabled": True}, + ], + }) + + +@routes.post(f"/{EXTENSION_PREFIX}/image_dir_preview/delete_image") +async def image_dir_preview_delete_image(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"), 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 _preview_item_deleted_for_state(state, index): + return web.json_response({ + "ok": True, + "deleted": True, + "exists": False, + "message": "Preview image already deleted.", + "deleted_indices": _preview_deleted_indices(state), + "ckpt_name_str": relpath, + "source_path": str(source_path), + }) + if not _preview_item_exists_for_state(state, source_path): + return web.json_response({ + "ok": True, + "deleted": False, + "exists": False, + "message": "Preview image not found.", + "deleted_indices": _preview_deleted_indices(state), + "ckpt_name_str": relpath, + "source_path": str(source_path), + }) + try: + _delete_preview_source_image(state, source_path) + deleted_indices = _mark_preview_item_deleted(tab_id, node_id, index) + except ValueError as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + except FileNotFoundError: + return web.json_response({"ok": True, "deleted": False, "exists": False, "message": "Preview image not found.", "deleted_indices": _preview_deleted_indices(state), "ckpt_name_str": relpath, "source_path": str(source_path)}) + except Exception: + logger.exception("[CheckpointHandpickerSuite] failed to delete preview image") + return web.json_response({"ok": False, "error": "Failed to delete preview image."}, status=500) + return web.json_response({ + "ok": True, + "deleted": True, + "exists": False, + "message": "Preview image deleted.", + "deleted_indices": deleted_indices, "ckpt_name_str": relpath, "source_path": str(source_path), - "items": [{"id": "set_thumbnail", "label": "Set as checkpoint thumbnail", "enabled": True}], }) @@ -2123,7 +2237,7 @@ async def image_dir_preview_set_thumbnail(request): 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): + if _preview_item_deleted_for_state(state, index) or not _preview_item_exists_for_state(state, source_path): return web.json_response({ "ok": True, "exists": False, diff --git a/web/assets/no_image.svg b/web/assets/no_image.svg new file mode 100644 index 0000000..1b6fe81 --- /dev/null +++ b/web/assets/no_image.svg @@ -0,0 +1,6 @@ + + + + NO + IMAGE + diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index ebedf32..ed3368a 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -909,6 +909,7 @@ function imageDirPreviewItemAtLocal(node, local) { if (inTileX < 0 || inTileX > tileW || inTileY < 0 || inTileY > tileH) return null; const index = row * cols + col; if (index < 0 || index >= count || index >= sourcePaths.length) return null; + if (isImageDirDeletedIndex(node, index)) return null; return { index, sourcePath: String(sourcePaths[index] || ""), ckptName: String(st.ckpt_name_str || "") }; } @@ -981,6 +982,65 @@ function installImageDirHoverCapture() { }); } +const NO_IMAGE_ASSET_URL = new URL("./assets/no_image.svg", import.meta.url).href; +let noImageAsset = null; +let noImageAssetRequested = false; + +function ensureNoImageAsset() { + if (noImageAsset || noImageAssetRequested) return noImageAsset; + noImageAssetRequested = true; + const img = new Image(); + img.onload = () => { + noImageAsset = img; + app.graph?.setDirtyCanvas?.(true, true); + }; + img.onerror = () => { + noImageAsset = null; + }; + img.src = NO_IMAGE_ASSET_URL; + return null; +} + +function deletedImageDirIndexSet(node) { + const raw = node?.__hpsPreviewState?.deleted_indices; + if (!Array.isArray(raw) || !raw.length) return null; + return new Set(raw.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v >= 0)); +} + +function isImageDirDeletedIndex(node, index) { + if (index == null || index < 0) return false; + const set = deletedImageDirIndexSet(node); + return !!set && set.has(Number(index)); +} + +function updateNodeDeletedIndices(node, values) { + if (!node) return; + const next = Array.isArray(values) ? values.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v >= 0) : []; + node.__hpsPreviewState = { ...(node.__hpsPreviewState || {}), deleted_indices: next }; +} + +function drawNoImagePlaceholder(ctx, rect) { + ctx.save(); + ctx.fillStyle = "rgba(20,20,20,0.72)"; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + const asset = noImageAsset || ensureNoImageAsset(); + if (asset) { + ctx.drawImage(asset, rect.x, rect.y, rect.w, rect.h); + } else { + ctx.strokeStyle = "rgba(255,255,255,0.16)"; + ctx.lineWidth = 1; + ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(1, rect.w - 1), Math.max(1, rect.h - 1)); + ctx.fillStyle = "rgba(255,255,255,0.88)"; + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("NO IMAGE", rect.x + rect.w / 2, rect.y + rect.h / 2, Math.max(1, rect.w - 10)); + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + } + ctx.restore(); +} + async function fetchImageDirContextMenu(node, itemIndex) { const st = node.__hpsPreviewState || {}; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/context_menu`, { @@ -1010,6 +1070,26 @@ async function setImageDirCheckpointThumbnail(node, itemIndex) { app.graph?.setDirtyCanvas?.(true, true); } +async function deleteImageDirPreviewImage(node, itemIndex) { + const st = node.__hpsPreviewState || {}; + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/delete_image`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + 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) { + if (Array.isArray(result.deleted_indices)) updateNodeDeletedIndices(node, result.deleted_indices); + if (result.deleted) clearImageDirHover(node); + node.__hpsPreviewContextMessage = result.message || (result.deleted ? "Preview image deleted." : "Preview image not found."); + node.__hpsPreviewContextMessageAt = Date.now(); + } else { + node.__hpsPreviewContextMessage = result?.error || "Failed to delete preview image."; + node.__hpsPreviewContextMessageAt = Date.now(); + } + app.graph?.setDirtyCanvas?.(true, true); +} + async function openImageDirContextMenu(node, item, event) { hideHpsContextMenu(); try { @@ -1019,11 +1099,25 @@ async function openImageDirContextMenu(node, item, event) { return; } const items = (result.items || []).map((entry) => { + if (entry?.id === "delete_image" && entry?.enabled !== false) { + return { + label: entry.label || "Delete image", + enabled: true, + onClick: async () => { + if (!globalThis.confirm?.("Delete this image?")) return; + await deleteImageDirPreviewImage(node, item.index); + }, + }; + } if (entry?.id === "set_thumbnail" && entry?.enabled !== false) { return { label: entry.label || "Set as checkpoint thumbnail", enabled: true, onClick: () => setImageDirCheckpointThumbnail(node, item.index) }; } - return { label: entry?.label || "Image deleted", enabled: false }; - }); + return null; + }).filter(Boolean); + if (!items.length) { + hideHpsContextMenu(); + return; + } showHpsContextMenu(items, event.clientX, event.clientY); } catch (error) { showHpsContextMenu([{ label: "Failed to open menu", enabled: false }], event.clientX, event.clientY); @@ -1087,7 +1181,13 @@ 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) { + if (g.isImageDir) { + for (const index of deletedImageDirIndexSet(this) || []) { + const rect = imageDirPreviewTileRect(this, index); + if (rect) drawNoImagePlaceholder(ctx, rect); + } + } + if (g.isImageDir && this.__hpsHoveredPreviewIndex != null && !isImageDirDeletedIndex(this, this.__hpsHoveredPreviewIndex)) { const rect = imageDirPreviewTileRect(this, this.__hpsHoveredPreviewIndex); if (rect) { ctx.save();