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 `
${this.escapeHtml(image.filename)}
${isVideo ? `
` : ` `}
${hasThumb ? '
' : ''} ${isVideo ? '
VIDEO
' : ''}
${this.escapeHtml(image.filename)}
${this.formatFileSize(image.size)}${hasThumb ? ' â€ĸ Fast' : ''}${isVideo ? ' â€ĸ Video' : ''}
`; }).join(''); } renderListView() { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); grid.classList.add('hidden'); list.classList.remove('hidden'); list.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 `
${this.escapeHtml(image.filename)} ${hasThumb ? '
' : ''} ${isVideo ? '
â–ļ
' : ''}
${this.escapeHtml(image.filename)}${isVideo ? ' đŸŽŦ' : ''}
${this.formatFileSize(image.size)} â€ĸ ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' â€ĸ Fast' : ''}${isVideo ? ' â€ĸ Video' : ''}
${image.relative_path}
${isVideo ? ` ` : ` `}
`; }).join(''); } initializeViewer() { // Destroy existing viewer if it exists if (this.viewer) { this.viewer.destroy(); } const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList'); // Find all images in the container const images = container.querySelectorAll('img[data-original]'); if (images.length === 0) { console.warn('No images found for viewer initialization'); return; } this.viewer = new Viewer(container, { inline: false, button: true, navbar: true, title: true, // IMPORTANT: Use original images for viewing, not thumbnails url: 'data-original', // Tell ViewerJS to use data-original attribute toolbar: { zoomIn: 1, zoomOut: 1, oneToOne: 1, reset: 1, prev: 1, play: { show: 1, size: 'large', }, next: 1, rotateLeft: 1, rotateRight: 1, flipHorizontal: 1, flipVertical: 1, }, className: 'viewer-dark-theme', title: [1, (image, imageData) => `${imageData.alt} (${this.images.length} images) - Original Image`], viewed: (event) => { console.log('ViewerJS opened original image for metadata'); // Add metadata sidebar when ViewerJS opens // Use the original image URL from data-original attribute const originalImg = event.detail.originalImage; setTimeout(() => this.addMetadataSidebar(originalImg), 100); }, show: function() { console.log('Viewer shown - displaying original image'); }, shown: function() { console.log('ViewerJS initialization complete - ready for metadata'); }, hide: function() { // Remove sidebar when viewer closes const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) { sidebar.remove(); } } }); // Don't add click handlers here - let ViewerJS handle them } addMetadataSidebar(originalImage) { const viewerContainer = document.querySelector('.viewer-container'); if (!viewerContainer || document.getElementById('metadata-sidebar')) return; const sidebar = document.createElement('div'); sidebar.id = 'metadata-sidebar'; sidebar.className = 'metadata-sidebar'; sidebar.innerHTML = `

Generation data

Loading metadata...

`; document.body.appendChild(sidebar); // Load metadata for the current image (ALWAYS use original for metadata) const originalImageUrl = originalImage.dataset.original || originalImage.src; console.log('Loading metadata from original image:', originalImageUrl); this.loadImageMetadata(originalImageUrl, sidebar.querySelector('#metadata-content')); // Listen for ViewerJS view changes to update metadata this.setupViewerMetadataUpdates(sidebar); } setupViewerMetadataUpdates(sidebar) { // Listen for ViewerJS navigation events const viewerContainer = document.querySelector('.viewer-container'); if (!viewerContainer) return; // Override ViewerJS navigation to update metadata const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'src') { const img = mutation.target; if (img.tagName === 'IMG' && img.closest('.viewer-canvas')) { // Find the corresponding original image const imageUrl = img.src; this.loadImageMetadata(imageUrl, sidebar.querySelector('#metadata-content')); } } }); }); const canvas = viewerContainer.querySelector('.viewer-canvas'); if (canvas) { observer.observe(canvas, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); } } addFallbackClickHandlers() { // Add simple click handlers that work even if ViewerJS fails const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList'); const imageItems = container.querySelectorAll('.image-item'); imageItems.forEach((item, index) => { item.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const img = item.querySelector('img[data-original]'); if (img) { // ALWAYS use the original media URL for viewing const originalMediaUrl = img.dataset.original; const caption = img.dataset.caption; const isVideo = img.dataset.isVideo === 'true'; const mediaType = img.dataset.mediaType || 'image'; console.log('Opening original media for viewing:', originalMediaUrl, 'Type:', mediaType); if (isVideo) { // For videos, use our custom video modal this.showVideoModal(originalMediaUrl, caption, index); } else { // For images, try ViewerJS first, fallback to simple modal if (this.viewer) { try { this.viewer.view(index); } catch (e) { console.warn('ViewerJS failed, using fallback:', e); this.showSimpleImageModal(originalMediaUrl, caption); } } else { this.showSimpleImageModal(originalMediaUrl, caption); } } } }); }); } async showSimpleImageModal(imageUrl, caption) { // Create a modal that matches the screenshot design const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black z-50 flex'; modal.innerHTML = `
${caption}

Generation data

Loading metadata...

`; // Close on click outside image area modal.addEventListener('click', (e) => { if (e.target === modal || e.target.id === 'modalImage') { modal.remove(); } }); // Close on Escape key const escapeHandler = (e) => { if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('keydown', escapeHandler); document.body.appendChild(modal); // Add navigation functionality this.setupImageNavigation(modal, imageUrl); // Load and display metadata await this.loadImageMetadata(imageUrl, modal.querySelector('#metadata-content')); } async showVideoModal(videoUrl, caption, videoIndex) { const settings = this.getSettings(); // Create a modal for video viewing const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black z-50 flex'; modal.innerHTML = `

Video Info

`; // Close on click outside video area modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); // Close on Escape key const escapeHandler = (e) => { if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('keydown', escapeHandler); document.body.appendChild(modal); // Set up video controls this.setupVideoControls(modal, settings); // Add navigation functionality this.setupVideoNavigation(modal, videoUrl, videoIndex); // Load and display video info await this.loadVideoInfo(videoUrl, modal.querySelector('#video-info-content')); } setupVideoControls(modal, settings) { const video = modal.querySelector('#modalVideo'); const muteBtn = modal.querySelector('#videoMuteBtn'); const loopBtn = modal.querySelector('#videoLoopBtn'); // Mute/unmute toggle muteBtn.addEventListener('click', () => { video.muted = !video.muted; muteBtn.textContent = video.muted ? '🔇' : '🔊'; // Save setting const newSettings = this.getSettings(); newSettings.videoMute = video.muted; this.saveSettings(newSettings); }); // Loop toggle loopBtn.addEventListener('click', () => { video.loop = !video.loop; loopBtn.style.opacity = video.loop ? '1' : '0.5'; // Save setting const newSettings = this.getSettings(); newSettings.videoLoop = video.loop; this.saveSettings(newSettings); }); // Set initial states muteBtn.textContent = video.muted ? '🔇' : '🔊'; loopBtn.style.opacity = video.loop ? '1' : '0.5'; } setupVideoNavigation(modal, currentVideoUrl, currentIndex) { const prevBtn = modal.querySelector('#prevVideoBtn'); const nextBtn = modal.querySelector('#nextVideoBtn'); // Get all video items from current gallery const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList'); const videoItems = Array.from(container.querySelectorAll('.image-item img[data-is-video="true"]')); if (videoItems.length <= 1) { prevBtn.style.display = 'none'; nextBtn.style.display = 'none'; return; } const currentVideoIndex = videoItems.findIndex(img => img.dataset.original === currentVideoUrl); prevBtn.addEventListener('click', () => { const prevIndex = currentVideoIndex > 0 ? currentVideoIndex - 1 : videoItems.length - 1; const prevVideo = videoItems[prevIndex]; if (prevVideo) { modal.remove(); this.showVideoModal(prevVideo.dataset.original, prevVideo.dataset.caption, prevIndex); } }); nextBtn.addEventListener('click', () => { const nextIndex = currentVideoIndex < videoItems.length - 1 ? currentVideoIndex + 1 : 0; const nextVideo = videoItems[nextIndex]; if (nextVideo) { modal.remove(); this.showVideoModal(nextVideo.dataset.original, nextVideo.dataset.caption, nextIndex); } }); // Add keyboard navigation const keyHandler = (e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); prevBtn.click(); } else if (e.key === 'ArrowRight') { e.preventDefault(); nextBtn.click(); } }; document.addEventListener('keydown', keyHandler); // Clean up listener when modal is removed const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { mutation.removedNodes.forEach((node) => { if (node === modal) { document.removeEventListener('keydown', keyHandler); observer.disconnect(); } }); } }); }); observer.observe(document.body, { childList: true }); } async loadVideoInfo(videoUrl, container) { try { // Extract basic video information const fileName = videoUrl.split('/').pop(); const fileExt = fileName.split('.').pop().toUpperCase(); container.innerHTML = `

File Info

${fileName}
Type: ${fileExt} Video

Video Properties

Loading video information...

Playback Settings

Autoplay: ${this.getSettings().videoAutoplay ? 'Enabled' : 'Disabled'}
Muted: ${this.getSettings().videoMute ? 'Yes' : 'No'}
Loop: ${this.getSettings().videoLoop ? 'Enabled' : 'Disabled'}

â„šī¸ Video Note

Videos don't contain the same metadata as PNG images. ComfyUI workflow data is only embedded in PNG outputs.

`; // Try to get video metadata from the video element const video = document.querySelector('#modalVideo'); if (video) { video.addEventListener('loadedmetadata', () => { const propertiesDiv = container.querySelector('#video-properties'); if (propertiesDiv) { const duration = video.duration; const width = video.videoWidth; const height = video.videoHeight; propertiesDiv.innerHTML = `

Video Properties

Duration: ${this.formatDuration(duration)}
Resolution: ${width} × ${height}
Aspect Ratio: ${(width/height).toFixed(2)}:1
`; } }); } } catch (error) { console.error('Error loading video info:', error); container.innerHTML = `

❌ Video Info Error

${error.message}

`; } } formatDuration(seconds) { if (isNaN(seconds) || seconds === 0) return '0:00'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } else { return `${minutes}:${secs.toString().padStart(2, '0')}`; } } async loadImageMetadata(imageUrl, container) { try { // First try to get linked prompt from database let databasePrompt = null; try { // Extract relative path from the URL for the API call const urlParts = imageUrl.split('/prompt_manager/images/serve/'); if (urlParts.length > 1) { const relativePath = urlParts[1]; const response = await fetch(`/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}`); if (response.ok) { const data = await response.json(); if (data.success && data.prompt) { databasePrompt = data.prompt; } } } } catch (dbError) { // No database prompt found, will fall back to metadata extraction } // If we have a database prompt, use it; otherwise extract from metadata let metadata; if (databasePrompt) { // Create metadata object from database prompt metadata = { positivePrompt: databasePrompt.text, negativePrompt: 'No negative prompt found', // Database doesn't separate positive/negative checkpoint: 'Unknown', steps: 'Unknown', cfgScale: 'Unknown', sampler: 'Unknown', seed: 'Unknown', workflow: databasePrompt.workflow_data, imagePath: imageUrl, source: 'database', // Mark this as coming from database promptId: databasePrompt.prompt_id, category: databasePrompt.category, tags: databasePrompt.tags, rating: databasePrompt.rating, notes: databasePrompt.notes }; // Still try to extract technical parameters from PNG metadata if available try { const pngMetadata = await this.extractImageMetadata(imageUrl); if (pngMetadata) { metadata.checkpoint = pngMetadata.checkpoint; metadata.steps = pngMetadata.steps; metadata.cfgScale = pngMetadata.cfgScale; metadata.sampler = pngMetadata.sampler; metadata.seed = pngMetadata.seed; // Use negative prompt from PNG if available if (pngMetadata.negativePrompt && pngMetadata.negativePrompt !== 'No negative prompt found') { metadata.negativePrompt = pngMetadata.negativePrompt; } } } catch (pngError) { // Could not extract PNG metadata, using database-only data } } else { // Fall back to PNG metadata extraction metadata = await this.extractImageMetadata(imageUrl); if (metadata) { metadata.source = 'png'; // Mark this as coming from PNG } } this.displayMetadata(metadata, container); } catch (error) { console.error('Error loading metadata:', error); container.innerHTML = `

