♻️重构:调整预览器设计
This commit is contained in:
@@ -1,4 +1,2 @@
|
||||
# from .toast_box import *
|
||||
# from .dialog_box import *
|
||||
from .image_base64_previewer import *
|
||||
from .image_batch_base64_previewer import *
|
||||
# from .dialog_box import *
|
||||
@@ -1,80 +0,0 @@
|
||||
from ... import register_node
|
||||
from ...utils.format import static_image_formats
|
||||
|
||||
@register_node(emoji="🪲")
|
||||
class ImageBase64Previewer:
|
||||
"""
|
||||
A ComfyUI node that previews base64 encoded images in the frontend.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"base64": ("STRING", {
|
||||
"default": ""
|
||||
}),
|
||||
"format": (static_image_formats, {
|
||||
"default": static_image_formats[0],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("base64",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "EasyToolkit/Debug"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def run(self, base64, format="image/png"):
|
||||
"""
|
||||
Preview base64 encoded image in frontend.
|
||||
"""
|
||||
# Validate base64 string
|
||||
if not base64 or not base64.strip():
|
||||
return {
|
||||
"result": ("",),
|
||||
"ui": {
|
||||
"base64_preview": [{
|
||||
"base64_data": "",
|
||||
"format": format,
|
||||
"error": "Empty base64 image data"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
# Clean the base64 string (remove data:image/... prefix if present)
|
||||
clean_base64 = base64.strip()
|
||||
if clean_base64.startswith("data:image/"):
|
||||
# Extract just the base64 part after the comma
|
||||
parts = clean_base64.split(",", 1)
|
||||
if len(parts) == 2:
|
||||
clean_base64 = parts[1]
|
||||
|
||||
# Validate base64 format
|
||||
if not clean_base64:
|
||||
return {
|
||||
"result": (base64,),
|
||||
"ui": {
|
||||
"base64_preview": [{
|
||||
"base64_data": "",
|
||||
"format": format,
|
||||
"error": "Invalid base64 image format"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
# Return the base64 data for frontend preview
|
||||
return {
|
||||
"result": (base64,),
|
||||
"ui": {
|
||||
"base64_preview": [{
|
||||
"base64_data": clean_base64,
|
||||
"format": format,
|
||||
"error": ""
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
from ... import register_node
|
||||
from ...utils.format import static_image_formats
|
||||
|
||||
@register_node(emoji="🪲")
|
||||
class ImageBatchBase64Previewer:
|
||||
"""
|
||||
A ComfyUI node that previews batch of base64 encoded images in the frontend.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"base64_batch": ("STRING", {
|
||||
"default": ""
|
||||
}),
|
||||
"format": (static_image_formats, {
|
||||
"default": static_image_formats[0],
|
||||
}),
|
||||
"fps": ("FLOAT", {
|
||||
"default": 24.0,
|
||||
"min": 1.0,
|
||||
"max": 120.0,
|
||||
"step": 1.0
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("base64_batch",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "EasyToolkit/Debug"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def run(self, base64_batch, format="image/png", fps=24.0):
|
||||
"""
|
||||
Preview batch of base64 encoded images in frontend.
|
||||
"""
|
||||
# Validate base64 string
|
||||
if not base64_batch or not base64_batch.strip():
|
||||
return {
|
||||
"result": ("",),
|
||||
"ui": {
|
||||
"base64_batch_preview": [{
|
||||
"base64_datas": [],
|
||||
"format": format,
|
||||
"fps": fps,
|
||||
"error": "Empty base64 image data"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
# Parse the base64 list (separated by newlines)
|
||||
base64_items = base64_batch.strip().split("\n")
|
||||
clean_base64_list = []
|
||||
|
||||
for base64_item in base64_items:
|
||||
if not base64_item.strip():
|
||||
continue
|
||||
|
||||
# Clean the base64 string (remove data:image/... prefix if present)
|
||||
clean_base64 = base64_item.strip()
|
||||
if clean_base64.startswith("data:image/"):
|
||||
# Extract just the base64 part after the comma
|
||||
parts = clean_base64.split(",", 1)
|
||||
if len(parts) == 2:
|
||||
clean_base64 = parts[1]
|
||||
|
||||
if clean_base64:
|
||||
clean_base64_list.append(clean_base64)
|
||||
|
||||
# Validate base64 format
|
||||
if not clean_base64_list:
|
||||
return {
|
||||
"result": (base64_batch,),
|
||||
"ui": {
|
||||
"base64_batch_preview": [{
|
||||
"base64_datas": [],
|
||||
"format": format,
|
||||
"fps": fps,
|
||||
"error": "No valid base64 images found"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
# Return the base64 data for frontend preview
|
||||
return {
|
||||
"result": (base64_batch,),
|
||||
"ui": {
|
||||
"base64_batch_preview": [{
|
||||
"base64_datas": clean_base64_list,
|
||||
"format": format,
|
||||
"fps": fps,
|
||||
"error": ""
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
from .image_batch_selector import *
|
||||
from .image_encryptor import *
|
||||
from .image_batch_encryptor import *
|
||||
from .image_batch_encryptor import *
|
||||
from .image_safe_previewer import *
|
||||
@@ -0,0 +1,52 @@
|
||||
from ... import register_node
|
||||
from ...utils.format import static_image_formats
|
||||
from ...utils.image import image_to_bytes
|
||||
from ...utils.encoding import encode_bytes
|
||||
import base64
|
||||
|
||||
@register_node(emoji="🖼️")
|
||||
class ImageSafePreviewer:
|
||||
"""
|
||||
A ComfyUI node that previews images in the frontend by converting them to base64.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"format": (static_image_formats, {
|
||||
"default": static_image_formats[0],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "EasyToolkit/Image"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def run(self, image, format="image/png"):
|
||||
"""
|
||||
Convert image to base64 and preview in frontend.
|
||||
"""
|
||||
# Convert image to bytes
|
||||
image_bytes = image_to_bytes(image, format)
|
||||
|
||||
# Convert bytes to base64
|
||||
base64_data = encode_bytes(image_bytes, "base64")
|
||||
|
||||
# Return the base64 data for frontend preview
|
||||
return {
|
||||
"result": (),
|
||||
"ui": {
|
||||
"base64_preview": [{
|
||||
"base64_data": base64_data,
|
||||
"format": format
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
calculateAvailableSpace,
|
||||
drawImagePreview
|
||||
} from "../preview_utils.js";
|
||||
|
||||
/**
|
||||
* Extension for ImageBatchBase64Previewer node
|
||||
* Displays batch of base64 encoded images directly on the node during workflow execution
|
||||
*/
|
||||
app.registerExtension({
|
||||
name: "EasyToolkit.ImageBatchBase64Previewer",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === "ImageBatchBase64Previewer") {
|
||||
// Add custom node behavior
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
|
||||
this.previewImages = [];
|
||||
this.currentFrame = 0;
|
||||
this.lastFrameTime = 0;
|
||||
this.animationFrameId = null;
|
||||
|
||||
nodeType.prototype.onExecuted = function(output) {
|
||||
// Call original method if it exists
|
||||
if (originalOnExecuted) {
|
||||
originalOnExecuted.call(this, output);
|
||||
}
|
||||
|
||||
const previews = output?.base64_batch_preview;
|
||||
if (previews && previews.length > 0) {
|
||||
const preview = previews[0];
|
||||
|
||||
// Clear previous images and animation
|
||||
this.previewImages = [];
|
||||
this.currentFrame = 0;
|
||||
this.lastFrameTime = 0;
|
||||
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
// Load all images
|
||||
if (preview.base64_datas && preview.base64_datas.length > 0) {
|
||||
let loadedCount = 0;
|
||||
const totalImages = preview.base64_datas.length;
|
||||
|
||||
preview.base64_datas.forEach((base64Data, index) => {
|
||||
const img = new Image();
|
||||
img.src = `data:${preview.format};base64,${base64Data}`;
|
||||
img.onload = () => {
|
||||
this.previewImages[index] = img;
|
||||
loadedCount++;
|
||||
|
||||
// Start animation when all images are loaded
|
||||
if (loadedCount === totalImages && totalImages > 1) {
|
||||
this.startAnimation(preview.fps || 24.0);
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error(`Failed to load image ${index + 1}`);
|
||||
loadedCount++;
|
||||
if (loadedCount === totalImages) {
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start animation for multiple images
|
||||
nodeType.prototype.startAnimation = function(fps) {
|
||||
const frameInterval = 1000 / fps;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
if (!this.lastFrameTime) {
|
||||
this.lastFrameTime = currentTime;
|
||||
}
|
||||
|
||||
const elapsed = currentTime - this.lastFrameTime;
|
||||
|
||||
if (elapsed > frameInterval) {
|
||||
this.currentFrame = (this.currentFrame + 1) % this.previewImages.length;
|
||||
this.lastFrameTime = currentTime;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Override onDrawForeground to draw the preview image(s) on the node
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||||
if (onDrawForeground) {
|
||||
onDrawForeground.apply(this, arguments);
|
||||
}
|
||||
|
||||
if (this.previewImages && this.previewImages.length > 0) {
|
||||
// Calculate available space using utility function
|
||||
const { margin, startY, availableWidth, availableHeight } = calculateAvailableSpace(this);
|
||||
|
||||
// Get current image to display
|
||||
let currentImage;
|
||||
if (this.previewImages.length === 1) {
|
||||
currentImage = this.previewImages[0];
|
||||
} else {
|
||||
currentImage = this.previewImages[this.currentFrame];
|
||||
}
|
||||
|
||||
if (currentImage && currentImage.complete) {
|
||||
// Draw image preview using utility function
|
||||
const { height: drawnHeight } = drawImagePreview(
|
||||
ctx,
|
||||
currentImage,
|
||||
margin,
|
||||
startY,
|
||||
availableWidth,
|
||||
availableHeight
|
||||
);
|
||||
|
||||
// Auto-resize node to fit image if needed
|
||||
const minHeight = startY + drawnHeight + margin;
|
||||
if (this.size[1] < minHeight) {
|
||||
this.size[1] = minHeight;
|
||||
}
|
||||
|
||||
// Show frame info for multiple images
|
||||
if (this.previewImages.length > 1) {
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||||
ctx.fillRect(margin, startY - 20, 80, 18);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(`Frame: ${this.currentFrame + 1}/${this.previewImages.length}`, margin + 5, startY - 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up animation when node is removed
|
||||
const originalOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
if (originalOnRemoved) {
|
||||
originalOnRemoved.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
} from "../preview_utils.js";
|
||||
|
||||
/**
|
||||
* Extension for ImageBase64Previewer node
|
||||
* Extension for ImageSafePreviewer node
|
||||
* Displays base64 encoded images directly on the node during workflow execution
|
||||
*/
|
||||
app.registerExtension({
|
||||
name: "EasyToolkit.ImageBase64Previewer",
|
||||
name: "EasyToolkit.ImageSafePreviewer",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === "ImageBase64Previewer") {
|
||||
if (nodeData.name === "ImageSafePreviewer") {
|
||||
// Add custom node behavior
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
|
||||
@@ -37,19 +37,18 @@ app.registerExtension({
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Override onDrawForeground to draw the preview image on the node
|
||||
// Override onDrawForeground to draw the preview image and resolution text on the node
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||||
if (onDrawForeground) {
|
||||
onDrawForeground.apply(this, arguments);
|
||||
}
|
||||
|
||||
|
||||
if (this.previewImage && this.previewImage.complete) {
|
||||
// Calculate available space using utility function
|
||||
const { margin, startY, availableWidth, availableHeight } = calculateAvailableSpace(this);
|
||||
|
||||
|
||||
// Draw image preview using utility function
|
||||
const { height: drawnHeight } = drawImagePreview(
|
||||
ctx,
|
||||
@@ -59,9 +58,21 @@ app.registerExtension({
|
||||
availableWidth,
|
||||
availableHeight
|
||||
);
|
||||
|
||||
// Auto-resize node to fit image if needed
|
||||
const minHeight = startY + drawnHeight + margin;
|
||||
|
||||
// Draw resolution text below the image
|
||||
const resolutionText = `${this.previewImage.width} × ${this.previewImage.height}`;
|
||||
const textY = startY + drawnHeight + 15;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#888";
|
||||
ctx.font = "12px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(resolutionText, this.size[0] / 2, textY);
|
||||
ctx.restore();
|
||||
|
||||
// Auto-resize node to fit image and resolution text if needed
|
||||
const textHeight = 15; // Additional space for resolution text
|
||||
const minHeight = startY + drawnHeight + margin + textHeight;
|
||||
if (this.size[1] < minHeight) {
|
||||
this.size[1] = minHeight;
|
||||
}
|
||||
Reference in New Issue
Block a user