class GalleryManager { constructor() { this.images = []; this.currentPage = 1; this.limit = 100; this.total = 0; this.viewMode = 'grid'; // 'grid' or 'list' this.viewer = null; this.initializeEventListeners(); this.loadImages(); // Check thumbnails at startup if enabled this.checkThumbnailsAtStartup(); } initializeEventListeners() { document.getElementById('refreshBtn').addEventListener('click', () => this.loadImages()); document.getElementById('limitSelector').addEventListener('change', (e) => this.changeLimit(parseInt(e.target.value))); document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid')); document.getElementById('listViewBtn').addEventListener('click', () => this.setViewMode('list')); document.getElementById('settingsBtn').addEventListener('click', () => this.showSettings()); document.getElementById('autoTagBtn').addEventListener('click', () => this.showAutoTagModal()); document.getElementById('addPromptBtn').addEventListener('click', () => this.showAddPromptModal()); document.getElementById('retryBtn').addEventListener('click', () => this.loadImages()); document.getElementById('prevPageBtn').addEventListener('click', () => this.previousPage()); document.getElementById('nextPageBtn').addEventListener('click', () => this.nextPage()); document.getElementById('pageInput').addEventListener('change', (e) => this.goToPage(parseInt(e.target.value))); document.getElementById('pageInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.goToPage(parseInt(e.target.value)); } }); // Settings modal event listeners document.getElementById('closeSettingsBtn').addEventListener('click', () => this.hideSettings()); document.getElementById('saveSettingsBtn').addEventListener('click', () => this.saveSettings()); document.getElementById('resetSettingsBtn').addEventListener('click', () => this.resetSettings()); document.getElementById('generateThumbnailsBtn').addEventListener('click', () => this.generateThumbnails()); document.getElementById('clearCacheBtn').addEventListener('click', () => this.clearCache()); document.getElementById('rescanFolderBtn').addEventListener('click', () => this.rescanFolder()); document.getElementById('scanDuplicatesBtn').addEventListener('click', () => this.scanDuplicates()); // Duplicates modal event listeners document.getElementById('closeDuplicatesBtn').addEventListener('click', () => this.hideDuplicatesModal()); document.getElementById('selectAllBtn').addEventListener('click', () => this.selectAllDuplicates()); document.getElementById('removeDuplicatesBtn').addEventListener('click', () => this.removeDuplicates()); // Close modal on outside click document.getElementById('settingsModal').addEventListener('click', (e) => { if (e.target.id === 'settingsModal') { this.hideSettings(); } }); document.getElementById('duplicatesModal').addEventListener('click', (e) => { if (e.target.id === 'duplicatesModal') { this.hideDuplicatesModal(); } }); // Auto Tag modal event listeners document.getElementById('startAutoTagBtn').addEventListener('click', () => this.startAutoTag()); document.getElementById('startReviewBtn').addEventListener('click', () => this.startReview()); document.getElementById('skipReviewBtn').addEventListener('click', () => this.skipReviewImage()); document.getElementById('applyReviewBtn').addEventListener('click', () => this.applyReviewTags()); document.getElementById('cancelAutoTagBtn').addEventListener('click', () => this.cancelAutoTag()); document.getElementById('cancelDownloadBtn').addEventListener('click', () => this.cancelDownload()); document.getElementById('unloadModelBtn').addEventListener('click', () => this.unloadModel()); // Add Prompt modal event listeners document.getElementById('saveNewPromptBtn').addEventListener('click', () => this.saveNewPrompt()); document.getElementById('addPromptTagInput').addEventListener('input', (e) => this.handleTagInput(e)); document.getElementById('addPromptTagInput').addEventListener('keydown', (e) => this.handleTagKeydown(e)); document.getElementById('clearRatingBtn').addEventListener('click', () => this.clearRating()); // Rating stars click handlers document.querySelectorAll('#addPromptRating .rating-star').forEach(star => { star.addEventListener('click', (e) => this.setRating(parseInt(e.target.dataset.rating))); }); // Close AutoTag modals on outside click ['autoTagModal', 'autoTagReviewModal', 'autoTagProgressModal', 'autoTagDownloadModal', 'addPromptModal'].forEach(modalId => { document.getElementById(modalId).addEventListener('click', (e) => { if (e.target.id === modalId) { this.hideModal(modalId); } }); }); } async loadImages() { this.showLoading(); try { const offset = (this.currentPage - 1) * this.limit; const response = await fetch(`/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.success) { this.images = data.images; this.total = data.total; this.updateStats(); this.renderGallery(); this.updatePagination(); document.getElementById('loadingStatus').textContent = 'Loaded'; } else { throw new Error(data.error || 'Failed to load images'); } } catch (error) { console.error('Error loading images:', error); this.showError(error.message); } } showLoading() { document.getElementById('loadingState').classList.remove('hidden'); document.getElementById('errorState').classList.add('hidden'); document.getElementById('galleryGrid').classList.add('hidden'); document.getElementById('galleryList').classList.add('hidden'); document.getElementById('paginationControls').classList.add('hidden'); document.getElementById('loadingStatus').textContent = 'Loading...'; } showError(message) { document.getElementById('loadingState').classList.add('hidden'); document.getElementById('errorState').classList.remove('hidden'); document.getElementById('galleryGrid').classList.add('hidden'); document.getElementById('galleryList').classList.add('hidden'); document.getElementById('paginationControls').classList.add('hidden'); document.getElementById('errorMessage').textContent = message; document.getElementById('loadingStatus').textContent = 'Error'; } updateStats() { const start = (this.currentPage - 1) * this.limit + 1; const end = Math.min(this.currentPage * this.limit, this.total); document.getElementById('showingStart').textContent = this.images.length > 0 ? start : 0; document.getElementById('showingEnd').textContent = end; document.getElementById('totalImages').textContent = this.total; } setViewMode(mode) { this.viewMode = mode; // Update button states const gridBtn = document.getElementById('gridViewBtn'); const listBtn = document.getElementById('listViewBtn'); if (mode === 'grid') { gridBtn.classList.remove('bg-gray-600'); gridBtn.classList.add('bg-violet-600'); listBtn.classList.remove('bg-violet-600'); listBtn.classList.add('bg-gray-600'); } else { listBtn.classList.remove('bg-gray-600'); listBtn.classList.add('bg-violet-600'); gridBtn.classList.remove('bg-violet-600'); gridBtn.classList.add('bg-gray-600'); } this.renderGallery(); } renderGallery() { if (this.images.length === 0) { this.showError('No images found in the output folder'); return; } document.getElementById('loadingState').classList.add('hidden'); document.getElementById('errorState').classList.add('hidden'); if (this.viewMode === 'grid') { this.renderGridView(); } else { this.renderListView(); } // Initialize ViewerJS after rendering setTimeout(() => this.initializeViewer(), 100); // Add simple fallback click handlers this.addFallbackClickHandlers(); } renderGridView() { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); list.classList.add('hidden'); grid.classList.remove('hidden'); grid.innerHTML = this.images.map((image, index) => { // Use thumbnail for display if available, fallback to original const displayUrl = image.thumbnail_url || image.url; const hasThumb = !!image.thumbnail_url; const isVideo = image.is_video || false; const mediaType = image.media_type || 'image'; return `
Loading metadata...
Videos don't contain the same metadata as PNG images. ComfyUI workflow data is only embedded in PNG outputs.
${error.message}
${error.message}
No metadata found in this image
Loading metadata from original image...
${JSON.stringify(this.currentMetadata.workflow, null, 2)}
`);
}
}
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');
}
}
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'}`;
this.copyToClipboard(allData);
}
downloadWorkflowJSON() {
if (this.currentMetadata && this.currentMetadata.workflow) {
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);
this.showNotification('Workflow downloaded!', 'success');
}
}
formatPromptText(text) {
if (!text || text === 'No prompt found' || text === 'No negative prompt found') {
return `${text || 'No prompt found'}`;
}
return this.escapeHtml(text);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
unescapeHtml(text) {
const div = document.createElement('div');
div.innerHTML = text;
return div.textContent;
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-6 py-4 rounded-lg shadow-lg z-[20000] transition-all duration-300 transform translate-x-full`;
const colors = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
warning: "bg-yellow-600 text-white",
info: "bg-blue-600 text-white"
};
notification.className += ` ${colors[type] || colors.info}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.classList.remove("translate-x-full"), 100);
setTimeout(() => {
notification.classList.add("translate-x-full");
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}
formatImageCaption(image) {
const date = new Date(image.modified_time * 1000);
return `${image.filename} | ${this.formatFileSize(image.size)} | ${date.toLocaleString()}`;
}
async extractImageMetadata(imageUrl) {
try {
// Fetch the image as an array buffer
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
// Parse PNG metadata
const metadata = await this.parsePNGMetadata(arrayBuffer);
const comfyData = this.extractComfyUIData(metadata);
return this.parseWorkflowData(comfyData, imageUrl);
} catch (error) {
console.error('Error extracting metadata:', error);
return null;
}
}
async 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;
}
extractComfyUIData(metadata) {
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);
}
}
}
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);
}
}
}
return { workflow: workflowData, prompt: promptData };
}
parseWorkflowData(comfyData, imagePath) {
// 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) {
const promptNodes = comfyData.prompt;
// Track all text nodes and try to identify positive/negative
const textNodes = [];
for (const nodeId in promptNodes) {
const node = promptNodes[nodeId];
// Checkpoint - check multiple types
if ((node.class_type === 'CheckpointLoaderSimple' ||
node.class_type === 'UNETLoader' ||
node.class_type === 'DualCLIPLoader') && node.inputs) {
checkpoint = node.inputs.ckpt_name || node.inputs.unet_name || checkpoint;
}
// Collect all text nodes for analysis
if (node.inputs && node.inputs.text) {
textNodes.push({
nodeId: nodeId,
classType: node.class_type,
text: node.inputs.text,
inputs: node.inputs
});
}
// Special handling for PromptManager nodes
if (node.class_type === 'PromptManager' && node.inputs) {
// PromptManager always contains the positive prompt
if (node.inputs.text) {
positivePrompt = node.inputs.text;
}
// Also check for loaded prompt
if (node.inputs.selected_prompt) {
positivePrompt = node.inputs.selected_prompt;
}
}
// PromptManagerText node
if (node.class_type === 'PromptManagerText' && node.inputs) {
if (node.inputs.text) {
positivePrompt = node.inputs.text;
}
if (node.inputs.selected_prompt) {
positivePrompt = node.inputs.selected_prompt;
}
}
// Sampling parameters - check multiple sampler types
if ((node.class_type === 'KSampler' ||
node.class_type === 'KSamplerAdvanced' ||
node.class_type === 'SamplerCustom' ||
node.class_type === 'SamplerCustomAdvanced') && node.inputs) {
steps = node.inputs.steps || steps;
cfgScale = node.inputs.cfg || cfgScale;
sampler = node.inputs.sampler_name || sampler;
seed = node.inputs.seed || node.inputs.noise_seed || seed;
}
// New ComfyUI node types for modern workflows
if (node.class_type === 'CFGGuider' && node.inputs && node.inputs.cfg) {
cfgScale = node.inputs.cfg;
}
if (node.class_type === 'BasicScheduler' && node.inputs && node.inputs.steps) {
steps = node.inputs.steps;
}
if (node.class_type === 'KSamplerSelect' && node.inputs && node.inputs.sampler_name) {
sampler = node.inputs.sampler_name;
}
if ((node.class_type === 'RandomNoise' || node.class_type === 'SeedHistory') && node.inputs) {
if (node.inputs.noise_seed) {
// RandomNoise nodes might reference other nodes, resolve if needed
seed = Array.isArray(node.inputs.noise_seed) ? node.inputs.noise_seed[0] : node.inputs.noise_seed;
} else if (node.inputs.seed) {
seed = node.inputs.seed;
}
}
}
// Process collected text nodes to identify positive/negative prompts
// if we haven't found them from PromptManager
for (const textNode of textNodes) {
const text = textNode.text;
// Ensure text is a string before calling toLowerCase
if (!text || typeof text !== 'string') {
continue;
}
const textLower = text.toLowerCase();
// Skip if this is the PromptManager text we already have
if (textNode.classType === 'PromptManager' || textNode.classType === 'PromptManagerText') {
continue;
}
// Identify negative prompts by common patterns
if (textNode.classType === 'CLIPTextEncode') {
if (textLower.includes('bad anatomy') || textLower.includes('unfinished') ||
textLower.includes('censored') || textLower.includes('weird anatomy') ||
textLower.includes('negative') || textLower.includes('embedding:') ||
textLower.includes('worst quality') || textLower.includes('low quality')) {
negativePrompt = text;
} else if (positivePrompt === 'No prompt found') {
// If we haven't found a positive prompt yet, this might be it
positivePrompt = text;
}
}
}
}
// Also try to parse from workflow data if we didn't find everything in prompt
if (comfyData.workflow && comfyData.workflow.nodes) {
for (const node of comfyData.workflow.nodes) {
// Checkpoint loaders
if ((node.type === 'CheckpointLoaderSimple' ||
node.type === 'UNETLoader' ||
node.type === 'DualCLIPLoader') && node.widgets_values) {
checkpoint = node.widgets_values[0] || checkpoint;
}
// PromptManager nodes in workflow
if (node.type === 'PromptManager' && node.widgets_values) {
// The first widget value is usually the text
if (node.widgets_values[0] && positivePrompt === 'No prompt found') {
positivePrompt = node.widgets_values[0];
}
}
if (node.type === 'PromptManagerText' && node.widgets_values) {
if (node.widgets_values[0] && positivePrompt === 'No prompt found') {
positivePrompt = node.widgets_values[0];
}
}
// CLIPTextEncode nodes
if (node.type === 'CLIPTextEncode' && node.widgets_values && node.widgets_values[0]) {
const text = node.widgets_values[0];
// Ensure text is a string before calling toLowerCase
if (text && typeof text === 'string') {
const textLower = text.toLowerCase();
if (textLower.includes('bad anatomy') || textLower.includes('unfinished') ||
textLower.includes('censored') || textLower.includes('negative') ||
textLower.includes('worst quality') || textLower.includes('low quality')) {
negativePrompt = text;
} else if (positivePrompt === 'No prompt found') {
positivePrompt = text;
}
}
}
// Samplers
if ((node.type === 'KSampler' || node.type === 'KSamplerAdvanced' ||
node.type === 'SamplerCustom' || node.type === 'SamplerCustomAdvanced') && node.widgets_values) {
if (node.widgets_values.length >= 4) {
seed = node.widgets_values[0] || seed;
steps = node.widgets_values[1] || steps;
cfgScale = node.widgets_values[2] || cfgScale;
sampler = node.widgets_values[3] || sampler;
}
}
}
}
return {
checkpoint,
positivePrompt,
negativePrompt,
steps,
cfgScale,
sampler,
seed,
workflow: comfyData.workflow,
imagePath
};
}
// Settings functionality
showSettings() {
this.loadSettingsFromStorage();
document.getElementById('settingsModal').classList.remove('hidden');
document.getElementById('settingsModal').classList.add('flex');
document.body.style.overflow = 'hidden';
}
hideSettings() {
document.getElementById('settingsModal').classList.add('hidden');
document.getElementById('settingsModal').classList.remove('flex');
document.body.style.overflow = '';
}
loadSettingsFromStorage() {
const settings = this.getSettings();
// Load checkbox settings
document.getElementById('lazyLoadingToggle').checked = settings.lazyLoading;
document.getElementById('autoLoadMetadataToggle').checked = settings.autoLoadMetadata;
document.getElementById('cacheMetadataToggle').checked = settings.cacheMetadata;
document.getElementById('showFilePathsToggle').checked = settings.showFilePaths;
document.getElementById('showImageInfoToggle').checked = settings.showImageInfo;
document.getElementById('debugModeToggle').checked = settings.debugMode;
document.getElementById('checkThumbnailsAtStartup').checked = settings.checkThumbnailsAtStartup;
// Load video settings
document.getElementById('videoAutoplayToggle').checked = settings.videoAutoplay;
document.getElementById('videoMuteToggle').checked = settings.videoMute;
document.getElementById('videoLoopToggle').checked = settings.videoLoop;
// Load select settings
document.getElementById('imageQualitySelect').value = settings.imageQuality;
document.getElementById('defaultViewModeSelect').value = settings.defaultViewMode;
document.getElementById('defaultLimitSelect').value = settings.defaultLimit;
document.getElementById('gridColumnsSelect').value = settings.gridColumns;
// Load input settings
document.getElementById('apiTimeoutInput').value = settings.apiTimeout;
// Update thumbnail status
document.getElementById('thumbnailStatus').textContent = settings.thumbnailsGenerated ? 'Generated' : 'Not generated';
}
getSettings() {
const defaultSettings = {
lazyLoading: true,
imageQuality: 'medium',
defaultViewMode: 'grid',
defaultLimit: 100,
gridColumns: 8,
showImageInfo: true,
autoLoadMetadata: true,
cacheMetadata: true,
showFilePaths: true,
debugMode: false,
apiTimeout: 30,
thumbnailsGenerated: false,
checkThumbnailsAtStartup: true,
// Video settings
videoAutoplay: false,
videoMute: true,
videoLoop: true
};
try {
const stored = localStorage.getItem('gallerySettings');
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to load settings from localStorage:', e);
}
return defaultSettings;
}
saveSettings() {
const settings = {
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
imageQuality: document.getElementById('imageQualitySelect').value,
defaultViewMode: document.getElementById('defaultViewModeSelect').value,
defaultLimit: parseInt(document.getElementById('defaultLimitSelect').value),
gridColumns: parseInt(document.getElementById('gridColumnsSelect').value),
showImageInfo: document.getElementById('showImageInfoToggle').checked,
autoLoadMetadata: document.getElementById('autoLoadMetadataToggle').checked,
cacheMetadata: document.getElementById('cacheMetadataToggle').checked,
showFilePaths: document.getElementById('showFilePathsToggle').checked,
debugMode: document.getElementById('debugModeToggle').checked,
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
// Video settings
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
videoMute: document.getElementById('videoMuteToggle').checked,
videoLoop: document.getElementById('videoLoopToggle').checked
};
try {
localStorage.setItem('gallerySettings', JSON.stringify(settings));
this.showNotification('Settings saved successfully!', 'success');
this.hideSettings();
// Apply some settings immediately
this.applySettings(settings);
} catch (e) {
console.error('Failed to save settings:', e);
this.showNotification('Failed to save settings', 'error');
}
}
resetSettings() {
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
localStorage.removeItem('gallerySettings');
this.loadSettingsFromStorage();
this.showNotification('Settings reset to defaults', 'success');
}
}
applySettings(settings) {
// Apply grid columns
this.updateGridColumns(settings.gridColumns);
// Apply default view mode if different
if (this.viewMode !== settings.defaultViewMode) {
this.setViewMode(settings.defaultViewMode);
}
// Apply default limit if different
if (this.limit !== settings.defaultLimit) {
this.limit = settings.defaultLimit;
document.getElementById('limitSelector').value = settings.defaultLimit;
}
}
updateGridColumns(columns) {
const gridContainer = document.getElementById('galleryGrid');
if (gridContainer) {
// Remove existing column classes
gridContainer.className = gridContainer.className.replace(/xl:grid-cols-\d+/g, '');
// Add new column class
gridContainer.classList.add(`xl:grid-cols-${columns}`);
}
}
async generateThumbnails() {
const btn = document.getElementById('generateThumbnailsBtn');
const status = document.getElementById('thumbnailStatus');
const progressContainer = document.getElementById('thumbnailProgress');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressPercent = document.getElementById('progressPercent');
const progressDetails = document.getElementById('progressDetails');
const progressETA = document.getElementById('progressETA');
btn.disabled = true;
btn.textContent = 'Generating...';
status.textContent = 'Initializing thumbnail generation...';
progressContainer.classList.remove('hidden');
// Initialize progress display
progressBar.style.width = '0%';
progressPercent.textContent = 'Starting...';
progressText.textContent = 'Connecting to server...';
progressDetails.textContent = 'Preparing to scan output directory...';
progressETA.textContent = '';
try {
// Start thumbnail generation with progress updates
const result = await this.generateThumbnailsWithProgress({
quality: document.getElementById('imageQualitySelect').value
});
// Update settings on success
const settings = this.getSettings();
settings.thumbnailsGenerated = true;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
status.textContent = 'Generation completed';
progressText.textContent = 'Completed!';
progressDetails.textContent = `Generated ${result.count} new, skipped ${result.skipped || 0} existing (${result.total_images} total) in ${result.elapsed_time}s`;
this.showNotification('Thumbnails generated successfully!', 'success');
// Hide progress after a delay
setTimeout(() => {
progressContainer.classList.add('hidden');
}, 3000);
} catch (error) {
console.error('Thumbnail generation error:', error);
console.error('Error details:', error.stack);
status.textContent = 'Generation failed';
progressText.textContent = 'Failed';
progressDetails.textContent = error.message;
progressBar.style.width = '0%';
progressBar.classList.add('bg-red-600');
progressPercent.textContent = 'Error';
this.showNotification('Failed to generate thumbnails: ' + error.message, 'error');
// Hide progress after a delay
setTimeout(() => {
progressContainer.classList.add('hidden');
progressBar.classList.remove('bg-red-600');
}, 5000);
} finally {
btn.disabled = false;
btn.textContent = 'Generate';
}
}
async generateThumbnailsWithProgress(options) {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressPercent = document.getElementById('progressPercent');
const progressDetails = document.getElementById('progressDetails');
const progressETA = document.getElementById('progressETA');
const cancelBtn = document.getElementById('cancelThumbnailsBtn');
return new Promise((resolve, reject) => {
// Show cancel button
cancelBtn.classList.remove('hidden');
console.log(`Starting thumbnail generation with quality: ${options.quality}`);
// Set up Server-Sent Events
const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${options.quality}`);
// Log connection
console.log('EventSource connected for thumbnail generation progress');
let cancelled = false;
let resultData = null;
// Handle cancellation
const cancelHandler = () => {
cancelled = true;
eventSource.close();
cancelBtn.classList.add('hidden');
reject(new Error('Thumbnail generation cancelled by user'));
};
cancelBtn.addEventListener('click', cancelHandler);
// Handle scanning/status events
eventSource.addEventListener('status', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
progressText.textContent = data.message || 'Processing...';
if (data.phase === 'scanning') {
progressDetails.textContent = 'Scanning output folder for images and videos...';
progressBar.style.width = '0%';
progressPercent.textContent = 'Scanning...';
} else {
progressDetails.textContent = 'Preparing thumbnail generation...';
}
// Log status updates
if (this.getSettings().debugMode) {
console.log(`Thumbnail generation status: ${data.phase} - ${data.message}`);
}
});
// Handle start event
eventSource.addEventListener('start', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
// Show detailed file type breakdown if available
const fileBreakdown = (data.image_count !== undefined && data.video_count !== undefined)
? ` (${data.image_count} images, ${data.video_count} videos)`
: '';
progressText.textContent = `Starting to process ${data.total_images} media files${fileBreakdown}...`;
progressDetails.textContent = data.message || `Found ${data.total_images} images and videos to process`;
progressBar.style.width = '0%';
progressPercent.textContent = '0%';
progressETA.textContent = 'Calculating...';
// Log start event
if (this.getSettings().debugMode) {
console.log(`Thumbnail generation started: ${data.total_images} files to process`);
}
});
// Handle progress updates
eventSource.addEventListener('progress', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
// Update progress bar
progressBar.style.width = `${data.percentage}%`;
progressPercent.textContent = `${data.percentage}%`;
// Update text with current file being processed and action
const fileInfo = data.current_file ? ` - ${data.current_file}` : '';
const actionText = data.action === 'skipped' ? ' [Skipping]' : data.action === 'generating' ? ' [Generating]' : '';
progressText.textContent = `Processing ${data.file_type || 'file'}... (${data.processed}/${data.total_images})${fileInfo}${actionText}`;
// More detailed progress information
const elapsedText = data.elapsed ? ` | Time: ${Math.floor(data.elapsed)}s` : '';
progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped} | Rate: ${data.rate} img/s${elapsedText}`;
// Update ETA
if (data.eta > 0 && data.eta < 3600) {
const etaText = data.eta > 60 ? `${Math.floor(data.eta / 60)}m ${Math.floor(data.eta % 60)}s` : `${Math.floor(data.eta)}s`;
progressETA.textContent = `ETA: ${etaText}`;
} else {
progressETA.textContent = '';
}
// Log to console for debugging if debug mode is enabled
if (this.getSettings().debugMode) {
console.log(`Thumbnail generation progress: ${data.processed}/${data.total_images} - ${data.current_file}`);
}
});
// Handle completion
eventSource.addEventListener('complete', (event) => {
if (cancelled) return;
resultData = JSON.parse(event.data);
// Update final progress
progressBar.style.width = '100%';
progressPercent.textContent = '100%';
progressText.textContent = 'Completed!';
// Show detailed completion message
const errorCount = resultData.error_count || (resultData.errors ? resultData.errors.length : 0);
const errors = errorCount > 0 ? ` (${errorCount} errors)` : '';
const rateText = resultData.processing_rate ? ` at ${resultData.processing_rate} img/s` : '';
progressDetails.textContent = `Generated: ${resultData.count} new, Skipped: ${resultData.skipped} existing | Total: ${resultData.total_images} files | Time: ${resultData.elapsed_time}s${rateText}${errors}`;
// Show first error if any occurred
if (resultData.errors && resultData.errors.length > 0 && this.getSettings().debugMode) {
console.error('First thumbnail error:', resultData.errors[0]);
}
progressETA.textContent = 'Done';
// Log completion details
if (this.getSettings().debugMode) {
console.log('Thumbnail generation completed:', resultData);
if (resultData.errors && resultData.errors.length > 0) {
console.warn('Thumbnail generation errors:', resultData.errors);
}
}
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
resolve(resultData);
});
// Handle errors
eventSource.addEventListener('error', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
reject(new Error(data.error || 'Thumbnail generation failed'));
});
// Handle EventSource errors
eventSource.onerror = (error) => {
if (cancelled) return;
console.error('EventSource error:', error);
console.error('EventSource readyState:', eventSource.readyState);
// Check if connection is closing normally
if (eventSource.readyState === EventSource.CLOSED) {
console.log('EventSource connection closed');
} else {
console.error('EventSource connection failed');
}
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
// Only reject if we don't have result data
if (!resultData) {
reject(new Error('Connection to server lost during thumbnail generation'));
}
};
});
}
async clearCache() {
if (confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) {
try {
// Clear only PromptManager-related browser caches (never touch user images)
if ('caches' in window) {
const cacheNames = await caches.keys();
// Only clear caches that are specifically ours
const ourCaches = cacheNames.filter(name =>
name.includes('prompt-manager') ||
name.includes('gallery') ||
name.includes('thumbnail')
);
await Promise.all(ourCaches.map(name => caches.delete(name)));
}
// Clear only our localStorage entries (never touch user data)
Object.keys(localStorage).forEach(key => {
if (key.startsWith('gallery_cache_') ||
key.startsWith('metadata_cache_') ||
key.startsWith('promptManager_') ||
key.startsWith('pm_')) {
localStorage.removeItem(key);
}
});
// Call backend to clear server-side thumbnails (only our generated thumbnails)
try {
const response = await fetch('/prompt_manager/images/clear-thumbnails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const data = await response.json();
console.log('Server thumbnails cleared:', data);
}
} catch (e) {
console.warn('Could not clear server thumbnails:', e);
// Not a critical error - continue with local cleanup
}
// Update thumbnail status
const settings = this.getSettings();
settings.thumbnailsGenerated = false;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
document.getElementById('thumbnailStatus').textContent = 'Not generated';
this.showNotification('PromptManager cache cleared successfully! Original images untouched.', 'success');
} catch (error) {
console.error('Clear cache error:', error);
this.showNotification('Failed to clear cache', 'error');
}
}
}
async rescanFolder() {
const btn = document.getElementById('rescanFolderBtn');
btn.disabled = true;
btn.textContent = 'Scanning...';
try {
// Force reload images
await this.loadImages();
this.showNotification('Folder rescanned successfully!', 'success');
} catch (error) {
console.error('Rescan error:', error);
this.showNotification('Failed to rescan folder', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Rescan';
}
}
async scanDuplicates() {
this.showDuplicatesModal();
const btn = document.getElementById('scanDuplicatesBtn');
btn.disabled = true;
btn.textContent = 'Scanning...';
try {
const response = await fetch('/prompt_manager/scan_duplicates');
const data = await response.json();
if (data.success) {
this.displayDuplicates(data.duplicates || []);
this.showNotification(`Found ${data.duplicates?.length || 0} duplicate image groups`, 'info');
} else {
throw new Error(data.error || 'Failed to scan for duplicate images');
}
} catch (error) {
console.error('Duplicate scan error:', error);
this.showNotification('Failed to scan for duplicate images', 'error');
this.hideDuplicatesModal();
} finally {
btn.disabled = false;
btn.textContent = 'Scan';
}
}
showDuplicatesModal() {
document.getElementById('duplicatesModal').classList.remove('hidden');
document.getElementById('duplicatesModal').classList.add('flex');
document.body.style.overflow = 'hidden';
// Reset modal state
document.getElementById('duplicatesScanStatus').classList.remove('hidden');
document.getElementById('duplicatesContent').classList.add('hidden');
document.getElementById('duplicatesFooter').classList.add('hidden');
}
hideDuplicatesModal() {
document.getElementById('duplicatesModal').classList.add('hidden');
document.getElementById('duplicatesModal').classList.remove('flex');
document.body.style.overflow = '';
}
displayDuplicates(duplicates) {
const scanStatus = document.getElementById('duplicatesScanStatus');
const content = document.getElementById('duplicatesContent');
const footer = document.getElementById('duplicatesFooter');
const list = document.getElementById('duplicatesList');
const count = document.getElementById('duplicatesCount');
scanStatus.classList.add('hidden');
if (duplicates.length === 0) {
list.innerHTML = 'Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
đĄ Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
Processing ${missingCount} images out of ${imageCount} total...