feat(metadata): Add metadata extraction in viewer

This commit is contained in:
Vito Sansevero
2025-06-01 12:25:41 -07:00
parent 798adcc0f3
commit ece65edbb2
+480 -34
View File
@@ -196,6 +196,10 @@
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
📋 View Logs
</button>
<button id="metadataBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
🔍 Metadata
</button>
</div>
</div>
</div>
@@ -568,41 +572,79 @@
<!-- Image Viewer Modal -->
<div id="imageViewerModal" class="fixed inset-0 bg-black bg-opacity-90 hidden items-center justify-center" style="z-index: 9999;">
<div class="relative w-full h-full flex items-center justify-center">
<!-- Close button -->
<button onclick="window.admin.closeImageViewer()" class="absolute top-4 right-4 p-2 rounded-lg bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<!-- Previous button -->
<button onclick="window.admin.previousImage()" class="absolute left-4 top-1/2 transform -translate-y-1/2 p-3 rounded-full bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" 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>
<!-- Next button -->
<button onclick="window.admin.nextImage()" class="absolute right-4 top-1/2 transform -translate-y-1/2 p-3 rounded-full bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" 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>
<!-- View mode indicator -->
<div id="viewModeIndicator" class="absolute top-4 left-4 text-white text-sm bg-black/50 px-3 py-1 rounded-full z-10">
Fit to Window
<div class="h-screen flex w-full">
<!-- Main Image Area -->
<div class="flex-1 flex items-center justify-center p-4 bg-black relative">
<!-- Close button -->
<button onclick="window.admin.closeImageViewer()" class="absolute top-4 right-4 p-2 rounded-lg bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<!-- Previous button -->
<button onclick="window.admin.previousImage()" class="absolute left-4 top-1/2 transform -translate-y-1/2 p-3 rounded-full bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" 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>
<!-- Next button -->
<button onclick="window.admin.nextImage()" class="absolute right-4 top-1/2 transform -translate-y-1/2 p-3 rounded-full bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" 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>
<!-- View mode indicator -->
<div id="viewModeIndicator" class="absolute top-4 left-4 text-white text-sm bg-black/50 px-3 py-1 rounded-full z-10">
Fit to Window
</div>
<!-- Toggle metadata panel button -->
<button id="toggleMetadataBtn" onclick="window.admin.toggleMetadataPanel()" class="absolute top-4 left-1/2 transform -translate-x-1/2 p-2 rounded-lg bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" 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>
</button>
<!-- Image container -->
<div class="w-full h-full flex items-center justify-center">
<img id="viewerImage" src="" alt="Generated image" class="max-w-full max-h-full object-contain rounded-lg cursor-pointer transition-all duration-300" onclick="window.admin.toggleImageSize()">
</div>
<!-- Image counter -->
<div id="imageCounter" class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-white text-sm bg-black/50 px-3 py-1 rounded-full z-10">
1 / 1
</div>
</div>
<!-- Image container -->
<div class="w-full h-full flex items-center justify-center p-4">
<img id="viewerImage" src="" alt="Generated image" class="rounded-lg cursor-pointer transition-all duration-300" onclick="window.admin.toggleImageSize()">
</div>
<!-- Image counter -->
<div id="imageCounter" class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-white text-sm bg-black/50 px-3 py-1 rounded-full z-10">
1 / 1
<!-- Metadata Panel -->
<div id="metadataPanel" 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">Metadata</h1>
</div>
<button class="text-xs text-blue-400 hover:text-blue-300 transition-colors" onclick="window.admin.copyAllMetadata()">
📋 COPY ALL
</button>
</div>
<!-- Scrollable Content -->
<div id="metadataContent" class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Metadata will be loaded here -->
<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 Metadata</p>
<p class="text-sm">Loading image metadata...</p>
</div>
</div>
</div>
</div>
</div>
@@ -847,6 +889,7 @@
document.getElementById("restoreBtn").addEventListener("click", () => this.showRestoreModal());
document.getElementById("scanBtn").addEventListener("click", () => this.showScanModal());
document.getElementById("logsBtn").addEventListener("click", () => this.showLogsModal());
document.getElementById("metadataBtn").addEventListener("click", () => this.openMetadataViewer());
// Pagination controls
document.getElementById("limitSelector").addEventListener("change", (e) => this.changeLimit(parseInt(e.target.value)));
@@ -1702,6 +1745,8 @@
this.naturalImageSize.width = img.naturalWidth;
this.naturalImageSize.height = img.naturalHeight;
this.applyImageSizing();
// Extract metadata after image loads
this.extractImageMetadata();
};
img.src = imageUrl;
@@ -1724,6 +1769,8 @@
this.naturalImageSize.width = img.naturalWidth;
this.naturalImageSize.height = img.naturalHeight;
this.applyImageSizing();
// Extract metadata after image loads
this.extractImageMetadata();
};
img.src = nextImageUrl;
@@ -1743,6 +1790,8 @@
this.naturalImageSize.width = img.naturalWidth;
this.naturalImageSize.height = img.naturalHeight;
this.applyImageSizing();
// Extract metadata after image loads
this.extractImageMetadata();
};
img.src = prevImageUrl;
@@ -2759,6 +2808,403 @@
this.pagination.currentPage = 1; // Reset to first page
await this.loadRecentPrompts(1);
}
// Metadata functionality
openMetadataViewer() {
window.open('/prompt_manager/gallery.html', '_blank');
}
toggleMetadataPanel() {
const panel = document.getElementById('metadataPanel');
panel.classList.toggle('hidden');
}
async extractImageMetadata() {
const viewerImage = document.getElementById('viewerImage');
const imageSrc = viewerImage.src;
if (!imageSrc) return;
try {
// Fetch the image as an array buffer
const response = await fetch(imageSrc);
const arrayBuffer = await response.arrayBuffer();
console.log('Image buffer size:', arrayBuffer.byteLength);
// Parse PNG metadata
const metadata = await this.parsePNGMetadata(arrayBuffer);
console.log('Extracted metadata:', metadata);
const comfyData = this.extractComfyUIData(metadata);
console.log('ComfyUI data:', comfyData);
// Update metadata panel
this.updateMetadataPanel(comfyData, imageSrc);
} catch (error) {
console.error('Error extracting metadata:', error);
this.showMetadataError();
}
}
async parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
let chunkCount = 0;
console.log('Starting PNG metadata parsing...');
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
chunkCount++;
console.log(`Chunk ${chunkCount}: type=${type}, length=${length}`);
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');
console.log(`iTXt parts count: ${parts.length}, first part: ${parts[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);
console.log(`Found metadata: ${key} = ${value.substring(0, 100)}...`);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
console.log(`Parsed ${chunkCount} chunks, found ${Object.keys(metadata).length} metadata items`);
return metadata;
}
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);
console.log(`Successfully parsed workflow field: ${field}`);
break;
} catch (e) {
console.log('Failed to parse workflow field:', field, e.message);
}
}
}
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);
console.log(`Successfully parsed prompt field: ${field}`);
break;
} catch (e) {
console.log('Failed to parse prompt field:', field, e.message);
console.log('Raw data:', metadata[field].substring(0, 200) + '...');
}
}
}
return { workflow: workflowData, prompt: promptData };
}
updateMetadataPanel(comfyData, imageSrc) {
const metadataContent = document.getElementById('metadataContent');
if (!metadataContent) return;
// Get the actual file path from current image data
let filePath = imageSrc; // fallback to URL
if (this.currentGalleryImages && this.currentImageIndex !== null && this.currentGalleryImages[this.currentImageIndex]) {
const currentImage = this.currentGalleryImages[this.currentImageIndex];
filePath = currentImage.image_path || currentImage.filename || imageSrc;
}
// 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...', Object.keys(comfyData.prompt).length, 'nodes');
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;
console.log('Found checkpoint:', 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) {
console.log('Found CLIPTextEncode:', node.inputs.text.substring(0, 50));
// 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;
console.log('Found sampler params:', {seed, steps, cfgScale, sampler});
}
}
} else {
console.log('No prompt data found, will try workflow fallback');
}
// Store the current metadata for copying
this.currentMetadata = {
positivePrompt,
negativePrompt,
checkpoint,
steps,
cfgScale,
sampler,
seed,
workflow: comfyData.workflow,
prompt: comfyData.prompt
};
// Update the HTML
metadataContent.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="window.admin.copyToClipboard('${filePath}')">
${filePath}
</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="window.admin.copyPrompt('positive')">
<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="window.admin.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="window.admin.copyPrompt('negative')">
<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="window.admin.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="window.admin.showWorkflowData()">View Raw Workflow JSON</button>
<button class="text-blue-400 hover:text-blue-300" onclick="window.admin.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>
`;
}
showMetadataError() {
const metadataContent = document.getElementById('metadataContent');
if (!metadataContent) return;
metadataContent.innerHTML = `
<div class="text-center text-red-400 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-lg mb-2">Error Loading Metadata</p>
<p class="text-sm">Could not extract ComfyUI metadata from this image</p>
</div>
`;
}
async copyPrompt(type) {
if (!this.currentMetadata) return;
const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
await this.copyToClipboard(text);
}
async copyAllMetadata() {
if (!this.currentMetadata) return;
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
Positive Prompt: ${this.currentMetadata.positivePrompt}
Negative Prompt: ${this.currentMetadata.negativePrompt}
Steps: ${this.currentMetadata.steps || 'Unknown'}
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
await this.copyToClipboard(allData);
}
showFullPrompt(type) {
if (!this.currentMetadata) return;
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.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>
`);
}
showWorkflowData() {
if (!this.currentMetadata || !this.currentMetadata.workflow) return;
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(this.currentMetadata.workflow, null, 2)}</pre>
</body>
</html>
`);
}
downloadWorkflowJSON() {
if (!this.currentMetadata || !this.currentMetadata.workflow) return;
const dataStr = JSON.stringify(this.currentMetadata.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 copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showNotification('Copied to clipboard!', 'success');
} catch (err) {
console.error('Copy failed:', err);
this.showNotification('Copy failed', 'error');
}
}
}
// Initialize the admin interface