fix(gallery): resolve prompt display issue when navigating between images

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.
This commit is contained in:
Vito Sansevero
2025-08-11 14:18:13 -07:00
parent 6aa924cbf0
commit a17dcba540
2 changed files with 236 additions and 28 deletions
+83
View File
@@ -317,6 +317,10 @@ class PromptManagerAPI:
@routes.post("/prompt_manager/images/link")
async def link_image_route(request):
return await self.link_image_to_prompt(request)
@routes.get("/prompt_manager/images/prompt/{image_path:.*}")
async def get_image_prompt_route(request):
return await self.get_image_prompt(request)
@routes.delete("/prompt_manager/images/{image_id}")
async def delete_image_route(request):
@@ -2368,6 +2372,85 @@ class PromptManagerAPI:
'error': str(e)
}, status=500)
async def get_image_prompt(self, request):
"""Get prompt information for a specific image path."""
try:
import urllib.parse
import os
import json
from pathlib import Path
# Get the image path from URL
raw_image_path = request.match_info.get('image_path', '')
image_path = urllib.parse.unquote(raw_image_path)
if not image_path:
return web.json_response({
'success': False,
'error': 'Image path is required'
}, status=400)
# Convert relative path to absolute if needed
if not os.path.isabs(image_path):
# If it's a relative path from ComfyUI output, make it absolute
output_dir = self._find_comfyui_output_dir()
if output_dir:
image_path = str(Path(output_dir) / image_path)
# Look up the image in generated_images table
try:
with self.db.model.get_connection() as conn:
cursor = conn.execute(
"""SELECT gi.prompt_id, p.text, p.category, p.tags, p.rating, p.notes,
gi.workflow_data, gi.prompt_metadata, gi.generation_time
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
WHERE gi.image_path = ? OR gi.image_path LIKE ?""",
(image_path, f'%{os.path.basename(image_path)}')
)
result = cursor.fetchone()
if result:
# Convert to dict
prompt_data = {
'prompt_id': result[0],
'text': result[1],
'category': result[2],
'tags': json.loads(result[3]) if result[3] else [],
'rating': result[4],
'notes': result[5],
'workflow_data': json.loads(result[6]) if result[6] else None,
'prompt_metadata': json.loads(result[7]) if result[7] else None,
'generation_time': result[8],
'image_path': image_path
}
return web.json_response({
'success': True,
'prompt': prompt_data
})
else:
# No linked prompt found - this is normal for many images
return web.json_response({
'success': False,
'error': 'No prompt found for this image',
'image_path': image_path
})
except Exception as db_error:
self.logger.error(f"Database error in get_image_prompt: {db_error}")
return web.json_response({
'success': False,
'error': 'Database error occurred'
}, status=500)
except Exception as e:
self.logger.error(f"Get image prompt error: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def delete_image(self, request):
"""Delete an image record."""
try:
+153 -28
View File
@@ -1405,7 +1405,72 @@
async loadImageMetadata(imageUrl, container) {
try {
const metadata = await this.extractImageMetadata(imageUrl);
// 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);
@@ -1894,46 +1959,85 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
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
if (node.class_type === 'CheckpointLoaderSimple' && node.inputs) {
checkpoint = node.inputs.ckpt_name || checkpoint;
// 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;
}
// Prompts - need to identify which is positive vs negative
if (node.class_type === 'PromptManager' && node.inputs && node.inputs.text) {
// PromptManager typically contains the positive prompt
positivePrompt = node.inputs.text;
// 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
});
}
if (node.class_type === 'CLIPTextEncode' && node.inputs && node.inputs.text) {
// Check if this looks like a negative prompt
const text = node.inputs.text.toLowerCase();
if (text.includes('bad anatomy') || text.includes('unfinished') ||
text.includes('censored') || text.includes('weird anatomy') ||
text.includes('negative') || text.includes('embedding:')) {
negativePrompt = node.inputs.text;
} else if (positivePrompt === 'No prompt found') {
// If we haven't found a positive prompt yet, this might be it
// 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;
}
}
// Sampling parameters
if (node.class_type === 'KSampler' && node.inputs) {
steps = node.inputs.steps || steps;
cfgScale = node.inputs.cfg || cfgScale;
sampler = node.inputs.sampler_name || sampler;
seed = node.inputs.seed || seed;
// 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;
}
}
if (node.class_type === 'KSamplerAdvanced' && node.inputs) {
// 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.noise_seed || seed;
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;
}
}
}
}
@@ -1941,23 +2045,44 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// 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) {
if (node.type === 'CheckpointLoaderSimple' && node.widgets_values) {
// 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('censored') || textLower.includes('negative') ||
textLower.includes('worst quality') || textLower.includes('low quality')) {
negativePrompt = text;
} else if (positivePrompt === 'No prompt found') {
positivePrompt = text;
}
}
if ((node.type === 'KSampler' || node.type === 'KSamplerAdvanced') && node.widgets_values) {
// 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;