feat(prompt_manager): add prepend and append text options

This commit is contained in:
Vito Sansevero
2025-06-03 17:03:18 -07:00
parent 6816f41e2c
commit 6b6ba3c62c
3 changed files with 677 additions and 13 deletions
+45 -11
View File
@@ -92,6 +92,12 @@ class PromptManager(ComfyNodeABC):
"search_text": (IO.STRING, {
"default": "",
"tooltip": "Search for past prompts containing this text"
}),
"prepend_text": (IO.STRING, {
"tooltip": "Text to prepend to the main prompt (connected STRING nodes will be added before the main text)"
}),
"append_text": (IO.STRING, {
"tooltip": "Text to append to the main prompt (connected STRING nodes will be added after the main text)"
})
}
}
@@ -114,7 +120,9 @@ class PromptManager(ComfyNodeABC):
text: str,
category: str = "",
tags: str = "",
search_text: str = ""
search_text: str = "",
prepend_text: str = "",
append_text: str = ""
) -> Tuple[Any]:
"""
Encode the text prompt and save it to the database.
@@ -125,6 +133,8 @@ class PromptManager(ComfyNodeABC):
category: Optional category for organization
tags: Comma-separated tags
search_text: Text to search for in past prompts
prepend_text: Text to prepend to the main prompt
append_text: Text to append to the main prompt
Returns:
Tuple containing the conditioning for the diffusion model
@@ -132,6 +142,20 @@ class PromptManager(ComfyNodeABC):
Raises:
RuntimeError: If clip input is invalid
"""
# Combine prepend, main text, and append text
final_text = ""
if prepend_text and prepend_text.strip():
final_text += prepend_text.strip() + " "
final_text += text
if append_text and append_text.strip():
final_text += " " + append_text.strip()
# Use the combined text for encoding
encoding_text = final_text
# For database storage, save the original main text with metadata about prepend/append
storage_text = text
# Search functionality is now handled by the JavaScript UI
# The search parameters are still available for backend processing if needed
@@ -147,23 +171,33 @@ class PromptManager(ComfyNodeABC):
# Save prompt to database and set execution context for gallery tracking
prompt_id = None
if text and text.strip():
self.logger.debug(f"Processing prompt text: {text[:100]}...")
if storage_text and storage_text.strip():
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
# Add prepend/append info to tags if they exist
extended_tags = self._parse_tags(tags) or []
if prepend_text and prepend_text.strip():
extended_tags.append(f"prepend:{prepend_text.strip()[:50]}")
if append_text and append_text.strip():
extended_tags.append(f"append:{append_text.strip()[:50]}")
try:
prompt_id = self._save_prompt_to_database(
text=text.strip(), # Always strip whitespace
text=storage_text.strip(), # Always strip whitespace
category=category.strip() if category else None,
tags=self._parse_tags(tags)
tags=extended_tags if extended_tags else None
)
# Set current prompt for image tracking
if prompt_id:
execution_id = self.prompt_tracker.set_current_prompt(
prompt_text=text.strip(),
prompt_text=encoding_text.strip(), # Use final combined text for tracking
additional_data={
'category': category.strip() if category else None,
'tags': self._parse_tags(tags),
'prompt_id': prompt_id
'tags': extended_tags,
'prompt_id': prompt_id,
'prepend_text': prepend_text.strip() if prepend_text else None,
'append_text': append_text.strip() if append_text else None
}
)
self.logger.info(f"Set execution context: {execution_id} for prompt ID: {prompt_id}")
@@ -173,9 +207,9 @@ class PromptManager(ComfyNodeABC):
self.logger.warning(f"Failed to save prompt to database: {e}")
# Already logged above, no need for additional print
# Perform standard CLIP text encoding
self.logger.debug("Performing CLIP text encoding")
tokens = clip.tokenize(text)
# Perform standard CLIP text encoding using the combined text
self.logger.debug(f"Performing CLIP text encoding on combined text: {encoding_text[:100]}...")
tokens = clip.tokenize(encoding_text)
conditioning = clip.encode_from_tokens_scheduled(tokens)
self.logger.debug("CLIP encoding completed successfully")
+74 -2
View File
@@ -2843,7 +2843,9 @@
this.updateMetadataPanel(comfyData, imageSrc);
} catch (error) {
console.error('Error extracting metadata:', error);
this.showMetadataError();
// Try fallback: get prompt from database using current image
await this.tryFallbackMetadata(imageSrc);
}
}
@@ -3129,12 +3131,82 @@
}
async copyPrompt(type) {
if (!this.currentMetadata) return;
console.log('copyPrompt called with type:', type);
console.log('currentMetadata:', this.currentMetadata);
if (!this.currentMetadata) {
console.error('No metadata available for copying');
this.showNotification('❌ No metadata available for copying', 'error');
return;
}
const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
console.log('Copying text:', text?.substring(0, 100));
if (!text || text === 'No prompt found' || text === 'No negative prompt found') {
this.showNotification(`❌ No ${type} prompt available`, 'error');
return;
}
await this.copyToClipboard(text);
}
async tryFallbackMetadata(imageSrc) {
console.log('Trying fallback metadata extraction for:', imageSrc);
try {
// Try to get prompt from current gallery image data
let fallbackPrompt = 'No prompt found';
if (this.currentGalleryImages && this.currentImageIndex !== null && this.currentGalleryImages[this.currentImageIndex]) {
const currentImage = this.currentGalleryImages[this.currentImageIndex];
console.log('Current image data:', currentImage);
// If we have prompt_id, try to get the prompt from our local prompts array
if (currentImage.prompt_id && this.prompts) {
const prompt = this.prompts.find(p => p.id === currentImage.prompt_id);
if (prompt) {
fallbackPrompt = prompt.text;
console.log('Found prompt from database:', fallbackPrompt.substring(0, 100));
}
}
}
// Set fallback metadata
this.currentMetadata = {
positivePrompt: fallbackPrompt,
negativePrompt: 'No negative prompt found',
checkpoint: 'Unknown',
steps: 'Unknown',
cfgScale: 'Unknown',
sampler: 'Unknown',
seed: 'Unknown',
workflow: null,
prompt: null
};
// Update metadata panel with fallback data
this.updateMetadataPanel({}, imageSrc);
} catch (error) {
console.error('Fallback metadata extraction failed:', error);
this.showMetadataError();
// Set empty metadata as last resort
this.currentMetadata = {
positivePrompt: 'No prompt found',
negativePrompt: 'No negative prompt found',
checkpoint: 'Unknown',
steps: 'Unknown',
cfgScale: 'Unknown',
sampler: 'Unknown',
seed: 'Unknown',
workflow: null,
prompt: null
};
}
}
async copyAllMetadata() {
if (!this.currentMetadata) return;
+558
View File
@@ -0,0 +1,558 @@
<!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="https://cdn.tailwindcss.com"></script>
<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-green-400');
setTimeout(() => {
button.innerHTML = originalContent;
button.classList.remove('text-green-400');
}, 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 {
workflowData = JSON.parse(metadata[field]);
break;
} catch (e) {
console.log('Failed to parse workflow field:', field);
}
}
}
for (const field of promptFields) {
if (metadata[field]) {
try {
promptData = JSON.parse(metadata[field]);
break;
} catch (e) {
console.log('Failed to parse prompt field:', field);
}
}
}
return { workflow: workflowData, prompt: promptData };
}
function updateDataPanel(comfyData, filename) {
const dataPanel = document.getElementById('data-panel-content');
if (!dataPanel) return;
// Extract information from ComfyUI workflow
let checkpoint = 'Unknown';
let positivePrompt = 'No prompt found';
let negativePrompt = 'No negative prompt found';
let steps = 'Unknown';
let cfgScale = 'Unknown';
let sampler = 'Unknown';
let seed = 'Unknown';
// Parse prompt data first (more reliable for actual generation parameters)
if (comfyData.prompt) {
console.log('Parsing prompt data...');
const promptNodes = comfyData.prompt;
for (const nodeId in promptNodes) {
const node = promptNodes[nodeId];
console.log(`Node ${nodeId}:`, node.class_type, node);
// Checkpoint
if (node.class_type === 'CheckpointLoaderSimple' && node.inputs) {
checkpoint = node.inputs.ckpt_name || checkpoint;
}
// Prompts - need to identify which is positive vs negative
if (node.class_type === 'PromptManager' && node.inputs && node.inputs.text) {
// PromptManager typically contains the positive prompt
positivePrompt = node.inputs.text;
console.log('Found PromptManager with text:', positivePrompt.substring(0, 100));
}
if (node.class_type === 'CLIPTextEncode' && node.inputs && node.inputs.text) {
// Check if this looks like a negative prompt
const text = node.inputs.text.toLowerCase();
if (text.includes('bad anatomy') || text.includes('unfinished') ||
text.includes('censored') || text.includes('weird anatomy') ||
text.includes('negative') || text.includes('embedding:')) {
negativePrompt = node.inputs.text;
console.log('Found negative prompt:', negativePrompt.substring(0, 100));
} else if (positivePrompt === 'No prompt found') {
// If we haven't found a positive prompt yet, this might be it
positivePrompt = node.inputs.text;
console.log('Found potential positive prompt:', positivePrompt.substring(0, 100));
}
}
// Sampling parameters
if (node.class_type === 'KSampler' && node.inputs) {
seed = node.inputs.seed || seed;
steps = node.inputs.steps || steps;
cfgScale = node.inputs.cfg || cfgScale;
sampler = node.inputs.sampler_name || sampler;
}
}
}
// Fallback to workflow parsing if prompt parsing didn't work well
if (comfyData.workflow && (positivePrompt === 'No prompt found' || checkpoint === 'Unknown')) {
console.log('Falling back to workflow parsing...');
const nodes = comfyData.workflow.nodes || [];
// Look for checkpoint loader
const checkpointNode = nodes.find(node =>
node.type === 'CheckpointLoaderSimple' ||
node.type === 'CheckpointLoader'
);
if (checkpointNode && checkpointNode.widgets_values) {
checkpoint = checkpointNode.widgets_values[0] || checkpoint;
}
// Look for prompts in workflow nodes
const textEncodeNodes = nodes.filter(node => node.type === 'CLIPTextEncode');
const promptManagerNodes = nodes.filter(node => node.type === 'PromptManager');
// Check PromptManager first for positive prompt
if (promptManagerNodes.length > 0 && promptManagerNodes[0].widgets_values) {
positivePrompt = promptManagerNodes[0].widgets_values[0] || positivePrompt;
}
// For negative prompt, look for CLIPTextEncode that isn't the positive
for (const node of textEncodeNodes) {
if (node.widgets_values && node.widgets_values[0]) {
const text = node.widgets_values[0].toLowerCase();
if (text.includes('bad anatomy') || text.includes('unfinished') ||
text.includes('censored') || text.includes('weird anatomy')) {
negativePrompt = node.widgets_values[0];
break;
}
}
}
// Look for sampler
const samplerNode = nodes.find(node =>
node.type === 'KSampler' ||
node.type === 'KSamplerAdvanced'
);
if (samplerNode && samplerNode.widgets_values) {
seed = samplerNode.widgets_values[0] || seed;
steps = samplerNode.widgets_values[1] || steps;
cfgScale = samplerNode.widgets_values[2] || cfgScale;
sampler = samplerNode.widgets_values[3] || sampler;
}
}
// Update the HTML
dataPanel.innerHTML = `
<!-- File Path -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">File Path</h2>
<div class="text-sm text-blue-400 hover:text-blue-300 cursor-pointer bg-gray-800 p-2 rounded break-all" onclick="openInFileExplorer('${filename}')">
${filename}
</div>
</div>
<!-- Resources used -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">Resources used</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-blue-400 hover:text-blue-300 cursor-pointer">${checkpoint}</div>
<div class="text-xs text-gray-500">ComfyUI Generated</div>
</div>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">CHECKPOINT</span>
</div>
</div>
<!-- Prompt -->
<div>
<div class="flex items-center gap-2 mb-2">
<h2 class="text-sm font-medium text-gray-300">Prompt</h2>
<span class="px-2 py-1 text-xs bg-orange-600 text-orange-100 rounded">COMFYUI</span>
<button class="ml-auto text-gray-400 hover:text-gray-300" 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-gray-300 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto">
${positivePrompt.substring(0, 200)}${positivePrompt.length > 200 ? '...' : ''}
</div>
${positivePrompt.length > 200 ? '<button class="text-blue-400 hover:text-blue-300 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-gray-300">Negative prompt</h2>
<button class="text-gray-400 hover:text-gray-300" 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-gray-300 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto">
${negativePrompt.substring(0, 200)}${negativePrompt.length > 200 ? '...' : ''}
</div>
${negativePrompt.length > 200 ? '<button class="text-blue-400 hover:text-blue-300 text-sm mt-1" onclick="showFullPrompt(\'negative\')">Show more</button>' : ''}
</div>
<!-- Other metadata -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-3">Other metadata</h2>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">CFG SCALE: ${cfgScale}</span>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">STEPS: ${steps}</span>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">SAMPLER: ${sampler}</span>
</div>
<div class="mt-2">
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">SEED: ${seed}</span>
</div>
</div>
<!-- Raw Workflow Data -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">ComfyUI Workflow</h2>
<div class="flex items-center gap-2">
<button class="text-blue-400 hover:text-blue-300 text-sm" onclick="showWorkflowData()">View Raw Workflow JSON</button>
<button class="text-blue-400 hover:text-blue-300" 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 showFullPrompt(type) {
if (currentWorkflowData) {
const prompt = type === 'positive' ? currentWorkflowData.positivePrompt : currentWorkflowData.negativePrompt;
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</h2>
<div style="background: #222; padding: 15px; border-radius: 5px; white-space: pre-wrap; line-height: 1.5;">${prompt}</div>
<button onclick="navigator.clipboard.writeText(\`${prompt.replace(/`/g, '\\`')}\`).then(() => alert('Copied!'))" style="margin-top: 20px; padding: 10px 20px; background: #444; color: #fff; border: none; border-radius: 5px; cursor: pointer;">Copy to Clipboard</button>
</body>
</html>
`);
}
}
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-gray-900');
});
imageArea.addEventListener('dragleave', (e) => {
e.preventDefault();
imageArea.classList.remove('bg-gray-900');
});
imageArea.addEventListener('drop', (e) => {
e.preventDefault();
imageArea.classList.remove('bg-gray-900');
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-black text-gray-100 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-4 bg-black 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-gray-500 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-lg 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-gray-800 bg-opacity-50 hover:bg-opacity-75 rounded-full flex items-center justify-center text-white 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 shadow-2xl">
</div>
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-gray-800 bg-opacity-50 hover:bg-opacity-75 rounded-full flex items-center justify-center text-white 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-gray-900 border-l border-gray-700 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" 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-lg font-medium text-white">Generation data</h1>
</div>
<button class="text-xs text-blue-400 hover:text-blue-300 transition-colors" onclick="copyAllData()">
📋 COPY ALL
</button>
</div>
<!-- Scrollable Content -->
<div id="data-panel-content" class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Initial placeholder content -->
<div class="text-center text-gray-500 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-lg 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>