diff --git a/Metadata_Inspector.py b/Metadata_Inspector.py index 98e682a..03cde58 100644 --- a/Metadata_Inspector.py +++ b/Metadata_Inspector.py @@ -1,147 +1,245 @@ -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 +import json +import math +import os +import datetime +from PIL import Image, ExifTags +from nodes import LoadImage +from folder_paths import get_annotated_filepath + +# Windows Explorer が書き込む UTF-16LE のタグ (XPTitle, XPComment, XPAuthor, XPKeywords, XPSubject) +_XP_TAGS = {0x9C9B, 0x9C9C, 0x9C9D, 0x9C9E, 0x9C9F} +# 中身が文字列ではないバイナリなので出力に含めない img.info のキー +_SKIP_INFO_KEYS = {"exif", "icc_profile"} + + +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) + + +def _looks_like_text(s): + """制御文字や置換文字が多い文字列はバイナリとみなす""" + if not s: + return False + bad = sum(1 for c in s if c == "�" or (ord(c) < 32 and c not in "\n\r\t")) + return bad / len(s) < 0.1 + + +def _plausibility(s): + """ASCII・かな/漢字・全角・ラテン文字など、ふつうの文章に出てくる文字の割合""" + if not s: + return 0.0 + ok = sum(1 for c in s if 0x20 <= ord(c) <= 0x7E or c in "\n\r\t" + or 0xA0 <= ord(c) <= 0x24F or 0x3000 <= ord(c) <= 0x30FF + or 0x4E00 <= ord(c) <= 0x9FFF or 0xFF00 <= ord(c) <= 0xFFEF) + return ok / len(s) + + +def _decode_utf16(payload): + if payload[:2] in (b"\xff\xfe", b"\xfe\xff"): + return payload.decode("utf-16", errors="replace") + # BOM なし: Exif の規定は BE だが LE で書く実装もあるため、両方で復号して自然な方を採る + be = payload.decode("utf-16-be", errors="replace") + le = payload.decode("utf-16-le", errors="replace") + return le if _plausibility(le) > _plausibility(be) else be + + +def _decode_bytes(data, tag=None): + """バイト列を文字列にする。文字列と判断できないバイナリは None""" + if tag in _XP_TAGS: + text = data.decode("utf-16-le", errors="replace") + else: + header, payload = data[:8], data[8:] + if header == b"UNICODE\x00": # UserComment の文字コード指定 (UTF-16) + text = _decode_utf16(payload) + else: + if header in (b"ASCII\x00\x00\x00", b"JIS\x00\x00\x00\x00\x00", b"\x00" * 8): + data = payload + text = None + for enc in ("utf-8", "cp932"): + try: + text = data.decode(enc) + break + except UnicodeDecodeError: + continue + if text is None: + text = data.decode("utf-8", errors="replace") + text = text.strip("\x00").strip() + return text if _looks_like_text(text) else None + + +def _parse_constant(name): + # NaN / Infinity は JSON として不正なので文字列にしておく + return name + + +def _try_parse_json(text): + """文字列全体が JSON のときだけパースする。文中の JSON を切り出して本文を失うことはしない""" + stripped = text.strip() + for _ in range(2): # JSON 文字列の中にさらに JSON が入っている場合に備えて2段まで + if not (stripped[:1] in ("{", "[") and stripped[-1:] in ("}", "]")): + return text + try: + parsed = json.loads(stripped, parse_constant=_parse_constant) + except (json.JSONDecodeError, RecursionError): + return text + if isinstance(parsed, str): + stripped = parsed.strip() + continue + return parsed + return text + + +def _convert_value(value, tag=None): + """任意の値を JSON にできる形へ。数値は数値のまま保持する""" + if isinstance(value, str): + parsed = _try_parse_json(value) + if parsed is value and "Steps:" in value and "Seed:" in value: + return [line.strip() for line in value.split("\n") if line.strip()] + return parsed + if isinstance(value, bytes): + text = _decode_bytes(value, tag) + if text is None: + return f"" + return _convert_value(text) + if isinstance(value, bool) or value is None or isinstance(value, int): + return value + if isinstance(value, float): + return value if math.isfinite(value) else str(value) + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + if isinstance(value, (list, tuple)): + return [_convert_value(v) for v in value] + if isinstance(value, dict): + return {str(k): _convert_value(v) for k, v in value.items()} + try: # PIL の IFDRational など + f = float(value) + return f if math.isfinite(f) else str(value) + except (TypeError, ValueError): + return str(value) + + +def _resolve_path(image_name): + image_name = image_name.strip() + if len(image_name) >= 2 and image_name[0] == '"' and image_name[-1] == '"': + image_name = image_name[1:-1].strip() + if not image_name: + return None + if os.path.isabs(image_name): + return image_name + try: + return get_annotated_filepath(image_name) + except Exception: + return None + + +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" + + @classmethod + def IS_CHANGED(s, image_name): + # 同じパスのファイルが差し替えられたときに、キャッシュされた結果を使わない + path = _resolve_path(image_name) + try: + st = os.stat(path) + return f"{st.st_mtime_ns}:{st.st_size}" + except (OSError, TypeError): + return "" + + def extract(self, image_name): + image_path = _resolve_path(image_name) + if not image_path or not os.path.isfile(image_path): + return (json.dumps({"error": "File not found"}),) + + all_metadata = {} + 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", + } + + try: + 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 in _SKIP_INFO_KEYS: + continue + all_metadata[str(key)] = _convert_value(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})"] = _convert_value(value, tag) + + 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})"] = _convert_value(value, tag) + + all_metadata["EXIF_Full_Data"] = exif_results + except Exception as e: + all_metadata["EXIF_Error"] = f"EXIF Deep Scan failed: {str(e)}" + except Exception as e: + # 画像として開けないファイル・壊れたファイル: ワークフローを止めずにエラー内容を返す + all_metadata["error"] = f"Cannot read image: {type(e).__name__}: {e}" + + return (json.dumps(all_metadata, ensure_ascii=False, default=str),) + + +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): + try: + data = json.loads(json_string) + except (json.JSONDecodeError, TypeError): + 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" +} diff --git a/README.md b/README.md index d81f999..5dbf55a 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,21 @@ The primary goal of this tool is to **reveal hidden prompt strings and workflow 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. +- **Purpose**: Loads an image from the `input` folder like the standard loader, and additionally outputs the file name. +- **Function**: Outputs `image`, `mask` and `image_name`. Connect `image_name` to **Extract PNG Metadata** so the inspected file always follows the loaded image. -### 2. Extract Metadata (The Core) +### 2. Extract PNG 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. + - **JPEG/EXIF Recovery**: Deep-scans EXIF sub-IFDs to find hidden `UserComment` (ASCII / UTF-16 `UNICODE` / JIS, big- and little-endian), `ImageDescription` and Windows `XP*` tags often used by Civitai and Tensor.art. + - **Smart JSON Parsing**: Metadata values that are JSON (ComfyUI `prompt` / `workflow`) are expanded into structures. Plain-text metadata such as A1111 `parameters` is kept intact (JSON fragments inside the text, e.g. `Hashes: {...}`, never replace the prompt). + - **Robust**: Binary blobs such as ICC profiles are skipped, and unreadable files / directories return an `error` field instead of stopping the workflow. + - **Accepts**: a file name in the `input` folder, or an absolute path (surrounding double quotes are ignored). Because absolute paths are accepted, do not expose ComfyUI to untrusted networks. ### 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. +- **Function**: Transforms the raw metadata string into an interactive, foldable tree view directly on the ComfyUI canvas. It highlights potential prompt areas (long text) in yellow for quick identification. Click a value to copy it. All metadata is rendered as plain text, so untrusted images cannot inject HTML or scripts into the page. ## Key Features - **Hidden Prompt Extraction**: Recovers data from JPEGs where standard tools fail. @@ -45,7 +47,7 @@ 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. +2. Connect it to Extract PNG 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. diff --git a/pyproject.toml b/pyproject.toml index 3cadaf8..cf642f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,16 @@ -[project] -name = "ComfyUI-Metadata-Inspector" -description = "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)." -version = "1.0.0" -readme = "README.md" -license = {file = "LICENSE"} -dependencies = [] - -[project.urls] -Repository = "https://github.com/TakkunRed/ComfyUI-Metadata-Inspector" -# Used by Comfy Registry https://comfyregistry.org - -[tool.comfy] -PublisherId = "takkun" -DisplayName = "ComfyUI-Metadata-Inspector" +[project] +name = "ComfyUI-Metadata-Inspector" +description = "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)." +version = "1.0.1" +readme = "README.md" +license = {file = "LICENSE"} +dependencies = [] + +[project.urls] +Repository = "https://github.com/TakkunRed/ComfyUI-Metadata-Inspector" +# Used by Comfy Registry https://comfyregistry.org + +[tool.comfy] +PublisherId = "takkun" +DisplayName = "ComfyUI-Metadata-Inspector" Icon = "" \ No newline at end of file diff --git a/web/js/json_viewer.js b/web/js/json_viewer.js index 7afa7d9..8aceb96 100644 --- a/web/js/json_viewer.js +++ b/web/js/json_viewer.js @@ -1,107 +1,162 @@ -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 +import { app } from "../../../scripts/app.js"; + +// 画像のメタデータは第三者が自由に書き込める信頼できない入力。 +// HTML 文字列を組み立てず、DOM API と textContent だけで描画する(XSS 対策)。 + +const LONG_TEXT_LENGTH = 50; + +const el = (tag, css, text) => { + const e = document.createElement(tag); + if (css) e.style.cssText = css; + if (text !== undefined) e.textContent = text; + return e; +}; + +const copyText = async (text) => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (_) { + // http:// 経由(LAN 内アクセス等)では clipboard API が使えないため textarea で代替 + const ta = el("textarea", "position: fixed; opacity: 0;"); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand("copy"); } catch (_) { /* noop */ } + ta.remove(); + return ok; + } +}; + +// 「プロンプトらしい長い文字列を含むか」をキャッシュ付きで判定(深い JSON でも再計算しない) +const promptCache = new WeakMap(); +const hasPromptDeep = (obj) => { + if (promptCache.has(obj)) return promptCache.get(obj); + let found = false; + for (const k of Object.keys(obj)) { + const v = obj[k]; + if (typeof v === "string" && v.length > LONG_TEXT_LENGTH) { found = true; break; } + if (typeof v === "object" && v !== null && hasPromptDeep(v)) { found = true; break; } + } + promptCache.set(obj, found); + return found; +}; + +const renderLeaf = (key, val) => { + const row = el("div", "padding-left: 15px; color: #ddd; margin-bottom: 3px; display: flex;"); + row.appendChild(el("span", "color: #99ffcc; white-space: nowrap; margin-right: 5px;", `${key}:`)); + + const isLongText = typeof val === "string" && val.length > LONG_TEXT_LENGTH; + const highlightStyle = isLongText + ? "background-color: #1e2a3a; color: #ffeb99; padding: 2px 4px; border-radius: 2px; border: 1px solid #3d4d5d;" + : "color: #fff;"; + const text = String(val); + const span = el("span", `${highlightStyle} cursor: copy; word-break: break-all; white-space: pre-wrap;`, text); + span.title = "Click to copy"; + span.addEventListener("click", async (ev) => { + ev.stopPropagation(); + const ok = await copyText(text); + const prev = span.style.outline; + span.style.outline = ok ? "1px solid #4caf50" : "1px solid #f44336"; + span.title = ok ? "Copied!" : "Copy failed"; + setTimeout(() => { span.style.outline = prev; span.title = "Click to copy"; }, 700); + }); + row.appendChild(span); + return row; +}; + +const renderTree = (obj) => { + const root = el("div", "font-family: monospace; font-size: 11px; text-align: left;"); + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (typeof val === "object" && val !== null) { + const containsPrompt = hasPromptDeep(val); + const labelStyle = containsPrompt + ? "color: #ffcc00; text-decoration: underline; background: rgba(255, 204, 0, 0.1);" + : "color: #f8b195;"; + + const wrap = el("div", "margin-bottom: 2px;"); + const head = el("div", `cursor: pointer; ${labelStyle} display: flex; align-items: center; user-select: none;`); + const arrow = el("span", "width: 12px; display: inline-block; color: #ffb347;", "▶"); + const label = el("strong", "", key); + head.append(arrow, label); + if (typeof val.class_type === "string") { + head.appendChild(el("span", "color: #888; font-weight: normal; font-size: 10px; margin-left: 4px;", `(${val.class_type})`)); + } + if (containsPrompt) { + head.appendChild(el("span", "font-size: 9px; margin-left: 5px; color: #ffcc00;", "[★Prompt]")); + } + + const content = el("div", "display: none; margin-left: 10px; border-left: 1px solid #444; padding-left: 6px;"); + let built = false; + head.addEventListener("click", (ev) => { + ev.stopPropagation(); + const open = content.style.display === "none"; + if (open && !built) { // 巨大な JSON でも開いた分だけ描画する + content.appendChild(renderTree(val)); + built = true; + } + content.style.display = open ? "block" : "none"; + arrow.textContent = open ? "▼" : "▶"; + }); + + wrap.append(head, content); + root.appendChild(wrap); + } else { + root.appendChild(renderLeaf(key, val)); + } + } + return root; +}; + +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 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 = el("div", "border-bottom: 1px solid #333; margin-bottom: 8px; padding-bottom: 4px; color: #ffb347; font-weight: bold; flex-shrink: 0;", "PNG Info Viewer "); + header.appendChild(el("span", "font-size: 9px; color: #666;", "(★ = Potential Prompt)")); + viewerContainer.appendChild(header); + + const treeRoot = el("div", "overflow-y: auto; flex-grow: 1;"); + if (typeof data === "object") { + treeRoot.appendChild(renderTree(data)); + } else { + treeRoot.appendChild(renderLeaf("value", 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]); + }; + } + }, +});