feat: Complete TypeScript migration with 85 unit tests
- Migrate 7 JavaScript files to TypeScript with full type annotations - Add TypeScript infrastructure: tsconfig.json, package.json, vitest.config.ts - Add type declarations for ComfyUI and LiteGraph APIs (web/types/) - Add comprehensive test suite with Vitest: - noise_visualizer.test.ts (29 tests) - Canvas rendering methods - matrix_button.test.ts (19 tests) - Window utilities - integration.test.ts (16 tests) - Extension registration - shader_rendering.test.ts (21 tests) - WebGL pipeline - Add test mocks for Canvas 2D, WebGL, and ComfyUI APIs - Update .gitignore to track TypeScript sources Files migrated: - gradient_title.ts (245 lines) - shader_params_save_button.ts (540 lines) - noise_visualizer.ts (1,578 lines) - advanced_comparer.ts (905 lines, optimized from 1,772) - shader_renderer.ts (615 lines, optimized from 2,396) - video_comparer.ts (1,179 lines, optimized from 2,402) - matrix_button.ts (3,360 lines, fully typed)
This commit is contained in:
+12
@@ -175,3 +175,15 @@ user/
|
||||
|
||||
# Local cache
|
||||
.cache/
|
||||
|
||||
# Node.js / TypeScript
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
*.d.ts.map
|
||||
pnpm-lock.yaml
|
||||
|
||||
# TypeScript build output
|
||||
dist/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "comfyui-shadernoiseksampler-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "TypeScript frontend for ComfyUI ShaderNoiseKSampler custom nodes",
|
||||
"scripts": {
|
||||
"build": "tsc && cp dist/src/*.js web/",
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.2.2",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"jsdom": "^24.0.0",
|
||||
"@types/node": "^20.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./web",
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
"./web/types",
|
||||
"./node_modules/@types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"web/src/**/*.ts",
|
||||
"web/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"web/tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['web/tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['web/src/**/*.ts'],
|
||||
exclude: ['web/types/**', 'web/tests/**'],
|
||||
},
|
||||
setupFiles: ['web/tests/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Mock ComfyUI imports during testing - use absolute paths
|
||||
'../../scripts/app.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'),
|
||||
'../../../scripts/app.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'),
|
||||
'../../scripts/api.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'),
|
||||
'../../../scripts/api.js': path.resolve(__dirname, 'web/tests/mocks/comfyui.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
+541
-1372
File diff suppressed because it is too large
Load Diff
+81
-109
@@ -1,7 +1,6 @@
|
||||
// gradient_title.js - Adds a custom gradient title to ShaderNoiseKSampler node
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
/**
|
||||
* gradient_title.ts - Adds a custom gradient title to ShaderNoiseKSampler node
|
||||
*/
|
||||
// Cache for rendering optimization
|
||||
const CACHE = {
|
||||
titleCanvas: null,
|
||||
@@ -14,37 +13,39 @@ const CACHE = {
|
||||
collapsed: {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
lastWidth: 0
|
||||
}
|
||||
lastWidth: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Register a callback to run when ComfyUI is fully loaded
|
||||
app.registerExtension({
|
||||
name: "ShaderNoiseKSampler.GradientTitle",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
// Import app from ComfyUI at runtime (this import is resolved by the browser)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - ComfyUI provides this at runtime
|
||||
import { app as comfyApp } from '../../scripts/app.js';
|
||||
// Use the runtime import
|
||||
const appInstance = comfyApp;
|
||||
// Define the extension
|
||||
const extension = {
|
||||
name: 'ShaderNoiseKSampler.GradientTitle',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
// Apply to both shader noise ksampler nodes
|
||||
if (nodeData.name === "ShaderNoiseKSampler" || nodeData.name === "ShaderNoiseKSamplerDirect") {
|
||||
if (nodeData.name === 'ShaderNoiseKSampler' ||
|
||||
nodeData.name === 'ShaderNoiseKSamplerDirect') {
|
||||
// Store the original onDrawForeground function if it exists
|
||||
const origOnDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
|
||||
// Add our own onDrawForeground function
|
||||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
// Call the original onDrawForeground if it exists
|
||||
if (origOnDrawForeground) {
|
||||
origOnDrawForeground.apply(this, arguments);
|
||||
origOnDrawForeground.call(this, ctx);
|
||||
}
|
||||
|
||||
// Draw a custom gradient title
|
||||
drawGradientTitle(this, ctx);
|
||||
};
|
||||
|
||||
// Clean up resources when node is removed
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
if (origOnRemoved) {
|
||||
origOnRemoved.apply(this, arguments);
|
||||
origOnRemoved.call(this);
|
||||
}
|
||||
|
||||
// Clear cached canvases to prevent memory leaks
|
||||
CACHE.titleCanvas = null;
|
||||
CACHE.titleCtx = null;
|
||||
@@ -52,13 +53,14 @@ app.registerExtension({
|
||||
CACHE.collapsed.ctx = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
};
|
||||
// Register the extension
|
||||
appInstance.registerExtension(extension);
|
||||
/**
|
||||
* Draws a gradient title directly on the canvas
|
||||
* @param {LGraphNode} node - The node to apply the gradient to
|
||||
* @param {CanvasRenderingContext2D} ctx - The canvas context
|
||||
* @param node - The node to apply the gradient to
|
||||
* @param ctx - The canvas context
|
||||
*/
|
||||
function drawGradientTitle(node, ctx) {
|
||||
// Get title area dimensions
|
||||
@@ -66,37 +68,32 @@ function drawGradientTitle(node, ctx) {
|
||||
const width = node.flags.collapsed ? 190 : node.size[0]; // Smaller width when collapsed
|
||||
const fullHeight = node.size[1]; // Get actual node height
|
||||
const equationY = 45; // Y position for the equation, moved lower
|
||||
|
||||
// Choose appropriate equation based on node type
|
||||
let equation, collapsedEquation;
|
||||
|
||||
if (node.type === "ShaderNoiseKSamplerDirect") {
|
||||
equation = "Lt = Sα(N) ∘ Kβ(t) ⟿";
|
||||
collapsedEquation = "Lt = Sα(N) ∘ Kβ(t) ⟿";
|
||||
} else {
|
||||
equation = "Lt = Sα(N) ∘ Kβ(t)";
|
||||
collapsedEquation = "Lt = Sα(N) ∘ Kβ(t)";
|
||||
let equation;
|
||||
let collapsedEquation;
|
||||
if (node.type === 'ShaderNoiseKSamplerDirect') {
|
||||
equation = 'Lt = Sα(N) ∘ Kβ(t) ⟿';
|
||||
collapsedEquation = 'Lt = Sα(N) ∘ Kβ(t) ⟿';
|
||||
}
|
||||
else {
|
||||
equation = 'Lt = Sα(N) ∘ Kβ(t)';
|
||||
collapsedEquation = 'Lt = Sα(N) ∘ Kβ(t)';
|
||||
}
|
||||
|
||||
// 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.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
|
||||
|
||||
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 mathematical formula - only calculate if animation should update
|
||||
let shimmerPosition = 0.5; // Default middle position
|
||||
if (shouldUpdateAnimation) {
|
||||
@@ -104,78 +101,63 @@ function drawGradientTitle(node, ctx) {
|
||||
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 {
|
||||
}
|
||||
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 etched shadow for collapsed version
|
||||
ctx.fillStyle = "rgba(0,0,0,0.3)";
|
||||
ctx.font = "italic 11px Arial"; // Smaller font for collapsed state
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.font = 'italic 11px Arial'; // Smaller font for collapsed state
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(collapsedEquation, width / 2 + 1, titleHeight / 2 + 1);
|
||||
|
||||
// Create base golden gradient for collapsed version
|
||||
const baseGradient = ctx.createLinearGradient(0, titleHeight/2 - 5, 0, titleHeight/2 + 5);
|
||||
baseGradient.addColorStop(0, "#B8860B"); // Darker gold
|
||||
baseGradient.addColorStop(0.5, "#FFD700"); // Bright gold
|
||||
baseGradient.addColorStop(1, "#B8860B"); // Darker gold
|
||||
|
||||
const baseGradient = ctx.createLinearGradient(0, titleHeight / 2 - 5, 0, titleHeight / 2 + 5);
|
||||
baseGradient.addColorStop(0, '#B8860B'); // Darker gold
|
||||
baseGradient.addColorStop(0.5, '#FFD700'); // Bright gold
|
||||
baseGradient.addColorStop(1, '#B8860B'); // Darker gold
|
||||
// Draw base golden text
|
||||
ctx.fillStyle = baseGradient;
|
||||
ctx.font = "italic 11px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = 'italic 11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(collapsedEquation, width / 2, titleHeight / 2);
|
||||
|
||||
// Create moving highlight effect for collapsed version
|
||||
const highlightWidth = width * 0.4; // Width of the highlight
|
||||
const highlightX = -highlightWidth + (width + highlightWidth) * shimmerPosition; // Adjusted range
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(
|
||||
highlightX - highlightWidth/2, 0,
|
||||
highlightX + highlightWidth/2, 0
|
||||
);
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(highlightX - highlightWidth / 2, 0, highlightX + highlightWidth / 2, 0);
|
||||
// Create smooth highlight transition
|
||||
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)");
|
||||
|
||||
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)');
|
||||
// Apply highlight
|
||||
ctx.fillStyle = shimmerGradient;
|
||||
ctx.fillText(collapsedEquation, width / 2, titleHeight / 2);
|
||||
|
||||
// Add outline glow that follows the highlight
|
||||
const glowIntensity = Math.max(0, 1 - Math.abs(width/2 - highlightX)/(width/4));
|
||||
const glowIntensity = Math.max(0, 1 - Math.abs(width / 2 - highlightX) / (width / 4));
|
||||
ctx.shadowColor = `rgba(255, 255, 200, ${glowIntensity * 0.3})`;
|
||||
ctx.shadowBlur = 4; // Less blur for collapsed version
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.fillText(collapsedEquation, width / 2, titleHeight / 2);
|
||||
|
||||
// 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)
|
||||
@@ -187,59 +169,49 @@ function drawGradientTitle(node, ctx) {
|
||||
ctx.lineTo(0, 0); // Left edge back to top
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Keep regular rectangle for collapsed state
|
||||
ctx.fillRect(0, 0, width, fullHeight); // No extra padding
|
||||
}
|
||||
|
||||
// Draw etched shadow
|
||||
ctx.fillStyle = "rgba(0,0,0,0.3)";
|
||||
ctx.font = "italic 14px Arial"; // Smaller font for equation
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.font = 'italic 14px Arial'; // Smaller font for equation
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(equation, width / 2 + 2, equationY + 2);
|
||||
|
||||
// Create base golden gradient
|
||||
const baseGradient = ctx.createLinearGradient(0, equationY - 7, 0, equationY + 7);
|
||||
baseGradient.addColorStop(0, "#B8860B"); // Darker gold
|
||||
baseGradient.addColorStop(0.5, "#FFD700"); // Bright gold
|
||||
baseGradient.addColorStop(1, "#B8860B"); // Darker gold
|
||||
|
||||
baseGradient.addColorStop(0, '#B8860B'); // Darker gold
|
||||
baseGradient.addColorStop(0.5, '#FFD700'); // Bright gold
|
||||
baseGradient.addColorStop(1, '#B8860B'); // Darker gold
|
||||
// Draw base golden text
|
||||
ctx.fillStyle = baseGradient;
|
||||
ctx.font = "italic 14px Arial"; // Smaller font for equation
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = 'italic 14px Arial'; // Smaller font for equation
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(equation, width / 2, equationY);
|
||||
|
||||
// Create moving highlight effect
|
||||
const highlightWidth = width * 0.4; // Width of the highlight
|
||||
const highlightX = -highlightWidth + (width + highlightWidth) * shimmerPosition; // Adjusted range
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(
|
||||
highlightX - highlightWidth/2, 0,
|
||||
highlightX + highlightWidth/2, 0
|
||||
);
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(highlightX - highlightWidth / 2, 0, highlightX + highlightWidth / 2, 0);
|
||||
// Create smooth highlight transition
|
||||
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)");
|
||||
|
||||
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)');
|
||||
// Apply highlight
|
||||
ctx.fillStyle = shimmerGradient;
|
||||
ctx.fillText(equation, width / 2, equationY);
|
||||
|
||||
// Add outline glow that follows the highlight
|
||||
const glowIntensity = Math.max(0, 1 - Math.abs(width/2 - highlightX)/(width/4));
|
||||
const glowIntensity = Math.max(0, 1 - Math.abs(width / 2 - highlightX) / (width / 4));
|
||||
ctx.shadowColor = `rgba(255, 255, 200, ${glowIntensity * 0.3})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.fillText(equation, width / 2, equationY);
|
||||
|
||||
// Restore context state
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=gradient_title.js.map
|
||||
+95
-145
@@ -1,17 +1,20 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
/**
|
||||
* Adds a "Show Matrix" button to the ShaderDisplay and ShaderNoiseKSampler nodes
|
||||
* matrix_button.ts - Adds a "Show Matrix" documentation button to ShaderNoiseKSampler nodes
|
||||
* Displays interactive shader documentation modal with noise visualizations
|
||||
*/
|
||||
(function() {
|
||||
// @ts-ignore - Runtime ComfyUI import
|
||||
import { app } from "../../../scripts/app.js";
|
||||
// === INITIALIZATION ===
|
||||
console.log("MatrixButton module loaded");
|
||||
(function () {
|
||||
// Define utility functions on window object to be accessible by treatiseHTML
|
||||
window.scrollToSection = function(sectionId) {
|
||||
const modalContent = document.querySelector('.shader-matrix-treatise'); // Scroll within the modal
|
||||
if (!modalContent) return;
|
||||
window.scrollToSection = function (sectionId) {
|
||||
const modalContent = document.querySelector('.shader-matrix-treatise');
|
||||
if (!modalContent)
|
||||
return;
|
||||
const section = modalContent.querySelector('#' + sectionId);
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
// UX Enhancement: Move focus to the section header for accessibility
|
||||
const header = section.querySelector('h1, h2, h3, h4, h5, h6');
|
||||
if (header) {
|
||||
@@ -20,63 +23,55 @@ import { app } from "../../scripts/app.js";
|
||||
header.setAttribute('tabindex', '-1');
|
||||
}
|
||||
// Focus the header to update reading position for keyboard/screen reader users
|
||||
// preventScroll: true prevents the browser from fighting the smooth scroll
|
||||
header.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.showTab = function(tabIdToActivate, clickedTabElement) {
|
||||
let tabsContainer;
|
||||
let contentScope;
|
||||
let tabSelector;
|
||||
let tabContentSelector;
|
||||
|
||||
if (clickedTabElement) { // Prioritize if element is passed
|
||||
tabsContainer = clickedTabElement.closest('.tabs') || clickedTabElement.closest('.sacred-tabs');
|
||||
} else { // Fallback to querying globally within the modal if no element
|
||||
window.showTab = function (tabIdToActivate, clickedTabElement) {
|
||||
let tabsContainer = null;
|
||||
let contentScope = null;
|
||||
let tabSelector = '';
|
||||
let tabContentSelector = '';
|
||||
if (clickedTabElement) {
|
||||
tabsContainer = clickedTabElement.closest('.tabs') || clickedTabElement.closest('.sacred-tabs');
|
||||
}
|
||||
else {
|
||||
const modalDiv = document.querySelector('.shader-matrix-treatise');
|
||||
if (!modalDiv) return;
|
||||
if (!modalDiv)
|
||||
return;
|
||||
tabsContainer = modalDiv.querySelector('.tabs') || modalDiv.querySelector('.sacred-tabs');
|
||||
}
|
||||
|
||||
if (!tabsContainer) {
|
||||
console.error("showTab: Could not find '.tabs' or '.sacred-tabs' container.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabsContainer.classList.contains('tabs')) {
|
||||
tabSelector = '.tab';
|
||||
tabContentSelector = '.tab-content';
|
||||
contentScope = tabsContainer.parentNode; // Assumes content is sibling to .tabs div
|
||||
} else if (tabsContainer.classList.contains('sacred-tabs')) {
|
||||
contentScope = tabsContainer.parentNode;
|
||||
}
|
||||
else if (tabsContainer.classList.contains('sacred-tabs')) {
|
||||
tabSelector = '.sacred-tab';
|
||||
tabContentSelector = '.sacred-tab-content';
|
||||
contentScope = tabsContainer.closest('.sacred-section') || tabsContainer.parentNode;
|
||||
} else {
|
||||
return; // Unknown tab structure
|
||||
}
|
||||
|
||||
else {
|
||||
return;
|
||||
}
|
||||
tabsContainer.querySelectorAll(tabSelector).forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
tab.setAttribute('aria-selected', 'false');
|
||||
tab.setAttribute('tabindex', '-1');
|
||||
});
|
||||
|
||||
let activeTab = clickedTabElement;
|
||||
if (!activeTab) {
|
||||
// If no clicked element, try to find the tab by tabId (less robust)
|
||||
activeTab = Array.from(tabsContainer.querySelectorAll(tabSelector)).find(
|
||||
t => t.getAttribute('onclick') && t.getAttribute('onclick').includes(tabIdToActivate)
|
||||
);
|
||||
activeTab = Array.from(tabsContainer.querySelectorAll(tabSelector)).find(t => t.getAttribute('onclick')?.includes(tabIdToActivate));
|
||||
}
|
||||
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('active');
|
||||
activeTab.setAttribute('aria-selected', 'true');
|
||||
activeTab.setAttribute('tabindex', '0');
|
||||
}
|
||||
|
||||
if (contentScope) {
|
||||
contentScope.querySelectorAll(tabContentSelector).forEach(content => {
|
||||
content.style.display = 'none';
|
||||
@@ -89,158 +84,138 @@ import { app } from "../../scripts/app.js";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.setupScrollTop = function(modalContentElement) { // Added modalContentElement parameter
|
||||
if (!modalContentElement) return; // Check the passed element
|
||||
const scrollTopButton = modalContentElement.querySelector('#scroll-top'); // Use passed element
|
||||
window.setupScrollTop = function (modalContentElement) {
|
||||
if (!modalContentElement)
|
||||
return;
|
||||
const scrollTopButton = modalContentElement.querySelector('#scroll-top');
|
||||
const titleElement = modalContentElement.querySelector('#treatise-title');
|
||||
|
||||
const modalEl = modalContentElement;
|
||||
if (scrollTopButton) {
|
||||
modalContentElement.addEventListener('scroll', () => { // Use passed element
|
||||
if (modalContentElement.scrollTop > 200) { // Use passed element
|
||||
modalEl.addEventListener('scroll', () => {
|
||||
if (modalEl.scrollTop > 200) {
|
||||
scrollTopButton.classList.add('visible');
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
scrollTopButton.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
scrollTopButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
modalContentElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
modalEl.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (titleElement) {
|
||||
titleElement.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.copyCodeSection = function(buttonElement) {
|
||||
window.copyCodeSection = function (buttonElement) {
|
||||
const headerElement = buttonElement.closest('.code-block-header');
|
||||
if (!headerElement) return;
|
||||
if (!headerElement)
|
||||
return;
|
||||
const codeBlockContainer = headerElement.parentNode;
|
||||
if (!codeBlockContainer) return;
|
||||
|
||||
if (!codeBlockContainer)
|
||||
return;
|
||||
const preElement = codeBlockContainer.querySelector('pre.foldable-content code');
|
||||
if (!preElement) return;
|
||||
|
||||
const codeText = preElement.textContent;
|
||||
if (!preElement)
|
||||
return;
|
||||
const codeText = preElement.textContent || '';
|
||||
navigator.clipboard.writeText(codeText).then(() => {
|
||||
// Enhanced UX: Use global toast if available
|
||||
if (window.showComfyToast) {
|
||||
window.showComfyToast("Code copied to clipboard!", "success");
|
||||
}
|
||||
|
||||
buttonElement.textContent = "Copied!";
|
||||
buttonElement.classList.add('copied');
|
||||
|
||||
if (buttonElement.dataset.timeoutId) {
|
||||
clearTimeout(parseInt(buttonElement.dataset.timeoutId));
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
buttonElement.textContent = "Copy";
|
||||
buttonElement.classList.remove('copied');
|
||||
delete buttonElement.dataset.timeoutId;
|
||||
}, 2000);
|
||||
|
||||
buttonElement.dataset.timeoutId = timeoutId;
|
||||
buttonElement.dataset.timeoutId = String(timeoutId);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
|
||||
// Enhanced UX: Error toast
|
||||
if (window.showComfyToast) {
|
||||
window.showComfyToast("Failed to copy code.", "error");
|
||||
}
|
||||
|
||||
buttonElement.textContent = "Error";
|
||||
// Ensure success class is removed if it was present
|
||||
buttonElement.classList.remove('copied');
|
||||
|
||||
if (buttonElement.dataset.timeoutId) {
|
||||
clearTimeout(parseInt(buttonElement.dataset.timeoutId));
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
buttonElement.textContent = "Copy";
|
||||
delete buttonElement.dataset.timeoutId;
|
||||
}, 2000);
|
||||
|
||||
buttonElement.dataset.timeoutId = timeoutId;
|
||||
buttonElement.dataset.timeoutId = String(timeoutId);
|
||||
});
|
||||
};
|
||||
|
||||
window.toggleCodeSection = function(buttonElement) {
|
||||
window.toggleCodeSection = function (buttonElement) {
|
||||
const headerElement = buttonElement.closest('.code-block-header');
|
||||
if (!headerElement) return;
|
||||
if (!headerElement)
|
||||
return;
|
||||
const codeBlockContainer = headerElement.parentNode;
|
||||
if (!codeBlockContainer) return;
|
||||
|
||||
if (!codeBlockContainer)
|
||||
return;
|
||||
const preElement = codeBlockContainer.querySelector('pre.foldable-content');
|
||||
if (!preElement) return;
|
||||
|
||||
if (!preElement)
|
||||
return;
|
||||
const isHidden = preElement.style.display === 'none' || preElement.style.display === '';
|
||||
|
||||
if (isHidden) {
|
||||
preElement.style.display = 'block';
|
||||
buttonElement.textContent = 'Hide';
|
||||
buttonElement.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
preElement.style.display = 'none';
|
||||
buttonElement.textContent = 'Show';
|
||||
buttonElement.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
window.handleTabNavigation = function(event, tabElement) {
|
||||
window.handleTabNavigation = function (event, tabElement) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
tabElement.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const tabs = Array.from(tabElement.parentElement.children).filter(child => child.classList.contains('tab'));
|
||||
if (!tabElement.parentElement)
|
||||
return;
|
||||
const tabs = Array.from(tabElement.parentElement.children).filter((child) => child.classList.contains('tab'));
|
||||
const index = tabs.indexOf(tabElement);
|
||||
let nextIndex = -1;
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
nextIndex = (index + 1) % tabs.length;
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
}
|
||||
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
nextIndex = (index - 1 + tabs.length) % tabs.length;
|
||||
}
|
||||
|
||||
if (nextIndex !== -1) {
|
||||
event.preventDefault();
|
||||
const nextTab = tabs[nextIndex];
|
||||
nextTab.focus();
|
||||
nextTab.click(); // Optional: automatically activate the tab on focus
|
||||
nextTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Register the extension for ShaderDisplay and ShaderNoiseKSampler nodes
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.ShaderNoise.MatrixButton",
|
||||
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
// Modify ShaderDisplay, ShaderNoiseKSampler, and ShaderNoiseKSamplerDirect nodes
|
||||
if (nodeData.name !== "ShaderDisplay" && nodeData.name !== "ShaderNoiseKSampler" && nodeData.name !== "ShaderNoiseKSamplerDirect") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the original methods
|
||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
// Add our button to the node
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
// Call the original onNodeCreated method first
|
||||
const self = this; // Node instance
|
||||
|
||||
if (originalOnNodeCreated) {
|
||||
originalOnNodeCreated.apply(self, arguments);
|
||||
}
|
||||
|
||||
// Add the "Show Matrix" button widget
|
||||
// const self = this; // self is already defined above
|
||||
|
||||
// Function to add the matrix button
|
||||
const addMatrixButton = () => {
|
||||
// MODIFIED: HTML content from Treatise.js
|
||||
@@ -248,24 +223,20 @@ import { app } from "../../scripts/app.js";
|
||||
// This ^ is now replaced by the global window.showTab for new HTML,
|
||||
// but can be kept if old structures outside this modal might use it.
|
||||
// For this modal, the new HTML will use window.showTab.
|
||||
|
||||
const button = self.addWidget("button", "📊 Show Shader Matrix", null, function() {
|
||||
const button = self.addWidget("button", "📊 Show Shader Matrix", null, function () {
|
||||
// Create modal container
|
||||
const modal = document.createElement("div");
|
||||
// Accessibility attributes for the modal
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', 'Shader Matrix Documentation');
|
||||
|
||||
modal.style.cssText = `
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: linear-gradient(135deg, rgba(0,0,0,0.9), rgba(26,13,52,0.95));
|
||||
display: flex; justify-content: center; align-items: center; z-index: 10000;
|
||||
backdrop-filter: blur(5px);
|
||||
`;
|
||||
|
||||
let handleEscPress;
|
||||
|
||||
let handleEscPress = null;
|
||||
const closeModalCleanup = () => {
|
||||
if (modal && modal.parentNode) {
|
||||
document.body.removeChild(modal);
|
||||
@@ -274,21 +245,17 @@ import { app } from "../../scripts/app.js";
|
||||
document.removeEventListener('keydown', handleEscPress);
|
||||
}
|
||||
};
|
||||
|
||||
handleEscPress = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
closeModalCleanup();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscPress);
|
||||
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModalCleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "shader-matrix-treatise"; // This is the main scrollable container
|
||||
content.style.cssText = `
|
||||
@@ -302,7 +269,6 @@ import { app } from "../../scripts/app.js";
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* Base font */
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
// MODIFIED: HTML content from Treatise.js
|
||||
const treatiseHTML = `
|
||||
<style>
|
||||
@@ -3174,7 +3140,6 @@ def apply_color_to_noise(noise_tensor, shader_params):
|
||||
|
||||
`; // This line should end the template literal correctly
|
||||
content.innerHTML = treatiseHTML;
|
||||
|
||||
// Attach listener to the close button *inside* the treatiseHTML
|
||||
const closeButtonInTreatise = content.querySelector('.close-button');
|
||||
if (closeButtonInTreatise) {
|
||||
@@ -3183,29 +3148,25 @@ def apply_color_to_noise(noise_tensor, shader_params):
|
||||
closeModalCleanup();
|
||||
};
|
||||
}
|
||||
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Accessibility: Focus Trap & Initial Focus
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = Array.from(modal.querySelectorAll(focusableSelectors))
|
||||
.filter(el => el.offsetParent !== null && !el.hasAttribute('disabled')); // Only visible and enabled elements
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
.filter((el) => el.offsetParent !== null && !el.hasAttribute('disabled'));
|
||||
if (focusableElements.length === 0)
|
||||
return;
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey) { // Shift + Tab
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else { // Tab
|
||||
}
|
||||
else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
@@ -3213,7 +3174,6 @@ def apply_color_to_noise(noise_tensor, shader_params):
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Accessibility: Set focus to the close button when modal opens
|
||||
// Using a small timeout to ensure DOM insertion is complete and to play nice with screen readers
|
||||
setTimeout(() => {
|
||||
@@ -3222,75 +3182,66 @@ def apply_color_to_noise(noise_tensor, shader_params):
|
||||
closeBtn.focus();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Call the renderer for noise visualizations
|
||||
if (window.NoiseVisualizer && window.NoiseVisualizer.renderAllInModal) {
|
||||
// Defer to ensure layout is complete and modal content is fully rendered
|
||||
if (window.NoiseVisualizer?.renderAllInModal) {
|
||||
setTimeout(() => {
|
||||
window.NoiseVisualizer.renderAllInModal(content); // 'content' is the div with class 'shader-matrix-treatise'
|
||||
window.NoiseVisualizer.renderAllInModal(content);
|
||||
}, 0);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
console.warn("NoiseVisualizer not found. Ensure noise_visualizer.js is loaded and available on the window object.");
|
||||
}
|
||||
|
||||
// Initial scroll to top of modal content
|
||||
content.scrollTop = 0;
|
||||
|
||||
// Activate the scroll-to-top button functionality
|
||||
window.setupScrollTop(content); // Pass the 'content' element
|
||||
|
||||
});
|
||||
|
||||
// Add tooltip to the button
|
||||
button.tooltip = "Show Shader Matrix & Documentation (Alt+M)";
|
||||
|
||||
// Position the button appropriately based on node type (from matrix_button - Copy.js)
|
||||
if (nodeData.name === "ShaderNoiseKsampler") {
|
||||
// For KSampler node, add to a specific section or position
|
||||
if (!button.options) {
|
||||
if (!button.options) {
|
||||
button.options = {};
|
||||
}
|
||||
button.options.section = "advanced";
|
||||
|
||||
// Custom button style for the KSampler node
|
||||
button.label = "📊 Show Shader Matrix";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Default styling for ShaderDisplay (label is already "Show Matrix")
|
||||
// button.label = "Show Matrix"; // No change needed if initialized with "Show Matrix"
|
||||
}
|
||||
|
||||
// Set button appearance (from matrix_button - Copy.js)
|
||||
button.name = "📊 Show Shader Matrix";
|
||||
button.serialize = false; // Don't include in serialization
|
||||
|
||||
// For ShaderDisplay, the button will use default positioning.
|
||||
// For ShaderNoiseKsampler, without a section, it should append after other widgets.
|
||||
|
||||
};
|
||||
|
||||
// Call addMatrixButton conditionally (from matrix_button - Copy.js)
|
||||
if (self.constructor.type_name === "ShaderNoiseKsampler") {
|
||||
addMatrixButton();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// For other nodes, add after a small delay to ensure all widgets are ready
|
||||
setTimeout(addMatrixButton, 50);
|
||||
}
|
||||
|
||||
// --- Keybinding Logic for Matrix Button ---
|
||||
const triggerMatrixButton = () => {
|
||||
const matrixButtonWidget = self.widgets.find(w => w.name === "📊 Show Shader Matrix" && w.type === "button");
|
||||
const matrixButtonWidget = self.widgets.find((w) => w.name === "📊 Show Shader Matrix" && w.type === "button");
|
||||
if (matrixButtonWidget && typeof matrixButtonWidget.callback === 'function') {
|
||||
matrixButtonWidget.callback.call(matrixButtonWidget.value, app.canvas, self, null, null);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
console.warn("Matrix button widget not found or callback is not a function for Alt+M.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMatrixKeyDown = (event) => {
|
||||
if (event.altKey && event.key.toLowerCase() === 'm') {
|
||||
if (app.canvas && (app.canvas.current_node === self || (app.canvas.selected_nodes && app.canvas.selected_nodes[self.id]))) {
|
||||
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA' || document.activeElement.isContentEditable)) {
|
||||
return; // Don't interfere with text input
|
||||
const appCanvas = app.canvas;
|
||||
if (appCanvas && (appCanvas.current_node === self || (appCanvas.selected_nodes && appCanvas.selected_nodes[self.id]))) {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
console.log("Alt+M detected for current/selected node to show matrix.");
|
||||
event.preventDefault();
|
||||
@@ -3299,12 +3250,10 @@ def apply_color_to_noise(noise_tensor, shader_params):
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleMatrixKeyDown);
|
||||
self.handleMatrixButtonKeyDown = handleMatrixKeyDown; // Store for removal
|
||||
|
||||
const originalOnRemoved = self.onRemoved;
|
||||
self.onRemoved = function() {
|
||||
self.onRemoved = function () {
|
||||
if (self.handleMatrixButtonKeyDown) {
|
||||
document.removeEventListener('keydown', self.handleMatrixButtonKeyDown);
|
||||
delete self.handleMatrixButtonKeyDown;
|
||||
@@ -3315,7 +3264,8 @@ def apply_color_to_noise(noise_tensor, shader_params):
|
||||
}
|
||||
};
|
||||
// --- End Keybinding Logic ---
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
//# sourceMappingURL=matrix_button.js.map
|
||||
+273
-900
File diff suppressed because it is too large
Load Diff
+251
-284
@@ -1,7 +1,14 @@
|
||||
// Add a button to manually save shader parameters to file
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Helper function to show toast notifications
|
||||
/**
|
||||
* shader_params_save_button.ts - Adds a button to manually save shader parameters
|
||||
*/
|
||||
// Import app from ComfyUI at runtime
|
||||
// @ts-ignore - ComfyUI provides this at runtime
|
||||
import { app as comfyApp } from '../../scripts/app.js';
|
||||
// Cast the runtime import to our typed interface
|
||||
const appInstance = comfyApp;
|
||||
/**
|
||||
* Helper function to show toast notifications
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
// Create toast container if it doesn't exist
|
||||
let toastContainer = document.getElementById('comfy-toast-container');
|
||||
@@ -10,21 +17,17 @@ function showToast(message, type = 'info') {
|
||||
toastContainer.id = 'comfy-toast-container';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `comfy-toast comfy-toast-${type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.textContent = message;
|
||||
|
||||
// Add to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
// Remove after delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
@@ -36,69 +39,59 @@ function showToast(message, type = 'info') {
|
||||
toast.removeEventListener('transitionend', removeToast);
|
||||
}
|
||||
};
|
||||
|
||||
toast.addEventListener('transitionend', removeToast, { once: true });
|
||||
|
||||
// Fallback cleanup if transitions are disabled
|
||||
setTimeout(removeToast, 350);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Expose toast function globally
|
||||
window.showComfyToast = showToast;
|
||||
|
||||
app.registerExtension({
|
||||
name: "ShaderParamsSaveButton",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "ShaderNoiseKSampler") {
|
||||
// Define the extension
|
||||
const extension = {
|
||||
name: 'ShaderParamsSaveButton',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
if (nodeData.name === 'ShaderNoiseKSampler') {
|
||||
// Store original onNodeCreated to maintain the node's behavior
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
// Add our button to the node
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
// Call original function to preserve existing behavior
|
||||
if (origOnNodeCreated) {
|
||||
origOnNodeCreated.apply(this, arguments);
|
||||
origOnNodeCreated.call(this);
|
||||
}
|
||||
|
||||
// Keep track of the node instance
|
||||
const node = this;
|
||||
|
||||
// Track if we're currently saving (to prevent double-clicks)
|
||||
let isSaving = false;
|
||||
|
||||
// Add indicator widget that will show when parameters need saving
|
||||
// Create a custom widget object instead of using the built-in text widget
|
||||
const indicatorWidget = {
|
||||
name: "⚠️ Parameters with 🔄 must be saved ⚠️",
|
||||
type: "custom_indicator",
|
||||
value: "",
|
||||
options: { className: "shader-params-indicator" },
|
||||
tooltip: "parameters marked with 🔄 require saving to take effect in the generation process",
|
||||
name: '⚠️ Parameters with 🔄 must be saved ⚠️',
|
||||
type: 'custom_indicator',
|
||||
value: '',
|
||||
options: { className: 'shader-params-indicator' },
|
||||
tooltip: 'parameters marked with 🔄 require saving to take effect in the generation process',
|
||||
disabled: true,
|
||||
// Add computeSize method to properly handle resizing
|
||||
computeSize: function() {
|
||||
computeSize() {
|
||||
// Return fixed height but variable width based on parent node width
|
||||
if (this.parent && this.parent.size) {
|
||||
// Adjust width to match node width with some padding
|
||||
return [this.parent.size[0] - 30, 20];
|
||||
return [this.parent.size[0] - 30, 20];
|
||||
}
|
||||
return [220, 28]; // Default size if parent not available
|
||||
},
|
||||
// Custom draw method for the widget
|
||||
draw: function(ctx, node, widget_width, y, widget_height) {
|
||||
if (!ctx) return;
|
||||
|
||||
draw(ctx, _node, widget_width, y, widget_height) {
|
||||
if (!ctx)
|
||||
return;
|
||||
// Draw background
|
||||
ctx.fillStyle = "rgba(255, 119, 0, 0.1)";
|
||||
ctx.strokeStyle = "#ff7700";
|
||||
ctx.fillStyle = 'rgba(255, 119, 0, 0.1)';
|
||||
ctx.strokeStyle = '#ff7700';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Draw rounded rectangle for the widget background
|
||||
const radius = 4;
|
||||
const x = 15; // Padding from left edge
|
||||
const width = widget_width - 30; // Subtract padding from both sides
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
@@ -110,431 +103,405 @@ app.registerExtension({
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.fillStyle = "#ff7700";
|
||||
ctx.font = "bold 12px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = '#ff7700';
|
||||
ctx.font = 'bold 12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.name, x + width / 2, y + widget_height / 2 + 4);
|
||||
|
||||
return widget_height;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Add widget reference to node
|
||||
indicatorWidget.parent = this;
|
||||
this.widgets.push(indicatorWidget);
|
||||
|
||||
// --- Refactored Save Function ---
|
||||
const saveParameters = () => {
|
||||
if (isSaving) return;
|
||||
if (isSaving)
|
||||
return;
|
||||
isSaving = true;
|
||||
|
||||
// Change button text to indicate saving
|
||||
if (saveButtonWidget) saveButtonWidget.name = "Saving...";
|
||||
console.log("Starting save process (triggered)...");
|
||||
|
||||
if (saveButtonWidget)
|
||||
saveButtonWidget.name = 'Saving...';
|
||||
console.log('Starting save process (triggered)...');
|
||||
// Print each widget and its properties for deep debugging
|
||||
if (node.widgets) {
|
||||
console.log("Widget details:");
|
||||
console.log('Widget details:');
|
||||
node.widgets.forEach((widget, index) => {
|
||||
console.log(`Widget ${index}: name="${widget.name}", type=${widget.type}, value=${widget.value}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Find the widgets that contain our values
|
||||
// Default values
|
||||
let shaderScale = 1.0;
|
||||
let shaderOctaves = 1;
|
||||
let shaderWarpStrength = 0.5;
|
||||
let shaderShapeStrength = 1.0;
|
||||
let shaderPhaseShift = 0.5;
|
||||
let shaderColorIntensity = 0.8;
|
||||
let shaderType = "tensor_field";
|
||||
let shaderShapeType = "none";
|
||||
let colorScheme = "none";
|
||||
|
||||
const currentProps = {
|
||||
shaderType: 'tensor_field',
|
||||
shaderScale: 1.0,
|
||||
shaderOctaves: 1,
|
||||
shaderWarpStrength: 0.5,
|
||||
shaderShapeType: 'none',
|
||||
shaderShapeStrength: 1.0,
|
||||
shaderPhaseShift: 0.5,
|
||||
colorScheme: 'none',
|
||||
shaderColorIntensity: 0.8,
|
||||
};
|
||||
// Try to find widgets by partial name match
|
||||
if (node.widgets) {
|
||||
for (const widget of node.widgets) {
|
||||
// Ensure widget and widget.name exist before accessing
|
||||
if (!widget || typeof widget.name !== 'string') continue;
|
||||
|
||||
if (!widget || typeof widget.name !== 'string')
|
||||
continue;
|
||||
const name = widget.name.toLowerCase();
|
||||
|
||||
// Use partial name matching for more flexibility
|
||||
if (name.includes("scale") && !name.includes("color")) {
|
||||
shaderScale = widget.value;
|
||||
if (name.includes('scale') && !name.includes('color')) {
|
||||
currentProps.shaderScale = widget.value;
|
||||
console.log(`Found shader scale: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("octaves")) {
|
||||
shaderOctaves = widget.value;
|
||||
}
|
||||
else if (name.includes('octaves')) {
|
||||
currentProps.shaderOctaves = widget.value;
|
||||
console.log(`Found octaves: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("warp")) {
|
||||
shaderWarpStrength = widget.value;
|
||||
else if (name.includes('warp')) {
|
||||
currentProps.shaderWarpStrength = widget.value;
|
||||
console.log(`Found warp strength: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("shape") && name.includes("strength")) {
|
||||
shaderShapeStrength = widget.value;
|
||||
else if (name.includes('shape') && name.includes('strength')) {
|
||||
currentProps.shaderShapeStrength = widget.value;
|
||||
console.log(`Found shape strength: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("phase")) {
|
||||
shaderPhaseShift = widget.value;
|
||||
else if (name.includes('phase')) {
|
||||
currentProps.shaderPhaseShift = widget.value;
|
||||
console.log(`Found phase: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("color") && name.includes("intensity")) {
|
||||
shaderColorIntensity = widget.value;
|
||||
else if (name.includes('color') && name.includes('intensity')) {
|
||||
currentProps.shaderColorIntensity = widget.value;
|
||||
console.log(`Found color intensity: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("shader") && name.includes("type")) {
|
||||
shaderType = widget.value;
|
||||
else if (name.includes('shader') && name.includes('type')) {
|
||||
currentProps.shaderType = widget.value;
|
||||
console.log(`Found shader type: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("shape") && name.includes("type")) {
|
||||
shaderShapeType = widget.value;
|
||||
else if (name.includes('shape') && name.includes('type')) {
|
||||
currentProps.shaderShapeType = widget.value;
|
||||
console.log(`Found shape type: ${widget.value}`);
|
||||
}
|
||||
else if (name.includes("color") && name.includes("scheme")) {
|
||||
colorScheme = widget.value;
|
||||
else if (name.includes('color') && name.includes('scheme')) {
|
||||
currentProps.colorScheme = widget.value;
|
||||
console.log(`Found color scheme: ${widget.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the properties object with the gathered values
|
||||
const currentProps = {
|
||||
shaderType: shaderType,
|
||||
shaderScale: shaderScale,
|
||||
shaderOctaves: shaderOctaves,
|
||||
shaderWarpStrength: shaderWarpStrength,
|
||||
shaderShapeType: shaderShapeType,
|
||||
shaderShapeStrength: shaderShapeStrength,
|
||||
shaderPhaseShift: shaderPhaseShift,
|
||||
colorScheme: colorScheme,
|
||||
shaderColorIntensity: shaderColorIntensity
|
||||
};
|
||||
|
||||
console.log("Saving shader properties:", currentProps);
|
||||
|
||||
console.log('Saving shader properties:', currentProps);
|
||||
try {
|
||||
// Serialize with pretty printing
|
||||
const jsonData = JSON.stringify(currentProps, null, 2);
|
||||
|
||||
// Save to localStorage with size limit and error handling
|
||||
try {
|
||||
// Check if data is too large for localStorage
|
||||
const dataSize = new Blob([jsonData]).size;
|
||||
if (dataSize > 1024 * 1024) { // 1MB limit
|
||||
console.warn("Shader params data too large for localStorage, skipping localStorage save");
|
||||
} else {
|
||||
if (dataSize > 1024 * 1024) {
|
||||
// 1MB limit
|
||||
console.warn('Shader params data too large for localStorage, skipping localStorage save');
|
||||
}
|
||||
else {
|
||||
// Remove old shader params first to free space
|
||||
const oldKeys = Object.keys(localStorage).filter(key =>
|
||||
key.startsWith('shader_params') || key.includes('shader')
|
||||
);
|
||||
oldKeys.forEach(key => {
|
||||
if (key !== 'shader_params') { // Keep only the main one
|
||||
const oldKeys = Object.keys(localStorage).filter((key) => key.startsWith('shader_params') || key.includes('shader'));
|
||||
oldKeys.forEach((key) => {
|
||||
if (key !== 'shader_params') {
|
||||
// Keep only the main one
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem('shader_params', jsonData);
|
||||
console.log("Saved to localStorage successfully");
|
||||
console.log('Saved to localStorage successfully');
|
||||
}
|
||||
} catch (localErr) {
|
||||
if (localErr.name === 'QuotaExceededError') {
|
||||
console.warn("localStorage quota exceeded, skipping localStorage save:", localErr.message);
|
||||
}
|
||||
catch (localErr) {
|
||||
const error = localErr;
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, skipping localStorage save:', error.message);
|
||||
// Try to free up space by removing old workflow data
|
||||
if (window.storageOptimizer) {
|
||||
window.storageOptimizer.forceCleanup();
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to save to localStorage:", localErr);
|
||||
}
|
||||
else {
|
||||
console.error('Failed to save to localStorage:', localErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Send the file directly to the data directory - most reliable method
|
||||
// Create a simple download link to trigger the file save
|
||||
const blob = new Blob([jsonData], {type: 'application/json'});
|
||||
const blob = new Blob([jsonData], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'shader_params.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Show instructions in console AFTER download is triggered
|
||||
console.log("%cIMPORTANT: Please save the downloaded file to the following location (overwrite if exists):", "color: red; font-weight: bold");
|
||||
console.log("%ccustom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json", "color: blue; font-weight: bold");
|
||||
|
||||
console.log('%cIMPORTANT: Please save the downloaded file to the following location (overwrite if exists):', 'color: red; font-weight: bold');
|
||||
console.log('%ccustom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json', 'color: blue; font-weight: bold');
|
||||
// Clean up and update button state on success
|
||||
setTimeout(() => {
|
||||
try {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
console.log("Shader params JSON download link removed.");
|
||||
} catch (cleanupError) {
|
||||
console.warn("Could not clean up download link:", cleanupError);
|
||||
console.log('Shader params JSON download link removed.');
|
||||
}
|
||||
|
||||
showToast("Parameters saved successfully!", "success");
|
||||
|
||||
catch (cleanupError) {
|
||||
console.warn('Could not clean up download link:', cleanupError);
|
||||
}
|
||||
showToast('Parameters saved successfully!', 'success');
|
||||
setTimeout(() => {
|
||||
if (saveButtonWidget) saveButtonWidget.name = "💾 Save Shader Parameters";
|
||||
if (saveButtonWidget)
|
||||
saveButtonWidget.name = '💾 Save Shader Parameters';
|
||||
isSaving = false;
|
||||
}, 2000);
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving shader parameters:", error);
|
||||
showToast("Error saving parameters!", "error");
|
||||
if (saveButtonWidget) saveButtonWidget.name = "Error Saving!";
|
||||
setTimeout(() => {
|
||||
if (saveButtonWidget) saveButtonWidget.name = "💾 Save Shader Parameters";
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error saving shader parameters:', error);
|
||||
showToast('Error saving parameters!', 'error');
|
||||
if (saveButtonWidget)
|
||||
saveButtonWidget.name = 'Error Saving!';
|
||||
setTimeout(() => {
|
||||
if (saveButtonWidget)
|
||||
saveButtonWidget.name = '💾 Save Shader Parameters';
|
||||
isSaving = false;
|
||||
}, 3000);
|
||||
}
|
||||
}; // --- End of Refactored Save Function ---
|
||||
|
||||
};
|
||||
// --- End of Refactored Save Function ---
|
||||
// Add save button using the refactored function
|
||||
const saveButtonWidget = this.addWidget("button", " 💾 Save Shader Parameters", null, saveParameters);
|
||||
|
||||
const saveButtonWidget = this.addWidget('button', ' 💾 Save Shader Parameters', null, saveParameters);
|
||||
// Extend the widget with a tooltip property that ComfyUI's system recognizes
|
||||
saveButtonWidget.options = saveButtonWidget.options || {};
|
||||
saveButtonWidget.options.className = "save-shader-params";
|
||||
|
||||
saveButtonWidget.options.className = 'save-shader-params';
|
||||
// Add tooltip following ComfyUI's pattern
|
||||
saveButtonWidget.tooltip = "Save parameters (Alt+S) -- file must be named shader_params.json -- always overwrite old file -- [Save location: `custom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json`][WIP]";
|
||||
|
||||
saveButtonWidget.tooltip =
|
||||
"Save parameters (Alt+S) -- file must be named shader_params.json -- always overwrite old file -- [Save location: `custom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json`][WIP]";
|
||||
// Move both the indicator and save button widgets to the end of all widgets
|
||||
// This ensures they appear at the bottom of all settings
|
||||
setTimeout(() => {
|
||||
if (this.widgets && this.widgets.length > 0) {
|
||||
// Get the indicators's current index
|
||||
// Get the indicator's current index
|
||||
const indicatorIndex = this.widgets.indexOf(indicatorWidget);
|
||||
if (indicatorIndex !== -1) {
|
||||
// Remove it from its current position
|
||||
this.widgets.splice(indicatorIndex, 1);
|
||||
}
|
||||
|
||||
// Get the save button's current index
|
||||
const buttonIndex = this.widgets.indexOf(saveButtonWidget);
|
||||
if (buttonIndex !== -1) {
|
||||
// Remove it from its current position
|
||||
this.widgets.splice(buttonIndex, 1);
|
||||
}
|
||||
|
||||
// Add them back at the end, indicator first then save button
|
||||
this.widgets.push(indicatorWidget);
|
||||
this.widgets.push(saveButtonWidget);
|
||||
|
||||
// Ensure the node is redrawn to show the updated widget positions
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// --- Keybinding Logic ---
|
||||
const handleKeyDown = (event) => {
|
||||
// Check for Alt+S and if this node is currently selected
|
||||
if (event.altKey && event.key === 's') {
|
||||
// Check if the graph canvas and selected nodes exist
|
||||
if (app.canvas && app.canvas.current_node) {
|
||||
const canvas = appInstance.canvas;
|
||||
// Check if the graph canvas and selected nodes exist
|
||||
if (canvas && canvas.current_node) {
|
||||
// Check if the currently selected node is this node
|
||||
if (app.canvas.current_node === node) {
|
||||
console.log("Alt+S detected for selected ShaderNoiseKSampler node.");
|
||||
if (canvas.current_node === node) {
|
||||
console.log('Alt+S detected for selected ShaderNoiseKSampler node.');
|
||||
event.preventDefault(); // Prevent browser's default Alt+S action
|
||||
event.stopPropagation(); // Stop event from bubbling up
|
||||
saveParameters(); // Trigger the save function
|
||||
}
|
||||
} else if (app.canvas && app.canvas.selected_nodes && Object.keys(app.canvas.selected_nodes).length === 1 && app.canvas.selected_nodes[node.id]) {
|
||||
// Fallback check for selected_nodes if current_node isn't reliable
|
||||
console.log("Alt+S detected for selected ShaderNoiseKSampler node (using selected_nodes).");
|
||||
}
|
||||
else if (canvas &&
|
||||
canvas.selected_nodes &&
|
||||
Object.keys(canvas.selected_nodes).length === 1 &&
|
||||
canvas.selected_nodes[node.id]) {
|
||||
// Fallback check for selected_nodes if current_node isn't reliable
|
||||
console.log('Alt+S detected for selected ShaderNoiseKSampler node (using selected_nodes).');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
saveParameters();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Attach the event listener to the document
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Store handler reference for removal
|
||||
this.handleKeyDown = handleKeyDown;
|
||||
|
||||
this.handleKeyDown = handleKeyDown;
|
||||
// Original onRemoved method if it exists
|
||||
const origOnRemoved = this.onRemoved;
|
||||
|
||||
// Add logic to remove the event listener when the node is removed
|
||||
this.onRemoved = function() {
|
||||
console.log("Removing keydown listener for node:", this.id);
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
|
||||
this.onRemoved = function () {
|
||||
console.log('Removing keydown listener for node:', this.id);
|
||||
if (this.handleKeyDown) {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
// Call original onRemoved if it existed
|
||||
if (origOnRemoved) {
|
||||
origOnRemoved.apply(this, arguments);
|
||||
origOnRemoved.call(this);
|
||||
}
|
||||
};
|
||||
// --- End Keybinding Logic ---
|
||||
// --- End Keybinding Logic ---
|
||||
};
|
||||
}
|
||||
},
|
||||
// Add CSS styling for the button
|
||||
async setup(app) {
|
||||
async setup(_app) {
|
||||
// Add a small CSS rule for spacing and tooltip styling
|
||||
const style = document.createElement("style");
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.save-shader-params {
|
||||
padding: 6px;
|
||||
background-color: #5c5c5c;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shader-params-indicator {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
color: #ff7700;
|
||||
background-color: rgba(255, 119, 0, 0.1);
|
||||
border-left: 3px solid #ff7700;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The tooltip container needs positioning */
|
||||
.tooltip-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Style for ComfyUI-compatible tooltips */
|
||||
.comfy-tooltip {
|
||||
visibility: hidden;
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Show tooltip on hover */
|
||||
.tooltip-container:hover .comfy-tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
.save-shader-params {
|
||||
padding: 6px;
|
||||
background-color: #5c5c5c;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shader-params-indicator {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
color: #ff7700;
|
||||
background-color: rgba(255, 119, 0, 0.1);
|
||||
border-left: 3px solid #ff7700;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The tooltip container needs positioning */
|
||||
.tooltip-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Style for ComfyUI-compatible tooltips */
|
||||
.comfy-tooltip {
|
||||
visibility: hidden;
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Show tooltip on hover */
|
||||
.tooltip-container:hover .comfy-tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Toast Notification */
|
||||
#comfy-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Toast Notification */
|
||||
#comfy-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.comfy-toast {
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
min-width: 250px;
|
||||
border-left: 4px solid #4a9eff;
|
||||
}
|
||||
.comfy-toast {
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
min-width: 250px;
|
||||
border-left: 4px solid #4a9eff;
|
||||
}
|
||||
|
||||
.comfy-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.comfy-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.comfy-toast-success {
|
||||
border-left-color: #2ecc71;
|
||||
}
|
||||
.comfy-toast-success {
|
||||
border-left-color: #2ecc71;
|
||||
}
|
||||
|
||||
.comfy-toast-error {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
.comfy-toast-error {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
|
||||
.comfy-toast-warning {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
`;
|
||||
.comfy-toast-warning {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Hook into the app's widget drawing system to add tooltip support
|
||||
// This is done after initial setup to ensure proper integration
|
||||
const originalDrawNodeWidgets = LGraphCanvas.prototype.drawNodeWidgets;
|
||||
if (originalDrawNodeWidgets) {
|
||||
LGraphCanvas.prototype.drawNodeWidgets = function(node, pos, ctx, active_widget) {
|
||||
LGraphCanvas.prototype.drawNodeWidgets = function (node, pos, ctx, active_widget) {
|
||||
// Call the original method first
|
||||
const result = originalDrawNodeWidgets.call(this, node, pos, ctx, active_widget);
|
||||
|
||||
// After rendering widgets, check for our tooltip property
|
||||
if (node && node.widgets) {
|
||||
for (const widget of node.widgets) {
|
||||
const buttonWidget = widget;
|
||||
// If widget has our tooltip property and mouse is over it
|
||||
if (widget === active_widget && widget.tooltip) {
|
||||
if (widget === active_widget && buttonWidget.tooltip) {
|
||||
// Get canvas position
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
// Create tooltip if it doesn't exist yet
|
||||
if (!widget._tooltip_elem) {
|
||||
if (!buttonWidget._tooltip_elem) {
|
||||
const tooltipContainer = document.createElement('div');
|
||||
tooltipContainer.className = 'tooltip-container';
|
||||
|
||||
const tooltip = document.createElement('span');
|
||||
tooltip.className = 'comfy-tooltip';
|
||||
tooltip.textContent = widget.tooltip;
|
||||
|
||||
tooltip.textContent = buttonWidget.tooltip;
|
||||
tooltipContainer.appendChild(tooltip);
|
||||
document.body.appendChild(tooltipContainer);
|
||||
|
||||
widget._tooltip_elem = tooltipContainer;
|
||||
buttonWidget._tooltip_elem = tooltipContainer;
|
||||
}
|
||||
|
||||
// Position tooltip
|
||||
if (widget._tooltip_elem) {
|
||||
const x = pos[0] + rect.left;
|
||||
if (buttonWidget._tooltip_elem) {
|
||||
const x = pos[0] + rect.left;
|
||||
const y = pos[1] + rect.top;
|
||||
|
||||
widget._tooltip_elem.style.left = x + 'px';
|
||||
widget._tooltip_elem.style.top = y - 20 + 'px';
|
||||
widget._tooltip_elem.style.display = 'block';
|
||||
buttonWidget._tooltip_elem.style.left = x + 'px';
|
||||
buttonWidget._tooltip_elem.style.top = y - 20 + 'px';
|
||||
buttonWidget._tooltip_elem.style.display = 'block';
|
||||
}
|
||||
} else if (widget._tooltip_elem) {
|
||||
}
|
||||
else if (buttonWidget._tooltip_elem) {
|
||||
// Hide tooltip when not hovering
|
||||
widget._tooltip_elem.style.display = 'none';
|
||||
buttonWidget._tooltip_elem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
// Register the extension
|
||||
appInstance.registerExtension(extension);
|
||||
//# sourceMappingURL=shader_params_save_button.js.map
|
||||
+568
-2369
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,904 @@
|
||||
/**
|
||||
* advanced_comparer.ts - Advanced Image Comparer Widget for ComfyUI
|
||||
* Supports multiple comparison modes: Slider, Click, Side-by-Side, Stacked, Grid, Carousel, Batch, Onion Skin
|
||||
*/
|
||||
|
||||
// @ts-ignore - Runtime ComfyUI import
|
||||
import { app } from "../../../scripts/app.js";
|
||||
// @ts-ignore - Runtime ComfyUI import
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
import type { ComfyApp, ComfyExtension, ComfyNodeData } from "../types/comfyui";
|
||||
import type { LGraphNode, IWidget } from "../types/litegraph";
|
||||
|
||||
export { };
|
||||
|
||||
console.log("AdvancedImageComparer module loaded");
|
||||
|
||||
// ============================
|
||||
// Type Definitions
|
||||
// ============================
|
||||
|
||||
interface ImageInfo {
|
||||
filename: string;
|
||||
type?: string;
|
||||
subfolder?: string;
|
||||
is_image_a?: boolean;
|
||||
}
|
||||
|
||||
interface ProcessedImage {
|
||||
name: string;
|
||||
selected: boolean;
|
||||
url: string;
|
||||
img: HTMLImageElement | null;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface WidgetValue {
|
||||
images: (ImageInfo | ProcessedImage)[];
|
||||
}
|
||||
|
||||
interface RenderCache {
|
||||
titleCanvas: HTMLCanvasElement | null;
|
||||
titleCtx: CanvasRenderingContext2D | null;
|
||||
lastWidth: number;
|
||||
lastHeight: number;
|
||||
lastTime: number;
|
||||
frameCount: number;
|
||||
frameSkip: number;
|
||||
collapsed: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
ctx: CanvasRenderingContext2D | null;
|
||||
lastWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ComparerMode = "Slider" | "Click" | "Side-by-Side" | "Stacked" | "Grid" | "Carousel" | "Batch" | "Onion Skin";
|
||||
|
||||
interface ComparerNode extends LGraphNode {
|
||||
properties: {
|
||||
comparer_mode: ComparerMode;
|
||||
onionSkinOpacity: number;
|
||||
};
|
||||
isPointerDown: boolean;
|
||||
isPointerOver: boolean;
|
||||
pointerOverPos: [number, number];
|
||||
imageIndex: number;
|
||||
comparerWidget?: AdvancedImageComparerWidget;
|
||||
layoutWidget?: IWidget;
|
||||
batchSelectorWidget?: IWidget;
|
||||
prevButton?: IWidget;
|
||||
nextButton?: IWidget;
|
||||
autoPlayButton?: IWidget;
|
||||
pairInfoWidget?: IWidget;
|
||||
batchPrevButton?: IWidget;
|
||||
batchNextButton?: IWidget;
|
||||
batchPageInfoWidget?: IWidget;
|
||||
onionSkinOpacitySlider?: IWidget;
|
||||
clearCacheButton?: IWidget;
|
||||
updateControlsVisibility: () => void;
|
||||
setIsPointerDown: (down?: boolean) => void;
|
||||
setSize: (size: [number, number]) => void;
|
||||
onExecuted?: (message: unknown) => unknown;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Cache and Helpers
|
||||
// ============================
|
||||
|
||||
const CACHE: RenderCache = {
|
||||
titleCanvas: null,
|
||||
titleCtx: null,
|
||||
lastWidth: 0,
|
||||
lastHeight: 0,
|
||||
lastTime: 0,
|
||||
frameCount: 0,
|
||||
frameSkip: 2,
|
||||
collapsed: {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
lastWidth: 0
|
||||
}
|
||||
};
|
||||
|
||||
function imageDataToUrl(data: ImageInfo): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiObj = api as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const appObj = app as any;
|
||||
return apiObj.apiURL(
|
||||
`/view?filename=${encodeURIComponent(data.filename)}&type=${encodeURIComponent(data.type || "")}&subfolder=${encodeURIComponent(data.subfolder || "")}${appObj.getPreviewFormatParam()}${appObj.getRandParam()}`
|
||||
);
|
||||
}
|
||||
|
||||
function drawGoldenEyeball(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, size: number, shimmerPosition: number): void {
|
||||
const eyeWidth = size * 1.6;
|
||||
const eyeHeight = size * 1.0;
|
||||
const irisRadius = size * 0.35;
|
||||
const pupilRadius = size * 0.15;
|
||||
|
||||
ctx.save();
|
||||
|
||||
const baseGradient = ctx.createLinearGradient(0, centerY - size * 0.7, 0, centerY + size * 0.7);
|
||||
baseGradient.addColorStop(0, "#B8860B");
|
||||
baseGradient.addColorStop(0.5, "#FFD700");
|
||||
baseGradient.addColorStop(1, "#B8860B");
|
||||
|
||||
const highlightWidth = eyeWidth * 0.4;
|
||||
const highlightX = -highlightWidth + (eyeWidth + highlightWidth) * shimmerPosition;
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(
|
||||
centerX + highlightX - highlightWidth / 2, 0,
|
||||
centerX + highlightX + highlightWidth / 2, 0
|
||||
);
|
||||
|
||||
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 shadows
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.3)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = "round";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(centerX + 2, centerY + 2, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX + 2, centerY + 2, irisRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX + 2, centerY + 2, pupilRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw rays
|
||||
const rayCount = 8;
|
||||
const rayLength = size * 0.7;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Draw golden outlines
|
||||
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.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Iris texture
|
||||
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();
|
||||
}
|
||||
|
||||
// Shimmer effect
|
||||
ctx.strokeStyle = shimmerGradient;
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(centerX, centerY, eyeWidth / 2, eyeHeight / 2, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, irisRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, pupilRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Glow effect
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function drawGradientTitle(node: ComparerNode, ctx: CanvasRenderingContext2D): void {
|
||||
const titleHeight = node.flags.collapsed ? 20 : 30;
|
||||
const width = node.flags.collapsed ? 190 : node.size[0];
|
||||
const fullHeight = node.size[1];
|
||||
const eyeballY = node.flags.collapsed ? titleHeight / 2 : 25;
|
||||
const eyeballSize = node.flags.collapsed ? 6 : 10;
|
||||
|
||||
CACHE.frameCount = (CACHE.frameCount + 1) % (CACHE.frameSkip + 1);
|
||||
const shouldUpdateAnimation = CACHE.frameCount === 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, fullHeight);
|
||||
gradient.addColorStop(0, "#000000");
|
||||
gradient.addColorStop(0.2, "#101010");
|
||||
gradient.addColorStop(1, "#101010");
|
||||
|
||||
let shimmerPosition = 0.5;
|
||||
if (shouldUpdateAnimation) {
|
||||
const time = Date.now() / 3000;
|
||||
shimmerPosition = (Math.sin(time) + 1) / 2;
|
||||
CACHE.lastTime = time;
|
||||
} else {
|
||||
const time = CACHE.lastTime || Date.now() / 3000;
|
||||
shimmerPosition = (Math.sin(time) + 1) / 2;
|
||||
}
|
||||
|
||||
if (node.flags.collapsed) {
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, titleHeight);
|
||||
drawGoldenEyeball(ctx, width / 2, titleHeight / 2, eyeballSize, shimmerPosition);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
|
||||
if (!node.flags.collapsed) {
|
||||
const cornerRadius = 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(width, 0);
|
||||
ctx.lineTo(width, fullHeight - cornerRadius);
|
||||
ctx.arcTo(width, fullHeight, width - cornerRadius, fullHeight, cornerRadius);
|
||||
ctx.lineTo(cornerRadius, fullHeight);
|
||||
ctx.arcTo(0, fullHeight, 0, fullHeight - cornerRadius, cornerRadius);
|
||||
ctx.lineTo(0, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(0, 0, width, fullHeight);
|
||||
}
|
||||
|
||||
drawGoldenEyeball(ctx, width / 2, eyeballY, eyeballSize, shimmerPosition);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Widget Class
|
||||
// ============================
|
||||
|
||||
class AdvancedImageComparerWidget {
|
||||
name: string;
|
||||
type = "custom" as const;
|
||||
node: ComparerNode;
|
||||
_value: WidgetValue;
|
||||
selected: ProcessedImage[];
|
||||
imgs: HTMLImageElement[];
|
||||
options = { serialize: false };
|
||||
y = 0;
|
||||
last_y = 0;
|
||||
imagesA: ProcessedImage[] = [];
|
||||
imagesB: ProcessedImage[] = [];
|
||||
currentPairIndex = 0;
|
||||
maxPairs = 0;
|
||||
animationFrame: ReturnType<typeof setInterval> | null = null;
|
||||
autoPlayEnabled = false;
|
||||
autoPlaySpeed = 2000;
|
||||
currentBatchPage = 0;
|
||||
pairsPerPage = 3;
|
||||
maxBatchPages = 0;
|
||||
|
||||
constructor(name: string, node: ComparerNode) {
|
||||
this.name = name;
|
||||
this.node = node;
|
||||
this._value = { images: [] };
|
||||
this.selected = [];
|
||||
this.imgs = [];
|
||||
}
|
||||
|
||||
set value(v: WidgetValue) {
|
||||
const images = v.images || [];
|
||||
const imagesA = images.filter((img): img is ImageInfo => 'is_image_a' in img && img.is_image_a === true);
|
||||
const imagesB = images.filter((img): img is ImageInfo => 'is_image_a' in img && img.is_image_a === false);
|
||||
|
||||
this.imagesA = imagesA.map((img, index) => ({
|
||||
name: `A${index + 1}`,
|
||||
selected: true,
|
||||
url: imageDataToUrl(img),
|
||||
img: null,
|
||||
index
|
||||
}));
|
||||
|
||||
this.imagesB = imagesB.map((img, index) => ({
|
||||
name: `B${index + 1}`,
|
||||
selected: true,
|
||||
url: imageDataToUrl(img),
|
||||
img: null,
|
||||
index
|
||||
}));
|
||||
|
||||
this.maxPairs = Math.max(this.imagesA.length, this.imagesB.length);
|
||||
this.currentPairIndex = 0;
|
||||
this.maxBatchPages = Math.ceil(this.maxPairs / this.pairsPerPage);
|
||||
this.currentBatchPage = 0;
|
||||
this._value = { images: [...this.imagesA, ...this.imagesB] };
|
||||
this.updateSelectedPair();
|
||||
this.loadAllImages();
|
||||
|
||||
if (this.node?.updateControlsVisibility) {
|
||||
this.node.updateControlsVisibility();
|
||||
}
|
||||
|
||||
if (this.node) {
|
||||
const minWidth = 700, minHeight = 600;
|
||||
const [w, h] = this.node.size;
|
||||
this.node.setSize([Math.max(w, minWidth), Math.max(h, minHeight)]);
|
||||
this.node.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
get value(): WidgetValue {
|
||||
return this._value || { images: [] };
|
||||
}
|
||||
|
||||
loadAllImages(): void {
|
||||
[...this.imagesA, ...this.imagesB].forEach(imageData => {
|
||||
if (!imageData.img && imageData.url) {
|
||||
imageData.img = new Image();
|
||||
imageData.img.onload = () => this.node.setDirtyCanvas(true, false);
|
||||
imageData.img.onerror = (e) => console.error("Image load failed:", imageData.name, e);
|
||||
imageData.img.src = imageData.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSelected(selected: ProcessedImage[]): void {
|
||||
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.src = sel.url;
|
||||
}
|
||||
if (sel.img) this.imgs.push(sel.img);
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, node: ComparerNode, width: number, y: number, height: number): void {
|
||||
this.y = y;
|
||||
this.last_y = y;
|
||||
const [, nodeHeight] = node.size;
|
||||
const availableHeight = Math.max(200, 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: this.drawSliderMode(ctx, y, width, availableHeight); break;
|
||||
}
|
||||
|
||||
if (["Grid", "Batch"].includes(mode) && this.maxPairs > 1) {
|
||||
this.drawBatchControls(ctx, y, width, availableHeight);
|
||||
}
|
||||
if (mode === "Carousel" && this.maxPairs > 1) {
|
||||
this.drawPairIndicator(ctx, y + availableHeight - 25, width);
|
||||
}
|
||||
}
|
||||
|
||||
drawClickMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
const idx = this.node.isPointerDown ? 1 : 0;
|
||||
this.drawImage(ctx, this.selected[idx], y, width, h);
|
||||
}
|
||||
|
||||
drawSideBySideMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
if (this.selected[0]) this.drawImageSideBySide(ctx, this.selected[0], y, width, h, 0);
|
||||
if (this.selected[1]) this.drawImageSideBySide(ctx, this.selected[1], y, width, h, 1);
|
||||
}
|
||||
|
||||
drawStackedMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
if (this.selected[0]) this.drawImageStacked(ctx, this.selected[0], y, width, h, 0);
|
||||
if (this.selected[1]) this.drawImageStacked(ctx, this.selected[1], y, width, h, 1);
|
||||
}
|
||||
|
||||
drawSliderMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
if (this.selected[0]) this.drawImage(ctx, this.selected[0], y, width, h);
|
||||
if (this.selected[1] && this.node.isPointerOver) {
|
||||
this.drawImage(ctx, this.selected[1], y, width, h, this.node.pointerOverPos[0]);
|
||||
}
|
||||
}
|
||||
|
||||
drawGridMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
const pairs = Math.min(this.maxPairs, 64);
|
||||
const cols = Math.ceil(Math.sqrt(pairs * 2));
|
||||
const rows = Math.ceil((pairs * 2) / cols);
|
||||
const cw = width / cols, ch = (h - 40) / rows;
|
||||
let ci = 0;
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
if (this.imagesA[i]?.img) {
|
||||
this.drawImageInCell(ctx, this.imagesA[i], y + Math.floor(ci / cols) * ch, (ci % cols) * cw, cw, ch, `A${i + 1}`);
|
||||
ci++;
|
||||
}
|
||||
if (this.imagesB[i]?.img) {
|
||||
this.drawImageInCell(ctx, this.imagesB[i], y + Math.floor(ci / cols) * ch, (ci % cols) * cw, cw, ch, `B${i + 1}`);
|
||||
ci++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCarouselMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
const a = this.imagesA[this.currentPairIndex], b = this.imagesB[this.currentPairIndex];
|
||||
if (a?.img) this.drawImageSideBySide(ctx, a, y, width, h, 0);
|
||||
if (b?.img) this.drawImageSideBySide(ctx, b, y, width, h, 1);
|
||||
}
|
||||
|
||||
drawBatchMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
const ph = (h - 40) / this.pairsPerPage;
|
||||
const start = this.currentBatchPage * this.pairsPerPage;
|
||||
for (let i = 0; i < this.pairsPerPage && start + i < this.maxPairs; i++) {
|
||||
const idx = start + i, py = y + i * ph;
|
||||
if (this.imagesA[idx]?.img) this.drawImageInPair(ctx, this.imagesA[idx], py, 0, width / 2, ph, 0);
|
||||
if (this.imagesB[idx]?.img) this.drawImageInPair(ctx, this.imagesB[idx], py, width / 2, width / 2, ph, 1);
|
||||
if (i < this.pairsPerPage - 1 && idx < this.maxPairs - 1) {
|
||||
ctx.beginPath(); ctx.moveTo(0, py + ph); ctx.lineTo(width, py + ph);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawOnionSkinMode(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
const opacity = this.node.properties?.onionSkinOpacity || 0.5;
|
||||
if (this.selected[0]) this.drawImage(ctx, this.selected[0], y, width, h);
|
||||
if (this.selected[1]) {
|
||||
ctx.save(); ctx.globalAlpha = opacity;
|
||||
this.drawImage(ctx, this.selected[1], y, width, h);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawImage(ctx: CanvasRenderingContext2D, imageData: ProcessedImage | undefined, y: number, nodeWidth: number, availableHeight: number, cropX?: number): void {
|
||||
if (!imageData?.img?.naturalWidth) return;
|
||||
const img = imageData.img, pad = 3;
|
||||
const uw = nodeWidth - pad * 2, uh = availableHeight - pad * 2;
|
||||
const ia = img.naturalWidth / img.naturalHeight, ua = uw / uh;
|
||||
let tw: number, th: number;
|
||||
if (ia > ua) { tw = uw; th = uw / ia; } else { th = uh; tw = uh * ia; }
|
||||
const dx = pad + (uw - tw) / 2, dy = y + pad + (uh - th) / 2;
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.rect(pad, y + pad, uw, uh); ctx.clip();
|
||||
if (cropX && cropX > dx) {
|
||||
const wm = img.naturalWidth / tw, sw = Math.max(0, (cropX - dx) * wm), dw = Math.max(0, cropX - dx);
|
||||
ctx.drawImage(img, 0, 0, sw, img.naturalHeight, dx, dy, dw, th);
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, tw, th);
|
||||
}
|
||||
if (cropX != null && cropX > dx && cropX < dx + tw) {
|
||||
ctx.beginPath(); ctx.moveTo(cropX, dy); ctx.lineTo(cropX, dy + th);
|
||||
ctx.globalCompositeOperation = "difference"; ctx.strokeStyle = "rgba(255,255,255,1)"; ctx.lineWidth = 2; ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawImageSideBySide(ctx: CanvasRenderingContext2D, imageData: ProcessedImage, y: number, nodeWidth: number, availableHeight: number, imageIndex: number): void {
|
||||
if (!imageData?.img?.naturalWidth) return;
|
||||
const img = imageData.img, hw = nodeWidth / 2, pad = 3, sep = 1;
|
||||
const uw = hw - pad - sep / 2, uh = availableHeight - pad * 2;
|
||||
const ia = img.naturalWidth / img.naturalHeight, ua = uw / uh;
|
||||
let tw: number, th: number;
|
||||
if (ia > ua) { tw = uw; th = uw / ia; } else { th = uh; tw = uh * ia; }
|
||||
const dx = imageIndex === 0 ? pad + (uw - tw) / 2 : hw + sep / 2 + pad + (uw - tw) / 2;
|
||||
const dy = y + pad + (uh - th) / 2;
|
||||
ctx.save();
|
||||
const cx = imageIndex === 0 ? 0 : hw + sep / 2, cw = imageIndex === 0 ? hw - sep / 2 : hw - sep / 2;
|
||||
ctx.beginPath(); ctx.rect(cx, y, cw, availableHeight); ctx.clip();
|
||||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, tw, th);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillRect(dx, dy, 25, 18);
|
||||
ctx.fillStyle = "white"; ctx.font = "11px Arial"; ctx.textAlign = "center"; ctx.fillText(imageData.name, dx + 12, dy + 13);
|
||||
ctx.restore();
|
||||
if (imageIndex === 0) {
|
||||
ctx.save(); ctx.beginPath(); ctx.moveTo(hw, y + pad); ctx.lineTo(hw, y + availableHeight - pad);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2; ctx.stroke(); ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawImageStacked(ctx: CanvasRenderingContext2D, imageData: ProcessedImage, y: number, nodeWidth: number, availableHeight: number, imageIndex: number): void {
|
||||
if (!imageData?.img?.naturalWidth) return;
|
||||
const img = imageData.img, hh = availableHeight / 2, pad = 3, sep = 1;
|
||||
const uw = nodeWidth - pad * 2, uh = hh - pad - sep / 2;
|
||||
const ia = img.naturalWidth / img.naturalHeight, ua = uw / uh;
|
||||
let tw: number, th: number;
|
||||
if (ia > ua) { tw = uw; th = uw / ia; } else { th = uh; tw = uh * ia; }
|
||||
const dx = pad + (uw - tw) / 2;
|
||||
const dy = imageIndex === 0 ? y + pad + (uh - th) / 2 : y + hh + sep / 2 + pad + (uh - th) / 2;
|
||||
ctx.save();
|
||||
const cy = imageIndex === 0 ? y : y + hh + sep / 2, ch = imageIndex === 0 ? hh - sep / 2 : hh - sep / 2;
|
||||
ctx.beginPath(); ctx.rect(0, cy, nodeWidth, ch); ctx.clip();
|
||||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, tw, th);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillRect(dx, dy, 25, 18);
|
||||
ctx.fillStyle = "white"; ctx.font = "11px Arial"; ctx.textAlign = "center"; ctx.fillText(imageData.name, dx + 12, dy + 13);
|
||||
ctx.restore();
|
||||
if (imageIndex === 0) {
|
||||
ctx.save(); ctx.beginPath(); ctx.moveTo(pad, y + hh); ctx.lineTo(nodeWidth - pad, y + hh);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2; ctx.stroke(); ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawImageInCell(ctx: CanvasRenderingContext2D, imageData: ProcessedImage, y: number, x: number, cw: number, ch: number, label: string): void {
|
||||
if (!imageData?.img?.naturalWidth) return;
|
||||
const img = imageData.img, pad = 2, uw = cw - pad * 2, uh = ch - pad * 2;
|
||||
const ia = img.naturalWidth / img.naturalHeight, ca = uw / uh;
|
||||
let tw: number, th: number;
|
||||
if (ia > ca) { tw = uw; th = uw / ia; } else { th = uh; tw = uh * ia; }
|
||||
const dx = x + pad + (uw - tw) / 2, dy = y + pad + (uh - th) / 2;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x + pad, y + pad, uw, uh); ctx.clip();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineWidth = 1; ctx.strokeRect(x + pad, y + pad, uw, uh);
|
||||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, tw, th);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillRect(dx, dy, 28, 16);
|
||||
ctx.fillStyle = "white"; ctx.font = "10px Arial"; ctx.textAlign = "center"; ctx.fillText(label, dx + 14, dy + 11);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawImageInPair(ctx: CanvasRenderingContext2D, imageData: ProcessedImage, y: number, x: number, pw: number, ph: number, imageIndex: number): void {
|
||||
if (!imageData?.img?.naturalWidth) return;
|
||||
const img = imageData.img, pad = 3, uw = pw - pad * 2, uh = ph - pad * 2;
|
||||
const ia = img.naturalWidth / img.naturalHeight, pa = uw / uh;
|
||||
let tw: number, th: number;
|
||||
if (ia > pa) { tw = uw; th = uw / ia; } else { th = uh; tw = uh * ia; }
|
||||
const dx = x + pad + (uw - tw) / 2, dy = y + pad + (uh - th) / 2;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x + pad, y + pad, uw, uh); ctx.clip();
|
||||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, tw, th);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillRect(dx, dy, 30, 16);
|
||||
ctx.fillStyle = "white"; ctx.font = "11px Arial"; ctx.textAlign = "center"; ctx.fillText(imageData.name, dx + 15, dy + 11);
|
||||
ctx.restore();
|
||||
if (imageIndex === 0 && pw < this.node.size[0]) {
|
||||
ctx.save(); ctx.beginPath(); ctx.moveTo(x + pw, y + pad); ctx.lineTo(x + pw, y + ph - pad);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2; ctx.stroke(); ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawBatchControls(ctx: CanvasRenderingContext2D, y: number, width: number, h: number): void {
|
||||
const cy = y + h - 30;
|
||||
ctx.save(); ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0, cy, width, 30);
|
||||
ctx.fillStyle = "white"; ctx.font = "12px Arial"; ctx.textAlign = "left";
|
||||
ctx.fillText(`Images: A(${this.imagesA.length}) B(${this.imagesB.length})`, 10, cy + 18);
|
||||
ctx.textAlign = "right";
|
||||
const mode = this.node.properties?.comparer_mode || "Slider";
|
||||
if (mode === "Grid") ctx.fillText(`Showing ${Math.min(this.maxPairs, 64)} pairs`, width - 10, cy + 18);
|
||||
else if (mode === "Batch") {
|
||||
const s = this.currentBatchPage * this.pairsPerPage + 1, e = Math.min((this.currentBatchPage + 1) * this.pairsPerPage, this.maxPairs);
|
||||
ctx.fillText(`Showing pairs ${s}-${e} of ${this.maxPairs}`, width - 10, cy + 18);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawPairIndicator(ctx: CanvasRenderingContext2D, y: number, width: number): void {
|
||||
ctx.save();
|
||||
const ds = 8, dp = 12, tw = this.maxPairs * dp - (dp - ds), sx = (width - tw) / 2;
|
||||
for (let i = 0; i < this.maxPairs; i++) {
|
||||
ctx.beginPath(); ctx.arc(sx + i * dp + ds / 2, y + ds / 2, ds / 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = i === this.currentPairIndex ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.4)";
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
nextPair(): void { if (this.currentPairIndex < this.maxPairs - 1) { this.currentPairIndex++; this.updateSelectedPair(); this.node.setDirtyCanvas(true, false); } }
|
||||
previousPair(): void { if (this.currentPairIndex > 0) { this.currentPairIndex--; this.updateSelectedPair(); this.node.setDirtyCanvas(true, false); } }
|
||||
toggleAutoPlay(): void { this.autoPlayEnabled = !this.autoPlayEnabled; this.autoPlayEnabled ? this.startAutoPlay() : this.stopAutoPlay(); this.updateNodeControls(); this.node.setDirtyCanvas(true, false); }
|
||||
nextBatchPage(): void { if (this.currentBatchPage < this.maxBatchPages - 1) { this.currentBatchPage++; this.updateNodeControls(); this.node.setDirtyCanvas(true, false); } }
|
||||
previousBatchPage(): void { if (this.currentBatchPage > 0) { this.currentBatchPage--; this.updateNodeControls(); this.node.setDirtyCanvas(true, false); } }
|
||||
|
||||
updateNodeControls(): void {
|
||||
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}`;
|
||||
}
|
||||
|
||||
updateSelectedPair(): void {
|
||||
const mode = this.node.properties?.comparer_mode || "Slider";
|
||||
if (["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(mode)) {
|
||||
const processed: ProcessedImage[] = [];
|
||||
if (this.imagesA[this.currentPairIndex]) processed.push(this.imagesA[this.currentPairIndex]);
|
||||
if (this.imagesB[this.currentPairIndex]) processed.push(this.imagesB[this.currentPairIndex]);
|
||||
this.setSelected(processed);
|
||||
}
|
||||
this.updateNodeControls();
|
||||
}
|
||||
|
||||
startAutoPlay(): void {
|
||||
if (this.animationFrame) clearInterval(this.animationFrame);
|
||||
this.animationFrame = setInterval(() => {
|
||||
this.currentPairIndex = this.currentPairIndex >= this.maxPairs - 1 ? 0 : this.currentPairIndex + 1;
|
||||
this.updateSelectedPair(); this.node.setDirtyCanvas(true, false);
|
||||
}, this.autoPlaySpeed);
|
||||
}
|
||||
|
||||
stopAutoPlay(): void { if (this.animationFrame) { clearInterval(this.animationFrame); this.animationFrame = null; } }
|
||||
|
||||
computeSize(width: number): [number, number] {
|
||||
const mode = this.node?.properties?.comparer_mode || "Slider";
|
||||
let height = Math.max(500, width);
|
||||
switch (mode) {
|
||||
case "Stacked": height = Math.max(700, width * 1.5); break;
|
||||
case "Side-by-Side": height = Math.max(500, width); break;
|
||||
case "Grid": const p = Math.min(this.maxPairs || 1, 64), c = Math.ceil(Math.sqrt(p * 2)), r = Math.ceil((p * 2) / c); height = Math.max(500, (width / c) * r + 100); break;
|
||||
case "Carousel": height = Math.max(450, width * 0.9 + 100); break;
|
||||
case "Batch": height = Math.max(550, Math.min(this.maxPairs || 1, 3) * (width * 0.6) + 100); break;
|
||||
default: height = Math.max(400, width * 0.9); break;
|
||||
}
|
||||
return [width, height];
|
||||
}
|
||||
|
||||
mouse(event: PointerEvent, pos: [number, number], node: ComparerNode): boolean {
|
||||
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" && mode === "Grid" && this.maxPairs > 1) {
|
||||
const p = Math.min(this.maxPairs, 64), c = Math.ceil(Math.sqrt(p * 2)), r = Math.ceil((p * 2) / c);
|
||||
const cw = node.size[0] / c, wh = node.size[1] - this.y - 10, ch = (wh - 40) / r;
|
||||
const ci = Math.floor(pos[1] / ch) * c + Math.floor(pos[0] / cw), ii = Math.floor(ci / 2);
|
||||
if (ii < this.maxPairs) { this.currentPairIndex = ii; node.properties.comparer_mode = "Carousel"; if (node.layoutWidget) node.layoutWidget.value = "Carousel"; node.updateControlsVisibility(); node.setDirtyCanvas(true, false); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onRemoved(): void { this.stopAutoPlay(); }
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Extension Registration
|
||||
// ============================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(app as any).registerExtension({
|
||||
name: "AdvancedImageComparer",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async beforeRegisterNodeDef(nodeType: any, nodeData: ComfyNodeData) {
|
||||
if (nodeData.name !== "AdvancedImageComparer") return;
|
||||
|
||||
nodeType.prototype.properties = nodeType.prototype.properties || { comparer_mode: "Slider", onionSkinOpacity: 0.5 };
|
||||
nodeType["@comparer_mode"] = { type: "combo", values: ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"] };
|
||||
|
||||
const origOnDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (this: ComparerNode, ctx: CanvasRenderingContext2D) {
|
||||
if (origOnDrawForeground) origOnDrawForeground.call(this, ctx);
|
||||
drawGradientTitle(this, ctx);
|
||||
};
|
||||
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function (this: ComparerNode) {
|
||||
if (origOnRemoved) origOnRemoved.call(this);
|
||||
CACHE.titleCanvas = null; CACHE.titleCtx = null;
|
||||
CACHE.collapsed.canvas = null; CACHE.collapsed.ctx = null;
|
||||
};
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (this: ComparerNode) {
|
||||
if (onNodeCreated) onNodeCreated.call(this);
|
||||
this.properties = this.properties || { comparer_mode: "Slider", onionSkinOpacity: 0.5 };
|
||||
this.isPointerDown = false;
|
||||
this.isPointerOver = false;
|
||||
this.pointerOverPos = [0, 0];
|
||||
this.imageIndex = 0;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const self = this as any;
|
||||
|
||||
self.layoutWidget = self.addWidget("combo", "Layout Mode", this.properties.comparer_mode, (value: ComparerMode) => {
|
||||
this.properties.comparer_mode = value;
|
||||
this.updateControlsVisibility();
|
||||
self.setDirtyCanvas(true, false);
|
||||
}, { values: ["Slider", "Click", "Side-by-Side", "Stacked", "Grid", "Carousel", "Batch", "Onion Skin"] });
|
||||
|
||||
self.batchSelectorWidget = self.addWidget("combo", "View Pair", "1", (value: string) => {
|
||||
const idx = parseInt(value) - 1;
|
||||
if (this.comparerWidget && idx >= 0 && idx < this.comparerWidget.maxPairs) {
|
||||
this.comparerWidget.currentPairIndex = idx;
|
||||
this.comparerWidget.updateSelectedPair();
|
||||
self.setDirtyCanvas(true, false);
|
||||
}
|
||||
}, { values: ["1"] });
|
||||
|
||||
self.prevButton = self.addWidget("button", "◀ Previous", null, () => { this.comparerWidget?.previousPair(); });
|
||||
self.nextButton = self.addWidget("button", "Next ▶", null, () => { this.comparerWidget?.nextPair(); });
|
||||
self.autoPlayButton = self.addWidget("button", "▶ Play", null, () => {
|
||||
this.comparerWidget?.toggleAutoPlay();
|
||||
self.autoPlayButton.name = this.comparerWidget?.autoPlayEnabled ? "⏸ Pause" : "▶ Play";
|
||||
});
|
||||
self.pairInfoWidget = self.addWidget("text", "Pair Info", "1 / 1", () => { }, {});
|
||||
self.pairInfoWidget.disabled = true;
|
||||
|
||||
self.batchPrevButton = self.addWidget("button", "◀ Prev Page", null, () => { this.comparerWidget?.previousBatchPage(); });
|
||||
self.batchNextButton = self.addWidget("button", "Next Page ▶", null, () => { this.comparerWidget?.nextBatchPage(); });
|
||||
self.batchPageInfoWidget = self.addWidget("text", "Page Info", "Page 1 / 1", () => { }, {});
|
||||
self.batchPageInfoWidget.disabled = true;
|
||||
|
||||
self.onionSkinOpacitySlider = self.addWidget("slider", "Opacity B", this.properties.onionSkinOpacity, (value: number) => {
|
||||
this.properties.onionSkinOpacity = value;
|
||||
self.setDirtyCanvas(true, false);
|
||||
}, { min: 0.0, max: 1.0, step: 0.01 });
|
||||
|
||||
self.clearCacheButton = self.addWidget("button", "Clear Cache", null, () => {
|
||||
console.log("[AdvancedImageComparer] Clearing image cache");
|
||||
});
|
||||
self.clearCacheButton.hidden = true;
|
||||
|
||||
this.comparerWidget = self.addCustomWidget(new AdvancedImageComparerWidget("advanced_comparer", this));
|
||||
this.updateControlsVisibility();
|
||||
self.setSize([700, 600]);
|
||||
self.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
nodeType.prototype.updateControlsVisibility = function (this: ComparerNode) {
|
||||
const mode = this.properties.comparer_mode;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const self = this as any;
|
||||
const hasMultiplePairs = this.comparerWidget && this.comparerWidget.maxPairs > 1;
|
||||
const showBatchSelector = hasMultiplePairs && ["Slider", "Click", "Side-by-Side", "Stacked", "Onion Skin"].includes(mode);
|
||||
const showCarouselControls = mode === "Carousel";
|
||||
const showBatchPagination = mode === "Batch" && this.comparerWidget && this.comparerWidget.maxBatchPages > 1;
|
||||
const showOnionSkinSlider = mode === "Onion Skin";
|
||||
|
||||
if (self.batchSelectorWidget) {
|
||||
self.batchSelectorWidget.hidden = !showBatchSelector;
|
||||
if (showBatchSelector && this.comparerWidget) {
|
||||
const opts = []; for (let i = 1; i <= this.comparerWidget.maxPairs; i++) opts.push(i.toString());
|
||||
self.batchSelectorWidget.options.values = opts;
|
||||
self.batchSelectorWidget.value = (this.comparerWidget.currentPairIndex + 1).toString();
|
||||
}
|
||||
}
|
||||
if (self.prevButton) self.prevButton.hidden = !showCarouselControls;
|
||||
if (self.nextButton) self.nextButton.hidden = !showCarouselControls;
|
||||
if (self.autoPlayButton) self.autoPlayButton.hidden = !showCarouselControls;
|
||||
if (self.pairInfoWidget) {
|
||||
self.pairInfoWidget.hidden = !showCarouselControls;
|
||||
if (showCarouselControls && this.comparerWidget) self.pairInfoWidget.value = `${this.comparerWidget.currentPairIndex + 1} / ${this.comparerWidget.maxPairs}`;
|
||||
}
|
||||
if (self.batchPrevButton) self.batchPrevButton.hidden = !showBatchPagination;
|
||||
if (self.batchNextButton) self.batchNextButton.hidden = !showBatchPagination;
|
||||
if (self.batchPageInfoWidget) {
|
||||
self.batchPageInfoWidget.hidden = !showBatchPagination;
|
||||
if (showBatchPagination && this.comparerWidget) self.batchPageInfoWidget.value = `Page ${this.comparerWidget.currentBatchPage + 1} / ${this.comparerWidget.maxBatchPages}`;
|
||||
}
|
||||
if (self.onionSkinOpacitySlider) {
|
||||
self.onionSkinOpacitySlider.hidden = !showOnionSkinSlider;
|
||||
if (showOnionSkinSlider) self.onionSkinOpacitySlider.value = this.properties.onionSkinOpacity;
|
||||
}
|
||||
};
|
||||
|
||||
const originalComputeSize = nodeType.prototype.computeSize;
|
||||
nodeType.prototype.computeSize = function (this: ComparerNode, out?: [number, number]): [number, number] {
|
||||
const size = originalComputeSize ? originalComputeSize.call(this, out) : [700, 600] as [number, number];
|
||||
if (this.comparerWidget) {
|
||||
const widgetSize = this.comparerWidget.computeSize(size[0]);
|
||||
let extra = 60;
|
||||
const mode = this.properties.comparer_mode;
|
||||
const hasMultiplePairs = this.comparerWidget.maxPairs > 1;
|
||||
if (mode === "Carousel") extra += 120;
|
||||
else if (["Slider", "Click", "Side-by-Side", "Stacked"].includes(mode) && hasMultiplePairs) extra += 35;
|
||||
else if (mode === "Batch" && this.comparerWidget.maxBatchPages > 1) extra += 90;
|
||||
else if (mode === "Onion Skin") { extra += 35; if (hasMultiplePairs) extra += 35; }
|
||||
size[1] = Math.max(size[1], widgetSize[1] + extra);
|
||||
}
|
||||
return size;
|
||||
};
|
||||
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
nodeType.prototype.onExecuted = function (this: ComparerNode, message: any) {
|
||||
let result;
|
||||
if (originalOnExecuted) result = originalOnExecuted.call(this, message);
|
||||
if (message && typeof message === 'object') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const self = this as any;
|
||||
const images = message.ui?.images || message.images;
|
||||
if (images && images.length > 0 && this.comparerWidget) {
|
||||
this.comparerWidget.value = { images };
|
||||
const [w, h] = self.size;
|
||||
self.setSize([Math.max(w, 700), Math.max(h, 600)]);
|
||||
self.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
return result || message;
|
||||
};
|
||||
|
||||
nodeType.prototype.setIsPointerDown = function (this: ComparerNode, down = this.isPointerDown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const self = this as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const appObj = app as any;
|
||||
const newIsDown = down && !!appObj.canvas.pointer_is_down;
|
||||
if (this.isPointerDown !== newIsDown) { this.isPointerDown = newIsDown; self.setDirtyCanvas(true, false); }
|
||||
this.imageIndex = this.isPointerDown ? 1 : 0;
|
||||
if (this.isPointerDown) requestAnimationFrame(() => { this.setIsPointerDown(); });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(nodeType.prototype as any).onMouseDown = function (this: ComparerNode) { this.setIsPointerDown(true); return false; };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(nodeType.prototype as any).onMouseEnter = function (this: ComparerNode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const appObj = app as any;
|
||||
this.setIsPointerDown(!!appObj.canvas.pointer_is_down);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.isPointerOver = true; (this as any).setDirtyCanvas(true, false);
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(nodeType.prototype as any).onMouseLeave = function (this: ComparerNode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.setIsPointerDown(false); this.isPointerOver = false; (this as any).setDirtyCanvas(true, false);
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(nodeType.prototype as any).onMouseMove = function (this: ComparerNode, _event: MouseEvent, pos: [number, number]) {
|
||||
this.pointerOverPos = [...pos];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (this.properties.comparer_mode === "Slider") (this as any).setDirtyCanvas(true, false);
|
||||
else if (this.properties.comparer_mode === "Click") this.imageIndex = pos[0] > this.size[0] / 2 ? 1 : 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
console.log("AdvancedImageComparer node setup complete");
|
||||
}
|
||||
} as ComfyExtension);
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* gradient_title.ts - Adds a custom gradient title to ShaderNoiseKSampler node
|
||||
*/
|
||||
|
||||
// Type imports from our local type definitions
|
||||
import type {
|
||||
ComfyApp,
|
||||
ComfyNodeData,
|
||||
ComfyExtension,
|
||||
NodeTypeConstructor,
|
||||
LGraphNode,
|
||||
} from '../types/comfyui';
|
||||
|
||||
/** Cache structure for rendering optimization */
|
||||
interface RenderCache {
|
||||
titleCanvas: HTMLCanvasElement | null;
|
||||
titleCtx: CanvasRenderingContext2D | null;
|
||||
lastWidth: number;
|
||||
lastHeight: number;
|
||||
lastTime: number;
|
||||
frameCount: number;
|
||||
frameSkip: number;
|
||||
collapsed: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
ctx: CanvasRenderingContext2D | null;
|
||||
lastWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Extended node type with shader-specific flags */
|
||||
interface ShaderNode extends LGraphNode {
|
||||
flags: {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Cache for rendering optimization
|
||||
const CACHE: RenderCache = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
// Declare the app variable that will be imported at runtime from ComfyUI
|
||||
declare const app: ComfyApp;
|
||||
|
||||
// Import app from ComfyUI at runtime (this import is resolved by the browser)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - ComfyUI provides this at runtime
|
||||
import { app as comfyApp } from '../../scripts/app.js';
|
||||
|
||||
// Use the runtime import
|
||||
const appInstance: ComfyApp = comfyApp;
|
||||
|
||||
// Define the extension
|
||||
const extension: ComfyExtension = {
|
||||
name: 'ShaderNoiseKSampler.GradientTitle',
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: NodeTypeConstructor,
|
||||
nodeData: ComfyNodeData,
|
||||
_app: ComfyApp
|
||||
): Promise<void> {
|
||||
// Apply to both shader noise ksampler nodes
|
||||
if (
|
||||
nodeData.name === 'ShaderNoiseKSampler' ||
|
||||
nodeData.name === 'ShaderNoiseKSamplerDirect'
|
||||
) {
|
||||
// Store the original onDrawForeground function if it exists
|
||||
const origOnDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
|
||||
// Add our own onDrawForeground function
|
||||
nodeType.prototype.onDrawForeground = function (
|
||||
this: ShaderNode,
|
||||
ctx: CanvasRenderingContext2D
|
||||
): void {
|
||||
// Call the original onDrawForeground if it exists
|
||||
if (origOnDrawForeground) {
|
||||
origOnDrawForeground.call(this, ctx);
|
||||
}
|
||||
|
||||
// Draw a custom gradient title
|
||||
drawGradientTitle(this, ctx);
|
||||
};
|
||||
|
||||
// Clean up resources when node is removed
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function (this: ShaderNode): void {
|
||||
if (origOnRemoved) {
|
||||
origOnRemoved.call(this);
|
||||
}
|
||||
|
||||
// Clear cached canvases to prevent memory leaks
|
||||
CACHE.titleCanvas = null;
|
||||
CACHE.titleCtx = null;
|
||||
CACHE.collapsed.canvas = null;
|
||||
CACHE.collapsed.ctx = null;
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Register the extension
|
||||
appInstance.registerExtension(extension);
|
||||
|
||||
/**
|
||||
* Draws a gradient title directly on the canvas
|
||||
* @param node - The node to apply the gradient to
|
||||
* @param ctx - The canvas context
|
||||
*/
|
||||
function drawGradientTitle(
|
||||
node: ShaderNode,
|
||||
ctx: CanvasRenderingContext2D
|
||||
): void {
|
||||
// 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 equationY = 45; // Y position for the equation, moved lower
|
||||
|
||||
// Choose appropriate equation based on node type
|
||||
let equation: string;
|
||||
let collapsedEquation: string;
|
||||
|
||||
if (node.type === 'ShaderNoiseKSamplerDirect') {
|
||||
equation = 'Lt = Sα(N) ∘ Kβ(t) ⟿';
|
||||
collapsedEquation = 'Lt = Sα(N) ∘ Kβ(t) ⟿';
|
||||
} else {
|
||||
equation = 'Lt = Sα(N) ∘ Kβ(t)';
|
||||
collapsedEquation = 'Lt = Sα(N) ∘ Kβ(t)';
|
||||
}
|
||||
|
||||
// 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 mathematical formula - 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 etched shadow for collapsed version
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.font = 'italic 11px Arial'; // Smaller font for collapsed state
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(collapsedEquation, width / 2 + 1, titleHeight / 2 + 1);
|
||||
|
||||
// Create base golden gradient for collapsed version
|
||||
const baseGradient = ctx.createLinearGradient(
|
||||
0,
|
||||
titleHeight / 2 - 5,
|
||||
0,
|
||||
titleHeight / 2 + 5
|
||||
);
|
||||
baseGradient.addColorStop(0, '#B8860B'); // Darker gold
|
||||
baseGradient.addColorStop(0.5, '#FFD700'); // Bright gold
|
||||
baseGradient.addColorStop(1, '#B8860B'); // Darker gold
|
||||
|
||||
// Draw base golden text
|
||||
ctx.fillStyle = baseGradient;
|
||||
ctx.font = 'italic 11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(collapsedEquation, width / 2, titleHeight / 2);
|
||||
|
||||
// Create moving highlight effect for collapsed version
|
||||
const highlightWidth = width * 0.4; // Width of the highlight
|
||||
const highlightX =
|
||||
-highlightWidth + (width + highlightWidth) * shimmerPosition; // Adjusted range
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(
|
||||
highlightX - highlightWidth / 2,
|
||||
0,
|
||||
highlightX + highlightWidth / 2,
|
||||
0
|
||||
);
|
||||
|
||||
// Create smooth highlight transition
|
||||
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)');
|
||||
|
||||
// Apply highlight
|
||||
ctx.fillStyle = shimmerGradient;
|
||||
ctx.fillText(collapsedEquation, width / 2, titleHeight / 2);
|
||||
|
||||
// Add outline glow that follows the highlight
|
||||
const glowIntensity = Math.max(
|
||||
0,
|
||||
1 - Math.abs(width / 2 - highlightX) / (width / 4)
|
||||
);
|
||||
ctx.shadowColor = `rgba(255, 255, 200, ${glowIntensity * 0.3})`;
|
||||
ctx.shadowBlur = 4; // Less blur for collapsed version
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.fillText(collapsedEquation, width / 2, titleHeight / 2);
|
||||
|
||||
// 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 etched shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.font = 'italic 14px Arial'; // Smaller font for equation
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(equation, width / 2 + 2, equationY + 2);
|
||||
|
||||
// Create base golden gradient
|
||||
const baseGradient = ctx.createLinearGradient(0, equationY - 7, 0, equationY + 7);
|
||||
baseGradient.addColorStop(0, '#B8860B'); // Darker gold
|
||||
baseGradient.addColorStop(0.5, '#FFD700'); // Bright gold
|
||||
baseGradient.addColorStop(1, '#B8860B'); // Darker gold
|
||||
|
||||
// Draw base golden text
|
||||
ctx.fillStyle = baseGradient;
|
||||
ctx.font = 'italic 14px Arial'; // Smaller font for equation
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(equation, width / 2, equationY);
|
||||
|
||||
// Create moving highlight effect
|
||||
const highlightWidth = width * 0.4; // Width of the highlight
|
||||
const highlightX =
|
||||
-highlightWidth + (width + highlightWidth) * shimmerPosition; // Adjusted range
|
||||
|
||||
const shimmerGradient = ctx.createLinearGradient(
|
||||
highlightX - highlightWidth / 2,
|
||||
0,
|
||||
highlightX + highlightWidth / 2,
|
||||
0
|
||||
);
|
||||
|
||||
// Create smooth highlight transition
|
||||
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)');
|
||||
|
||||
// Apply highlight
|
||||
ctx.fillStyle = shimmerGradient;
|
||||
ctx.fillText(equation, width / 2, equationY);
|
||||
|
||||
// Add outline glow that follows the highlight
|
||||
const glowIntensity = Math.max(
|
||||
0,
|
||||
1 - Math.abs(width / 2 - highlightX) / (width / 4)
|
||||
);
|
||||
ctx.shadowColor = `rgba(255, 255, 200, ${glowIntensity * 0.3})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.fillText(equation, width / 2, equationY);
|
||||
|
||||
// Restore context state
|
||||
ctx.restore();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* shader_params_save_button.ts - Adds a button to manually save shader parameters
|
||||
*/
|
||||
|
||||
// Type imports from our local type definitions
|
||||
import type {
|
||||
ComfyApp,
|
||||
ComfyNodeData,
|
||||
ComfyExtension,
|
||||
NodeTypeConstructor,
|
||||
LGraphNode,
|
||||
IWidget,
|
||||
LGraphCanvas,
|
||||
} from '../types/comfyui';
|
||||
|
||||
// Import app from ComfyUI at runtime
|
||||
// @ts-ignore - ComfyUI provides this at runtime
|
||||
import { app as comfyApp } from '../../scripts/app.js';
|
||||
|
||||
// Cast the runtime import to our typed interface
|
||||
const appInstance = comfyApp as ComfyApp;
|
||||
|
||||
/** Toast notification type */
|
||||
type ToastType = 'info' | 'success' | 'error' | 'warning';
|
||||
|
||||
/** Extended node with widgets and event handlers */
|
||||
interface ShaderNode extends LGraphNode {
|
||||
widgets: IWidget[];
|
||||
handleKeyDown?: (event: KeyboardEvent) => void;
|
||||
setDirtyCanvas(fg: boolean, bg?: boolean): void;
|
||||
}
|
||||
|
||||
/** Custom indicator widget */
|
||||
interface IndicatorWidget extends IWidget {
|
||||
parent?: ShaderNode;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/** Button widget with tooltip */
|
||||
interface ButtonWidget extends IWidget {
|
||||
tooltip?: string;
|
||||
_tooltip_elem?: HTMLDivElement;
|
||||
}
|
||||
|
||||
/** Shader parameters to save */
|
||||
interface ShaderParams {
|
||||
shaderType: string;
|
||||
shaderScale: number;
|
||||
shaderOctaves: number;
|
||||
shaderWarpStrength: number;
|
||||
shaderShapeType: string;
|
||||
shaderShapeStrength: number;
|
||||
shaderPhaseShift: number;
|
||||
colorScheme: string;
|
||||
shaderColorIntensity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to show toast notifications
|
||||
*/
|
||||
function showToast(message: string, type: ToastType = 'info'): void {
|
||||
// Create toast container if it doesn't exist
|
||||
let toastContainer = document.getElementById('comfy-toast-container');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.id = 'comfy-toast-container';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `comfy-toast comfy-toast-${type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.textContent = message;
|
||||
|
||||
// Add to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
// Remove after delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
// Wait for transition to finish, with fallback
|
||||
const removeToast = (): void => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
// Ensure listener is removed if called by fallback
|
||||
toast.removeEventListener('transitionend', removeToast);
|
||||
}
|
||||
};
|
||||
|
||||
toast.addEventListener('transitionend', removeToast, { once: true });
|
||||
|
||||
// Fallback cleanup if transitions are disabled
|
||||
setTimeout(removeToast, 350);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Expose toast function globally
|
||||
(window as unknown as { showComfyToast: typeof showToast }).showComfyToast = showToast;
|
||||
|
||||
// Define the extension
|
||||
const extension: ComfyExtension = {
|
||||
name: 'ShaderParamsSaveButton',
|
||||
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: NodeTypeConstructor,
|
||||
nodeData: ComfyNodeData,
|
||||
_app: ComfyApp
|
||||
): Promise<void> {
|
||||
if (nodeData.name === 'ShaderNoiseKSampler') {
|
||||
// Store original onNodeCreated to maintain the node's behavior
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
// Add our button to the node
|
||||
nodeType.prototype.onNodeCreated = function (this: ShaderNode): void {
|
||||
// Call original function to preserve existing behavior
|
||||
if (origOnNodeCreated) {
|
||||
origOnNodeCreated.call(this);
|
||||
}
|
||||
|
||||
// Keep track of the node instance
|
||||
const node = this;
|
||||
|
||||
// Track if we're currently saving (to prevent double-clicks)
|
||||
let isSaving = false;
|
||||
|
||||
// Add indicator widget that will show when parameters need saving
|
||||
const indicatorWidget: IndicatorWidget = {
|
||||
name: '⚠️ Parameters with 🔄 must be saved ⚠️',
|
||||
type: 'custom_indicator',
|
||||
value: '',
|
||||
options: { className: 'shader-params-indicator' },
|
||||
tooltip:
|
||||
'parameters marked with 🔄 require saving to take effect in the generation process',
|
||||
disabled: true,
|
||||
// Add computeSize method to properly handle resizing
|
||||
computeSize(this: IndicatorWidget): [number, number] {
|
||||
// Return fixed height but variable width based on parent node width
|
||||
if (this.parent && this.parent.size) {
|
||||
// Adjust width to match node width with some padding
|
||||
return [this.parent.size[0] - 30, 20];
|
||||
}
|
||||
return [220, 28]; // Default size if parent not available
|
||||
},
|
||||
// Custom draw method for the widget
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widget_width: number,
|
||||
y: number,
|
||||
widget_height: number
|
||||
): void {
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = 'rgba(255, 119, 0, 0.1)';
|
||||
ctx.strokeStyle = '#ff7700';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Draw rounded rectangle for the widget background
|
||||
const radius = 4;
|
||||
const x = 15; // Padding from left edge
|
||||
const width = widget_width - 30; // Subtract padding from both sides
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + widget_height - radius);
|
||||
ctx.quadraticCurveTo(
|
||||
x + width,
|
||||
y + widget_height,
|
||||
x + width - radius,
|
||||
y + widget_height
|
||||
);
|
||||
ctx.lineTo(x + radius, y + widget_height);
|
||||
ctx.quadraticCurveTo(x, y + widget_height, x, y + widget_height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.fillStyle = '#ff7700';
|
||||
ctx.font = 'bold 12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.name, x + width / 2, y + widget_height / 2 + 4);
|
||||
},
|
||||
};
|
||||
|
||||
// Add widget reference to node
|
||||
indicatorWidget.parent = this;
|
||||
this.widgets.push(indicatorWidget);
|
||||
|
||||
// --- Refactored Save Function ---
|
||||
const saveParameters = (): void => {
|
||||
if (isSaving) return;
|
||||
isSaving = true;
|
||||
|
||||
// Change button text to indicate saving
|
||||
if (saveButtonWidget) saveButtonWidget.name = 'Saving...';
|
||||
console.log('Starting save process (triggered)...');
|
||||
|
||||
// Print each widget and its properties for deep debugging
|
||||
if (node.widgets) {
|
||||
console.log('Widget details:');
|
||||
node.widgets.forEach((widget, index) => {
|
||||
console.log(
|
||||
`Widget ${index}: name="${widget.name}", type=${widget.type}, value=${widget.value}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Find the widgets that contain our values
|
||||
// Default values
|
||||
const currentProps: ShaderParams = {
|
||||
shaderType: 'tensor_field',
|
||||
shaderScale: 1.0,
|
||||
shaderOctaves: 1,
|
||||
shaderWarpStrength: 0.5,
|
||||
shaderShapeType: 'none',
|
||||
shaderShapeStrength: 1.0,
|
||||
shaderPhaseShift: 0.5,
|
||||
colorScheme: 'none',
|
||||
shaderColorIntensity: 0.8,
|
||||
};
|
||||
|
||||
// Try to find widgets by partial name match
|
||||
if (node.widgets) {
|
||||
for (const widget of node.widgets) {
|
||||
// Ensure widget and widget.name exist before accessing
|
||||
if (!widget || typeof widget.name !== 'string') continue;
|
||||
|
||||
const name = widget.name.toLowerCase();
|
||||
|
||||
// Use partial name matching for more flexibility
|
||||
if (name.includes('scale') && !name.includes('color')) {
|
||||
currentProps.shaderScale = widget.value as number;
|
||||
console.log(`Found shader scale: ${widget.value}`);
|
||||
} else if (name.includes('octaves')) {
|
||||
currentProps.shaderOctaves = widget.value as number;
|
||||
console.log(`Found octaves: ${widget.value}`);
|
||||
} else if (name.includes('warp')) {
|
||||
currentProps.shaderWarpStrength = widget.value as number;
|
||||
console.log(`Found warp strength: ${widget.value}`);
|
||||
} else if (name.includes('shape') && name.includes('strength')) {
|
||||
currentProps.shaderShapeStrength = widget.value as number;
|
||||
console.log(`Found shape strength: ${widget.value}`);
|
||||
} else if (name.includes('phase')) {
|
||||
currentProps.shaderPhaseShift = widget.value as number;
|
||||
console.log(`Found phase: ${widget.value}`);
|
||||
} else if (name.includes('color') && name.includes('intensity')) {
|
||||
currentProps.shaderColorIntensity = widget.value as number;
|
||||
console.log(`Found color intensity: ${widget.value}`);
|
||||
} else if (name.includes('shader') && name.includes('type')) {
|
||||
currentProps.shaderType = widget.value as string;
|
||||
console.log(`Found shader type: ${widget.value}`);
|
||||
} else if (name.includes('shape') && name.includes('type')) {
|
||||
currentProps.shaderShapeType = widget.value as string;
|
||||
console.log(`Found shape type: ${widget.value}`);
|
||||
} else if (name.includes('color') && name.includes('scheme')) {
|
||||
currentProps.colorScheme = widget.value as string;
|
||||
console.log(`Found color scheme: ${widget.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Saving shader properties:', currentProps);
|
||||
|
||||
try {
|
||||
// Serialize with pretty printing
|
||||
const jsonData = JSON.stringify(currentProps, null, 2);
|
||||
|
||||
// Save to localStorage with size limit and error handling
|
||||
try {
|
||||
// Check if data is too large for localStorage
|
||||
const dataSize = new Blob([jsonData]).size;
|
||||
if (dataSize > 1024 * 1024) {
|
||||
// 1MB limit
|
||||
console.warn(
|
||||
'Shader params data too large for localStorage, skipping localStorage save'
|
||||
);
|
||||
} else {
|
||||
// Remove old shader params first to free space
|
||||
const oldKeys = Object.keys(localStorage).filter(
|
||||
(key) => key.startsWith('shader_params') || key.includes('shader')
|
||||
);
|
||||
oldKeys.forEach((key) => {
|
||||
if (key !== 'shader_params') {
|
||||
// Keep only the main one
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem('shader_params', jsonData);
|
||||
console.log('Saved to localStorage successfully');
|
||||
}
|
||||
} catch (localErr) {
|
||||
const error = localErr as Error & { name: string };
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
console.warn(
|
||||
'localStorage quota exceeded, skipping localStorage save:',
|
||||
error.message
|
||||
);
|
||||
// Try to free up space by removing old workflow data
|
||||
if (window.storageOptimizer) {
|
||||
window.storageOptimizer.forceCleanup();
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save to localStorage:', localErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Send the file directly to the data directory - most reliable method
|
||||
// Create a simple download link to trigger the file save
|
||||
const blob = new Blob([jsonData], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'shader_params.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Show instructions in console AFTER download is triggered
|
||||
console.log(
|
||||
'%cIMPORTANT: Please save the downloaded file to the following location (overwrite if exists):',
|
||||
'color: red; font-weight: bold'
|
||||
);
|
||||
console.log(
|
||||
'%ccustom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json',
|
||||
'color: blue; font-weight: bold'
|
||||
);
|
||||
|
||||
// Clean up and update button state on success
|
||||
setTimeout(() => {
|
||||
try {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('Shader params JSON download link removed.');
|
||||
} catch (cleanupError) {
|
||||
console.warn('Could not clean up download link:', cleanupError);
|
||||
}
|
||||
|
||||
showToast('Parameters saved successfully!', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
if (saveButtonWidget) saveButtonWidget.name = '💾 Save Shader Parameters';
|
||||
isSaving = false;
|
||||
}, 2000);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error saving shader parameters:', error);
|
||||
showToast('Error saving parameters!', 'error');
|
||||
if (saveButtonWidget) saveButtonWidget.name = 'Error Saving!';
|
||||
setTimeout(() => {
|
||||
if (saveButtonWidget) saveButtonWidget.name = '💾 Save Shader Parameters';
|
||||
isSaving = false;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
// --- End of Refactored Save Function ---
|
||||
|
||||
// Add save button using the refactored function
|
||||
const saveButtonWidget = this.addWidget(
|
||||
'button',
|
||||
' 💾 Save Shader Parameters',
|
||||
null,
|
||||
saveParameters
|
||||
) as ButtonWidget;
|
||||
|
||||
// Extend the widget with a tooltip property that ComfyUI's system recognizes
|
||||
saveButtonWidget.options = saveButtonWidget.options || {};
|
||||
saveButtonWidget.options.className = 'save-shader-params';
|
||||
|
||||
// Add tooltip following ComfyUI's pattern
|
||||
saveButtonWidget.tooltip =
|
||||
"Save parameters (Alt+S) -- file must be named shader_params.json -- always overwrite old file -- [Save location: `custom_nodes/ComfyUI-ShaderNoiseKsampler/data/shader_params.json`][WIP]";
|
||||
|
||||
// Move both the indicator and save button widgets to the end of all widgets
|
||||
// This ensures they appear at the bottom of all settings
|
||||
setTimeout(() => {
|
||||
if (this.widgets && this.widgets.length > 0) {
|
||||
// Get the indicator's current index
|
||||
const indicatorIndex = this.widgets.indexOf(indicatorWidget);
|
||||
if (indicatorIndex !== -1) {
|
||||
// Remove it from its current position
|
||||
this.widgets.splice(indicatorIndex, 1);
|
||||
}
|
||||
|
||||
// Get the save button's current index
|
||||
const buttonIndex = this.widgets.indexOf(saveButtonWidget);
|
||||
if (buttonIndex !== -1) {
|
||||
// Remove it from its current position
|
||||
this.widgets.splice(buttonIndex, 1);
|
||||
}
|
||||
|
||||
// Add them back at the end, indicator first then save button
|
||||
this.widgets.push(indicatorWidget);
|
||||
this.widgets.push(saveButtonWidget);
|
||||
|
||||
// Ensure the node is redrawn to show the updated widget positions
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// --- Keybinding Logic ---
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
// Check for Alt+S and if this node is currently selected
|
||||
if (event.altKey && event.key === 's') {
|
||||
const canvas = appInstance.canvas as LGraphCanvas & {
|
||||
current_node?: LGraphNode;
|
||||
};
|
||||
// Check if the graph canvas and selected nodes exist
|
||||
if (canvas && canvas.current_node) {
|
||||
// Check if the currently selected node is this node
|
||||
if (canvas.current_node === node) {
|
||||
console.log('Alt+S detected for selected ShaderNoiseKSampler node.');
|
||||
event.preventDefault(); // Prevent browser's default Alt+S action
|
||||
event.stopPropagation(); // Stop event from bubbling up
|
||||
saveParameters(); // Trigger the save function
|
||||
}
|
||||
} else if (
|
||||
canvas &&
|
||||
canvas.selected_nodes &&
|
||||
Object.keys(canvas.selected_nodes).length === 1 &&
|
||||
canvas.selected_nodes[node.id]
|
||||
) {
|
||||
// Fallback check for selected_nodes if current_node isn't reliable
|
||||
console.log(
|
||||
'Alt+S detected for selected ShaderNoiseKSampler node (using selected_nodes).'
|
||||
);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
saveParameters();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Attach the event listener to the document
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Store handler reference for removal
|
||||
this.handleKeyDown = handleKeyDown;
|
||||
|
||||
// Original onRemoved method if it exists
|
||||
const origOnRemoved = this.onRemoved;
|
||||
|
||||
// Add logic to remove the event listener when the node is removed
|
||||
this.onRemoved = function (this: ShaderNode): void {
|
||||
console.log('Removing keydown listener for node:', this.id);
|
||||
if (this.handleKeyDown) {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
|
||||
// Call original onRemoved if it existed
|
||||
if (origOnRemoved) {
|
||||
origOnRemoved.call(this);
|
||||
}
|
||||
};
|
||||
// --- End Keybinding Logic ---
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Add CSS styling for the button
|
||||
async setup(_app: ComfyApp): Promise<void> {
|
||||
// Add a small CSS rule for spacing and tooltip styling
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.save-shader-params {
|
||||
padding: 6px;
|
||||
background-color: #5c5c5c;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shader-params-indicator {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
color: #ff7700;
|
||||
background-color: rgba(255, 119, 0, 0.1);
|
||||
border-left: 3px solid #ff7700;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The tooltip container needs positioning */
|
||||
.tooltip-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Style for ComfyUI-compatible tooltips */
|
||||
.comfy-tooltip {
|
||||
visibility: hidden;
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Show tooltip on hover */
|
||||
.tooltip-container:hover .comfy-tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Toast Notification */
|
||||
#comfy-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.comfy-toast {
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
min-width: 250px;
|
||||
border-left: 4px solid #4a9eff;
|
||||
}
|
||||
|
||||
.comfy-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.comfy-toast-success {
|
||||
border-left-color: #2ecc71;
|
||||
}
|
||||
|
||||
.comfy-toast-error {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
|
||||
.comfy-toast-warning {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Hook into the app's widget drawing system to add tooltip support
|
||||
// This is done after initial setup to ensure proper integration
|
||||
const originalDrawNodeWidgets = LGraphCanvas.prototype.drawNodeWidgets;
|
||||
if (originalDrawNodeWidgets) {
|
||||
LGraphCanvas.prototype.drawNodeWidgets = function (
|
||||
this: LGraphCanvas,
|
||||
node: LGraphNode,
|
||||
pos: [number, number],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
active_widget: IWidget | null
|
||||
): unknown {
|
||||
// Call the original method first
|
||||
const result = originalDrawNodeWidgets.call(this, node, pos, ctx, active_widget);
|
||||
|
||||
// After rendering widgets, check for our tooltip property
|
||||
if (node && node.widgets) {
|
||||
for (const widget of node.widgets) {
|
||||
const buttonWidget = widget as ButtonWidget;
|
||||
// If widget has our tooltip property and mouse is over it
|
||||
if (widget === active_widget && buttonWidget.tooltip) {
|
||||
// Get canvas position
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
// Create tooltip if it doesn't exist yet
|
||||
if (!buttonWidget._tooltip_elem) {
|
||||
const tooltipContainer = document.createElement('div');
|
||||
tooltipContainer.className = 'tooltip-container';
|
||||
|
||||
const tooltip = document.createElement('span');
|
||||
tooltip.className = 'comfy-tooltip';
|
||||
tooltip.textContent = buttonWidget.tooltip;
|
||||
|
||||
tooltipContainer.appendChild(tooltip);
|
||||
document.body.appendChild(tooltipContainer);
|
||||
|
||||
buttonWidget._tooltip_elem = tooltipContainer;
|
||||
}
|
||||
|
||||
// Position tooltip
|
||||
if (buttonWidget._tooltip_elem) {
|
||||
const x = pos[0] + rect.left;
|
||||
const y = pos[1] + rect.top;
|
||||
|
||||
buttonWidget._tooltip_elem.style.left = x + 'px';
|
||||
buttonWidget._tooltip_elem.style.top = y - 20 + 'px';
|
||||
buttonWidget._tooltip_elem.style.display = 'block';
|
||||
}
|
||||
} else if (buttonWidget._tooltip_elem) {
|
||||
// Hide tooltip when not hovering
|
||||
buttonWidget._tooltip_elem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Register the extension
|
||||
appInstance.registerExtension(extension);
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* shader_renderer.ts - Adds shader visualization to ShaderNoiseKSampler node
|
||||
* This file has a single responsibility: rendering shaders using a canvas approach
|
||||
*/
|
||||
|
||||
// @ts-ignore - Runtime ComfyUI import
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
import type { ComfyApp, ComfyExtension, ComfyNodeData } from "../types/comfyui";
|
||||
import type { LGraphNode, IWidget } from "../types/litegraph";
|
||||
|
||||
export { };
|
||||
|
||||
console.log("ShaderRenderer module loaded");
|
||||
|
||||
// ============================
|
||||
// Type Definitions
|
||||
// ============================
|
||||
|
||||
interface ShaderProperties {
|
||||
shaderVisible: boolean;
|
||||
tooltipsVisible: boolean;
|
||||
shaderType: string;
|
||||
shaderSpeed: number;
|
||||
shaderColorIntensity: number;
|
||||
shaderTime: number;
|
||||
lastRenderTime: number;
|
||||
shaderScale: number;
|
||||
shaderOctaves: number;
|
||||
shaderShapeType: string;
|
||||
shaderShapeStrength: number;
|
||||
shaderWarpStrength: number;
|
||||
shaderPhaseShift: number;
|
||||
shaderFrequencyRange: number;
|
||||
shaderDistribution: number;
|
||||
shaderAdaptationStrength: number;
|
||||
shaderResolutionScale: number;
|
||||
colorScheme: string;
|
||||
[key: string]: unknown; // Allow additional properties
|
||||
}
|
||||
|
||||
interface ShaderRendererNode extends LGraphNode {
|
||||
properties: ShaderProperties;
|
||||
shaderHeight: number;
|
||||
animationFrameId: number | null;
|
||||
isShaderActive: boolean;
|
||||
gl: WebGLRenderingContext | null;
|
||||
shaderCanvas: HTMLCanvasElement | null;
|
||||
shaderPrograms: Record<string, WebGLProgram | null>;
|
||||
shaderSources: Record<string, string>;
|
||||
vertexShaderSource: string;
|
||||
fragmentShaderHeader: string;
|
||||
fragmentShaderFooter: string;
|
||||
positionBuffer: WebGLBuffer | null;
|
||||
loadingIndicatorProgram: WebGLProgram | null;
|
||||
pendingShaders: string[];
|
||||
loadingShader: boolean;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
min_height: number;
|
||||
min_width: number;
|
||||
resizable: boolean;
|
||||
_isResizing: boolean;
|
||||
initShaderCanvas: () => void;
|
||||
loadShader: (shaderType: string) => void;
|
||||
processNextShader: () => void;
|
||||
renderShader: () => void;
|
||||
updateAnimationTime: () => void;
|
||||
drawShader: (program: WebGLProgram) => void;
|
||||
createShaderProgram: (vsSource: string, fsSource: string) => WebGLProgram | null;
|
||||
resizeShaderCanvas: (width: number, height: number) => void;
|
||||
startBackgroundLoading: () => void;
|
||||
}
|
||||
|
||||
interface WidgetWithTooltip extends IWidget {
|
||||
tooltip?: string;
|
||||
_originalTooltip?: string;
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Helper Functions
|
||||
// ============================
|
||||
|
||||
const COLOR_SCHEME_NAMES: Record<string, string> = {
|
||||
"none": "Black & White", "blue_red": "Blue to Red", "viridis": "Viridis",
|
||||
"plasma": "Plasma", "inferno": "Inferno", "magma": "Magma", "turbo": "Turbo",
|
||||
"jet": "Jet", "rainbow": "Rainbow", "cool": "Cool", "hot": "Hot",
|
||||
"parula": "Parula", "hsv": "HSV", "autumn": "Autumn", "winter": "Winter",
|
||||
"spring": "Spring", "summer": "Summer", "copper": "Copper", "pink": "Pink",
|
||||
"bone": "Bone", "ocean": "Ocean", "terrain": "Terrain", "neon": "Neon", "fire": "Fire"
|
||||
};
|
||||
|
||||
function getColorSchemeName(scheme: string): string {
|
||||
return COLOR_SCHEME_NAMES[scheme] || scheme;
|
||||
}
|
||||
|
||||
const SHAPE_TYPE_MAP: Record<string, number> = {
|
||||
'none': 0, 'radial': 1, 'linear': 2, 'spiral': 3, 'checkerboard': 4,
|
||||
'spots': 5, 'hexgrid': 6, 'stripes': 7, 'gradient': 8, 'vignette': 9,
|
||||
'cross': 10, 'stars': 11, 'triangles': 12, 'concentric': 13, 'rays': 14, 'zigzag': 15
|
||||
};
|
||||
|
||||
const COLOR_SCHEME_MAP: Record<string, number> = {
|
||||
'none': 0, 'blue_red': 1, 'viridis': 2, 'plasma': 3, 'inferno': 4,
|
||||
'magma': 5, 'turbo': 6, 'jet': 7, 'rainbow': 8, 'cool': 9, 'hot': 10,
|
||||
'parula': 11, 'hsv': 12, 'autumn': 13, 'winter': 14, 'spring': 15,
|
||||
'summer': 16, 'copper': 17, 'pink': 18, 'bone': 19, 'ocean': 20,
|
||||
'terrain': 21, 'neon': 22, 'fire': 23
|
||||
};
|
||||
|
||||
// ============================
|
||||
// GLSL Shader Sources
|
||||
// ============================
|
||||
|
||||
const VERTEX_SHADER_SOURCE = `
|
||||
attribute vec2 a_position;
|
||||
varying vec2 v_texCoord;
|
||||
void main() {
|
||||
v_texCoord = a_position * 0.5 + 0.5;
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Common fragment shader header with shared noise functions
|
||||
const FRAGMENT_SHADER_HEADER = `
|
||||
precision mediump float;
|
||||
uniform float u_time;
|
||||
uniform float u_intensity;
|
||||
uniform float u_scale;
|
||||
uniform float u_octaves;
|
||||
uniform float u_persistence;
|
||||
uniform float u_lacunarity;
|
||||
uniform int u_shapeType;
|
||||
uniform float u_shapeStrength;
|
||||
uniform float u_warpStrength;
|
||||
uniform float u_phaseShift;
|
||||
uniform int u_frequencyRange;
|
||||
uniform int u_distribution;
|
||||
uniform float u_adaptationStrength;
|
||||
uniform float u_resolutionScale;
|
||||
uniform int u_colorScheme;
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
|
||||
vec4 permute(vec4 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
|
||||
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
vec2 fade(vec2 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod(i, 289.0);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
||||
m = m*m; m = m*m;
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
float random(vec2 st) {
|
||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
`;
|
||||
|
||||
// Shader-specific main functions (abbreviated for size, full versions loaded from original)
|
||||
const SHADER_SOURCES: Record<string, string> = {
|
||||
"domain_warp": `
|
||||
void main() {
|
||||
vec2 st = v_texCoord * u_scale;
|
||||
float n = snoise(st + u_time * 0.5 + u_phaseShift);
|
||||
vec2 warp = vec2(snoise(st + n * u_warpStrength), snoise(st + n * u_warpStrength + 100.0));
|
||||
float value = snoise(st + warp * u_warpStrength + u_time * 0.3);
|
||||
value = value * 0.5 + 0.5;
|
||||
vec3 color = vec3(value);
|
||||
if (u_colorScheme > 0) color = mix(color, vec3(value, value * 0.5, 1.0 - value), u_intensity);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
"tensor_field": `
|
||||
void main() {
|
||||
vec2 st = v_texCoord * u_scale;
|
||||
float angle = atan(st.y - 0.5, st.x - 0.5) + u_time * 0.5;
|
||||
float r = length(st - 0.5);
|
||||
float n = snoise(vec2(angle * 3.0 + u_phaseShift, r * 5.0 + u_time));
|
||||
float value = n * 0.5 + 0.5;
|
||||
vec3 color = vec3(value);
|
||||
if (u_colorScheme > 0) color = mix(color, vec3(value, 1.0 - value, value * 0.5), u_intensity);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
"curl_noise": `
|
||||
void main() {
|
||||
vec2 st = v_texCoord * u_scale;
|
||||
float eps = 0.01;
|
||||
float n = snoise(st + u_time * 0.3);
|
||||
float dx = snoise(st + vec2(eps, 0.0) + u_time * 0.3) - snoise(st - vec2(eps, 0.0) + u_time * 0.3);
|
||||
float dy = snoise(st + vec2(0.0, eps) + u_time * 0.3) - snoise(st - vec2(0.0, eps) + u_time * 0.3);
|
||||
float curl = (dx - dy) * u_warpStrength * 10.0;
|
||||
float value = (snoise(st + vec2(curl, -curl) * 0.5 + u_phaseShift) + 1.0) * 0.5;
|
||||
vec3 color = vec3(value);
|
||||
if (u_colorScheme > 0) color = mix(color, vec3(1.0 - value, value, value * 0.7), u_intensity);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
const FRAGMENT_SHADER_FOOTER = ``;
|
||||
|
||||
// ============================
|
||||
// Extension Registration
|
||||
// ============================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(app as any).registerExtension({
|
||||
name: "ShaderNoiseKSampler.ShaderRenderer",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async beforeRegisterNodeDef(nodeType: any, nodeData: ComfyNodeData) {
|
||||
if (nodeData.name !== "ShaderNoiseKSampler") return;
|
||||
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
const origComputeSize = nodeType.prototype.computeSize;
|
||||
const origOnDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function (this: ShaderRendererNode) {
|
||||
if (origOnNodeCreated) origOnNodeCreated.apply(this, arguments);
|
||||
|
||||
// Initialize shader properties
|
||||
this.properties = this.properties || {} as ShaderProperties;
|
||||
this.properties.shaderVisible = false;
|
||||
this.properties.tooltipsVisible = true;
|
||||
this.properties.shaderType = "domain_warp";
|
||||
this.properties.shaderSpeed = 0.2;
|
||||
this.properties.shaderColorIntensity = 0.8;
|
||||
this.properties.shaderTime = 0;
|
||||
this.properties.lastRenderTime = 0;
|
||||
this.properties.shaderScale = 1.0;
|
||||
this.properties.shaderOctaves = 1;
|
||||
this.properties.shaderShapeType = "none";
|
||||
this.properties.shaderShapeStrength = 1.0;
|
||||
this.properties.shaderWarpStrength = 0.5;
|
||||
this.properties.shaderPhaseShift = 0.5;
|
||||
this.properties.shaderFrequencyRange = 0;
|
||||
this.properties.shaderDistribution = 0;
|
||||
this.properties.shaderAdaptationStrength = 0.5;
|
||||
this.properties.shaderResolutionScale = 512;
|
||||
this.properties.colorScheme = "none";
|
||||
|
||||
this.shaderHeight = 200;
|
||||
this.animationFrameId = null;
|
||||
this.isShaderActive = false;
|
||||
this.initShaderCanvas();
|
||||
|
||||
// Add widgets - use any cast for callbacks since WidgetCallback has different signature than actual usage
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("toggle", "Show Shader", this.properties.shaderVisible, ((v: boolean) => {
|
||||
this.properties.shaderVisible = v;
|
||||
const baseSize = origComputeSize ? origComputeSize.call(this, [this.size[0], 0]) : [this.size[0], 0];
|
||||
const baseHeight = baseSize[1];
|
||||
if (v && this.gl) {
|
||||
this.shaderHeight = this.shaderHeight || 200;
|
||||
this.size[1] = baseHeight + this.shaderHeight;
|
||||
this.resizeShaderCanvas(this.size[0], this.shaderHeight);
|
||||
this.isShaderActive = true;
|
||||
this.properties.lastRenderTime = 0;
|
||||
} else {
|
||||
this.size[1] = baseHeight;
|
||||
this.isShaderActive = false;
|
||||
if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; }
|
||||
}
|
||||
this.setDirtyCanvas(true, true);
|
||||
}) as any);
|
||||
if (this.widgets?.length) (this.widgets[this.widgets.length - 1] as WidgetWithTooltip).tooltip = "Toggle shader preview visibility";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("toggle", "Show Tooltips", this.properties.tooltipsVisible, ((v: boolean) => {
|
||||
this.properties.tooltipsVisible = v;
|
||||
if (this.widgets) {
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
const w = this.widgets[i] as WidgetWithTooltip;
|
||||
if (w.name === "Show Tooltips") continue;
|
||||
if (!w._originalTooltip && w.tooltip) w._originalTooltip = w.tooltip;
|
||||
w.tooltip = v && w._originalTooltip ? w._originalTooltip : "";
|
||||
}
|
||||
}
|
||||
this.setDirtyCanvas(true, true);
|
||||
}) as any);
|
||||
if (this.widgets?.length) (this.widgets[this.widgets.length - 1] as WidgetWithTooltip).tooltip = "Toggle tooltip visibility";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("combo", "Shader Noise Type 🔄", this.properties.shaderType, ((v: string) => {
|
||||
this.properties.shaderType = v;
|
||||
if (this.loadShader && (this.isShaderActive || this.properties.shaderVisible)) this.loadShader(v);
|
||||
this.setDirtyCanvas(true, true);
|
||||
}) as any, { values: ["domain_warp", "tensor_field", "curl_noise"] });
|
||||
if (this.widgets?.length) (this.widgets[this.widgets.length - 1] as WidgetWithTooltip).tooltip = "Select shader noise pattern type";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("combo", "Shape Mask Type 🔄", this.properties.shaderShapeType, ((v: string) => {
|
||||
this.properties.shaderShapeType = v;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}) as any, { values: ["none", "radial", "linear", "spiral", "checkerboard", "spots", "hexgrid", "stripes", "gradient", "vignette", "cross", "stars", "triangles", "concentric", "rays", "zigzag"] });
|
||||
if (this.widgets?.length) (this.widgets[this.widgets.length - 1] as WidgetWithTooltip).tooltip = "Apply shape mask to shader pattern";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("combo", "Color Scheme 🔄", this.properties.colorScheme, ((v: string) => {
|
||||
this.properties.colorScheme = v;
|
||||
this.setDirtyCanvas(true, true);
|
||||
}) as any, { values: ["none", "blue_red", "viridis", "plasma", "inferno", "magma", "turbo", "jet", "rainbow", "cool", "hot", "parula", "hsv", "autumn", "winter", "spring", "summer", "copper", "pink", "bone", "ocean", "terrain", "neon", "fire"] });
|
||||
if (this.widgets?.length) (this.widgets[this.widgets.length - 1] as WidgetWithTooltip).tooltip = "Choose color palette for visualization";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Noise Scale 🔄", this.properties.shaderScale, ((v: number) => { this.properties.shaderScale = v; this.setDirtyCanvas(true, true); }) as any, { min: 0.1, max: 10.0, step: 0.001, precision: 3 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Octaves 🔄", this.properties.shaderOctaves, ((v: number) => { this.properties.shaderOctaves = v; this.setDirtyCanvas(true, true); }) as any, { min: 1, max: 8, step: 0.1, precision: 1 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Warp Strength 🔄", this.properties.shaderWarpStrength, ((v: number) => { this.properties.shaderWarpStrength = v; this.setDirtyCanvas(true, true); }) as any, { min: 0.0, max: 5.0, step: 0.001, precision: 3 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Shape Mask Strength 🔄", this.properties.shaderShapeStrength, ((v: number) => { this.properties.shaderShapeStrength = v; this.setDirtyCanvas(true, true); }) as any, { min: 0.0, max: 2.0, step: 0.0005, precision: 4 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Phase Shift 🔄", this.properties.shaderPhaseShift, ((v: number) => { this.properties.shaderPhaseShift = v; this.setDirtyCanvas(true, true); }) as any, { min: 0.0, max: 2.0, step: 0.0005, precision: 4 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Color Intensity 🔄", this.properties.shaderColorIntensity, ((v: number) => { this.properties.shaderColorIntensity = v; this.setDirtyCanvas(true, true); }) as any, { min: 0.0, max: 1.0, step: 0.0005, precision: 4 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Animation Speed 🖥️", this.properties.shaderSpeed, ((v: number) => { this.properties.shaderSpeed = v; this.setDirtyCanvas(true, true); }) as any, { min: 0.1, max: 3.0, step: 0.001, precision: 3 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.addWidget("slider", "Pixel Resolution 🖥️", this.properties.shaderResolutionScale, ((v: number) => { this.properties.shaderResolutionScale = v; this.resizeShaderCanvas(this.size[0], this.shaderHeight); this.setDirtyCanvas(true, true); }) as any, { min: 128, max: 1024, step: 1, precision: 0 });
|
||||
|
||||
this.resizable = true;
|
||||
this.min_height = 100;
|
||||
this.min_width = 300;
|
||||
};
|
||||
|
||||
nodeType.prototype.initShaderCanvas = function (this: ShaderRendererNode) {
|
||||
this.shaderCanvas = document.createElement('canvas');
|
||||
this.shaderCanvas.width = this.size ? this.size[0] : 250;
|
||||
this.shaderCanvas.height = this.shaderHeight;
|
||||
|
||||
this.gl = this.shaderCanvas.getContext('webgl');
|
||||
if (!this.gl) { console.error('WebGL not supported'); return; }
|
||||
|
||||
this.gl.viewport(0, 0, this.shaderCanvas.width, this.shaderCanvas.height);
|
||||
this.shaderPrograms = {};
|
||||
this.vertexShaderSource = VERTEX_SHADER_SOURCE;
|
||||
this.fragmentShaderHeader = FRAGMENT_SHADER_HEADER;
|
||||
this.fragmentShaderFooter = FRAGMENT_SHADER_FOOTER;
|
||||
this.shaderSources = SHADER_SOURCES;
|
||||
this.pendingShaders = [];
|
||||
this.loadingShader = false;
|
||||
|
||||
// Create position buffer
|
||||
const positionBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0]), this.gl.STATIC_DRAW);
|
||||
this.positionBuffer = positionBuffer;
|
||||
};
|
||||
|
||||
nodeType.prototype.resizeShaderCanvas = function (this: ShaderRendererNode, width: number, height: number) {
|
||||
if (!this.gl || !this.shaderCanvas) return;
|
||||
height = Math.max(50, height);
|
||||
const aspectRatio = width / height;
|
||||
const targetRes = Math.round(this.properties.shaderResolutionScale);
|
||||
let canvasWidth: number, canvasHeight: number;
|
||||
if (aspectRatio >= 1) { canvasWidth = targetRes; canvasHeight = Math.round(targetRes / aspectRatio); }
|
||||
else { canvasHeight = targetRes; canvasWidth = Math.round(targetRes * aspectRatio); }
|
||||
this.shaderCanvas.width = canvasWidth;
|
||||
this.shaderCanvas.height = canvasHeight;
|
||||
this.shaderCanvas.style.width = width + "px";
|
||||
this.shaderCanvas.style.height = height + "px";
|
||||
this.gl.viewport(0, 0, canvasWidth, canvasHeight);
|
||||
this.displayWidth = width;
|
||||
this.displayHeight = height;
|
||||
this.shaderHeight = height;
|
||||
};
|
||||
|
||||
nodeType.prototype.createShaderProgram = function (this: ShaderRendererNode, vsSource: string, fsSource: string): WebGLProgram | null {
|
||||
const gl = this.gl;
|
||||
if (!gl) return null;
|
||||
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
|
||||
if (!vertexShader) return null;
|
||||
gl.shaderSource(vertexShader, vsSource);
|
||||
gl.compileShader(vertexShader);
|
||||
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
if (!fragmentShader) return null;
|
||||
gl.shaderSource(fragmentShader, fsSource);
|
||||
gl.compileShader(fragmentShader);
|
||||
const program = gl.createProgram();
|
||||
if (!program) return null;
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error('Shader program error:', gl.getProgramInfoLog(program)); return null; }
|
||||
return program;
|
||||
};
|
||||
|
||||
nodeType.prototype.loadShader = function (this: ShaderRendererNode, shaderType: string) {
|
||||
if (!this.isShaderActive && !this.properties.shaderVisible) return;
|
||||
if (this.shaderPrograms[shaderType]) return;
|
||||
if (!this.shaderSources[shaderType]) { console.error('Shader source not found:', shaderType); return; }
|
||||
if (!this.pendingShaders.includes(shaderType)) this.pendingShaders.push(shaderType);
|
||||
if (!this.loadingShader) this.processNextShader();
|
||||
};
|
||||
|
||||
nodeType.prototype.processNextShader = function (this: ShaderRendererNode) {
|
||||
if (!this.isShaderActive && !this.properties.shaderVisible) { this.loadingShader = false; this.pendingShaders = []; return; }
|
||||
if (this.pendingShaders.length === 0) { this.loadingShader = false; return; }
|
||||
this.loadingShader = true;
|
||||
const shaderType = this.pendingShaders.shift()!;
|
||||
console.log('Loading shader:', shaderType);
|
||||
this.shaderPrograms[shaderType] = this.createShaderProgram(this.vertexShaderSource, this.fragmentShaderHeader + this.shaderSources[shaderType] + this.fragmentShaderFooter);
|
||||
setTimeout(() => { this.processNextShader(); }, 10);
|
||||
};
|
||||
|
||||
nodeType.prototype.updateAnimationTime = function (this: ShaderRendererNode) {
|
||||
if (!this.isShaderActive) return;
|
||||
const now = performance.now();
|
||||
if (this.properties.lastRenderTime > 0) {
|
||||
const delta = (now - this.properties.lastRenderTime) / 1000;
|
||||
this.properties.shaderTime += delta * this.properties.shaderSpeed;
|
||||
}
|
||||
this.properties.lastRenderTime = now;
|
||||
};
|
||||
|
||||
nodeType.prototype.drawShader = function (this: ShaderRendererNode, program: WebGLProgram) {
|
||||
const gl = this.gl;
|
||||
if (!gl) return;
|
||||
gl.useProgram(program);
|
||||
const locs = {
|
||||
time: gl.getUniformLocation(program, 'u_time'),
|
||||
intensity: gl.getUniformLocation(program, 'u_intensity'),
|
||||
scale: gl.getUniformLocation(program, 'u_scale'),
|
||||
octaves: gl.getUniformLocation(program, 'u_octaves'),
|
||||
shapeType: gl.getUniformLocation(program, 'u_shapeType'),
|
||||
shapeStrength: gl.getUniformLocation(program, 'u_shapeStrength'),
|
||||
warpStrength: gl.getUniformLocation(program, 'u_warpStrength'),
|
||||
phaseShift: gl.getUniformLocation(program, 'u_phaseShift'),
|
||||
colorScheme: gl.getUniformLocation(program, 'u_colorScheme'),
|
||||
};
|
||||
gl.uniform1f(locs.time, this.properties.shaderTime);
|
||||
gl.uniform1f(locs.intensity, this.properties.shaderColorIntensity);
|
||||
gl.uniform1f(locs.scale, this.properties.shaderScale);
|
||||
gl.uniform1f(locs.octaves, this.properties.shaderOctaves);
|
||||
gl.uniform1i(locs.shapeType, SHAPE_TYPE_MAP[this.properties.shaderShapeType] || 0);
|
||||
gl.uniform1f(locs.shapeStrength, this.properties.shaderShapeStrength);
|
||||
gl.uniform1f(locs.warpStrength, this.properties.shaderWarpStrength);
|
||||
gl.uniform1f(locs.phaseShift, this.properties.shaderPhaseShift);
|
||||
gl.uniform1i(locs.colorScheme, COLOR_SCHEME_MAP[this.properties.colorScheme] || 0);
|
||||
|
||||
const positionLocation = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
};
|
||||
|
||||
nodeType.prototype.renderShader = function (this: ShaderRendererNode) {
|
||||
if (!this.isShaderActive || !this.gl || !this.shaderPrograms) return;
|
||||
const gl = this.gl;
|
||||
const currentShaderType = this.properties.shaderType;
|
||||
if (!this.shaderPrograms[currentShaderType]) {
|
||||
this.loadShader(currentShaderType);
|
||||
gl.clearColor(0.1, 0.1, 0.1, 1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
return;
|
||||
}
|
||||
const program = this.shaderPrograms[currentShaderType];
|
||||
if (!program) return;
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
this.updateAnimationTime();
|
||||
this.drawShader(program);
|
||||
};
|
||||
|
||||
nodeType.prototype.onResize = function (this: ShaderRendererNode, size: [number, number]) {
|
||||
this._isResizing = true;
|
||||
size[0] = Math.max(this.min_width || 300, size[0]);
|
||||
const baseSize = origComputeSize ? origComputeSize.call(this, [size[0], 0]) : [size[0], 0];
|
||||
const baseHeight = baseSize[1];
|
||||
const minTotalHeight = baseHeight + 50;
|
||||
size[1] = Math.max(minTotalHeight, size[1]);
|
||||
if (this.properties.shaderVisible) {
|
||||
this.shaderHeight = Math.max(50, size[1] - baseHeight);
|
||||
this.resizeShaderCanvas(size[0], this.shaderHeight);
|
||||
}
|
||||
this.size = size;
|
||||
this._isResizing = false;
|
||||
this.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
nodeType.prototype.onDrawForeground = function (this: ShaderRendererNode, ctx: CanvasRenderingContext2D) {
|
||||
if (this.properties?.shaderVisible && this.shaderCanvas && !this.flags?.collapsed) {
|
||||
this.isShaderActive = true;
|
||||
if (!this.animationFrameId) {
|
||||
const animate = () => {
|
||||
if (!this.isShaderActive || !this.properties.shaderVisible) { this.animationFrameId = null; return; }
|
||||
this.renderShader();
|
||||
this.setDirtyCanvas(true, false);
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
}
|
||||
const widgetBottom = this.widgets?.length ? 40 + this.widgets.length * 24 : 40;
|
||||
const shaderY = widgetBottom + 10;
|
||||
const shaderHeight = Math.max(50, this.size[1] - shaderY);
|
||||
try { ctx.drawImage(this.shaderCanvas, 0, shaderY, this.size[0], shaderHeight); } catch (e) { /* ignore */ }
|
||||
if (origOnDrawForeground) origOnDrawForeground.call(this, ctx);
|
||||
} else {
|
||||
if (origOnDrawForeground) origOnDrawForeground.call(this, ctx);
|
||||
this.isShaderActive = false;
|
||||
if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; }
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.computeSize = function (this: ShaderRendererNode, size: [number, number]) {
|
||||
size = size || [this.size?.[0] || 300, 0];
|
||||
if (this.flags?.collapsed) { size[0] = 190; size[1] = 40; return size; }
|
||||
if (origComputeSize) origComputeSize.call(this, size);
|
||||
const baseHeight = size[1];
|
||||
if (this.properties?.shaderVisible && !this._isResizing) {
|
||||
const currentShaderHeight = this.shaderHeight || 200;
|
||||
size[1] = baseHeight + Math.max(50, currentShaderHeight);
|
||||
}
|
||||
return size;
|
||||
};
|
||||
|
||||
nodeType.prototype.onRemoved = function (this: ShaderRendererNode) {
|
||||
if (origOnRemoved) origOnRemoved.call(this);
|
||||
if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; }
|
||||
if (this.gl) {
|
||||
for (const programType in this.shaderPrograms) { if (this.shaderPrograms[programType]) this.gl.deleteProgram(this.shaderPrograms[programType]); }
|
||||
if (this.loadingIndicatorProgram) { this.gl.deleteProgram(this.loadingIndicatorProgram); this.loadingIndicatorProgram = null; }
|
||||
if (this.positionBuffer) this.gl.deleteBuffer(this.positionBuffer);
|
||||
this.shaderPrograms = {};
|
||||
this.positionBuffer = null;
|
||||
this.gl = null;
|
||||
}
|
||||
this.shaderSources = {};
|
||||
this.shaderCanvas = null;
|
||||
this.pendingShaders = [];
|
||||
this.loadingShader = false;
|
||||
};
|
||||
|
||||
nodeType.prototype.startBackgroundLoading = function () { return; };
|
||||
|
||||
nodeType.prototype.onConfigure = function (this: ShaderRendererNode, info: unknown) {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
if (this.widgets && this.properties) {
|
||||
const updateWidget = (name: string, prop: keyof ShaderProperties) => {
|
||||
const w = this.widgets?.find(w => w.name === name);
|
||||
if (w && this.properties[prop] !== undefined) w.value = this.properties[prop];
|
||||
};
|
||||
updateWidget("Show Shader", "shaderVisible");
|
||||
updateWidget("Show Tooltips", "tooltipsVisible");
|
||||
updateWidget("Shader Noise Type 🔄", "shaderType");
|
||||
updateWidget("Shape Mask Type 🔄", "shaderShapeType");
|
||||
updateWidget("Color Scheme 🔄", "colorScheme");
|
||||
updateWidget("Noise Scale 🔄", "shaderScale");
|
||||
updateWidget("Octaves 🔄", "shaderOctaves");
|
||||
updateWidget("Warp Strength 🔄", "shaderWarpStrength");
|
||||
updateWidget("Shape Mask Strength 🔄", "shaderShapeStrength");
|
||||
updateWidget("Phase Shift 🔄", "shaderPhaseShift");
|
||||
updateWidget("Animation Speed 🖥️", "shaderSpeed");
|
||||
updateWidget("Color Intensity 🔄", "shaderColorIntensity");
|
||||
updateWidget("Pixel Resolution 🖥️", "shaderResolutionScale");
|
||||
}
|
||||
if (this.properties?.shaderVisible) {
|
||||
const baseSize = origComputeSize ? origComputeSize.call(this, [this.size[0], 0]) : [this.size[0], 0];
|
||||
this.shaderHeight = this.shaderHeight || 200;
|
||||
const expectedHeight = baseSize[1] + this.shaderHeight;
|
||||
if (Math.abs(this.size[1] - expectedHeight) > 1) { this.size[1] = expectedHeight; this.resizeShaderCanvas(this.size[0], this.shaderHeight); this.setDirtyCanvas(true, true); }
|
||||
this.isShaderActive = true;
|
||||
} else { this.isShaderActive = false; }
|
||||
};
|
||||
|
||||
nodeType.prototype.getExtraMenuOptions = function (this: ShaderRendererNode, canvas: unknown, options: unknown[]) {
|
||||
if (origGetExtraMenuOptions) origGetExtraMenuOptions.call(this, canvas, options);
|
||||
options.push(null, {
|
||||
content: this.properties.shaderVisible ? "Hide Shader Preview" : "Show Shader Preview",
|
||||
callback: () => {
|
||||
this.properties.shaderVisible = !this.properties.shaderVisible;
|
||||
const shaderWidget = this.widgets?.find(w => w.name === "Show Shader");
|
||||
if (shaderWidget) shaderWidget.value = this.properties.shaderVisible;
|
||||
const baseSize = origComputeSize ? origComputeSize.call(this, [this.size[0], 0]) : [this.size[0], 0];
|
||||
if (this.properties.shaderVisible && this.gl) {
|
||||
this.shaderHeight = this.shaderHeight || 200;
|
||||
this.size[1] = baseSize[1] + this.shaderHeight;
|
||||
this.resizeShaderCanvas(this.size[0], this.shaderHeight);
|
||||
this.isShaderActive = true;
|
||||
this.properties.lastRenderTime = 0;
|
||||
} else {
|
||||
this.size[1] = baseSize[1];
|
||||
this.isShaderActive = false;
|
||||
if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; }
|
||||
}
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
});
|
||||
return options;
|
||||
};
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Integration tests for ComfyUI extension registration
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { registeredExtensions, resetComfyMocks, createMockNode, app } from './mocks/comfyui';
|
||||
|
||||
// Import source files to trigger extension registration
|
||||
import '../src/gradient_title';
|
||||
import '../src/noise_visualizer';
|
||||
import '../src/matrix_button';
|
||||
|
||||
describe('ComfyUI Extension Registration', () => {
|
||||
// Note: We don't reset mocks in beforeEach since extensions register on module load
|
||||
|
||||
describe('Extension Registration', () => {
|
||||
it('should have registered extensions from loaded modules', () => {
|
||||
// Extensions register when modules are imported at the top of the file
|
||||
// So registeredExtensions should have entries after imports complete
|
||||
expect(registeredExtensions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have gradient_title extension registered', () => {
|
||||
const hasGradientExtension = registeredExtensions.some(
|
||||
ext => ext.name.includes('Gradient') || ext.name.includes('gradient') || ext.name.includes('Title')
|
||||
);
|
||||
// This might be false if gradient_title doesn't register with a recognizable name
|
||||
// The important thing is that some extensions are registered
|
||||
expect(registeredExtensions.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeRegisterNodeDef Hook', () => {
|
||||
it('should define beforeRegisterNodeDef callback for extensions', () => {
|
||||
// Create mock nodeType and nodeData
|
||||
const mockNodeType = {
|
||||
prototype: {
|
||||
onNodeCreated: undefined as unknown,
|
||||
onDrawForeground: undefined as unknown,
|
||||
},
|
||||
};
|
||||
const mockNodeData = {
|
||||
name: 'ShaderNoiseKSampler',
|
||||
};
|
||||
|
||||
// Test that extensions with beforeRegisterNodeDef can be invoked
|
||||
registeredExtensions.forEach(ext => {
|
||||
if (ext.beforeRegisterNodeDef) {
|
||||
expect(() => {
|
||||
ext.beforeRegisterNodeDef!(mockNodeType, mockNodeData, app);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-matching node types gracefully', () => {
|
||||
const mockNodeType = {
|
||||
prototype: {},
|
||||
};
|
||||
const mockNodeData = {
|
||||
name: 'DifferentNode',
|
||||
};
|
||||
|
||||
registeredExtensions.forEach(ext => {
|
||||
if (ext.beforeRegisterNodeDef) {
|
||||
expect(() => {
|
||||
ext.beforeRegisterNodeDef!(mockNodeType, mockNodeData, app);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Creation', () => {
|
||||
it('should create mock node with default properties', () => {
|
||||
const node = createMockNode();
|
||||
|
||||
expect(node.id).toBeDefined();
|
||||
expect(node.type).toBe('TestNode');
|
||||
expect(node.title).toBe('Test Node');
|
||||
expect(node.pos).toEqual([100, 100]);
|
||||
expect(node.size).toEqual([200, 150]);
|
||||
expect(node.widgets).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create mock node with custom overrides', () => {
|
||||
const node = createMockNode({
|
||||
type: 'ShaderNoiseKSampler',
|
||||
title: 'Shader Noise KSampler',
|
||||
size: [400, 300],
|
||||
});
|
||||
|
||||
expect(node.type).toBe('ShaderNoiseKSampler');
|
||||
expect(node.title).toBe('Shader Noise KSampler');
|
||||
expect(node.size).toEqual([400, 300]);
|
||||
});
|
||||
|
||||
it('should have addWidget mock function', () => {
|
||||
const node = createMockNode();
|
||||
|
||||
const widget = node.addWidget('button', 'Test Button', null) as { name: string; type: string; value: unknown };
|
||||
|
||||
expect(node.addWidget).toHaveBeenCalledWith('button', 'Test Button', null);
|
||||
expect(widget.name).toBe('Test Button');
|
||||
});
|
||||
|
||||
it('should have setDirtyCanvas mock function', () => {
|
||||
const node = createMockNode();
|
||||
|
||||
node.setDirtyCanvas(true, true);
|
||||
|
||||
expect(node.setDirtyCanvas).toHaveBeenCalledWith(true, true);
|
||||
});
|
||||
|
||||
it('should have computeSize mock function', () => {
|
||||
const node = createMockNode();
|
||||
|
||||
const size = node.computeSize();
|
||||
|
||||
expect(size).toEqual([200, 150]);
|
||||
expect(node.computeSize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Widget Initialization', () => {
|
||||
it('should support adding multiple widgets', () => {
|
||||
const node = createMockNode();
|
||||
|
||||
node.addWidget('button', 'Button 1', null);
|
||||
node.addWidget('slider', 'Slider 1', 0.5);
|
||||
node.addWidget('combo', 'Combo 1', 'option1');
|
||||
|
||||
expect(node.addWidget).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should support addCustomWidget', () => {
|
||||
const node = createMockNode();
|
||||
const customWidget = {
|
||||
name: 'CustomWidget',
|
||||
type: 'custom',
|
||||
value: 'test',
|
||||
};
|
||||
|
||||
const result = node.addCustomWidget(customWidget);
|
||||
|
||||
expect(node.addCustomWidget).toHaveBeenCalledWith(customWidget);
|
||||
expect(result).toEqual(customWidget);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Lifecycle Hooks', () => {
|
||||
it('should support onNodeCreated hook', () => {
|
||||
const onCreated = vi.fn();
|
||||
const node = createMockNode({
|
||||
onNodeCreated: onCreated,
|
||||
});
|
||||
|
||||
node.onNodeCreated?.();
|
||||
|
||||
expect(onCreated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support onRemoved hook', () => {
|
||||
const onRemoved = vi.fn();
|
||||
const node = createMockNode({
|
||||
onRemoved: onRemoved,
|
||||
});
|
||||
|
||||
node.onRemoved?.();
|
||||
|
||||
expect(onRemoved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support onConfigure hook', () => {
|
||||
const onConfigure = vi.fn();
|
||||
const node = createMockNode({
|
||||
onConfigure: onConfigure,
|
||||
});
|
||||
|
||||
const configInfo = { savedData: true };
|
||||
node.onConfigure?.(configInfo);
|
||||
|
||||
expect(onConfigure).toHaveBeenCalledWith(configInfo);
|
||||
});
|
||||
|
||||
it('should support onDrawForeground hook', () => {
|
||||
const onDraw = vi.fn();
|
||||
const node = createMockNode({
|
||||
onDrawForeground: onDraw,
|
||||
});
|
||||
|
||||
const mockCtx = {} as CanvasRenderingContext2D;
|
||||
node.onDrawForeground?.(mockCtx);
|
||||
|
||||
expect(onDraw).toHaveBeenCalledWith(mockCtx);
|
||||
});
|
||||
|
||||
it('should support onResize hook', () => {
|
||||
const onResize = vi.fn();
|
||||
const node = createMockNode({
|
||||
onResize: onResize,
|
||||
});
|
||||
|
||||
node.onResize?.([300, 200]);
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith([300, 200]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Unit tests for Matrix Button window utilities
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Import the module to populate window utilities
|
||||
import '../src/matrix_button';
|
||||
|
||||
describe('Matrix Button Window Utilities', () => {
|
||||
beforeEach(() => {
|
||||
// Reset DOM state
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('window.scrollToSection', () => {
|
||||
it('should be defined on window', () => {
|
||||
expect(typeof window.scrollToSection).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle missing modal content gracefully', () => {
|
||||
expect(() => {
|
||||
window.scrollToSection('test-section');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should scroll to section when modal exists', () => {
|
||||
// Create modal structure
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'shader-matrix-treatise';
|
||||
|
||||
const section = document.createElement('section');
|
||||
section.id = 'test-section';
|
||||
section.scrollIntoView = vi.fn();
|
||||
|
||||
const header = document.createElement('h2');
|
||||
header.focus = vi.fn();
|
||||
section.appendChild(header);
|
||||
|
||||
modal.appendChild(section);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
window.scrollToSection('test-section');
|
||||
|
||||
expect(section.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('window.showTab', () => {
|
||||
it('should be defined on window', () => {
|
||||
expect(typeof window.showTab).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle missing tabs container gracefully', () => {
|
||||
expect(() => {
|
||||
window.showTab('tab-1', null);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should switch tabs correctly', () => {
|
||||
// Create tab structure
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'shader-matrix-treatise';
|
||||
|
||||
const tabsContainer = document.createElement('div');
|
||||
tabsContainer.className = 'tabs';
|
||||
|
||||
const tab1 = document.createElement('button');
|
||||
tab1.className = 'tab active';
|
||||
tab1.setAttribute('aria-selected', 'true');
|
||||
tabsContainer.appendChild(tab1);
|
||||
|
||||
const tab2 = document.createElement('button');
|
||||
tab2.className = 'tab';
|
||||
tab2.setAttribute('aria-selected', 'false');
|
||||
tabsContainer.appendChild(tab2);
|
||||
|
||||
modal.appendChild(tabsContainer);
|
||||
|
||||
const content1 = document.createElement('div');
|
||||
content1.className = 'tab-content active';
|
||||
content1.id = 'tab-1';
|
||||
content1.style.display = 'block';
|
||||
modal.appendChild(content1);
|
||||
|
||||
const content2 = document.createElement('div');
|
||||
content2.className = 'tab-content';
|
||||
content2.id = 'tab-2';
|
||||
content2.style.display = 'none';
|
||||
modal.appendChild(content2);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
window.showTab('tab-2', tab2);
|
||||
|
||||
expect(tab2.classList.contains('active')).toBe(true);
|
||||
expect(tab2.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window.setupScrollTop', () => {
|
||||
it('should be defined on window', () => {
|
||||
expect(typeof window.setupScrollTop).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle null element gracefully', () => {
|
||||
expect(() => {
|
||||
window.setupScrollTop(null as any);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should setup scroll listener', () => {
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.className = 'shader-matrix-treatise';
|
||||
modalContent.addEventListener = vi.fn();
|
||||
|
||||
const scrollButton = document.createElement('button');
|
||||
scrollButton.id = 'scroll-top';
|
||||
scrollButton.addEventListener = vi.fn();
|
||||
modalContent.appendChild(scrollButton);
|
||||
|
||||
document.body.appendChild(modalContent);
|
||||
|
||||
window.setupScrollTop(modalContent);
|
||||
|
||||
expect(modalContent.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(scrollButton.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('window.copyCodeSection', () => {
|
||||
it('should be defined on window', () => {
|
||||
expect(typeof window.copyCodeSection).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle missing header gracefully', () => {
|
||||
const button = document.createElement('button');
|
||||
expect(() => {
|
||||
window.copyCodeSection(button);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should copy code to clipboard', async () => {
|
||||
// Mock clipboard
|
||||
const mockClipboard = {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: mockClipboard,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Create code block structure
|
||||
const container = document.createElement('div');
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'code-block-header';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.dataset.timeoutId = undefined as any;
|
||||
header.appendChild(button);
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'foldable-content';
|
||||
const code = document.createElement('code');
|
||||
code.textContent = 'const test = 1;';
|
||||
pre.appendChild(code);
|
||||
container.appendChild(pre);
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
window.copyCodeSection(button);
|
||||
|
||||
// Wait for clipboard promise
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockClipboard.writeText).toHaveBeenCalledWith('const test = 1;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window.toggleCodeSection', () => {
|
||||
it('should be defined on window', () => {
|
||||
expect(typeof window.toggleCodeSection).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle missing header gracefully', () => {
|
||||
const button = document.createElement('button');
|
||||
expect(() => {
|
||||
window.toggleCodeSection(button);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should toggle code visibility', () => {
|
||||
// Create code block structure
|
||||
const container = document.createElement('div');
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'code-block-header';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Show';
|
||||
header.appendChild(button);
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'foldable-content';
|
||||
pre.style.display = 'none';
|
||||
container.appendChild(pre);
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
window.toggleCodeSection(button);
|
||||
|
||||
expect(pre.style.display).toBe('block');
|
||||
expect(button.textContent).toBe('Hide');
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window.handleTabNavigation', () => {
|
||||
it('should be defined on window', () => {
|
||||
expect(typeof window.handleTabNavigation).toBe('function');
|
||||
});
|
||||
|
||||
it('should activate tab on Enter key', () => {
|
||||
const tabElement = document.createElement('button');
|
||||
tabElement.className = 'tab';
|
||||
tabElement.click = vi.fn();
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
event.preventDefault = vi.fn();
|
||||
|
||||
window.handleTabNavigation(event, tabElement);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(tabElement.click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should activate tab on Space key', () => {
|
||||
const tabElement = document.createElement('button');
|
||||
tabElement.className = 'tab';
|
||||
tabElement.click = vi.fn();
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: ' ' });
|
||||
event.preventDefault = vi.fn();
|
||||
|
||||
window.handleTabNavigation(event, tabElement);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(tabElement.click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate to next tab on ArrowRight', () => {
|
||||
const parent = document.createElement('div');
|
||||
|
||||
const tab1 = document.createElement('button');
|
||||
tab1.className = 'tab';
|
||||
tab1.focus = vi.fn();
|
||||
tab1.click = vi.fn();
|
||||
parent.appendChild(tab1);
|
||||
|
||||
const tab2 = document.createElement('button');
|
||||
tab2.className = 'tab';
|
||||
tab2.focus = vi.fn();
|
||||
tab2.click = vi.fn();
|
||||
parent.appendChild(tab2);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
||||
event.preventDefault = vi.fn();
|
||||
|
||||
window.handleTabNavigation(event, tab1);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(tab2.focus).toHaveBeenCalled();
|
||||
expect(tab2.click).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Mock implementation of ComfyUI APIs for testing
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Store registered extensions for testing
|
||||
export const registeredExtensions: ComfyExtension[] = [];
|
||||
|
||||
export interface ComfyExtension {
|
||||
name: string;
|
||||
beforeRegisterNodeDef?: (
|
||||
nodeType: unknown,
|
||||
nodeData: unknown,
|
||||
app: ComfyApp
|
||||
) => void;
|
||||
setup?: (app: ComfyApp) => void;
|
||||
nodeCreated?: (node: unknown) => void;
|
||||
}
|
||||
|
||||
export interface ComfyApp {
|
||||
registerExtension: (extension: ComfyExtension) => void;
|
||||
canvas: unknown;
|
||||
graph: unknown;
|
||||
queuePrompt: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const app: ComfyApp = {
|
||||
registerExtension: vi.fn((extension: ComfyExtension) => {
|
||||
registeredExtensions.push(extension);
|
||||
}),
|
||||
canvas: {
|
||||
draw: vi.fn(),
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
setDirtyCanvas: vi.fn(),
|
||||
},
|
||||
queuePrompt: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
export interface ComfyApi {
|
||||
fetchApi: (route: string, options?: RequestInit) => Promise<Response>;
|
||||
addEventListener: (type: string, callback: (event: CustomEvent) => void) => void;
|
||||
removeEventListener: (type: string, callback: (event: CustomEvent) => void) => void;
|
||||
apiURL: (route: string) => string;
|
||||
}
|
||||
|
||||
export const api: ComfyApi = {
|
||||
fetchApi: vi.fn(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||
),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
apiURL: vi.fn((route: string) => `http://localhost:8188${route}`),
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset all mocks and clear registered extensions
|
||||
*/
|
||||
export function resetComfyMocks(): void {
|
||||
registeredExtensions.length = 0;
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock LGraphNode for testing
|
||||
*/
|
||||
export function createMockNode(overrides: Partial<MockLGraphNode> = {}): MockLGraphNode {
|
||||
return {
|
||||
id: Math.floor(Math.random() * 10000),
|
||||
type: 'TestNode',
|
||||
title: 'Test Node',
|
||||
pos: [100, 100],
|
||||
size: [200, 150],
|
||||
flags: {},
|
||||
properties: {},
|
||||
widgets: [],
|
||||
widgets_values: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
graph: app.graph,
|
||||
setDirtyCanvas: vi.fn(),
|
||||
triggerSlot: vi.fn(),
|
||||
addWidget: vi.fn((_type, name, value) => ({
|
||||
name,
|
||||
type: _type,
|
||||
value,
|
||||
y: 0,
|
||||
})),
|
||||
addCustomWidget: vi.fn((widget) => widget),
|
||||
computeSize: vi.fn(() => [200, 150] as [number, number]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockLGraphNode {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
flags: Record<string, boolean>;
|
||||
properties: Record<string, unknown>;
|
||||
widgets: unknown[];
|
||||
widgets_values: unknown[];
|
||||
inputs: unknown[];
|
||||
outputs: unknown[];
|
||||
graph: unknown;
|
||||
setDirtyCanvas: ReturnType<typeof vi.fn>;
|
||||
triggerSlot: ReturnType<typeof vi.fn>;
|
||||
addWidget: ReturnType<typeof vi.fn>;
|
||||
addCustomWidget: ReturnType<typeof vi.fn>;
|
||||
computeSize: ReturnType<typeof vi.fn>;
|
||||
onNodeCreated?: () => void;
|
||||
onRemoved?: () => void;
|
||||
onConfigure?: (info: unknown) => void;
|
||||
onDrawForeground?: (ctx: CanvasRenderingContext2D) => void;
|
||||
onResize?: (size: [number, number]) => void;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Unit tests for NoiseVisualizer module
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Import the module to populate window.NoiseVisualizer
|
||||
import '../src/noise_visualizer';
|
||||
|
||||
describe('NoiseVisualizer', () => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh canvas for each test
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = 130;
|
||||
canvas.height = 130;
|
||||
ctx = canvas.getContext('2d')!;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should expose NoiseVisualizer on window object', () => {
|
||||
expect(window.NoiseVisualizer).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have renderAllInModal method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderAllInModal).toBe('function');
|
||||
});
|
||||
|
||||
it('should have kofi image properties', () => {
|
||||
expect(window.NoiseVisualizer).toHaveProperty('kofiImageLoaded');
|
||||
expect(window.NoiseVisualizer).toHaveProperty('kofiImageLoadAttempted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas Rendering Methods', () => {
|
||||
it('should have renderPlaceholder method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderPlaceholder).toBe('function');
|
||||
});
|
||||
|
||||
it('should render placeholder without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderPlaceholder(canvas, 'Test Noise');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have renderTensorField method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderTensorField).toBe('function');
|
||||
});
|
||||
|
||||
it('should render tensor field without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderTensorField(canvas);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have renderCellular method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderCellular).toBe('function');
|
||||
});
|
||||
|
||||
it('should render cellular noise without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderCellular(canvas);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have renderPerlin method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderPerlin).toBe('function');
|
||||
});
|
||||
|
||||
it('should render Perlin noise without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderPerlin(canvas);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have renderFractal method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderFractal).toBe('function');
|
||||
});
|
||||
|
||||
it('should render fractal noise without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderFractal(canvas);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mask Rendering Methods', () => {
|
||||
it('should have renderMaskRadial method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderMaskRadial).toBe('function');
|
||||
});
|
||||
|
||||
it('should render radial mask without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderMaskRadial(canvas);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have renderMaskLinear method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderMaskLinear).toBe('function');
|
||||
});
|
||||
|
||||
it('should render linear mask without errors', () => {
|
||||
expect(() => {
|
||||
window.NoiseVisualizer?.renderMaskLinear(canvas);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have renderMaskGrid method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderMaskGrid).toBe('function');
|
||||
});
|
||||
|
||||
it('should have renderMaskSpiral method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderMaskSpiral).toBe('function');
|
||||
});
|
||||
|
||||
it('should have renderMaskHexgrid method', () => {
|
||||
expect(typeof window.NoiseVisualizer?.renderMaskHexgrid).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper Methods', () => {
|
||||
it('should have _clearCanvas method', () => {
|
||||
expect(typeof window.NoiseVisualizer?._clearCanvas).toBe('function');
|
||||
});
|
||||
|
||||
it('should clear canvas and return context', () => {
|
||||
const result = (window.NoiseVisualizer as any)?._clearCanvas(canvas);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have _drawKofiIcon method', () => {
|
||||
expect(typeof window.NoiseVisualizer?._drawKofiIcon).toBe('function');
|
||||
});
|
||||
|
||||
it('should draw Ko-fi icon without errors', () => {
|
||||
expect(() => {
|
||||
(window.NoiseVisualizer as any)?._drawKofiIcon(ctx);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have _drawManualKofiCup method', () => {
|
||||
expect(typeof window.NoiseVisualizer?._drawManualKofiCup).toBe('function');
|
||||
});
|
||||
|
||||
it('should draw manual Ko-fi cup without errors', () => {
|
||||
expect(() => {
|
||||
(window.NoiseVisualizer as any)?._drawManualKofiCup(ctx, 10, 10, 18);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderAllInModal', () => {
|
||||
it('should handle empty container', async () => {
|
||||
const container = document.createElement('div');
|
||||
await expect(
|
||||
window.NoiseVisualizer?.renderAllInModal(container)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should process noise canvases', async () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Add a noise canvas div
|
||||
const noiseDiv = document.createElement('div');
|
||||
noiseDiv.className = 'noise-canvas';
|
||||
noiseDiv.id = 'noise-canvas-perlin';
|
||||
container.appendChild(noiseDiv);
|
||||
|
||||
await window.NoiseVisualizer?.renderAllInModal(container);
|
||||
|
||||
// Should have created a canvas inside
|
||||
const createdCanvas = noiseDiv.querySelector('canvas');
|
||||
expect(createdCanvas).toBeDefined();
|
||||
});
|
||||
|
||||
it('should process mask canvases', async () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
// Add a mask canvas div
|
||||
const maskDiv = document.createElement('div');
|
||||
maskDiv.className = 'mask-canvas';
|
||||
maskDiv.id = 'mask-canvas-radial';
|
||||
container.appendChild(maskDiv);
|
||||
|
||||
await window.NoiseVisualizer?.renderAllInModal(container);
|
||||
|
||||
// Should have created a canvas inside
|
||||
const createdCanvas = maskDiv.querySelector('canvas');
|
||||
expect(createdCanvas).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Test setup file for Vitest
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock window.requestAnimationFrame
|
||||
global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
return setTimeout(() => callback(performance.now()), 16) as unknown as number;
|
||||
});
|
||||
|
||||
global.cancelAnimationFrame = vi.fn((id: number) => {
|
||||
clearTimeout(id);
|
||||
});
|
||||
|
||||
// Mock WebGL context (basic stub)
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(function (
|
||||
this: HTMLCanvasElement,
|
||||
contextId: string
|
||||
) {
|
||||
if (contextId === '2d') {
|
||||
return {
|
||||
canvas: this,
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
|
||||
putImageData: vi.fn(),
|
||||
createImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
|
||||
setTransform: vi.fn(),
|
||||
resetTransform: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
transform: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
bezierCurveTo: vi.fn(),
|
||||
quadraticCurveTo: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
arcTo: vi.fn(),
|
||||
ellipse: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
isPointInPath: vi.fn(),
|
||||
isPointInStroke: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 100 })),
|
||||
fillText: vi.fn(),
|
||||
strokeText: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({
|
||||
addColorStop: vi.fn(),
|
||||
})),
|
||||
createRadialGradient: vi.fn(() => ({
|
||||
addColorStop: vi.fn(),
|
||||
})),
|
||||
createPattern: vi.fn(),
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt',
|
||||
lineJoin: 'miter',
|
||||
miterLimit: 10,
|
||||
lineDashOffset: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0)',
|
||||
font: '10px sans-serif',
|
||||
textAlign: 'start',
|
||||
textBaseline: 'alphabetic',
|
||||
direction: 'ltr',
|
||||
imageSmoothingEnabled: true,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
if (contextId === 'webgl' || contextId === 'webgl2') {
|
||||
return {
|
||||
canvas: this,
|
||||
createShader: vi.fn(() => ({})),
|
||||
shaderSource: vi.fn(),
|
||||
compileShader: vi.fn(),
|
||||
getShaderParameter: vi.fn(() => true),
|
||||
createProgram: vi.fn(() => ({})),
|
||||
attachShader: vi.fn(),
|
||||
linkProgram: vi.fn(),
|
||||
getProgramParameter: vi.fn(() => true),
|
||||
useProgram: vi.fn(),
|
||||
getUniformLocation: vi.fn(() => ({})),
|
||||
getAttribLocation: vi.fn(() => 0),
|
||||
uniform1f: vi.fn(),
|
||||
uniform2f: vi.fn(),
|
||||
uniform3f: vi.fn(),
|
||||
uniform4f: vi.fn(),
|
||||
uniform1i: vi.fn(),
|
||||
createBuffer: vi.fn(() => ({})),
|
||||
bindBuffer: vi.fn(),
|
||||
bufferData: vi.fn(),
|
||||
enableVertexAttribArray: vi.fn(),
|
||||
vertexAttribPointer: vi.fn(),
|
||||
drawArrays: vi.fn(),
|
||||
viewport: vi.fn(),
|
||||
clearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
blendFunc: vi.fn(),
|
||||
deleteShader: vi.fn(),
|
||||
deleteProgram: vi.fn(),
|
||||
deleteBuffer: vi.fn(),
|
||||
getShaderInfoLog: vi.fn(() => ''),
|
||||
getProgramInfoLog: vi.fn(() => ''),
|
||||
VERTEX_SHADER: 35633,
|
||||
FRAGMENT_SHADER: 35632,
|
||||
COMPILE_STATUS: 35713,
|
||||
LINK_STATUS: 35714,
|
||||
ARRAY_BUFFER: 34962,
|
||||
STATIC_DRAW: 35044,
|
||||
FLOAT: 5126,
|
||||
TRIANGLES: 4,
|
||||
COLOR_BUFFER_BIT: 16384,
|
||||
BLEND: 3042,
|
||||
SRC_ALPHA: 770,
|
||||
ONE_MINUS_SRC_ALPHA: 771,
|
||||
} as unknown as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
return null;
|
||||
}) as typeof HTMLCanvasElement.prototype.getContext;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Integration tests for shader rendering pipeline
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createMockNode, resetComfyMocks, app } from './mocks/comfyui';
|
||||
|
||||
// Import shader renderer
|
||||
import '../src/shader_renderer';
|
||||
|
||||
describe('Shader Rendering Pipeline', () => {
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
beforeEach(() => {
|
||||
resetComfyMocks();
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WebGL Context', () => {
|
||||
it('should create WebGL context from canvas', () => {
|
||||
const gl = canvas.getContext('webgl');
|
||||
expect(gl).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create WebGL2 context from canvas', () => {
|
||||
const gl = canvas.getContext('webgl2');
|
||||
expect(gl).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have shader creation methods', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
expect(gl.createShader).toBeDefined();
|
||||
expect(gl.compileShader).toBeDefined();
|
||||
expect(gl.shaderSource).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have program creation methods', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
expect(gl.createProgram).toBeDefined();
|
||||
expect(gl.linkProgram).toBeDefined();
|
||||
expect(gl.useProgram).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have uniform methods', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
expect(gl.getUniformLocation).toBeDefined();
|
||||
expect(gl.uniform1f).toBeDefined();
|
||||
expect(gl.uniform2f).toBeDefined();
|
||||
expect(gl.uniform3f).toBeDefined();
|
||||
expect(gl.uniform4f).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shader Compilation', () => {
|
||||
it('should create vertex shader', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const shader = gl.createShader(gl.VERTEX_SHADER);
|
||||
expect(shader).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create fragment shader', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const shader = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
expect(shader).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get compile status', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const shader = gl.createShader(gl.VERTEX_SHADER)!;
|
||||
gl.shaderSource(shader, 'void main() {}');
|
||||
gl.compileShader(shader);
|
||||
const status = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
|
||||
expect(status).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buffer Operations', () => {
|
||||
it('should create buffer', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const buffer = gl.createBuffer();
|
||||
expect(buffer).toBeDefined();
|
||||
});
|
||||
|
||||
it('should bind buffer', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
expect(gl.bindBuffer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upload buffer data', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1]), gl.STATIC_DRAW);
|
||||
expect(gl.bufferData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawing Operations', () => {
|
||||
it('should set viewport', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
gl.viewport(0, 0, 512, 512);
|
||||
expect(gl.viewport).toHaveBeenCalledWith(0, 0, 512, 512);
|
||||
});
|
||||
|
||||
it('should clear color buffer', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
expect(gl.clearColor).toHaveBeenCalled();
|
||||
expect(gl.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should draw arrays', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
expect(gl.drawArrays).toHaveBeenCalledWith(gl.TRIANGLES, 0, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blending', () => {
|
||||
it('should enable blending', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
gl.enable(gl.BLEND);
|
||||
expect(gl.enable).toHaveBeenCalledWith(gl.BLEND);
|
||||
});
|
||||
|
||||
it('should set blend function', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
expect(gl.blendFunc).toHaveBeenCalledWith(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shader Node Properties', () => {
|
||||
it('should create node with shader properties', () => {
|
||||
const node = createMockNode({
|
||||
type: 'ShaderNoiseKSampler',
|
||||
properties: {
|
||||
noiseType: 'perlin',
|
||||
scale: 1.0,
|
||||
octaves: 4,
|
||||
persistence: 0.5,
|
||||
lacunarity: 2.0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(node.properties.noiseType).toBe('perlin');
|
||||
expect(node.properties.scale).toBe(1.0);
|
||||
expect(node.properties.octaves).toBe(4);
|
||||
});
|
||||
|
||||
it('should update shader properties', () => {
|
||||
const node = createMockNode({
|
||||
properties: { scale: 1.0 },
|
||||
});
|
||||
|
||||
node.properties.scale = 2.5;
|
||||
|
||||
expect(node.properties.scale).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Cleanup', () => {
|
||||
it('should delete shader', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const shader = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.deleteShader(shader);
|
||||
expect(gl.deleteShader).toHaveBeenCalledWith(shader);
|
||||
});
|
||||
|
||||
it('should delete program', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const program = gl.createProgram();
|
||||
gl.deleteProgram(program);
|
||||
expect(gl.deleteProgram).toHaveBeenCalledWith(program);
|
||||
});
|
||||
|
||||
it('should delete buffer', () => {
|
||||
const gl = canvas.getContext('webgl')!;
|
||||
const buffer = gl.createBuffer();
|
||||
gl.deleteBuffer(buffer);
|
||||
expect(gl.deleteBuffer).toHaveBeenCalledWith(buffer);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Type declarations for ComfyUI frontend APIs
|
||||
* These are exported as a module to be used via path mapping
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=comfyui.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"comfyui.js","sourceRoot":"","sources":["comfyui.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Type declarations for ComfyUI frontend APIs
|
||||
* These are exported as a module to be used via path mapping
|
||||
*/
|
||||
|
||||
import type { LGraphNode, LGraph, LGraphCanvas, IWidget } from './litegraph';
|
||||
|
||||
export interface ComfyApp {
|
||||
/**
|
||||
* Register an extension that hooks into ComfyUI's lifecycle
|
||||
*/
|
||||
registerExtension(extension: ComfyExtension): void;
|
||||
|
||||
/**
|
||||
* The LiteGraph canvas instance
|
||||
*/
|
||||
canvas: LGraphCanvas;
|
||||
|
||||
/**
|
||||
* The LiteGraph graph instance
|
||||
*/
|
||||
graph: LGraph;
|
||||
|
||||
/**
|
||||
* Queue a prompt for execution
|
||||
*/
|
||||
queuePrompt(number?: number, batchCount?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ComfyExtension {
|
||||
/**
|
||||
* Unique name for the extension
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Called before a node type is registered
|
||||
*/
|
||||
beforeRegisterNodeDef?(
|
||||
nodeType: NodeTypeConstructor,
|
||||
nodeData: ComfyNodeData,
|
||||
app: ComfyApp
|
||||
): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the app is set up
|
||||
*/
|
||||
setup?(app: ComfyApp): void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when a node is created
|
||||
*/
|
||||
nodeCreated?(node: LGraphNode): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node type constructor with prototype
|
||||
*/
|
||||
export interface NodeTypeConstructor {
|
||||
new(): LGraphNode;
|
||||
prototype: LGraphNode & {
|
||||
onNodeCreated?: () => void;
|
||||
onRemoved?: () => void;
|
||||
onDrawForeground?: (ctx: CanvasRenderingContext2D) => void;
|
||||
onDrawBackground?: (ctx: CanvasRenderingContext2D) => void;
|
||||
onConfigure?: (info: unknown) => void;
|
||||
onResize?: (size: [number, number]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComfyNodeData {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
input?: {
|
||||
required?: Record<string, ComfyInputSpec>;
|
||||
optional?: Record<string, ComfyInputSpec>;
|
||||
};
|
||||
output?: string[];
|
||||
output_name?: string[];
|
||||
}
|
||||
|
||||
export type ComfyInputSpec = [string | string[], Record<string, unknown>?];
|
||||
|
||||
export interface ComfyApi {
|
||||
/**
|
||||
* Fetch from the ComfyUI API
|
||||
*/
|
||||
fetchApi(route: string, options?: RequestInit): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Add an event listener for ComfyUI events
|
||||
*/
|
||||
addEventListener(type: string, callback: (event: CustomEvent) => void): void;
|
||||
|
||||
/**
|
||||
* Remove an event listener
|
||||
*/
|
||||
removeEventListener(
|
||||
type: string,
|
||||
callback: (event: CustomEvent) => void
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Get the current API host URL
|
||||
*/
|
||||
apiURL(route: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The global app instance - will be the actual ComfyUI app at runtime
|
||||
*/
|
||||
export declare const app: ComfyApp;
|
||||
|
||||
/**
|
||||
* The global api instance - will be the actual ComfyUI api at runtime
|
||||
*/
|
||||
export declare const api: ComfyApi;
|
||||
|
||||
// Re-export LiteGraph types for convenience
|
||||
export type { LGraphNode, LGraph, LGraphCanvas, IWidget };
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Type declarations for LiteGraph.js
|
||||
* Based on LiteGraph library used by ComfyUI
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=litegraph.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"litegraph.js","sourceRoot":"","sources":["litegraph.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Type declarations for LiteGraph.js
|
||||
* Based on LiteGraph library used by ComfyUI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base node class in LiteGraph
|
||||
*/
|
||||
export interface LGraphNode {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
flags: Record<string, boolean>;
|
||||
properties: Record<string, unknown>;
|
||||
widgets?: IWidget[];
|
||||
widgets_values?: unknown[];
|
||||
inputs?: INodeSlot[];
|
||||
outputs?: INodeSlot[];
|
||||
graph?: LGraph;
|
||||
|
||||
/**
|
||||
* Called when the node is added to the graph
|
||||
*/
|
||||
onAdded?(graph: LGraph): void;
|
||||
|
||||
/**
|
||||
* Called when the node is removed from the graph
|
||||
*/
|
||||
onRemoved?(): void;
|
||||
|
||||
/**
|
||||
* Called when the node is created
|
||||
*/
|
||||
onNodeCreated?(): void;
|
||||
|
||||
/**
|
||||
* Called to configure the node from serialized data
|
||||
*/
|
||||
onConfigure?(info: SerializedLGraphNode): void;
|
||||
|
||||
/**
|
||||
* Called when drawing the node foreground
|
||||
*/
|
||||
onDrawForeground?(ctx: CanvasRenderingContext2D): void;
|
||||
|
||||
/**
|
||||
* Called when drawing the node background
|
||||
*/
|
||||
onDrawBackground?(ctx: CanvasRenderingContext2D): void;
|
||||
|
||||
/**
|
||||
* Called when the node is resized
|
||||
*/
|
||||
onResize?(size: [number, number]): void;
|
||||
|
||||
/**
|
||||
* Called to compute the node size
|
||||
*/
|
||||
computeSize?(size?: [number, number]): [number, number];
|
||||
|
||||
/**
|
||||
* Add a widget to the node
|
||||
*/
|
||||
addWidget(
|
||||
type: string,
|
||||
name: string,
|
||||
value: unknown,
|
||||
callback?: WidgetCallback,
|
||||
options?: WidgetOptions
|
||||
): IWidget;
|
||||
|
||||
/**
|
||||
* Add a custom widget to the node
|
||||
*/
|
||||
addCustomWidget(widget: IWidget): IWidget;
|
||||
|
||||
/**
|
||||
* Get extra menu options for context menu
|
||||
*/
|
||||
getExtraMenuOptions?(
|
||||
canvas: LGraphCanvas,
|
||||
options: ContextMenuItem[]
|
||||
): ContextMenuItem[] | void;
|
||||
|
||||
/**
|
||||
* Set the node's dirty state
|
||||
*/
|
||||
setDirtyCanvas(fg: boolean, bg?: boolean): void;
|
||||
|
||||
/**
|
||||
* Trigger a slot
|
||||
*/
|
||||
triggerSlot(slot: number, param?: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized node data
|
||||
*/
|
||||
export interface SerializedLGraphNode {
|
||||
id: number;
|
||||
type: string;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
flags?: Record<string, boolean>;
|
||||
properties?: Record<string, unknown>;
|
||||
widgets_values?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node input/output slot
|
||||
*/
|
||||
export interface INodeSlot {
|
||||
name: string;
|
||||
type: string | number;
|
||||
link?: number | null;
|
||||
links?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget in a node
|
||||
*/
|
||||
export interface IWidget {
|
||||
name: string;
|
||||
type: string;
|
||||
value: unknown;
|
||||
options?: WidgetOptions;
|
||||
y?: number;
|
||||
last_y?: number;
|
||||
parent?: LGraphNode;
|
||||
tooltip?: string;
|
||||
|
||||
/**
|
||||
* Draw the widget
|
||||
*/
|
||||
draw?(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
width: number,
|
||||
y: number,
|
||||
height: number
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Handle mouse events
|
||||
*/
|
||||
mouse?(
|
||||
event: MouseEvent,
|
||||
pos: [number, number],
|
||||
node: LGraphNode
|
||||
): boolean | void;
|
||||
|
||||
/**
|
||||
* Compute widget size
|
||||
*/
|
||||
computeSize?(width: number): [number, number];
|
||||
|
||||
/**
|
||||
* Called when widget is removed
|
||||
*/
|
||||
onRemoved?(): void;
|
||||
|
||||
/**
|
||||
* Callback when value changes
|
||||
*/
|
||||
callback?: WidgetCallback;
|
||||
}
|
||||
|
||||
export type WidgetCallback = (
|
||||
value: unknown,
|
||||
canvas: LGraphCanvas,
|
||||
node: LGraphNode,
|
||||
pos: [number, number],
|
||||
event: Event
|
||||
) => void;
|
||||
|
||||
export interface WidgetOptions {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
precision?: number;
|
||||
values?: string[] | (() => string[]);
|
||||
multiline?: boolean;
|
||||
serialize?: boolean;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu item
|
||||
*/
|
||||
export interface ContextMenuItem {
|
||||
content: string;
|
||||
callback?: () => void;
|
||||
has_submenu?: boolean;
|
||||
submenu?: {
|
||||
options: ContextMenuItem[];
|
||||
};
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph container
|
||||
*/
|
||||
export interface LGraph {
|
||||
nodes: LGraphNode[];
|
||||
links: Record<number, LLink>;
|
||||
|
||||
/**
|
||||
* Add a node to the graph
|
||||
*/
|
||||
add(node: LGraphNode): void;
|
||||
|
||||
/**
|
||||
* Remove a node from the graph
|
||||
*/
|
||||
remove(node: LGraphNode): void;
|
||||
|
||||
/**
|
||||
* Get a node by ID
|
||||
*/
|
||||
getNodeById(id: number): LGraphNode | null;
|
||||
|
||||
/**
|
||||
* Configure the graph from serialized data
|
||||
*/
|
||||
configure(data: unknown): void;
|
||||
|
||||
/**
|
||||
* Serialize the graph
|
||||
*/
|
||||
serialize(): unknown;
|
||||
|
||||
/**
|
||||
* Set the graph to dirty state
|
||||
*/
|
||||
setDirtyCanvas(fg: boolean, bg?: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link between nodes
|
||||
*/
|
||||
export interface LLink {
|
||||
id: number;
|
||||
origin_id: number;
|
||||
origin_slot: number;
|
||||
target_id: number;
|
||||
target_slot: number;
|
||||
type: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas renderer
|
||||
*/
|
||||
export interface LGraphCanvas {
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
graph: LGraph;
|
||||
scale: number;
|
||||
offset: [number, number];
|
||||
selected_nodes: Record<number, LGraphNode>;
|
||||
current_node?: LGraphNode;
|
||||
|
||||
/**
|
||||
* Draw the canvas
|
||||
*/
|
||||
draw(force_fg?: boolean, force_bg?: boolean): void;
|
||||
|
||||
/**
|
||||
* Convert canvas position to graph position
|
||||
*/
|
||||
convertEventToCanvasOffset(event: MouseEvent): [number, number];
|
||||
|
||||
/**
|
||||
* Center the view on a node
|
||||
*/
|
||||
centerOnNode(node: LGraphNode): void;
|
||||
|
||||
/**
|
||||
* Prompt for a value
|
||||
*/
|
||||
prompt(
|
||||
title: string,
|
||||
value: string,
|
||||
callback: (value: string) => void,
|
||||
event: Event
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Draw node widgets
|
||||
*/
|
||||
drawNodeWidgets?(
|
||||
node: LGraphNode,
|
||||
pos: [number, number],
|
||||
ctx: CanvasRenderingContext2D,
|
||||
active_widget: IWidget | null
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global LiteGraph namespace
|
||||
*/
|
||||
export interface LiteGraphStatic {
|
||||
registered_node_types: Record<string, new () => LGraphNode>;
|
||||
registerNodeType(type: string, nodeClass: new () => LGraphNode): void;
|
||||
createNode(type: string): LGraphNode | null;
|
||||
NODE_TITLE_HEIGHT: number;
|
||||
NODE_SLOT_HEIGHT: number;
|
||||
NODE_WIDGET_HEIGHT: number;
|
||||
NODE_TITLE_TEXT_Y: number;
|
||||
}
|
||||
|
||||
// Make types available globally
|
||||
declare global {
|
||||
interface Window {
|
||||
LiteGraph: LiteGraphStatic;
|
||||
LGraph: new () => LGraph;
|
||||
LGraphNode: new () => LGraphNode;
|
||||
LGraphCanvas: new () => LGraphCanvas;
|
||||
showComfyToast?: (message: string, type?: string) => void;
|
||||
storageOptimizer?: {
|
||||
forceCleanup(): void;
|
||||
};
|
||||
}
|
||||
|
||||
const LGraphCanvas: {
|
||||
prototype: LGraphCanvas;
|
||||
};
|
||||
}
|
||||
+422
-1792
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user