❌ Metadata Error

${error.message}

`; } } displayMetadata(metadata, container) { if (!metadata) { container.innerHTML = `

No metadata found in this image

`; return; } const html = `

File Path

${metadata.imagePath || 'Unknown'}

Resources used

Model: ${metadata.checkpoint || 'Unknown'}

Prompt

${this.formatPromptText(metadata.positivePrompt)}

Negative prompt

${this.formatPromptText(metadata.negativePrompt)}

Other metadata

CFG SCALE: ${metadata.cfgScale || 'Unknown'}
STEPS: ${metadata.steps || 'Unknown'}
SAMPLER: ${metadata.sampler || 'Unknown'}
SEED: ${metadata.seed || 'Unknown'}
${metadata.workflow ? `

ComfyUI Workflow

` : ''} `; container.innerHTML = html; // Store metadata for actions this.currentMetadata = metadata; // Add click handlers this.setupMetadataActions(container); } setupImageNavigation(modal, currentImageUrl) { // Find current image index this.currentImageIndex = this.images.findIndex(img => img.url === currentImageUrl); const prevBtn = modal.querySelector('#prevImageBtn'); const nextBtn = modal.querySelector('#nextImageBtn'); const modalImage = modal.querySelector('#modalImage'); const updateImage = async (newIndex) => { if (newIndex >= 0 && newIndex < this.images.length) { this.currentImageIndex = newIndex; const newImage = this.images[newIndex]; // ALWAYS use original URL for viewing and metadata modalImage.src = newImage.url; // This is always the original URL modalImage.alt = this.formatImageCaption(newImage); // Update metadata using original image const metadataContainer = modal.querySelector('#metadata-content'); metadataContainer.innerHTML = `

