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:
AEmotionStudio
2026-01-28 01:00:10 -08:00
parent cb391fa5d9
commit 1b59dc2ac8
30 changed files with 12135 additions and 6971 deletions
+12
View File
@@ -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/
+25
View File
@@ -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"
}
}
+39
View File
@@ -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"
]
}
+26
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+81 -109
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+251 -284
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+904
View File
@@ -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);
+322
View File
@@ -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
+640
View File
@@ -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);
+614
View File
@@ -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
+208
View File
@@ -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]);
});
});
});
+284
View File
@@ -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();
});
});
});
+119
View File
@@ -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;
}
+197
View File
@@ -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();
});
});
});
+136
View File
@@ -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;
+192
View File
@@ -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);
});
});
});
+6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"comfyui.js","sourceRoot":"","sources":["comfyui.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
+122
View File
@@ -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 };
+6
View File
@@ -0,0 +1,6 @@
/**
* Type declarations for LiteGraph.js
* Based on LiteGraph library used by ComfyUI
*/
export {};
//# sourceMappingURL=litegraph.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"litegraph.js","sourceRoot":"","sources":["litegraph.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
+331
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff