- 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>
163 lines
7.0 KiB
JavaScript
163 lines
7.0 KiB
JavaScript
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]);
|
|
};
|
|
}
|
|
},
|
|
});
|