created new node, image picker pop up
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user