routes: Add workflow reconstruction and logging features

This commit is contained in:
MajoorWaldi
2025-12-17 13:16:02 +01:00
parent 4be8cbd513
commit bb2d1da9c0
3 changed files with 104 additions and 131 deletions
+1 -67
View File
@@ -496,70 +496,6 @@ app.registerExtension({
if (!window.__MajoorAssetsManagerHotkeysInitialized) {
window.__MajoorAssetsManagerHotkeysInitialized = true;
const toggleAssetsManager = () => {
const SIDEBAR_ID = "majoorAssetsManagerSidebar";
const BOTTOM_ID = "majoorAssetsManagerBottom";
try {
const em = app?.extensionManager;
const sidebar = em?.sidebarTab;
const bottom = em?.bottomPanel;
const sidebarActive = sidebar && sidebar.activeSidebarTabId === SIDEBAR_ID;
const bottomActive =
bottom &&
bottom.bottomPanelVisible === true &&
bottom.activeBottomPanelTabId === BOTTOM_ID;
if (sidebarActive) {
sidebar.activeSidebarTabId = null;
return true;
}
if (bottomActive) {
bottom.bottomPanelVisible = false;
return true;
}
const sidebarRegistered =
sidebar &&
Array.isArray(sidebar.sidebarTabs) &&
sidebar.sidebarTabs.some((t) => t && t.id === SIDEBAR_ID);
if (sidebarRegistered) {
sidebar.activeSidebarTabId = SIDEBAR_ID;
return true;
}
const bottomRegistered =
bottom &&
Array.isArray(bottom.bottomPanelTabs) &&
bottom.bottomPanelTabs.some((t) => t && t.id === BOTTOM_ID);
if (bottomRegistered) {
bottom.activeBottomPanelTabId = BOTTOM_ID;
bottom.bottomPanelVisible = true;
return true;
}
} catch (_) {
// fall through to DOM-based toggle
}
const selectors = [
`[data-sidebar-tab-id="majoorAssetsManagerSidebar"]`,
`[data-sidebar-tab="majoorAssetsManagerSidebar"]`,
`[data-tab-id="majoorAssetsManagerSidebar"]`,
`[data-extension-id="majoorAssetsManagerSidebar"]`,
`[data-tab-id="majoorAssetsManagerBottom"]`,
`[data-bottom-tab-id="majoorAssetsManagerBottom"]`,
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && typeof el.click === "function") {
el.click();
return true;
}
}
return false;
};
const onKeyDown = (ev) => {
if (!mjrSettings.hotkeys.enabled) return;
if (ev.isComposing) return;
@@ -573,9 +509,7 @@ if (!window.__MajoorAssetsManagerHotkeysInitialized) {
if (isTyping) return;
let handled = false;
if (ev.key === "Tab" && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
handled = toggleAssetsManager();
} else if (mjrSettings.hotkeys.rating && /^[0-5]$/.test(ev.key) && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
if (mjrSettings.hotkeys.rating && /^[0-5]$/.test(ev.key) && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
mjrGlobalState.instances.forEach(inst => {
if (!inst?.state) return;
const rate = parseInt(ev.key, 10);
+25 -8
View File
@@ -567,14 +567,31 @@ export function createMetadataSidebar(options) {
showPanel(file, fallbackMeta, cachedEntry ? null : "Loading metadata...");
try {
const res = await api.fetchApi(`/mjr/filemanager/metadata?${params.toString()}`);
const data = await res.json();
if (seq !== metaRequestSeq) return;
if (data.ok && data.generation) {
const meta = data.generation || {};
if (meta.has_workflow === undefined) meta.has_workflow = !!meta.workflow;
if (meta.has_workflow && file) file.hasWorkflow = true;
try {
const res = await api.fetchApi(`/mjr/filemanager/metadata?${params.toString()}`);
if (seq !== metaRequestSeq) return;
if (!res.ok) {
let extra = "";
try {
const txt = await res.text();
if (txt) extra = `: ${String(txt).slice(0, 160)}`;
} catch (_) {}
showPanel(file, null, `Error while loading metadata (HTTP ${res.status}${extra})`);
return;
}
let data = null;
try {
data = await res.json();
} catch (e) {
showPanel(file, null, "Error while loading metadata (invalid server response).");
return;
}
if (seq !== metaRequestSeq) return;
if (data.ok && data.generation) {
const meta = data.generation || {};
if (meta.has_workflow === undefined) meta.has_workflow = !!meta.workflow;
if (meta.has_workflow && file) file.hasWorkflow = true;
if (meta.rating === undefined && file.rating !== undefined) meta.rating = file.rating;
if (!meta.tags && file.tags) meta.tags = file.tags;
const sourceMtime = meta.mtime ?? file.mtime;
+78 -56
View File
@@ -8,6 +8,7 @@ import shutil
import platform
import concurrent.futures
import json
import math
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -45,6 +46,45 @@ from .mjr_collections import (
log = get_logger(__name__)
def _json_sanitize(obj: Any) -> Any:
"""
Ensure objects are JSON-safe and strict-JSON compliant.
Python's json.dumps allows NaN/Infinity by default, but browsers reject them.
"""
if obj is None:
return None
if isinstance(obj, float):
return obj if math.isfinite(obj) else None
if isinstance(obj, (bytes, bytearray)):
try:
return bytes(obj).decode("utf-8", errors="replace")
except Exception:
return repr(obj)
if isinstance(obj, Path):
return str(obj)
if isinstance(obj, dict):
out: Dict[str, Any] = {}
for k, v in obj.items():
try:
key = str(k)
except Exception:
key = repr(k)
out[key] = _json_sanitize(v)
return out
if isinstance(obj, (list, tuple, set)):
return [_json_sanitize(v) for v in obj]
return obj
def _json_response(data: Any, status: int = 200) -> web.Response:
cleaned = _json_sanitize(data)
return web.json_response(
cleaned,
status=status,
dumps=lambda x: json.dumps(x, ensure_ascii=False, allow_nan=False),
)
# ---------------------------------------------------------------------------
# In-memory cache for output listing
# Avoids redoing a full os.scandir on every auto-refresh.
@@ -553,7 +593,7 @@ async def list_files(request: web.Request) -> web.Response:
loop = asyncio.get_running_loop()
loop.run_in_executor(None, _prefetch)
return web.json_response(
return _json_response(
{
"files": items,
"total": total,
@@ -574,7 +614,7 @@ async def batch_metadata(request: web.Request) -> web.Response:
except ConnectionResetError:
return web.Response(status=499)
except Exception:
return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400)
return _json_response({"ok": False, "error": "Invalid JSON"}, status=400)
items = payload.get("items") or []
loop = asyncio.get_running_loop()
@@ -641,7 +681,7 @@ async def batch_metadata(request: web.Request) -> web.Response:
continue
try:
return web.json_response({"ok": True, "metadatas": results, "errors": errors})
return _json_response({"ok": True, "metadatas": results, "errors": errors})
except ConnectionResetError:
return web.Response(status=499)
@@ -653,9 +693,7 @@ async def delete_files(request: web.Request) -> web.Response:
try:
payload = await request.json()
except Exception:
return web.json_response(
{"ok": False, "error": "Invalid JSON body"}, status=400
)
return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
items = payload.get("items") or []
deleted = []
@@ -701,7 +739,7 @@ async def delete_files(request: web.Request) -> web.Response:
{"filename": filename, "subfolder": subfolder, "error": str(exc)}
)
return web.json_response({"ok": True, "deleted": deleted, "errors": errors})
return _json_response({"ok": True, "deleted": deleted, "errors": errors})
@PromptServer.instance.routes.post("/mjr/filemanager/open_explorer")
@@ -715,28 +753,28 @@ async def open_folder(request: web.Request) -> web.Response:
try:
payload = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
filename = payload.get("filename")
subfolder = payload.get("subfolder", "")
if not filename:
return web.json_response({"ok": False, "error": "Missing filename"}, status=400)
return _json_response({"ok": False, "error": "Missing filename"}, status=400)
try:
target = _safe_target(root, subfolder, filename)
except ValueError:
return web.json_response({"ok": False, "error": "File is outside output directory"}, status=400)
return _json_response({"ok": False, "error": "File is outside output directory"}, status=400)
if not target.exists():
return web.json_response({"ok": False, "error": "File not found"}, status=404)
return _json_response({"ok": False, "error": "File not found"}, status=404)
ok, warning = _open_in_file_manager(target)
if ok:
resp = {"ok": True}
if warning:
resp["warning"] = warning
return web.json_response(resp, status=200)
return web.json_response({"ok": False, "error": warning or "Failed to open folder"}, status=500)
return _json_response(resp, status=200)
return _json_response({"ok": False, "error": warning or "Failed to open folder"}, status=500)
# ---------------------------------------------------------------------------
@@ -751,21 +789,15 @@ async def get_metadata(request: web.Request) -> web.Response:
subfolder = request.query.get("subfolder", "")
if not filename:
return web.json_response(
{"ok": False, "error": "missing filename"}, status=400
)
return _json_response({"ok": False, "error": "missing filename"}, status=400)
try:
target = _safe_target(root, subfolder, filename)
except ValueError:
return web.json_response(
{"ok": False, "error": "File is outside output directory"}, status=400
)
return _json_response({"ok": False, "error": "File is outside output directory"}, status=400)
if not target.exists():
return web.json_response(
{"ok": False, "error": "File not found"}, status=404
)
return _json_response({"ok": False, "error": "File not found"}, status=404)
def _extract_generation(path: Path) -> Dict[str, Any]:
def _extract_one(p: Path) -> Dict[str, Any]:
@@ -831,9 +863,7 @@ async def get_metadata(request: web.Request) -> web.Response:
rating, tags = _rating_tags_with_fallback(target, kind)
try:
return web.json_response(
{"ok": True, "generation": params, "rating": rating, "tags": tags}
)
return _json_response({"ok": True, "generation": params, "rating": rating, "tags": tags})
except ConnectionResetError:
return web.Response(status=499)
@@ -842,7 +872,7 @@ async def get_metadata(request: web.Request) -> web.Response:
async def get_capabilities(request: web.Request) -> web.Response:
os_name = platform.system().lower()
exiftool_available = _get_exiftool_path() is not None
return web.json_response(
return _json_response(
{
"ok": True,
"os": os_name,
@@ -859,9 +889,7 @@ async def update_metadata(request: web.Request) -> web.Response:
try:
payload = await request.json()
except Exception:
return web.json_response(
{"ok": False, "error": "Invalid JSON body"}, status=400
)
return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
filename = payload.get("filename")
subfolder = payload.get("subfolder", "")
@@ -871,22 +899,16 @@ async def update_metadata(request: web.Request) -> web.Response:
tags = payload.get("tags") if tags_provided else None
if not filename:
return web.json_response(
{"ok": False, "error": "Missing filename"}, status=400
)
return _json_response({"ok": False, "error": "Missing filename"}, status=400)
try:
target = _safe_target(root, subfolder, filename)
except ValueError:
return web.json_response(
{"ok": False, "error": "File is outside output directory"}, status=400
)
return _json_response({"ok": False, "error": "File is outside output directory"}, status=400)
kind = classify_ext(filename.lower())
if not target.exists():
return web.json_response(
{"ok": False, "error": "File not found"}, status=404
)
return _json_response({"ok": False, "error": "File not found"}, status=404)
updates = {}
if rating_provided:
@@ -897,7 +919,7 @@ async def update_metadata(request: web.Request) -> web.Response:
try:
meta = update_metadata_with_windows(str(target), updates)
except Exception as exc:
return web.json_response(
return _json_response(
{
"ok": False,
"file": {"filename": filename, "subfolder": subfolder},
@@ -906,7 +928,7 @@ async def update_metadata(request: web.Request) -> web.Response:
status=500,
)
return web.json_response({"ok": True, "rating": meta.get("rating", 0), "tags": meta.get("tags", [])})
return _json_response({"ok": True, "rating": meta.get("rating", 0), "tags": meta.get("tags", [])})
@PromptServer.instance.routes.post("/mjr/filemanager/generation/update")
@@ -924,14 +946,14 @@ async def update_generation_sidecar(request: web.Request) -> web.Response:
try:
payload = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
files = payload.get("files") or []
prompt = payload.get("prompt")
workflow = payload.get("workflow")
if not isinstance(files, list) or not files:
return web.json_response({"ok": False, "error": "No files provided"}, status=400)
return _json_response({"ok": False, "error": "No files provided"}, status=400)
updated: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
@@ -976,7 +998,7 @@ async def update_generation_sidecar(request: web.Request) -> web.Response:
except Exception as exc:
errors.append({"file": {"filename": filename, "subfolder": subfolder}, "error": str(exc)})
return web.json_response({"ok": True, "updated": updated, "errors": errors})
return _json_response({"ok": True, "updated": updated, "errors": errors})
@PromptServer.instance.routes.post("/mjr/filemanager/sidecar/update")
@@ -988,32 +1010,32 @@ async def update_sidecar(request: web.Request) -> web.Response:
try:
payload = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
return _json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
filename = payload.get("filename")
subfolder = payload.get("subfolder", "")
meta_updates = payload.get("meta")
if not filename or not isinstance(meta_updates, dict):
return web.json_response({"ok": False, "error": "Missing filename or invalid meta"}, status=400)
return _json_response({"ok": False, "error": "Missing filename or invalid meta"}, status=400)
try:
target = _safe_target(root, subfolder, filename)
except ValueError:
return web.json_response({"ok": False, "error": "File is outside output directory"}, status=400)
return _json_response({"ok": False, "error": "File is outside output directory"}, status=400)
if not target.exists():
return web.json_response({"ok": False, "error": "File not found"}, status=404)
return _json_response({"ok": False, "error": "File not found"}, status=404)
meta_path = metadata_path(str(target))
if not meta_path:
return web.json_response({"ok": False, "error": "Sidecar disabled"}, status=400)
return _json_response({"ok": False, "error": "Sidecar disabled"}, status=400)
try:
merged = deep_merge_metadata(str(target), meta_updates)
return web.json_response({"ok": True, "meta": merged})
return _json_response({"ok": True, "meta": merged})
except Exception as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=500)
return _json_response({"ok": False, "error": str(exc)}, status=500)
# ---------------------------------------------------------------------------
@@ -1023,14 +1045,14 @@ async def update_sidecar(request: web.Request) -> web.Response:
@PromptServer.instance.routes.get("/mjr/collections/list")
async def list_collections_route(request: web.Request) -> web.Response:
names = await asyncio.to_thread(get_collections)
return web.json_response({"collections": names})
return _json_response({"collections": names})
@PromptServer.instance.routes.get("/mjr/collections/{name}")
async def get_collection_route(request: web.Request) -> web.Response:
name = request.match_info["name"]
files = await asyncio.to_thread(load_collection, name)
return web.json_response({"files": files})
return _json_response({"files": files})
@PromptServer.instance.routes.post("/mjr/collections/add")
@@ -1038,9 +1060,9 @@ async def add_to_collection_route(request: web.Request) -> web.Response:
try:
data = await request.json()
await asyncio.to_thread(add_to_collection, data["name"], data["path"])
return web.json_response({"ok": True})
return _json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
return _json_response({"ok": False, "error": str(e)}, status=500)
@PromptServer.instance.routes.post("/mjr/collections/remove")
@@ -1048,6 +1070,6 @@ async def remove_from_collection_route(request: web.Request) -> web.Response:
try:
data = await request.json()
await asyncio.to_thread(remove_from_collection, data["name"], data["path"])
return web.json_response({"ok": True})
return _json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
return _json_response({"ok": False, "error": str(e)}, status=500)