Files
ComfyAssets-ComfyUI_PromptM…/web/gallery.html
T
Vito Sansevero 6fdfdea89c fix(gallery): enhance metadata parsing for modern ComfyUI workflows
- Add support for new ComfyUI node types in gallery metadata extraction:
  - CFGGuider for CFG scale values
  - BasicScheduler for steps parameter
  - KSamplerSelect for sampler selection
  - RandomNoise/SeedHistory for seed values
- Update both main gallery (/gallery) and metadata viewer (/metadata.html)
- Fix metadata display issues where technical parameters showed as "Unknown"
- Add comprehensive test image and debug tools for validation
- Maintain backward compatibility with legacy KSampler nodes

This resolves metadata parsing issues with modern ComfyUI workflows
that use the newer node architecture for sampling and generation.
2025-08-11 15:15:19 -07:00

3128 lines
167 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Gallery</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
850: '#1f2937',
875: '#1a202c',
925: '#0f172a'
}
}
}
}
}
</script>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #374151;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.viewer-dark-theme .viewer-backdrop {
background-color: #000000 !important;
background: #000000 !important;
}
/* Additional selectors to ensure black background */
.viewer-backdrop {
background-color: #000000 !important;
background: #000000 !important;
}
.viewer-container {
background-color: #000000 !important;
background: #000000 !important;
}
.viewer-canvas {
background-color: #000000 !important;
background: #000000 !important;
}
.viewer-dark-theme .viewer-toolbar {
background-color: rgba(31, 41, 55, 0.95) !important;
}
.viewer-dark-theme .viewer-navbar {
background-color: rgba(31, 41, 55, 0.95) !important;
}
.viewer-dark-theme .viewer-title {
color: #f3f4f6 !important;
}
.image-item {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.image-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.metadata-sidebar {
position: fixed !important;
top: 0 !important;
right: 0 !important;
width: 384px !important;
height: 100vh !important;
background: rgb(17, 24, 39) !important;
border-left: 1px solid rgb(55, 65, 81) !important;
display: flex !important;
flex-direction: column !important;
z-index: 10000 !important;
transition: transform 0.3s ease-in-out !important;
}
.metadata-sidebar.collapsed {
transform: translateX(340px) !important;
}
.metadata-collapse-btn {
position: absolute;
left: -40px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 80px;
background: rgb(17, 24, 39);
border: 1px solid rgb(55, 65, 81);
border-right: none;
border-radius: 6px 0 0 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10001;
}
.metadata-collapse-btn:hover {
background: rgb(31, 41, 55);
}
.metadata-collapse-btn svg {
transition: transform 0.3s ease;
}
.metadata-sidebar.collapsed .metadata-collapse-btn svg {
transform: rotate(180deg);
}
</style>
</head>
<body class="bg-gray-925 text-gray-100 min-h-screen">
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-gradient-to-r from-gray-850 to-gray-800 border-b border-gray-700 px-6 py-4">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-violet-500 to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-xl">🖼️</span>
</div>
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-violet-400 to-purple-400 bg-clip-text text-transparent">
PromptManager Gallery
</h1>
<p class="text-gray-400 text-sm">Browse all images from your ComfyUI output folder</p>
</div>
</div>
<div class="hidden sm:flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<span class="text-green-400 text-sm font-medium">Online</span>
</div>
</div>
</div>
</header>
<!-- Controls -->
<div class="bg-gray-850 border-b border-gray-700/50 px-6 py-4">
<div class="max-w-7xl mx-auto">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center space-x-4">
<label class="text-sm text-gray-400">Show:</label>
<select id="limitSelector" class="px-3 py-1 bg-gray-700 border border-gray-600 rounded text-gray-100 text-sm">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
<span class="text-sm text-gray-400">images</span>
</div>
<div class="flex items-center space-x-2">
<button id="refreshBtn"
class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white text-sm font-medium rounded-lg transition-colors">
🔄 Refresh
</button>
<button id="gridViewBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
⊞ Grid View
</button>
<button id="listViewBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
☰ List View
</button>
<button id="settingsBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
⚙️ Settings
</button>
</div>
</div>
</div>
</div>
<!-- Stats Bar -->
<div class="bg-gray-800 px-6 py-3 border-b border-gray-700/50">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">
Showing <span id="showingStart">0</span> to <span id="showingEnd">0</span> of <span id="totalImages">0</span> images
</span>
<span class="text-gray-400" id="loadingStatus">Loading...</span>
</div>
</div>
</div>
<!-- Gallery Content -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div class="max-w-7xl mx-auto p-6">
<!-- Loading State -->
<div id="loadingState" class="flex items-center justify-center py-20">
<div class="text-center">
<div class="w-12 h-12 border-4 border-violet-500/30 border-t-violet-500 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-400">Loading gallery...</p>
</div>
</div>
<!-- Error State -->
<div id="errorState" class="hidden flex items-center justify-center py-20">
<div class="text-center">
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-3xl">❌</span>
</div>
<h3 class="text-xl font-semibold text-gray-100 mb-2">Error Loading Gallery</h3>
<p class="text-gray-400 mb-4" id="errorMessage">Something went wrong while loading the images.</p>
<button id="retryBtn" class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white font-medium rounded-lg transition-colors">
🔄 Try Again
</button>
</div>
</div>
<!-- Gallery Grid -->
<div id="galleryGrid" class="hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-4">
<!-- Images will be inserted here -->
</div>
<!-- Gallery List -->
<div id="galleryList" class="hidden space-y-3">
<!-- Images will be inserted here -->
</div>
<!-- Pagination -->
<div id="paginationControls" class="hidden mt-8 flex items-center justify-center space-x-2">
<button id="prevPageBtn" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
← Previous
</button>
<div class="flex items-center space-x-2">
<span class="text-gray-400">Page</span>
<input id="pageInput" type="number" min="1" class="w-16 px-2 py-1 bg-gray-700 text-white text-center rounded border border-gray-600 focus:border-blue-500 focus:outline-none" value="1">
<span id="pageInfo" class="text-gray-400">of 1</span>
</div>
<button id="nextPageBtn" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Next →
</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl max-w-2xl w-full max-h-[90vh] overflow-hidden mx-4 border border-gray-700">
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-100">⚙️ Gallery Settings</h3>
<button id="closeSettingsBtn" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-200px)] custom-scrollbar space-y-6">
<!-- Performance Settings -->
<div class="space-y-4">
<h4 class="text-lg font-medium text-gray-100 flex items-center">
<span class="mr-2">🚀</span>
Performance Settings
</h4>
<!-- Thumbnail Generation -->
<div class="bg-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h5 class="text-white font-medium">Generate Thumbnails</h5>
<p class="text-sm text-gray-400">Create optimized thumbnails for faster loading</p>
</div>
<button id="generateThumbnailsBtn" class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white text-sm rounded transition-colors">
Generate
</button>
</div>
<div class="text-xs text-gray-500 mb-2">
Status: <span id="thumbnailStatus">Not generated</span>
<div class="text-xs text-gray-600 mt-1">
💡 Thumbnails speed up gallery loading. Original images always used for viewing and metadata.
</div>
</div>
<!-- Check thumbnails at startup -->
<div class="flex items-center justify-between py-2 border-t border-gray-600">
<div>
<label class="text-sm text-gray-300">Check thumbnails at startup</label>
<p class="text-xs text-gray-500">Prompt to generate thumbnails if missing when opening gallery</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="checkThumbnailsAtStartup" class="sr-only peer" checked>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
</label>
</div>
<!-- Progress bar (hidden by default) -->
<div id="thumbnailProgress" class="hidden">
<div class="flex items-center justify-between text-xs text-gray-400 mb-1">
<span id="progressText">Processing...</span>
<div class="flex items-center gap-2">
<span id="progressPercent">0%</span>
<button id="cancelThumbnailsBtn" class="text-red-400 hover:text-red-300 text-xs px-2 py-1 bg-red-900/20 rounded hidden">
Cancel
</button>
</div>
</div>
<div class="w-full bg-gray-600 rounded-full h-2">
<div id="progressBar" class="bg-violet-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center justify-between text-xs text-gray-500 mt-1">
<span id="progressDetails">Scanning images...</span>
<span id="progressETA" class="text-gray-600"></span>
</div>
</div>
</div>
<!-- Image Loading -->
<div class="bg-gray-700 rounded-lg p-4">
<h5 class="text-white font-medium mb-3">Image Loading</h5>
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Lazy Loading</label>
<input type="checkbox" id="lazyLoadingToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Image Quality</label>
<select id="imageQualitySelect" class="px-3 py-1 bg-gray-600 border border-gray-500 rounded text-gray-100 text-sm">
<option value="high">High (Original)</option>
<option value="medium" selected>Medium (Optimized)</option>
<option value="low">Low (Fast)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Display Settings -->
<div class="space-y-4">
<h4 class="text-lg font-medium text-gray-100 flex items-center">
<span class="mr-2">🖼️</span>
Display Settings
</h4>
<div class="bg-gray-700 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Default View Mode</label>
<select id="defaultViewModeSelect" class="px-3 py-1 bg-gray-600 border border-gray-500 rounded text-gray-100 text-sm">
<option value="grid" selected>Grid View</option>
<option value="list">List View</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Items per Page</label>
<select id="defaultLimitSelect" class="px-3 py-1 bg-gray-600 border border-gray-500 rounded text-gray-100 text-sm">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Grid Columns (Desktop)</label>
<select id="gridColumnsSelect" class="px-3 py-1 bg-gray-600 border border-gray-500 rounded text-gray-100 text-sm">
<option value="4">4 Columns</option>
<option value="6">6 Columns</option>
<option value="8" selected>8 Columns</option>
<option value="10">10 Columns</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Show Media Info</label>
<input type="checkbox" id="showImageInfoToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
</div>
<!-- Video Settings -->
<div class="bg-gray-700 rounded-lg p-4 space-y-3">
<h5 class="text-white font-medium mb-3">Video Settings</h5>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Autoplay Videos</label>
<input type="checkbox" id="videoAutoplayToggle" class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Mute Videos by Default</label>
<input type="checkbox" id="videoMuteToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Loop Videos</label>
<input type="checkbox" id="videoLoopToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
</div>
</div>
<!-- Metadata Settings -->
<div class="space-y-4">
<h4 class="text-lg font-medium text-gray-100 flex items-center">
<span class="mr-2">📋</span>
Metadata Settings
</h4>
<div class="bg-gray-700 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Auto-load Metadata</label>
<input type="checkbox" id="autoLoadMetadataToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Cache Metadata</label>
<input type="checkbox" id="cacheMetadataToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Show File Paths</label>
<input type="checkbox" id="showFilePathsToggle" checked class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
</div>
</div>
<!-- Maintenance -->
<div class="space-y-4">
<h4 class="text-lg font-medium text-gray-100 flex items-center">
<span class="mr-2">🔧</span>
Maintenance
</h4>
<div class="bg-gray-700 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<div>
<h5 class="text-white text-sm font-medium">Clear Cache</h5>
<p class="text-xs text-gray-400">Clear cached metadata and thumbnails</p>
</div>
<button id="clearCacheBtn" class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-sm rounded transition-colors">
Clear
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h5 class="text-white text-sm font-medium">Rescan Output Folder</h5>
<p class="text-xs text-gray-400">Refresh the image list from disk</p>
</div>
<button id="rescanFolderBtn" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors">
Rescan
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h5 class="text-white text-sm font-medium">Find Duplicate Images</h5>
<p class="text-xs text-gray-400">Scan for duplicate images based on file content</p>
</div>
<button id="scanDuplicatesBtn" class="px-3 py-1 bg-orange-600 hover:bg-orange-700 text-white text-sm rounded transition-colors">
Scan
</button>
</div>
</div>
</div>
<!-- Advanced Settings -->
<div class="space-y-4">
<h4 class="text-lg font-medium text-gray-100 flex items-center">
<span class="mr-2">🔬</span>
Advanced
</h4>
<div class="bg-gray-700 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">Debug Mode</label>
<input type="checkbox" id="debugModeToggle" class="w-4 h-4 text-violet-600 bg-gray-600 border-gray-500 rounded focus:ring-violet-500">
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-gray-300">API Timeout (seconds)</label>
<input type="number" id="apiTimeoutInput" value="30" min="5" max="300" class="px-3 py-1 bg-gray-600 border border-gray-500 rounded text-gray-100 text-sm w-20">
</div>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-700 flex justify-end space-x-3">
<button id="resetSettingsBtn" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Reset to Defaults
</button>
<button id="saveSettingsBtn" class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white font-medium rounded-lg transition-colors">
Save Settings
</button>
</div>
</div>
</div>
<!-- Duplicates Modal -->
<div id="duplicatesModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 border border-gray-700">
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-100">🔍 Duplicate Images Found</h3>
<button id="closeDuplicatesBtn" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-200px)] custom-scrollbar">
<div id="duplicatesScanStatus" class="text-center py-8">
<div class="inline-flex items-center px-4 py-2 bg-orange-600 rounded-lg">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Scanning for duplicate images...
</div>
</div>
<div id="duplicatesContent" class="hidden">
<div class="mb-6 p-4 bg-red-900/20 border border-red-500/50 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<div>
<h4 class="text-red-300 font-medium">⚠️ Warning: Removing duplicate images cannot be undone!</h4>
<p class="text-red-200 text-sm mt-1">When you remove a duplicate image, the selected file will be permanently deleted from disk. Choose carefully which images to keep.</p>
</div>
</div>
</div>
<div id="duplicatesList" class="space-y-4">
<!-- Duplicates will be populated here -->
</div>
</div>
</div>
<div id="duplicatesFooter" class="hidden p-6 border-t border-gray-700 flex justify-between items-center">
<div class="text-sm text-gray-400">
<span id="duplicatesCount">0</span> duplicate image groups found
</div>
<div class="flex space-x-3">
<button id="selectAllBtn" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Select All to Delete
</button>
<button id="removeDuplicatesBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Delete Selected
</button>
</div>
</div>
</div>
</div>
<script>
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('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();
}
});
}
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 `
<div class="image-item bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-gray-600 cursor-pointer group">
<div class="aspect-square bg-gray-900 overflow-hidden relative">
<img src="${displayUrl}"
alt="${image.filename}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
data-original="${image.url}"
data-thumbnail="${image.thumbnail_url || ''}"
data-caption="${this.formatImageCaption(image)}"
data-media-type="${mediaType}"
data-is-video="${isVideo}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">
<!-- Fallback for failed thumbnails -->
<div class="hidden absolute inset-0 bg-gray-700 text-gray-400 flex items-center justify-center">
<div class="text-center">
<div class="text-2xl mb-1">${isVideo ? '🎬' : '🖼️'}</div>
<div class="text-xs">Failed to load</div>
</div>
</div>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center">
${isVideo ? `
<div class="w-12 h-12 bg-black bg-opacity-60 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
` : `
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
`}
</div>
${hasThumb ? '<div class="absolute top-2 right-2 w-3 h-3 bg-green-500 rounded-full" title="Thumbnail available"></div>' : ''}
${isVideo ? '<div class="absolute top-2 left-2 px-2 py-1 bg-red-600 text-white text-xs rounded" title="Video file">VIDEO</div>' : ''}
</div>
<div class="p-2">
<div class="text-xs text-gray-400 truncate" title="${image.filename}">${image.filename}</div>
<div class="text-xs text-gray-500">${this.formatFileSize(image.size)}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>
</div>
</div>
`;
}).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 `
<div class="image-item bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 cursor-pointer group flex items-center p-4">
<div class="w-16 h-16 bg-gray-900 rounded-lg overflow-hidden flex-shrink-0 mr-4 relative">
<img src="${displayUrl}"
alt="${image.filename}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
data-original="${image.url}"
data-thumbnail="${image.thumbnail_url || ''}"
data-caption="${this.formatImageCaption(image)}"
data-media-type="${mediaType}"
data-is-video="${isVideo}"
onerror="this.innerHTML='<div class=\\'flex items-center justify-center h-full text-gray-400 text-xs\\'>⚠️</div>'">
${hasThumb ? '<div class="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full" title="Thumbnail available"></div>' : ''}
${isVideo ? '<div class="absolute top-1 left-1 w-4 h-3 bg-red-600 text-white text-xs flex items-center justify-center rounded" title="Video">▶</div>' : ''}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-100 truncate">${image.filename}${isVideo ? ' 🎬' : ''}</div>
<div class="text-xs text-gray-400">${this.formatFileSize(image.size)} • ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>
<div class="text-xs text-gray-500 truncate">${image.relative_path}</div>
</div>
<div class="flex-shrink-0 ml-4">
${isVideo ? `
<svg class="w-5 h-5 text-red-400 group-hover:text-red-300 transition-colors" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
` : `
<svg class="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
`}
</div>
</div>
`;
}).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 = `
<!-- Collapse Button -->
<div class="metadata-collapse-btn" onclick="this.closest('.metadata-sidebar').classList.toggle('collapsed')">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</div>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h1 class="text-lg font-medium text-white">Generation data</h1>
</div>
<button class="text-xs text-blue-400 hover:text-blue-300 transition-colors metadata-copy-all">
📋 COPY ALL
</button>
</div>
<!-- Scrollable Content -->
<div id="metadata-content" class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Loading state -->
<div class="text-center text-gray-500 py-8">
<div class="w-8 h-8 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mx-auto mb-2"></div>
<p class="text-sm">Loading metadata...</p>
</div>
</div>
`;
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 = `
<!-- Image area with navigation -->
<div class="flex-1 flex items-center justify-center relative">
<!-- Navigation arrows -->
<button class="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black bg-opacity-50 hover:bg-opacity-70 rounded-full flex items-center justify-center text-white text-xl z-10" id="prevImageBtn">
‹
</button>
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black bg-opacity-50 hover:bg-opacity-70 rounded-full flex items-center justify-center text-white text-xl z-10" id="nextImageBtn">
›
</button>
<!-- Close button -->
<button class="absolute top-4 right-4 w-10 h-10 bg-black bg-opacity-50 hover:bg-opacity-70 rounded-full flex items-center justify-center text-white text-xl z-10" onclick="this.parentElement.parentElement.remove()">
×
</button>
<!-- Main image -->
<img src="${imageUrl}" alt="${caption}" class="max-w-full max-h-full object-contain" id="modalImage">
</div>
<!-- Metadata sidebar (matching the screenshot) -->
<div class="metadata-sidebar">
<!-- Collapse Button -->
<div class="metadata-collapse-btn" onclick="this.closest('.metadata-sidebar').classList.toggle('collapsed')">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</div>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h1 class="text-lg font-medium text-white">Generation data</h1>
</div>
<button class="text-xs text-blue-400 hover:text-blue-300 transition-colors metadata-copy-all">
📋 COPY ALL
</button>
</div>
<!-- Scrollable Content -->
<div id="metadata-content" class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Loading state -->
<div class="text-center text-gray-500 py-8">
<div class="w-8 h-8 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mx-auto mb-2"></div>
<p class="text-sm">Loading metadata...</p>
</div>
</div>
</div>
`;
// 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 area with navigation -->
<div class="flex-1 flex items-center justify-center relative">
<!-- Navigation arrows -->
<button class="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black bg-opacity-50 hover:bg-opacity-70 rounded-full flex items-center justify-center text-white text-xl z-10" id="prevVideoBtn">
‹
</button>
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black bg-opacity-50 hover:bg-opacity-70 rounded-full flex items-center justify-center text-white text-xl z-10" id="nextVideoBtn">
›
</button>
<!-- Close button -->
<button class="absolute top-4 right-4 w-10 h-10 bg-black bg-opacity-50 hover:bg-opacity-70 rounded-full flex items-center justify-center text-white text-xl z-10" onclick="this.parentElement.parentElement.remove()">
×
</button>
<!-- Video controls -->
<div class="absolute bottom-4 right-4 flex space-x-2 z-10">
<button id="videoMuteBtn" class="px-3 py-2 bg-black bg-opacity-50 hover:bg-opacity-70 rounded text-white text-sm">
🔊
</button>
<button id="videoLoopBtn" class="px-3 py-2 bg-black bg-opacity-50 hover:bg-opacity-70 rounded text-white text-sm">
🔁
</button>
</div>
<!-- Main video -->
<video id="modalVideo"
src="${videoUrl}"
class="max-w-full max-h-full object-contain"
controls
${settings.videoAutoplay ? 'autoplay' : ''}
${settings.videoMute ? 'muted' : ''}
${settings.videoLoop ? 'loop' : ''}>
Your browser does not support the video tag.
</video>
</div>
<!-- Metadata sidebar (videos have limited metadata) -->
<div class="metadata-sidebar">
<!-- Collapse Button -->
<div class="metadata-collapse-btn" onclick="this.closest('.metadata-sidebar').classList.toggle('collapsed')">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</div>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<h1 class="text-lg font-medium text-white">Video Info</h1>
</div>
<button class="text-xs text-blue-400 hover:text-blue-300 transition-colors" onclick="navigator.clipboard.writeText('${videoUrl}').then(() => this.textContent = '✓ Copied!'); setTimeout(() => this.textContent = '📋 COPY URL', 1000)">
📋 COPY URL
</button>
</div>
<!-- Video info content -->
<div id="video-info-content" class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Video details will be loaded here -->
</div>
</div>
`;
// 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 -->
<div class="space-y-2">
<h2 class="text-sm font-medium text-gray-300">File Info</h2>
<div class="text-xs text-gray-400 font-mono bg-gray-800 p-2 rounded break-all cursor-pointer hover:bg-gray-700"
onclick="navigator.clipboard.writeText('${videoUrl}').then(() => this.textContent = 'Copied!'); setTimeout(() => this.textContent = '${fileName}', 1000)"
title="Click to copy path">
${fileName}
</div>
<div class="text-xs text-gray-500">
Type: ${fileExt} Video
</div>
</div>
<!-- Video Properties -->
<div class="space-y-2" id="video-properties">
<h2 class="text-sm font-medium text-gray-300">Video Properties</h2>
<div class="text-xs text-gray-400">
Loading video information...
</div>
</div>
<!-- Video Settings -->
<div class="space-y-3">
<h2 class="text-sm font-medium text-gray-300">Playback Settings</h2>
<div class="space-y-2 text-xs">
<div class="flex justify-between items-center">
<span class="text-gray-400">Autoplay:</span>
<span class="text-gray-200">${this.getSettings().videoAutoplay ? 'Enabled' : 'Disabled'}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-400">Muted:</span>
<span class="text-gray-200">${this.getSettings().videoMute ? 'Yes' : 'No'}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-400">Loop:</span>
<span class="text-gray-200">${this.getSettings().videoLoop ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</div>
<!-- Note about metadata -->
<div class="bg-blue-900/20 border border-blue-600 rounded-lg p-3">
<h4 class="text-blue-400 font-medium mb-1 text-sm">ℹ️ Video Note</h4>
<p class="text-gray-300 text-xs">
Videos don't contain the same metadata as PNG images. ComfyUI workflow data is only embedded in PNG outputs.
</p>
</div>
`;
// 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 = `
<h2 class="text-sm font-medium text-gray-300">Video Properties</h2>
<div class="space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-400">Duration:</span>
<span class="text-gray-200">${this.formatDuration(duration)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Resolution:</span>
<span class="text-gray-200">${width} × ${height}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Aspect Ratio:</span>
<span class="text-gray-200">${(width/height).toFixed(2)}:1</span>
</div>
</div>
`;
}
});
}
} catch (error) {
console.error('Error loading video info:', error);
container.innerHTML = `
<div class="bg-red-900/20 border border-red-600 rounded-lg p-4">
<h4 class="text-red-400 font-medium mb-2">❌ Video Info Error</h4>
<p class="text-gray-300 text-sm">${error.message}</p>
</div>
`;
}
}
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 = `
<div class="bg-red-900/20 border border-red-600 rounded-lg p-4">
<h4 class="text-red-400 font-medium mb-2">❌ Metadata Error</h4>
<p class="text-gray-300 text-sm">${error.message}</p>
</div>
`;
}
}
displayMetadata(metadata, container) {
if (!metadata) {
container.innerHTML = `
<div class="text-center text-gray-500 py-8">
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="text-sm">No metadata found in this image</p>
</div>
`;
return;
}
const html = `
<!-- File Path -->
<div class="space-y-2">
<h2 class="text-sm font-medium text-gray-300">File Path</h2>
<div class="text-xs text-gray-400 font-mono bg-gray-800 p-2 rounded break-all cursor-pointer hover:bg-gray-700" data-copy-path title="Click to copy">
${metadata.imagePath || 'Unknown'}
</div>
</div>
<!-- Resources used -->
<div class="space-y-3">
<h2 class="text-sm font-medium text-gray-300">Resources used</h2>
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-400">Model:</span>
<span class="text-gray-200">${metadata.checkpoint || 'Unknown'}</span>
</div>
</div>
</div>
<!-- Prompt Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-300">Prompt</h2>
<button class="text-xs px-2 py-1 bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors" data-copy-type="positive">
COPY PROMPT
</button>
</div>
<div class="text-xs text-gray-200 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto break-words leading-relaxed">
${this.formatPromptText(metadata.positivePrompt)}
<div class="mt-2 pt-2 border-t border-gray-700">
<button class="text-blue-400 hover:text-blue-300 underline text-xs" data-show-type="positive">
Show more
</button>
</div>
</div>
</div>
<!-- Negative Prompt -->
<div class="space-y-3">
<h2 class="text-sm font-medium text-gray-300">Negative prompt</h2>
<div class="text-xs text-gray-200 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto break-words leading-relaxed">
${this.formatPromptText(metadata.negativePrompt)}
<div class="mt-2 pt-2 border-t border-gray-700">
<button class="text-blue-400 hover:text-blue-300 underline text-xs" data-show-type="negative">
Show more
</button>
</div>
</div>
</div>
<!-- Other metadata -->
<div class="space-y-3">
<h2 class="text-sm font-medium text-gray-300">Other metadata</h2>
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-400">CFG SCALE:</span>
<span class="text-gray-200 px-2 py-1 bg-gray-700 rounded text-xs">${metadata.cfgScale || 'Unknown'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">STEPS:</span>
<span class="text-gray-200 px-2 py-1 bg-gray-700 rounded text-xs">${metadata.steps || 'Unknown'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">SAMPLER:</span>
<span class="text-gray-200 px-2 py-1 bg-gray-700 rounded text-xs">${metadata.sampler || 'Unknown'}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">SEED:</span>
<span class="text-gray-200 font-mono px-2 py-1 bg-gray-700 rounded text-xs">${metadata.seed || 'Unknown'}</span>
</div>
</div>
</div>
<!-- ComfyUI Workflow -->
${metadata.workflow ? `
<div class="space-y-3">
<h2 class="text-sm font-medium text-gray-300">ComfyUI Workflow</h2>
<div class="space-y-2">
<button class="text-xs text-blue-400 hover:text-blue-300 underline flex items-center gap-1" data-action="show-workflow">
<span>View Raw Workflow JSON</span>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
<button class="text-xs text-blue-400 hover:text-blue-300 underline flex items-center gap-1" data-action="download-workflow">
<span>Download Workflow</span>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</button>
</div>
</div>
` : ''}
`;
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 = `
<div class="text-center text-gray-500 py-8">
<div class="w-8 h-8 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mx-auto mb-2"></div>
<p class="text-sm">Loading metadata from original image...</p>
</div>
`;
// 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 newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</h2>
<div style="background: #222; padding: 15px; border-radius: 5px; white-space: pre-wrap; line-height: 1.5;">${prompt}</div>
<button onclick="navigator.clipboard.writeText(\`${prompt.replace(/`/g, '\\`')}\`).then(() => alert('Copied!'))" style="margin-top: 20px; padding: 10px 20px; background: #444; color: #fff; border: none; border-radius: 5px; cursor: pointer;">Copy to Clipboard</button>
</body>
</html>
`);
}
}
showWorkflowData() {
if (this.currentMetadata && this.currentMetadata.workflow) {
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>ComfyUI Workflow Data</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>ComfyUI Workflow JSON</h2>
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(this.currentMetadata.workflow, null, 2)}</pre>
</body>
</html>
`);
}
}
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 `<span class="text-gray-500 italic">${text || 'No prompt found'}</span>`;
}
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;
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];
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');
btn.disabled = true;
btn.textContent = 'Generating...';
status.textContent = 'Initializing...';
progressContainer.classList.remove('hidden');
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);
status.textContent = 'Generation failed';
progressText.textContent = 'Failed';
progressDetails.textContent = error.message;
progressBar.style.width = '0%';
progressBar.classList.add('bg-red-600');
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');
// Set up Server-Sent Events
const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${options.quality}`);
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...';
progressDetails.textContent = data.phase === 'scanning' ? 'Scanning output folder...' : 'Preparing...';
});
// Handle start event
eventSource.addEventListener('start', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
progressText.textContent = `Processing ${data.total_images} images...`;
progressDetails.textContent = data.message || `Found ${data.total_images} images 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
progressText.textContent = `Processing... (${data.processed}/${data.total_images})`;
progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped} | Rate: ${data.rate} img/s`;
// 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 = '';
}
});
// 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!';
progressDetails.textContent = resultData.message || `Generated ${resultData.count} new thumbnails`;
progressETA.textContent = 'Done';
// 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);
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
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 = '<div class="text-center py-8 text-gray-400">No duplicate images found! 🎉</div>';
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 = `
<div class="mb-3 text-sm font-medium text-gray-300">
Duplicate Group ${groupIndex + 1} (${group.images.length} identical ${group.images[0].media_type}s)
</div>
<div class="mb-3 p-3 bg-gray-800 rounded text-sm text-gray-300">
<strong>Content Hash:</strong> ${group.hash.substring(0, 16)}...
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
${group.images.map((image, imageIndex) => `
<div class="flex items-start p-3 bg-gray-800 rounded border border-gray-600 ${imageIndex === 0 ? 'border-green-500' : ''}">
<div class="flex-shrink-0 mr-3">
${image.media_type === 'video' ? `
<div class="w-20 h-20 bg-gray-600 rounded flex items-center justify-center relative">
${image.thumbnail_url ? `
<img src="${image.thumbnail_url}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
` : `
<svg class="w-8 h-8 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h6l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
</svg>
`}
<div class="absolute bottom-0 right-0 bg-black bg-opacity-75 text-white text-xs px-1 rounded">VIDEO</div>
</div>
` : `
<img src="${image.thumbnail_url || image.url}" alt="${image.filename}"
class="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80"
onclick="this.parentElement.parentElement.querySelector('.image-preview').click()">
`}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-300 mb-2">
<div class="font-medium truncate" title="${image.filename}">${image.filename}</div>
<div class="text-xs text-gray-400 mt-1">
<span class="font-medium">Size:</span> ${this.formatFileSize(image.size)} •
<span class="font-medium">Modified:</span> ${new Date(image.modified_time * 1000).toLocaleDateString()}
</div>
<div class="text-xs text-gray-500 mt-1 truncate" title="${image.relative_path}">
${image.relative_path}
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button class="image-preview text-xs bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded"
onclick="window.open('${image.url}', '_blank')">
View Full
</button>
</div>
<div class="flex items-center">
${imageIndex === 0 ? `
<span class="px-3 py-1 bg-green-600 text-white text-xs rounded">KEEP (Oldest)</span>
` : `
<label class="flex items-center">
<input type="checkbox" class="duplicate-checkbox" data-group="${groupIndex}" data-image-path="${image.path}"
class="w-4 h-4 text-red-600 bg-gray-600 border-gray-500 rounded focus:ring-red-500">
<span class="ml-2 text-sm text-red-300">Delete</span>
</label>
`}
</div>
</div>
</div>
</div>
`).join('')}
</div>
`;
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 = `
<div class="bg-gray-800 rounded-xl max-w-md w-full mx-4 border border-gray-700">
<div class="p-6">
<h3 class="text-xl font-semibold text-gray-100 mb-4 flex items-center">
<span class="mr-2">🚀</span>
Generate Thumbnails?
</h3>
<p class="text-gray-300 mb-4">
Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
</p>
<p class="text-sm text-gray-500 mb-6">
💡 Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
</p>
<div class="flex space-x-3">
<button id="thumbnailPromptNo" class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
Not Now
</button>
<button id="thumbnailPromptYes" class="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg transition-colors">
Generate
</button>
</div>
</div>
</div>
`;
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 = `
<div class="p-6">
<h3 class="text-xl font-semibold text-gray-100 mb-4 flex items-center">
<span class="mr-2">🚀</span>
Generating Thumbnails
</h3>
<p class="text-gray-300 mb-4">
Processing ${missingCount} images out of ${imageCount} total...
</p>
<!-- Progress Section -->
<div class="mb-6">
<div class="flex items-center justify-between text-sm text-gray-400 mb-2">
<span id="modalProgressText">Initializing...</span>
<span id="modalProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-600 rounded-full h-3">
<div id="modalProgressBar" class="bg-violet-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center justify-between text-xs text-gray-500 mt-2">
<span id="modalProgressDetails">Starting thumbnail generation...</span>
<span id="modalProgressETA" class="text-gray-600"></span>
</div>
</div>
<!-- Status Messages -->
<div id="modalStatusMessages" class="text-sm text-gray-400 mb-4 max-h-32 overflow-y-auto custom-scrollbar">
<div class="text-violet-400">• Starting thumbnail generation...</div>
</div>
<!-- Buttons -->
<div class="flex space-x-3">
<button id="modalCancelBtn" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
Cancel
</button>
<button id="modalCloseBtn" class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors hidden">
Close
</button>
</div>
</div>
`;
// 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;
addStatusMessage('Starting thumbnail generation...');
// Get quality setting
const quality = this.getSettings().imageQuality || 'medium';
try {
// Start the progress stream
const response = await fetch('/prompt_manager/images/generate-thumbnails/progress', {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let startTime = Date.now();
while (true) {
if (isCancelled()) {
addStatusMessage('Generation cancelled by user', 'warning');
break;
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
let lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line in buffer
for (let line of lines) {
line = line.trim();
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
// Update progress UI
const progress = Math.round(data.progress || 0);
progressPercent.textContent = `${progress}%`;
progressBar.style.width = `${progress}%`;
if (data.status) {
progressText.textContent = data.status;
}
if (data.processed !== undefined && data.found !== undefined) {
progressDetails.textContent = `Processed: ${data.processed}, Generated: ${data.found}`;
}
// Calculate ETA
if (progress > 5) {
const elapsed = (Date.now() - startTime) / 1000;
const estimated = (elapsed / progress) * 100;
const remaining = Math.max(0, estimated - elapsed);
if (remaining > 60) {
progressETA.textContent = `~${Math.round(remaining / 60)}m remaining`;
} else {
progressETA.textContent = `~${Math.round(remaining)}s remaining`;
}
}
// Add status messages for milestones
if (progress === 25) addStatusMessage('25% complete - processing continues...');
if (progress === 50) addStatusMessage('50% complete - halfway there!');
if (progress === 75) addStatusMessage('75% complete - almost done!');
} else if (data.type === 'complete') {
addStatusMessage(`Completed: ${data.generated} new, ${data.skipped} skipped, ${data.total} total`, 'success');
addStatusMessage(`Processing time: ${data.elapsed_time}s`, 'info');
break;
} else if (data.type === 'error') {
throw new Error(data.error || 'Unknown error during generation');
}
} catch (parseError) {
console.debug('Parse error for line:', line, parseError);
}
}
}
}
} catch (error) {
addStatusMessage(`Error: ${error.message}`, 'error');
throw error;
}
}
}
// Initialize gallery when page loads
document.addEventListener('DOMContentLoaded', () => {
new GalleryManager();
});
</script>
</body>
</html>