diff --git a/Metadata_Inspector.py b/Metadata_Inspector.py new file mode 100644 index 0000000..98e682a --- /dev/null +++ b/Metadata_Inspector.py @@ -0,0 +1,147 @@ +import json +import os +import datetime +import re +from PIL import Image, ExifTags +import numpy as np +from nodes import LoadImage +from folder_paths import get_annotated_filepath + +class LoadImageWithFileName(LoadImage): + @classmethod + def INPUT_TYPES(s): + return super().INPUT_TYPES() + + RETURN_TYPES = ("IMAGE", "MASK", "STRING") + RETURN_NAMES = ("image", "mask", "image_name") + FUNCTION = "load_image_info" + CATEGORY = "image" + + def load_image_info(self, image): + img, mask = super().load_image(image) + return (img, mask, image) + + +class ExtractPngMetadata: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image_name": ("STRING", {"default": "example.png"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("metadata_json",) + FUNCTION = "extract" + CATEGORY = "image/metadata" + + def extract(self, image_name): + if os.path.isabs(image_name): + image_path = image_name + else: + image_path = get_annotated_filepath(image_name) + + all_metadata = {} + + def json_serializable(obj): + try: + if isinstance(obj, (datetime.date, datetime.datetime)): + return obj.isoformat() + if isinstance(obj, bytes): + try: + s = obj.decode('utf-8', errors="replace").strip('\x00') + except: + s = obj.decode('cp932', errors="replace").strip('\x00') + return re.sub(r'^(ASCII|UNICODE|JIS|UNDEFINED)\x00*', '', s).strip() + return str(obj) + except: + return f"" + + def try_parse_json(value): + val_str = value if isinstance(value, str) else json_serializable(value) + + stripped = val_str.strip() + json_match = re.search(r'(\{.*\}|\[.*\])', stripped, re.DOTALL) + if json_match: + try: + parsed = json.loads(json_match.group(0)) + if isinstance(parsed, str): + return try_parse_json(parsed) + return parsed + except: + pass + + if "Steps:" in stripped and "Seed:" in stripped: + return [line.strip() for line in stripped.split('\n') if line.strip()] + + return val_str + + if os.path.exists(image_path): + stat = os.stat(image_path) + all_metadata["fileinfo"] = { + "filename": os.path.basename(image_path), + "date": datetime.datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), + "size": f"{stat.st_size / 1024:.2f} KB", + } + + with Image.open(image_path) as img: + all_metadata["fileinfo"]["resolution"] = f"{img.width}x{img.height}" + + for key, value in img.info.items(): + if key == "exif": continue + all_metadata[key] = try_parse_json(value) + + try: + exif_obj = img.getexif() + if exif_obj: + exif_results = {} + + for tag, value in exif_obj.items(): + tag_name = ExifTags.TAGS.get(tag, f"Tag_{tag}") + exif_results[f"{tag_name} (ID: {tag})"] = try_parse_json(value) + + exif_ifd = exif_obj.get_ifd(0x8769) + if exif_ifd: + for tag, value in exif_ifd.items(): + tag_name = ExifTags.TAGS.get(tag, f"ExifIFD_Tag_{tag}") + exif_results[f"{tag_name} (ID: {tag})"] = try_parse_json(value) + + all_metadata["EXIF_Full_Data"] = exif_results + except Exception as e: + all_metadata["EXIF_Error"] = f"EXIF Deep Scan failed: {str(e)}" + else: + all_metadata = {"error": "File not found"} + + return (json.dumps(all_metadata, default=json_serializable),) + +class JsonVisualizer: + @classmethod + def INPUT_TYPES(s): + return {"required": {"json_string": ("STRING", {"forceInput": True})}} + + RETURN_TYPES = ("STRING",) + FUNCTION = "visualize" + CATEGORY = "ui/visualizer" + OUTPUT_NODE = True + + def visualize(self, json_string): + import json + try: + data = json.loads(json_string) + except: + data = {"error": "Invalid JSON"} + + return {"ui": {"json_data": [data]}, "result": (json_string,)} + +NODE_CLASS_MAPPINGS = { + "LoadImageWithFileName": LoadImageWithFileName, + "ExtractPngMetadata": ExtractPngMetadata, + "JsonVisualizer": JsonVisualizer +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImageWithFileName": "Load Image w/ Name", + "ExtractPngMetadata": "Extract PNG Metadata", + "JsonVisualizer": "JSON Tree Viewer" +} \ No newline at end of file diff --git a/README.md b/README.md index f51a6f6..4102253 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ -# ComfyUI-Metadata-Inspector -ComfyUI-Metadata-Inspector +# ComfyUI-Metadata-Inspector + +A specialized suite of custom nodes for ComfyUI designed to hunt down and visualize generation parameters (prompts, steps, samplers, etc.) embedded in AI-generated images. It works across different formats and platforms (ComfyUI, Automatic1111, Civitai, and more). + +[![GitHub license](https://img.shields.io/github/license/TakkunRed/ComfyUI-Metadata-Inspector)](https://github.com/TakkunRed/ComfyUI-Metadata-Inspector/blob/main/LICENSE) +[![ComfyUI](https://img.shields.io/badge/ComfyUI-Custom_Nodes-blue)](https://github.com/comfyanonymous/ComfyUI) + +## Core Mission +The primary goal of this tool is to **reveal hidden prompt strings and workflow structures** from AI-generated content. Whether it's a PNG with a complex node graph or a JPEG with a text-based prompt hidden in EXIF data, this inspector brings it to light. + +## Node Descriptions + +This extension includes three essential nodes to complete the inspection workflow: + +### 1. Load Image w/ Name +- **Purpose**: Provides a flexible way to load images. +- **Function**: Unlike the standard loader, it allows you to input a filename via string or absolute path, making it easier to integrate with dynamic file-handling workflows. + +### 2. Extract Metadata (The Core) +- **Purpose**: The "brain" that scans the image file for generation data. +- **Function**: + - **PNG Deep Scan**: Extracts full ComfyUI/A1111 prompt and workflow JSONs. + - **JPEG/EXIF Recovery**: Deep-scans EXIF sub-IFDs to find hidden `UserComment` and `ImageDescription` tags often used by Civitai and Tensor.art. + - **Smart JSON Recovery**: Detects JSON structures even when they are buried inside plain-text metadata. + +### 3. JSON Tree Viewer (Web UI) +- **Purpose**: Provides a human-readable visualization of the raw data. +- **Function**: Transforms the raw metadata string into an interactive, foldable tree view directly on the ComfyUI canvas. It highlights potential prompt areas in orange for quick identification. + +## Key Features +- **Hidden Prompt Extraction**: Recovers data from JPEGs where standard tools fail. +- **Workflow Reconstruction Support**: Outputs raw JSON strings that can be used to understand complex node setups. +- **Multi-Platform Compatibility**: Supports metadata formats from ComfyUI, Automatic1111, and various web-based generators. + +## Installation + +### Manual Install +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/TakkunRed/ComfyUI-Metadata-Inspector.git +``` + +## How to Use + +1. Place Load Image w/ Name. +2. Connect it to Extract Metadata. +3. Connect the metadata_json output to JSON Tree Viewer. +4. Input your image path and run the queue to see the hidden prompt data. + +## License +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0db5446 --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +from .Metadata_Inspector import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +# ここが重要です。文字列でディレクトリ名を指定します。 +WEB_DIRECTORY = "web" + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] \ No newline at end of file diff --git a/web/js/json_viewer.js b/web/js/json_viewer.js new file mode 100644 index 0000000..7afa7d9 --- /dev/null +++ b/web/js/json_viewer.js @@ -0,0 +1,107 @@ +import { app } from "../../../scripts/app.js"; + +app.registerExtension({ + name: "Comfy.JsonTreeVisualizer", // 名前が他と被らないように変更 + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // 対象を新しいノード名に変更 + if (nodeData.name === "JsonVisualizer") { + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + + // --- ここから成功していた時のロジックを完全再現 --- + const data = Array.isArray(message.json_data) ? message.json_data[0] : message.json_data; + if (!data) return; + + if (this.widgets) { + const existing = this.widgets.findIndex(w => w.name === "MetadataViewer"); + if (existing !== -1) { + this.widgets[existing].onRemove?.(); + this.widgets.splice(existing, 1); + } + } + + const hasPromptDeep = (obj) => { + for (const k in obj) { + const v = obj[k]; + if (typeof v === "string" && v.length > 50) return true; + if (typeof v === "object" && v !== null && hasPromptDeep(v)) return true; + } + return false; + }; + + const createTree = (obj) => { + let html = '
'; + for (const key in obj) { + const val = obj[key]; + const isObject = typeof val === "object" && val !== null; + if (isObject) { + const classType = val.class_type ? ` (${val.class_type})` : ""; + const containsPrompt = hasPromptDeep(val); + const labelStyle = containsPrompt + ? "color: #ffcc00; text-decoration: underline; background: rgba(255, 204, 0, 0.1);" + : "color: #f8b195;"; + + html += ` +
+
+ ▶ + ${key}${classType} + ${containsPrompt ? ' [★Prompt]' : ''} +
+ +
`; + } else { + const isLongText = typeof val === "string" && val.length > 50; + const highlightStyle = isLongText + ? "background-color: #1e2a3a; color: #ffeb99; padding: 2px 4px; border-radius: 2px; border: 1px solid #3d4d5d;" + : "color: #fff;"; + + const escapedVal = String(val).replace(/'/g, "\\'").replace(/"/g, '"'); + html += ` +
+ ${key}: + + ${val} + +
`; + } + } + html += '
'; + return html; + }; + + const viewerContainer = document.createElement("div"); + viewerContainer.style.cssText = ` + display: flex; flex-direction: column; width: 100%; height: 100%; + min-height: 250px; background: #0f0f0f; padding: 10px; margin-top: 5px; + border: 1px solid #333; border-radius: 4px; color: #eee; box-sizing: border-box; + `; + + const header = document.createElement("div"); + header.innerHTML = `PNG Info Viewer (★ = Potential Prompt)`; + header.style.cssText = "border-bottom: 1px solid #333; margin-bottom: 8px; padding-bottom: 4px; color: #ffb347; font-weight: bold; flex-shrink: 0;"; + viewerContainer.appendChild(header); + + const treeRoot = document.createElement("div"); + treeRoot.style.cssText = "overflow-y: auto; flex-grow: 1;"; + treeRoot.innerHTML = createTree(data); + viewerContainer.appendChild(treeRoot); + + const w = this.addDOMWidget("MetadataViewer", "view", viewerContainer); + w.serialize = false; + + this.onResize = function(size) { + viewerContainer.style.height = (size[1] - 40) + "px"; + }; + + if (this.size[1] < 200) this.setSize([Math.max(this.size[0], 500), 600]); + }; + } + }, +}); \ No newline at end of file