class GalleryManager { constructor() { this.images = []; this.currentPage = 1; this.limit = 100; this.total = 0; this.viewMode = 'grid'; // 'grid' or 'list' this.viewer = null; this.currentSubfolder = ''; this.expandedFolders = new Set(); this.thumbSize = 'md'; // sm, md, lg this.infiniteScroll = false; this.loadingMore = false; // Duplicate scan: a server-side job the modal follows (see scanDuplicates) this.dupScan = ScanProgress.initialState(); this.dupSource = null; this.dupResults = null; this.api = ApiClient.createApiClient(); // Only the newest page request may update the grid this.imagesRequest = ApiClient.latestOnly(); // Elements rendered with data-action="..." are dispatched here instead of through // inline onclick handlers, so file names and URLs never land inside JavaScript source. this.actionHandlers = { "open-url": ({ url }) => window.open(url, '_blank'), "copy-url": ({ url, label }, el) => this.copyUrlWithFeedback(el, url, label), "select-suggestion": ({ tag }) => this.selectSuggestion(tag), "remove-tag": ({ index }) => this.removeTag(index), "remove-review-tag": ({ index }) => this.removeReviewTag(index), }; this.initializeEventListeners(); this.applySettings(this.getSettings()); this.loadFolderTree(); this.loadImages(); this.resumeDuplicateScan(); // Check thumbnails at startup if enabled this.checkThumbnailsAtStartup(); } handleActionClick(e) { const target = e.target.closest('[data-action]'); if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target); } copyUrlWithFeedback(el, url, label) { navigator.clipboard.writeText(url).then(() => { el.textContent = '✓ Copied!'; setTimeout(() => { el.textContent = label; }, 1000); }); } handleMediaError(img) { if (!img || img.tagName !== 'IMG' || !img.dataset || img.dataset.original === undefined) return; const next = ImageHelpers.fallbackImageSource(img.getAttribute('src'), img.dataset); if (next) { // The thumbnail failed (stale cache, half-written file): show the original instead img.dataset.fellBack = '1'; img.src = next; return; } img.style.display = 'none'; const fallback = img.nextElementSibling; if (fallback && fallback.dataset && fallback.dataset.mediaFallback !== undefined) { fallback.style.display = 'flex'; } } initializeEventListeners() { document.addEventListener('click', (e) => this.handleActionClick(e)); // Image load failures do not bubble; capture them once for every card document.addEventListener('error', (e) => this.handleMediaError(e.target), true); 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()); document.getElementById('dupScanPill').addEventListener('click', () => this.openDuplicatesModal()); document.getElementById('galleryScroll').addEventListener('scroll', () => this.maybeLoadMore(), { passive: true }); // 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()); // WD14 model selection toggle (prompt vs thresholds) document.querySelectorAll('input[name="autoTagModel"]').forEach(radio => { radio.addEventListener('change', (e) => { const isWd14 = e.target.value.startsWith('wd14'); document.getElementById('promptSection').classList.toggle('hidden', isWd14); document.getElementById('wd14ThresholdSection').classList.toggle('hidden', !isWd14); }); }); // WD14 threshold slider value display document.getElementById('wd14GeneralThreshold').addEventListener('input', (e) => { document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(e.target.value).toFixed(2); }); document.getElementById('wd14CharacterThreshold').addEventListener('input', (e) => { document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(e.target.value).toFixed(2); }); // 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); } }); }); // Thumbnail size buttons ['Sm', 'Md', 'Lg'].forEach(size => { const btn = document.getElementById('thumb' + size + 'Btn'); if (btn) btn.addEventListener('click', () => this.setThumbSize(size.toLowerCase())); }); // Folder tree events document.getElementById('clearFolderFilterBtn').addEventListener('click', () => { this.currentSubfolder = ''; this.renderFolderTree(); this.loadImages(1); }); // Resize handle for folder panel const resizeHandle = document.getElementById('folderResizeHandle'); if (resizeHandle) { let isResizing = false; resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const panel = document.getElementById('folderTreePanel'); const newWidth = Math.max(160, Math.min(500, e.clientX - panel.getBoundingClientRect().left)); panel.style.width = newWidth + 'px'; }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }); } } async loadFolderTree() { try { const response = await ApiClient.fetchUrl('/prompt_manager/gallery/subfolders?include_ancestors=true'); if (!response.ok) return; const data = await response.json(); if (data.success && data.subfolders.length > 0) { this.folderList = data.subfolders; document.getElementById('folderTreePanel').classList.remove('hidden'); const rh = document.getElementById('folderResizeHandle'); if (rh) rh.classList.remove('hidden'); this.renderFolderTree(); } } catch (error) { console.error('Folder tree error:', error); } } renderFolderTree() { const container = document.getElementById('folderTree'); container.innerHTML = ''; // Build tree structure from flat sorted paths const tree = {}; for (const path of this.folderList) { const parts = path.replace(/\\/g, '/').split('/'); let node = tree; for (const part of parts) { if (!node[part]) node[part] = {}; node = node[part]; } } const renderNode = (obj, parentPath, depth) => { const entries = Object.keys(obj).sort(); for (const name of entries) { const fullPath = parentPath ? parentPath + '/' + name : name; const hasChildren = Object.keys(obj[name]).length > 0; const isExpanded = this.expandedFolders.has(fullPath); const isActive = this.currentSubfolder === fullPath; const row = document.createElement('div'); row.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors' + (isActive ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm'); row.style.paddingLeft = (8 + depth * 16) + 'px'; // Toggle arrow const arrow = document.createElement('span'); arrow.className = 'inline-block w-4 text-center text-pm-secondary mr-1 select-none'; if (hasChildren) { arrow.textContent = isExpanded ? '\u25BE' : '\u25B8'; arrow.addEventListener('click', (e) => { e.stopPropagation(); if (this.expandedFolders.has(fullPath)) { this.expandedFolders.delete(fullPath); } else { this.expandedFolders.add(fullPath); } this.renderFolderTree(); }); } else { arrow.textContent = ''; } row.appendChild(arrow); const label = document.createElement('span'); label.textContent = name; label.className = 'truncate'; row.appendChild(label); // Click to filter row.addEventListener('click', () => { this.currentSubfolder = fullPath; this.renderFolderTree(); this.loadImages(1); }); container.appendChild(row); if (hasChildren && isExpanded) { renderNode(obj[name], fullPath, depth + 1); } } }; // "All" option at top const allRow = document.createElement('div'); allRow.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors' + (!this.currentSubfolder ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm'); allRow.style.paddingLeft = '8px'; allRow.innerHTML = 'All Images'; allRow.addEventListener('click', () => { this.currentSubfolder = ''; this.renderFolderTree(); this.loadImages(1); }); container.appendChild(allRow); renderNode(tree, '', 0); } setThumbSize(size) { this.thumbSize = size; const grid = document.getElementById('galleryGrid'); const sizes = { sm: { cols: 'repeat(auto-fill, minmax(100px, 1fr))', gap: '4px' }, md: { cols: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '8px' }, lg: { cols: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' }, }; const s = sizes[size] || sizes.md; grid.classList.remove('gallery-columns-custom'); // an explicit size wins over the column setting grid.style.gridTemplateColumns = s.cols; grid.style.gap = s.gap; // Update button states ['Sm', 'Md', 'Lg'].forEach(s => { const btn = document.getElementById('thumb' + s + 'Btn'); if (btn) { if (s.toLowerCase() === size) { btn.className = btn.className.replace('bg-pm-input', 'bg-pm-accent').replace('text-pm ', 'text-pm-accent-fg '); } else { btn.className = btn.className.replace('bg-pm-accent', 'bg-pm-input').replace('text-pm-accent-fg', 'text-pm'); } } }); } /** * Load one page of images. `page` is committed to this.currentPage only after the * server answered, and a newer request supersedes an older one still in flight. */ async loadImages(page = this.currentPage, { append = false } = {}) { if (!append) this.showLoading(); else document.getElementById('loadingStatus').textContent = 'Loading more...'; const offset = (page - 1) * this.limit; let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`; if (this.currentSubfolder) { url += `&subfolder=${encodeURIComponent(this.currentSubfolder)}`; } try { const data = await this.imagesRequest((signal) => this.api.get(url, { signal })); if (data === ApiClient.STALE) return; const next = ListState.nextPageState( { page: this.currentPage, limit: this.limit, total: this.total }, { page, total: data.total }, ); this.currentPage = next.page; this.total = next.total; const images = data.images.map(image => ({ ...image, url: ApiClient.resolveUrl(image.url), thumbnail_url: ApiClient.resolveUrl(image.thumbnail_url), })); const from = append ? this.images.length : 0; this.images = append ? this.images.concat(images) : images; this.updateStats(); this.renderGallery({ from }); this.updatePagination(); document.getElementById('loadingStatus').textContent = 'Loaded'; this.maybeLoadMore(); // a short page may not fill the viewport } catch (error) { console.error('Error loading images:', error); this.showError(error.message); } } maybeLoadMore() { const el = document.getElementById('galleryScroll'); if (!el) return; const wanted = ListState.shouldLoadMore({ infiniteScroll: this.infiniteScroll, loading: this.loadingMore, page: this.currentPage, limit: this.limit, total: this.total, scrollTop: el.scrollTop, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, }); if (wanted) this.loadMoreImages(); } async loadMoreImages() { if (this.loadingMore) return; this.loadingMore = true; try { await this.loadImages(this.currentPage + 1, { append: true }); } finally { this.loadingMore = false; } } 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.infiniteScroll ? 1 : (this.currentPage - 1) * this.limit + 1; const end = this.infiniteScroll ? this.images.length : 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-pm-input'); gridBtn.classList.add('bg-pm-accent'); listBtn.classList.remove('bg-pm-accent'); listBtn.classList.add('bg-pm-input'); } else { listBtn.classList.remove('bg-pm-input'); listBtn.classList.add('bg-pm-accent'); gridBtn.classList.remove('bg-pm-accent'); gridBtn.classList.add('bg-pm-input'); } this.renderGallery(); } renderGallery({ from = 0 } = {}) { if (from === 0) this.destroyViewer(); 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({ from }); } else { this.renderListView({ from }); } this.addFallbackClickHandlers(); } renderGridView({ from = 0 } = {}) { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); const showInfo = this.getSettings().showImageInfo; list.classList.add('hidden'); grid.classList.remove('hidden'); const html = this.images.slice(from).map((image, offset) => { const index = from + offset; // 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 `
${escapeHtml(image.filename)}
${isVideo ? `
` : ` `}
${hasThumb ? '
' : ''} ${isVideo ? '
VIDEO
' : ''}
${escapeHtml(image.filename)}
${showInfo ? `
${this.formatFileSize(image.size)}${hasThumb ? ' â€ĸ Fast' : ''}${isVideo ? ' â€ĸ Video' : ''}
` : ''}
`; }).join(''); if (from === 0) grid.innerHTML = html; else grid.insertAdjacentHTML('beforeend', html); } renderListView({ from = 0 } = {}) { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); const showInfo = this.getSettings().showImageInfo; grid.classList.add('hidden'); list.classList.remove('hidden'); const html = this.images.slice(from).map((image, offset) => { const index = from + offset; // 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 `
${escapeHtml(image.filename)} ${hasThumb ? '
' : ''} ${isVideo ? '
â–ļ
' : ''}
${escapeHtml(image.filename)}${isVideo ? ' đŸŽŦ' : ''}
${showInfo ? `
${this.formatFileSize(image.size)} â€ĸ ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' â€ĸ Fast' : ''}${isVideo ? ' â€ĸ Video' : ''}
` : ''}
${escapeHtml(image.relative_path)}
${isVideo ? ` ` : ` `}
`; }).join(''); if (from === 0) list.innerHTML = html; else list.insertAdjacentHTML('beforeend', html); } destroyViewer({ keepSlideshow = false } = {}) { this.metadataRequest = null; this.currentMetadata = null; if (!keepSlideshow) this.stopViewerSlideshow(); if (this.viewer) { this.viewer.clearEnforceFocus(); this.viewer.destroy(); } this.viewer = null; document.getElementById('metadata-sidebar')?.remove(); } openViewer(index, { keepSlideshow = false } = {}) { if (!Number.isInteger(index) || index < 0 || index >= this.images.length) return; this.destroyViewer({ keepSlideshow }); const size = 100; const start = Math.max(0, Math.min(index - size / 2, this.images.length - size)); const container = document.createElement('div'); container.innerHTML = this.images.slice(start, start + size).map(image => `${escapeHtml(image.filename)}` ).join(''); this.viewer = new Viewer(container, { inline: false, initialViewIndex: index - start, button: true, backdrop: 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: '', title: [1, (image, imageData) => `${imageData.alt} (${this.images.length} images) - Original Image`], viewed: (event) => { // Add metadata sidebar when ViewerJS opens // Use the original image URL from data-original attribute const originalImg = event.detail.originalImage; this.addMetadataSidebar(originalImg); }, play: (event) => { event.preventDefault(); if (this.slideshowPlaying) this.stopViewerSlideshow(); else { this.slideshowPlaying = true; if (this.viewer.options.fullscreen) this.viewer.requestFullscreen(); this.scheduleViewerSlide(); } }, hide: () => { this.metadataRequest = null; this.currentMetadata = null; this.viewer?.clearEnforceFocus(); this.stopViewerSlideshow(); const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) { sidebar.remove(); } } }); const viewer = this.viewer; const navigate = (direction, loop = false) => { if (viewer !== this.viewer || viewer.hiding || viewer.played) return viewer; let target = start + viewer.index + direction; if (loop) target = (target + this.images.length) % this.images.length; else target = Math.max(0, Math.min(target, this.images.length - 1)); if (target >= start && target < start + viewer.length) { viewer.view(target - start); } else { this.openViewer(target, { keepSlideshow: true }); } return this.viewer; }; viewer.prev = (loop) => navigate(-1, loop); viewer.next = (loop) => navigate(1, loop); viewer.show(); } stopViewerSlideshow() { clearTimeout(this.slideshowTimer); this.slideshowTimer = null; if (this.slideshowPlaying) this.viewer?.exitFullscreen(); this.slideshowPlaying = false; } scheduleViewerSlide() { // Native Viewer.js slideshows preload every original and loop within the filmstrip. this.slideshowTimer = setTimeout(() => { this.slideshowTimer = null; if (!this.slideshowPlaying || !this.viewer) return; this.viewer.next(true); if (this.slideshowPlaying) this.scheduleViewerSlide(); }, 5000); } addMetadataSidebar(originalImage) { const viewerContainer = document.querySelector('.viewer-container'); if (!viewerContainer) return; const existing = document.getElementById('metadata-sidebar'); if (existing) { this.loadImageMetadata(originalImage.dataset.original || originalImage.src, existing.querySelector('#metadata-content')); return; } const sidebar = document.createElement('div'); sidebar.id = 'metadata-sidebar'; sidebar.className = 'metadata-sidebar'; sidebar.innerHTML = `

Generation data

Loading metadata...

`; document.body.appendChild(sidebar); // Apply default collapsed state from settings if (this.getSettings().sidebarCollapsedByDefault) { sidebar.classList.add('collapsed'); } // Load metadata for the current image (ALWAYS use original for metadata) const originalImageUrl = originalImage.dataset.original || originalImage.src; this.loadImageMetadata(originalImageUrl, sidebar.querySelector('#metadata-content')); } addFallbackClickHandlers() { for (const id of ['galleryGrid', 'galleryList']) { const container = document.getElementById(id); if (container.dataset.mediaClicksBound) continue; container.dataset.mediaClicksBound = '1'; container.addEventListener('click', (event) => { const item = event.target.closest('.image-item'); if (!item) return; const img = item.querySelector('img[data-original]'); if (!img) return; event.preventDefault(); event.stopPropagation(); const index = Number(item.dataset.imageIndex); const { original, caption, isVideo } = img.dataset; if (isVideo === 'true') { this.showVideoModal(original, caption, index); } else { try { this.openViewer(index); } catch (error) { console.warn('ViewerJS failed, using fallback:', error); this.destroyViewer(); this.showSimpleImageModal(original, 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.id = 'image-viewer-modal'; modal.innerHTML = `
${escapeHtml(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; the listener is dropped once the modal leaves the // document, whichever way it was closed const escapeHandler = (e) => { if (e.key === 'Escape') modal.remove(); }; document.addEventListener('keydown', escapeHandler); const closeObserver = new MutationObserver(() => { if (modal.isConnected) return; document.removeEventListener('keydown', escapeHandler); closeObserver.disconnect(); }); closeObserver.observe(document.body, { childList: true }); document.body.appendChild(modal); // Apply default collapsed state from settings const sidebarInModal = modal.querySelector('.metadata-sidebar'); if (sidebarInModal && this.getSettings().sidebarCollapsedByDefault) { sidebarInModal.classList.add('collapsed'); } // 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; the listener is dropped once the modal leaves the // document, whichever way it was closed const escapeHandler = (e) => { if (e.key === 'Escape') modal.remove(); }; document.addEventListener('keydown', escapeHandler); const closeObserver = new MutationObserver(() => { if (modal.isConnected) return; document.removeEventListener('keydown', escapeHandler); closeObserver.disconnect(); }); closeObserver.observe(document.body, { childList: true }); document.body.appendChild(modal); // Apply default collapsed state from settings const sidebarInVideoModal = modal.querySelector('.metadata-sidebar'); if (sidebarInVideoModal && this.getSettings().sidebarCollapsedByDefault) { sidebarInVideoModal.classList.add('collapsed'); } // 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

${escapeHtml(fileName)}
Type: ${escapeHtml(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

${escapeHtml(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) { const request = {}; this.metadataRequest = request; this.currentMetadata = null; container.textContent = 'Loading metadata...'; const isCurrent = () => this.metadataRequest === request && container.isConnected !== false; try { // First try to get linked prompt from database let databasePrompt = null; try { const metadataUrl = ImageHelpers.metadataLookupUrl(imageUrl); if (metadataUrl) { const response = await ApiClient.fetchUrl(metadataUrl); 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 = { textSource: databasePrompt.pair_context?.text_source, positivePrompt: databasePrompt.pair_context?.positive_text ?? databasePrompt.text, negativePrompt: databasePrompt.pair_context?.negative_text ?? databasePrompt.pair?.negative_text ?? 'No negative prompt found', 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 (!databasePrompt.pair && 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 } } if (isCurrent()) this.displayMetadata(metadata, container); } catch (error) { if (!isCurrent()) return; console.error('Error loading metadata:', error); container.innerHTML = `

❌ Metadata Error

${escapeHtml(error.message)}

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

No metadata found in this image

`; return; } const textSourceLabel = new Map([ ['execution','Recorded during execution'], ['graph-inferred','Recovered from workflow metadata'], ['embedded-parameters','Read from embedded parameters'], ['mixed','Execution and workflow metadata'], ]).get(metadata.textSource); const html = `

File Path

${escapeHtml(metadata.imagePath || 'Unknown')}

Resources used

Model: ${escapeHtml(metadata.checkpoint || 'Unknown')}
${textSourceLabel ? `

${escapeHtml(textSourceLabel)}

` : ''}

Prompt

${this.formatPromptText(metadata.positivePrompt)}

Negative prompt

${this.formatPromptText(metadata.negativePrompt)}

Other metadata

CFG SCALE: ${escapeHtml(metadata.cfgScale || 'Unknown')}
STEPS: ${escapeHtml(metadata.steps || 'Unknown')}
SAMPLER: ${escapeHtml(metadata.sampler || 'Unknown')}
SEED: ${escapeHtml(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); // 'remove' is not a DOM event; watch for the modal leaving the document instead const navObserver = new MutationObserver(() => { if (modal.isConnected) return; document.removeEventListener('keydown', keyHandler); navObserver.disconnect(); }); navObserver.observe(document.body, { childList: true }); // 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 = 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'); if (!newWindow) { this.showNotification('Popup blocked: allow popups to view the workflow', 'warning'); return; } newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow)); newWindow.document.close(); } } 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 escapeHtml(text); } unescapeHtml(text) { const div = document.createElement('div'); div.innerHTML = text; return div.textContent; } 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 ApiClient.fetchUrl(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; } } parsePNGMetadata(arrayBuffer) { return PngMetadata.parsePngTextChunks(arrayBuffer); } 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.warn('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.warn('Failed to parse prompt field:', field); } } } return { workflow: workflowData, prompt: promptData }; } parseWorkflowData(comfyData, imagePath) { // Trace prompts, model and sampler settings through the node graph (#75) const { checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed, } = window.ComfyMetadata.extractGenerationParams(comfyData); 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('sidebarCollapsedByDefaultToggle').checked = settings.sidebarCollapsedByDefault; document.getElementById('infiniteScrollToggle').checked = settings.infiniteScroll; 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() { let stored = null; try { const raw = localStorage.getItem('gallerySettings'); if (raw) stored = JSON.parse(raw); } catch (e) { console.warn('Failed to load settings from localStorage:', e); } return GallerySettings.normalizeGallerySettings(stored); } 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, sidebarCollapsedByDefault: document.getElementById('sidebarCollapsedByDefaultToggle').checked, infiniteScroll: document.getElementById('infiniteScrollToggle').checked, debugMode: document.getElementById('debugModeToggle').checked, apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value), thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed, 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) { // Runs at startup and after Save, so every display setting takes effect // without a reload. Settings that change what is rendered re-render. const loaded = this.images.length > 0; this.updateGridColumns(settings.gridColumns); const limitChanged = this.limit !== settings.defaultLimit; this.limit = settings.defaultLimit; const scrollChanged = this.infiniteScroll !== settings.infiniteScroll; this.infiniteScroll = settings.infiniteScroll; const limitSelector = document.getElementById('limitSelector'); if (limitSelector) limitSelector.value = String(settings.defaultLimit); if (this.viewMode !== settings.defaultViewMode) { this.setViewMode(settings.defaultViewMode); } else if (loaded && !limitChanged) { this.renderGallery(); // e.g. "Show Media Info" toggled } if (loaded && (limitChanged || scrollChanged)) this.loadImages(1); } updateGridColumns(columns) { const gridContainer = document.getElementById('galleryGrid'); if (!gridContainer) return; // The Tailwind column classes are compiled for a fixed set of values and a // wider breakpoint overrides them; an explicit template wins on desktop widths. gridContainer.style.removeProperty('grid-template-columns'); // undo a thumb-size override gridContainer.style.setProperty('--gallery-columns', GallerySettings.gridColumnsStyle(columns)); gridContainer.classList.add('gallery-columns-custom'); } 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.notifyThumbnailsDone(result); // 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-pm-error'); 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-pm-error'); }, 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'); // Set up Server-Sent Events // The progress route is POST (it generates files), so read it with SseStream like the autotag streams const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(options.quality)}`, { method: 'POST' }); // Log connection 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) { } }); // 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) { } }); // 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` : ''; const failedText = data.error_count ? `, Failed: ${data.error_count}` : ''; progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped}${failedText} | 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) { } }); // 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) { 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); }); // Per-file failures: count them, keep listening eventSource.addEventListener('file_error', (event) => { if (cancelled) return; try { const data = JSON.parse(event.data); console.warn('Thumbnail failed:', data.current_file || data.file || '', data.error || ''); } catch (e) { console.warn('Thumbnail failed (unparseable event)'); } }); // 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) { } 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')); } }; // Stream ended without a complete/error frame (close() does not trigger this) eventSource.onclose = () => { if (cancelled || resultData) return; cancelBtn.removeEventListener('click', cancelHandler); cancelBtn.classList.add('hidden'); reject(new Error('Connection to server closed before thumbnail generation finished')); }; }); } 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 ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { const data = await response.json(); } } 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 { // The listing is cached on the server; ask it to rewalk the folders first const result = await this.api.post('/prompt_manager/gallery/rescan', {}); this.hideSettings(); await Promise.all([this.loadFolderTree(), this.loadImages(1)]); const where = result.roots === 1 ? 'the output folder' : `${result.roots} folders`; this.showNotification(`Rescanned ${where}: ${result.total} files`, 'success'); } catch (error) { console.error('Rescan error:', error); this.showNotification('Failed to rescan folder', 'error'); } finally { btn.disabled = false; btn.textContent = 'Rescan'; } } // ── Duplicate scan ────────────────────────────────────────────── // The scan runs on the server (POST /scan_duplicates/stream starts or attaches). // Closing the modal only hides it: a pill shows progress and reopens it, and a // reload re-attaches to a scan that is still running. scanDuplicates() { if (this.dupScan.status === ScanProgress.RUNNING) { this.openDuplicatesModal(); return; } this.dupResults = null; this.dupScan = ScanProgress.reduceScanEvent(ScanProgress.initialState(), { type: 'progress', progress: 0, status: 'Starting...', processed: 0, found: 0, }); this.setDuplicateButton(true); this.openDuplicatesModal(); this.attachDuplicateStream(); } attachDuplicateStream() { if (this.dupSource) this.dupSource.close(); const source = SseStream.connect('/prompt_manager/scan_duplicates/stream', { method: 'POST', body: '{}' }); this.dupSource = source; source.onmessage = (e) => { let data; try { data = JSON.parse(e.data); } catch (_) { return; } this.applyDuplicateEvent(data); }; source.onerror = () => { if (this.dupSource !== source || this.dupScan.status !== ScanProgress.RUNNING) return; setTimeout(() => this.resumeDuplicateScan(), 2000); }; source.onclose = () => { if (this.dupSource === source && this.dupScan.status === ScanProgress.RUNNING) this.resumeDuplicateScan(); }; } async resumeDuplicateScan() { let status; try { const response = await ApiClient.fetchUrl('/prompt_manager/scan_duplicates/status'); if (!response.ok) return; status = await response.json(); } catch (_) { return; } const state = ScanProgress.fromStatus(status); if (state.status !== ScanProgress.RUNNING) { if (this.dupScan.status === ScanProgress.RUNNING) { // The scan we were following ended while we were away const last = status.last_event; const terminal = last && (last.type === 'complete' || last.type === 'error'); this.applyDuplicateEvent(terminal ? last : { type: 'error', message: 'the server no longer reports a running scan' }); } return; } this.dupScan = state; this.setDuplicateButton(true); this.renderDuplicateProgress(); if (document.getElementById('duplicatesModal').classList.contains('hidden')) this.showDuplicatePill(); this.attachDuplicateStream(); } applyDuplicateEvent(data) { const next = ScanProgress.reduceScanEvent(this.dupScan, data); if (next === this.dupScan) return; this.dupScan = next; this.renderDuplicateProgress(); if (next.status === ScanProgress.DONE) { this.dupResults = Array.isArray(data.duplicates) ? data.duplicates : []; this.setDuplicateButton(false); const modalOpen = !document.getElementById('duplicatesModal').classList.contains('hidden'); if (modalOpen) { this.hideDuplicatePill(); this.displayDuplicates(this.dupResults); } else { this.showDuplicatePill(`Duplicate scan done: ${this.dupResults.length} groups · click to view`); } this.showNotification(`Found ${this.dupResults.length} duplicate image groups`, 'info'); } else if (next.status === ScanProgress.FAILED) { this.setDuplicateButton(false); this.hideDuplicatePill(); this.hideDuplicatesModal(); this.showNotification(`Duplicate scan failed: ${next.message}`, 'error'); } } renderDuplicateProgress() { const state = this.dupScan; const bar = document.getElementById('dupProgressBar'); const text = document.getElementById('dupProgressText'); if (bar) bar.style.width = `${state.progress}%`; if (text) text.textContent = state.status === ScanProgress.RUNNING ? `${state.progress}% · ${state.processed} files hashed` : state.statusText; const pill = document.getElementById('dupScanPillText'); if (pill && state.status === ScanProgress.RUNNING) pill.textContent = `Finding duplicates ${state.progress}% · ${state.processed} files`; } setDuplicateButton(running) { const btn = document.getElementById('scanDuplicatesBtn'); if (!btn) return; btn.disabled = running; btn.textContent = running ? 'Scanning...' : 'Scan'; } showDuplicatePill(label) { const pill = document.getElementById('dupScanPill'); if (label) document.getElementById('dupScanPillText').textContent = label; pill.classList.remove('hidden'); } hideDuplicatePill() { document.getElementById('dupScanPill').classList.add('hidden'); } /** Show the modal in whatever state the scan is in: progress, or results. */ openDuplicatesModal() { this.hideDuplicatePill(); document.getElementById('duplicatesModal').classList.remove('hidden'); document.getElementById('duplicatesModal').classList.add('flex'); document.body.style.overflow = 'hidden'; if (this.dupScan.status === ScanProgress.DONE && this.dupResults) { this.displayDuplicates(this.dupResults); return; } document.getElementById('duplicatesScanStatus').classList.remove('hidden'); document.getElementById('duplicatesContent').classList.add('hidden'); document.getElementById('duplicatesFooter').classList.add('hidden'); this.renderDuplicateProgress(); } showDuplicatesModal() { this.openDuplicatesModal(); } hideDuplicatesModal() { document.getElementById('duplicatesModal').classList.add('hidden'); document.getElementById('duplicatesModal').classList.remove('flex'); document.body.style.overflow = ''; // Minimize rather than abandon: the scan keeps running on the server if (this.dupScan.status === ScanProgress.RUNNING) this.showDuplicatePill(); } 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-pm-surface rounded-pm-md p-4 border border-pm'; groupEl.innerHTML = `
Duplicate Group ${groupIndex + 1} (${group.images.length} identical ${escapeHtml(group.images[0].media_type)}s)
Content Hash: ${escapeHtml(String(group.hash || '').substring(0, 16))}...
${group.images.map((image, imageIndex) => `
${image.media_type === 'video' ? `
${image.thumbnail_url ? ` Video thumbnail ` : ` `}
VIDEO
` : ` ${escapeHtml(image.filename)} `}
${escapeHtml(image.filename)}
Size: ${this.formatFileSize(image.size)} â€ĸ Modified: ${new Date(image.modified_time * 1000).toLocaleDateString()}
${escapeHtml(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 ApiClient.fetchUrl('/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.loadImages(1); } 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 && !this.infiniteScroll) { document.getElementById('paginationControls').classList.remove('hidden'); } else { document.getElementById('paginationControls').classList.add('hidden'); } } previousPage() { if (this.currentPage > 1) this.loadImages(this.currentPage - 1); } nextPage() { const totalPages = Math.ceil(this.total / this.limit); if (this.currentPage < totalPages) this.loadImages(this.currentPage + 1); } 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.loadImages(page); } 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 { const data = await this.api.get('/prompt_manager/images/thumbnails/status'); const total = data.total_images; const pending = data.pending; if (!data.success || !Number.isSafeInteger(total) || !Number.isSafeInteger(pending) || total <= 0 || pending <= 0 || pending > total) return; const signature = JSON.stringify([data.collection_id, total, pending]); if (settings.thumbnailPromptDismissed === signature) return; if (pending / total > 0.1) { this.showThumbnailGenerationPrompt(total, pending, signature); } } 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, signature = null) { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50'; modal.innerHTML = `

🚀 Generate Thumbnails?

${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?

Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks in Gallery Settings.

`; document.body.appendChild(modal); const dismiss = () => { if (signature) { try { const settings = this.getSettings(); settings.thumbnailPromptDismissed = signature; localStorage.setItem('gallerySettings', JSON.stringify(settings)); } catch (error) { console.warn('Could not save thumbnail prompt dismissal:', error); } } document.body.removeChild(modal); }; document.getElementById('thumbnailPromptNo').addEventListener('click', dismiss); 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) { dismiss(); } }); } async startThumbnailGenerationInModal(modal, imageCount, missingCount) { // Transform the modal to show progress const modalContent = modal.querySelector('.bg-pm-surface'); 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-pm-secondary', 'success': 'text-pm-success', 'error': 'text-pm-error', 'warning': 'text-pm-warning' }; 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 const thumbResult = 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.notifyThumbnailsDone(thumbResult); } } catch (error) { console.error('Thumbnail generation failed:', error); addStatusMessage(`Generation failed: ${error.message}`, 'error'); progressBar.classList.add('bg-pm-error'); progressBar.classList.remove('bg-pm-accent'); } 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'; // Set up Server-Sent Events (same as the settings modal) // The progress route is POST (it generates files), so read it with SseStream like the autotag streams const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(quality)}`, { method: 'POST' }); 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` : ''; const failedText = data.error_count ? `, Failed: ${data.error_count}` : ''; progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped}${failedText} | 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'); } // Clean up eventSource.close(); resolve(resultData); }); // Per-file failures: report them, keep listening eventSource.addEventListener('file_error', (event) => { if (isCancelled()) return; let detail = 'Thumbnail failed'; try { const data = JSON.parse(event.data); detail = `Thumbnail failed: ${data.current_file || data.file || ''} ${data.error || ''}`.trim(); } catch (e) { // non-JSON frame; keep the generic message } addStatusMessage(detail, 'error'); }); // 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-pm-error'); progressBar.classList.remove('bg-pm-accent'); // 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) { return; } addStatusMessage('Connection to server lost', 'error'); progressBar.classList.add('bg-pm-error'); progressBar.classList.remove('bg-pm-accent'); // Clean up eventSource.close(); if (!resultData) { reject(new Error('Connection to server lost during thumbnail generation')); } }; // Stream ended without a complete/error frame (close() does not trigger this) eventSource.onclose = () => { if (isCancelled() || resultData) return; addStatusMessage('Connection to server closed before thumbnail generation finished', 'error'); reject(new Error('Connection to server closed before thumbnail generation finished')); }; }); } // ========================================== // 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 ApiClient.fetchUrl('/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 ApiClient.fetchUrl('/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 => `