Add image metadata tab and backend workflow normalization
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user