diff --git a/core/routes/image_metadata.py b/core/routes/image_metadata.py new file mode 100644 index 0000000..960ca4f --- /dev/null +++ b/core/routes/image_metadata.py @@ -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, + ) diff --git a/core/routes/registry.py b/core/routes/registry.py index 33f42dd..65cb450 100644 --- a/core/routes/registry.py +++ b/core/routes/registry.py @@ -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) diff --git a/core/services/image_metadata_service.py b/core/services/image_metadata_service.py new file mode 100644 index 0000000..95319ad --- /dev/null +++ b/core/services/image_metadata_service.py @@ -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) diff --git a/core/services/loaded_models_service.py b/core/services/loaded_models_service.py index d952a11..54de56e 100644 --- a/core/services/loaded_models_service.py +++ b/core/services/loaded_models_service.py @@ -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) diff --git a/core/workflow/formats.py b/core/workflow/formats.py new file mode 100644 index 0000000..a56e99f --- /dev/null +++ b/core/workflow/formats.py @@ -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", +] diff --git a/tests/test_image_metadata.py b/tests/test_image_metadata.py new file mode 100644 index 0000000..c6d1724 --- /dev/null +++ b/tests/test_image_metadata.py @@ -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 diff --git a/tests/test_refactoring_targets.py b/tests/test_refactoring_targets.py index f1e558b..5ffd273 100644 --- a/tests/test_refactoring_targets.py +++ b/tests/test_refactoring_targets.py @@ -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"), diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 440d3dc..32095c9 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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; diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 3ef2a72..36f66a1 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -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, diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 1cf3ba2..c57dbf4 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -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() { diff --git a/web/resolver/shell/dialog_shell_methods.js b/web/resolver/shell/dialog_shell_methods.js index 6720bfe..03845d2 100644 --- a/web/resolver/shell/dialog_shell_methods.js +++ b/web/resolver/shell/dialog_shell_methods.js @@ -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 ]) ]), diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 7a5a58f..a970944 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -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 }); } diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js new file mode 100644 index 0000000..fd396c3 --- /dev/null +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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 = ` +
+
+
+

Image Metadata

+

Inspect workflow data embedded in an image or loaded from JSON.

+
+ +
+
+
+
Drop an image or workflow JSON here
+
PNG, JPEG, WebP, or ComfyUI UI/API JSON
+
+ + +
+
+ + +
+ + + +
+
+
+
+ `; + 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 = `
${this.escapeHtml(message)}
`; + return; + } + if (state.error) { + result.innerHTML = `

${this.escapeHtml(state.error)}

`; + return; + } + if (!state.metadata) { + result.innerHTML = '

Drop an image or workflow JSON to inspect it.

'; + 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]) => ` + + ${this.escapeHtml(label)} + ${this.escapeHtml(value)} + + `).join(''); + const parametersHtml = parameters ? ` + + ${this.escapeHtml('Generation parameters')} +
${this.escapeHtml(parameters)}
+ + ` : ''; + const modelContainerId = `mr-image-inspector-models-${Date.now()}`; + + result.innerHTML = ` +
+
+
+

Extracted Metadata

+

Read-only information from the selected source.

+
+
+ ${rowsHtml}${parametersHtml}
+
+
+
+
+ `; + + 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); + }, +}; diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 73dbc45..701b232 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -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';