1616 lines
60 KiB
JavaScript
1616 lines
60 KiB
JavaScript
// AdvancedImageComparer.js
|
|
// Implementation based on rgthree's image_comparer.js but simplified and enhanced for batch comparison
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
|
|
console.log("AdvancedImageComparer module loaded");
|
|
|
|
// Cache for rendering optimization
|
|
const CACHE = {
|
|
titleCanvas: null,
|
|
titleCtx: null,
|
|
lastWidth: 0,
|
|
lastHeight: 0,
|
|
lastTime: 0,
|
|
frameCount: 0,
|
|
frameSkip: 2, // Only update animation every X frames
|
|
collapsed: {
|
|
canvas: null,
|
|
ctx: null,
|
|
lastWidth: 0
|
|
}
|
|
};
|
|
|
|
function imageDataToUrl(data) {
|
|
return api.apiURL(`/view?filename=${encodeURIComponent(data.filename)}&type=${data.type || ""}&subfolder=${data.subfolder || ""}${app.getPreviewFormatParam()}${app.getRandParam()}`);
|
|
}
|
|
|
|
/**
|
|
* Draws a custom golden eyeball using canvas drawing commands
|
|
* @param {CanvasRenderingContext2D} ctx - The canvas context
|
|
* @param {number} centerX - X center position
|
|
* @param {number} centerY - Y center position
|
|
* @param {number} size - Size of the eyeball
|
|
* @param {number} shimmerPosition - Position of the shimmer effect (0-1)
|
|
*/
|
|
function drawGoldenEyeball(ctx, centerX, centerY, size, shimmerPosition) {
|
|
// Refined proportions for smaller, cleaner look
|
|
const eyeWidth = size * 1.6;
|
|
const eyeHeight = size * 1.0;
|
|
const irisRadius = size * 0.35;
|
|
const pupilRadius = size * 0.15;
|
|
|
|
ctx.save();
|
|
|
|
// Create base golden gradient exactly like the mathematical formula
|
|
const baseGradient = ctx.createLinearGradient(0, centerY - size*0.7, 0, centerY + size*0.7);
|
|
baseGradient.addColorStop(0, "#B8860B"); // Darker gold
|
|
baseGradient.addColorStop(0.5, "#FFD700"); // Bright gold
|
|
baseGradient.addColorStop(1, "#B8860B"); // Darker gold
|
|
|
|
// Create moving highlight effect (matching gradient_title.js exactly)
|
|
const highlightWidth = eyeWidth * 0.4; // Width of the highlight (same as formula)
|
|
const highlightX = -highlightWidth + (eyeWidth + highlightWidth) * shimmerPosition; // Adjusted range
|
|
|
|
const shimmerGradient = ctx.createLinearGradient(
|
|
centerX + highlightX - highlightWidth/2, 0,
|
|
centerX + highlightX + highlightWidth/2, 0
|
|
);
|
|
|
|
// Create smooth highlight transition (exact same as formula)
|
|
shimmerGradient.addColorStop(0, "rgba(255, 255, 200, 0)");
|
|
shimmerGradient.addColorStop(0.1, "rgba(255, 255, 200, 0)");
|
|
shimmerGradient.addColorStop(0.5, "rgba(255, 255, 200, 0.3)");
|
|
shimmerGradient.addColorStop(0.9, "rgba(255, 255, 200, 0)");
|
|
shimmerGradient.addColorStop(1, "rgba(255, 255, 200, 0)");
|
|
|
|
// Draw etched shadow for all outlines (matching formula style)
|
|
ctx.strokeStyle = "rgba(0,0,0,0.3)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.lineCap = "round";
|
|
|
|
// Shadow for main eye outline
|
|
ctx.beginPath();
|
|
ctx.ellipse(centerX + 2, centerY + 2, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Shadow for iris
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(centerX + 2, centerY + 2, irisRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Shadow for pupil
|
|
ctx.beginPath();
|
|
ctx.arc(centerX + 2, centerY + 2, pupilRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Draw 8 eyelashes/rays around the eye - shadows first
|
|
const rayCount = 8;
|
|
const rayLength = size * 0.7;
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let i = 0; i < rayCount; i++) {
|
|
const angle = (i / rayCount) * Math.PI * 2;
|
|
const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1);
|
|
const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1);
|
|
const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength);
|
|
const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(startX + 2, startY + 2);
|
|
ctx.lineTo(endX + 2, endY + 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Now draw the golden base outlines
|
|
ctx.strokeStyle = baseGradient;
|
|
ctx.lineWidth = 1.5;
|
|
|
|
// Main eye outline
|
|
ctx.beginPath();
|
|
ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Iris outline
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Pupil outline
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Draw 8 eyelashes/rays with base golden color
|
|
for (let i = 0; i < rayCount; i++) {
|
|
const angle = (i / rayCount) * Math.PI * 2;
|
|
const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1);
|
|
const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1);
|
|
const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength);
|
|
const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(startX, startY);
|
|
ctx.lineTo(endX, endY);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Add subtle iris texture lines (fewer for cleaner look)
|
|
ctx.lineWidth = 0.5;
|
|
for (let i = 0; i < 8; i++) {
|
|
const angle = (i / 8) * Math.PI * 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerX + Math.cos(angle) * pupilRadius * 1.1, centerY + Math.sin(angle) * pupilRadius * 1.1);
|
|
ctx.lineTo(centerX + Math.cos(angle) * irisRadius * 0.9, centerY + Math.sin(angle) * irisRadius * 0.9);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Apply shimmer highlight effect to all outlines
|
|
ctx.strokeStyle = shimmerGradient;
|
|
ctx.lineWidth = 1.5;
|
|
|
|
// Shimmer on main eye outline
|
|
ctx.beginPath();
|
|
ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Shimmer on iris
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Shimmer on pupil
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Shimmer on 8 eyelashes/rays
|
|
for (let i = 0; i < rayCount; i++) {
|
|
const angle = (i / rayCount) * Math.PI * 2;
|
|
const startX = centerX + Math.cos(angle) * (eyeWidth/2 + 1);
|
|
const startY = centerY + Math.sin(angle) * (eyeHeight/2 + 1);
|
|
const endX = centerX + Math.cos(angle) * (eyeWidth/2 + rayLength);
|
|
const endY = centerY + Math.sin(angle) * (eyeHeight/2 + rayLength);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(startX, startY);
|
|
ctx.lineTo(endX, endY);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Add outline glow that follows the highlight (matching formula exactly)
|
|
const glowIntensity = Math.max(0, 1 - Math.abs(centerX - (centerX + highlightX))/(eyeWidth/4));
|
|
ctx.shadowColor = `rgba(255, 255, 200, ${glowIntensity * 0.3})`;
|
|
ctx.shadowBlur = 8;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
|
|
// Final glow pass on main outline
|
|
ctx.strokeStyle = baseGradient;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.ellipse(centerX, centerY, eyeWidth/2, eyeHeight/2, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws a gradient background with golden eyeball title
|
|
* @param {LGraphNode} node - The node to apply the gradient to
|
|
* @param {CanvasRenderingContext2D} ctx - The canvas context
|
|
*/
|
|
function drawGradientTitle(node, ctx) {
|
|
// Get title area dimensions
|
|
const titleHeight = node.flags.collapsed ? 20 : 30; // Smaller height when collapsed
|
|
const width = node.flags.collapsed ? 190 : node.size[0]; // Smaller width when collapsed
|
|
const fullHeight = node.size[1]; // Get actual node height
|
|
const eyeballY = node.flags.collapsed ? titleHeight / 2 : 25; // Much closer to top
|
|
|
|
// Eyeball size based on collapsed state (made smaller)
|
|
const eyeballSize = node.flags.collapsed ? 6 : 10;
|
|
|
|
// Update animation frame counter (performance optimization)
|
|
CACHE.frameCount = (CACHE.frameCount + 1) % (CACHE.frameSkip + 1);
|
|
const shouldUpdateAnimation = CACHE.frameCount === 0;
|
|
|
|
// Save current state
|
|
ctx.save();
|
|
|
|
// Reset shadow properties for gradient drawing
|
|
ctx.shadowColor = "transparent";
|
|
ctx.shadowBlur = 0;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
|
|
// Create vertical black gradient for entire background that fills node height
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, fullHeight);
|
|
gradient.addColorStop(0, "#000000"); // Pure black at top
|
|
gradient.addColorStop(0.2, "#101010"); // Transition to very dark gray
|
|
gradient.addColorStop(1, "#101010"); // Very dark gray at bottom
|
|
|
|
// Create smooth shimmer effect for eyeball - only calculate if animation should update
|
|
let shimmerPosition = 0.5; // Default middle position
|
|
if (shouldUpdateAnimation) {
|
|
const time = Date.now() / 3000; // Faster time factor (changed from 4000)
|
|
shimmerPosition = (Math.sin(time) + 1) / 2; // Changed from cos to sin for left-to-right only
|
|
// Store for later use if needed
|
|
CACHE.lastTime = time;
|
|
} else {
|
|
// Reuse last calculation for animation frames we're skipping
|
|
const time = CACHE.lastTime || Date.now() / 3000;
|
|
shimmerPosition = (Math.sin(time) + 1) / 2;
|
|
}
|
|
|
|
// Add collapse button handler
|
|
if (node.flags.collapsed) {
|
|
// If node is collapsed, adjust the title rendering
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, width, titleHeight);
|
|
|
|
// Draw custom golden eyeball for collapsed version
|
|
drawGoldenEyeball(ctx, width / 2, titleHeight / 2, eyeballSize, shimmerPosition);
|
|
|
|
// Skip the rest of the rendering when collapsed
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
// Draw background that fills the entire node
|
|
ctx.fillStyle = gradient;
|
|
|
|
// Use rounded rectangle for the background with rounded corners at the bottom
|
|
if (!node.flags.collapsed) {
|
|
const cornerRadius = 8; // Adjust radius as needed
|
|
|
|
// Create path for rounded rectangle
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0); // Top-left corner (no rounding)
|
|
ctx.lineTo(width, 0); // Top-right corner (no rounding)
|
|
ctx.lineTo(width, fullHeight - cornerRadius); // Right edge before bottom-right corner
|
|
ctx.arcTo(width, fullHeight, width - cornerRadius, fullHeight, cornerRadius); // Bottom-right rounded corner
|
|
ctx.lineTo(cornerRadius, fullHeight); // Bottom edge before bottom-left corner
|
|
ctx.arcTo(0, fullHeight, 0, fullHeight - cornerRadius, cornerRadius); // Bottom-left rounded corner
|
|
ctx.lineTo(0, 0); // Left edge back to top
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
} else {
|
|
// Keep regular rectangle for collapsed state
|
|
ctx.fillRect(0, 0, width, fullHeight); // No extra padding
|
|
}
|
|
|
|
// Draw custom golden eyeball for expanded version
|
|
drawGoldenEyeball(ctx, width / 2, eyeballY, eyeballSize, shimmerPosition);
|
|
|
|
// Restore context state
|
|
ctx.restore();
|
|
}
|
|
|
|
class AdvancedImageComparerWidget {
|
|
constructor(name, node) {
|
|
this.name = name;
|
|
this.type = "custom";
|
|
this.node = node;
|
|
this._value = { images: [] };
|
|
this.selected = [];
|
|
this.imgs = [];
|
|
this.options = { serialize: false };
|
|
this.y = 0;
|
|
this.last_y = 0;
|
|
|
|
// Enhanced batch handling properties
|
|
this.imagesA = [];
|
|
this.imagesB = [];
|
|
this.currentPairIndex = 0;
|
|
this.maxPairs = 0;
|
|
this.animationFrame = null;
|
|
this.autoPlayEnabled = false;
|
|
this.autoPlaySpeed = 2000; // 2 seconds per pair
|
|
|
|
// Batch pagination properties
|
|
this.currentBatchPage = 0;
|
|
this.pairsPerPage = 3; // Show 3 pairs per page in batch mode
|
|
this.maxBatchPages = 0;
|
|
}
|
|
|
|
set value(v) {
|
|
// Process the images from the execution result
|
|
const images = v.images || [];
|
|
|
|
const imagesA = images.filter(img => img.is_image_a);
|
|
const imagesB = images.filter(img => img.is_image_b);
|
|
|
|
// Store all images for batch processing
|
|
this.imagesA = imagesA.map((img, index) => ({
|
|
name: `A${index + 1}`,
|
|
selected: true,
|
|
url: imageDataToUrl(img),
|
|
img: null,
|
|
index: index
|
|
}));
|
|
|
|
this.imagesB = imagesB.map((img, index) => ({
|
|
name: `B${index + 1}`,
|
|
selected: true,
|
|
url: imageDataToUrl(img),
|
|
img: null,
|
|
index: index
|
|
}));
|
|
|
|
// Calculate max pairs for comparison
|
|
this.maxPairs = Math.max(this.imagesA.length, this.imagesB.length);
|
|
this.currentPairIndex = 0;
|
|
|
|
// Calculate batch pagination
|
|
this.maxBatchPages = Math.ceil(this.maxPairs / this.pairsPerPage);
|
|
this.currentBatchPage = 0;
|
|
|
|
// Set the value and update selected pair
|
|
this._value = { images: [...this.imagesA, ...this.imagesB] };
|
|
this.updateSelectedPair();
|
|
|
|
// Load all images for batch modes
|
|
this.loadAllImages();
|
|
|
|
// Update controls when images change
|
|
if (this.node && this.node.updateControlsVisibility) {
|
|
this.node.updateControlsVisibility();
|
|
}
|
|
}
|
|
|
|
get value() {
|
|
return this._value || { images: [] };
|
|
}
|
|
|
|
loadAllImages() {
|
|
const allImages = [...this.imagesA, ...this.imagesB];
|
|
|
|
allImages.forEach(imageData => {
|
|
if (!imageData.img && imageData.url) {
|
|
imageData.img = new Image();
|
|
imageData.img.onload = () => {
|
|
this.node.setDirtyCanvas(true, false);
|
|
};
|
|
imageData.img.onerror = (error) => {
|
|
console.error("[AdvancedImageComparer] Image failed to load:", imageData.name, "URL:", imageData.url, "Error:", error);
|
|
};
|
|
imageData.img.src = imageData.url;
|
|
}
|
|
});
|
|
}
|
|
|
|
setSelected(selected) {
|
|
this.selected = selected;
|
|
this.imgs = [];
|
|
|
|
for (const sel of selected) {
|
|
if (!sel.img && sel.url) {
|
|
sel.img = new Image();
|
|
sel.img.onload = () => {
|
|
this.node.setDirtyCanvas(true, false);
|
|
};
|
|
sel.img.onerror = (error) => {
|
|
console.error("[AdvancedImageComparer] Image failed to load:", sel.name, "URL:", sel.url, "Error:", error);
|
|
};
|
|
sel.img.src = sel.url;
|
|
}
|
|
if (sel.img) {
|
|
this.imgs.push(sel.img);
|
|
}
|
|
}
|
|
}
|
|
|
|
draw(ctx, node, width, y, height) {
|
|
this.y = y;
|
|
this.last_y = y;
|
|
|
|
// Calculate the actual available height for images
|
|
const [nodeWidth, nodeHeight] = node.size;
|
|
const availableHeight = nodeHeight - y - 10;
|
|
|
|
const mode = node.properties?.comparer_mode || "Slider";
|
|
|
|
switch (mode) {
|
|
case "Click":
|
|
this.drawClickMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
case "Side-by-Side":
|
|
this.drawSideBySideMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
case "Stacked":
|
|
this.drawStackedMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
case "Grid":
|
|
this.drawGridMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
case "Carousel":
|
|
this.drawCarouselMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
case "Batch":
|
|
this.drawBatchMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
case "Onion Skin":
|
|
this.drawOnionSkinMode(ctx, y, width, availableHeight);
|
|
break;
|
|
|
|
default: // "Slider"
|
|
this.drawSliderMode(ctx, y, width, availableHeight);
|
|
break;
|
|
}
|
|
|
|
// Draw controls for batch modes (excluding Carousel which uses widgets)
|
|
if (["Grid", "Batch"].includes(mode) && this.maxPairs > 1) {
|
|
this.drawBatchControls(ctx, y, width, availableHeight);
|
|
}
|
|
|
|
// Draw pair indicator for Carousel mode
|
|
if (mode === "Carousel" && this.maxPairs > 1) {
|
|
this.drawPairIndicator(ctx, y + availableHeight - 25, width);
|
|
}
|
|
}
|
|
|
|
drawClickMode(ctx, y, width, availableHeight) {
|
|
const imageIndex = this.node.isPointerDown ? 1 : 0;
|
|
this.drawImage(ctx, this.selected[imageIndex], y, width, availableHeight);
|
|
}
|
|
|
|
drawSideBySideMode(ctx, y, width, availableHeight) {
|
|
if (this.selected[0]) {
|
|
this.drawImageSideBySide(ctx, this.selected[0], y, width, availableHeight, 0);
|
|
}
|
|
if (this.selected[1]) {
|
|
this.drawImageSideBySide(ctx, this.selected[1], y, width, availableHeight, 1);
|
|
}
|
|
}
|
|
|
|
drawStackedMode(ctx, y, width, availableHeight) {
|
|
if (this.selected[0]) {
|
|
this.drawImageStacked(ctx, this.selected[0], y, width, availableHeight, 0);
|
|
}
|
|
if (this.selected[1]) {
|
|
this.drawImageStacked(ctx, this.selected[1], y, width, availableHeight, 1);
|
|
}
|
|
}
|
|
|
|
drawSliderMode(ctx, y, width, availableHeight) {
|
|
if (this.selected[0]) {
|
|
this.drawImage(ctx, this.selected[0], y, width, availableHeight);
|
|
}
|
|
|
|
if (this.selected[1] && this.node.isPointerOver) {
|
|
const cropX = this.node.pointerOverPos[0];
|
|
this.drawImage(ctx, this.selected[1], y, width, availableHeight, cropX);
|
|
}
|
|
}
|
|
|
|
drawGridMode(ctx, y, width, availableHeight) {
|
|
// Calculate grid layout
|
|
const pairs = Math.min(this.maxPairs, 64); // Show max 64 pairs in grid
|
|
const cols = Math.ceil(Math.sqrt(pairs * 2)); // 2 images per pair
|
|
const rows = Math.ceil((pairs * 2) / cols);
|
|
|
|
const cellWidth = width / cols;
|
|
const cellHeight = (availableHeight - 40) / rows; // Reserve space for controls
|
|
|
|
let cellIndex = 0;
|
|
for (let i = 0; i < pairs; i++) {
|
|
const imageA = this.imagesA[i];
|
|
const imageB = this.imagesB[i];
|
|
|
|
if (imageA && imageA.img) {
|
|
const col = cellIndex % cols;
|
|
const row = Math.floor(cellIndex / cols);
|
|
this.drawImageInCell(ctx, imageA, y + row * cellHeight, col * cellWidth, cellWidth, cellHeight, `A${i + 1}`);
|
|
cellIndex++;
|
|
}
|
|
|
|
if (imageB && imageB.img) {
|
|
const col = cellIndex % cols;
|
|
const row = Math.floor(cellIndex / cols);
|
|
this.drawImageInCell(ctx, imageB, y + row * cellHeight, col * cellWidth, cellWidth, cellHeight, `B${i + 1}`);
|
|
cellIndex++;
|
|
}
|
|
}
|
|
}
|
|
|
|
drawCarouselMode(ctx, y, width, availableHeight) {
|
|
const imageA = this.imagesA[this.currentPairIndex];
|
|
const imageB = this.imagesB[this.currentPairIndex];
|
|
|
|
// Draw current pair side by side (use full available height since controls are now widgets)
|
|
if (imageA && imageA.img) {
|
|
this.drawImageSideBySide(ctx, imageA, y, width, availableHeight, 0);
|
|
}
|
|
if (imageB && imageB.img) {
|
|
this.drawImageSideBySide(ctx, imageB, y, width, availableHeight, 1);
|
|
}
|
|
}
|
|
|
|
drawBatchMode(ctx, y, width, availableHeight) {
|
|
const pairHeight = (availableHeight - 40) / this.pairsPerPage; // Reserve space for controls
|
|
const startPairIndex = this.currentBatchPage * this.pairsPerPage;
|
|
const endPairIndex = Math.min(startPairIndex + this.pairsPerPage, this.maxPairs);
|
|
|
|
for (let i = 0; i < this.pairsPerPage; i++) {
|
|
const pairIndex = startPairIndex + i;
|
|
if (pairIndex >= this.maxPairs) break;
|
|
|
|
const imageA = this.imagesA[pairIndex];
|
|
const imageB = this.imagesB[pairIndex];
|
|
const pairY = y + i * pairHeight;
|
|
|
|
if (imageA && imageA.img) {
|
|
this.drawImageInPair(ctx, imageA, pairY, 0, width / 2, pairHeight, 0);
|
|
}
|
|
if (imageB && imageB.img) {
|
|
this.drawImageInPair(ctx, imageB, pairY, width / 2, width / 2, pairHeight, 1);
|
|
}
|
|
|
|
// Draw separator between pairs
|
|
if (i < this.pairsPerPage - 1 && pairIndex < this.maxPairs - 1) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, pairY + pairHeight);
|
|
ctx.lineTo(width, pairY + pairHeight);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.3)";
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
drawImageInCell(ctx, imageData, y, x, cellWidth, cellHeight, label) {
|
|
if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) {
|
|
return;
|
|
}
|
|
|
|
const image = imageData.img;
|
|
const padding = 4;
|
|
const usableWidth = cellWidth - padding * 2;
|
|
const usableHeight = cellHeight - padding * 2;
|
|
|
|
const imageAspect = image.naturalWidth / image.naturalHeight;
|
|
const cellAspect = usableWidth / usableHeight;
|
|
|
|
let targetWidth, targetHeight;
|
|
|
|
if (imageAspect > cellAspect) {
|
|
targetWidth = usableWidth;
|
|
targetHeight = usableWidth / imageAspect;
|
|
} else {
|
|
targetHeight = usableHeight;
|
|
targetWidth = usableHeight * imageAspect;
|
|
}
|
|
|
|
const destX = x + padding + (usableWidth - targetWidth) / 2;
|
|
const destY = y + padding + (usableHeight - targetHeight) / 2;
|
|
|
|
ctx.save();
|
|
|
|
// Draw border
|
|
ctx.strokeStyle = "rgba(255,255,255,0.5)";
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(x + padding, y + padding, usableWidth, usableHeight);
|
|
|
|
// Draw image
|
|
ctx.drawImage(
|
|
image,
|
|
0, 0, image.naturalWidth, image.naturalHeight,
|
|
destX, destY, targetWidth, targetHeight
|
|
);
|
|
|
|
// Draw label
|
|
ctx.fillStyle = "rgba(0,0,0,0.8)";
|
|
ctx.fillRect(destX, destY, 25, 18);
|
|
ctx.fillStyle = "white";
|
|
ctx.font = "11px Arial";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(label, destX + 12, destY + 13);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawImageInPair(ctx, imageData, y, x, pairWidth, pairHeight, imageIndex) {
|
|
if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) {
|
|
return;
|
|
}
|
|
|
|
const image = imageData.img;
|
|
const padding = 2;
|
|
const usableWidth = pairWidth - padding * 2;
|
|
const usableHeight = pairHeight - padding * 2;
|
|
|
|
const imageAspect = image.naturalWidth / image.naturalHeight;
|
|
const pairAspect = usableWidth / usableHeight;
|
|
|
|
let targetWidth, targetHeight;
|
|
|
|
if (imageAspect > pairAspect) {
|
|
targetWidth = usableWidth;
|
|
targetHeight = usableWidth / imageAspect;
|
|
} else {
|
|
targetHeight = usableHeight;
|
|
targetWidth = usableHeight * imageAspect;
|
|
}
|
|
|
|
const destX = x + padding + (usableWidth - targetWidth) / 2;
|
|
const destY = y + padding + (usableHeight - targetHeight) / 2;
|
|
|
|
ctx.save();
|
|
|
|
// Draw image
|
|
ctx.drawImage(
|
|
image,
|
|
0, 0, image.naturalWidth, image.naturalHeight,
|
|
destX, destY, targetWidth, targetHeight
|
|
);
|
|
|
|
// Draw label
|
|
ctx.fillStyle = "rgba(0,0,0,0.8)";
|
|
ctx.fillRect(destX, destY, 25, 18);
|
|
ctx.fillStyle = "white";
|
|
ctx.font = "12px Arial";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(imageData.name, destX + 12, destY + 13);
|
|
|
|
// Draw separator line for side-by-side in batch mode
|
|
if (imageIndex === 0 && pairWidth < this.node.size[0]) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + pairWidth, y);
|
|
ctx.lineTo(x + pairWidth, y + pairHeight);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.5)";
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawBatchControls(ctx, y, width, availableHeight) {
|
|
const controlY = y + availableHeight - 30;
|
|
const mode = this.node.properties?.comparer_mode || "Slider";
|
|
|
|
ctx.save();
|
|
|
|
// Draw control background
|
|
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
|
ctx.fillRect(0, controlY, width, 30);
|
|
|
|
ctx.fillStyle = "white";
|
|
ctx.font = "12px Arial";
|
|
ctx.textAlign = "left";
|
|
|
|
if (mode === "Carousel") {
|
|
// Carousel controls
|
|
const buttonWidth = 60;
|
|
const buttonHeight = 20;
|
|
const buttonY = controlY + 5;
|
|
|
|
// Previous button
|
|
ctx.fillStyle = "rgba(100,100,100,0.8)";
|
|
ctx.fillRect(10, buttonY, buttonWidth, buttonHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText("◀ Prev", 10 + buttonWidth/2, buttonY + 14);
|
|
|
|
// Next button
|
|
ctx.fillStyle = "rgba(100,100,100,0.8)";
|
|
ctx.fillRect(80, buttonY, buttonWidth, buttonHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText("Next ▶", 80 + buttonWidth/2, buttonY + 14);
|
|
|
|
// Auto-play button
|
|
ctx.fillStyle = this.autoPlayEnabled ? "rgba(0,150,0,0.8)" : "rgba(100,100,100,0.8)";
|
|
ctx.fillRect(150, buttonY, buttonWidth, buttonHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText(this.autoPlayEnabled ? "⏸ Pause" : "▶ Play", 150 + buttonWidth/2, buttonY + 14);
|
|
|
|
// Pair indicator
|
|
ctx.textAlign = "right";
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText(`${this.currentPairIndex + 1} / ${this.maxPairs}`, width - 10, buttonY + 14);
|
|
} else {
|
|
// General batch info
|
|
ctx.fillText(`Images: A(${this.imagesA.length}) B(${this.imagesB.length})`, 10, controlY + 18);
|
|
|
|
if (mode === "Grid") {
|
|
ctx.textAlign = "right";
|
|
ctx.fillText(`Showing ${Math.min(this.maxPairs, 64)} pairs`, width - 10, controlY + 18);
|
|
} else if (mode === "Batch") {
|
|
const startPair = this.currentBatchPage * this.pairsPerPage + 1;
|
|
const endPair = Math.min((this.currentBatchPage + 1) * this.pairsPerPage, this.maxPairs);
|
|
ctx.textAlign = "right";
|
|
ctx.fillText(`Showing pairs ${startPair}-${endPair} of ${this.maxPairs}`, width - 10, controlY + 18);
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawPairIndicator(ctx, y, width) {
|
|
ctx.save();
|
|
|
|
const dotSize = 8;
|
|
const dotSpacing = 12;
|
|
const totalWidth = this.maxPairs * dotSpacing - (dotSpacing - dotSize);
|
|
const startX = (width - totalWidth) / 2;
|
|
|
|
for (let i = 0; i < this.maxPairs; i++) {
|
|
const x = startX + i * dotSpacing;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x + dotSize/2, y + dotSize/2, dotSize/2, 0, Math.PI * 2);
|
|
|
|
if (i === this.currentPairIndex) {
|
|
ctx.fillStyle = "rgba(255,255,255,1)";
|
|
} else {
|
|
ctx.fillStyle = "rgba(255,255,255,0.4)";
|
|
}
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawImage(ctx, imageData, y, nodeWidth, availableHeight, cropX) {
|
|
if (!imageData) {
|
|
return;
|
|
}
|
|
|
|
if (!imageData.img) {
|
|
return;
|
|
}
|
|
|
|
if (!imageData.img.naturalWidth || !imageData.img.naturalHeight) {
|
|
return;
|
|
}
|
|
|
|
const image = imageData.img;
|
|
|
|
const imageAspect = image.naturalWidth / image.naturalHeight;
|
|
const widgetAspect = nodeWidth / availableHeight;
|
|
|
|
let targetWidth, targetHeight, offsetX = 0;
|
|
|
|
// Ensure minimum size for visibility
|
|
const minSize = 100;
|
|
const effectiveHeight = Math.max(availableHeight, minSize);
|
|
const effectiveWidth = Math.max(nodeWidth, minSize);
|
|
|
|
if (imageAspect > widgetAspect) {
|
|
targetWidth = effectiveWidth;
|
|
targetHeight = effectiveWidth / imageAspect;
|
|
} else {
|
|
targetHeight = effectiveHeight;
|
|
targetWidth = effectiveHeight * imageAspect;
|
|
offsetX = (nodeWidth - targetWidth) / 2;
|
|
}
|
|
|
|
const widthMultiplier = image.naturalWidth / targetWidth;
|
|
const sourceX = 0;
|
|
const sourceY = 0;
|
|
const sourceWidth = cropX != null ? (cropX - offsetX) * widthMultiplier : image.naturalWidth;
|
|
const sourceHeight = image.naturalHeight;
|
|
|
|
const destX = (nodeWidth - targetWidth) / 2;
|
|
const destY = y + (availableHeight - targetHeight) / 2;
|
|
const destWidth = cropX != null ? cropX - offsetX : targetWidth;
|
|
const destHeight = targetHeight;
|
|
|
|
ctx.save();
|
|
|
|
if (cropX) {
|
|
ctx.beginPath();
|
|
ctx.rect(destX, destY, destWidth, destHeight);
|
|
ctx.clip();
|
|
}
|
|
|
|
ctx.drawImage(
|
|
image,
|
|
sourceX, sourceY, sourceWidth, sourceHeight,
|
|
destX, destY, destWidth, destHeight
|
|
);
|
|
|
|
// Draw slider line
|
|
if (cropX != null && cropX >= (nodeWidth - targetWidth) / 2 && cropX <= targetWidth + offsetX) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(cropX, destY);
|
|
ctx.lineTo(cropX, destY + destHeight);
|
|
ctx.globalCompositeOperation = "difference";
|
|
ctx.strokeStyle = "rgba(255,255,255,1)";
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Navigation methods for carousel mode
|
|
nextPair() {
|
|
if (this.currentPairIndex < this.maxPairs - 1) {
|
|
this.currentPairIndex++;
|
|
this.updateSelectedPair();
|
|
this.node.setDirtyCanvas(true, false);
|
|
}
|
|
}
|
|
|
|
previousPair() {
|
|
if (this.currentPairIndex > 0) {
|
|
this.currentPairIndex--;
|
|
this.updateSelectedPair();
|
|
this.node.setDirtyCanvas(true, false);
|
|
}
|
|
}
|
|
|
|
toggleAutoPlay() {
|
|
this.autoPlayEnabled = !this.autoPlayEnabled;
|
|
|
|
if (this.autoPlayEnabled) {
|
|
this.startAutoPlay();
|
|
} else {
|
|
this.stopAutoPlay();
|
|
}
|
|
|
|
this.updateNodeControls();
|
|
this.node.setDirtyCanvas(true, false);
|
|
}
|
|
|
|
// Update the node's control widgets
|
|
updateNodeControls() {
|
|
if (this.node.pairInfoWidget) {
|
|
this.node.pairInfoWidget.value = `${this.currentPairIndex + 1} / ${this.maxPairs}`;
|
|
}
|
|
if (this.node.autoPlayButton) {
|
|
this.node.autoPlayButton.name = this.autoPlayEnabled ? "⏸ Pause" : "▶ Play";
|
|
}
|
|
if (this.node.batchSelectorWidget) {
|
|
this.node.batchSelectorWidget.value = (this.currentPairIndex + 1).toString();
|
|
}
|
|
if (this.node.batchPageInfoWidget) {
|
|
this.node.batchPageInfoWidget.value = `Page ${this.currentBatchPage + 1} / ${this.maxBatchPages}`;
|
|
}
|
|
}
|
|
|
|
// Update the selected pair for modes that show individual pairs
|
|
updateSelectedPair() {
|
|
const mode = this.node.properties?.comparer_mode || "Slider";
|
|
|
|
// For modes that show individual pairs, update the selected images
|
|
if (["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(mode)) {
|
|
const processedImages = [];
|
|
|
|
if (this.imagesA[this.currentPairIndex]) {
|
|
processedImages.push(this.imagesA[this.currentPairIndex]);
|
|
}
|
|
if (this.imagesB[this.currentPairIndex]) {
|
|
processedImages.push(this.imagesB[this.currentPairIndex]);
|
|
}
|
|
|
|
this.setSelected(processedImages);
|
|
}
|
|
|
|
this.updateNodeControls();
|
|
}
|
|
|
|
startAutoPlay() {
|
|
if (this.animationFrame) {
|
|
clearInterval(this.animationFrame);
|
|
}
|
|
|
|
this.animationFrame = setInterval(() => {
|
|
if (this.currentPairIndex >= this.maxPairs - 1) {
|
|
// Loop back to the beginning
|
|
this.currentPairIndex = 0;
|
|
} else {
|
|
this.currentPairIndex++;
|
|
}
|
|
this.updateSelectedPair();
|
|
this.node.setDirtyCanvas(true, false);
|
|
}, this.autoPlaySpeed);
|
|
}
|
|
|
|
stopAutoPlay() {
|
|
if (this.animationFrame) {
|
|
clearInterval(this.animationFrame);
|
|
this.animationFrame = null;
|
|
}
|
|
}
|
|
|
|
// Batch pagination methods
|
|
nextBatchPage() {
|
|
if (this.currentBatchPage < this.maxBatchPages - 1) {
|
|
this.currentBatchPage++;
|
|
this.updateNodeControls();
|
|
this.node.setDirtyCanvas(true, false);
|
|
}
|
|
}
|
|
|
|
previousBatchPage() {
|
|
if (this.currentBatchPage > 0) {
|
|
this.currentBatchPage--;
|
|
this.updateNodeControls();
|
|
this.node.setDirtyCanvas(true, false);
|
|
}
|
|
}
|
|
|
|
drawImageSideBySide(ctx, imageData, y, nodeWidth, availableHeight, imageIndex) {
|
|
if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) {
|
|
return;
|
|
}
|
|
|
|
const image = imageData.img;
|
|
const halfWidth = nodeWidth / 2;
|
|
const padding = 2; // Small padding between images
|
|
const usableWidth = halfWidth - padding;
|
|
|
|
// Ensure minimum size for visibility
|
|
const minSize = 100;
|
|
const effectiveHeight = Math.max(availableHeight, minSize);
|
|
const effectiveWidth = Math.max(usableWidth, minSize / 2);
|
|
|
|
const imageAspect = image.naturalWidth / image.naturalHeight;
|
|
const widgetAspect = effectiveWidth / effectiveHeight;
|
|
|
|
let targetWidth, targetHeight;
|
|
|
|
if (imageAspect > widgetAspect) {
|
|
targetWidth = effectiveWidth;
|
|
targetHeight = effectiveWidth / imageAspect;
|
|
} else {
|
|
targetHeight = effectiveHeight;
|
|
targetWidth = effectiveHeight * imageAspect;
|
|
}
|
|
|
|
const destX = imageIndex === 0 ?
|
|
(halfWidth - targetWidth) / 2 :
|
|
halfWidth + padding + (usableWidth - targetWidth) / 2;
|
|
const destY = y + (availableHeight - targetHeight) / 2;
|
|
|
|
ctx.save();
|
|
|
|
// Draw image
|
|
ctx.drawImage(
|
|
image,
|
|
0, 0, image.naturalWidth, image.naturalHeight,
|
|
destX, destY, targetWidth, targetHeight
|
|
);
|
|
|
|
// Draw label
|
|
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
|
ctx.fillRect(destX, destY, 20, 20);
|
|
ctx.fillStyle = "white";
|
|
ctx.font = "12px Arial";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(imageData.name, destX + 10, destY + 14);
|
|
|
|
// Draw separator line
|
|
if (imageIndex === 0) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(halfWidth, y);
|
|
ctx.lineTo(halfWidth, y + availableHeight);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.5)";
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawImageStacked(ctx, imageData, y, nodeWidth, availableHeight, imageIndex) {
|
|
if (!imageData || !imageData.img || !imageData.img.naturalWidth || !imageData.img.naturalHeight) {
|
|
return;
|
|
}
|
|
|
|
const image = imageData.img;
|
|
const halfHeight = availableHeight / 2;
|
|
const padding = 2; // Small padding between images
|
|
const usableHeight = halfHeight - padding;
|
|
|
|
// Ensure minimum size for visibility
|
|
const minSize = 100;
|
|
const effectiveHeight = Math.max(usableHeight, minSize / 2);
|
|
const effectiveWidth = Math.max(nodeWidth, minSize);
|
|
|
|
const imageAspect = image.naturalWidth / image.naturalHeight;
|
|
const widgetAspect = effectiveWidth / effectiveHeight;
|
|
|
|
let targetWidth, targetHeight;
|
|
|
|
if (imageAspect > widgetAspect) {
|
|
targetWidth = effectiveWidth;
|
|
targetHeight = effectiveWidth / imageAspect;
|
|
} else {
|
|
targetHeight = effectiveHeight;
|
|
targetWidth = effectiveHeight * imageAspect;
|
|
}
|
|
|
|
const destX = (nodeWidth - targetWidth) / 2;
|
|
const destY = imageIndex === 0 ?
|
|
y + (halfHeight - targetHeight) / 2 :
|
|
y + halfHeight + padding + (usableHeight - targetHeight) / 2;
|
|
|
|
ctx.save();
|
|
|
|
// Draw image
|
|
ctx.drawImage(
|
|
image,
|
|
0, 0, image.naturalWidth, image.naturalHeight,
|
|
destX, destY, targetWidth, targetHeight
|
|
);
|
|
|
|
// Draw label
|
|
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
|
ctx.fillRect(destX, destY, 20, 20);
|
|
ctx.fillStyle = "white";
|
|
ctx.font = "12px Arial";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(imageData.name, destX + 10, destY + 14);
|
|
|
|
// Draw separator line
|
|
if (imageIndex === 0) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y + halfHeight);
|
|
ctx.lineTo(nodeWidth, y + halfHeight);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.5)";
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawOnionSkinMode(ctx, y, width, availableHeight) {
|
|
const opacity = this.node.properties?.onionSkinOpacity || 0.5; // Default to 50% opacity
|
|
|
|
if (this.selected[0]) {
|
|
this.drawImage(ctx, this.selected[0], y, width, availableHeight);
|
|
}
|
|
|
|
if (this.selected[1]) {
|
|
ctx.save();
|
|
ctx.globalAlpha = opacity;
|
|
this.drawImage(ctx, this.selected[1], y, width, availableHeight);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
computeSize(width) {
|
|
const mode = this.node?.properties?.comparer_mode || "Slider";
|
|
|
|
// Base height calculation - reduced minimum sizes for better sizing flexibility
|
|
let height = Math.max(150, width * 0.5); // Reduced from 300 and 0.75
|
|
|
|
// Adjust height based on layout mode
|
|
switch (mode) {
|
|
case "Stacked":
|
|
// Stacked mode needs more height to show both images vertically
|
|
height = Math.max(200, width * 0.8); // Reduced from 500 and 1.2
|
|
break;
|
|
case "Side-by-Side":
|
|
// Side-by-side can use standard height but smaller than before
|
|
height = Math.max(120, width * 0.4); // Reduced from 300 and 0.6
|
|
break;
|
|
case "Grid":
|
|
// Grid mode needs more height to show multiple pairs
|
|
const pairs = Math.min(this.maxPairs || 1, 64);
|
|
const cols = Math.ceil(Math.sqrt(pairs * 2));
|
|
const rows = Math.ceil((pairs * 2) / cols);
|
|
height = Math.max(180, (width / cols) * rows + 60); // Reduced from 400 and 80
|
|
break;
|
|
case "Carousel":
|
|
// Carousel mode uses standard height plus controls
|
|
height = Math.max(150, width * 0.4 + 60); // Reduced from 350, 0.6, and 80
|
|
break;
|
|
case "Batch":
|
|
// Batch mode shows multiple pairs vertically
|
|
const visiblePairs = Math.min(this.maxPairs || 1, 3);
|
|
height = Math.max(200, visiblePairs * (width * 0.3) + 60); // Reduced from 500, 0.4, and 80
|
|
break;
|
|
case "Onion Skin":
|
|
height = Math.max(150, width * 0.5); // Reduced from 300 and 0.75
|
|
break;
|
|
default:
|
|
// Slider and Click modes use standard height but smaller
|
|
height = Math.max(150, width * 0.5); // Reduced from 300 and 0.75
|
|
break;
|
|
}
|
|
|
|
return [width, height];
|
|
}
|
|
|
|
mouse(event, pos, node) {
|
|
// Handle mouse events for the widget
|
|
const mode = node.properties?.comparer_mode || "Slider";
|
|
|
|
if (event.type === "pointermove") {
|
|
node.pointerOverPos = [...pos];
|
|
if (mode === "Slider") {
|
|
node.setDirtyCanvas(true, false);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.type === "pointerdown") {
|
|
// Handle clicks on grid cells for selection
|
|
if (mode === "Grid" && this.maxPairs > 1) {
|
|
const pairs = Math.min(this.maxPairs, 64);
|
|
const cols = Math.ceil(Math.sqrt(pairs * 2));
|
|
const rows = Math.ceil((pairs * 2) / cols);
|
|
const cellWidth = node.size[0] / cols;
|
|
const widgetHeight = node.size[1] - this.y - 10;
|
|
const cellHeight = (widgetHeight - 40) / rows; // Reserve space for controls
|
|
|
|
const col = Math.floor(pos[0] / cellWidth);
|
|
const row = Math.floor(pos[1] / cellHeight);
|
|
const cellIndex = row * cols + col;
|
|
|
|
// Determine which image was clicked
|
|
let imageIndex = Math.floor(cellIndex / 2);
|
|
let isImageB = cellIndex % 2 === 1;
|
|
|
|
if (imageIndex < this.maxPairs) {
|
|
// Switch to carousel mode to focus on this pair
|
|
this.currentPairIndex = imageIndex;
|
|
node.properties.comparer_mode = "Carousel";
|
|
node.layoutWidget.value = "Carousel";
|
|
node.setSize(node.computeSize());
|
|
node.setDirtyCanvas(true, false);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Cleanup method
|
|
onRemoved() {
|
|
this.stopAutoPlay();
|
|
}
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "AdvancedImageComparer",
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
|
// Only process if this is our target node
|
|
if (nodeData.name !== "AdvancedImageComparer") {
|
|
return;
|
|
}
|
|
|
|
// Add properties
|
|
nodeType.prototype.properties = nodeType.prototype.properties || {};
|
|
nodeType.prototype.properties.comparer_mode = "Slider";
|
|
nodeType.prototype.properties.onionSkinOpacity = 0.5; // Default opacity for Onion Skin mode
|
|
|
|
nodeType["@comparer_mode"] = {
|
|
type: "combo",
|
|
values: ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"],
|
|
};
|
|
|
|
// Store the original onDrawForeground function if it exists
|
|
const origOnDrawForeground = nodeType.prototype.onDrawForeground;
|
|
|
|
// Add our own onDrawForeground function for gradient background
|
|
nodeType.prototype.onDrawForeground = function(ctx) {
|
|
// Call the original onDrawForeground if it exists
|
|
if (origOnDrawForeground) {
|
|
origOnDrawForeground.apply(this, arguments);
|
|
}
|
|
|
|
// Draw our custom gradient title
|
|
drawGradientTitle(this, ctx);
|
|
};
|
|
|
|
// Clean up resources when node is removed
|
|
const origOnRemoved = nodeType.prototype.onRemoved;
|
|
nodeType.prototype.onRemoved = function() {
|
|
if (origOnRemoved) {
|
|
origOnRemoved.apply(this, arguments);
|
|
}
|
|
|
|
// Clear cached canvases to prevent memory leaks
|
|
CACHE.titleCanvas = null;
|
|
CACHE.titleCtx = null;
|
|
CACHE.collapsed.canvas = null;
|
|
CACHE.collapsed.ctx = null;
|
|
};
|
|
|
|
// Initialize state variables
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function() {
|
|
if (onNodeCreated) {
|
|
onNodeCreated.apply(this, arguments);
|
|
}
|
|
|
|
// Ensure properties are properly initialized
|
|
this.properties = this.properties || {};
|
|
if (!this.properties.comparer_mode) {
|
|
this.properties.comparer_mode = "Slider";
|
|
}
|
|
|
|
this.isPointerDown = false;
|
|
this.isPointerOver = false;
|
|
this.pointerOverPos = [0, 0];
|
|
this.imageIndex = 0;
|
|
|
|
// Add layout control widget
|
|
this.layoutWidget = this.addWidget("combo", "Layout Mode", this.properties.comparer_mode, (value) => {
|
|
this.properties.comparer_mode = value;
|
|
|
|
// Show/hide controls based on mode
|
|
this.updateControlsVisibility();
|
|
|
|
this.setDirtyCanvas(true, false);
|
|
}, {
|
|
values: ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"]
|
|
});
|
|
|
|
// Add batch selector widget for modes that need it
|
|
this.batchSelectorWidget = this.addWidget("combo", "View Pair", "1", (value) => {
|
|
console.log("[AdvancedImageComparer] Batch selector changed to:", value);
|
|
const pairIndex = parseInt(value) - 1;
|
|
if (this.comparerWidget && pairIndex >= 0 && pairIndex < this.comparerWidget.maxPairs) {
|
|
this.comparerWidget.currentPairIndex = pairIndex;
|
|
this.comparerWidget.updateSelectedPair();
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
}, {
|
|
values: ["1"]
|
|
});
|
|
|
|
// Add carousel control widgets (initially hidden)
|
|
this.prevButton = this.addWidget("button", "◀ Previous", null, () => {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.previousPair();
|
|
}
|
|
});
|
|
|
|
this.nextButton = this.addWidget("button", "Next ▶", null, () => {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.nextPair();
|
|
}
|
|
});
|
|
|
|
this.autoPlayButton = this.addWidget("button", "▶ Play", null, () => {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.toggleAutoPlay();
|
|
// Update button text
|
|
this.autoPlayButton.name = this.comparerWidget.autoPlayEnabled ? "⏸ Pause" : "▶ Play";
|
|
}
|
|
});
|
|
|
|
this.pairInfoWidget = this.addWidget("text", "Pair Info", "1 / 1", () => {}, {});
|
|
this.pairInfoWidget.disabled = true;
|
|
|
|
// Add batch pagination control widgets (initially hidden)
|
|
this.batchPrevButton = this.addWidget("button", "◀ Prev Page", null, () => {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.previousBatchPage();
|
|
}
|
|
});
|
|
|
|
this.batchNextButton = this.addWidget("button", "Next Page ▶", null, () => {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.nextBatchPage();
|
|
}
|
|
});
|
|
|
|
this.batchPageInfoWidget = this.addWidget("text", "Page Info", "Page 1 / 1", () => {}, {});
|
|
this.batchPageInfoWidget.disabled = true;
|
|
|
|
// Add Onion Skin opacity slider (initially hidden)
|
|
this.onionSkinOpacitySlider = this.addWidget("slider", "Opacity B", this.properties.onionSkinOpacity, (value) => {
|
|
this.properties.onionSkinOpacity = parseFloat(value);
|
|
this.setDirtyCanvas(true, false);
|
|
}, {
|
|
min: 0.0,
|
|
max: 1.0,
|
|
step: 0.01
|
|
});
|
|
|
|
// Create the custom widget
|
|
this.comparerWidget = this.addCustomWidget(new AdvancedImageComparerWidget("advanced_comparer", this));
|
|
|
|
// Initialize controls visibility
|
|
this.updateControlsVisibility();
|
|
|
|
this.setSize(this.computeSize());
|
|
this.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
// Method to show/hide controls based on mode
|
|
nodeType.prototype.updateControlsVisibility = function() {
|
|
const mode = this.properties.comparer_mode;
|
|
const hasMultiplePairs = this.comparerWidget && this.comparerWidget.maxPairs > 1;
|
|
|
|
// Show batch selector for modes that need individual pair selection
|
|
const showBatchSelector = hasMultiplePairs && ["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(mode);
|
|
|
|
// Show carousel controls for carousel mode
|
|
const showCarouselControls = mode === "Carousel" && hasMultiplePairs;
|
|
|
|
// Show batch pagination controls for batch mode with multiple pages
|
|
const showBatchPagination = mode === "Batch" && this.comparerWidget && this.comparerWidget.maxBatchPages > 1;
|
|
|
|
// Show Onion Skin opacity slider for Onion Skin mode
|
|
const showOnionSkinSlider = mode === "Onion Skin";
|
|
|
|
// Update batch selector
|
|
if (this.batchSelectorWidget) {
|
|
this.batchSelectorWidget.type = showBatchSelector ? "combo" : "hidden";
|
|
if (showBatchSelector && this.comparerWidget) {
|
|
// Update the options for the batch selector
|
|
const options = [];
|
|
for (let i = 1; i <= this.comparerWidget.maxPairs; i++) {
|
|
options.push(i.toString());
|
|
}
|
|
this.batchSelectorWidget.options.values = options;
|
|
this.batchSelectorWidget.value = (this.comparerWidget.currentPairIndex + 1).toString();
|
|
}
|
|
}
|
|
|
|
// Update carousel controls
|
|
if (this.prevButton) {
|
|
this.prevButton.type = showCarouselControls ? "button" : "hidden";
|
|
}
|
|
if (this.nextButton) {
|
|
this.nextButton.type = showCarouselControls ? "button" : "hidden";
|
|
}
|
|
if (this.autoPlayButton) {
|
|
this.autoPlayButton.type = showCarouselControls ? "button" : "hidden";
|
|
}
|
|
if (this.pairInfoWidget) {
|
|
this.pairInfoWidget.type = showCarouselControls ? "text" : "hidden";
|
|
if (showCarouselControls && this.comparerWidget) {
|
|
this.pairInfoWidget.value = `${this.comparerWidget.currentPairIndex + 1} / ${this.comparerWidget.maxPairs}`;
|
|
}
|
|
}
|
|
|
|
// Update batch pagination controls
|
|
if (this.batchPrevButton) {
|
|
this.batchPrevButton.type = showBatchPagination ? "button" : "hidden";
|
|
}
|
|
if (this.batchNextButton) {
|
|
this.batchNextButton.type = showBatchPagination ? "button" : "hidden";
|
|
}
|
|
if (this.batchPageInfoWidget) {
|
|
this.batchPageInfoWidget.type = showBatchPagination ? "text" : "hidden";
|
|
if (showBatchPagination && this.comparerWidget) {
|
|
this.batchPageInfoWidget.value = `Page ${this.comparerWidget.currentBatchPage + 1} / ${this.comparerWidget.maxBatchPages}`;
|
|
}
|
|
}
|
|
|
|
// Update Onion Skin opacity slider
|
|
if (this.onionSkinOpacitySlider) {
|
|
this.onionSkinOpacitySlider.type = showOnionSkinSlider ? "slider" : "hidden";
|
|
if (showOnionSkinSlider) {
|
|
this.onionSkinOpacitySlider.value = this.properties.onionSkinOpacity;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Override computeSize to account for the widget
|
|
const originalComputeSize = nodeType.prototype.computeSize;
|
|
nodeType.prototype.computeSize = function(out) {
|
|
const size = originalComputeSize ? originalComputeSize.apply(this, arguments) : [200, 100]; // Reduced defaults
|
|
if (this.comparerWidget) {
|
|
const widgetSize = this.comparerWidget.computeSize(size[0]);
|
|
|
|
// Calculate additional space needed for controls - reduced padding
|
|
let extraHeight = 40; // Reduced from 60 for base padding
|
|
|
|
const mode = this.properties.comparer_mode;
|
|
const hasMultiplePairs = this.comparerWidget.maxPairs > 1;
|
|
|
|
if (mode === "Carousel" && hasMultiplePairs) {
|
|
extraHeight += 90; // Reduced from 120 for carousel controls
|
|
} else if (["Slider", "Click", "Side-by-Side", "Stacked"].includes(mode) && hasMultiplePairs) {
|
|
extraHeight += 25; // Reduced from 30 for batch selector
|
|
} else if (mode === "Batch" && this.comparerWidget.maxBatchPages > 1) {
|
|
extraHeight += 70; // Reduced from 90 for batch pagination
|
|
} else if (mode === "Onion Skin") {
|
|
extraHeight += 25; // Reduced from 30 for opacity slider
|
|
if (hasMultiplePairs) {
|
|
extraHeight += 25; // Reduced from 30 for batch selector
|
|
}
|
|
}
|
|
|
|
size[1] = Math.max(size[1], widgetSize[1] + extraHeight);
|
|
}
|
|
return size;
|
|
};
|
|
|
|
// Override onExecuted to handle image data - this should be called when the node executes
|
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
|
nodeType.prototype.onExecuted = function(message) {
|
|
// Call the original onExecuted first (this handles the standard PreviewImage functionality)
|
|
let result;
|
|
if (originalOnExecuted) {
|
|
result = originalOnExecuted.apply(this, arguments);
|
|
}
|
|
|
|
// Now handle our custom logic
|
|
if (message && typeof message === 'object') {
|
|
|
|
// Check for images in different possible locations
|
|
let images = null;
|
|
if (message.ui && message.ui.images && Array.isArray(message.ui.images)) {
|
|
images = message.ui.images;
|
|
} else if (message.images && Array.isArray(message.images)) {
|
|
images = message.images;
|
|
}
|
|
|
|
if (images && images.length > 0) {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.value = { images: images };
|
|
this.setDirtyCanvas(true, false);
|
|
} else {
|
|
console.error("[AdvancedImageComparer] No comparerWidget found on node!");
|
|
}
|
|
}
|
|
}
|
|
|
|
return result || message;
|
|
};
|
|
|
|
// Mouse event handlers
|
|
nodeType.prototype.setIsPointerDown = function(down = this.isPointerDown) {
|
|
const newIsDown = down && !!app.canvas.pointer_is_down;
|
|
if (this.isPointerDown !== newIsDown) {
|
|
this.isPointerDown = newIsDown;
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
this.imageIndex = this.isPointerDown ? 1 : 0;
|
|
|
|
if (this.isPointerDown) {
|
|
requestAnimationFrame(() => {
|
|
this.setIsPointerDown();
|
|
});
|
|
}
|
|
};
|
|
|
|
nodeType.prototype.onMouseDown = function(event, pos, canvas) {
|
|
this.setIsPointerDown(true);
|
|
return false;
|
|
};
|
|
|
|
nodeType.prototype.onMouseEnter = function(event) {
|
|
this.setIsPointerDown(!!app.canvas.pointer_is_down);
|
|
this.isPointerOver = true;
|
|
this.setDirtyCanvas(true, false);
|
|
};
|
|
|
|
nodeType.prototype.onMouseLeave = function(event) {
|
|
this.setIsPointerDown(false);
|
|
this.isPointerOver = false;
|
|
this.setDirtyCanvas(true, false);
|
|
};
|
|
|
|
nodeType.prototype.onMouseMove = function(event, pos, canvas) {
|
|
this.pointerOverPos = [...pos];
|
|
|
|
const mode = this.properties.comparer_mode || "Slider";
|
|
|
|
switch (mode) {
|
|
case "Slider":
|
|
this.setDirtyCanvas(true, false);
|
|
break;
|
|
case "Click":
|
|
this.imageIndex = this.pointerOverPos[0] > this.size[0] / 2 ? 1 : 0;
|
|
break;
|
|
case "Side-by-Side":
|
|
case "Stacked":
|
|
// No special mouse handling needed for these modes
|
|
break;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// Add context menu options (keeping for convenience, but main control is via widget)
|
|
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
|
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
|
if (getExtraMenuOptions) {
|
|
getExtraMenuOptions.apply(this, arguments);
|
|
}
|
|
|
|
const layoutModes = ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"];
|
|
const currentMode = this.properties.comparer_mode || "Slider";
|
|
|
|
// Add separator for layout modes section
|
|
options.push(null);
|
|
|
|
// Add main layout modes submenu
|
|
const layoutSubmenu = [];
|
|
|
|
layoutModes.forEach(mode => {
|
|
layoutSubmenu.push({
|
|
content: `${mode === currentMode ? "✓ " : ""}${mode}`,
|
|
callback: () => {
|
|
this.properties.comparer_mode = mode;
|
|
if (this.layoutWidget) {
|
|
this.layoutWidget.value = mode;
|
|
}
|
|
this.updateControlsVisibility();
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
});
|
|
});
|
|
|
|
options.push({
|
|
content: "Layout Mode",
|
|
submenu: {
|
|
options: layoutSubmenu
|
|
}
|
|
});
|
|
|
|
// Add quick access to most common modes
|
|
options.push(null); // separator
|
|
|
|
const quickModes = ["Slider", "Side-by-Side", "Grid", "Carousel"];
|
|
quickModes.forEach(mode => {
|
|
if (mode !== currentMode) {
|
|
options.push({
|
|
content: `Switch to ${mode}`,
|
|
callback: () => {
|
|
this.properties.comparer_mode = mode;
|
|
if (this.layoutWidget) {
|
|
this.layoutWidget.value = mode;
|
|
}
|
|
this.updateControlsVisibility();
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add Select Pair submenu if applicable
|
|
if (this.comparerWidget && this.comparerWidget.maxPairs > 1 &&
|
|
["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(currentMode)) {
|
|
|
|
options.push(null); // separator
|
|
const pairSubmenu = [];
|
|
for (let i = 0; i < this.comparerWidget.maxPairs; i++) {
|
|
pairSubmenu.push({
|
|
content: `${i === this.comparerWidget.currentPairIndex ? "✓ " : ""}Pair ${i + 1}`,
|
|
callback: () => {
|
|
if (this.comparerWidget) {
|
|
this.comparerWidget.currentPairIndex = i;
|
|
this.comparerWidget.updateSelectedPair();
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
options.push({
|
|
content: "Select Pair",
|
|
submenu: {
|
|
options: pairSubmenu
|
|
}
|
|
});
|
|
}
|
|
|
|
options.push(null); // separator
|
|
|
|
options.push(
|
|
{
|
|
content: "Reset to Default Size",
|
|
callback: () => {
|
|
this.setSize(this.computeSize());
|
|
this.setDirtyCanvas(true, false);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
console.log("AdvancedImageComparer node setup complete");
|
|
}
|
|
});
|