created new node, image picker pop up

This commit is contained in:
Fill
2025-12-02 23:26:11 -08:00
parent b171999b94
commit ca4a195229
4 changed files with 1165 additions and 1 deletions
+3
View File
@@ -105,6 +105,7 @@ from .nodes.image.FL_ImageBatchToGrid import FL_ImageBatchToGrid
from .nodes.image.FL_ImageNotes import FL_ImageNotes
from .nodes.image.FL_ImageOverlay import FL_ImageOverlay
from .nodes.image.FL_ImageSelector import FL_ImageSelector
from .nodes.image.FL_ImageSelectPicker import FL_ImageSelectPicker
from .nodes.image.FL_ImageSlicer import FL_ImageSlicer
from .nodes.image.FL_Image_AddToBatch import FL_ImageAddToBatch
from .nodes.image.FL_Image_Blank import FL_ImageBlank
@@ -316,6 +317,7 @@ NODE_CLASS_MAPPINGS = {
"FL_Math": FL_Math,
"FL_ImageSlicer": FL_ImageSlicer,
"FL_ImageSelector": FL_ImageSelector,
"FL_ImageSelectPicker": FL_ImageSelectPicker,
"FL_ImageOverlay": FL_ImageOverlay,
"FL_ImageAspectCropper": FL_ImageAspectCropper,
"FL_HF_UploaderAbsolute": FL_HF_UploaderAbsolute,
@@ -504,6 +506,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FL_Math": "FL Math",
"FL_ImageSlicer": "FL Image Slicer",
"FL_ImageSelector": "FL Image Selector",
"FL_ImageSelectPicker": "FL Image Select Picker",
"FL_ImageOverlay": "FL Image Overlay",
"FL_ImageAspectCropper": "FL Image Aspect Cropper",
"FL_HF_UploaderAbsolute": "FL HF Uploader Absolute",
+235
View File
@@ -0,0 +1,235 @@
# FL_ImageSelectPicker: Interactive image selector with modal UI
import os
import io
import base64
import threading
import time
import uuid
import torch
import numpy as np
from PIL import Image
from aiohttp import web
from server import PromptServer
import execution
# Global storage for pending selections
pending_selections = {}
class InterruptProcessing(Exception):
"""Exception to interrupt ComfyUI processing."""
pass
class FL_ImageSelectPicker:
"""
An interactive image selector node that pauses execution and displays
a modal UI for the user to select which images to keep from a batch.
"""
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("selected_images", "selection_info")
FUNCTION = "select_images"
CATEGORY = "🏵️Fill Nodes/Image"
THUMBNAIL_MAX_SIZE = 512 # Max dimension for thumbnails sent to frontend
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"timeout_seconds": ("INT", {"default": 300, "min": 30, "max": 3600, "step": 10}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
def _create_thumbnail(self, pil_img, max_size):
"""Create a thumbnail that fits within max_size while preserving aspect ratio."""
width, height = pil_img.size
# Only resize if larger than max_size
if width <= max_size and height <= max_size:
return pil_img
# Calculate new size preserving aspect ratio
if width > height:
new_width = max_size
new_height = int(height * (max_size / width))
else:
new_height = max_size
new_width = int(width * (max_size / height))
# Use LANCZOS for high-quality downscaling
return pil_img.resize((new_width, new_height), Image.LANCZOS)
def select_images(self, images, timeout_seconds, unique_id):
batch_size = images.shape[0]
if batch_size == 0:
return (images, "No images in batch")
# Generate a session ID for this selection
session_id = f"{unique_id}_{uuid.uuid4().hex[:8]}"
# Convert images to thumbnails for sending to frontend (faster loading)
# Original full-res images are kept in pending_selections for output
image_data = []
print(f"[FL_ImageSelectPicker] Generating thumbnails for {batch_size} images...")
for i in range(batch_size):
img_tensor = images[i]
# Convert tensor to PIL
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
pil_img = Image.fromarray(img_np)
# Store original dimensions before thumbnailing
orig_width, orig_height = pil_img.size
# Create thumbnail for frontend display
thumbnail = self._create_thumbnail(pil_img, self.THUMBNAIL_MAX_SIZE)
# Convert thumbnail to base64 (using JPEG for smaller size)
buffered = io.BytesIO()
thumbnail.save(buffered, format="JPEG", quality=85)
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
image_data.append({
"index": i,
"data": f"data:image/jpeg;base64,{img_base64}",
"width": orig_width, # Report original dimensions
"height": orig_height # so user knows actual size
})
# Create event to block on
event = threading.Event()
pending_selections[session_id] = {
"event": event,
"selection": None,
"cancelled": False,
"images": images
}
# Send message to frontend to show selector
PromptServer.instance.send_sync("fl_image_selector_show", {
"session_id": session_id,
"images": image_data,
"batch_size": batch_size,
"timeout_seconds": timeout_seconds
})
print(f"[FL_ImageSelectPicker] Waiting for user selection... (timeout: {timeout_seconds}s)")
# Block until user responds or timeout
event_set = event.wait(timeout=timeout_seconds)
# Get result
result = pending_selections.get(session_id, {})
selection = result.get("selection")
cancelled = result.get("cancelled", False)
# Clean up
if session_id in pending_selections:
del pending_selections[session_id]
if not event_set:
# Timeout - cancel the execution
print(f"[FL_ImageSelectPicker] Timeout reached. Cancelling execution...")
nodes = execution.nodes
if hasattr(nodes, 'interrupt_processing'):
nodes.interrupt_processing()
raise InterruptProcessing("Image selection timed out")
if cancelled:
# User cancelled - interrupt the execution
print(f"[FL_ImageSelectPicker] Selection cancelled by user. Interrupting execution...")
# Set the interrupt flag in ComfyUI's execution module
nodes = execution.nodes
if hasattr(nodes, 'interrupt_processing'):
nodes.interrupt_processing()
# Raise an exception to stop this node's execution
raise InterruptProcessing("Image selection was cancelled by user")
if selection is None or len(selection) == 0:
# No selection - return all
print(f"[FL_ImageSelectPicker] No images selected. Returning all {batch_size} images.")
return (images, f"No selection: returned all {batch_size} images")
# Filter to selected indices
selected_indices = sorted(selection)
selected_images = images[selected_indices]
info = f"Selected {len(selected_indices)} of {batch_size} images: indices {selected_indices}"
print(f"[FL_ImageSelectPicker] {info}")
return (selected_images, info)
# API endpoint to receive selection from frontend
@PromptServer.instance.routes.post("/fl_image_select_picker/select")
async def receive_selection(request):
try:
data = await request.json()
session_id = data.get("session_id")
selection = data.get("selection", []) # List of indices
cancelled = data.get("cancelled", False)
if session_id in pending_selections:
pending_selections[session_id]["selection"] = selection
pending_selections[session_id]["cancelled"] = cancelled
pending_selections[session_id]["event"].set() # Unblock the waiting thread
return web.json_response({"status": "ok", "received": len(selection)})
else:
return web.json_response({"status": "error", "message": "Session not found"}, status=404)
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
# API endpoint to check if a session is still active (for frontend to detect if node was interrupted)
@PromptServer.instance.routes.get("/fl_image_select_picker/status/{session_id}")
async def check_status(request):
session_id = request.match_info.get("session_id")
if session_id in pending_selections:
return web.json_response({"status": "active"})
else:
return web.json_response({"status": "inactive"})
# API endpoint to fetch full-res image on demand for preview
@PromptServer.instance.routes.get("/fl_image_select_picker/full_image/{session_id}/{index}")
async def get_full_image(request):
try:
session_id = request.match_info.get("session_id")
index = int(request.match_info.get("index"))
if session_id not in pending_selections:
return web.json_response({"status": "error", "message": "Session not found"}, status=404)
images = pending_selections[session_id].get("images")
if images is None:
return web.json_response({"status": "error", "message": "Images not found"}, status=404)
batch_size = images.shape[0]
if index < 0 or index >= batch_size:
return web.json_response({"status": "error", "message": f"Invalid index {index}"}, status=400)
# Convert the requested image to full-res base64
img_tensor = images[index]
img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
pil_img = Image.fromarray(img_np)
# Convert to PNG for full quality
buffered = io.BytesIO()
pil_img.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
return web.json_response({
"status": "ok",
"index": index,
"data": f"data:image/png;base64,{img_base64}",
"width": pil_img.width,
"height": pil_img.height
})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui_fill-nodes"
description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection."
version = "2.1.9"
version = "2.2.0"
license = "LICENSE"
dependencies = ["diffusers", "librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"]
+926
View File
@@ -0,0 +1,926 @@
/**
* FL_ImageSelectPicker.js
* Interactive modal for selecting images from a batch
*/
import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
// Track active modal to prevent duplicates
let activeModal = null;
let countdownInterval = null;
let previewModal = null;
let currentPreviewIndex = -1;
let currentSessionId = null;
let currentBatchSize = 0;
// Listen for the show selector event from the backend
api.addEventListener("fl_image_selector_show", (event) => {
const { session_id, images, batch_size, timeout_seconds } = event.detail;
console.log(`[FL_ImageSelectPicker] Received ${batch_size} images for selection (timeout: ${timeout_seconds}s)`);
showImageSelectorModal(session_id, images, batch_size, timeout_seconds || 300);
});
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function showImageSelectorModal(sessionId, images, batchSize, timeoutSeconds) {
// Close any existing modal
if (activeModal) {
activeModal.remove();
activeModal = null;
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
// Store session info for preview functionality
currentSessionId = sessionId;
currentBatchSize = batchSize;
// Track selected indices
const selectedIndices = new Set();
let remainingSeconds = timeoutSeconds;
// Create modal overlay
const overlay = document.createElement('div');
overlay.className = 'fl-image-picker-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
`;
// Create modal container - takes up most of the screen for better image viewing
const container = document.createElement('div');
container.className = 'fl-image-picker-container';
container.style.cssText = `
background: linear-gradient(145deg, #2d2d2d, #252525);
border-radius: 12px;
border: 1px solid #3a3a3a;
width: 95%;
height: 95%;
max-width: 1900px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
animation: slideIn 0.3s ease-out;
overflow: hidden;
`;
// Create header
const header = document.createElement('div');
header.style.cssText = `
padding: 20px 24px;
border-bottom: 1px solid #404040;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%);
border-radius: 12px 12px 0 0;
`;
const titleContainer = document.createElement('div');
titleContainer.innerHTML = `
<h2 style="margin: 0; color: #fff; font-size: 20px; font-weight: 600;">
Select Images to Keep
</h2>
<p style="margin: 4px 0 0 0; color: #888; font-size: 13px;">
Click images to select/deselect. Selected images will be passed through.
</p>
`;
// Header right side - stats and timer
const headerRight = document.createElement('div');
headerRight.style.cssText = `
display: flex;
align-items: center;
gap: 16px;
`;
const statsDisplay = document.createElement('div');
statsDisplay.id = 'fl-picker-stats';
statsDisplay.style.cssText = `
color: #4ECDC4;
font-size: 16px;
font-weight: 600;
padding: 8px 16px;
background: rgba(78, 205, 196, 0.1);
border-radius: 6px;
border: 1px solid rgba(78, 205, 196, 0.3);
`;
// Timer display
const timerDisplay = document.createElement('div');
timerDisplay.id = 'fl-picker-timer';
timerDisplay.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
background: rgba(255, 193, 7, 0.1);
border-radius: 6px;
border: 1px solid rgba(255, 193, 7, 0.3);
`;
timerDisplay.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#FFC107" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span id="timer-text">${formatTime(remainingSeconds)}</span>
`;
function updateStats() {
statsDisplay.textContent = `${selectedIndices.size} of ${batchSize} selected`;
}
function updateTimer() {
const timerText = timerDisplay.querySelector('#timer-text');
if (timerText) {
timerText.textContent = formatTime(remainingSeconds);
}
// Change color when low on time
if (remainingSeconds <= 30) {
timerDisplay.style.background = 'rgba(217, 83, 79, 0.2)';
timerDisplay.style.borderColor = 'rgba(217, 83, 79, 0.5)';
timerDisplay.style.color = '#d9534f';
const svg = timerDisplay.querySelector('svg');
if (svg) svg.style.stroke = '#d9534f';
} else if (remainingSeconds <= 60) {
timerDisplay.style.background = 'rgba(255, 193, 7, 0.2)';
timerDisplay.style.borderColor = 'rgba(255, 193, 7, 0.5)';
timerDisplay.style.color = '#FFC107';
const svg = timerDisplay.querySelector('svg');
if (svg) svg.style.stroke = '#FFC107';
}
}
updateStats();
updateTimer();
headerRight.appendChild(statsDisplay);
headerRight.appendChild(timerDisplay);
header.appendChild(titleContainer);
header.appendChild(headerRight);
// Start countdown timer
countdownInterval = setInterval(() => {
remainingSeconds--;
updateTimer();
if (remainingSeconds <= 0) {
clearInterval(countdownInterval);
countdownInterval = null;
// Timeout - cancel the job instead of sending images through
sendSelection(sessionId, [], true);
closeModal();
}
}, 1000);
// Create image grid container - uses fixed minimum width to prevent shrinking
const gridContainer = document.createElement('div');
gridContainer.className = 'fl-image-grid';
gridContainer.style.cssText = `
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
align-content: start;
`;
// Create image cards
images.forEach((imgData, index) => {
const card = document.createElement('div');
card.className = 'fl-image-card';
card.dataset.index = index;
card.style.cssText = `
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 3px solid transparent;
background: #1a1a1a;
height: 280px;
`;
// Image element - use object-fit: contain to show full image without cropping
const img = document.createElement('img');
img.src = imgData.data;
img.style.cssText = `
width: 100%;
height: 100%;
object-fit: contain;
display: block;
transition: opacity 0.2s ease;
background: #0a0a0a;
`;
// Index badge
const badge = document.createElement('div');
badge.style.cssText = `
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
`;
badge.textContent = `#${index + 1}`;
// Dimensions badge
const dimBadge = document.createElement('div');
dimBadge.style.cssText = `
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: #888;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
`;
dimBadge.textContent = `${imgData.width}x${imgData.height}`;
// Selection checkmark overlay
const checkOverlay = document.createElement('div');
checkOverlay.className = 'check-overlay';
checkOverlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(78, 205, 196, 0.3);
display: none;
align-items: center;
justify-content: center;
pointer-events: none;
`;
checkOverlay.innerHTML = `
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#4ECDC4" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
// Preview button (magnifying glass) - appears on hover
const previewBtn = document.createElement('button');
previewBtn.className = 'preview-btn';
previewBtn.title = 'View full resolution';
previewBtn.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
width: 36px;
height: 36px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
`;
previewBtn.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
`;
previewBtn.onmouseenter = () => {
previewBtn.style.background = 'rgba(78, 205, 196, 0.8)';
previewBtn.style.borderColor = '#4ECDC4';
};
previewBtn.onmouseleave = () => {
previewBtn.style.background = 'rgba(0, 0, 0, 0.7)';
previewBtn.style.borderColor = 'rgba(255, 255, 255, 0.2)';
};
previewBtn.onclick = (e) => {
e.stopPropagation(); // Don't trigger card selection
showFullResPreview(index, imgData);
};
card.appendChild(img);
card.appendChild(badge);
card.appendChild(dimBadge);
card.appendChild(checkOverlay);
card.appendChild(previewBtn);
// Click handler
card.onclick = () => {
if (selectedIndices.has(index)) {
selectedIndices.delete(index);
card.style.border = '3px solid transparent';
checkOverlay.style.display = 'none';
} else {
selectedIndices.add(index);
card.style.border = '3px solid #4ECDC4';
checkOverlay.style.display = 'flex';
}
updateStats();
};
// Hover effect
card.onmouseenter = () => {
if (!selectedIndices.has(index)) {
card.style.border = '3px solid rgba(78, 205, 196, 0.4)';
}
card.style.transform = 'scale(1.02)';
previewBtn.style.display = 'flex';
};
card.onmouseleave = () => {
if (!selectedIndices.has(index)) {
card.style.border = '3px solid transparent';
}
card.style.transform = 'scale(1)';
previewBtn.style.display = 'none';
};
gridContainer.appendChild(card);
});
// Create footer
const footer = document.createElement('div');
footer.style.cssText = `
padding: 16px 24px;
border-top: 1px solid #404040;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
`;
// Quick action buttons
const quickActions = document.createElement('div');
quickActions.style.cssText = `
display: flex;
gap: 10px;
`;
const selectAllBtn = createButton('Select All', '#555', () => {
images.forEach((_, idx) => {
selectedIndices.add(idx);
const card = gridContainer.querySelector(`[data-index="${idx}"]`);
if (card) {
card.style.border = '3px solid #4ECDC4';
card.querySelector('.check-overlay').style.display = 'flex';
}
});
updateStats();
});
const selectNoneBtn = createButton('Select None', '#555', () => {
selectedIndices.clear();
gridContainer.querySelectorAll('.fl-image-card').forEach(card => {
card.style.border = '3px solid transparent';
card.querySelector('.check-overlay').style.display = 'none';
});
updateStats();
});
const invertBtn = createButton('Invert', '#555', () => {
images.forEach((_, idx) => {
const card = gridContainer.querySelector(`[data-index="${idx}"]`);
if (selectedIndices.has(idx)) {
selectedIndices.delete(idx);
card.style.border = '3px solid transparent';
card.querySelector('.check-overlay').style.display = 'none';
} else {
selectedIndices.add(idx);
card.style.border = '3px solid #4ECDC4';
card.querySelector('.check-overlay').style.display = 'flex';
}
});
updateStats();
});
quickActions.appendChild(selectAllBtn);
quickActions.appendChild(selectNoneBtn);
quickActions.appendChild(invertBtn);
// Main action buttons
const mainActions = document.createElement('div');
mainActions.style.cssText = `
display: flex;
gap: 12px;
`;
const cancelBtn = createButton('Cancel', '#d9534f', async () => {
await sendSelection(sessionId, [], true);
closeModal();
});
const confirmBtn = createButton('Continue with Selected', '#4ECDC4', async () => {
const selection = Array.from(selectedIndices).sort((a, b) => a - b);
await sendSelection(sessionId, selection, false);
closeModal();
}, true);
mainActions.appendChild(cancelBtn);
mainActions.appendChild(confirmBtn);
footer.appendChild(quickActions);
footer.appendChild(mainActions);
// Assemble modal
container.appendChild(header);
container.appendChild(gridContainer);
container.appendChild(footer);
overlay.appendChild(container);
// Add CSS animations
addStyles();
// Add to DOM
document.body.appendChild(overlay);
activeModal = overlay;
// Keyboard shortcuts (only when preview modal is NOT open)
const keyHandler = (e) => {
// Don't handle if preview modal is open - let the preview handler deal with it
if (previewModal) return;
if (e.key === 'Escape') {
sendSelection(sessionId, [], true);
closeModal();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
const selection = Array.from(selectedIndices).sort((a, b) => a - b);
sendSelection(sessionId, selection, false);
closeModal();
}
};
document.addEventListener('keydown', keyHandler);
function closeModal() {
// Clear countdown timer
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
document.removeEventListener('keydown', keyHandler);
if (overlay.parentNode) {
overlay.style.animation = 'fadeIn 0.15s ease-in reverse';
container.style.animation = 'slideIn 0.15s ease-in reverse';
setTimeout(() => {
if (overlay.parentNode) {
overlay.remove();
}
activeModal = null;
}, 150);
}
}
}
function createButton(text, color, onClick, isPrimary = false) {
const btn = document.createElement('button');
btn.textContent = text;
btn.style.cssText = `
padding: ${isPrimary ? '10px 24px' : '8px 16px'};
background: ${isPrimary ? color : 'transparent'};
border: 2px solid ${color};
border-radius: 6px;
color: ${isPrimary ? '#fff' : color};
cursor: pointer;
font-size: ${isPrimary ? '14px' : '13px'};
font-weight: ${isPrimary ? '600' : '500'};
transition: all 0.2s ease;
`;
btn.onmouseenter = () => {
btn.style.background = color;
btn.style.color = '#fff';
btn.style.transform = 'scale(1.02)';
};
btn.onmouseleave = () => {
btn.style.background = isPrimary ? color : 'transparent';
btn.style.color = isPrimary ? '#fff' : color;
btn.style.transform = 'scale(1)';
};
btn.onclick = onClick;
return btn;
}
async function sendSelection(sessionId, selection, cancelled) {
try {
const response = await fetch('/fl_image_select_picker/select', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
session_id: sessionId,
selection: selection,
cancelled: cancelled
})
});
const result = await response.json();
console.log('[FL_ImageSelectPicker] Selection sent:', result);
} catch (error) {
console.error('[FL_ImageSelectPicker] Error sending selection:', error);
}
}
// Full-resolution preview modal
async function showFullResPreview(index, imgData) {
currentPreviewIndex = index;
// Close existing preview if any
if (previewModal) {
previewModal.remove();
previewModal = null;
}
// Create preview overlay
const overlay = document.createElement('div');
overlay.className = 'fl-preview-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
`;
// Create container
const container = document.createElement('div');
container.style.cssText = `
position: relative;
max-width: 95%;
max-height: 95%;
display: flex;
flex-direction: column;
align-items: center;
`;
// Header with image info and close button
const header = document.createElement('div');
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px 8px 0 0;
margin-bottom: -1px;
`;
const infoText = document.createElement('div');
infoText.id = 'preview-info';
infoText.style.cssText = `
color: #fff;
font-size: 14px;
font-weight: 500;
`;
infoText.textContent = `Image #${index + 1} of ${currentBatchSize} - ${imgData.width}x${imgData.height}`;
const closeBtn = document.createElement('button');
closeBtn.style.cssText = `
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
`;
closeBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
closeBtn.onclick = closePreview;
header.appendChild(infoText);
header.appendChild(closeBtn);
// Image container with loading state
const imgContainer = document.createElement('div');
imgContainer.style.cssText = `
position: relative;
background: #0a0a0a;
border-radius: 0 0 8px 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
min-width: 300px;
min-height: 300px;
`;
// Loading spinner
const spinner = document.createElement('div');
spinner.id = 'preview-spinner';
spinner.style.cssText = `
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #888;
`;
spinner.innerHTML = `
<div style="
width: 40px;
height: 40px;
border: 3px solid #333;
border-top-color: #4ECDC4;
border-radius: 50%;
animation: spin 1s linear infinite;
"></div>
<span>Loading full resolution...</span>
`;
// The actual image
const img = document.createElement('img');
img.id = 'preview-image';
img.style.cssText = `
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
opacity: 0;
transition: opacity 0.3s ease;
`;
imgContainer.appendChild(spinner);
imgContainer.appendChild(img);
// Navigation arrows
const leftArrow = document.createElement('button');
leftArrow.className = 'preview-nav-btn';
leftArrow.style.cssText = `
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: 2px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
`;
leftArrow.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
`;
leftArrow.onmouseenter = () => {
leftArrow.style.background = 'rgba(78, 205, 196, 0.6)';
leftArrow.style.borderColor = '#4ECDC4';
};
leftArrow.onmouseleave = () => {
leftArrow.style.background = 'rgba(0, 0, 0, 0.6)';
leftArrow.style.borderColor = 'rgba(255, 255, 255, 0.3)';
};
leftArrow.onclick = () => navigatePreview(-1);
const rightArrow = document.createElement('button');
rightArrow.className = 'preview-nav-btn';
rightArrow.style.cssText = `
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: 2px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
`;
rightArrow.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
`;
rightArrow.onmouseenter = () => {
rightArrow.style.background = 'rgba(78, 205, 196, 0.6)';
rightArrow.style.borderColor = '#4ECDC4';
};
rightArrow.onmouseleave = () => {
rightArrow.style.background = 'rgba(0, 0, 0, 0.6)';
rightArrow.style.borderColor = 'rgba(255, 255, 255, 0.3)';
};
rightArrow.onclick = () => navigatePreview(1);
// Help text
const helpText = document.createElement('div');
helpText.style.cssText = `
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: #666;
font-size: 12px;
background: rgba(0, 0, 0, 0.6);
padding: 8px 16px;
border-radius: 4px;
`;
helpText.textContent = 'Use arrow keys to navigate, Escape to close';
container.appendChild(header);
container.appendChild(imgContainer);
overlay.appendChild(container);
overlay.appendChild(leftArrow);
overlay.appendChild(rightArrow);
overlay.appendChild(helpText);
// Click outside to close
overlay.onclick = (e) => {
if (e.target === overlay) {
closePreview();
}
};
document.body.appendChild(overlay);
previewModal = overlay;
// Load full-res image
await loadFullResImage(index);
}
async function loadFullResImage(index) {
const spinner = document.getElementById('preview-spinner');
const img = document.getElementById('preview-image');
const infoText = document.getElementById('preview-info');
if (!spinner || !img) return;
// Show loading state
spinner.style.display = 'flex';
img.style.opacity = '0';
try {
const response = await fetch(`/fl_image_select_picker/full_image/${currentSessionId}/${index}`);
const data = await response.json();
if (data.status === 'ok') {
img.src = data.data;
img.onload = () => {
spinner.style.display = 'none';
img.style.opacity = '1';
};
if (infoText) {
infoText.textContent = `Image #${index + 1} of ${currentBatchSize} - ${data.width}x${data.height}`;
}
currentPreviewIndex = index;
} else {
spinner.innerHTML = `<span style="color: #d9534f;">Error loading image</span>`;
}
} catch (error) {
console.error('[FL_ImageSelectPicker] Error loading full-res image:', error);
spinner.innerHTML = `<span style="color: #d9534f;">Error loading image</span>`;
}
}
function navigatePreview(direction) {
let newIndex = currentPreviewIndex + direction;
// Wrap around
if (newIndex < 0) {
newIndex = currentBatchSize - 1;
} else if (newIndex >= currentBatchSize) {
newIndex = 0;
}
loadFullResImage(newIndex);
}
function closePreview() {
if (previewModal) {
previewModal.style.animation = 'fadeIn 0.15s ease-in reverse';
setTimeout(() => {
if (previewModal && previewModal.parentNode) {
previewModal.remove();
}
previewModal = null;
currentPreviewIndex = -1;
}, 150);
}
}
// Keyboard handler for preview navigation
document.addEventListener('keydown', (e) => {
if (!previewModal) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
navigatePreview(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
navigatePreview(1);
} else if (e.key === 'Escape') {
e.preventDefault();
closePreview();
}
});
function addStyles() {
if (document.getElementById('fl-image-picker-styles')) return;
const style = document.createElement('style');
style.id = 'fl-image-picker-styles';
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.fl-image-grid {
scrollbar-width: thin;
scrollbar-color: #4ECDC4 #1a1a1a;
}
.fl-image-grid::-webkit-scrollbar {
width: 12px;
}
.fl-image-grid::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 6px;
}
.fl-image-grid::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #4ECDC4 0%, #45B7AA 100%);
border-radius: 6px;
border: 2px solid #1a1a1a;
}
.fl-image-grid::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #5FE0D3 0%, #4ECDC4 100%);
}
.fl-image-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.fl-image-card:hover .preview-btn {
display: flex !important;
}
`;
document.head.appendChild(style);
}
// Register extension (for node-specific enhancements if needed)
app.registerExtension({
name: "FillNodes.ImageSelectPicker",
async nodeCreated(node) {
if (node.comfyClass === "FL_ImageSelectPicker") {
// Add any node-specific UI enhancements here
// For example, we could add a status indicator widget
}
}
});