Loading metadata from original image...

`; // Load metadata from original image (which has embedded workflow data) await this.loadImageMetadata(newImage.url, metadataContainer); } // Update button states prevBtn.style.opacity = newIndex > 0 ? '1' : '0.5'; nextBtn.style.opacity = newIndex < this.images.length - 1 ? '1' : '0.5'; }; prevBtn.addEventListener('click', () => { if (this.currentImageIndex > 0) { updateImage(this.currentImageIndex - 1); } }); nextBtn.addEventListener('click', () => { if (this.currentImageIndex < this.images.length - 1) { updateImage(this.currentImageIndex + 1); } }); // Keyboard navigation const keyHandler = (e) => { if (e.key === 'ArrowLeft' && this.currentImageIndex > 0) { updateImage(this.currentImageIndex - 1); } else if (e.key === 'ArrowRight' && this.currentImageIndex < this.images.length - 1) { updateImage(this.currentImageIndex + 1); } }; document.addEventListener('keydown', keyHandler); // Clean up on modal close modal.addEventListener('remove', () => { document.removeEventListener('keydown', keyHandler); }); // Initial button state updateImage(this.currentImageIndex); } setupMetadataActions(container) { // Copy prompt buttons const copyButtons = container.querySelectorAll('[data-copy-type]'); copyButtons.forEach(btn => { btn.addEventListener('click', (e) => { const type = btn.getAttribute('data-copy-type'); if (this.currentMetadata) { const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt; this.copyToClipboard(text); } }); }); // File path copy const pathEl = container.querySelector('[data-copy-path]'); if (pathEl) { pathEl.addEventListener('click', () => { if (this.currentMetadata) { this.copyToClipboard(this.currentMetadata.imagePath); } }); } // Show more buttons const showMoreBtns = container.querySelectorAll('[data-show-type]'); showMoreBtns.forEach(btn => { btn.addEventListener('click', (e) => { const type = btn.getAttribute('data-show-type'); this.showFullPrompt(type); }); }); // Workflow action buttons const actionBtns = container.querySelectorAll('[data-action]'); actionBtns.forEach(btn => { btn.addEventListener('click', (e) => { const action = btn.getAttribute('data-action'); if (action === 'show-workflow') { this.showWorkflowData(); } else if (action === 'download-workflow') { this.downloadWorkflowJSON(); } }); }); // Copy all button const copyAllBtn = container.closest('.metadata-sidebar').querySelector('.metadata-copy-all'); if (copyAllBtn) { copyAllBtn.addEventListener('click', () => { this.copyAllMetadata(); }); } } showFullPrompt(type) { if (this.currentMetadata) { const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt; const safeType = this.escapeHtml(type.charAt(0).toUpperCase() + type.slice(1)); const newWindow = window.open('', '_blank'); const doc = newWindow.document; doc.open(); doc.write('' + safeType + ' Prompt'); 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', () => { navigator.clipboard.writeText(pre.textContent).then(() => alert('Copied!')); }); doc.body.appendChild(btn); } } showWorkflowData() { if (this.currentMetadata && this.currentMetadata.workflow) { const newWindow = window.open('', '_blank'); newWindow.document.write(` ComfyUI Workflow Data

