Files
ComfyAssets-ComfyUI_PromptM…/web/metadata.html
T
Vito aa768f57dd fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search (#151)
* fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search

- fix(#75): stop SaveImage patch from writing the last-run prompt into every
  PromptManager node (negative prompts were saved as copies of the positive)
- fix(#75): replace four duplicated metadata parsers with a shared
  web/js/comfy-metadata.js that traces sampler links (KSampler, CFGGuider /
  SamplerCustomAdvanced, LoRA chains, ControlNet pass-through, any checkpoint
  loader) and recovers per-node text for images saved by older versions
- fix(#143): treat null JSON body fields as empty in prompt API handlers
- fix(#149): keep auto-hide timeout 0 (disabled) instead of falling back to
  the default; validate result_timeout on save
- feat(#150): support tag:name filters in the node search box
- docs(#142): scan dialog mentions A1111/Forge metadata support
- docs(#144): fix ComfyUI-Lora-Manager links
- ci: run JS tests with node --test

* fix: bound metadata graph traversal to prevent tab freeze on crafted PNGs

Link resolution tried every linked input recursively with only a depth cap,
so a PNG whose prompt graph has cycles and fan-out could force ~inputs^25
paths and hang the gallery/metadata page. Each lookup now shares a visited
set (each node expanded once) with a hard cap of 500 visits.

* fix: read the matching output of multi-output settings nodes

KSampler settings linked to a node like SamplerCombo all resolved to its
first value, so steps, cfg and sampler showed the sampler name. Scalar
resolution now prefers the linked node's input with the same field name.

* fix: address review — trust API text, restore workflow-only parsing

- Workflow widget text now only replaces API text for metadata showing the
  pre-3.2.3 corruption signature (2+ PromptManager nodes with identical API
  text but different widgets). Otherwise the API graph wins, so expanded
  dynamic prompts and linked text display what actually ran.
- Images with only a UI workflow are converted to the API graph shape and
  traced the same way: prompts, KSamplerAdvanced, UNET/checkpoint loaders,
  KJNodes Set/Get, Reroute, bypassed and muted nodes.
2026-10-07 11:27:40 -07:00

487 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Gallery - Generation Data</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
<style>
/* Ensure the layout takes full height and prevents overflow */
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Ensure image container uses flexbox properly */
#image-area {
min-height: 0; /* Allow flex child to shrink */
}
/* Make sure the image scales properly */
#main-image {
max-width: calc(100% - 2rem); /* Account for padding */
max-height: calc(100% - 2rem); /* Account for padding */
}
</style>
<script>
let currentWorkflowData = null;
function openInFileExplorer(path) {
// For Windows file explorer
if (navigator.platform.indexOf('Win') > -1) {
// This will work in Electron apps or desktop environments
try {
window.require('child_process').exec(`explorer /select,"${path}"`);
} catch (e) {
// Fallback: copy path to clipboard and show alert
navigator.clipboard.writeText(path).then(() => {
alert('File path copied to clipboard: ' + path);
}).catch(() => {
alert('File path: ' + path);
});
}
} else {
// For other platforms, just copy to clipboard
navigator.clipboard.writeText(path).then(() => {
alert('File path copied to clipboard: ' + path);
}).catch(() => {
alert('File path: ' + path);
});
}
}
async function copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
// Show temporary feedback
const originalContent = button.innerHTML;
button.innerHTML = '✓ Copied!';
button.classList.add('text-pm-success');
setTimeout(() => {
button.innerHTML = originalContent;
button.classList.remove('text-pm-success');
}, 1000);
} catch (err) {
alert('Copy failed: ' + err);
}
}
function copyPositivePrompt(event) {
if (currentWorkflowData && currentWorkflowData.positivePrompt) {
copyToClipboard(currentWorkflowData.positivePrompt, event.target.closest('button'));
}
}
function copyNegativePrompt(event) {
if (currentWorkflowData && currentWorkflowData.negativePrompt) {
copyToClipboard(currentWorkflowData.negativePrompt, event.target.closest('button'));
}
}
function downloadWorkflowJSON() {
if (currentWorkflowData && currentWorkflowData.workflow) {
const dataStr = JSON.stringify(currentWorkflowData.workflow, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'comfyui_workflow.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
}
async function copyAllData() {
if (!currentWorkflowData) return;
const allData = `Checkpoint: ${currentWorkflowData.checkpoint || 'Unknown'}
Positive Prompt: ${currentWorkflowData.positivePrompt}
Negative Prompt: ${currentWorkflowData.negativePrompt}
Steps: ${currentWorkflowData.steps || 'Unknown'}
CFG Scale: ${currentWorkflowData.cfgScale || 'Unknown'}
Sampler: ${currentWorkflowData.sampler || 'Unknown'}
Seed: ${currentWorkflowData.seed || 'Unknown'}`;
await copyToClipboard(allData);
}
async function parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
return metadata;
}
function extractComfyUIData(metadata) {
// Look for ComfyUI workflow data in various possible fields
let workflowData = null;
let promptData = null;
// Common ComfyUI metadata field names
const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI'];
const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters'];
for (const field of workflowFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
workflowData = JSON.parse(cleanedJson);
break;
} catch (e) {
console.log('Failed to parse workflow field:', field, e);
}
}
}
for (const field of promptFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
promptData = JSON.parse(cleanedJson);
break;
} catch (e) {
console.log('Failed to parse prompt field:', field, e);
}
}
}
return { workflow: workflowData, prompt: promptData };
}
function updateDataPanel(comfyData, filename) {
const dataPanel = document.getElementById('data-panel-content');
if (!dataPanel) return;
// Trace prompts, model and sampler settings through the node graph (#75)
const {
checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed,
} = window.ComfyMetadata.extractGenerationParams(comfyData);
// Update the HTML
dataPanel.innerHTML = `
<!-- File Path -->
<div>
<h2 class="text-sm font-medium text-pm-secondary mb-2">File Path</h2>
<div class="text-sm text-pm-accent hover:bg-pm-hover cursor-pointer bg-pm-surface p-2 rounded-pm-sm break-all" onclick="openInFileExplorer('${filename}')">
${filename}
</div>
</div>
<!-- Resources used -->
<div>
<h2 class="text-sm font-medium text-pm-secondary mb-2">Resources used</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-pm-accent cursor-pointer">${checkpoint}</div>
<div class="text-xs text-pm-muted">ComfyUI Generated</div>
</div>
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">CHECKPOINT</span>
</div>
</div>
<!-- Prompt -->
<div>
<div class="flex items-center gap-2 mb-2">
<h2 class="text-sm font-medium text-pm-secondary">Prompt</h2>
<span class="px-2 py-1 text-xs bg-pm-warning text-pm rounded-pm-sm">COMFYUI</span>
<button class="ml-auto text-pm-secondary hover:text-pm" onclick="copyPositivePrompt(event)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded-pm-sm max-h-32 overflow-y-auto">
${positivePrompt.substring(0, 200)}${positivePrompt.length > 200 ? '...' : ''}
</div>
${positivePrompt.length > 200 ? '<button class="text-pm-accent hover:bg-pm-hover text-sm mt-1" onclick="showFullPrompt(\'positive\')">Show more</button>' : ''}
</div>
<!-- Negative prompt -->
<div>
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-medium text-pm-secondary">Negative prompt</h2>
<button class="text-pm-secondary hover:text-pm" onclick="copyNegativePrompt(event)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded-pm-sm max-h-32 overflow-y-auto">
${negativePrompt.substring(0, 200)}${negativePrompt.length > 200 ? '...' : ''}
</div>
${negativePrompt.length > 200 ? '<button class="text-pm-accent hover:bg-pm-hover text-sm mt-1" onclick="showFullPrompt(\'negative\')">Show more</button>' : ''}
</div>
<!-- Other metadata -->
<div>
<h2 class="text-sm font-medium text-pm-secondary mb-3">Other metadata</h2>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">CFG SCALE: ${cfgScale}</span>
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">STEPS: ${steps}</span>
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">SAMPLER: ${sampler}</span>
</div>
<div class="mt-2">
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">SEED: ${seed}</span>
</div>
</div>
<!-- Raw Workflow Data -->
<div>
<h2 class="text-sm font-medium text-pm-secondary mb-2">ComfyUI Workflow</h2>
<div class="flex items-center gap-2">
<button class="text-pm-accent hover:bg-pm-hover text-sm" onclick="showWorkflowData()">View Raw Workflow JSON</button>
<button class="text-pm-accent hover:bg-pm-hover" onclick="downloadWorkflowJSON()" title="Download JSON">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</button>
</div>
</div>
`;
// Store the full data for later use
currentWorkflowData = {
positivePrompt,
negativePrompt,
checkpoint,
steps,
cfgScale,
sampler,
seed,
workflow: comfyData.workflow,
prompt: comfyData.prompt
};
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showFullPrompt(type) {
if (currentWorkflowData) {
const prompt = type === 'positive' ? currentWorkflowData.positivePrompt : currentWorkflowData.negativePrompt;
const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
const newWindow = window.open('', '_blank');
const doc = newWindow.document;
doc.open();
doc.write('<!DOCTYPE html><html><head><title>' + safeType + ' Prompt</title></head><body></body></html>');
doc.close();
doc.body.style.cssText = 'background:#111;color:#fff;font-family:monospace;padding:20px;';
const h2 = doc.createElement('h2');
h2.textContent = type.charAt(0).toUpperCase() + type.slice(1) + ' Prompt';
doc.body.appendChild(h2);
const pre = doc.createElement('pre');
pre.style.cssText = 'background:#222;padding:15px;border-radius:5px;white-space:pre-wrap;line-height:1.5;';
pre.textContent = prompt;
doc.body.appendChild(pre);
const btn = doc.createElement('button');
btn.textContent = 'Copy to Clipboard';
btn.style.cssText = 'margin-top:20px;padding:10px 20px;background:#444;color:#fff;border:none;border-radius:5px;cursor:pointer;';
btn.addEventListener('click', function() { navigator.clipboard.writeText(pre.textContent).then(function() { alert('Copied!'); }); });
doc.body.appendChild(btn);
}
}
function showWorkflowData() {
if (currentWorkflowData && currentWorkflowData.workflow) {
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>ComfyUI Workflow Data</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>ComfyUI Workflow JSON</h2>
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(currentWorkflowData.workflow, null, 2)}</pre>
</body>
</html>
`);
}
}
async function handleImageUpload(event) {
const file = event.target.files[0];
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
const metadata = await parsePNGMetadata(arrayBuffer);
const comfyData = extractComfyUIData(metadata);
// Update the image display
const img = document.getElementById('main-image');
const url = URL.createObjectURL(file);
img.src = url;
// Update data panel
updateDataPanel(comfyData, file.name);
console.log('Extracted metadata:', metadata);
console.log('ComfyUI data:', comfyData);
} catch (error) {
console.error('Error processing image:', error);
alert('Error processing image: ' + error.message);
}
}
// Initialize drag and drop
function initializeDragDrop() {
const imageArea = document.getElementById('image-area');
imageArea.addEventListener('dragover', (e) => {
e.preventDefault();
imageArea.classList.add('bg-pm-surface');
});
imageArea.addEventListener('dragleave', (e) => {
e.preventDefault();
imageArea.classList.remove('bg-pm-surface');
});
imageArea.addEventListener('drop', (e) => {
e.preventDefault();
imageArea.classList.remove('bg-pm-surface');
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type === 'image/png') {
const event = { target: { files: [file] } };
handleImageUpload(event);
} else {
alert('Please drop a PNG file');
}
}
});
}
// Initialize when page loads
window.addEventListener('load', initializeDragDrop);
// Handle window resize to ensure image stays properly sized
window.addEventListener('resize', () => {
// The CSS will handle the responsive sizing automatically
// but we can trigger any additional adjustments if needed
});
</script>
</head>
<body class="bg-pm-primary text-pm font-sans overflow-hidden">
<div class="h-screen flex">
<!-- Main Image Area -->
<div id="image-area" class="flex-1 flex items-center justify-center p-3 bg-pm-primary relative border-2 border-dashed border-transparent transition-colors min-w-0">
<!-- Upload prompt overlay -->
<div class="absolute inset-0 flex flex-col items-center justify-center text-pm-muted pointer-events-none">
<svg class="w-16 h-16 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="text-sm mb-2">Drag & Drop PNG with ComfyUI Workflow</p>
<p class="text-sm">Or click to browse files</p>
</div>
<!-- File input -->
<input type="file" id="file-input" accept=".png" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" onchange="handleImageUpload(event)">
<!-- Navigation arrows -->
<button class="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-pm-surface/50 hover:bg-pm-hover rounded-full flex items-center justify-center text-pm transition-all z-10">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<!-- Main Image -->
<div class="w-full h-full flex items-center justify-center z-10 pointer-events-none">
<img id="main-image"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='1200' viewBox='0 0 800 1200'%3E%3Crect width='800' height='1200' fill='%23374151'/%3E%3Ctext x='400' y='600' font-family='Arial' font-size='24' fill='%23D1D5DB' text-anchor='middle'%3EDrop PNG Here%3C/text%3E%3Ctext x='400' y='640' font-family='Arial' font-size='16' fill='%239CA3AF' text-anchor='middle'%3EComfyUI Workflow%3C/text%3E%3C/svg%3E"
alt="Generated Image"
class="max-w-full max-h-full object-contain rounded-pm-md shadow-pm">
</div>
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-pm-surface/50 hover:bg-pm-hover rounded-full flex items-center justify-center text-pm transition-all z-10">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- Generation Data Panel -->
<div class="w-96 bg-pm-primary border-l border-pm flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-3 border-b border-pm">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h1 class="text-sm font-semibold text-pm">Generation data</h1>
</div>
<button class="text-xs text-pm-accent hover:bg-pm-hover transition-colors" onclick="copyAllData()">
📋 COPY ALL
</button>
</div>
<!-- Scrollable Content -->
<div id="data-panel-content" class="flex-1 overflow-y-auto p-3 space-y-3">
<!-- Initial placeholder content -->
<div class="text-center text-pm-muted py-8">
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="text-sm mb-2">No Image Loaded</p>
<p class="text-sm">Upload a PNG with embedded ComfyUI workflow to view generation data</p>
</div>
</div>
</div>
</div>
</body>
</html>