Fixes issue where PromptManager Gallery showed identical prompts for all
images when using workflows like Flux models that generate multiple images
with the same base workflow.
**Problem:**
- Gallery relied solely on PNG metadata extraction for prompt display
- Images generated from same workflow (e.g., Flux models) contained
identical workflow metadata but should show different prompts
- Navigation between images didn't update prompt information correctly
**Solution:**
1. **Enhanced metadata extraction:**
- Improved parseWorkflowData() to handle more ComfyUI node types
- Added support for PromptManagerText, UNETLoader, DualCLIPLoader
- Better negative prompt detection patterns
- Enhanced sampling parameter extraction
2. **New API endpoint:**
- Added GET /prompt_manager/images/prompt/{image_path}
- Queries database for actual prompt used during generation
- Provides accurate prompt data linked to specific images
3. **Hybrid metadata loading:**
- Primary: Query database for linked prompt data
- Fallback: Extract from PNG metadata if no database record
- Combines database prompts with PNG technical parameters
**Changes:**
- py/api.py: Added get_image_prompt() endpoint and route
- web/gallery.html: Enhanced loadImageMetadata() and parseWorkflowData()
**Result:**
- Gallery now displays correct, unique prompts for each image
- Prompt information updates properly when navigating between images
- Generation data refreshes correctly with prev/next navigation
- Maintains backward compatibility for images without database records
Resolves user-reported issue with Flux.1_Krea_Dev_workflow.json where
all images showed identical prompts in gallery view.
3106 lines
166 KiB
HTML
3106 lines
166 KiB
HTML
<!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;
|
||
}
|
||
}
|
||
|
||
// 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> |