adding image previews on some nodes, will eventually roll out to all nodes that need the ability

This commit is contained in:
Fill
2025-10-31 20:37:05 -07:00
parent 540095cb8a
commit 0dab601948
8 changed files with 428 additions and 83 deletions
@@ -1,15 +1,14 @@
import os
import re
import io
import base64
import gdown
from PIL import Image
import torch
import numpy as np
import hashlib
import json
from pathlib import Path
import hashlib
import json
from pathlib import Path
from server import PromptServer
class FL_GoogleDriveImageDownloader:
@@ -26,18 +25,23 @@ class FL_GoogleDriveImageDownloader:
"default": True,
"label": "Use Cached Image"
}),
"show_preview": ("BOOLEAN", {
"default": False,
"label": "Show Preview on Node"
}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "download_and_process_image"
OUTPUT_NODE = True
CATEGORY = "🏵️Fill Nodes/Google Drive"
def __init__(self):
self.cache_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cache")
self.cache_index_file = os.path.join(self.cache_dir, "image_cache_index.json")
os.makedirs(self.cache_dir, exist_ok=True)
# Initialize the cache index if it doesn't exist
if not os.path.exists(self.cache_index_file):
with open(self.cache_index_file, "w") as f:
@@ -60,80 +64,94 @@ class FL_GoogleDriveImageDownloader:
try:
with open(self.cache_index_file, "r") as f:
cache_index = json.load(f)
if file_id in cache_index:
cached_path = cache_index[file_id]
if os.path.exists(cached_path):
return cached_path
except Exception as e:
print(f"Cache lookup error: {str(e)}")
return None
def save_to_cache(self, file_id, image_path):
"""Save downloaded image to cache"""
try:
# Generate a unique filename
cache_filename = os.path.join(self.cache_dir, f"{file_id}.png")
# Copy the image to cache
img = Image.open(image_path)
img.save(cache_filename)
# Update the cache index
with open(self.cache_index_file, "r") as f:
cache_index = json.load(f)
cache_index[file_id] = cache_filename
with open(self.cache_index_file, "w") as f:
json.dump(cache_index, f)
return cache_filename
except Exception as e:
print(f"Cache save error: {str(e)}")
return None
def download_and_process_image(self, google_drive_link: str, use_cache: bool = True) -> tuple:
def prepare_image_for_display(self, pil_image):
"""Convert PIL image to base64 for frontend display"""
# Create a copy to avoid modifying the original
display_img = pil_image.copy()
# Resize image if it's too large for preview
max_size = (512, 512)
display_img.thumbnail(max_size, Image.Resampling.LANCZOS)
buffered = io.BytesIO()
display_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
def download_and_process_image(self, google_drive_link: str, use_cache: bool = True, show_preview: bool = True) -> tuple:
try:
# Extract file ID
file_id = self.extract_file_id_from_link(google_drive_link)
image_path = None
temp_path = None # Initialize temp_path to ensure it's always defined
# Check cache first if caching is enabled
if use_cache:
cached_path = self.get_cached_image(file_id)
if cached_path:
print(f"Using cached image for file ID: {file_id}")
image_path = cached_path
# If not in cache or caching disabled, download the image
if not image_path:
# Create temporary directory for download
temp_dir = os.path.join(os.getcwd(), 'temp_downloads')
os.makedirs(temp_dir, exist_ok=True)
# Create download URL
url = f'https://drive.google.com/uc?id={file_id}'
# Download the file
print("Downloading image from Google Drive...")
temp_path = os.path.join(temp_dir, f"temp_image_{file_id}")
output = gdown.download(url=url, output=temp_path, quiet=False, fuzzy=True)
if not output:
raise ValueError("Failed to download image")
image_path = output
# Save to cache if enabled
if use_cache:
cached_path = self.save_to_cache(file_id, image_path)
if cached_path:
image_path = cached_path
# Open and process the image
try:
image = Image.open(image_path)
@@ -142,34 +160,39 @@ class FL_GoogleDriveImageDownloader:
image = image.convert('RGB')
except Exception as e:
raise ValueError(f"Invalid image file: {str(e)}")
# Send image to frontend for preview if enabled
if show_preview:
display_image = self.prepare_image_for_display(image)
PromptServer.instance.send_sync("fl_google_drive_image_downloader", {"image": display_image})
# Convert to the format expected by ComfyUI
image_np = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_np)
image_tensor = image_tensor.unsqueeze(0) # Add batch dimension
# Cleanup temp file if it exists and isn't the cached version
if temp_path and os.path.exists(temp_path) and temp_path != image_path:
os.remove(temp_path)
print(f"Successfully processed image: {image_tensor.shape}")
return (image_tensor,)
except Exception as e:
raise ValueError(f"Error processing image from Google Drive: {str(e)}")
@classmethod
def IS_CHANGED(cls, google_drive_link, use_cache):
def IS_CHANGED(cls, google_drive_link, use_cache, show_preview):
# Only signal a change if use_cache is False
# This ensures the node won't rerun when caching is enabled
if not use_cache:
return float("NaN")
# Otherwise, we should check if the link has changed since last run
# This is done by hashing the link
if not google_drive_link:
return 0
# Simple hash of the link string
link_hash = hashlib.md5(google_drive_link.encode()).hexdigest()
return link_hash
return link_hash
+25 -1
View File
@@ -1,7 +1,10 @@
import torch
import numpy as np
import io
import base64
from PIL import Image, ImageFilter
from ..utils import tensor_to_pil, pil_to_tensor
from server import PromptServer
class FL_ImageOverlay:
@@ -46,16 +49,18 @@ class FL_ImageOverlay:
"invert_mask": ("BOOLEAN", {"default": False}),
"mask_feather": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
"boundary_behavior": (["clip", "extend_canvas"], {"default": "clip"}),
"show_preview": ("BOOLEAN", {"default": False, "label": "Show Preview on Node"}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "overlay_images"
OUTPUT_NODE = True
CATEGORY = "🏵️Fill Nodes/Image"
def overlay_images(self, base_image, overlay_image, mask, x_offset, y_offset,
alignment, resize_overlay, blend_mode, opacity, invert_mask,
mask_feather, boundary_behavior):
mask_feather, boundary_behavior, show_preview=False):
# Process batch - use first image from each batch
base_pil = tensor_to_pil(base_image, batch_index=0)
overlay_pil = tensor_to_pil(overlay_image, batch_index=0)
@@ -101,6 +106,11 @@ class FL_ImageOverlay:
blend_mode, opacity, boundary_behavior
)
# Send preview to frontend if enabled
if show_preview:
display_image = self.prepare_image_for_display(result_pil)
PromptServer.instance.send_sync("fl_image_overlay", {"image": display_image})
# Convert back to tensor
result_tensor = pil_to_tensor(result_pil)
@@ -266,3 +276,17 @@ class FL_ImageOverlay:
# Convert back to PIL
result_np = (result_np * 255).astype(np.uint8)
return Image.fromarray(result_np, mode='RGB')
def prepare_image_for_display(self, pil_image):
"""Convert PIL image to base64 for frontend display"""
# Create a copy to avoid modifying the original
display_img = pil_image.copy()
# Resize image if it's too large for preview
max_size = (512, 512)
display_img.thumbnail(max_size, Image.Resampling.LANCZOS)
buffered = io.BytesIO()
display_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
+36 -2
View File
@@ -3,7 +3,10 @@ import torch.nn.functional as F
from torchvision.ops import masks_to_boxes
from torchvision.transforms.functional import resize as tv_resize, InterpolationMode
import numpy as np
import io
import base64
from PIL import Image
from server import PromptServer
class FL_PasteOnCanvas:
@classmethod
@@ -20,6 +23,7 @@ class FL_PasteOnCanvas:
"resize_algorithm": (["bilinear", "nearest", "bicubic", "lanczos"],),
"include_alpha": ("BOOLEAN", {"default": False}),
"use_full_mask": ("BOOLEAN", {"default": False}),
"show_preview": ("BOOLEAN", {"default": False, "label": "Show Preview on Node"}),
},
"optional": {
"mask": ("IMAGE",),
@@ -29,10 +33,11 @@ class FL_PasteOnCanvas:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "cut_and_paste"
OUTPUT_NODE = True
CATEGORY = "🏵️Fill Nodes/Utility"
def cut_and_paste(self, image, canvas_width, canvas_height, background_red, background_green, background_blue,
padding, resize_algorithm, include_alpha, use_full_mask, mask=None, bg_image_optional=None):
padding, resize_algorithm, include_alpha, use_full_mask, show_preview=True, mask=None, bg_image_optional=None):
# Ensure inputs are in the correct format
image = self.tensor_to_rgba(image)
B, H, W, C = image.shape
@@ -127,6 +132,11 @@ class FL_PasteOnCanvas:
if not include_alpha:
canvas = canvas[..., :3]
# Send preview to frontend if enabled
if show_preview:
display_image = self.prepare_image_for_display(canvas)
PromptServer.instance.send_sync("fl_paste_on_canvas", {"image": display_image})
return (canvas,)
def prepare_background_image(self, bg_image_optional, canvas_width, canvas_height, batch_size):
@@ -175,4 +185,28 @@ class FL_PasteOnCanvas:
return None
if len(tensor.shape) == 4:
return tensor.mean(dim=-1)
return tensor
return tensor
def prepare_image_for_display(self, tensor_image):
"""Convert tensor image to base64 for frontend display"""
# Take the first image from the batch
if tensor_image.shape[0] > 0:
img_tensor = tensor_image[0]
else:
img_tensor = tensor_image
# Convert to numpy and PIL
img_np = (img_tensor.cpu().numpy() * 255).astype('uint8')
pil_image = Image.fromarray(img_np)
# Create a copy to avoid modifying the original
display_img = pil_image.copy()
# Resize image if it's too large for preview
max_size = (512, 512)
display_img.thumbnail(max_size, Image.Resampling.LANCZOS)
buffered = io.BytesIO()
display_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
+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.0.3"
version = "2.0.4"
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,104 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
app.registerExtension({
name: "FL.GoogleDriveImageDownloader",
async nodeCreated(node) {
if (node.comfyClass === "FL_GoogleDriveImageDownloader") {
const MIN_WIDTH = 200;
const MIN_HEIGHT_WITH_PREVIEW = 300;
const MIN_HEIGHT_WITHOUT_PREVIEW = 100;
const PADDING = 10;
// Add image preview
const img = new Image();
img.onload = () => node.setDirtyCanvas(true);
node.onDrawBackground = function(ctx) {
if (!this.flags.collapsed) {
// Get the show_preview widget value
const showPreviewWidget = this.widgets?.find(w => w.name === "show_preview");
const showPreview = showPreviewWidget ? showPreviewWidget.value : false;
// Only draw if preview is enabled and image is loaded
if (!showPreview || !img.src) {
return;
}
const [w, h] = this.size;
// Calculate the Y position of the last widget
const lastWidget = node.widgets[node.widgets.length - 1];
const lastWidgetY = lastWidget.last_y || 0;
// Set the image Y offset to be just below the last widget
const IMAGE_Y_OFFSET = lastWidgetY + 30;
const imageArea = h - IMAGE_Y_OFFSET - PADDING;
// Draw image
if (img.src && imageArea > 50) {
const aspectRatio = img.width / img.height;
let drawWidth = w - 2 * PADDING;
let drawHeight = imageArea;
if (drawWidth / drawHeight > aspectRatio) {
drawWidth = drawHeight * aspectRatio;
} else {
drawHeight = drawWidth / aspectRatio;
}
const x = PADDING + (w - 2 * PADDING - drawWidth) / 2;
const y = IMAGE_Y_OFFSET;
ctx.drawImage(img, x, y, drawWidth, drawHeight);
}
}
};
// Listen for the image from the backend
api.addEventListener("fl_google_drive_image_downloader", (event) => {
if (event.detail.image) {
img.src = event.detail.image;
}
});
function updateNodeSize() {
// Check if preview is enabled
const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview");
const showPreview = showPreviewWidget ? showPreviewWidget.value : false;
// Use different minimum heights based on preview state
const minHeight = showPreview ? MIN_HEIGHT_WITH_PREVIEW : MIN_HEIGHT_WITHOUT_PREVIEW;
node.size[0] = Math.max(MIN_WIDTH, node.size[0]);
node.size[1] = Math.max(minHeight, node.size[1]);
}
node.onResize = updateNodeSize;
updateNodeSize();
// Update size when preview toggle changes
const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function(value) {
if (originalCallback) {
originalCallback.apply(this, arguments);
}
// If toggling preview off, shrink the node height to minimum
if (!value) {
node.size[1] = MIN_HEIGHT_WITHOUT_PREVIEW;
} else {
// If toggling preview on, grow the node height to minimum with preview
node.size[1] = MIN_HEIGHT_WITH_PREVIEW;
}
updateNodeSize();
node.setDirtyCanvas(true);
};
}
}
}
});
+104
View File
@@ -0,0 +1,104 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
app.registerExtension({
name: "FL.ImageOverlay",
async nodeCreated(node) {
if (node.comfyClass === "FL_ImageOverlay") {
const MIN_WIDTH = 250;
const MIN_HEIGHT_WITH_PREVIEW = 700; // Space for 11 widgets + preview
const MIN_HEIGHT_WITHOUT_PREVIEW = 320; // Space for 11 widgets only
const PADDING = 10;
// Add image preview
const img = new Image();
img.onload = () => node.setDirtyCanvas(true);
node.onDrawBackground = function(ctx) {
if (!this.flags.collapsed) {
// Get the show_preview widget value
const showPreviewWidget = this.widgets?.find(w => w.name === "show_preview");
const showPreview = showPreviewWidget ? showPreviewWidget.value : false;
// Only draw if preview is enabled and image is loaded
if (!showPreview || !img.src) {
return;
}
const [w, h] = this.size;
// Calculate the Y position of the last widget
const lastWidget = node.widgets[node.widgets.length - 1];
const lastWidgetY = lastWidget.last_y || 0;
// Set the image Y offset to be just below the last widget
const IMAGE_Y_OFFSET = lastWidgetY + 30;
const imageArea = h - IMAGE_Y_OFFSET - PADDING;
// Draw image
if (img.src && imageArea > 50) {
const aspectRatio = img.width / img.height;
let drawWidth = w - 2 * PADDING;
let drawHeight = imageArea;
if (drawWidth / drawHeight > aspectRatio) {
drawWidth = drawHeight * aspectRatio;
} else {
drawHeight = drawWidth / aspectRatio;
}
const x = PADDING + (w - 2 * PADDING - drawWidth) / 2;
const y = IMAGE_Y_OFFSET;
ctx.drawImage(img, x, y, drawWidth, drawHeight);
}
}
};
// Listen for the image from the backend
api.addEventListener("fl_image_overlay", (event) => {
if (event.detail.image) {
img.src = event.detail.image;
}
});
function updateNodeSize() {
// Check if preview is enabled
const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview");
const showPreview = showPreviewWidget ? showPreviewWidget.value : false;
// Use different minimum heights based on preview state
const minHeight = showPreview ? MIN_HEIGHT_WITH_PREVIEW : MIN_HEIGHT_WITHOUT_PREVIEW;
node.size[0] = Math.max(MIN_WIDTH, node.size[0]);
node.size[1] = Math.max(minHeight, node.size[1]);
}
node.onResize = updateNodeSize;
updateNodeSize();
// Update size when preview toggle changes
const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function(value) {
if (originalCallback) {
originalCallback.apply(this, arguments);
}
// If toggling preview off, shrink the node height to minimum
if (!value) {
node.size[1] = MIN_HEIGHT_WITHOUT_PREVIEW;
} else {
// If toggling preview on, grow the node height to minimum with preview
node.size[1] = MIN_HEIGHT_WITH_PREVIEW;
}
updateNodeSize();
node.setDirtyCanvas(true);
};
}
}
}
});
+104
View File
@@ -0,0 +1,104 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
app.registerExtension({
name: "FL.PasteOnCanvas",
async nodeCreated(node) {
if (node.comfyClass === "FL_PasteOnCanvas") {
const MIN_WIDTH = 250;
const MIN_HEIGHT_WITH_PREVIEW = 650; // Space for 10 widgets + preview
const MIN_HEIGHT_WITHOUT_PREVIEW = 320; // Space for 10 widgets only
const PADDING = 10;
// Add image preview
const img = new Image();
img.onload = () => node.setDirtyCanvas(true);
node.onDrawBackground = function(ctx) {
if (!this.flags.collapsed) {
// Get the show_preview widget value
const showPreviewWidget = this.widgets?.find(w => w.name === "show_preview");
const showPreview = showPreviewWidget ? showPreviewWidget.value : false;
// Only draw if preview is enabled and image is loaded
if (!showPreview || !img.src) {
return;
}
const [w, h] = this.size;
// Calculate the Y position of the last widget
const lastWidget = node.widgets[node.widgets.length - 1];
const lastWidgetY = lastWidget.last_y || 0;
// Set the image Y offset to be just below the last widget
const IMAGE_Y_OFFSET = lastWidgetY + 30;
const imageArea = h - IMAGE_Y_OFFSET - PADDING;
// Draw image
if (img.src && imageArea > 50) {
const aspectRatio = img.width / img.height;
let drawWidth = w - 2 * PADDING;
let drawHeight = imageArea;
if (drawWidth / drawHeight > aspectRatio) {
drawWidth = drawHeight * aspectRatio;
} else {
drawHeight = drawWidth / aspectRatio;
}
const x = PADDING + (w - 2 * PADDING - drawWidth) / 2;
const y = IMAGE_Y_OFFSET;
ctx.drawImage(img, x, y, drawWidth, drawHeight);
}
}
};
// Listen for the image from the backend
api.addEventListener("fl_paste_on_canvas", (event) => {
if (event.detail.image) {
img.src = event.detail.image;
}
});
function updateNodeSize() {
// Check if preview is enabled
const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview");
const showPreview = showPreviewWidget ? showPreviewWidget.value : false;
// Use different minimum heights based on preview state
const minHeight = showPreview ? MIN_HEIGHT_WITH_PREVIEW : MIN_HEIGHT_WITHOUT_PREVIEW;
node.size[0] = Math.max(MIN_WIDTH, node.size[0]);
node.size[1] = Math.max(minHeight, node.size[1]);
}
node.onResize = updateNodeSize;
updateNodeSize();
// Update size when preview toggle changes
const showPreviewWidget = node.widgets?.find(w => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function(value) {
if (originalCallback) {
originalCallback.apply(this, arguments);
}
// If toggling preview off, shrink the node height to minimum
if (!value) {
node.size[1] = MIN_HEIGHT_WITHOUT_PREVIEW;
} else {
// If toggling preview on, grow the node height to minimum with preview
node.size[1] = MIN_HEIGHT_WITH_PREVIEW;
}
updateNodeSize();
node.setDirtyCanvas(true);
};
}
}
}
});
@@ -1,48 +0,0 @@
import { app } from "../../../../scripts/app.js";
app.registerExtension({
name: "Fill-Nodes.PasteOnCanvasAnimatedDisplay",
async nodeCreated(node) {
if (node.comfyClass === "FL_PasteOnCanvas") {
addAnimatedDisplay(node);
}
}
});
function addAnimatedDisplay(node) {
// Increase the node size
node.size = [220, 270];
// Override the onDrawBackground method to add our animated display
node.onDrawBackground = function(ctx) {
// Calculate the current time to animate the balls
const time = Date.now() * 0.001; // Current time in seconds
// Draw moving circles and their mirrored counterparts
for (let i = 0; i < 5; i++) {
const x = 60 + (this.size[0] - 20) * (0.2 + 0.02 * Math.sin(time + i));
const y = -20 + (this.size[1] - 40) * (0.2 + 0.02 * Math.cos(time * 1.5 + i));
const mirrorX = this.size[0] - x; // Mirror X coordinate
// Original set of circles
ctx.beginPath();
ctx.arc(x, y, 7, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${(time * 100 + i * 50) % 360}, 100%, 75%)`;
ctx.fill();
// Mirrored set of circles
ctx.beginPath();
ctx.arc(mirrorX, y, 7, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${(time * 100 + i * 50) % 360}, 100%, 75%)`;
ctx.fill();
}
// Request next frame for continuous animation
node.setDirtyCanvas(true);
requestAnimationFrame(() => node.setDirtyCanvas(true));
};
// Force the initial redraw to start the animation
node.setDirtyCanvas(true);
}