ImageDirPreviewにおける削除された画像の管理機能を実装し、削除インデックスの追跡とプレースホルダー表示を追加

This commit is contained in:
るみなー先生
2026-06-17 00:03:47 +09:00
parent 808ecb34ff
commit 58d598be30
3 changed files with 226 additions and 6 deletions
+117 -3
View File
@@ -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,
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#aeb3b5"/>
<rect x="94" y="94" width="324" height="324" rx="28" fill="#bfc5c8"/>
<text x="256" y="206" text-anchor="middle" font-family="Arial, sans-serif" font-size="70" font-weight="700" fill="#687382">NO</text>
<text x="256" y="280" text-anchor="middle" font-family="Arial, sans-serif" font-size="56" font-weight="700" fill="#687382">IMAGE</text>
</svg>

After

Width:  |  Height:  |  Size: 490 B

+103 -3
View File
@@ -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();