Add image metadata tab and backend workflow normalization

This commit is contained in:
Dariusz L
2026-09-12 16:50:40 +02:00
parent 50b157e72b
commit 74e4b190a9
14 changed files with 1230 additions and 2 deletions
+16
View File
@@ -0,0 +1,16 @@
"""Workflow metadata inspection route registration."""
from ..services.image_metadata_service import ImageMetadataService
from .context import RouteContext
from .helpers import register_service_route
def register_image_metadata_routes(context: RouteContext):
"""Register the read-only image/JSON workflow inspector endpoint."""
service = ImageMetadataService(context)
register_service_route(
context,
path="/model_resolver/inspect-metadata",
error_prefix="inspect-metadata",
operation=service.inspect_metadata,
)
+2
View File
@@ -135,6 +135,7 @@ def register_routes(self):
from .downloads import register_download_routes
from .hashes import register_hash_routes
from .helpers import create_route_helpers
from .image_metadata import register_image_metadata_routes
from .loaded_models import register_loaded_model_routes
from .metadata import register_metadata_routes
from .model_details import register_model_details_routes
@@ -254,6 +255,7 @@ def register_routes(self):
register_workflow_analysis_routes(route_context)
register_hash_routes(route_context)
register_metadata_routes(route_context)
register_image_metadata_routes(route_context)
register_loaded_model_routes(route_context)
model_service = ModelService(route_context)
register_civitai_search_routes(route_context, model_service)
+298
View File
@@ -0,0 +1,298 @@
"""Read-only workflow metadata inspection for images and JSON files."""
import base64
import binascii
import io
import json
from collections.abc import Mapping
from typing import Any, Optional
from ..request_utils import (
read_optional_object_payload,
read_optional_text_field,
read_text_field,
)
from ..routes.context import RouteContext
from ..workflow.formats import normalize_workflow_payload
MAX_IMAGE_BYTES = 64 * 1024 * 1024
MAX_WORKFLOW_JSON_BYTES = 16 * 1024 * 1024
MAX_PARAMETERS_LENGTH = 12_000
def _decode_metadata_value(value: Any) -> Any:
if isinstance(value, bytes):
try:
return value.decode("utf-8")
except UnicodeDecodeError:
return value.decode("utf-8", errors="replace")
return value
def _metadata_json_value(value: Any) -> Any:
value = _decode_metadata_value(value)
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return value
if text[0] not in "[{":
prefix, separator, remainder = text.partition(":")
if separator and prefix.strip().lower() in {
"prompt",
"workflow",
"comfy",
"comfyui",
}:
text = remainder.strip()
else:
return value
if not text or text[0] not in "[{":
return value
try:
return json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
return value
def _safe_metadata_value(value: Any) -> Any:
value = _decode_metadata_value(value)
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _read_image_bytes(data_url: str) -> bytes:
encoded = data_url.strip()
if encoded.startswith("data:"):
header, separator, encoded = encoded.partition(",")
if not separator or ";base64" not in header.lower():
raise ValueError("Image payload must be a base64 data URL")
encoded = "".join(encoded.split())
if not encoded:
raise ValueError("Image payload is empty")
if len(encoded) > ((MAX_IMAGE_BYTES + 2) * 4 // 3) + 16:
raise ValueError("Image payload is too large")
try:
raw = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Image payload is not valid base64") from exc
if not raw:
raise ValueError("Image payload is empty")
if len(raw) > MAX_IMAGE_BYTES:
raise ValueError("Image payload is too large")
return raw
def _image_metadata(image: Any) -> dict[str, Any]:
metadata = {
str(key): _safe_metadata_value(value)
for key, value in (image.info or {}).items()
}
try:
from PIL.ExifTags import TAGS
for tag_id, value in image.getexif().items():
key = TAGS.get(tag_id, str(tag_id))
metadata.setdefault(f"EXIF:{key}", _safe_metadata_value(value))
except Exception:
# EXIF is optional and malformed EXIF must not hide valid PNG/WebP info.
pass
return metadata
def _find_embedded_workflow(metadata: Mapping[str, Any]):
priority_keys = (
"workflow",
"prompt",
"comfyui",
"comfy",
"extra_pnginfo",
"extra_metadata",
)
candidates = []
seen = set()
for key in priority_keys:
if key in metadata:
candidates.append((key, metadata[key]))
seen.add(key)
candidates.extend(
(key, value)
for key, value in metadata.items()
if key not in seen
)
for key, value in candidates:
candidate = _metadata_json_value(value)
try:
normalized, workflow_format = normalize_workflow_payload(candidate)
except ValueError:
continue
return normalized, workflow_format, key
return None, None, ""
def _metadata_summary(
metadata: Mapping[str, Any],
*,
workflow_format: Optional[str],
workflow_source: str,
) -> dict[str, Any]:
parameters = next(
(
value
for key, value in metadata.items()
if str(key).strip().lower() == "parameters"
),
None,
)
parameters = _decode_metadata_value(parameters)
if parameters is not None and not isinstance(parameters, str):
parameters = str(parameters)
if isinstance(parameters, str):
parameters = parameters[:MAX_PARAMETERS_LENGTH]
else:
parameters = ""
return {
"keys": list(metadata.keys()),
"has_workflow": bool(workflow_format),
"workflow_source": workflow_source,
"parameters": parameters,
}
def inspect_image_bytes(raw: bytes, filename: str = "") -> dict[str, Any]:
"""Inspect a supported image without writing it or executing its workflow."""
try:
from PIL import Image
with Image.open(io.BytesIO(raw)) as image:
image.load()
metadata = _image_metadata(image)
workflow, workflow_format, workflow_source = _find_embedded_workflow(
metadata
)
return {
"source_type": "image",
"filename": filename or "Dropped image",
"image": {
"format": str(image.format or "").upper(),
"width": int(image.width),
"height": int(image.height),
"mode": str(image.mode or ""),
},
"metadata": _metadata_summary(
metadata,
workflow_format=workflow_format,
workflow_source=workflow_source,
),
"workflow": workflow,
"workflow_format": workflow_format or "",
"workflow_source": workflow_source,
"workflow_node_count": len(workflow.get("nodes", []))
if isinstance(workflow, Mapping)
else 0,
}
except ValueError:
raise
except Exception as exc:
raise ValueError(f"Could not read image metadata: {exc}") from exc
def _parse_workflow_json_text(value: str) -> Any:
if len(value.encode("utf-8")) > MAX_WORKFLOW_JSON_BYTES:
raise ValueError("Workflow JSON is too large")
try:
return json.loads(value)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError("Workflow JSON is not valid JSON") from exc
def inspect_workflow_json(
workflow: Any,
*,
filename: str = "",
source_type: str = "workflow",
) -> dict[str, Any]:
"""Normalize a UI/API workflow for the read-only inspector."""
if isinstance(workflow, str):
workflow = _parse_workflow_json_text(workflow)
try:
normalized, workflow_format = normalize_workflow_payload(workflow)
except ValueError:
raise
return {
"source_type": source_type,
"filename": filename or "Pasted workflow",
"image": None,
"metadata": {
"keys": [],
"has_workflow": True,
"workflow_source": "json",
"parameters": "",
},
"workflow": normalized,
"workflow_format": workflow_format,
"workflow_source": "json",
"workflow_node_count": len(normalized.get("nodes", [])),
}
class ImageMetadataService:
"""HTTP service for dropped images, workflow JSON files, and pasted JSON."""
def __init__(self, context: RouteContext):
self.asyncio = context.require("asyncio")
self.web = context.require("web")
async def inspect_metadata(self, request):
data = await read_optional_object_payload(request)
try:
source_type = read_text_field(
data,
"source_type",
contract_name="Metadata inspection request",
).lower()
filename = read_optional_text_field(
data,
"filename",
contract_name="Metadata inspection request",
) or ""
except TypeError as exc:
return self.web.json_response({"error": str(exc)}, status=400)
try:
if source_type == "image":
data_url = data.get("data_url")
if not isinstance(data_url, str):
raise ValueError("Image data is required")
raw = _read_image_bytes(data_url)
result = await self.asyncio.to_thread(
inspect_image_bytes,
raw,
filename,
)
elif source_type in {"workflow", "json"}:
workflow = data.get("workflow")
if workflow is None:
workflow = data.get("json_text")
if workflow is None:
raise ValueError("Workflow JSON is required")
result = await self.asyncio.to_thread(
inspect_workflow_json,
workflow,
filename=filename,
source_type="workflow",
)
else:
raise ValueError("Metadata source type must be image or workflow")
except (TypeError, ValueError) as exc:
return self.web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
return self.web.json_response(
{"error": f"Metadata inspection failed: {exc}"},
status=500,
)
return self.web.json_response(result)
+9
View File
@@ -9,6 +9,7 @@ from ..request_utils import (
validate_workflow_payload,
)
from ..routes.context import RouteContext
from ..workflow.formats import detect_workflow_format, normalize_workflow_payload
from ..workflow.traversal import iter_workflow_nodes_with_scope
@@ -49,6 +50,14 @@ class LoadedModelsService:
{"error": workflow_error}, status=400
)
if detect_workflow_format(workflow_json) == "api":
try:
workflow_json, _workflow_format = normalize_workflow_payload(
workflow_json
)
except ValueError as exc:
return self.web.json_response({"error": str(exc)}, status=400)
def update_loaded_progress(*args, **kwargs):
self.extension._update_loaded_progress(loaded_id, *args, **kwargs)
+207
View File
@@ -0,0 +1,207 @@
"""Helpers for recognizing and normalizing ComfyUI workflow formats."""
from collections.abc import Mapping
from typing import Any, Dict, Optional, Tuple
UI_WORKFLOW_FORMAT = "ui"
API_WORKFLOW_FORMAT = "api"
_WORKFLOW_CONTAINER_KEYS = (
"workflow",
"prompt",
"comfy",
"comfyui",
"extra_pnginfo",
"extra_metadata",
"graph",
)
def _is_api_link(value: Any) -> bool:
"""Return whether a value has ComfyUI API link shape: [node_id, output]."""
return (
isinstance(value, (list, tuple))
and len(value) == 2
and not isinstance(value[1], bool)
and isinstance(value[1], int)
and not isinstance(value[0], bool)
and isinstance(value[0], (str, int))
)
def _is_api_node(value: Any) -> bool:
return (
isinstance(value, Mapping)
and isinstance(value.get("class_type"), str)
and bool(value.get("class_type", "").strip())
and isinstance(value.get("inputs"), Mapping)
)
def detect_workflow_format(workflow: Any) -> Optional[str]:
"""Identify a ComfyUI editor workflow or API prompt graph."""
if not isinstance(workflow, Mapping):
return None
if isinstance(workflow.get("nodes"), list):
return UI_WORKFLOW_FORMAT
if any(_is_api_node(value) for value in workflow.values()):
return API_WORKFLOW_FORMAT
return None
def _parse_json_string(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text or text[0] not in "[{":
return value
import json
try:
return json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
return value
def find_workflow_payload(value: Any, *, max_depth: int = 6) -> Tuple[Any, Optional[str]]:
"""Find a workflow nested in common ComfyUI metadata containers."""
current = _parse_json_string(value)
if not isinstance(current, Mapping):
return None, None
detected = detect_workflow_format(current)
if detected:
return current, detected
if max_depth <= 0:
return None, None
for key in _WORKFLOW_CONTAINER_KEYS:
if key not in current:
continue
nested, detected = find_workflow_payload(
current.get(key),
max_depth=max_depth - 1,
)
if detected:
return nested, detected
return None, None
def _build_api_workflow(api_workflow: Mapping[str, Any]) -> Dict[str, Any]:
"""Convert an API prompt graph into a read-only editor-like workflow.
The analyzer consumes editor nodes, while API prompts store widget values
by input name and connections as ``[source_node, output_index]``. The
generated nodes retain input names so the existing category and custom-node
rules can inspect them without executing or mutating the prompt.
"""
entries = [
(str(node_id), node)
for node_id, node in api_workflow.items()
if _is_api_node(node)
]
link_ids: Dict[Tuple[str, int, str], int] = {}
output_links: Dict[Tuple[str, int], list[int]] = {}
next_link_id = 1
for target_node_id, node in entries:
inputs = node.get("inputs", {})
for _input_name, value in inputs.items():
if not _is_api_link(value):
continue
source_node_id = str(value[0])
output_index = int(value[1])
connection_key = (source_node_id, output_index, target_node_id)
link_id = link_ids.get(connection_key)
if link_id is None:
link_id = next_link_id
next_link_id += 1
link_ids[connection_key] = link_id
output_links.setdefault((source_node_id, output_index), []).append(
link_id
)
nodes = []
for node_id, node in entries:
inputs = node.get("inputs", {})
widgets_values = []
widget_items = []
input_items = []
for input_name, value in inputs.items():
input_name = str(input_name)
if _is_api_link(value):
connection_key = (str(value[0]), int(value[1]), node_id)
input_items.append(
{
"name": input_name,
"link": link_ids[connection_key],
}
)
continue
widgets_values.append(value)
widget_items.append({"name": input_name})
input_items.append(
{
"name": input_name,
"widget": {"name": input_name},
}
)
meta = node.get("_meta", {})
meta = meta if isinstance(meta, Mapping) else {}
title = str(meta.get("title", "") or "").strip()
output_indexes = [
output_index
for source_node_id, output_index in output_links
if source_node_id == node_id
]
outputs = []
if output_indexes:
for output_index in range(max(output_indexes) + 1):
outputs.append(
{
"links": output_links.get((node_id, output_index), []),
}
)
normalized_node = {
"id": node_id,
"type": str(node.get("class_type", "")),
"widgets_values": widgets_values,
"widgets": widget_items,
"inputs": input_items,
"outputs": outputs,
"mode": node.get("mode", 0),
}
if title:
normalized_node["title"] = title
if isinstance(node.get("properties"), Mapping):
normalized_node["properties"] = dict(node["properties"])
nodes.append(normalized_node)
return {"nodes": nodes}
def normalize_workflow_payload(workflow: Any) -> Tuple[Dict[str, Any], str]:
"""Return an analyzer-compatible workflow and its original format."""
payload, detected = find_workflow_payload(workflow)
if not detected or not isinstance(payload, Mapping):
raise ValueError(
"Unsupported workflow JSON. Expected a ComfyUI UI workflow "
"with a nodes array or an API prompt graph."
)
if detected == UI_WORKFLOW_FORMAT:
return dict(payload), detected
return _build_api_workflow(payload), detected
__all__ = [
"API_WORKFLOW_FORMAT",
"UI_WORKFLOW_FORMAT",
"detect_workflow_format",
"find_workflow_payload",
"normalize_workflow_payload",
]
+142
View File
@@ -0,0 +1,142 @@
import asyncio
import base64
import io
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from core.routes.context import RouteContext
from core.services.image_metadata_service import (
ImageMetadataService,
inspect_image_bytes,
inspect_workflow_json,
)
from core.workflow.formats import (
API_WORKFLOW_FORMAT,
UI_WORKFLOW_FORMAT,
detect_workflow_format,
normalize_workflow_payload,
)
def api_workflow():
return {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "models/checkpoint.safetensors"},
"_meta": {"title": "Checkpoint"},
},
"2": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"seed": 123,
"steps": 20,
},
},
}
def png_with_metadata(metadata):
image = Image.new("RGB", (32, 16), color=(20, 30, 40))
png_info = PngInfo()
for key, value in metadata.items():
png_info.add_text(key, value)
output = io.BytesIO()
image.save(output, format="PNG", pnginfo=png_info)
return output.getvalue()
def test_api_workflow_is_detected_and_normalized_for_existing_analyzer():
api = api_workflow()
assert detect_workflow_format(api) == API_WORKFLOW_FORMAT
normalized, workflow_format = normalize_workflow_payload(api)
assert workflow_format == API_WORKFLOW_FORMAT
assert [node["type"] for node in normalized["nodes"]] == [
"CheckpointLoaderSimple",
"KSampler",
]
assert normalized["nodes"][0]["widgets_values"] == [
"models/checkpoint.safetensors"
]
assert normalized["nodes"][0]["outputs"][0]["links"] == [1]
assert normalized["nodes"][1]["inputs"][0]["link"] == 1
def test_image_metadata_reads_comfyui_api_prompt():
result = inspect_image_bytes(
png_with_metadata({"prompt": json.dumps(api_workflow())}),
"generated.png",
)
assert result["filename"] == "generated.png"
assert result["image"] == {
"format": "PNG",
"width": 32,
"height": 16,
"mode": "RGB",
}
assert result["workflow_format"] == API_WORKFLOW_FORMAT
assert result["workflow_source"] == "prompt"
assert result["workflow_node_count"] == 2
assert result["metadata"]["has_workflow"] is True
def test_image_without_workflow_still_returns_safe_image_metadata():
result = inspect_image_bytes(
png_with_metadata({"parameters": "seed: 123, steps: 20"}),
"plain.png",
)
assert result["workflow"] is None
assert result["workflow_format"] == ""
assert result["metadata"]["parameters"] == "seed: 123, steps: 20"
def test_json_inspection_accepts_ui_and_api_workflows():
ui = {"nodes": [{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": []}]}
ui_result = inspect_workflow_json(ui, filename="workflow.json")
api_result = inspect_workflow_json(json.dumps(api_workflow()))
assert ui_result["workflow_format"] == UI_WORKFLOW_FORMAT
assert ui_result["workflow_node_count"] == 1
assert api_result["workflow_format"] == API_WORKFLOW_FORMAT
assert api_result["workflow_node_count"] == 2
def test_json_inspection_rejects_unrelated_json():
with pytest.raises(ValueError, match="Unsupported workflow JSON"):
inspect_workflow_json({"settings": {"theme": "dark"}})
@pytest.mark.asyncio
async def test_metadata_service_accepts_image_data_url():
raw = png_with_metadata({"prompt": json.dumps(api_workflow())})
data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
request = SimpleNamespace(
json=AsyncMock(
return_value={
"source_type": "image",
"filename": "generated.png",
"data_url": data_url,
}
)
)
service = ImageMetadataService(
RouteContext({"asyncio": asyncio, "web": web})
)
response = await service.inspect_metadata(request)
body = json.loads(response.text)
assert response.status == 200
assert body["workflow_format"] == API_WORKFLOW_FORMAT
assert body["workflow_node_count"] == 2
+2
View File
@@ -109,6 +109,7 @@ class TestRefactoringTargets(unittest.IsolatedAsyncioTestCase):
},
"metadata": {
("GET", "/model_resolver/models"),
("POST", "/model_resolver/inspect-metadata"),
("POST", "/model_resolver/metadata-size-audit"),
("GET", "/model_resolver/metadata-build/capabilities"),
("POST", "/model_resolver/metadata-build/start"),
@@ -230,6 +231,7 @@ class TestRefactoringTargets(unittest.IsolatedAsyncioTestCase):
("GET", "/model_resolver/calculate-file-hash/progress/{progress_id}"),
("POST", "/model_resolver/calculate-file-hash/cancel/{progress_id}"),
("GET", "/model_resolver/models"),
("POST", "/model_resolver/inspect-metadata"),
("POST", "/model_resolver/metadata-size-audit"),
("GET", "/model_resolver/metadata-build/capabilities"),
("POST", "/model_resolver/metadata-build/start"),
+162
View File
@@ -4782,6 +4782,168 @@ button.mr-info-link:disabled {
word-break: break-word;
}
/* Image Metadata Tab */
.mr-image-inspector {
min-height: 100%;
}
.mr-image-inspector-header {
align-items: center;
}
.mr-image-inspector-tools {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr);
gap: var(--mr-spacing-12);
padding: var(--mr-spacing-12);
border-bottom: 1px solid var(--mr-border);
}
.mr-image-inspector-dropzone,
.mr-image-inspector-paste {
display: flex;
flex-direction: column;
gap: var(--mr-spacing-8);
min-width: 0;
padding: var(--mr-spacing-16);
border: 1px dashed var(--mr-border-strong, rgba(255, 255, 255, 0.24));
border-radius: var(--mr-radius-xl);
background: rgba(255, 255, 255, 0.018);
}
.mr-image-inspector-dropzone {
align-items: center;
justify-content: center;
min-height: 132px;
text-align: center;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.mr-image-inspector-dropzone:hover,
.mr-image-inspector-dropzone.is-dragover {
border-color: var(--mr-accent);
background: rgba(114, 211, 138, 0.08);
}
.mr-image-inspector-dropzone-title {
color: var(--mr-text);
font-size: var(--mr-font-lg);
font-weight: 700;
}
.mr-image-inspector-dropzone-subtitle,
.mr-image-inspector-paste label {
color: var(--mr-text-muted);
font-size: var(--mr-font-md);
}
.mr-image-inspector-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--mr-spacing-8);
margin-top: var(--mr-spacing-4);
}
.mr-image-inspector-paste label {
font-weight: 700;
}
.mr-image-inspector-textarea {
width: 100%;
min-height: 112px;
box-sizing: border-box;
resize: vertical;
padding: var(--mr-spacing-8) var(--mr-spacing-10);
border: 1px solid var(--mr-border);
border-radius: var(--mr-radius-lg);
background: rgba(0, 0, 0, 0.18);
color: var(--mr-text);
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-sm);
}
.mr-image-inspector-textarea:focus {
border-color: var(--mr-accent);
outline: none;
}
.mr-image-inspector-paste .mr-btn {
align-self: flex-start;
}
.mr-image-inspector-result {
padding: 0 var(--mr-spacing-12) var(--mr-spacing-16);
}
.mr-image-inspector-summary {
padding-top: var(--mr-spacing-12);
}
.mr-image-inspector-info-table {
margin-bottom: var(--mr-spacing-12);
}
.mr-image-inspector-info-table td:last-child {
font-weight: 600;
}
.mr-image-inspector-parameters-row td:last-child {
display: block;
}
.mr-image-inspector-parameters {
max-height: 180px;
margin: 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: var(--mr-text-muted);
font: inherit;
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-sm);
font-weight: 500;
}
.mr-image-inspector-models {
padding-top: var(--mr-spacing-4);
}
.mr-image-inspector-loading,
.mr-image-inspector-empty {
display: flex;
align-items: center;
justify-content: center;
gap: var(--mr-spacing-8);
min-height: 86px;
color: var(--mr-text-muted);
text-align: center;
}
.mr-image-inspector-loading::before {
content: "";
width: 14px;
height: 14px;
flex: 0 0 14px;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: var(--mr-accent);
border-radius: 50%;
animation: mr-image-inspector-spin 700ms linear infinite;
}
@keyframes mr-image-inspector-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 760px) {
.mr-image-inspector-tools {
grid-template-columns: 1fr;
}
}
.mr-info-hash-control {
display: flex;
align-items: center;
+2
View File
@@ -10,6 +10,7 @@ import { modelInfoMethods } from "./views/model_info_methods.js";
import { missingBrowserMethods } from "./views/missing_browser_methods.js";
import { optionsMethods } from "./views/options_methods.js";
import { tabsLoadedMethods } from "./views/tabs_loaded_methods.js";
import { imageMetadataMethods } from "./views/tabs_image_metadata_methods.js";
import { downloadTargetMethods } from "./search/download_target_methods.js";
import { searchPanelMethods } from "./search/search_panel.js";
import { searchSourceMethods } from "./search/search_source_methods.js";
@@ -364,6 +365,7 @@ applyDialogMethods(
downloadTargetMethods,
optionsMethods,
tabsLoadedMethods,
imageMetadataMethods,
queueMethods,
queueStorageMethods,
downloadProgressMethods,
+1 -1
View File
@@ -818,7 +818,7 @@ export const searchPanelMethods = {
},
getValidTab(tab) {
return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing';
return ['missing', 'loaded', 'metadata', 'options'].includes(tab) ? tab : 'missing';
},
restoreActiveTab() {
@@ -18,6 +18,10 @@ export const dialogShellMethods = {
onclick: () => this.switchTab('loaded')
}, [$el("span.mr-tab-label", { textContent: "Loaded Models" })]);
this.metadataTab = $el("button.mr-tab", {
onclick: () => this.switchTab('metadata')
}, [$el("span.mr-tab-label", { textContent: "Metadata" })]);
this.optionsTab = $el("button.mr-tab", {
onclick: () => this.switchTab('options')
}, [$el("span.mr-tab-label", { textContent: "Options" })]);
@@ -67,6 +71,7 @@ export const dialogShellMethods = {
$el("div.mr-tabs", {}, [
this.missingTab,
this.loadedTab,
this.metadataTab,
this.optionsTab
])
]),
@@ -51,6 +51,8 @@ export const lifecycleGraphMethods = {
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
this.switchTab('loaded', { force: true });
} else if (this.activeTab === 'metadata') {
this.switchTab('metadata', { force: true });
} else {
this.switchTab('options', { force: true });
}
@@ -0,0 +1,374 @@
const MAX_IMAGE_FILE_SIZE = 64 * 1024 * 1024;
const MAX_WORKFLOW_FILE_SIZE = 16 * 1024 * 1024;
function getImageInspectorState(dialog) {
if (!dialog.imageInspectorState) {
dialog.imageInspectorState = {
metadata: null,
loadedModels: null,
error: '',
loading: '',
requestToken: null,
};
}
return dialog.imageInspectorState;
}
function isImageFile(file) {
const name = String(file?.name || '').toLowerCase();
return String(file?.type || '').startsWith('image/')
|| /\.(png|jpe?g|webp)$/i.test(name);
}
function isWorkflowFile(file) {
const name = String(file?.name || '').toLowerCase();
return file?.type === 'application/json'
|| file?.type === 'text/json'
|| name.endsWith('.json');
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Could not read the image file.'));
reader.readAsDataURL(file);
});
}
function countLabel(count, singular, plural = `${singular}s`) {
const value = Number(count) || 0;
return `${value} ${value === 1 ? singular : plural}`;
}
export const imageMetadataMethods = {
loadImageMetadata() {
if (!this.contentElement) return null;
this.contentElement.style.overflowY = 'auto';
this.renderImageMetadataShell();
return null;
},
renderImageMetadataShell() {
if (!this.contentElement) return;
const existingRoot = this.contentElement.querySelector('.mr-image-inspector');
if (!existingRoot) {
this.contentElement.innerHTML = `
<div class="mr-image-inspector">
<div class="mr-loaded-models-header mr-image-inspector-header">
<div class="mr-loaded-title-block">
<h3 class="mr-loaded-models-title">Image Metadata</h3>
<p class="mr-loaded-models-subtitle">Inspect workflow data embedded in an image or loaded from JSON.</p>
</div>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear">Clear</button>
</div>
<div class="mr-image-inspector-tools">
<div class="mr-image-inspector-dropzone" data-image-inspector-dropzone tabindex="0" role="button" aria-label="Drop an image or workflow JSON file">
<div class="mr-image-inspector-dropzone-title">Drop an image or workflow JSON here</div>
<div class="mr-image-inspector-dropzone-subtitle">PNG, JPEG, WebP, or ComfyUI UI/API JSON</div>
<div class="mr-image-inspector-actions">
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="choose-image">Load image</button>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="choose-json">Load JSON</button>
</div>
</div>
<input type="file" accept="image/png,image/jpeg,image/webp" hidden data-image-inspector-input="image">
<input type="file" accept="application/json,.json" hidden data-image-inspector-input="json">
<div class="mr-image-inspector-paste">
<label for="mr-image-inspector-json-text">Or paste workflow JSON</label>
<textarea id="mr-image-inspector-json-text" class="mr-image-inspector-textarea" rows="5" placeholder="Paste a ComfyUI UI workflow or API prompt graph..."></textarea>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="analyze-paste">Analyze pasted JSON</button>
</div>
</div>
<div class="mr-image-inspector-result" data-image-inspector-result></div>
</div>
`;
this.bindImageMetadataEvents();
}
this.renderImageMetadataResult();
},
bindImageMetadataEvents() {
const root = this.contentElement?.querySelector('.mr-image-inspector');
if (!root || root.dataset.imageInspectorBound === '1') return;
root.dataset.imageInspectorBound = '1';
const imageInput = root.querySelector('[data-image-inspector-input="image"]');
const jsonInput = root.querySelector('[data-image-inspector-input="json"]');
const dropzone = root.querySelector('[data-image-inspector-dropzone]');
dropzone?.addEventListener('click', (event) => {
if (event.target.closest?.('button')) return;
imageInput?.click();
});
root.addEventListener('click', (event) => {
const action = event.target.closest?.('[data-image-inspector-action]')?.dataset?.imageInspectorAction;
if (!action) return;
if (action === 'choose-image') imageInput?.click();
if (action === 'choose-json') jsonInput?.click();
if (action === 'clear') this.clearImageMetadata();
if (action === 'analyze-paste') {
const textarea = root.querySelector('.mr-image-inspector-textarea');
void this.inspectPastedWorkflow(textarea?.value || '');
}
});
imageInput?.addEventListener('change', (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void this.inspectImageFile(file);
});
jsonInput?.addEventListener('change', (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void this.inspectWorkflowFile(file);
});
dropzone?.addEventListener('dragover', (event) => {
event.preventDefault();
dropzone.classList.add('is-dragover');
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
});
dropzone?.addEventListener('dragleave', () => {
dropzone.classList.remove('is-dragover');
});
dropzone?.addEventListener('drop', (event) => {
event.preventDefault();
dropzone.classList.remove('is-dragover');
const file = event.dataTransfer?.files?.[0];
if (file) void this.inspectImageMetadataFile(file);
});
dropzone?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
imageInput?.click();
});
},
async inspectImageMetadataFile(file) {
if (isImageFile(file)) {
return this.inspectImageFile(file);
}
if (isWorkflowFile(file)) {
return this.inspectWorkflowFile(file);
}
this.showNotification('Select an image or a workflow JSON file.', 'warning');
return null;
},
async inspectImageFile(file) {
if (file.size > MAX_IMAGE_FILE_SIZE) {
this.showNotification('The image is too large to inspect (maximum 64 MB).', 'error');
return null;
}
try {
const dataUrl = await readFileAsDataUrl(file);
return this.runImageMetadataInspection({
source_type: 'image',
filename: file.name,
data_url: dataUrl,
});
} catch (error) {
this.showNotification(error.message || 'Could not read the image file.', 'error');
return null;
}
},
async inspectWorkflowFile(file) {
if (file.size > MAX_WORKFLOW_FILE_SIZE) {
this.showNotification('The workflow JSON is too large to inspect (maximum 16 MB).', 'error');
return null;
}
try {
const text = await file.text();
const workflow = JSON.parse(text);
return this.runImageMetadataInspection({
source_type: 'workflow',
filename: file.name,
workflow,
});
} catch (error) {
this.showNotification(error.message || 'The workflow file is not valid JSON.', 'error');
return null;
}
},
async inspectPastedWorkflow(text) {
const value = String(text || '').trim();
if (!value) {
this.showNotification('Paste a workflow JSON object first.', 'warning');
return null;
}
if (new TextEncoder().encode(value).length > MAX_WORKFLOW_FILE_SIZE) {
this.showNotification('The workflow JSON is too large to inspect (maximum 16 MB).', 'error');
return null;
}
try {
return this.runImageMetadataInspection({
source_type: 'workflow',
filename: 'Pasted workflow',
json_text: value,
});
} catch (error) {
this.showNotification(error.message || 'The pasted workflow is not valid JSON.', 'error');
return null;
}
},
async runImageMetadataInspection(payload) {
const state = getImageInspectorState(this);
const requestToken = `image-inspector-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
state.requestToken = requestToken;
state.metadata = null;
state.loadedModels = null;
state.error = '';
state.loading = 'metadata';
this.renderImageMetadataResult();
try {
const metadata = await this.fetchJson(
'/model_resolver/inspect-metadata',
{ method: 'POST', body: JSON.stringify(payload) },
'Read workflow metadata'
);
if (state.requestToken !== requestToken) return null;
state.metadata = metadata;
if (!metadata?.workflow) {
state.loading = '';
this.renderImageMetadataResult();
return metadata;
}
state.loading = 'models';
this.renderImageMetadataResult();
const loadedProgressId = `inspector-loaded-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const loadedModels = await this.fetchJson(
'/model_resolver/loaded',
{
method: 'POST',
body: JSON.stringify({
workflow: metadata.workflow,
loaded_id: loadedProgressId,
}),
},
'Scan workflow models'
);
if (state.requestToken !== requestToken) return null;
state.loadedModels = loadedModels;
state.loading = '';
this.renderImageMetadataResult();
return metadata;
} catch (error) {
if (state.requestToken !== requestToken) return null;
state.loading = '';
state.error = error?.message || 'Could not inspect workflow metadata.';
this.renderImageMetadataResult();
return null;
}
},
clearImageMetadata() {
const state = getImageInspectorState(this);
state.requestToken = `cleared-${Date.now()}`;
state.metadata = null;
state.loadedModels = null;
state.error = '';
state.loading = '';
this.renderImageMetadataShell();
},
renderImageMetadataResult() {
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
if (!result) return;
const state = getImageInspectorState(this);
if (state.loading) {
const message = state.loading === 'models'
? 'Scanning workflow models...'
: 'Reading embedded metadata...';
result.innerHTML = `<div class="mr-image-inspector-loading"><span class="mr-spinner"></span>${this.escapeHtml(message)}</div>`;
return;
}
if (state.error) {
result.innerHTML = `<p class="mr-error-text">${this.escapeHtml(state.error)}</p>`;
return;
}
if (!state.metadata) {
result.innerHTML = '<p class="mr-image-inspector-empty">Drop an image or workflow JSON to inspect it.</p>';
return;
}
const metadata = state.metadata;
const image = metadata.image || {};
const imageFormat = image.format || '—';
const dimensions = image.width && image.height
? `${image.width} × ${image.height}`
: '—';
const workflowFormat = metadata.workflow_format
? String(metadata.workflow_format).toUpperCase()
: 'Not found';
const metadataKeys = Array.isArray(metadata.metadata?.keys)
? metadata.metadata.keys.join(', ')
: '—';
const parameters = String(metadata.metadata?.parameters || '').trim();
const rows = [
['Source file', metadata.filename || '—'],
image.format ? ['Image format', imageFormat] : null,
image.width && image.height ? ['Dimensions', dimensions] : null,
['Workflow format', workflowFormat],
['Workflow source', metadata.workflow_source || '—'],
['Workflow nodes', metadata.workflow
? countLabel(metadata.workflow_node_count, 'node')
: 'No embedded workflow'],
image.mode ? ['Color mode', image.mode] : null,
['Metadata keys', metadataKeys],
].filter(Boolean);
const rowsHtml = rows.map(([label, value]) => `
<tr${label === 'Metadata keys' ? ' class="mr-info-row-wide"' : ''}>
<td>${this.escapeHtml(label)}</td>
<td>${this.escapeHtml(value)}</td>
</tr>
`).join('');
const parametersHtml = parameters ? `
<tr class="mr-info-row-wide mr-image-inspector-parameters-row">
<td>${this.escapeHtml('Generation parameters')}</td>
<td><pre class="mr-image-inspector-parameters">${this.escapeHtml(parameters)}</pre></td>
</tr>
` : '';
const modelContainerId = `mr-image-inspector-models-${Date.now()}`;
result.innerHTML = `
<section class="mr-image-inspector-summary">
<div class="mr-loaded-models-header">
<div class="mr-loaded-title-block">
<h3 class="mr-loaded-models-title">Extracted Metadata</h3>
<p class="mr-loaded-models-subtitle">Read-only information from the selected source.</p>
</div>
</div>
<table class="mr-info-table mr-image-inspector-info-table"><tbody>${rowsHtml}${parametersHtml}</tbody></table>
</section>
<section class="mr-image-inspector-models">
<div id="${this.escapeHtml(modelContainerId)}"></div>
</section>
`;
const modelContainer = result.querySelector(`#${modelContainerId}`);
if (!metadata.workflow) {
modelContainer.innerHTML = this.renderStatusMessage(
'No supported ComfyUI workflow was found in this image.',
'info'
);
} else if (state.loadedModels) {
this.displayLoadedModels(modelContainer, state.loadedModels);
} else {
modelContainer.innerHTML = this.renderStatusMessage(
'No loaded model scan is available yet.',
'info'
);
}
this.bindTooltips?.(result);
},
};
+8 -1
View File
@@ -13,12 +13,13 @@ export const tabsLoadedMethods = {
return {
missing: this.missingTab,
loaded: this.loadedTab,
metadata: this.metadataTab,
options: this.optionsTab
}[tab] || null;
},
updateTabButtonStates() {
['missing', 'loaded', 'options'].forEach((tab) => {
['missing', 'loaded', 'metadata', 'options'].forEach((tab) => {
const button = this.getTabButton(tab);
if (!button) return;
const isActive = tab === this.activeTab;
@@ -68,6 +69,12 @@ export const tabsLoadedMethods = {
}
this.setMissingFooterControlsVisible(false);
return this.loadLoadedModels();
} else if (this.activeTab === 'metadata') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(false);
return this.loadImageMetadata();
} else {
if (this.contentElement) {
this.contentElement.style.overflowY = 'hidden';