Fix XSS in JSON viewer, prompt loss on embedded JSON, UTF-16 UserComment (v1.0.1)
- Viewer: render with DOM/textContent instead of innerHTML (metadata is untrusted); reliable copy incl. multi-line values; lazy tree rendering
- Only parse a value as JSON when the whole string is JSON (A1111 "Hashes: {...}" no longer replaces the prompt)
- Decode UserComment (ASCII/UNICODE/JIS, UTF-16 BE/LE) and XP* tags correctly; skip binary blobs such as icc_profile
- Return an error field instead of raising for directories, empty names and non-image files
- Add IS_CHANGED; keep non-ASCII text and numbers in the output JSON
- README: fix node names/descriptions, note absolute-path behavior
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9e17648f99
commit
54b782cf44
+245
-147
@@ -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"<Unserializable>"
|
||||
|
||||
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"
|
||||
}
|
||||
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"<binary {len(value)} bytes>"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+15
-15
@@ -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 = ""
|
||||
+162
-107
@@ -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 = '<div style="font-family: monospace; font-size: 11px; text-align: left;">';
|
||||
for (const key in obj) {
|
||||
const val = obj[key];
|
||||
const isObject = typeof val === "object" && val !== null;
|
||||
if (isObject) {
|
||||
const classType = val.class_type ? ` <span style="color: #888; font-weight: normal; font-size: 10px;">(${val.class_type})</span>` : "";
|
||||
const containsPrompt = hasPromptDeep(val);
|
||||
const labelStyle = containsPrompt
|
||||
? "color: #ffcc00; text-decoration: underline; background: rgba(255, 204, 0, 0.1);"
|
||||
: "color: #f8b195;";
|
||||
|
||||
html += `
|
||||
<div style="margin-bottom: 2px;">
|
||||
<div style="cursor: pointer; ${labelStyle} display: flex; align-items: center; user-select: none;"
|
||||
onclick="const c = this.nextElementSibling; c.style.display = c.style.display === 'none' ? 'block' : 'none'; this.querySelector('.arrow').innerText = c.style.display === 'none' ? '▶' : '▼'; event.stopPropagation();">
|
||||
<span class="arrow" style="width: 12px; display: inline-block; color: #ffb347;">▶</span>
|
||||
<strong>${key}${classType}</strong>
|
||||
${containsPrompt ? ' <span style="font-size: 9px; margin-left: 5px; color: #ffcc00;">[★Prompt]</span>' : ''}
|
||||
</div>
|
||||
<div class="tree-content" style="display: none; margin-left: 10px; border-left: 1px solid #444; padding-left: 6px;">
|
||||
${createTree(val)}
|
||||
</div>
|
||||
</div>`;
|
||||
} 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 += `
|
||||
<div style="padding-left: 15px; color: #ddd; margin-bottom: 3px; display: flex;">
|
||||
<span style="color: #99ffcc; white-space: nowrap; margin-right: 5px;">${key}:</span>
|
||||
<span class="copyable"
|
||||
style="${highlightStyle} cursor: copy; word-break: break-all;"
|
||||
onclick="navigator.clipboard.writeText('${escapedVal}'); alert('Copied!'); event.stopPropagation();">
|
||||
${val}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
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 <span style="font-size: 9px; color: #666;">(★ = Potential Prompt)</span>`;
|
||||
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]);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
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]);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user