ComfyUI Workflow JSON

${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 = '
No duplicate images found! 🎉
'; content.classList.remove('hidden'); return; } count.textContent = duplicates.length; list.innerHTML = ''; duplicates.forEach((group, groupIndex) => { const groupEl = document.createElement('div'); groupEl.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600'; groupEl.innerHTML = `
Duplicate Group ${groupIndex + 1} (${group.images.length} identical ${group.images[0].media_type}s)
Content Hash: ${group.hash.substring(0, 16)}...
${group.images.map((image, imageIndex) => `
${image.media_type === 'video' ? `
${image.thumbnail_url ? ` Video thumbnail ` : ` `}
VIDEO
` : ` ${this.escapeHtml(image.filename)} `}
${this.escapeHtml(image.filename)}
Size: ${this.formatFileSize(image.size)} â€ĸ Modified: ${new Date(image.modified_time * 1000).toLocaleDateString()}
${image.relative_path}
${imageIndex === 0 ? ` KEEP (Oldest) ` : ` `}
`).join('')}
`; list.appendChild(groupEl); }); content.classList.remove('hidden'); footer.classList.remove('hidden'); } selectAllDuplicates() { const checkboxes = document.querySelectorAll('.duplicate-checkbox'); const selectBtn = document.getElementById('selectAllBtn'); const allChecked = Array.from(checkboxes).every(cb => cb.checked); checkboxes.forEach(cb => { cb.checked = !allChecked; }); selectBtn.textContent = allChecked ? 'Select All to Delete' : 'Deselect All'; } async removeDuplicates() { const checkboxes = document.querySelectorAll('.duplicate-checkbox:checked'); if (checkboxes.length === 0) { this.showNotification('No duplicate images selected for removal', 'warning'); return; } const confirmation = confirm(`Are you sure you want to permanently delete ${checkboxes.length} duplicate image files? This action cannot be undone!`); if (!confirmation) return; const btn = document.getElementById('removeDuplicatesBtn'); btn.disabled = true; btn.textContent = 'Deleting...'; try { const imagePaths = Array.from(checkboxes).map(cb => cb.dataset.imagePath); const response = await fetch('/prompt_manager/delete_duplicate_images', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_paths: imagePaths }) }); const data = await response.json(); if (data.success) { this.showNotification(`Successfully deleted ${imagePaths.length} duplicate image files`, 'success'); this.hideDuplicatesModal(); this.loadImages(); // Refresh the gallery } else { throw new Error(data.error || 'Failed to delete duplicate images'); } } catch (error) { console.error('Remove duplicates error:', error); this.showNotification('Failed to delete duplicate images', 'error'); } finally { btn.disabled = false; btn.textContent = 'Remove Selected'; } } formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } changeLimit(newLimit) { this.limit = newLimit; this.currentPage = 1; this.loadImages(); } updatePagination() { const totalPages = Math.ceil(this.total / this.limit); const hasPrev = this.currentPage > 1; const hasNext = this.currentPage < totalPages; document.getElementById('prevPageBtn').disabled = !hasPrev; document.getElementById('nextPageBtn').disabled = !hasNext; document.getElementById('pageInfo').textContent = `of ${totalPages}`; const pageInput = document.getElementById('pageInput'); pageInput.value = this.currentPage; pageInput.max = totalPages; if (totalPages > 1) { document.getElementById('paginationControls').classList.remove('hidden'); } else { document.getElementById('paginationControls').classList.add('hidden'); } } previousPage() { if (this.currentPage > 1) { this.currentPage--; this.loadImages(); } } nextPage() { const totalPages = Math.ceil(this.total / this.limit); if (this.currentPage < totalPages) { this.currentPage++; this.loadImages(); } } goToPage(page) { const totalPages = Math.ceil(this.total / this.limit); if (isNaN(page) || page < 1 || page > totalPages) { // Reset to current page if invalid input document.getElementById('pageInput').value = this.currentPage; return; } if (page !== this.currentPage) { this.currentPage = page; this.loadImages(); } } async checkThumbnailsAtStartup() { // Wait a moment to ensure page is fully loaded setTimeout(async () => { const settings = this.getSettings(); // Only check if the feature is enabled if (!settings.checkThumbnailsAtStartup) { console.debug('Thumbnail startup check disabled in settings'); return; } try { // Check a sample of images to see if any are missing thumbnails const response = await fetch('/prompt_manager/images/output?limit=50&offset=0'); const data = await response.json(); console.debug('Checking images for missing thumbnails:', data); if (data.success && data.images && data.images.length > 0) { // Count images missing thumbnails let missingThumbnails = 0; for (const image of data.images) { if (!image.thumbnail_url) { missingThumbnails++; } } console.debug(`Found ${missingThumbnails} images without thumbnails in sample of ${data.images.length}`); // If more than 10% of sampled images are missing thumbnails, show prompt const missingPercentage = (missingThumbnails / data.images.length) * 100; if (missingThumbnails > 0 && missingPercentage > 10) { // Estimate total missing based on sample const estimatedMissing = Math.round((missingThumbnails / data.images.length) * data.total); console.debug(`Estimated ${estimatedMissing} total images missing thumbnails (${missingPercentage.toFixed(1)}% of sample)`); this.showThumbnailGenerationPrompt(data.total, estimatedMissing); } else if (missingThumbnails > 0) { console.debug(`Only ${missingPercentage.toFixed(1)}% of images missing thumbnails, not showing prompt`); } else { console.debug('All sampled images have thumbnails'); } } else { console.debug('No images found or failed to fetch images'); } } catch (error) { console.debug('Could not check for missing thumbnails at startup:', error); } }, 1000); // Wait 1 second for page to settle } showThumbnailGenerationPrompt(imageCount, missingCount = imageCount) { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; modal.innerHTML = `

🚀 Generate Thumbnails?

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.

`; document.body.appendChild(modal); // Handle button clicks document.getElementById('thumbnailPromptNo').addEventListener('click', () => { document.body.removeChild(modal); }); document.getElementById('thumbnailPromptYes').addEventListener('click', () => { // Don't remove modal yet - transform it to show progress this.startThumbnailGenerationInModal(modal, imageCount, missingCount); }); // Close on outside click modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); } async startThumbnailGenerationInModal(modal, imageCount, missingCount) { // Transform the modal to show progress const modalContent = modal.querySelector('.bg-gray-800'); modalContent.innerHTML = `

🚀 Generating Thumbnails

Processing ${missingCount} images out of ${imageCount} total...

Initializing... 0%
Starting thumbnail generation...
â€ĸ Starting thumbnail generation...
`; // Setup progress tracking let isCancelled = false; const progressText = modal.querySelector('#modalProgressText'); const progressPercent = modal.querySelector('#modalProgressPercent'); const progressBar = modal.querySelector('#modalProgressBar'); const progressDetails = modal.querySelector('#modalProgressDetails'); const progressETA = modal.querySelector('#modalProgressETA'); const statusMessages = modal.querySelector('#modalStatusMessages'); const cancelBtn = modal.querySelector('#modalCancelBtn'); const closeBtn = modal.querySelector('#modalCloseBtn'); // Add status message helper const addStatusMessage = (message, type = 'info') => { const colors = { 'info': 'text-gray-400', 'success': 'text-green-400', 'error': 'text-red-400', 'warning': 'text-yellow-400' }; const msgEl = document.createElement('div'); msgEl.className = colors[type] || colors.info; msgEl.textContent = `â€ĸ ${message}`; statusMessages.appendChild(msgEl); statusMessages.scrollTop = statusMessages.scrollHeight; }; // Handle cancel cancelBtn.addEventListener('click', () => { isCancelled = true; addStatusMessage('Cancellation requested...', 'warning'); cancelBtn.disabled = true; cancelBtn.textContent = 'Cancelling...'; }); // Handle close (only appears when done) closeBtn.addEventListener('click', () => { document.body.removeChild(modal); // Refresh the gallery to show new thumbnails this.loadImages(); }); // Disable outside click while processing modal.removeEventListener('click', modal.clickHandler); try { addStatusMessage('Connecting to thumbnail generation service...'); // Start thumbnail generation with progress monitoring await this.generateThumbnailsWithProgressInModal({ progressText, progressPercent, progressBar, progressDetails, progressETA, addStatusMessage, isCancelled: () => isCancelled }); // Show completion if (!isCancelled) { progressText.textContent = 'Completed!'; progressPercent.textContent = '100%'; progressBar.style.width = '100%'; addStatusMessage('Thumbnail generation completed successfully!', 'success'); // Update settings const settings = this.getSettings(); settings.thumbnailsGenerated = true; localStorage.setItem('gallerySettings', JSON.stringify(settings)); // Show notification this.showNotification('Thumbnails generated successfully!', 'success'); } } catch (error) { console.error('Thumbnail generation failed:', error); addStatusMessage(`Generation failed: ${error.message}`, 'error'); progressBar.classList.add('bg-red-600'); progressBar.classList.remove('bg-violet-600'); } finally { // Show close button and hide cancel cancelBtn.classList.add('hidden'); closeBtn.classList.remove('hidden'); // Re-enable outside click to close setTimeout(() => { modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); // Refresh the gallery to show new thumbnails this.loadImages(); } }); }, 100); } } async generateThumbnailsWithProgressInModal(uiElements) { const { progressText, progressPercent, progressBar, progressDetails, progressETA, addStatusMessage, isCancelled } = uiElements; return new Promise((resolve, reject) => { addStatusMessage('Starting thumbnail generation...'); // Get quality setting const quality = this.getSettings().imageQuality || 'medium'; console.log(`Starting modal thumbnail generation with quality: ${quality}`); // Set up Server-Sent Events (same as the settings modal) const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${quality}`); let resultData = null; // Handle scanning/status events eventSource.addEventListener('status', (event) => { if (isCancelled()) { eventSource.close(); return; } const data = JSON.parse(event.data); if (data.phase === 'scanning') { progressText.textContent = 'Scanning for images...'; progressDetails.textContent = data.message || 'Scanning output folder for images and videos...'; addStatusMessage(data.message || 'Scanning output folder...', 'info'); } else if (data.phase === 'processing' && data.message.includes('Error')) { addStatusMessage(data.message, 'error'); } else { progressDetails.textContent = data.message || 'Preparing...'; } }); // Handle start event eventSource.addEventListener('start', (event) => { if (isCancelled()) { eventSource.close(); return; } const data = JSON.parse(event.data); // Show detailed file type breakdown const fileBreakdown = (data.image_count !== undefined && data.video_count !== undefined) ? ` (${data.image_count} images, ${data.video_count} videos)` : ''; progressText.textContent = `Processing ${data.total_images} files${fileBreakdown}...`; progressDetails.textContent = data.message || `Found ${data.total_images} media files to process`; progressBar.style.width = '0%'; progressPercent.textContent = '0%'; progressETA.textContent = 'Calculating...'; addStatusMessage(`Found ${data.total_images} media files to process${fileBreakdown}`, 'info'); }); // Handle progress updates eventSource.addEventListener('progress', (event) => { if (isCancelled()) { eventSource.close(); return; } const data = JSON.parse(event.data); // Update progress bar progressBar.style.width = `${data.percentage}%`; progressPercent.textContent = `${data.percentage}%`; // Update text with current file and action const actionText = data.action === 'skipped' ? ' [Skipped]' : data.action === 'generating' ? ' [Generating]' : ''; // Show full path but truncate if too long const fullPath = data.current_file || ''; const displayPath = fullPath.length > 60 ? '...' + fullPath.slice(-57) : fullPath; progressText.textContent = `Processing ${data.file_type || 'file'} ${data.processed}/${data.total_images}: ${displayPath}${actionText}`; // Update details 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 = ''; } // Add status message periodically if (data.processed % 50 === 0 || data.processed === data.total_images) { addStatusMessage(`Processed ${data.processed}/${data.total_images} files (${data.generated} generated, ${data.skipped} skipped)`, 'info'); } }); // Handle completion eventSource.addEventListener('complete', (event) => { if (isCancelled()) { eventSource.close(); 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} | Total: ${resultData.total_images} files | Time: ${resultData.elapsed_time}s${rateText}${errors}`; progressETA.textContent = 'Done'; // Add completion message if (errorCount > 0) { addStatusMessage(`Completed with ${errorCount} errors: Generated ${resultData.count} new thumbnails, skipped ${resultData.skipped} existing`, 'warning'); if (resultData.errors && resultData.errors.length > 0) { // Show first few errors resultData.errors.slice(0, 3).forEach(err => { addStatusMessage(err, 'error'); }); } } else { addStatusMessage(`Successfully generated ${resultData.count} new thumbnails, skipped ${resultData.skipped} existing`, 'success'); } console.log('Modal thumbnail generation completed:', resultData); // Clean up eventSource.close(); resolve(resultData); }); // Handle errors eventSource.addEventListener('error', (event) => { if (isCancelled()) { eventSource.close(); return; } let errorMsg = 'Thumbnail generation failed'; try { const data = JSON.parse(event.data); errorMsg = data.error || errorMsg; } catch (e) { // If not JSON, it's a connection error console.error('EventSource error in modal:', e); } addStatusMessage(errorMsg, 'error'); progressBar.classList.add('bg-red-600'); progressBar.classList.remove('bg-violet-600'); // Clean up eventSource.close(); reject(new Error(errorMsg)); }); // Handle EventSource connection errors eventSource.onerror = (error) => { if (isCancelled()) { eventSource.close(); return; } console.error('EventSource connection error in modal:', error); // Check if connection is closing normally (after complete event) if (eventSource.readyState === EventSource.CLOSED && resultData) { console.log('EventSource connection closed normally after completion'); return; } addStatusMessage('Connection to server lost', 'error'); progressBar.classList.add('bg-red-600'); progressBar.classList.remove('bg-violet-600'); // Clean up eventSource.close(); if (!resultData) { reject(new Error('Connection to server lost during thumbnail generation')); } }; }); } // ========================================== // Add Prompt Feature Methods // ========================================== // State for Add Prompt feature addPromptState = { allTags: [], allCategories: [], selectedTags: [], selectedRating: null }; async showAddPromptModal() { // Reset form this.resetAddPromptForm(); // Load tags and categories for autocomplete await this.loadTagsAndCategories(); this.showModal('addPromptModal'); document.getElementById('addPromptText').focus(); } hideAddPromptModal() { this.hideModal('addPromptModal'); this.hideSuggestions(); } resetAddPromptForm() { document.getElementById('addPromptText').value = ''; document.getElementById('addPromptCategory').value = ''; document.getElementById('addPromptNotes').value = ''; document.getElementById('addPromptTagInput').value = ''; document.getElementById('addPromptProtected').checked = true; document.getElementById('addPromptRatingValue').value = ''; this.addPromptState.selectedTags = []; this.addPromptState.selectedRating = null; this.renderSelectedTags(); this.updateRatingStars(0); } async loadTagsAndCategories() { try { // Load tags const tagsResponse = await fetch('/prompt_manager/tags'); const tagsData = await tagsResponse.json(); if (tagsData.success) { // Filter out the __protected__ tag from suggestions this.addPromptState.allTags = tagsData.tags.filter(tag => tag !== '__protected__'); } // Load categories const categoriesResponse = await fetch('/prompt_manager/categories'); const categoriesData = await categoriesResponse.json(); if (categoriesData.success) { this.addPromptState.allCategories = categoriesData.categories; this.populateCategoryDatalist(); } } catch (error) { console.error('Error loading tags/categories:', error); } } populateCategoryDatalist() { const datalist = document.getElementById('categoryList'); datalist.innerHTML = this.addPromptState.allCategories .map(cat => `