ImageDirPreviewにおける削除された画像の管理機能を実装し、削除インデックスの追跡とプレースホルダー表示を追加
This commit is contained in:
+117
-3
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user